Add authenticated history viewer

This commit is contained in:
Clavdij Bot committed 2026-10-06 22:03:30 +02:00
1 parent f889c6129d
commit ba0ffb59a7
3 files changed
+86 -26

No files matched your search

+3
View File
@@ -1,6 +1,9 @@
# Create with: cp .env.example .env ; then replace the placeholder. # Create with: cp .env.example .env ; then replace the placeholder.
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))" # Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
API_TOKEN=REPLACE_WITH_A_RANDOM_32+_CHARACTER_TOKEN API_TOKEN=REPLACE_WITH_A_RANDOM_32+_CHARACTER_TOKEN
# Login for the browser viewer at /; replace both values with long random secrets.
VIEWER_USERNAME=admin
VIEWER_PASSWORD=REPLACE_WITH_ANOTHER_RANDOM_32+_CHARACTER_SECRET
# Optional. Leave empty to keep all data only in SQLite. # Optional. Leave empty to keep all data only in SQLite.
DISCORD_WEBHOOK_URL= DISCORD_WEBHOOK_URL=
DATA_DIR=/data DATA_DIR=/data
+11 -1
View File
@@ -14,7 +14,7 @@ docker compose logs -f
curl http://127.0.0.1:8080/healthz curl http://127.0.0.1:8080/healthz
``` ```
Privzeto posluša samo na `127.0.0.1:8080`. Za dostop iz drugih naprav uporabi VPN (Tailscale/WireGuard) ali HTTPS reverse proxy z avtentikacijo. Ne odpiraj porta neposredno v internet. Privzeto posluša samo na `127.0.0.1:8080`. Za dostop iz drugih naprav uporabi VPN (Tailscale/WireGuard) ali HTTPS reverse proxy. Ne odpiraj porta neposredno v internet.
## API ## API
@@ -34,6 +34,16 @@ Privzeto posluša samo na `127.0.0.1:8080`. Za dostop iz drugih naprav uporabi V
Odgovor `202 Accepted` pomeni, da je paket zapisan v `./data/history.sqlite3`. Podvojeni obiski se ne podvojijo. Odgovor `202 Accepted` pomeni, da je paket zapisan v `./data/history.sqlite3`. Podvojeni obiski se ne podvojijo.
## Spletni pregledovalnik
Odpri `http://127.0.0.1:8080/` in se prijavi z `VIEWER_USERNAME` ter `VIEWER_PASSWORD` iz `.env`. Pregledovalnik omogoča:
- filter od/do (čas je UTC),
- filter po napravi,
- iskanje po domeni, naslovu ali URL-ju,
- seznam do 500 zadnjih obiskov,
- pregled unikatnih domen s številom obiskov in zadnjim obiskom.
## Discord (neobvezno) ## Discord (neobvezno)
V `.env` nastavi `DISCORD_WEBHOOK_URL`. Najprej se zapis shrani v SQLite, nato se poskusi poslati Discordu. Neuspeh Discorda ne izbriše podatkov. V `.env` nastavi `DISCORD_WEBHOOK_URL`. Najprej se zapis shrani v SQLite, nato se poskusi poslati Discordu. Neuspeh Discorda ne izbriše podatkov.
+72 -25
View File
@@ -1,15 +1,19 @@
import asyncio import asyncio
import json import base64
import hmac
import logging import logging
import os import os
import sqlite3 import sqlite3
from contextlib import closing from contextlib import closing
from datetime import datetime, timezone from datetime import datetime, timezone
from html import escape
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Annotated
from urllib.parse import urlparse
import httpx import httpx
from fastapi import FastAPI, Header, HTTPException, Request, status from fastapi import FastAPI, Header, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field, HttpUrl from pydantic import BaseModel, Field, HttpUrl
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s")
@@ -18,6 +22,8 @@ logger = logging.getLogger("history-collector")
DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
DB_PATH = DATA_DIR / "history.sqlite3" DB_PATH = DATA_DIR / "history.sqlite3"
API_TOKEN = os.getenv("API_TOKEN", "") API_TOKEN = os.getenv("API_TOKEN", "")
VIEWER_USERNAME = os.getenv("VIEWER_USERNAME", "admin")
VIEWER_PASSWORD = os.getenv("VIEWER_PASSWORD", "")
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "") DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
MAX_VISITS_PER_REQUEST = int(os.getenv("MAX_VISITS_PER_REQUEST", "100")) MAX_VISITS_PER_REQUEST = int(os.getenv("MAX_VISITS_PER_REQUEST", "100"))
@@ -33,47 +39,62 @@ class VisitBatch(BaseModel):
app = FastAPI(title="Local History Collector", docs_url=None, redoc_url=None) app = FastAPI(title="Local History Collector", docs_url=None, redoc_url=None)
def domain_from_url(url: str) -> str:
return (urlparse(url).hostname or "").lower()
def connect(): def connect():
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""CREATE TABLE IF NOT EXISTS visits ( conn.execute("""CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY, device_id TEXT NOT NULL, url TEXT NOT NULL,
device_id TEXT NOT NULL, domain TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL, visit_time TEXT NOT NULL, transition TEXT NOT NULL, received_at TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '', UNIQUE(device_id, url, visit_time))""")
visit_time TEXT NOT NULL, # Migration for databases created by collector v1.
transition TEXT NOT NULL, columns = {row[1] for row in conn.execute("PRAGMA table_info(visits)")}
received_at TEXT NOT NULL, if "domain" not in columns:
UNIQUE(device_id, url, visit_time) conn.execute("ALTER TABLE visits ADD COLUMN domain TEXT NOT NULL DEFAULT ''")
)""") missing = conn.execute("SELECT id, url FROM visits WHERE domain = ''").fetchall()
conn.executemany("UPDATE visits SET domain = ? WHERE id = ?", [(domain_from_url(row["url"]), row["id"]) for row in missing])
conn.execute("CREATE INDEX IF NOT EXISTS idx_visits_device_time ON visits(device_id, visit_time DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_visits_device_time ON visits(device_id, visit_time DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_visits_domain ON visits(domain)")
conn.commit()
return conn return conn
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
if len(API_TOKEN) < 24: if len(API_TOKEN) < 24:
raise RuntimeError("API_TOKEN must contain at least 24 characters") raise RuntimeError("API_TOKEN must contain at least 24 characters")
if len(VIEWER_PASSWORD) < 12:
raise RuntimeError("VIEWER_PASSWORD must contain at least 12 characters")
with closing(connect()): with closing(connect()):
pass pass
def require_token(authorization: str | None): def require_token(authorization: str | None):
expected = f"Bearer {API_TOKEN}" if not hmac.compare_digest(authorization or "", f"Bearer {API_TOKEN}"):
if authorization != expected:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API token")
def require_viewer(request: Request):
header = request.headers.get("authorization", "")
try:
scheme, encoded = header.split(" ", 1)
username, password = base64.b64decode(encoded).decode("utf-8").split(":", 1)
except Exception:
scheme = username = password = ""
if scheme.lower() != "basic" or not (hmac.compare_digest(username, VIEWER_USERNAME) and hmac.compare_digest(password, VIEWER_PASSWORD)):
raise HTTPException(status_code=401, detail="Authentication required", headers={"WWW-Authenticate": 'Basic realm="History Collector"'})
def save_batch(batch: VisitBatch) -> int: def save_batch(batch: VisitBatch) -> int:
received_at = datetime.now(timezone.utc).isoformat() received_at = datetime.now(timezone.utc).isoformat()
rows = [(batch.device_id, str(v.url), v.title, v.visit_time.astimezone(timezone.utc).isoformat(), rows = [(batch.device_id, str(v.url), domain_from_url(str(v.url)), v.title,
v.transition, received_at) for v in batch.visits] v.visit_time.astimezone(timezone.utc).isoformat(), v.transition, received_at) for v in batch.visits]
with closing(connect()) as conn: with closing(connect()) as conn:
before = conn.total_changes before = conn.total_changes
conn.executemany("""INSERT OR IGNORE INTO visits conn.executemany("""INSERT OR IGNORE INTO visits
(device_id, url, title, visit_time, transition, received_at) (device_id, url, domain, title, visit_time, transition, received_at)
VALUES (?, ?, ?, ?, ?, ?)""", rows) VALUES (?, ?, ?, ?, ?, ?, ?)""", rows)
inserted = conn.total_changes - before inserted = conn.total_changes - before
conn.commit() conn.commit()
return inserted return inserted
@@ -82,14 +103,12 @@ async def post_discord(batch: VisitBatch):
if not DISCORD_WEBHOOK_URL: if not DISCORD_WEBHOOK_URL:
return return
lines = [f"• {v.visit_time.astimezone(timezone.utc):%Y-%m-%d %H:%M UTC} — {v.title or '(brez naslova)'}\n <{v.url}>" for v in batch.visits] lines = [f"• {v.visit_time.astimezone(timezone.utc):%Y-%m-%d %H:%M UTC} — {v.title or '(brez naslova)'}\n <{v.url}>" for v in batch.visits]
content = f"**Zgodovina brskanja · {batch.device_id}**\n" + "\n".join(lines) chunks, current = [], f"**Zgodovina brskanja · {batch.device_id}**\n"
# Discord accepts message content up to 2,000 characters; split safely by visit.
chunks, current = [], "**Zgodovina brskanja · %s**\n" % batch.device_id
for line in lines: for line in lines:
candidate = current + line + "\n" candidate = current + line + "\n"
if len(candidate) > 1900 and current.strip(): if len(candidate) > 1900 and current.strip():
chunks.append(current) chunks.append(current)
current = "**Zgodovina brskanja · %s (nadaljevanje)**\n%s\n" % (batch.device_id, line) current = f"**Zgodovina brskanja · {batch.device_id} (nadaljevanje)**\n{line}\n"
else: else:
current = candidate current = candidate
if current.strip(): chunks.append(current) if current.strip(): chunks.append(current)
@@ -99,7 +118,6 @@ async def post_discord(batch: VisitBatch):
response = await client.post(DISCORD_WEBHOOK_URL, json={"content": content}) response = await client.post(DISCORD_WEBHOOK_URL, json={"content": content})
response.raise_for_status() response.raise_for_status()
except Exception: except Exception:
# The visits are already safely persisted; don't reject them because Discord is unavailable.
logger.exception("Discord relay failed") logger.exception("Discord relay failed")
@app.get("/healthz") @app.get("/healthz")
@@ -107,8 +125,37 @@ def healthz():
return {"ok": True} return {"ok": True}
@app.post("/v1/visits", status_code=status.HTTP_202_ACCEPTED) @app.post("/v1/visits", status_code=status.HTTP_202_ACCEPTED)
async def receive_visits(batch: VisitBatch, authorization: str | None = Header(default=None)): async def receive_visits(batch: VisitBatch, authorization: Annotated[str | None, Header()] = None):
require_token(authorization) require_token(authorization)
stored = save_batch(batch) stored = save_batch(batch)
asyncio.create_task(post_discord(batch)) asyncio.create_task(post_discord(batch))
return {"accepted": len(batch.visits), "stored": stored} return {"accepted": len(batch.visits), "stored": stored}
def iso_from_local(value: str | None) -> str | None:
if not value:
return None
try:
return datetime.fromisoformat(value).replace(tzinfo=timezone.utc).isoformat()
except ValueError:
return None
def viewer_page(rows, domains, devices, start, end, device, search):
opts = ['<option value="">Vse naprave</option>'] + [f'<option value="{escape(d)}" {"selected" if d == device else ""}>{escape(d)}</option>' for d in devices]
visit_rows = "".join(f"<tr><td>{escape(r['visit_time'].replace('+00:00', 'Z'))}</td><td>{escape(r['device_id'])}</td><td>{escape(r['domain'])}</td><td>{escape(r['title'])}</td><td><a href=\"{escape(r['url'], quote=True)}\" target=\"_blank\" rel=\"noreferrer\">{escape(r['url'])}</a></td></tr>" for r in rows) or '<tr><td colspan="5">Ni zadetkov.</td></tr>'
domain_rows = "".join(f"<tr><td>{escape(r['domain'])}</td><td>{r['visits']}</td><td>{escape(r['last_seen'].replace('+00:00', 'Z'))}</td></tr>" for r in domains) or '<tr><td colspan="3">Ni zadetkov.</td></tr>'
return f'''<!doctype html><html lang="sl"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Zgodovina brskanja</title><style>body{{font:14px system-ui,sans-serif;margin:2rem;background:#111827;color:#e5e7eb}}main{{max-width:1400px;margin:auto}}form,.box{{background:#1f2937;padding:1rem;border-radius:10px;margin:1rem 0}}input,select,button{{padding:.5rem;margin:.25rem;border-radius:5px;border:1px solid #4b5563}}input,select{{background:#111827;color:#e5e7eb}}button{{background:#2563eb;color:white}}table{{border-collapse:collapse;width:100%;background:#fff;color:#111827}}th,td{{padding:.55rem;border:1px solid #d1d5db;text-align:left;vertical-align:top}}th{{background:#e5e7eb}}a{{color:#075985;word-break:break-all}}.grid{{display:grid;grid-template-columns:2fr 1fr;gap:1rem}}@media(max-width:900px){{.grid{{grid-template-columns:1fr}}}}</style><main><h1>Zgodovina brskanja</h1><form method="get"><label>Od <input type="datetime-local" name="start" value="{escape(start or '')}"></label><label>Do <input type="datetime-local" name="end" value="{escape(end or '')}"></label><label>Naprava <select name="device">{''.join(opts)}</select></label><label>Išči <input name="q" value="{escape(search or '')}" placeholder="domena, naslov ali URL"></label><button>Filtriraj</button></form><div class="grid"><section class="box"><h2>Obiski (zadnjih 500)</h2><table><thead><tr><th>Čas (UTC)</th><th>Naprava</th><th>Domena</th><th>Naslov</th><th>URL</th></tr></thead><tbody>{visit_rows}</tbody></table></section><section class="box"><h2>Unikatne domene</h2><table><thead><tr><th>Domena</th><th>Obiski</th><th>Zadnji obisk</th></tr></thead><tbody>{domain_rows}</tbody></table></section></div></main></html>'''
@app.get("/", response_class=HTMLResponse)
def viewer(request: Request, start: str | None = Query(None), end: str | None = Query(None), device: str | None = Query(None), q: str | None = Query(None)):
require_viewer(request)
filters, params = [], []
if value := iso_from_local(start): filters.append("visit_time >= ?"); params.append(value)
if value := iso_from_local(end): filters.append("visit_time <= ?"); params.append(value)
if device: filters.append("device_id = ?"); params.append(device)
if q: filters.append("(domain LIKE ? OR title LIKE ? OR url LIKE ?)"); params.extend([f"%{q}%"] * 3)
where = (" WHERE " + " AND ".join(filters)) if filters else ""
with closing(connect()) as conn:
rows = conn.execute(f"SELECT visit_time, device_id, domain, title, url FROM visits{where} ORDER BY visit_time DESC LIMIT 500", params).fetchall()
domains = conn.execute(f"SELECT domain, COUNT(*) visits, MAX(visit_time) last_seen FROM visits{where} GROUP BY domain ORDER BY visits DESC, last_seen DESC LIMIT 200", params).fetchall()
devices = [r[0] for r in conn.execute("SELECT DISTINCT device_id FROM visits ORDER BY device_id")]
return viewer_page(rows, domains, devices, start, end, device, q)