Add multi-user: Google OAuth, roles, quotas, admin panel
Phase 2. The schema landed last commit; this makes it real. AUTH is now multi-provider. MUSE_AUTH takes a comma list, so github,google runs both. The callback URL is unchanged — /auth/callback for every provider, with the provider carried in the state COOKIE rather than the query string — so adding Google needs no edit to an already-registered GitHub app, and the callback can't be steered to a different provider by the redirect. With more than one enabled, /auth/login shows a chooser built from style attributes only, which the CSP already permits, so it needs no stylesheet and no extra hash. Google identities are the VERIFIED email. An unverified one is refused: anyone can put someone else's address on an account they control, and the allowlist is keyed on exactly that string. WHO MAY LOG IN is now two rules — the MUSE_ALLOWED_USERS bootstrap list, then a non-disabled users row. Inviting is adding a row; no email, no token, no expiry to manage. MUSE_ALLOWED_USERS stays mandatory only while the table is empty, so a working multi-user install isn't held hostage by an env var it has outgrown, but an empty database with no allowlist still refuses to start because nobody could ever get in. The rule is one function used at login AND on every request: checking only at sign-in isn't revocation, it's a delay. QUOTAS are requests per calendar month and bytes of imported audio, 0 = unlimited. Calendar month, not a rolling window — "12 left until the 1st" is something a person can plan around. Batches are checked against the whole batch, so a 30-track album with 5 requests left is refused up front rather than accepted and abandoned halfway. Storage is measured from the file actually written, not the size the peer advertised. Admins are exempt. ADMIN PANEL: host CPU/memory/disk read straight from /proc — no psutil, no compiled dependency, and in a container /proc reports the host, which is what you want for a homelab box. Plus library totals, slskd connection state, a 30-day chart, per-user and per-artist breakdowns, and inline role/quota editing. The chart is hand-rolled SVG: a charting library would add ~150KB for one stacked bar chart and most of them inject <style> at runtime, which this app's CSP blocks. Guard rails: the last active admin cannot be demoted, disabled or deleted; disabling revokes sessions immediately; deleting a user keeps their jobs, because the history records what is in the library and why. Requests are private by default — users see their own, admins see all — and retrying someone else's job returns 404 rather than 403, since a distinct error would confirm the id exists. Also drops AnimatePresence mode="wait" from the tab container. It gates the incoming panel on the outgoing one's exit animation reporting finished, so anything that stalls the frame loop leaves the UI stuck on the old tab with no way forward — found while testing, where a backgrounded page froze navigation entirely. A cosmetic transition should never gate navigation. Verified: admin endpoints 200/403/403/401 for admin/user/guest/anon; last-admin demote, disable and delete all 409; disable revokes mid-session; quota refuses the 3rd of 2 and a 30-track batch, then succeeds once raised; users see only their own jobs; cross-user retry 404s; bad role/provider/duplicate/empty patch all rejected; multi-provider chooser and both redirects correct; three CSRF variants 400; preflight refuses unknown providers, missing secrets, and an empty database with no allowlist. CPU, memory and disk confirmed populated inside the linux/amd64 container. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b4b68daaf
commit
f3c64d4fd0
8 files changed
+1595
-124
No files matched your search
@@ -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. <https://github.com/settings/developers> → **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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -200,17 +280,45 @@ def session_user(request: Request) -> str | None:
|
||||
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=?",
|
||||
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 = """<!doctype html><html lang=en><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1"><title>muse</title>
|
||||
</head><body style="margin:0;min-height:100vh;display:grid;place-items:center;
|
||||
background:#08090d;color:#f2f4f9;font:15px/1.5 -apple-system,BlinkMacSystemFont,
|
||||
ui-sans-serif,system-ui,sans-serif">
|
||||
<div style="text-align:center;padding:32px">
|
||||
<div style="font-size:26px;font-weight:700;letter-spacing:-.5px">muse</div>
|
||||
<div style="color:#9aa3b6;font-size:13px;margin:6px 0 26px">Sign in to continue</div>
|
||||
{buttons}</div></body></html>"""
|
||||
|
||||
_BTN = ('<a href="/auth/login?provider={p}" style="display:block;margin:10px auto;'
|
||||
'padding:12px 26px;min-width:230px;border-radius:12px;text-decoration:none;'
|
||||
'background:rgb(255 255 255/.06);border:1px solid rgb(255 255 255/.14);'
|
||||
'color:#f2f4f9;font-weight:600">Continue with {label}</a>')
|
||||
|
||||
|
||||
@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)
|
||||
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, {}
|
||||
|
||||
+48
-32
@@ -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 (
|
||||
<>
|
||||
<Aurora />
|
||||
@@ -135,6 +149,21 @@ function Muse() {
|
||||
</div>
|
||||
{me?.user && (
|
||||
<div className="flex items-center gap-2 text-[12.5px] text-dim">
|
||||
{quotaLeft !== null && (
|
||||
<span
|
||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||
className={cn(
|
||||
"hidden rounded-full border px-2 py-0.5 text-[11px] sm:inline",
|
||||
quotaLeft === 0
|
||||
? "border-err/50 text-err"
|
||||
: quotaLeft <= 5
|
||||
? "border-warn/50 text-warn"
|
||||
: "border-edge text-dim",
|
||||
)}
|
||||
>
|
||||
{quotaLeft} left
|
||||
</span>
|
||||
)}
|
||||
{me.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
@@ -166,12 +195,13 @@ function Muse() {
|
||||
|
||||
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||
|
||||
<nav className="flex pt-3">
|
||||
<nav className="flex overflow-x-auto pt-3 no-scrollbar">
|
||||
{(
|
||||
[
|
||||
["search", "Search"],
|
||||
["jobs", "Requests"],
|
||||
["bulk", "Import list"],
|
||||
...(isAdmin ? ([["admin", "Admin"]] as [Tab, string][]) : []),
|
||||
] as [Tab, string][]
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
@@ -206,15 +236,21 @@ function Muse() {
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
|
||||
<AnimatePresence mode="wait">
|
||||
{tab === "search" && (
|
||||
{/* Keyed on `tab`, so React remounts and Framer replays the entrance.
|
||||
Deliberately NOT wrapped in AnimatePresence mode="wait": that holds
|
||||
the incoming panel until the outgoing one's exit animation reports
|
||||
finished, which makes navigation depend on an animation completing.
|
||||
Anything that stalls the frame loop — a backgrounded tab, a browser
|
||||
that throttles rAF — then leaves the UI stuck on the old panel with
|
||||
no way forward. A cosmetic transition should never gate navigation. */}
|
||||
<motion.div
|
||||
key="search"
|
||||
key={tab}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{tab === "search" && (
|
||||
<>
|
||||
{history.length > 0 && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
@@ -257,33 +293,13 @@ function Muse() {
|
||||
{view.k === "artist" && (
|
||||
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "jobs" && (
|
||||
<motion.div
|
||||
key="jobs"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
<Requests snap={snap} refresh={refresh} />
|
||||
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
||||
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{tab === "bulk" && (
|
||||
<motion.div
|
||||
key="bulk"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
<BulkImport onQueued={refresh} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { Activity } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Hand-rolled SVG rather than a charting library.
|
||||
*
|
||||
* Recharts/Chart.js would add ~150KB to a page that needs one stacked bar
|
||||
* chart, and most of them inject <style> at runtime, which this app's CSP
|
||||
* blocks outright. An SVG with two rects per day is a few dozen lines and has
|
||||
* neither problem.
|
||||
*/
|
||||
export function ActivityChart({ data }: { data: Activity }) {
|
||||
const [hover, setHover] = React.useState<number | null>(null);
|
||||
|
||||
const max = Math.max(1, ...data.series.map((d) => d.n));
|
||||
const W = 100; // viewBox units — the SVG scales to its container
|
||||
const H = 32;
|
||||
const gap = 0.18;
|
||||
const bw = W / data.series.length;
|
||||
|
||||
const shown = hover != null ? data.series[hover] : null;
|
||||
const totals = data.series.reduce(
|
||||
(a, d) => ({
|
||||
n: a.n + d.n,
|
||||
imported: a.imported + d.imported,
|
||||
failed: a.failed + d.failed,
|
||||
}),
|
||||
{ n: 0, imported: 0, failed: 0 },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim">
|
||||
Requests · last {data.days} days
|
||||
</h3>
|
||||
<div className="mt-1 text-[22px] font-bold tracking-[-0.6px]">
|
||||
{shown ? shown.n : totals.n}
|
||||
<span className="ml-2 text-[13px] font-normal text-dim">
|
||||
{shown
|
||||
? new Date(shown.date + "T00:00:00").toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "total"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 text-[11.5px] text-dim">
|
||||
<Legend className="bg-accent" label={`${shown ? shown.imported : totals.imported} imported`} />
|
||||
<Legend className="bg-err" label={`${shown ? shown.failed : totals.failed} failed`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-28 w-full overflow-visible"
|
||||
onMouseLeave={() => setHover(null)}
|
||||
role="img"
|
||||
aria-label={`Daily request volume over the last ${data.days} days`}
|
||||
>
|
||||
{data.series.map((d, i) => {
|
||||
const h = (d.n / max) * H;
|
||||
const impH = d.n ? (d.imported / d.n) * h : 0;
|
||||
const x = i * bw;
|
||||
const on = hover === i;
|
||||
return (
|
||||
<g key={d.date} onMouseEnter={() => setHover(i)}>
|
||||
{/* Full-height hit area — 3px bars are impossible to hover. */}
|
||||
<rect x={x} y={0} width={bw} height={H} fill="transparent" />
|
||||
<motion.rect
|
||||
initial={{ height: 0, y: H }}
|
||||
animate={{ height: Math.max(h, d.n ? 0.6 : 0), y: H - Math.max(h, d.n ? 0.6 : 0) }}
|
||||
transition={{ delay: Math.min(i * 0.008, 0.3), duration: 0.45, ease: "easeOut" }}
|
||||
x={x + bw * gap}
|
||||
width={bw * (1 - gap * 2)}
|
||||
rx={0.5}
|
||||
className="fill-err"
|
||||
opacity={on ? 1 : 0.75}
|
||||
/>
|
||||
<motion.rect
|
||||
initial={{ height: 0, y: H }}
|
||||
animate={{ height: impH, y: H - impH }}
|
||||
transition={{ delay: Math.min(i * 0.008, 0.3), duration: 0.45, ease: "easeOut" }}
|
||||
x={x + bw * gap}
|
||||
width={bw * (1 - gap * 2)}
|
||||
rx={0.5}
|
||||
className="fill-accent"
|
||||
opacity={on ? 1 : 0.85}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div className="mt-1.5 flex justify-between text-[11px] text-dim">
|
||||
<span>{fmt(data.series[0]?.date)}</span>
|
||||
<span>{fmt(data.series[data.series.length - 1]?.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ className, label }: { className: string; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<i className={`inline-block h-2 w-2 rounded-sm ${className}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const fmt = (d?: string) =>
|
||||
d
|
||||
? new Date(d + "T00:00:00").toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "";
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
import { api, bytes, type Activity, type AdminUser, type SystemStats } from "@/lib/api";
|
||||
import { StatCard } from "@/components/admin/stat-card";
|
||||
import { ActivityChart } from "@/components/admin/activity-chart";
|
||||
import { UsersTable } from "@/components/admin/users-table";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AdminPanel({ me }: { me: string | null }) {
|
||||
const [sys, setSys] = React.useState<SystemStats | null>(null);
|
||||
const [act, setAct] = React.useState<Activity | null>(null);
|
||||
const [users, setUsers] = React.useState<{ users: AdminUser[]; roles: string[] } | null>(null);
|
||||
const [err, setErr] = React.useState<string | null>(null);
|
||||
|
||||
const loadUsers = React.useCallback(() => {
|
||||
api.admin.users().then(setUsers).catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadUsers();
|
||||
api.admin.activity(30).then(setAct).catch(() => undefined);
|
||||
const poll = () => api.admin.system().then(setSys).catch(() => undefined);
|
||||
poll();
|
||||
// 5s: CPU is a delta between samples, so the first reading is always null
|
||||
// and a slow poll would leave it null for an uncomfortably long time.
|
||||
const t = setInterval(poll, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [loadUsers]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||
if (!sys || !users) return <Skeletons n={6} />;
|
||||
|
||||
const s = sys.system;
|
||||
const lib = s.disks.library;
|
||||
const active = (sys.jobs.queued ?? 0) + (sys.jobs.searching ?? 0) + (sys.jobs.downloading ?? 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SlskdBanner slskd={sys.slskd} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<StatCard
|
||||
index={0}
|
||||
label="CPU"
|
||||
value={s.cpu_pct == null ? "—" : `${s.cpu_pct}%`}
|
||||
sub={s.load ? `load ${s.load.join(" · ")}${s.cpus ? ` on ${s.cpus}` : ""}` : undefined}
|
||||
pct={s.cpu_pct}
|
||||
/>
|
||||
<StatCard
|
||||
index={1}
|
||||
label="Memory"
|
||||
value={s.mem_pct == null ? "—" : `${s.mem_pct}%`}
|
||||
sub={s.mem_total ? `${bytes(s.mem_used)} of ${bytes(s.mem_total)}` : undefined}
|
||||
pct={s.mem_pct}
|
||||
/>
|
||||
<StatCard
|
||||
index={2}
|
||||
label="Library disk"
|
||||
value={lib ? `${lib.pct}%` : "—"}
|
||||
sub={lib ? `${bytes(lib.free)} free` : undefined}
|
||||
pct={lib?.pct ?? null}
|
||||
/>
|
||||
<StatCard
|
||||
index={3}
|
||||
label="Library"
|
||||
value={sys.library.tracks.toLocaleString()}
|
||||
sub={`${bytes(sys.library.bytes)} imported`}
|
||||
/>
|
||||
<StatCard index={4} label="In flight" value={active} sub={`${sys.pending} waiting · ${sys.workers} workers`} />
|
||||
<StatCard index={5} label="Failed" value={sys.jobs.failed ?? 0} sub="all time" />
|
||||
<StatCard index={6} label="Users" value={sys.users} sub={`${sys.sessions} active sessions`} />
|
||||
<StatCard
|
||||
index={7}
|
||||
label="Shared files"
|
||||
value={sys.slskd.shared_files?.toLocaleString() ?? "—"}
|
||||
sub="advertised to Soulseek"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{act && <ActivityChart data={act} />}
|
||||
|
||||
{act && act.per_user.length > 0 && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Breakdown
|
||||
title="By user"
|
||||
rows={act.per_user.map((r) => ({
|
||||
label: r.u,
|
||||
n: r.n,
|
||||
sub: r.bytes ? bytes(r.bytes) : "",
|
||||
}))}
|
||||
/>
|
||||
<Breakdown
|
||||
title="Most requested artists"
|
||||
rows={act.top_artists.map((r) => ({ label: r.artist, n: r.n, sub: "" }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UsersTable users={users.users} roles={users.roles} me={me} refresh={loadUsers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
|
||||
const healthy = slskd.ok && slskd.connected && slskd.logged_in;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-[13px]",
|
||||
healthy ? "border-ok/30" : "border-err/40",
|
||||
)}
|
||||
>
|
||||
{healthy ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-ok" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-err" />
|
||||
)}
|
||||
<span className={healthy ? "text-dim" : "text-err"}>
|
||||
{healthy
|
||||
? `slskd connected and logged in${slskd.version ? ` · v${slskd.version}` : ""}`
|
||||
: slskd.error
|
||||
? `slskd unreachable — ${slskd.error}`
|
||||
: `slskd reachable but not logged in to Soulseek — nothing will download`}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({
|
||||
title,
|
||||
rows,
|
||||
}: {
|
||||
title: string;
|
||||
rows: { label: string; n: number; sub: string }[];
|
||||
}) {
|
||||
const max = Math.max(1, ...rows.map((r) => r.n));
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<h3 className="mb-3 text-[13px] font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.slice(0, 8).map((r, i) => (
|
||||
<motion.div
|
||||
key={r.label}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<span className="w-32 shrink-0 text-[13px] clamp1">{r.label}</span>
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-faint">
|
||||
<motion.i
|
||||
className="block h-full rounded-full bg-accent"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(r.n / max) * 100}%` }}
|
||||
transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 shrink-0 text-right text-[12.5px] tabular-nums text-dim">
|
||||
{r.n}
|
||||
</span>
|
||||
{r.sub && (
|
||||
<span className="hidden w-16 shrink-0 text-right text-[11.5px] text-dim sm:block">
|
||||
{r.sub}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A metric with an optional usage bar. Bar colour escalates with pressure —
|
||||
* a disk at 94% should not look the same as one at 12%. */
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
pct,
|
||||
index = 0,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
sub?: React.ReactNode;
|
||||
pct?: number | null;
|
||||
index?: number;
|
||||
}) {
|
||||
const tone =
|
||||
pct == null ? "bg-accent" : pct >= 90 ? "bg-err" : pct >= 75 ? "bg-warn" : "bg-accent";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
||||
className="glass rounded-glass p-3.5"
|
||||
>
|
||||
<div className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</div>
|
||||
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="mt-0.5 text-[11.5px] text-dim clamp1">{sub}</div>}
|
||||
{pct != null && (
|
||||
<div className="mt-2 h-1 overflow-hidden rounded-full bg-faint">
|
||||
<motion.i
|
||||
className={cn("block h-full rounded-full", tone)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Ban, Check, Plus, ShieldCheck, Trash2, Undo2, X } from "lucide-react";
|
||||
import { api, bytes, type AdminUser } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function UsersTable({
|
||||
users,
|
||||
roles,
|
||||
me,
|
||||
refresh,
|
||||
}: {
|
||||
users: AdminUser[];
|
||||
roles: string[];
|
||||
me: string | null;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim">
|
||||
Users
|
||||
</h3>
|
||||
<p className="mt-1 text-[12.5px] text-dim">
|
||||
Adding someone here <em>is</em> the invitation — no email, no token. They sign in
|
||||
with that provider and they’re in.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
|
||||
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{adding ? "Cancel" : "Invite"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{adding && (
|
||||
<InviteForm
|
||||
roles={roles}
|
||||
onDone={() => {
|
||||
setAdding(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<AnimatePresence initial={false}>
|
||||
{users.map((u) => (
|
||||
<UserRow key={u.username} u={u} roles={roles} isMe={u.username === me} refresh={refresh} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
u,
|
||||
roles,
|
||||
isMe,
|
||||
refresh,
|
||||
}: {
|
||||
u: AdminUser;
|
||||
roles: string[];
|
||||
isMe: boolean;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [confirmDel, setConfirmDel] = React.useState(false);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, ok: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
toast(ok);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const quotaPct = u.quota ? (u.usage.requests / u.quota) * 100 : null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
||||
className={cn(
|
||||
"glass-hi rounded-glass p-3",
|
||||
u.disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="h-9 w-9 shrink-0 overflow-hidden rounded-full bg-faint">
|
||||
{u.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={u.avatar}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold clamp1">
|
||||
{u.display || u.username}
|
||||
{u.role === "admin" && <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-accent" />}
|
||||
{isMe && <span className="text-[11px] font-normal text-dim">(you)</span>}
|
||||
</div>
|
||||
<div className="text-[12px] text-dim clamp1">
|
||||
{u.username} · {u.provider}
|
||||
{u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`}
|
||||
{u.disabled && " · disabled"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={u.role}
|
||||
disabled={busy}
|
||||
onChange={(e) => run(() => api.admin.patchUser(u.username, { role: e.target.value }), `${u.username} is now ${e.target.value}`)}
|
||||
aria-label={`Role for ${u.username}`}
|
||||
className="shrink-0 cursor-pointer rounded-full border border-edge-hi bg-transparent
|
||||
px-2.5 py-1 text-[12px] text-ink outline-none
|
||||
focus:border-accent-line"
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r} value={r} className="bg-[#12141c]">
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => api.admin.patchUser(u.username, { disabled: !u.disabled }),
|
||||
u.disabled ? `${u.username} re-enabled` : `${u.username} disabled`,
|
||||
)
|
||||
}
|
||||
title={u.disabled ? "Re-enable" : "Disable and revoke sessions"}
|
||||
>
|
||||
{u.disabled ? <Undo2 className="h-3.5 w-3.5" /> : <Ban className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
|
||||
{confirmDel ? (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
className="border-err/50 text-err"
|
||||
onClick={() => run(() => api.admin.deleteUser(u.username), `${u.username} deleted`)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" /> Delete
|
||||
</Button>
|
||||
<Button size="sm" variant="quiet" onClick={() => setConfirmDel(false)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="quiet"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDel(true)}
|
||||
title="Delete user (their request history is kept)"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-[12px]">
|
||||
<QuotaField
|
||||
label="Requests / month"
|
||||
value={u.quota}
|
||||
suffix={`used ${u.usage.requests}`}
|
||||
pct={quotaPct}
|
||||
onSave={(v) => run(() => api.admin.patchUser(u.username, { quota: v }), "Quota updated")}
|
||||
/>
|
||||
<QuotaField
|
||||
label="Storage (GB)"
|
||||
value={+(u.storage_quota / 2 ** 30).toFixed(1)}
|
||||
suffix={`used ${bytes(u.usage.storage)}`}
|
||||
pct={u.storage_quota ? (u.usage.storage / u.storage_quota) * 100 : null}
|
||||
onSave={(v) =>
|
||||
run(() => api.admin.patchUser(u.username, { storage_quota_gb: v }), "Quota updated")
|
||||
}
|
||||
/>
|
||||
{u.bootstrap && (
|
||||
<span className="text-[11.5px] text-warn" title="Listed in MUSE_ALLOWED_USERS">
|
||||
bootstrap account — disabling won’t lock them out
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Number input that only commits on blur or Enter, so a quota isn't PATCHed
|
||||
* once per keystroke while you type "120". */
|
||||
function QuotaField({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
pct,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
suffix: string;
|
||||
pct: number | null;
|
||||
onSave: (v: number) => void;
|
||||
}) {
|
||||
const [v, setV] = React.useState(String(value));
|
||||
React.useEffect(() => setV(String(value)), [value]);
|
||||
|
||||
const commit = () => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
setV(String(value));
|
||||
return;
|
||||
}
|
||||
if (n !== value) onSave(n);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-dim">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="any"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
className="w-16 rounded-md border border-edge-hi bg-transparent px-1.5 py-0.5
|
||||
text-right text-ink outline-none focus:border-accent-line"
|
||||
/>
|
||||
<span className={cn("text-dim", pct != null && pct >= 90 && "text-err")}>
|
||||
{Number(v) === 0 ? "unlimited" : suffix}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) {
|
||||
const toast = useToast();
|
||||
const [username, setUsername] = React.useState("");
|
||||
const [provider, setProvider] = React.useState("github");
|
||||
const [role, setRole] = React.useState("user");
|
||||
const [quota, setQuota] = React.useState("0");
|
||||
const [storage, setStorage] = React.useState("0");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!username.trim()) {
|
||||
toast("Username required", true);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.admin.addUser({
|
||||
username: username.trim(),
|
||||
provider,
|
||||
role,
|
||||
quota: Number(quota) || 0,
|
||||
storage_quota_gb: Number(storage) || 0,
|
||||
});
|
||||
toast(`Invited ${username.trim()}`);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const input =
|
||||
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-[13px] " +
|
||||
"text-ink outline-none focus:border-accent-line";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
|
||||
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">
|
||||
{provider === "github" ? "GitHub username" : "Google email"}
|
||||
</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder={provider === "github" ? "octocat" : "friend@gmail.com"}
|
||||
className={input}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">Provider</span>
|
||||
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={cn(input, "cursor-pointer")}>
|
||||
<option value="github" className="bg-[#12141c]">GitHub</option>
|
||||
<option value="google" className="bg-[#12141c]">Google</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">Role</span>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} className={cn(input, "cursor-pointer")}>
|
||||
{roles.map((r) => (
|
||||
<option key={r} value={r} className="bg-[#12141c]">
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex w-24 flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">Req/month</span>
|
||||
<input type="number" min={0} value={quota} onChange={(e) => setQuota(e.target.value)} className={input} />
|
||||
</label>
|
||||
<label className="flex w-24 flex-col gap-1">
|
||||
<span className="text-[11.5px] text-dim">Storage GB</span>
|
||||
<input type="number" min={0} value={storage} onChange={(e) => setStorage(e.target.value)} className={input} />
|
||||
</label>
|
||||
<Button onClick={submit} disabled={busy}>
|
||||
{busy ? "Inviting…" : "Invite"}
|
||||
</Button>
|
||||
<span className="w-full text-[11.5px] text-dim">0 means unlimited.</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
+114
-4
@@ -92,13 +92,71 @@ export type Job = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Usage = { requests: number; storage: number };
|
||||
|
||||
export type Me = {
|
||||
user: string | null;
|
||||
auth: string;
|
||||
role?: string;
|
||||
role?: string | null;
|
||||
display?: string | null;
|
||||
avatar?: string | null;
|
||||
provider?: string;
|
||||
quota?: number;
|
||||
storage_quota?: number;
|
||||
usage?: Usage;
|
||||
providers?: string[];
|
||||
};
|
||||
|
||||
export type AdminUser = {
|
||||
username: string;
|
||||
provider: string;
|
||||
role: string;
|
||||
display: string | null;
|
||||
avatar: string | null;
|
||||
quota: number;
|
||||
storage_quota: number;
|
||||
disabled: boolean;
|
||||
created_at: string;
|
||||
last_seen: string | null;
|
||||
invited_by: string | null;
|
||||
usage: Usage;
|
||||
/** In MUSE_ALLOWED_USERS — cannot actually be locked out by disabling. */
|
||||
bootstrap: boolean;
|
||||
};
|
||||
|
||||
export type DiskStat = { total: number; used: number; free: number; pct: number | null };
|
||||
|
||||
export type SystemStats = {
|
||||
system: {
|
||||
cpu_pct: number | null;
|
||||
cpus: number | null;
|
||||
mem_total: number | null;
|
||||
mem_used: number | null;
|
||||
mem_pct: number | null;
|
||||
load: number[] | null;
|
||||
disks: Record<string, DiskStat | null>;
|
||||
};
|
||||
workers: number;
|
||||
pending: number;
|
||||
jobs: Record<string, number>;
|
||||
library: { tracks: number; bytes: number };
|
||||
users: number;
|
||||
sessions: number;
|
||||
slskd: {
|
||||
ok: boolean;
|
||||
connected?: boolean;
|
||||
logged_in?: boolean;
|
||||
version?: string;
|
||||
shared_files?: number | null;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Activity = {
|
||||
days: number;
|
||||
series: { date: string; n: number; imported: number; failed: number }[];
|
||||
per_user: { u: string; n: number; bytes: number }[];
|
||||
top_artists: { artist: string; n: number }[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -118,18 +176,33 @@ export class ApiError extends Error {
|
||||
async function get<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(path, { credentials: "same-origin", ...init });
|
||||
if (r.status === 401) throw new ApiError(401, "signed out");
|
||||
if (!r.ok) throw new ApiError(r.status, `HTTP ${r.status}`);
|
||||
if (!r.ok) {
|
||||
// FastAPI puts the useful part in `detail` — quota messages, "only active
|
||||
// admin", and so on. Surfacing "HTTP 429" instead would throw away the
|
||||
// only text that tells the user what to do about it.
|
||||
let msg = `HTTP ${r.status}`;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (typeof body?.detail === "string") msg = body.detail;
|
||||
else if (typeof body?.error === "string") msg = body.error;
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
throw new ApiError(r.status, msg);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
function send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
return get<T>(path, {
|
||||
method: "POST",
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
|
||||
|
||||
export const api = {
|
||||
me: () => get<Me>("/api/me"),
|
||||
search: (q: string) =>
|
||||
@@ -161,8 +234,45 @@ export const api = {
|
||||
skipped: { line: string; why: string }[];
|
||||
pending: number;
|
||||
}>("/api/import", { text }),
|
||||
|
||||
admin: {
|
||||
users: () => get<{ users: AdminUser[]; roles: string[] }>("/api/admin/users"),
|
||||
addUser: (u: {
|
||||
username: string;
|
||||
provider: string;
|
||||
role: string;
|
||||
quota: number;
|
||||
storage_quota_gb: number;
|
||||
}) => post<AdminUser>("/api/admin/users", u),
|
||||
patchUser: (
|
||||
username: string,
|
||||
patch: Partial<{
|
||||
role: string;
|
||||
quota: number;
|
||||
storage_quota_gb: number;
|
||||
disabled: boolean;
|
||||
}>,
|
||||
) => send<AdminUser>("PATCH", `/api/admin/users/${encodeURIComponent(username)}`, patch),
|
||||
deleteUser: (username: string) =>
|
||||
send<{ ok: boolean }>("DELETE", `/api/admin/users/${encodeURIComponent(username)}`),
|
||||
system: () => get<SystemStats>("/api/admin/system"),
|
||||
activity: (days = 30) => get<Activity>(`/api/admin/activity?days=${days}`),
|
||||
},
|
||||
};
|
||||
|
||||
/** Bytes → human. Binary units, because that's what df and the OS report. */
|
||||
export function bytes(n: number | null | undefined): string {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
|
||||
}
|
||||
|
||||
export const mmss = (ms: number | null | undefined) => {
|
||||
const t = Math.round((ms || 0) / 1000);
|
||||
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
|
||||
|
||||
Reference in new issue
Block a user