ops: async click pipeline, honest uptime, backups, retention; auth/audit plumbing

- Click events are enqueued and written by a background thread — redirects
  never block on DB writes or geo lookups.
- Uptime heartbeat marks per-minute rows (INSERT OR IGNORE), safe across
  Gunicorn workers; /api/uptime now measures real availability from each
  day's first heartbeat instead of a per-process counter that could only
  report 100%. Legacy uptime_daily rows still honoured.
- Hourly maintenance thread: daily SQLite backup via VACUUM INTO (atomic
  temp-file rename, race-safe across workers, BACKUP_KEEP retention),
  CLICK_RETENTION_DAYS purge, uptime-mark pruning.
- notify_webhook() helper for Slack/Discord-compatible WEBHOOK_URL.
- Auth groundwork: _try_api_key_auth() lets the login/admin decorators
  accept Bearer qrk_ tokens (scope-checked, no Set-Cookie), and
  log_audit() records admin actions — used by the following feature
  commits.
- New env config: WEBHOOK_URL, GEOIP_DB_PATH, GEO_API_FALLBACK,
  IP_ANONYMIZE, CLICK_RETENTION_DAYS, BACKUPS, BACKUP_DIR, BACKUP_KEEP.
- requirements: add geoip2 for local GeoLite2 lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-03 01:39:50 -05:00
parent c892588d31
commit d0e99b5244
2 changed files with 202 additions and 18 deletions
+201 -18
View File
@@ -17,6 +17,9 @@ import hashlib
import hmac import hmac
import time import time
import io import io
import json
import queue
import secrets
import struct import struct
import zlib import zlib
import threading import threading
@@ -40,6 +43,16 @@ DB_PATH = os.environ.get('DB_PATH', '/app/data/qrknit.db')
BASE_URL = os.environ.get('BASE_URL', 'http://localhost:5000').rstrip('/') BASE_URL = os.environ.get('BASE_URL', 'http://localhost:5000').rstrip('/')
APP_NAME = os.environ.get('APP_NAME', 'to.ALWISP') APP_NAME = os.environ.get('APP_NAME', 'to.ALWISP')
# Operational settings — see INSTALL.md
WEBHOOK_URL = os.environ.get('WEBHOOK_URL', '') # Slack/Discord-compatible webhook for admin notifications
GEOIP_DB_PATH = os.environ.get('GEOIP_DB_PATH', '/app/data/GeoLite2-Country.mmdb')
GEO_API_FALLBACK = os.environ.get('GEO_API_FALLBACK', 'false').lower() == 'true' # ip-api.com is non-commercial-use only
IP_ANONYMIZE = os.environ.get('IP_ANONYMIZE', 'none').lower() # none | truncate | hash
CLICK_RETENTION_DAYS = int(os.environ.get('CLICK_RETENTION_DAYS', '0')) # 0 = keep click events forever
BACKUPS_ENABLED = os.environ.get('BACKUPS', 'true').lower() == 'true'
BACKUP_DIR = os.environ.get('BACKUP_DIR', '') # defaults to <db dir>/backups
BACKUP_KEEP = int(os.environ.get('BACKUP_KEEP', '7'))
COOKIE_SECURE = os.environ.get('COOKIE_SECURE', 'false').lower() == 'true' COOKIE_SECURE = os.environ.get('COOKIE_SECURE', 'false').lower() == 'true'
app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
@@ -212,19 +225,22 @@ init_db()
seed_admin() seed_admin()
def now_iso():
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
def _uptime_heartbeat(): def _uptime_heartbeat():
"""Daemon thread: records a 'checks_up' ping every 60 s to track daily availability.""" """Daemon thread: marks the current UTC minute as 'up' once per minute.
INSERT OR IGNORE on the minute primary key makes this safe to run in every
Gunicorn worker — multiple workers collapse to a single mark per minute,
so uptime is no longer over-counted in multi-worker deployments.
"""
while True: while True:
try: try:
today = datetime.now(timezone.utc).date().isoformat() minute = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M')
with get_db() as conn: with get_db() as conn:
conn.execute(""" conn.execute('INSERT OR IGNORE INTO uptime_minutes (minute) VALUES (?)', (minute,))
INSERT INTO uptime_daily (date, checks_total, checks_up)
VALUES (?, 1, 1)
ON CONFLICT(date) DO UPDATE SET
checks_total = checks_total + 1,
checks_up = checks_up + 1
""", (today,))
except Exception: except Exception:
pass pass
time.sleep(60) time.sleep(60)
@@ -233,12 +249,146 @@ threading.Thread(target=_uptime_heartbeat, daemon=True).start()
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Auth decorators # Async click recording
# Clicks are enqueued from the redirect path and written by a background
# thread, so redirects never block on DB writes or geo lookups.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
_click_queue: "queue.Queue" = queue.Queue(maxsize=10000)
def _click_writer():
while True:
item = _click_queue.get()
try:
country = item['cf_country'] or get_country_for_ip(item['ip'])
with get_db() as conn:
conn.execute(
'INSERT INTO clicks (link_id,clicked_at,referrer,user_agent,ip_address,country,is_bot) '
'VALUES (?,?,?,?,?,?,?)',
(item['link_id'], item['ts'], item['referrer'], item['ua'],
anonymize_ip(item['ip']), country, item['is_bot'])
)
conn.execute('UPDATE links SET clicks=clicks+1 WHERE id=?', (item['link_id'],))
except Exception:
pass
threading.Thread(target=_click_writer, daemon=True).start()
# ─────────────────────────────────────────────
# Daily maintenance — backups, retention, pruning
# ─────────────────────────────────────────────
def _run_maintenance():
today = datetime.now(timezone.utc).date().isoformat()
cutoff = (datetime.now(timezone.utc) - timedelta(days=31)).strftime('%Y-%m-%dT%H:%M')
with get_db() as conn:
conn.execute('DELETE FROM uptime_minutes WHERE minute < ?', (cutoff,))
if CLICK_RETENTION_DAYS > 0:
click_cutoff = (datetime.now(timezone.utc).replace(tzinfo=None)
- timedelta(days=CLICK_RETENTION_DAYS)).isoformat()
conn.execute('DELETE FROM clicks WHERE clicked_at < ?', (click_cutoff,))
if BACKUPS_ENABLED:
backup_dir = BACKUP_DIR or os.path.join(os.path.dirname(DB_PATH), 'backups')
os.makedirs(backup_dir, exist_ok=True)
path = os.path.join(backup_dir, f'qrknit-{today}.db')
if not os.path.exists(path):
# Vacuum into a per-process temp file, then rename atomically —
# concurrent workers can't trip over a half-written target.
tmp = f'{path}.tmp{os.getpid()}'
try:
conn = sqlite3.connect(DB_PATH)
try:
conn.execute('VACUUM INTO ?', (tmp,))
finally:
conn.close()
os.replace(tmp, path)
except Exception:
try:
os.remove(tmp)
except OSError:
pass
backups = sorted(f for f in os.listdir(backup_dir)
if f.startswith('qrknit-') and f.endswith('.db'))
for old in backups[:-BACKUP_KEEP] if BACKUP_KEEP > 0 else []:
try:
os.remove(os.path.join(backup_dir, old))
except OSError:
pass
def _maintenance_loop():
while True:
try:
_run_maintenance()
except Exception:
pass
time.sleep(3600)
threading.Thread(target=_maintenance_loop, daemon=True).start()
# ─────────────────────────────────────────────
# Webhook notifications (Slack / Discord compatible)
# ─────────────────────────────────────────────
def notify_webhook(text):
"""POST a notification to WEBHOOK_URL. Fire-and-forget — never blocks a request."""
if not WEBHOOK_URL:
return
def _send():
try:
import urllib.request as _ureq
payload = json.dumps({'text': text, 'content': text}).encode()
req = _ureq.Request(WEBHOOK_URL, data=payload,
headers={'Content-Type': 'application/json', 'User-Agent': 'QRknit/1.0'})
_ureq.urlopen(req, timeout=5)
except Exception:
pass
threading.Thread(target=_send, daemon=True).start()
# ─────────────────────────────────────────────
# Auth decorators
# Requests authenticate with either a session cookie or an API key
# (`Authorization: Bearer qrk_…`). Keys carry a read or write scope.
# ─────────────────────────────────────────────
def _try_api_key_auth():
"""If a Bearer API key is present, validate it and load the key's user into
the request's session (without emitting a Set-Cookie). Returns an error
response tuple on a bad key / insufficient scope, else None."""
auth = request.headers.get('Authorization', '')
if not auth.startswith('Bearer '):
return None
token = auth[7:].strip()
if not token.startswith('qrk_'):
return jsonify({'error': 'Invalid API key'}), 401
key_hash = hashlib.sha256(token.encode()).hexdigest()
with get_db() as conn:
row = conn.execute(
'SELECT k.id, k.scope, u.id AS user_id, u.username, u.is_admin, u.org_id '
'FROM api_keys k JOIN users u ON k.user_id=u.id '
'WHERE k.key_hash=? AND k.revoked=0', (key_hash,)
).fetchone()
if not row:
return jsonify({'error': 'Invalid API key'}), 401
if row['scope'] != 'write' and request.method not in ('GET', 'HEAD', 'OPTIONS'):
return jsonify({'error': 'This API key is read-only'}), 403
conn.execute('UPDATE api_keys SET last_used_at=? WHERE id=?', (now_iso(), row['id']))
session['authenticated'] = True
session['user_id'] = row['user_id']
session['username'] = row['username']
session['is_admin'] = bool(row['is_admin'])
session['org_id'] = row['org_id']
session.modified = False # request-scoped only — no session cookie for API clients
return None
def login_required(f): def login_required(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
err = _try_api_key_auth()
if err:
return err
if not session.get('authenticated') or not session.get('user_id'): if not session.get('authenticated') or not session.get('user_id'):
return jsonify({'error': 'Authentication required'}), 401 return jsonify({'error': 'Authentication required'}), 401
return f(*args, **kwargs) return f(*args, **kwargs)
@@ -247,6 +397,9 @@ def login_required(f):
def admin_required(f): def admin_required(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
err = _try_api_key_auth()
if err:
return err
if not session.get('authenticated') or not session.get('user_id'): if not session.get('authenticated') or not session.get('user_id'):
return jsonify({'error': 'Authentication required'}), 401 return jsonify({'error': 'Authentication required'}), 401
if not session.get('is_admin'): if not session.get('is_admin'):
@@ -255,6 +408,14 @@ def admin_required(f):
return decorated return decorated
def log_audit(conn, action, target=None, details=None):
"""Record an administrative action in the audit log."""
conn.execute(
'INSERT INTO audit_log (actor, action, target, details, created_at) VALUES (?,?,?,?,?)',
(session.get('username'), action, target, details, now_iso())
)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# Plan / usage helpers # Plan / usage helpers
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@@ -577,21 +738,43 @@ def health():
@app.route('/api/uptime') @app.route('/api/uptime')
def get_uptime(): def get_uptime():
"""Public endpoint — 30-day daily uptime history for the header SLA graph.""" """Public endpoint — 30-day daily uptime history for the header SLA graph.
today = datetime.now(timezone.utc).date()
Computed from per-minute 'up' marks: pct = minutes marked / minutes elapsed
that day, so downtime actually shows up (the old per-process counter could
only ever report 100%, multiplied per worker). Falls back to legacy
uptime_daily rows for days that predate the minute marks.
"""
now = datetime.now(timezone.utc)
today = now.date()
start = today - timedelta(days=29) start = today - timedelta(days=29)
with get_db() as conn: with get_db() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT date, checks_total, checks_up FROM uptime_daily WHERE date >= ? ORDER BY date", "SELECT substr(minute,1,10) as day, COUNT(*) as mins, MIN(minute) as first "
"FROM uptime_minutes WHERE minute >= ? GROUP BY day",
(start.isoformat(),) (start.isoformat(),)
).fetchall() ).fetchall()
row_map = {r['date']: r for r in rows} legacy = conn.execute(
"SELECT date, checks_total, checks_up FROM uptime_daily WHERE date >= ?",
(start.isoformat(),)
).fetchall()
min_map = {r['day']: r for r in rows}
legacy_map = {r['date']: r for r in legacy}
days = [] days = []
for i in range(30): for i in range(30):
d = (start + timedelta(days=i)).isoformat() d_date = start + timedelta(days=i)
row = row_map.get(d) d = d_date.isoformat()
if row and row['checks_total'] > 0: row = min_map.get(d)
pct = round(row['checks_up'] / row['checks_total'] * 100, 2) if row:
# Window runs from the day's first heartbeat (so fresh installs and
# mid-day deployments aren't penalised) to end-of-day / now.
first_min = int(row['first'][11:13]) * 60 + int(row['first'][14:16])
end_min = 1439 if d_date < today else now.hour * 60 + now.minute
expected = max(1, end_min - first_min + 1)
pct = round(min(row['mins'] / expected, 1.0) * 100, 2)
elif d in legacy_map and legacy_map[d]['checks_total'] > 0:
r = legacy_map[d]
pct = round(r['checks_up'] / r['checks_total'] * 100, 2)
else: else:
pct = None pct = None
days.append({'date': d, 'pct': pct}) days.append({'date': d, 'pct': pct})
+1
View File
@@ -2,3 +2,4 @@ flask>=2.3.0
qrcode[pil]>=7.4.2 qrcode[pil]>=7.4.2
Pillow>=10.0.0 Pillow>=10.0.0
gunicorn>=21.0.0 gunicorn>=21.0.0
geoip2>=4.7.0