build a template website

This commit is contained in:
rainyy21
2026-10-05 19:40:24 -04:00
commit c86f93f4ca
36 changed files with 2581 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY tracker/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY tracker/app.py tracker/check_links.py ./
COPY links.json ./links.json
RUN useradd -r app && chown -R app /app
USER app
EXPOSE 8000
# One worker so the in-memory rate limiter sees every request; threads handle concurrency.
CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"]
+151
View File
@@ -0,0 +1,151 @@
"""Cookieless first-party tracker for the rain link hub.
Stores no IPs. "Unique visitors" are a daily-rotating HMAC of IP + user agent,
so a person counts once per day and cannot be followed across days.
"""
import datetime as dt
import hashlib
import hmac
import json
import logging
import os
import re
from urllib.parse import urlparse
from flask import Flask, request
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from psycopg_pool import ConnectionPool
from werkzeug.middleware.proxy_fix import ProxyFix
log = logging.getLogger("tracker")
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
DATABASE_URL = os.environ.get("DATABASE_URL", "")
SECRET = os.environ.get("TRACKER_SECRET", "dev-only-change-me")
SITE_HOST = os.environ.get("SITE_HOST", "").lower()
LINKS_FILE = os.environ.get("LINKS_FILE", "/app/links.json")
with open(LINKS_FILE, encoding="utf-8") as f:
_data = json.load(f)
LINK_IDS = {x["id"] for x in [_data["featured"], *_data["links"]]}
BOT_RE = re.compile(
r"bot|crawl|spider|slurp|curl|wget|python-requests|httpx|aiohttp|go-http|headless|"
r"phantom|preview|facebookexternalhit|embedly|monitor|uptime|lighthouse|scrapy",
re.I,
)
try: # optional offline country lookup (drop GeoLite2-Country.mmdb into ./geoip)
import geoip2.database
_geo = geoip2.database.Reader("/data/GeoLite2-Country.mmdb")
except Exception:
_geo = None
limiter = Limiter(get_remote_address, app=app, storage_uri="memory://")
_pool = None
def pool():
global _pool
if _pool is None:
_pool = ConnectionPool(DATABASE_URL, min_size=1, max_size=5, open=True)
return _pool
def insert(sql, params):
with pool().connection() as conn:
conn.execute(sql, params)
def visitor_hash(ip, ua):
day_key = hmac.new(SECRET.encode(), dt.date.today().isoformat().encode(), hashlib.sha256).digest()
return hmac.new(day_key, f"{ip}|{ua}".encode(), hashlib.sha256).hexdigest()[:32]
def country_of(ip):
cc = request.headers.get("CF-IPCountry", "")
if not (len(cc) == 2 and cc.isalpha()) and _geo:
try:
cc = _geo.country(ip).country.iso_code or ""
except Exception:
cc = ""
return cc.upper() if len(cc) == 2 and cc.isalpha() else "XX"
def device_of(ua):
u = ua.lower()
if "ipad" in u or "tablet" in u:
return "tablet"
if "mobi" in u or "android" in u or "iphone" in u:
return "mobile"
return "desktop"
def referrer_host(ref):
try:
host = (urlparse(ref or "").hostname or "").lower().removeprefix("www.")
except ValueError:
return None
if not host or host == SITE_HOST.removeprefix("www."):
return None
return host[:100]
def context():
"""Return request context, or None if the request should be silently ignored."""
ua = request.headers.get("User-Agent", "")
if len(ua) < 20 or BOT_RE.search(ua):
return None
if request.headers.get("Sec-Fetch-Site", "same-origin") != "same-origin":
return None
ip = request.remote_addr or ""
return {"ua": ua, "visitor": visitor_hash(ip, ua), "country": country_of(ip), "device": device_of(ua)}
def body():
return request.get_json(silent=True, force=True) or {}
def safe_insert(sql, params):
try:
insert(sql, params)
except Exception:
log.exception("insert failed")
@app.post("/api/t/visit")
@limiter.limit("20/minute;200/hour")
def visit():
ctx = context()
if ctx:
safe_insert(
"INSERT INTO visits (visitor, referrer, country, device) VALUES (%s,%s,%s,%s)",
(ctx["visitor"], referrer_host(str(body().get("ref", ""))), ctx["country"], ctx["device"]),
)
return "", 204
@app.post("/api/t/click")
@limiter.limit("40/minute;400/hour")
def click():
ctx = context()
link = body().get("link")
if ctx and isinstance(link, str) and link in LINK_IDS:
safe_insert(
"INSERT INTO clicks (link_id, visitor, country, device) VALUES (%s,%s,%s,%s)",
(link, ctx["visitor"], ctx["country"], ctx["device"]),
)
return "", 204
@app.get("/api/health")
@limiter.exempt
def health():
try:
with pool().connection() as conn:
conn.execute("SELECT 1")
return {"ok": True}
except Exception:
return {"ok": False}, 503
+75
View File
@@ -0,0 +1,75 @@
"""Checks every link in links.json and records the result in link_health.
Sites like X and Discord often block bots, so 401/403/429/999 count as "alive but
blocking checks". Anything else outside 2xx/3xx is flagged dead. If KUMA_PUSH_URL is
set, the result is pushed to an Uptime Kuma push monitor.
"""
import json
import os
import urllib.error
import urllib.parse
import urllib.request
import psycopg
UA = "Mozilla/5.0 (compatible; rain-hub-linkcheck)"
SOFT = {401, 403, 429, 999}
def fetch(url, method):
req = urllib.request.Request(url, method=method, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=15) as r:
return r.status, None
except urllib.error.HTTPError as e:
return e.code, None
except Exception as e:
return None, str(e)[:200]
def check(url):
status, err = fetch(url, "HEAD")
if status is None or status >= 400:
status, err = fetch(url, "GET")
if status is None:
return False, None, err
if 200 <= status < 400:
return True, status, None
if status in SOFT:
return True, status, "site blocks automated checks"
return False, status, "unexpected status"
def main():
with open(os.environ.get("LINKS_FILE", "/app/links.json"), encoding="utf-8") as f:
data = json.load(f)
items = [data["featured"], *data["links"]]
dead = []
with psycopg.connect(os.environ["DATABASE_URL"]) as conn:
for it in items:
url = it["href"]
if not url.startswith("http"):
continue
ok, status, note = check(url)
if not ok:
dead.append(it["id"])
conn.execute(
"""INSERT INTO link_health (link_id, url, ok, status, note, checked_at)
VALUES (%s,%s,%s,%s,%s, now())
ON CONFLICT (link_id) DO UPDATE SET url=EXCLUDED.url, ok=EXCLUDED.ok,
status=EXCLUDED.status, note=EXCLUDED.note, checked_at=now()""",
(it["id"], url, ok, status, note),
)
print(("OK " if ok else "DEAD"), it["id"], status, note or "")
push = os.environ.get("KUMA_PUSH_URL")
if push:
msg = "dead links: " + ", ".join(dead) if dead else "all links ok"
q = urllib.parse.urlencode({"status": "down" if dead else "up", "msg": msg})
try:
urllib.request.urlopen(f"{push}?{q}", timeout=10)
except Exception as e:
print("kuma push failed:", e)
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
flask>=3.0
flask-limiter>=3.5
psycopg[binary,pool]>=3.2
gunicorn>=22.0
geoip2>=4.8