diff --git a/README.md b/README.md index d1d07e1..ccfad65 100644 --- a/README.md +++ b/README.md @@ -163,13 +163,30 @@ loses to a good-enough one available now. ## Authentication -Single-user GitHub OAuth, built so this can face the internet. +OAuth against GitHub and/or Google, built so this can face the internet. **It fails closed.** Auth is on unless `MUSE_AUTH=off` is set explicitly, and if it is on with incomplete config the app *refuses to start*. The failure mode worth engineering against isn't a broken login page — it's a misconfigured deploy quietly serving an unauthenticated download client to the internet. +### Who may log in + +Two rules, checked in order: + +1. **`MUSE_ALLOWED_USERS`** — the bootstrap allowlist. Always admitted. This is + how the first admin gets in when the database is empty, and the recovery + path if someone locks themselves out. +2. **A `users` row that isn't disabled** — how everyone invited later gets in, + with no env change and no redeploy. + +Anyone matching neither is rejected and the attempt is logged with their +username. The check runs on **every request**, not just at login: an +authorization check that only runs at sign-in isn't revocation, it's a delay. + +The first account to ever sign in becomes **admin**. No bootstrap ceremony, no +default password. + ### Setting it up 1. → **New OAuth App** @@ -179,16 +196,70 @@ deploy quietly serving an unauthenticated download client to the internet. 3. Set `MUSE_GITHUB_CLIENT_ID`, `MUSE_GITHUB_CLIENT_SECRET`, `MUSE_ALLOWED_USERS` (your GitHub login), and `MUSE_BASE_URL`. +To add Google, set `MUSE_AUTH=github,google` plus the two Google variables. +**The callback URL is the same** — `/auth/callback` for every provider, with +the provider carried in the state cookie rather than the URL — so adding +Google needs no change to an already-registered GitHub app. With more than one +provider enabled, `/auth/login` shows a chooser. + +Google identities are the **verified** email address; an unverified one is +refused outright, since anyone can put someone else's address on an account +they control and the allowlist is keyed on exactly that string. + | variable | default | meaning | |---|---|---| -| `MUSE_AUTH` | `github` | `github` or `off` | -| `MUSE_GITHUB_CLIENT_ID` | — | OAuth app client id | -| `MUSE_GITHUB_CLIENT_SECRET` | — | OAuth app secret | -| `MUSE_ALLOWED_USERS` | — | comma-separated GitHub logins; **nobody else gets in** | +| `MUSE_AUTH` | `github` | comma list of `github`, `google`, or `off` | +| `MUSE_GITHUB_CLIENT_ID` / `_SECRET` | — | GitHub OAuth app | +| `MUSE_GOOGLE_CLIENT_ID` / `_SECRET` | — | Google OAuth client | +| `MUSE_ALLOWED_USERS` | — | bootstrap allowlist; required only while the users table is empty | | `MUSE_BASE_URL` | — | external URL, e.g. `https://muse.yourdomain.com` | | `MUSE_COOKIE_SECURE` | `true` | set `false` only for plain-http LAN testing | | `MUSE_SESSION_DAYS` | `7` | session lifetime | +## Multiple users + +Roles are **admin**, **user** and **guest**. Admins get an extra tab. + +**Inviting is just adding a row** — no email, no token, no expiry to manage. +Add someone's GitHub username or Google address in the admin panel and they can +sign in. Remove them and they can't. + +### Quotas + +Two per-user limits, both `0` = unlimited: + +- **Requests per month** — calendar month, resets on the 1st. A rolling 30-day + window was rejected on purpose: "12 left until the 1st" is something a person + can plan around, where a rolling window drips capacity back at unpredictable + times. +- **Storage** — bytes of imported audio attributable to that user, measured + from the file actually written rather than the size the Soulseek peer + advertised. + +Batches are checked against the **whole batch**: queueing a 30-track album with +5 requests left is refused up front, not accepted and then abandoned halfway. +Admins are exempt — somebody has to be able to fix the library when a quota is +what's in the way. + +### What the admin panel shows + +Host CPU, memory and disk (read straight from `/proc` — no psutil, no compiled +dependency, and inside a container `/proc` reports the *host*, which is what +you want for a homelab box), library size, slskd connection state, a 30-day +request chart, per-user and per-artist breakdowns, and the user table with +inline role and quota editing. + +### Guard rails + +- **The last active admin cannot be demoted, disabled or deleted.** An install + with no admin can't be administered and nothing in the UI can undo it. +- **Disabling revokes sessions immediately** rather than at expiry. +- **Deleting a user keeps their jobs.** The history is a record of what's in + the library and why; removing a person shouldn't rewrite it. +- **Requests are private by default.** Users see their own; admins see all + (`?mine=true` to scope down). Retrying someone else's job returns **404**, + not 403 — a distinct error would confirm the id exists. + ### What it does - **Server-side sessions.** The cookie carries a 256-bit random token and @@ -331,10 +402,18 @@ package page → Package settings → Change visibility. | `POST /api/request/batch` | queue many known tracks (a whole album) | | `POST /api/import` | queue many from pasted text | | `POST /api/retry/{id}` | requeue a failed job | -| `GET /api/jobs` | request history and state | +| `GET /api/jobs` | request history and state (own, or all for admins) | +| `GET /api/me` | who you are, your role and quota usage | | `GET /api/health` | slskd reachability, mount writability | +| `GET /api/admin/users` | list users with usage | +| `POST /api/admin/users` | invite | +| `PATCH /api/admin/users/{u}` | role, quotas, disable | +| `DELETE /api/admin/users/{u}` | remove (jobs retained) | +| `GET /api/admin/system` | CPU, memory, disk, slskd, library totals | +| `GET /api/admin/activity` | daily volume, per-user and per-artist breakdowns | -Everything except `/healthz` requires a session. +Everything except `/healthz` requires a session; `/api/admin/*` requires the +admin role. ## Releases diff --git a/app.py b/app.py index bc7c548..0edcd3e 100644 --- a/app.py +++ b/app.py @@ -37,10 +37,12 @@ import uuid from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from pathlib import Path +from urllib.parse import urlencode import httpx from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse, + RedirectResponse) from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -108,7 +110,7 @@ DERIVATIVE_RE = re.compile(r"\b(" + "|".join(DERIVATIVE) + r")\b") # ─────────────────────────── auth ─────────────────────────── # -# Single-user GitHub OAuth, built so this can face the internet. +# OAuth against GitHub and/or Google, built so this can face the internet. # # FAILS CLOSED. Auth is on unless MUSE_AUTH is explicitly set to "off", and if # it is on with incomplete config the app refuses to start. The failure mode @@ -120,29 +122,65 @@ DERIVATIVE_RE = re.compile(r"\b(" + "|".join(DERIVATIVE) + r")\b") # nothing else, and the database stores only its SHA-256. There is no signed # payload to get wrong, a stolen database yields no usable cookies, and logout # and revocation actually revoke. +# +# WHO MAY LOG IN is two rules, checked in this order: +# 1. MUSE_ALLOWED_USERS — the bootstrap allowlist. Always admitted. This is +# how the very first admin gets in when the users table is empty, and it +# is the recovery path if someone disables themselves. +# 2. A row in `users` that is not disabled — how everyone invited later gets +# in, without an env change or a redeploy. +# Anyone matching neither is rejected and the attempt is logged. AUTH_MODE = os.getenv("MUSE_AUTH", "github").strip().lower() -GH_CLIENT_ID = os.getenv("MUSE_GITHUB_CLIENT_ID", "").strip() -GH_CLIENT_SECRET = os.getenv("MUSE_GITHUB_CLIENT_SECRET", "").strip() -ALLOWED_USERS = {u.strip().lower() - for u in os.getenv("MUSE_ALLOWED_USERS", "").split(",") if u.strip()} +ENABLED_PROVIDERS = [p for p in (x.strip() for x in AUTH_MODE.split(",")) + if p and p != "off"] + # Absolute external URL, e.g. https://muse.aryan-singh.dev. Used to build the # OAuth callback. Taken from config rather than from X-Forwarded-* headers on # purpose: those are attacker-controllable and this is a redirect target. BASE_URL = os.getenv("MUSE_BASE_URL", "").rstrip("/") +ALLOWED_USERS = {u.strip().lower() + for u in os.getenv("MUSE_ALLOWED_USERS", "").split(",") if u.strip()} COOKIE_SECURE = os.getenv("MUSE_COOKIE_SECURE", "true").lower() != "false" SESSION_DAYS = int(os.getenv("MUSE_SESSION_DAYS", "7")) SESSION_COOKIE = "muse_session" STATE_COOKIE = "muse_oauth_state" -GH_AUTH = "https://github.com/login/oauth/authorize" -GH_TOKEN = "https://github.com/login/oauth/access_token" + +# One callback URL for every provider — the provider is carried in the state +# COOKIE, not the query string, so adding Google needs no change to the GitHub +# OAuth app that is already registered against /auth/callback. +PROVIDERS: dict[str, dict] = { + "github": { + "label": "GitHub", + "authorize": "https://github.com/login/oauth/authorize", + "token": "https://github.com/login/oauth/access_token", + "scope": "read:user", + "client_id": os.getenv("MUSE_GITHUB_CLIENT_ID", "").strip(), + "client_secret": os.getenv("MUSE_GITHUB_CLIENT_SECRET", "").strip(), + "id_env": "MUSE_GITHUB_CLIENT_ID", + "secret_env": "MUSE_GITHUB_CLIENT_SECRET", + }, + "google": { + "label": "Google", + "authorize": "https://accounts.google.com/o/oauth2/v2/auth", + "token": "https://oauth2.googleapis.com/token", + "scope": "openid email profile", + "client_id": os.getenv("MUSE_GOOGLE_CLIENT_ID", "").strip(), + "client_secret": os.getenv("MUSE_GOOGLE_CLIENT_SECRET", "").strip(), + "id_env": "MUSE_GOOGLE_CLIENT_ID", + "secret_env": "MUSE_GOOGLE_CLIENT_SECRET", + }, +} + GH_API = "https://api.github.com/user" +GOOGLE_API = "https://openidconnect.googleapis.com/v1/userinfo" # Paths reachable without a session. Deliberately tiny: /healthz reports a bare -# ok so the container healthcheck works without leaking configuration, and the -# static assets must load on the login page itself. +# ok so the container healthcheck works without leaking configuration. PUBLIC_PATHS = {"/healthz", "/auth/login", "/auth/callback", "/auth/logout"} +ROLES = ("admin", "user", "guest") + def auth_preflight(): if AUTH_MODE == "off": @@ -151,22 +189,50 @@ def auth_preflight(): log.warning("Never expose this to the internet in this state.") log.warning("=" * 62) return - if AUTH_MODE != "github": - raise RuntimeError(f"MUSE_AUTH must be 'github' or 'off', got {AUTH_MODE!r}") - missing = [n for n, v in ( - ("MUSE_GITHUB_CLIENT_ID", GH_CLIENT_ID), - ("MUSE_GITHUB_CLIENT_SECRET", GH_CLIENT_SECRET), - ("MUSE_ALLOWED_USERS", ALLOWED_USERS), - ("MUSE_BASE_URL", BASE_URL)) if not v] + if not ENABLED_PROVIDERS: + raise RuntimeError(f"MUSE_AUTH must list providers or be 'off', got {AUTH_MODE!r}") + + unknown = [p for p in ENABLED_PROVIDERS if p not in PROVIDERS] + if unknown: + raise RuntimeError( + f"MUSE_AUTH names unknown provider(s) {unknown}. " + f"Valid: {', '.join(PROVIDERS)}, or 'off'.") + + missing: list[str] = [] + for p in ENABLED_PROVIDERS: + cfg = PROVIDERS[p] + if not cfg["client_id"]: + missing.append(cfg["id_env"]) + if not cfg["client_secret"]: + missing.append(cfg["secret_env"]) + if not BASE_URL: + missing.append("MUSE_BASE_URL") + # The bootstrap allowlist is only mandatory while nobody exists yet — + # otherwise a working multi-user install would be held hostage by an env + # var it has outgrown. But an EMPTY database and no allowlist means nobody + # can ever log in, which is a misconfiguration worth refusing to start on. + if not ALLOWED_USERS and not _any_users(): + missing.append("MUSE_ALLOWED_USERS") if missing: raise RuntimeError( - "GitHub auth is enabled but " + ", ".join(missing) + " not set. " + "Auth is enabled but " + ", ".join(sorted(set(missing))) + " not set. " "Refusing to start: set them, or set MUSE_AUTH=off to run without " "authentication (LAN only).") + if not BASE_URL.startswith("https://") and COOKIE_SECURE: log.warning("MUSE_BASE_URL is not https but cookies are Secure — the " "browser will refuse to send them. Set MUSE_COOKIE_SECURE=false " "only if you are testing over plain http on a LAN.") + log.info("auth: providers=%s bootstrap_allowlist=%d", + ",".join(ENABLED_PROVIDERS), len(ALLOWED_USERS)) + + +def _any_users() -> bool: + try: + with db() as c: + return c.execute("SELECT 1 FROM users LIMIT 1").fetchone() is not None + except sqlite3.Error: + return False def _hash(tok: str) -> str: @@ -185,7 +251,21 @@ def new_session(username: str) -> str: return token -def session_user(request: Request) -> str | None: +def may_login(username: str, row: sqlite3.Row | None) -> bool: + """The single authorization rule, used at login AND on every request. + + Bootstrap allowlist first, then a live, non-disabled user row. Deliberately + one function: an authorization check that runs only at login is not a + revocation mechanism, it is a delay — disabling someone would leave their + session valid for up to MUSE_SESSION_DAYS. + """ + if username.lower() in ALLOWED_USERS: + return True + return bool(row) and not row["disabled"] + + +def current_user(request: Request) -> sqlite3.Row | None: + """The signed-in user's row, or None. Re-validated on every request.""" tok = request.cookies.get(SESSION_COOKIE) if not tok: return None @@ -193,24 +273,52 @@ def session_user(request: Request) -> str | None: row = c.execute( "SELECT username, expires_at FROM sessions WHERE token_hash=?", (_hash(tok),)).fetchone() - if not row: - return None - try: - if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): + if not row: return None - except ValueError: - return None - # Re-check the allowlist on every request, so removing someone from - # MUSE_ALLOWED_USERS locks them out immediately instead of whenever their - # session happens to expire. Same for the per-user disable flag: a - # revocation that only took effect on expiry would be a revocation in name - # only. - if row["username"].lower() not in ALLOWED_USERS: - return None - with db() as c: - u = c.execute("SELECT disabled FROM users WHERE username=?", + try: + if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc): + return None + except ValueError: + return None + u = c.execute("SELECT * FROM users WHERE username=?", (row["username"],)).fetchone() - return None if u and u["disabled"] else row["username"] + if not may_login(row["username"], u): + return None + if u is None: + # Allowlisted but no row — an admin deleted the bootstrap account while + # its session was still live. The allowlist is exactly the recovery + # path for that, so put the row back rather than half-authenticating + # them into a state where every role check fails. + u = upsert_user(row["username"]) + log.info("auth: restored deleted allowlisted user %s", row["username"]) + return u + + +def session_user(request: Request) -> str | None: + u = current_user(request) + return u["username"] if u else None + + +def user_role(request: Request) -> str: + u = current_user(request) + if u: + return u["role"] + # With auth off there is nobody to be, so everyone is an admin — otherwise + # the admin panel would be unreachable on a LAN-only install. + return "admin" if AUTH_MODE == "off" else "guest" + + +def require_admin(request: Request) -> str: + if AUTH_MODE == "off": + return "local" + u = current_user(request) + if not u: + raise HTTPException(401, "unauthenticated") + if u["role"] != "admin": + log.warning("admin: DENIED %s (role=%s) -> %s", + u["username"], u["role"], request.url.path) + raise HTTPException(403, "admin only") + return u["username"] SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') @@ -284,22 +392,35 @@ def init_db(): ("duration", "INTEGER")): if col not in cols: c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}") + ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()} + for col, decl in (("storage_quota", "INTEGER NOT NULL DEFAULT 0"), + ("invited_by", "TEXT")): + if col not in ucols: + c.execute(f"ALTER TABLE users ADD COLUMN {col} {decl}") c.execute("CREATE INDEX IF NOT EXISTS jobs_by_user ON jobs (requested_by)") c.execute("CREATE INDEX IF NOT EXISTS jobs_by_state ON jobs (state)") + c.execute("CREATE INDEX IF NOT EXISTS jobs_by_created ON jobs (created_at)") -def upsert_user(username: str, avatar: str = "", display: str = "") -> sqlite3.Row: - """Record the sign-in. The first user to ever log in becomes admin -- - there is no bootstrap ceremony and no default password to forget.""" +def upsert_user(username: str, avatar: str = "", display: str = "", + provider: str = "github") -> sqlite3.Row: + """Record the sign-in. + + The first user to ever log in becomes admin — no bootstrap ceremony, no + default password to forget. Everyone after that is a plain user until an + admin says otherwise; role is never touched on subsequent logins, or every + sign-in would silently undo a demotion. + """ with db() as c: first = c.execute("SELECT COUNT(*) n FROM users").fetchone()["n"] == 0 c.execute( "INSERT INTO users (username,provider,role,display,avatar,last_seen) " "VALUES (?,?,?,?,?,datetime('now')) " "ON CONFLICT(username) DO UPDATE SET " - " last_seen=datetime('now'), avatar=excluded.avatar, " + " last_seen=datetime('now'), provider=excluded.provider, " + " avatar=COALESCE(NULLIF(excluded.avatar,''), users.avatar), " " display=COALESCE(NULLIF(excluded.display,''), users.display)", - (username, "github", "admin" if first else "user", display, avatar)) + (username, provider, "admin" if first else "user", display, avatar)) return c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() @@ -542,7 +663,14 @@ async def import_worker(): if not src: continue # still being moved out of incomplete dest = tag_and_file(job, src) - set_state(job["id"], "imported", str(dest), imported_path=str(dest)) + # Record the real size, not the size the peer advertised — + # storage quotas are computed from this column. + try: + actual = src.stat().st_size + except OSError: + actual = job["size"] + set_state(job["id"], "imported", str(dest), + imported_path=str(dest), size=actual) elif "Completed" in st and "Succeeded" not in st: set_state(job["id"], "failed", f"transfer {st}") except Exception as e: @@ -553,8 +681,10 @@ async def import_worker(): @asynccontextmanager async def lifespan(_: FastAPI): - auth_preflight() # raises and stops the container if misconfigured + # init_db first: the preflight consults the users table to decide whether + # the bootstrap allowlist is still mandatory. init_db() + auth_preflight() # raises and stops the container if misconfigured # The queue lives in memory, so a restart mid-import would otherwise # strand every unstarted job in 'queued' forever with nothing to pick it @@ -682,64 +812,134 @@ async def healthz(): return {"ok": True} +CHOOSER = """ +muse + +
+
muse
+
Sign in to continue
+{buttons}
""" + +_BTN = ('Continue with {label}') + + @app.get("/auth/login") -async def auth_login(): +async def auth_login(provider: str = ""): + """Start OAuth. With several providers enabled and none named, offer a + choice — a tiny self-contained page that uses only style attributes, which + the CSP already permits, so it needs no stylesheet and no extra hash.""" if AUTH_MODE == "off": return RedirectResponse("/", status_code=302) - state = secrets.token_urlsafe(24) - url = (f"{GH_AUTH}?client_id={GH_CLIENT_ID}" - f"&redirect_uri={BASE_URL}/auth/callback" - f"&scope=read:user&state={state}&allow_signup=false") - resp = RedirectResponse(url, status_code=302) - # CSRF defence: the state we generated is echoed back by GitHub and must - # match this cookie, so a callback the user did not initiate is rejected. - _set_cookie(resp, STATE_COOKIE, state, 600) + + if not provider: + if len(ENABLED_PROVIDERS) == 1: + provider = ENABLED_PROVIDERS[0] + else: + buttons = "".join( + _BTN.format(p=p, label=PROVIDERS[p]["label"]) for p in ENABLED_PROVIDERS) + return HTMLResponse(CHOOSER.replace("{buttons}", buttons)) + + if provider not in ENABLED_PROVIDERS: + raise HTTPException(404, "unknown provider") + + cfg = PROVIDERS[provider] + nonce = secrets.token_urlsafe(24) + params = { + "client_id": cfg["client_id"], + "redirect_uri": f"{BASE_URL}/auth/callback", + "scope": cfg["scope"], + "state": nonce, + "response_type": "code", + } + if provider == "github": + params["allow_signup"] = "false" + if provider == "google": + # Force the account chooser; otherwise Google silently reuses whichever + # account the browser last used, which on a shared machine signs you in + # as someone else with no visible prompt. + params["prompt"] = "select_account" + + resp = RedirectResponse(f"{cfg['authorize']}?{urlencode(params)}", status_code=302) + # CSRF defence: the nonce is echoed back by the provider and must match + # this cookie, so a callback the user did not initiate is rejected. The + # provider name rides along in the COOKIE, never the query string, so the + # callback cannot be steered to a different provider by the redirect. + _set_cookie(resp, STATE_COOKIE, f"{provider}|{nonce}", 600) return resp +async def fetch_identity(provider: str, token: str) -> tuple[str, str, str]: + """(username, display, avatar) for a provider access token.""" + async with httpx.AsyncClient(timeout=20) as c: + if provider == "github": + r = await c.get(GH_API, headers={"Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json"}) + if r.status_code != 200: + raise HTTPException(401, "github user lookup failed") + p = r.json() or {} + return p.get("login") or "", p.get("name") or "", p.get("avatar_url") or "" + + r = await c.get(GOOGLE_API, headers={"Authorization": f"Bearer {token}"}) + if r.status_code != 200: + raise HTTPException(401, "google user lookup failed") + p = r.json() or {} + # Unverified Google emails must never become an identity: anyone can + # put someone else's address on an account they control, and the + # allowlist is keyed on exactly this string. + if not p.get("email") or not p.get("email_verified"): + raise HTTPException(403, "google account has no verified email") + return p["email"], p.get("name") or "", p.get("picture") or "" + + @app.get("/auth/callback") async def auth_callback(request: Request, code: str = "", state: str = ""): if AUTH_MODE == "off": return RedirectResponse("/", status_code=302) - expected = request.cookies.get(STATE_COOKIE) or "" - if not code or not state or not secrets.compare_digest(state, expected): + cookie = request.cookies.get(STATE_COOKIE) or "" + provider, _, nonce = cookie.partition("|") + if (not code or not state or not nonce + or provider not in ENABLED_PROVIDERS + or not secrets.compare_digest(state, nonce)): log.warning("auth: rejected callback with bad or missing state") return JSONResponse({"error": "invalid state"}, status_code=400) + cfg = PROVIDERS[provider] async with httpx.AsyncClient(timeout=20) as c: - t = await c.post(GH_TOKEN, headers={"Accept": "application/json"}, - data={"client_id": GH_CLIENT_ID, - "client_secret": GH_CLIENT_SECRET, + t = await c.post(cfg["token"], headers={"Accept": "application/json"}, + data={"client_id": cfg["client_id"], + "client_secret": cfg["client_secret"], "code": code, + "grant_type": "authorization_code", "redirect_uri": f"{BASE_URL}/auth/callback"}) token = (t.json() or {}).get("access_token") if t.status_code == 200 else None - if not token: - log.warning("auth: token exchange failed (%s)", t.status_code) - return JSONResponse({"error": "token exchange failed"}, status_code=401) + if not token: + log.warning("auth: %s token exchange failed (%s)", provider, t.status_code) + return JSONResponse({"error": "token exchange failed"}, status_code=401) - u = await c.get(GH_API, headers={"Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json"}) - if u.status_code != 200: - return JSONResponse({"error": "github user lookup failed"}, status_code=401) - profile = u.json() or {} - login = profile.get("login") or "" + username, display, avatar = await fetch_identity(provider, token) + if not username: + return JSONResponse({"error": "no username from provider"}, status_code=401) - if login.lower() not in ALLOWED_USERS: + with db() as c: + existing = c.execute("SELECT * FROM users WHERE username=?", + (username,)).fetchone() + if not may_login(username, existing): # Log the attempt: on an internet-facing box this is the signal that # someone found the login page. - log.warning("auth: DENIED github user %r (not in MUSE_ALLOWED_USERS)", login) + why = "disabled" if existing else "not invited" + log.warning("auth: DENIED %s user %r (%s)", provider, username, why) return JSONResponse({"error": "not authorised"}, status_code=403) - row = upsert_user(login, profile.get("avatar_url") or "", - profile.get("name") or "") - if row and row["disabled"]: - log.warning("auth: DENIED %r (account disabled)", login) - return JSONResponse({"error": "account disabled"}, status_code=403) - - log.info("auth: signed in %s (%s)", login, row["role"] if row else "user") + row = upsert_user(username, avatar, display, provider) + log.info("auth: signed in %s via %s (%s)", username, provider, row["role"]) resp = RedirectResponse("/", status_code=302) - _set_cookie(resp, SESSION_COOKIE, new_session(login), SESSION_DAYS * 86400) + _set_cookie(resp, SESSION_COOKIE, new_session(username), SESSION_DAYS * 86400) resp.delete_cookie(STATE_COOKIE, path="/") return resp @@ -755,15 +955,68 @@ async def auth_logout(request: Request): return resp +# ─────────────────────────── quotas ─────────────────────────── +# +# Two limits, both per user, both 0 = unlimited: +# quota requests per CALENDAR month (resets on the 1st) +# storage_quota bytes of imported audio attributable to this user +# +# Calendar month rather than a rolling 30-day window on purpose: "you have 12 +# left until the 1st" is something a person can reason about, where a rolling +# window silently drips capacity back at times nobody can predict. + +def month_start() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-01 00:00:00") + + +def usage_for(username: str | None) -> dict: + """Requests this month and bytes imported, for one user.""" + if not username: + return {"requests": 0, "storage": 0} + with db() as c: + n = c.execute( + "SELECT COUNT(*) n FROM jobs WHERE requested_by=? AND created_at >= ?", + (username, month_start())).fetchone()["n"] + b = c.execute( + "SELECT COALESCE(SUM(size),0) b FROM jobs " + "WHERE requested_by=? AND state='imported'", (username,)).fetchone()["b"] + return {"requests": n, "storage": b} + + +def check_quota(request: Request, adding: int = 1): + """Raise 429 if this request would exceed the caller's monthly allowance. + + Admins are exempt — someone has to be able to fix the library when a quota + is what is in the way. + """ + u = current_user(request) + if not u or u["role"] == "admin": + return + used = usage_for(u["username"]) + if u["quota"] and used["requests"] + adding > u["quota"]: + raise HTTPException(429, ( + f"Monthly request quota reached ({used['requests']}/{u['quota']}). " + f"Resets on the 1st.")) + if u["storage_quota"] and used["storage"] >= u["storage_quota"]: + raise HTTPException(429, ( + f"Storage quota reached ({used['storage'] // 2**30} GB of " + f"{u['storage_quota'] // 2**30} GB).")) + + @app.get("/api/me") async def me(request: Request): - who = session_user(request) - if not who: - return {"user": None, "auth": AUTH_MODE} - with db() as c: - u = c.execute("SELECT username,role,display,avatar,quota FROM users " - "WHERE username=?", (who,)).fetchone() - return {"user": who, "auth": AUTH_MODE, **(dict(u) if u else {"role": "user"})} + u = current_user(request) + if not u: + return {"user": None, "auth": AUTH_MODE, + "role": "admin" if AUTH_MODE == "off" else None, + "providers": ENABLED_PROVIDERS} + return { + "user": u["username"], "auth": AUTH_MODE, "role": u["role"], + "display": u["display"], "avatar": u["avatar"], "provider": u["provider"], + "quota": u["quota"], "storage_quota": u["storage_quota"], + "usage": usage_for(u["username"]), + "providers": ENABLED_PROVIDERS, + } class RequestBody(BaseModel): @@ -1080,6 +1333,7 @@ async def enrich(deezer_id) -> dict: @app.post("/api/request") async def request_track(body: RequestBody, request: Request): + check_quota(request) e = await enrich(body.deezer_id) return {"id": await enqueue_job( e.get("artist") or body.artist, body.title, @@ -1102,6 +1356,10 @@ async def request_batch(body: BatchBody, request: Request): album, year and track position for every one of them. Re-fetching would be nb_tracks extra calls to learn what we were just told. """ + # Checked against the whole batch, not one track: queueing a 30-track album + # when 5 requests remain should be refused up front, not accepted and then + # blocked halfway through. + check_quota(request, adding=len(body.tracks)) who = session_user(request) ids = [await enqueue_job(t.artist, t.title, t.album, t.year, t.track, t.deezer_id, requested_by=who, cover=t.cover, @@ -1155,6 +1413,10 @@ async def import_list(body: ImportBody, request: Request): m = DEEZER_URL.search(line) lines.extend(await expand_url(*m.groups()) if m else [line]) + # After expansion — a single playlist URL can be 200 tracks, and the point + # of the check is the real number, not the number of lines pasted. + check_quota(request, adding=len(lines)) + for line in lines: # Search the line as typed; the ranker already weights an artist-name # match heavily, so "Artist - Title" needs no special handling. @@ -1185,31 +1447,331 @@ async def import_list(body: ImportBody, request: Request): @app.get("/api/jobs") -async def jobs(limit: int = 200): +async def jobs(request: Request, limit: int = 200, mine: bool = False): + """Own requests by default; admins see everyone's. + + Scoped rather than global because on a shared instance one person's + request history is a list of what they listen to, which is nobody else's + business by default. + """ + u = current_user(request) + everyone = (AUTH_MODE == "off") or (u is not None and u["role"] == "admin" and not mine) + who = None if everyone else (u["username"] if u else None) + + where, args = ("", []) if everyone else ("WHERE requested_by IS ?", [who]) with db() as c: rows = c.execute( - "SELECT * FROM jobs ORDER BY created_at DESC, rowid DESC LIMIT ?", - (limit,)).fetchall() + f"SELECT * FROM jobs {where} ORDER BY created_at DESC, rowid DESC LIMIT ?", + [*args, limit]).fetchall() counts = {r["state"]: r["n"] for r in c.execute( - "SELECT state, COUNT(*) n FROM jobs GROUP BY state").fetchall()} + f"SELECT state, COUNT(*) n FROM jobs {where} GROUP BY state", + args).fetchall()} return {"jobs": [dict(r) for r in rows], "counts": counts, - "pending": work_q.qsize()} + "pending": work_q.qsize(), "scope": "all" if everyone else "mine"} @app.post("/api/retry/{job_id}") -async def retry(job_id: str): +async def retry(job_id: str, request: Request): """Put a failed job back on the queue. Soulseek failures are usually the peer going offline, not the request being wrong, so retrying later often just works.""" + u = current_user(request) with db() as c: - row = c.execute("SELECT id FROM jobs WHERE id=?", (job_id,)).fetchone() + row = c.execute("SELECT id, requested_by FROM jobs WHERE id=?", + (job_id,)).fetchone() if not row: raise HTTPException(404, "no such job") + # Same 404 rather than 403 for someone else's job: a distinct error would + # confirm the id exists, which is a small oracle over other people's + # requests. + if AUTH_MODE != "off" and u and u["role"] != "admin" \ + and row["requested_by"] != u["username"]: + raise HTTPException(404, "no such job") set_state(job_id, "queued", "") await work_q.put(job_id) return {"ok": True} +# ─────────────────────────── admin ─────────────────────────── + +class NewUser(BaseModel): + username: str + provider: str = "github" + role: str = "user" + quota: int = 0 + storage_quota_gb: float = 0 + + +class PatchUser(BaseModel): + role: str | None = None + quota: int | None = None + storage_quota_gb: float | None = None + disabled: bool | None = None + + +def user_dict(r: sqlite3.Row) -> dict: + u = usage_for(r["username"]) + return { + "username": r["username"], "provider": r["provider"], "role": r["role"], + "display": r["display"], "avatar": r["avatar"], + "quota": r["quota"], "storage_quota": r["storage_quota"], + "disabled": bool(r["disabled"]), "created_at": r["created_at"], + "last_seen": r["last_seen"], "invited_by": r["invited_by"], + "usage": u, + # Surfaced so the UI doesn't have to know the allowlist rule: these are + # the accounts that cannot actually be locked out by disabling them. + "bootstrap": r["username"].lower() in ALLOWED_USERS, + } + + +@app.get("/api/admin/users") +async def admin_users(request: Request): + require_admin(request) + with db() as c: + rows = c.execute("SELECT * FROM users ORDER BY role='admin' DESC, username").fetchall() + return {"users": [user_dict(r) for r in rows], "roles": list(ROLES)} + + +@app.post("/api/admin/users") +async def admin_add_user(body: NewUser, request: Request): + """Invite someone. There is no email or token — the row IS the invitation: + once it exists, that identity can sign in with the matching provider.""" + who = require_admin(request) + name = body.username.strip() + if not name: + raise HTTPException(400, "username required") + if body.role not in ROLES: + raise HTTPException(400, f"role must be one of {', '.join(ROLES)}") + if body.provider not in PROVIDERS: + raise HTTPException(400, f"provider must be one of {', '.join(PROVIDERS)}") + with db() as c: + if c.execute("SELECT 1 FROM users WHERE username=?", (name,)).fetchone(): + raise HTTPException(409, "that user already exists") + c.execute( + "INSERT INTO users (username,provider,role,quota,storage_quota,invited_by) " + "VALUES (?,?,?,?,?,?)", + (name, body.provider, body.role, max(0, body.quota), + int(max(0.0, body.storage_quota_gb) * 2**30), who)) + r = c.execute("SELECT * FROM users WHERE username=?", (name,)).fetchone() + log.info("admin: %s invited %s as %s", who, name, body.role) + return user_dict(r) + + +@app.patch("/api/admin/users/{username}") +async def admin_patch_user(username: str, body: PatchUser, request: Request): + who = require_admin(request) + with db() as c: + r = c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() + if not r: + raise HTTPException(404, "no such user") + + # An install with no admin cannot be administered, and nothing in the + # UI can undo it. Refuse to remove the last one — whether by demotion + # or by disabling them. + losing_admin = r["role"] == "admin" and ( + (body.role is not None and body.role != "admin") or body.disabled is True) + if losing_admin: + others = c.execute( + "SELECT COUNT(*) n FROM users " + "WHERE role='admin' AND disabled=0 AND username<>?", + (username,)).fetchone()["n"] + if others == 0: + raise HTTPException( + 409, "this is the only active admin — promote someone else first") + + sets, vals = [], [] + if body.role is not None: + if body.role not in ROLES: + raise HTTPException(400, f"role must be one of {', '.join(ROLES)}") + sets.append("role=?"); vals.append(body.role) + if body.quota is not None: + sets.append("quota=?"); vals.append(max(0, body.quota)) + if body.storage_quota_gb is not None: + sets.append("storage_quota=?") + vals.append(int(max(0.0, body.storage_quota_gb) * 2**30)) + if body.disabled is not None: + sets.append("disabled=?"); vals.append(1 if body.disabled else 0) + if not sets: + raise HTTPException(400, "nothing to change") + + c.execute(f"UPDATE users SET {', '.join(sets)} WHERE username=?", + [*vals, username]) + # Disabling must take effect now. session_user re-checks on every + # request anyway, but dropping the rows makes it unambiguous. + if body.disabled: + c.execute("DELETE FROM sessions WHERE username=?", (username,)) + r = c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() + log.info("admin: %s updated %s (%s)", who, username, ", ".join(sets)) + return user_dict(r) + + +@app.delete("/api/admin/users/{username}") +async def admin_delete_user(username: str, request: Request): + who = require_admin(request) + with db() as c: + r = c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() + if not r: + raise HTTPException(404, "no such user") + if r["role"] == "admin": + others = c.execute( + "SELECT COUNT(*) n FROM users " + "WHERE role='admin' AND disabled=0 AND username<>?", + (username,)).fetchone()["n"] + if others == 0: + raise HTTPException(409, "this is the only active admin") + c.execute("DELETE FROM sessions WHERE username=?", (username,)) + c.execute("DELETE FROM users WHERE username=?", (username,)) + # Their jobs are deliberately kept: the history is a record of what is in + # the library and why, and deleting a person should not rewrite it. + log.info("admin: %s deleted user %s (jobs retained)", who, username) + return {"ok": True, "jobs_retained": True} + + +@app.get("/api/admin/activity") +async def admin_activity(request: Request, days: int = 30): + require_admin(request) + days = max(1, min(days, 365)) + since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") + with db() as c: + daily = c.execute( + "SELECT substr(created_at,1,10) d, COUNT(*) n, " + " SUM(state='imported') imported, SUM(state='failed') failed " + "FROM jobs WHERE created_at >= ? GROUP BY d ORDER BY d", (since,)).fetchall() + per_user = c.execute( + "SELECT COALESCE(requested_by,'(unattributed)') u, COUNT(*) n, " + " COALESCE(SUM(CASE WHEN state='imported' THEN size END),0) bytes " + "FROM jobs WHERE created_at >= ? GROUP BY u ORDER BY n DESC", + (since,)).fetchall() + top = c.execute( + "SELECT artist, COUNT(*) n FROM jobs WHERE created_at >= ? " + "GROUP BY artist ORDER BY n DESC LIMIT 8", (since,)).fetchall() + + # Zero-fill: a bar chart with gaps for quiet days misreads as missing data + # rather than as nothing having happened. + by_day = {r["d"]: r for r in daily} + today = datetime.now(timezone.utc).date() + series = [] + for i in range(days - 1, -1, -1): + d = (today - timedelta(days=i)).isoformat() + r = by_day.get(d) + series.append({"date": d, "n": r["n"] if r else 0, + "imported": (r["imported"] or 0) if r else 0, + "failed": (r["failed"] or 0) if r else 0}) + return {"days": days, "series": series, + "per_user": [dict(r) for r in per_user], + "top_artists": [dict(r) for r in top]} + + +def _proc_stat() -> tuple[int, int]: + """(busy, total) jiffies from /proc/stat.""" + with open("/proc/stat") as f: + parts = [int(x) for x in f.readline().split()[1:]] + idle = parts[3] + (parts[4] if len(parts) > 4 else 0) + return sum(parts) - idle, sum(parts) + + +_cpu_prev: tuple[int, int] | None = None + + +def system_stats() -> dict: + """Host CPU/RAM plus disk for the paths that matter. + + Reads /proc directly rather than adding psutil: it is ~30 lines, keeps the + image free of a compiled dependency, and /proc in a container reports the + HOST's CPU and memory — which is what you actually want to see for a + homelab box. Everything is individually guarded so a non-Linux dev machine + degrades to nulls instead of 500ing the whole panel. + """ + global _cpu_prev + out: dict = {} + + try: + busy, total = _proc_stat() + if _cpu_prev: + db_, dt = busy - _cpu_prev[0], total - _cpu_prev[1] + out["cpu_pct"] = round(100 * db_ / dt, 1) if dt > 0 else None + else: + out["cpu_pct"] = None # first call has no baseline to diff + _cpu_prev = (busy, total) + except Exception: + out["cpu_pct"] = None + + try: + mem = {} + with open("/proc/meminfo") as f: + for line in f: + k, _, v = line.partition(":") + mem[k] = int(v.split()[0]) * 1024 + total_b = mem.get("MemTotal", 0) + avail = mem.get("MemAvailable", 0) + out["mem_total"] = total_b + out["mem_used"] = total_b - avail + out["mem_pct"] = round(100 * (total_b - avail) / total_b, 1) if total_b else None + except Exception: + out["mem_total"] = out["mem_used"] = out["mem_pct"] = None + + try: + out["load"] = [round(x, 2) for x in os.getloadavg()] + out["cpus"] = os.cpu_count() + except Exception: + out["load"] = None + + disks = {} + for label, p in (("library", LIBRARY_DIR), ("downloads", DOWNLOAD_DIR)): + try: + s = os.statvfs(p) + total_b = s.f_blocks * s.f_frsize + free = s.f_bavail * s.f_frsize + disks[label] = {"total": total_b, "used": total_b - free, "free": free, + "pct": round(100 * (total_b - free) / total_b, 1) if total_b else None} + except Exception: + disks[label] = None + out["disks"] = disks + return out + + +@app.get("/api/admin/system") +async def admin_system(request: Request): + require_admin(request) + out = {"system": system_stats(), "workers": WORKERS, "pending": work_q.qsize()} + + with db() as c: + out["jobs"] = {r["state"]: r["n"] for r in c.execute( + "SELECT state, COUNT(*) n FROM jobs GROUP BY state").fetchall()} + out["library"] = { + "tracks": c.execute( + "SELECT COUNT(*) n FROM jobs WHERE state='imported'").fetchone()["n"], + "bytes": c.execute( + "SELECT COALESCE(SUM(size),0) b FROM jobs " + "WHERE state='imported'").fetchone()["b"], + } + out["users"] = c.execute("SELECT COUNT(*) n FROM users").fetchone()["n"] + out["sessions"] = c.execute( + "SELECT COUNT(*) n FROM sessions WHERE expires_at > ?", + (datetime.now(timezone.utc).isoformat(),)).fetchone()["n"] + + slskd: dict = {"ok": False} + try: + async with slskd_client() as sc: + r = await sc.get("/api/v0/application") + slskd["ok"] = r.status_code == 200 + if r.status_code == 200: + d = r.json() + srv = d.get("server") or {} + slskd["connected"] = bool(srv.get("isConnected")) + slskd["logged_in"] = bool(srv.get("isLoggedIn")) + slskd["version"] = d.get("version") + shares = d.get("shares") or {} + slskd["shared_files"] = (shares.get("directories") or {}).get("fileCount") \ + or shares.get("fileCount") + else: + slskd["error"] = f"HTTP {r.status_code}" + except Exception as e: + slskd["error"] = str(e)[:120] + out["slskd"] = slskd + return out + + @app.get("/api/health") async def health(): ok, detail = True, {} diff --git a/web/app/page.tsx b/web/app/page.tsx index 5ad7158..52e8b5a 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { AnimatePresence, motion } from "framer-motion"; +import { motion } from "framer-motion"; import { ArrowLeft, Disc3, Music2 } from "lucide-react"; import { api, type Me, type Track } from "@/lib/api"; @@ -18,8 +18,9 @@ import { Requests, type JobsSnapshot } from "@/components/requests"; import { Skeletons } from "@/components/skeletons"; import { PlayerProvider } from "@/components/player"; import { ToastProvider } from "@/components/toast"; +import { AdminPanel } from "@/components/admin/admin-panel"; -type Tab = "search" | "jobs" | "bulk"; +type Tab = "search" | "jobs" | "bulk" | "admin"; type View = | { k: "welcome" } | { k: "results"; q: string } @@ -97,10 +98,17 @@ function Muse() { } catch { /* a 401 here just means the poll outlived the session */ } + // Refetched alongside jobs rather than once at mount so the quota chip + // counts down as you queue, and so a role change or a disable lands within + // one poll instead of at next sign-in. + try { + setMe(await api.me()); + } catch { + /* ignore */ + } }, []); React.useEffect(() => { - api.me().then(setMe).catch(() => undefined); void refresh(); const t = setInterval(refresh, 4000); return () => clearInterval(t); @@ -111,6 +119,12 @@ function Muse() { (snap?.counts.searching ?? 0) + (snap?.counts.downloading ?? 0); + const isAdmin = me?.role === "admin"; + // Only worth showing when a limit actually exists — a permanent "0 / 0" chip + // is noise on a single-user install, which is most of them. + const quotaLeft = + me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null; + return ( <> @@ -135,6 +149,21 @@ function Muse() { {me?.user && (
+ {quotaLeft !== null && ( + + {quotaLeft} left + + )} {me.avatar && ( /* eslint-disable-next-line @next/next/no-img-element */ -