ffe508b7f3
FastAPI + SQLite behind a single-page frontend, deployed as a container on mainserver behind Caddy. One flexible parts table plus key/value specs so components, filament, fasteners and tooling share a schema. Categories and locations are nestable trees whose filters and sidebar counts both roll up through descendants. Category spec templates pre-fill the properties worth recording for each kind of part, which is what makes manual entry tolerable. Every quantity change is logged. Search is FTS5 with prefix matching across names, MPNs, spec values, tags, category and location. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
76 lines
2.2 KiB
Python
76 lines
2.2 KiB
Python
"""Single-user login gate.
|
|
|
|
The app sits on a public hostname, so it needs a door. One shared password from
|
|
the environment buys a signed, expiring cookie — no user table, no password
|
|
reset flow, nothing to administer. Set PARTS_AUTH=off for LAN-only use.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import time
|
|
|
|
from fastapi import Cookie, HTTPException
|
|
|
|
COOKIE_NAME = "parts_session"
|
|
|
|
|
|
def _env(name: str, default: str = "") -> str:
|
|
return os.environ.get(name, default).strip()
|
|
|
|
|
|
def auth_enabled() -> bool:
|
|
return _env("PARTS_AUTH", "on").lower() not in ("off", "0", "false", "no")
|
|
|
|
|
|
def _secret() -> bytes:
|
|
secret = _env("PARTS_SECRET")
|
|
if not secret:
|
|
# Without a configured secret, derive one from the password so sessions
|
|
# are still unforgeable — they just don't survive a password change.
|
|
secret = "fallback:" + _env("PARTS_PASSWORD")
|
|
return hashlib.sha256(secret.encode()).digest()
|
|
|
|
|
|
def session_days() -> int:
|
|
try:
|
|
return max(1, int(_env("PARTS_SESSION_DAYS", "30")))
|
|
except ValueError:
|
|
return 30
|
|
|
|
|
|
def check_password(candidate: str) -> bool:
|
|
expected = _env("PARTS_PASSWORD")
|
|
if not expected:
|
|
return False
|
|
return hmac.compare_digest(candidate.encode(), expected.encode())
|
|
|
|
|
|
def issue_token() -> str:
|
|
expires = int(time.time()) + session_days() * 86400
|
|
payload = str(expires).encode()
|
|
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
|
return base64.urlsafe_b64encode(payload + b"." + sig).decode()
|
|
|
|
|
|
def token_valid(token: str) -> bool:
|
|
try:
|
|
raw = base64.urlsafe_b64decode(token.encode())
|
|
payload, sig = raw.rsplit(b".", 1)
|
|
expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
|
if not hmac.compare_digest(sig, expected):
|
|
return False
|
|
return int(payload) > time.time()
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def require_auth(parts_session: str | None = Cookie(default=None)):
|
|
"""FastAPI dependency guarding every data route."""
|
|
if not auth_enabled():
|
|
return True
|
|
if parts_session and token_valid(parts_session):
|
|
return True
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|