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:
@@ -17,6 +17,9 @@ import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import io
|
||||
import json
|
||||
import queue
|
||||
import secrets
|
||||
import struct
|
||||
import zlib
|
||||
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('/')
|
||||
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'
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
@@ -212,19 +225,22 @@ init_db()
|
||||
seed_admin()
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
minute = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M')
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
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,))
|
||||
conn.execute('INSERT OR IGNORE INTO uptime_minutes (minute) VALUES (?)', (minute,))
|
||||
except Exception:
|
||||
pass
|
||||
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):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
err = _try_api_key_auth()
|
||||
if err:
|
||||
return err
|
||||
if not session.get('authenticated') or not session.get('user_id'):
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
return f(*args, **kwargs)
|
||||
@@ -247,6 +397,9 @@ def login_required(f):
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
err = _try_api_key_auth()
|
||||
if err:
|
||||
return err
|
||||
if not session.get('authenticated') or not session.get('user_id'):
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
if not session.get('is_admin'):
|
||||
@@ -255,6 +408,14 @@ def admin_required(f):
|
||||
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
|
||||
# ─────────────────────────────────────────────
|
||||
@@ -577,21 +738,43 @@ def health():
|
||||
|
||||
@app.route('/api/uptime')
|
||||
def get_uptime():
|
||||
"""Public endpoint — 30-day daily uptime history for the header SLA graph."""
|
||||
today = datetime.now(timezone.utc).date()
|
||||
"""Public endpoint — 30-day daily uptime history for the header SLA graph.
|
||||
|
||||
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)
|
||||
with get_db() as conn:
|
||||
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(),)
|
||||
).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 = []
|
||||
for i in range(30):
|
||||
d = (start + timedelta(days=i)).isoformat()
|
||||
row = row_map.get(d)
|
||||
if row and row['checks_total'] > 0:
|
||||
pct = round(row['checks_up'] / row['checks_total'] * 100, 2)
|
||||
d_date = start + timedelta(days=i)
|
||||
d = d_date.isoformat()
|
||||
row = min_map.get(d)
|
||||
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:
|
||||
pct = None
|
||||
days.append({'date': d, 'pct': pct})
|
||||
|
||||
@@ -2,3 +2,4 @@ flask>=2.3.0
|
||||
qrcode[pil]>=7.4.2
|
||||
Pillow>=10.0.0
|
||||
gunicorn>=21.0.0
|
||||
geoip2>=4.7.0
|
||||
|
||||
Reference in New Issue
Block a user