build a template website
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
flask>=3.0
|
||||
flask-limiter>=3.5
|
||||
psycopg[binary,pool]>=3.2
|
||||
gunicorn>=22.0
|
||||
geoip2>=4.8
|
||||
Reference in New Issue
Block a user