storage: JSON files -> SQLite (users/history/searches/events)
- one-time import from the legacy JSON (left in place as snapshot); ghost rows (int vs str ep_id) deduped on import, unrepresentable after - history POST is now an upsert: the (user, slug_id, ep_id) PK is the merge key, so the unguarded read-modify-write (lost updates) and the corruption-swallow (JSONDecodeError -> [] -> fresh overwrite) are gone - searches: NOCASE PK upsert + cap 8 - events table + position/search logging (watch-time trajectory), 90-day retention pruned at boot - account delete cascades all four tables - 22/22 tests green; old JSON files untouched
This commit is contained in:
1 parent
2c0ee8eae3
commit
202bfa41c3
5 files changed
+222
-105
No files matched your search
+41
-38
@@ -1,4 +1,4 @@
|
||||
"""users.py — multi-user accounts for ani-web (PBKDF2, single json file).
|
||||
"""users.py — multi-user accounts for ani-web (PBKDF2, SQLite via db.py).
|
||||
|
||||
CLI: .venv/bin/python -m server.users add <username> (password via prompt)
|
||||
.venv/bin/python -m server.users rm <username>
|
||||
@@ -10,11 +10,12 @@ import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DATA = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data"))
|
||||
USERS = DATA / "users.json"
|
||||
from . import db
|
||||
|
||||
DATA = db.DATA
|
||||
SECRET = DATA / "secret.key"
|
||||
ITER = 210_000 # OWASP 2023 minimum for PBKDF2-SHA256
|
||||
NAME_RE = re.compile(r"^[a-z0-9_-]{3,32}$")
|
||||
@@ -32,9 +33,10 @@ def get_secret() -> bytes:
|
||||
|
||||
|
||||
def migrate_legacy():
|
||||
"""one-time: single-password era -> users.json + per-user history files."""
|
||||
"""one-time: single-password era -> users.json + per-user history files,
|
||||
which db.init() (called after this) absorbs into the tables."""
|
||||
cred = DATA / "credentials.json"
|
||||
if not USERS.exists() and cred.exists():
|
||||
if not (DATA / "users.json").exists() and cred.exists():
|
||||
d = json.loads(cred.read_text())
|
||||
save({"admin": {"salt": d["salt"], "hash": d["hash"], "created": time.time()}})
|
||||
cred.unlink()
|
||||
@@ -45,26 +47,16 @@ def migrate_legacy():
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
try:
|
||||
return json.loads(USERS.read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
"""tmp + os.replace: a crash mid-write leaves the previous file intact
|
||||
(in-place writes corrupted the file -> load() returned {} -> accounts
|
||||
or history silently vanished)."""
|
||||
tmp = path.parent / (path.name + ".tmp")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp, path)
|
||||
c = db.connect()
|
||||
rows = c.execute("SELECT name, salt, hash, iter, created FROM users").fetchall()
|
||||
c.close()
|
||||
return {n: {"salt": s, "hash": h, "iter": i, "created": cr} for n, s, h, i, cr in rows}
|
||||
|
||||
|
||||
def save(users: dict):
|
||||
"""file-based save, only for migrate_legacy (runs before db.init)."""
|
||||
DATA.mkdir(exist_ok=True)
|
||||
atomic_write(USERS, json.dumps(users, indent=1))
|
||||
(DATA / "users.json").write_text(json.dumps(users, indent=1))
|
||||
|
||||
|
||||
def add(username: str, password: str):
|
||||
@@ -73,37 +65,48 @@ def add(username: str, password: str):
|
||||
raise ValueError("username: 3-32 chars, only a-z 0-9 _ -")
|
||||
if len(password) < 8:
|
||||
raise ValueError("password: min 8 chars")
|
||||
users = load()
|
||||
if u in users:
|
||||
raise ValueError("username already taken")
|
||||
salt = secrets.token_bytes(16)
|
||||
users[u] = {"salt": salt.hex(),
|
||||
"hash": hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITER).hex(),
|
||||
"created": time.time()}
|
||||
save(users)
|
||||
c = db.connect()
|
||||
try:
|
||||
c.execute("INSERT INTO users VALUES (?,?,?,?,?)",
|
||||
(u, salt.hex(),
|
||||
hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITER).hex(),
|
||||
ITER, time.time()))
|
||||
c.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
raise ValueError("username already taken")
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def verify(username: str, password: str) -> bool:
|
||||
d = load().get(username.lower())
|
||||
if not d:
|
||||
c = db.connect()
|
||||
row = c.execute("SELECT salt, hash, iter FROM users WHERE name=?",
|
||||
(username.lower(),)).fetchone()
|
||||
c.close()
|
||||
if not row:
|
||||
return False
|
||||
h = hashlib.pbkdf2_hmac("sha256", password.encode(),
|
||||
bytes.fromhex(d["salt"]), d.get("iter", ITER))
|
||||
return secrets.compare_digest(h.hex(), d["hash"])
|
||||
bytes.fromhex(row[0]), row[2] or ITER)
|
||||
return secrets.compare_digest(h.hex(), row[1])
|
||||
|
||||
|
||||
def remove(username: str):
|
||||
u = username.lower()
|
||||
users = load()
|
||||
users.pop(u, None)
|
||||
save(users)
|
||||
(DATA / "history" / f"{u}.json").unlink(missing_ok=True)
|
||||
(DATA / "searches" / f"{u}.json").unlink(missing_ok=True)
|
||||
c = db.connect()
|
||||
c.execute("DELETE FROM users WHERE name=?", (u,))
|
||||
c.execute("DELETE FROM history WHERE user=?", (u,))
|
||||
c.execute("DELETE FROM searches WHERE user=?", (u,))
|
||||
c.execute("DELETE FROM events WHERE user=?", (u,))
|
||||
c.commit()
|
||||
c.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
migrate_legacy() # file-based, runs before db.init absorbs its output
|
||||
db.init()
|
||||
if cmd == "add" and len(sys.argv) > 2:
|
||||
name = sys.argv[2]
|
||||
pw = getpass.getpass(f"Password for {name} (min 8 chars): ")
|
||||
|
||||
Reference in new issue
Block a user