Compare commits

...

10 Commits

Author SHA1 Message Date
0d956adce5 chore: ignore local venv directory 2026-05-28 08:47:13 +00:00
1edb89884b ci: verify Docker image build on pull requests
All checks were successful
docker / test (pull_request) Successful in 37s
docker / build-and-push (pull_request) Has been skipped
docker / build-verify-pr (pull_request) Successful in 40s
2026-05-22 13:52:40 +00:00
0f106e3544 Add public read-only mode toggle
All checks were successful
docker / test (push) Successful in 15s
docker / build-and-push (push) Successful in 45s
2026-05-21 19:47:28 +00:00
a4b645bab6 Add duplicate link action with unique name prefill 2026-05-21 19:46:34 +00:00
87c610b8d7 Remove auth form reload and keep SPA navigation 2026-05-21 19:46:16 +00:00
58f7702074 Add drag-drop and keyboard link ordering 2026-05-21 19:45:51 +00:00
7cdf9b95f7 Replace admin alerts with inline errors and toasts
All checks were successful
docker / test (push) Successful in 37s
docker / build-and-push (push) Successful in 59s
2026-05-21 16:25:43 +00:00
Space-Banane
fd874c9499 admin: add backup/restore flow and structured request logging
All checks were successful
docker / test (push) Successful in 14s
docker / build-and-push (push) Successful in 1m36s
2026-05-20 22:44:02 +02:00
Space-Banane
791126cdd0 backend: close P1 data model and create-flow issues
All checks were successful
docker / test (push) Successful in 13s
docker / build-and-push (push) Successful in 1m23s
2026-05-20 22:40:32 +02:00
Space-Banane
643785ad1e backend: complete P0 session rotation hardening 2026-05-20 22:39:46 +02:00
9 changed files with 616 additions and 94 deletions

View File

@@ -6,6 +6,11 @@ on:
paths-ignore: paths-ignore:
- '**/*.md' - '**/*.md'
- '**/*.txt' - '**/*.txt'
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
- '**/*.txt'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -44,6 +49,7 @@ jobs:
build-and-push: build-and-push:
needs: test needs: test
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -134,3 +140,19 @@ jobs:
print(f"link failed: status={exc.code} body={body}") print(f"link failed: status={exc.code} body={body}")
raise raise
PY PY
build-verify-pr:
needs: test
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: false
build-args: |
VCS_REF=${{ github.sha }}
VCS_URL=${{ github.server_url }}/${{ github.repository }}

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ data
dist dist
*.log *.log
__pycache__ __pycache__
.venv/

View File

@@ -14,6 +14,8 @@ Dark dashboard for Arr* services and custom links.
- Icon blobs stored in the database - Icon blobs stored in the database
- Containerized app deployment (requires MariaDB) - Containerized app deployment (requires MariaDB)
- Admin-managed service links - Admin-managed service links
- Admin backup/export and restore with dry-run validation
- Structured JSON logs with request IDs (`x-request-id`)
## Local Dev ## Local Dev
@@ -59,6 +61,12 @@ The app expects a MariaDB instance configured through environment variables.
- `USERNAME_MAX_LEN` (default: `64`) - `USERNAME_MAX_LEN` (default: `64`)
- `PASSWORD_MIN_LEN` (default: `12`) - `PASSWORD_MIN_LEN` (default: `12`)
### Backup / Restore API
- `GET /api/admin/backup` exports users and links as JSON
- `POST /api/admin/restore?dry_run=true` validates a backup payload without applying
- `POST /api/admin/restore?dry_run=false` applies restore when body includes `"confirm": true`
## Gitea CI/CD ## Gitea CI/CD
Add these secrets in Gitea: Add these secrets in Gitea:

62
TODO.md
View File

@@ -4,10 +4,10 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
## P0 - Security and Reliability ## P0 - Security and Reliability
- [ ] Add session expiry and rotation. - [x] Add session expiry and rotation.
- [x] Add `expires_at` and `last_seen_at` to `sessions`. - [x] Add `expires_at` and `last_seen_at` to `sessions`.
- [x] Reject expired tokens in `current_user`. - [x] Reject expired tokens in `current_user`.
- [ ] Rotate session token on login and periodically on use. - [x] Rotate session token on login and periodically on use.
- [x] Harden auth endpoints. - [x] Harden auth endpoints.
- [x] Add login rate limiting by IP + username pair. - [x] Add login rate limiting by IP + username pair.
- [x] Add brute-force lockout window with clear error message. - [x] Add brute-force lockout window with clear error message.
@@ -26,37 +26,37 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
## P1 - Data Model and Backend Quality ## P1 - Data Model and Backend Quality
- Replace string timestamps with DB-native datetime. - [x] Replace string timestamps with DB-native datetime.
- Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`. - [x] Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`.
- Use UTC consistently for writes and reads. - [x] Use UTC consistently for writes and reads.
- Add display ordering support. - [x] Add display ordering support.
- Add `sort_order` column and stable ordering fallback by `name`. - [x] Add `sort_order` column and stable ordering fallback by `name`.
- Update read query to order by `enabled desc`, `sort_order`, `name`. - [x] Update read query to order by `enabled desc`, `sort_order`, `name`.
- Remove duplicate connection pattern in create flow. - [x] Remove duplicate connection pattern in create flow.
- Use one DB transaction/connection per request path where possible. - [x] Use one DB transaction/connection per request path where possible.
- Add backup and restore flow in admin API/UI. - [x] Add backup and restore flow in admin API/UI.
- Download full export. - [x] Download full export.
- Upload validated import with explicit confirmation. - [x] Upload validated import with explicit confirmation.
- Add dry-run validation mode before apply. - [x] Add dry-run validation mode before apply.
- Add structured logging. - [x] Add structured logging.
- Log auth attempts, CRUD actions, and restore events with request IDs. - [x] Log auth attempts, CRUD actions, and restore events with request IDs.
## P2 - UX and Product Improvements ## P2 - UX and Product Improvements
- Replace browser `alert()` with inline form errors/toasts. - [x] Replace browser `alert()` with inline form errors/toasts.
- Show server errors near submit controls. - [x] Show server errors near submit controls.
- Add success toasts for create/update/delete. - [x] Add success toasts for create/update/delete.
- Remove forced reload in auth forms. - [x] Remove forced reload in auth forms.
- Replace `location.reload()` with state refresh only. - [x] Replace `location.reload()` with state refresh only.
- Keep SPA navigation predictable on setup/login/logout. - [x] Keep SPA navigation predictable on setup/login/logout.
- Add drag-and-drop ordering in admin. - [x] Add drag-and-drop ordering in admin.
- Persist `sort_order` updates. - [x] Persist `sort_order` updates.
- Provide keyboard-accessible move controls as fallback. - [x] Provide keyboard-accessible move controls as fallback.
- Add duplicate/cloning for links. - [x] Add duplicate/cloning for links.
- Pre-fill form from an existing link. - [x] Pre-fill form from an existing link.
- Save as new record with unique name validation. - [x] Save as new record with unique name validation.
- Add public read-only mode toggle. - [x] Add public read-only mode toggle.
- Hide admin entry points and editing affordances for non-admin view. - [x] Hide admin entry points and editing affordances for non-admin view.
## P3 - Nice-to-Have ## P3 - Nice-to-Have
@@ -65,4 +65,4 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
- Add JSON import/export for services with icons. - Add JSON import/export for services with icons.
- Add keyboard shortcuts for search/quick launch. - Add keyboard shortcuts for search/quick launch.
- Add Open Graph metadata and richer SEO tags. - Add Open Graph metadata and richer SEO tags.
- Add CI verification that builds container image for pull requests. - [x] Add CI verification that builds container image for pull requests.

View File

@@ -3,7 +3,11 @@ from __future__ import annotations
import secrets import secrets
import os import os
import time import time
from datetime import datetime import json
import logging
import uuid
import base64
from datetime import datetime, timezone
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -45,6 +49,9 @@ app = FastAPI(title="Jellomator")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
login_attempts: dict[str, list[float]] = {} login_attempts: dict[str, list[float]] = {}
login_lockouts: dict[str, float] = {} login_lockouts: dict[str, float] = {}
logger = logging.getLogger("jellomator")
if not logger.handlers:
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(message)s")
@app.get("/healthz") @app.get("/healthz")
@@ -122,14 +129,48 @@ def init_db():
url text not null, url text not null,
description text, description text,
category varchar(255), category varchar(255),
sort_order int not null default 0,
icon_blob longblob, icon_blob longblob,
icon_mime varchar(255), icon_mime varchar(255),
icon_url text, icon_url text,
enabled tinyint(1) not null default 1, enabled tinyint(1) not null default 1,
created_at varchar(64) not null, created_at datetime not null,
updated_at varchar(64) not null updated_at datetime not null
) engine=InnoDB default charset=utf8mb4 ) engine=InnoDB default charset=utf8mb4
""") """)
cur.execute("show columns from links like 'sort_order'")
if cur.fetchone() is None:
cur.execute("alter table links add column sort_order int not null default 0 after category")
cur.execute("show columns from links like 'created_at'")
created_col = cur.fetchone()
cur.execute("show columns from links like 'updated_at'")
updated_col = cur.fetchone()
created_is_varchar = bool(created_col and str(created_col.get("Type", "")).startswith("varchar"))
updated_is_varchar = bool(updated_col and str(updated_col.get("Type", "")).startswith("varchar"))
if created_is_varchar or updated_is_varchar:
cur.execute("alter table links add column created_at_dt datetime null, add column updated_at_dt datetime null")
cur.execute(
"""
update links
set created_at_dt=coalesce(
str_to_date(created_at, '%Y-%m-%dT%H:%i:%s.%f'),
str_to_date(created_at, '%Y-%m-%dT%H:%i:%s'),
str_to_date(created_at, '%Y-%m-%d %H:%i:%s'),
utc_timestamp()
),
updated_at_dt=coalesce(
str_to_date(updated_at, '%Y-%m-%dT%H:%i:%s.%f'),
str_to_date(updated_at, '%Y-%m-%dT%H:%i:%s'),
str_to_date(updated_at, '%Y-%m-%d %H:%i:%s'),
utc_timestamp()
)
"""
)
cur.execute("alter table links drop column created_at, drop column updated_at")
cur.execute(
"alter table links change column created_at_dt created_at datetime not null, "
"change column updated_at_dt updated_at datetime not null"
)
init_db() init_db()
@@ -152,10 +193,32 @@ class LoginIn(BaseModel):
password: str password: str
class RestoreIn(BaseModel):
data: dict
confirm: bool = False
class ReorderItem(BaseModel):
id: int
sort_order: int
class ReorderIn(BaseModel):
items: list[ReorderItem]
def utc_now_iso() -> str: def utc_now_iso() -> str:
return datetime.utcnow().isoformat() return datetime.utcnow().isoformat()
def utc_now_db() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def utc_now_iso_z() -> str:
return datetime.now(timezone.utc).isoformat()
def expires_at_iso() -> str: def expires_at_iso() -> str:
now = datetime.utcnow().timestamp() now = datetime.utcnow().timestamp()
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat() return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
@@ -194,13 +257,12 @@ def current_user(request: Request, response: Response | None = None):
should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS
except ValueError: except ValueError:
should_rotate = True should_rotate = True
if should_rotate: if should_rotate and response is not None:
new_token = secrets.token_urlsafe(32) new_token = secrets.token_urlsafe(32)
cur.execute( cur.execute(
"update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s", "update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s",
(new_token, now_iso, new_expires_at, token), (new_token, now_iso, new_expires_at, token),
) )
if response is not None:
response.set_cookie( response.set_cookie(
SESSION_COOKIE, SESSION_COOKIE,
new_token, new_token,
@@ -263,6 +325,66 @@ def require_csrf(request: Request):
raise HTTPException(403, "Invalid CSRF token") raise HTTPException(403, "Invalid CSRF token")
def log_event(request: Request | None, event: str, **fields):
payload = {"event": event, "ts": utc_now_iso_z(), **fields}
if request is not None:
payload["request_id"] = getattr(request.state, "request_id", None)
payload["method"] = request.method
payload["path"] = request.url.path
payload["client_ip"] = client_ip(request)
logger.info(json.dumps(payload, separators=(",", ":")))
def parse_backup_payload(data: dict) -> tuple[list[dict], list[dict]]:
if not isinstance(data, dict):
raise HTTPException(422, "backup payload must be an object")
users = data.get("users")
links = data.get("links")
if not isinstance(users, list) or not isinstance(links, list):
raise HTTPException(422, "backup payload must include users[] and links[]")
parsed_users: list[dict] = []
parsed_links: list[dict] = []
for idx, user in enumerate(users):
if not isinstance(user, dict):
raise HTTPException(422, f"users[{idx}] must be an object")
username = str(user.get("username", "")).strip()
role = str(user.get("role", "")).strip() or "admin"
password_hash_b64 = str(user.get("password_hash_b64", "")).strip()
if not username or not password_hash_b64:
raise HTTPException(422, f"users[{idx}] must include username and password_hash_b64")
try:
password_hash = base64.b64decode(password_hash_b64.encode("ascii"), validate=True)
except Exception as exc:
raise HTTPException(422, f"users[{idx}] has invalid password_hash_b64") from exc
parsed_users.append({"username": username, "role": role, "password_hash": password_hash})
for idx, link in enumerate(links):
if not isinstance(link, dict):
raise HTTPException(422, f"links[{idx}] must be an object")
name = str(link.get("name", "")).strip()
url = str(link.get("url", "")).strip()
description = str(link.get("description", "") or "")
category = str(link.get("category", "") or "General")
icon_url = link.get("icon_url")
sort_order = int(link.get("sort_order", 0) or 0)
enabled = bool(link.get("enabled", True))
validate_link_payload(name, url, description, category, icon_url)
parsed_links.append(
{
"name": name,
"url": url,
"description": description,
"category": category,
"icon_url": icon_url,
"sort_order": sort_order,
"enabled": enabled,
}
)
lower_names = [l["name"].lower() for l in parsed_links]
if len(lower_names) != len(set(lower_names)):
raise HTTPException(422, "backup has duplicate link names")
return parsed_users, parsed_links
def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool: def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool:
with conn.cursor() as cur: with conn.cursor() as cur:
if exclude_id is None: if exclude_id is None:
@@ -313,6 +435,15 @@ def read_icon_blob(icon: UploadFile | None) -> tuple[bytes | None, str | None]:
return blob, icon.content_type return blob, icon.content_type
@app.middleware("http")
async def request_context(request: Request, call_next):
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex
request.state.request_id = request_id
response = await call_next(request)
response.headers["x-request-id"] = request_id
return response
@app.get("/api/me") @app.get("/api/me")
def me(request: Request, response: Response): def me(request: Request, response: Response):
current = current_user(request, response) current = current_user(request, response)
@@ -333,6 +464,7 @@ def setup(inp: SetupIn):
raise HTTPException(400, "Setup already complete") raise HTTPException(400, "Setup already complete")
pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt()) pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt())
cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin")) cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin"))
log_event(None, "auth.setup_complete", username=inp.username)
return {"ok": True} return {"ok": True}
@@ -344,6 +476,7 @@ def login(request: Request, inp: LoginIn):
key = login_key(request, inp.username) key = login_key(request, inp.username)
locked_until = login_lockouts.get(key) locked_until = login_lockouts.get(key)
if locked_until and locked_until > now_ts: if locked_until and locked_until > now_ts:
log_event(request, "auth.login_blocked", username=inp.username, locked_until=locked_until)
raise HTTPException(429, "Too many login attempts. Try again later.") raise HTTPException(429, "Too many login attempts. Try again later.")
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
@@ -355,9 +488,14 @@ def login(request: Request, inp: LoginIn):
login_attempts[key] = [t for t in attempts if t >= now_ts - LOGIN_WINDOW_SECONDS] login_attempts[key] = [t for t in attempts if t >= now_ts - LOGIN_WINDOW_SECONDS]
if len(login_attempts[key]) >= LOGIN_MAX_ATTEMPTS: if len(login_attempts[key]) >= LOGIN_MAX_ATTEMPTS:
login_lockouts[key] = now_ts + LOGIN_LOCKOUT_SECONDS login_lockouts[key] = now_ts + LOGIN_LOCKOUT_SECONDS
log_event(request, "auth.login_failed", username=inp.username)
raise HTTPException(401, "Invalid credentials") raise HTTPException(401, "Invalid credentials")
login_attempts.pop(key, None) login_attempts.pop(key, None)
login_lockouts.pop(key, None) login_lockouts.pop(key, None)
old_token = request.cookies.get(SESSION_COOKIE)
if old_token:
with c.cursor() as cur:
cur.execute("delete from sessions where token=%s", (old_token,))
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
with c.cursor() as cur: with c.cursor() as cur:
now = utc_now_iso() now = utc_now_iso()
@@ -375,6 +513,7 @@ def login(request: Request, inp: LoginIn):
max_age=SESSION_TTL_SECONDS, max_age=SESSION_TTL_SECONDS,
path="/", path="/",
) )
log_event(request, "auth.login_success", username=inp.username)
return response return response
@@ -388,6 +527,7 @@ def logout(request: Request):
cur.execute("delete from sessions where token=%s", (token,)) cur.execute("delete from sessions where token=%s", (token,))
resp = JSONResponse({"ok": True}) resp = JSONResponse({"ok": True})
resp.delete_cookie(SESSION_COOKIE, path="/") resp.delete_cookie(SESSION_COOKIE, path="/")
log_event(request, "auth.logout")
return resp return resp
@@ -395,7 +535,7 @@ def logout(request: Request):
def links(): def links():
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute("select * from links order by enabled desc, category, name") cur.execute("select * from links order by enabled desc, sort_order asc, name asc")
rows = cur.fetchall() rows = cur.fetchall()
out = [] out = []
for r in rows: for r in rows:
@@ -434,18 +574,18 @@ def create_link(
require_admin(request) require_admin(request)
require_csrf(request) require_csrf(request)
validate_link_payload(name, url, description, category, icon_url) validate_link_payload(name, url, description, category, icon_url)
icon_blob, icon_mime = read_icon_blob(icon)
now = utc_now_db()
with db() as c: with db() as c:
if link_name_exists(c, name): if link_name_exists(c, name):
raise HTTPException(409, "Link name already exists") raise HTTPException(409, "Link name already exists")
icon_blob, icon_mime = read_icon_blob(icon)
now = datetime.utcnow().isoformat()
with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute( cur.execute(
"""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) """insert into links(name,url,description,category,sort_order,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now), (name, url, description, category, 0, icon_blob, icon_mime, icon_url, int(enabled), now, now),
) )
log_event(request, "links.create", name=name)
return {"ok": True} return {"ok": True}
@@ -467,6 +607,7 @@ def delete_link(request: Request, link_id: int):
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute("delete from links where id=%s", (link_id,)) cur.execute("delete from links where id=%s", (link_id,))
log_event(request, "links.delete", link_id=link_id)
return {"ok": True} return {"ok": True}
@@ -486,7 +627,7 @@ def update_link(
require_csrf(request) require_csrf(request)
validate_link_payload(name, url, description, category, icon_url) validate_link_payload(name, url, description, category, icon_url)
icon_blob, icon_mime = read_icon_blob(icon) icon_blob, icon_mime = read_icon_blob(icon)
now = datetime.utcnow().isoformat() now = utc_now_db()
with db() as c: with db() as c:
if link_name_exists(c, name, exclude_id=link_id): if link_name_exists(c, name, exclude_id=link_id):
raise HTTPException(409, "Link name already exists") raise HTTPException(409, "Link name already exists")
@@ -501,9 +642,128 @@ def update_link(
"""update links set name=%s,url=%s,description=%s,category=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""", """update links set name=%s,url=%s,description=%s,category=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""",
(name, url, description, category, icon_url, int(enabled), now, link_id), (name, url, description, category, icon_url, int(enabled), now, link_id),
) )
log_event(request, "links.update", link_id=link_id, name=name)
return {"ok": True} return {"ok": True}
@app.patch("/api/links/order")
def reorder_links(request: Request, inp: ReorderIn):
require_admin(request)
require_csrf(request)
if not inp.items:
raise HTTPException(422, "items must not be empty")
seen: set[int] = set()
with db() as c:
c.begin()
try:
with c.cursor() as cur:
for item in inp.items:
if item.id in seen:
raise HTTPException(422, "duplicate link id in reorder payload")
seen.add(item.id)
cur.execute("update links set sort_order=%s,updated_at=%s where id=%s", (item.sort_order, utc_now_db(), item.id))
if cur.rowcount == 0:
raise HTTPException(404, f"link {item.id} not found")
c.commit()
except Exception:
c.rollback()
raise
log_event(request, "links.reorder", count=len(inp.items))
return {"ok": True}
@app.get("/api/admin/backup")
def backup(request: Request):
require_admin(request)
with db() as c:
with c.cursor() as cur:
cur.execute("select username, role, password_hash from users order by id asc")
users = cur.fetchall()
cur.execute(
"select name,url,description,category,sort_order,icon_url,enabled,created_at,updated_at from links order by id asc"
)
links_rows = cur.fetchall()
users_out = [
{
"username": u["username"],
"role": u["role"],
"password_hash_b64": base64.b64encode(u["password_hash"]).decode("ascii"),
}
for u in users
]
links_out = []
for link in links_rows:
links_out.append(
{
"name": link["name"],
"url": link["url"],
"description": link["description"],
"category": link["category"],
"sort_order": link["sort_order"],
"icon_url": link["icon_url"],
"enabled": bool(link["enabled"]),
"created_at": link["created_at"].replace(tzinfo=timezone.utc).isoformat() if link["created_at"] else None,
"updated_at": link["updated_at"].replace(tzinfo=timezone.utc).isoformat() if link["updated_at"] else None,
}
)
out = {"version": 1, "exported_at": utc_now_iso_z(), "users": users_out, "links": links_out}
log_event(request, "backup.export", users=len(users_out), links=len(links_out))
return out
@app.post("/api/admin/restore")
def restore(request: Request, inp: RestoreIn, dry_run: bool = True):
require_admin(request)
require_csrf(request)
parsed_users, parsed_links = parse_backup_payload(inp.data)
if dry_run:
log_event(request, "backup.restore_dry_run", users=len(parsed_users), links=len(parsed_links))
return {"ok": True, "dry_run": True, "users": len(parsed_users), "links": len(parsed_links)}
if not inp.confirm:
raise HTTPException(400, "confirm=true is required to apply restore")
now = utc_now_db()
with db() as c:
c.begin()
try:
with c.cursor() as cur:
cur.execute("set foreign_key_checks=0")
cur.execute("delete from sessions")
cur.execute("delete from users")
cur.execute("delete from links")
for user in parsed_users:
cur.execute(
"insert into users(username,password_hash,role) values (%s,%s,%s)",
(user["username"], user["password_hash"], user["role"]),
)
for link in parsed_links:
cur.execute(
"""
insert into links(name,url,description,category,sort_order,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
link["name"],
link["url"],
link["description"],
link["category"],
link["sort_order"],
None,
None,
link["icon_url"],
int(link["enabled"]),
now,
now,
),
)
cur.execute("set foreign_key_checks=1")
c.commit()
except Exception:
c.rollback()
raise
log_event(request, "backup.restore_apply", users=len(parsed_users), links=len(parsed_links))
return {"ok": True, "dry_run": False, "users": len(parsed_users), "links": len(parsed_links)}
if STATIC_DIR.exists(): if STATIC_DIR.exists():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
if PUBLIC_DIR.exists(): if PUBLIC_DIR.exists():

View File

@@ -16,6 +16,8 @@ export default function App() {
const [links, setLinks] = useState<LinkItem[]>([]); const [links, setLinks] = useState<LinkItem[]>([]);
const [page, setPage] = useState<Page>('loading'); const [page, setPage] = useState<Page>('loading');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [toast, setToast] = useState<{ message: string; tone: 'success' | 'error' } | null>(null);
const [publicMode, setPublicMode] = useState<boolean>(() => window.localStorage.getItem('public_mode') === '1');
async function refresh() { async function refresh() {
const current = await api.request<SetupState>('/api/me'); const current = await api.request<SetupState>('/api/me');
@@ -28,14 +30,21 @@ export default function App() {
if (!current.current_user) { if (!current.current_user) {
setPage(path.startsWith('/admin') ? 'login' : 'dashboard'); setPage(path.startsWith('/admin') ? 'login' : 'dashboard');
} else { } else {
setPage(path.startsWith('/admin') ? 'admin' : 'dashboard'); setPage(path.startsWith('/admin') && !publicMode ? 'admin' : 'dashboard');
} }
setLinks(await api.request<LinkItem[]>('/api/links')); setLinks(await api.request<LinkItem[]>('/api/links'));
} }
function showToast(message: string, tone: 'success' | 'error' = 'success') {
setToast({ message, tone });
window.setTimeout(() => {
setToast((current) => (current?.message === message ? null : current));
}, 2500);
}
useEffect(() => { useEffect(() => {
refresh().catch(() => setPage('setup')); refresh().catch(() => setPage('setup'));
}, []); }, [publicMode]);
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
@@ -67,22 +76,24 @@ export default function App() {
onSave={async (payload, file, editingId) => { onSave={async (payload, file, editingId) => {
const fd = toFormData(payload, file); const fd = toFormData(payload, file);
const url = editingId ? `/api/links/${editingId}` : '/api/links'; const url = editingId ? `/api/links/${editingId}` : '/api/links';
try {
await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd }); await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd });
} catch (err) { showToast(editingId ? 'Link updated.' : 'Link created.');
const message = err instanceof Error ? err.message : String(err);
if (message.includes('Link name already exists')) {
alert('Link names must be unique.');
return;
}
throw err;
}
await refresh(); await refresh();
}} }}
onDelete={async (id) => { onDelete={async (id) => {
await api.request(`/api/links/${id}`, { method: 'DELETE' }); await api.request(`/api/links/${id}`, { method: 'DELETE' });
showToast('Link deleted.');
await refresh(); await refresh();
}} }}
onReorder={async (items) => {
await api.request('/api/links/order', {
method: 'PATCH',
body: JSON.stringify({ items: items.map((item, index) => ({ id: item.id, sort_order: index })) }),
});
showToast('Order saved.');
await refresh();
}}
showToast={showToast}
/> />
</div> </div>
</Shell> </Shell>
@@ -91,11 +102,20 @@ export default function App() {
return ( return (
<Shell> <Shell>
{toast ? <Toast message={toast.message} tone={toast.tone} /> : null}
<Dashboard <Dashboard
links={links} links={links}
query={query} query={query}
setQuery={setQuery} setQuery={setQuery}
canLogout={Boolean(state?.current_user)} canLogout={Boolean(state?.current_user)}
canAdmin={Boolean(state?.current_user) && !publicMode}
publicMode={publicMode}
onTogglePublicMode={() => {
const next = !publicMode;
setPublicMode(next);
window.localStorage.setItem('public_mode', next ? '1' : '0');
if (next && window.location.pathname.startsWith('/admin')) nav('/');
}}
onAdmin={() => nav('/admin')} onAdmin={() => nav('/admin')}
onLogout={async () => { onLogout={async () => {
await api.request('/api/logout', { method: 'POST' }); await api.request('/api/logout', { method: 'POST' });
@@ -127,6 +147,14 @@ function Shell({ children }: { children: ReactNode }) {
return <div className="min-h-screen">{children}</div>; return <div className="min-h-screen">{children}</div>;
} }
function Toast({ message, tone }: { message: string; tone: 'success' | 'error' }) {
return (
<div className="toast-wrap" role="status" aria-live="polite">
<div className={`toast ${tone === 'error' ? 'toast-error' : 'toast-success'}`}>{message}</div>
</div>
);
}
function Centered({ children }: { children: ReactNode }) { function Centered({ children }: { children: ReactNode }) {
return <div className="grid min-h-screen place-items-center p-8 text-slate-500">{children}</div>; return <div className="grid min-h-screen place-items-center p-8 text-slate-500">{children}</div>;
} }
@@ -136,6 +164,9 @@ function Dashboard({
query, query,
setQuery, setQuery,
canLogout, canLogout,
canAdmin,
publicMode,
onTogglePublicMode,
onAdmin, onAdmin,
onLogout, onLogout,
}: { }: {
@@ -143,6 +174,9 @@ function Dashboard({
query: string; query: string;
setQuery: (value: string) => void; setQuery: (value: string) => void;
canLogout: boolean; canLogout: boolean;
canAdmin: boolean;
publicMode: boolean;
onTogglePublicMode: () => void;
onAdmin: () => void; onAdmin: () => void;
onLogout: () => Promise<void>; onLogout: () => Promise<void>;
}) { }) {
@@ -172,8 +206,11 @@ function Dashboard({
<header className="row-between"> <header className="row-between">
<h1 className="title-sm">Jellomator</h1> <h1 className="title-sm">Jellomator</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button type="button" className="btn-subtle" onClick={onTogglePublicMode}>
{publicMode ? 'Exit public mode' : 'Public mode'}
</button>
{canLogout ? <button type="button" className="btn-subtle" onClick={onLogout}>Logout</button> : null} {canLogout ? <button type="button" className="btn-subtle" onClick={onLogout}>Logout</button> : null}
<button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button> {canAdmin ? <button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button> : null}
</div> </div>
</header> </header>
@@ -243,6 +280,7 @@ function SetupPage({ onDone }: { onDone: () => Promise<void> }) {
action="Create admin" action="Create admin"
onSubmit={async (fd) => { onSubmit={async (fd) => {
await api.request('/api/setup', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) }); await api.request('/api/setup', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
nav('/admin');
await onDone(); await onDone();
}} }}
fields={['username', 'password']} fields={['username', 'password']}
@@ -257,7 +295,6 @@ function LoginPage({ onDone }: { onDone: () => Promise<void> }) {
action="Sign in" action="Sign in"
onSubmit={async (fd) => { onSubmit={async (fd) => {
await api.request('/api/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) }); await api.request('/api/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
nav('/');
await onDone(); await onDone();
}} }}
fields={['username', 'password']} fields={['username', 'password']}
@@ -276,20 +313,28 @@ function AuthCard({
fields: string[]; fields: string[];
onSubmit: (fd: FormData) => Promise<void>; onSubmit: (fd: FormData) => Promise<void>;
}) { }) {
const [submitError, setSubmitError] = useState<string | null>(null);
return ( return (
<div className="grid min-h-screen place-items-center p-4"> <div className="grid min-h-screen place-items-center p-4">
<form <form
className="panel w-full max-w-sm space-y-3" className="panel w-full max-w-sm space-y-3"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
setSubmitError(null);
try {
await onSubmit(new FormData(e.currentTarget)); await onSubmit(new FormData(e.currentTarget));
location.reload(); } catch (err) {
const message = err instanceof Error ? err.message : String(err);
setSubmitError(message || 'Request failed. Please try again.');
}
}} }}
> >
<h1 className="title-sm">{title}</h1> <h1 className="title-sm">{title}</h1>
{fields.map((f) => ( {fields.map((f) => (
<input key={f} name={f} type={f === 'password' ? 'password' : 'text'} className="input" placeholder={f} required /> <input key={f} name={f} type={f === 'password' ? 'password' : 'text'} className="input" placeholder={f} required />
))} ))}
{submitError ? <p className="text-xs text-rose-400">{submitError}</p> : null}
<button className="btn-subtle w-full" type="submit">{action}</button> <button className="btn-subtle w-full" type="submit">{action}</button>
</form> </form>
</div> </div>
@@ -300,14 +345,26 @@ function AdminPage({
links, links,
onSave, onSave,
onDelete, onDelete,
onReorder,
showToast,
}: { }: {
links: LinkItem[]; links: LinkItem[];
onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>; onSave: (payload: LinkForm, file?: File | null, editingId?: number) => Promise<void>;
onDelete: (id: number) => Promise<void>; onDelete: (id: number) => Promise<void>;
onReorder: (items: LinkItem[]) => Promise<void>;
showToast: (message: string, tone?: 'success' | 'error') => void;
}) { }) {
const [form, setForm] = useState<LinkForm>(emptyForm()); const [form, setForm] = useState<LinkForm>(emptyForm());
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [restoreText, setRestoreText] = useState('');
const [submitError, setSubmitError] = useState<string | null>(null);
const [orderedLinks, setOrderedLinks] = useState<LinkItem[]>(links);
const [draggingId, setDraggingId] = useState<number | null>(null);
useEffect(() => {
setOrderedLinks(links);
}, [links]);
const startEdit = (link: LinkItem) => { const startEdit = (link: LinkItem) => {
setEditingId(link.id); setEditingId(link.id);
@@ -322,20 +379,76 @@ function AdminPage({
}); });
}; };
const startDuplicate = (link: LinkItem) => {
const existingNames = new Set(orderedLinks.map((item) => item.name.toLowerCase()));
const base = `${link.name} (copy)`;
let candidate = base;
let counter = 2;
while (existingNames.has(candidate.toLowerCase())) {
candidate = `${base} ${counter}`;
counter += 1;
}
setEditingId(null);
setFile(null);
setForm({
name: candidate,
url: link.url,
description: link.description,
category: link.category,
icon_url: link.icon_url ?? '',
enabled: link.enabled,
});
};
const reset = () => { const reset = () => {
setEditingId(null); setEditingId(null);
setFile(null); setFile(null);
setForm(emptyForm()); setForm(emptyForm());
}; };
const moveBy = async (id: number, delta: number) => {
const index = orderedLinks.findIndex((link) => link.id === id);
const nextIndex = index + delta;
if (index < 0 || nextIndex < 0 || nextIndex >= orderedLinks.length) return;
const next = [...orderedLinks];
const [item] = next.splice(index, 1);
next.splice(nextIndex, 0, item);
setOrderedLinks(next);
await onReorder(next);
};
const handleDropOn = async (targetId: number) => {
if (draggingId === null || draggingId === targetId) return;
const from = orderedLinks.findIndex((link) => link.id === draggingId);
const to = orderedLinks.findIndex((link) => link.id === targetId);
if (from < 0 || to < 0) return;
const next = [...orderedLinks];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
setOrderedLinks(next);
setDraggingId(null);
await onReorder(next);
};
return ( return (
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]"> <div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
<div className="space-y-4">
<form <form
className="panel space-y-2" className="panel space-y-2"
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault(); event.preventDefault();
setSubmitError(null);
try {
await onSave(form, file, editingId ?? undefined); await onSave(form, file, editingId ?? undefined);
reset(); reset();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('Link name already exists')) {
setSubmitError('Link names must be unique.');
return;
}
setSubmitError(message || 'Saving failed. Please try again.');
}
}} }}
> >
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required /> <input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
@@ -352,17 +465,92 @@ function AdminPage({
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button> <button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null} {editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
</div> </div>
{submitError ? <p className="text-xs text-rose-400">{submitError}</p> : null}
</form> </form>
<div className="panel space-y-2">
<div className="text-xs text-slate-400">Backup / Restore</div>
<div className="flex gap-2">
<button
type="button"
className="btn-subtle"
onClick={async () => {
const backup = await api.request<Record<string, unknown>>('/api/admin/backup');
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `jellomator-backup-${new Date().toISOString().replaceAll(':', '-')}.json`;
a.click();
URL.revokeObjectURL(a.href);
}}
>
Export backup
</button>
<input
type="file"
accept="application/json"
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setRestoreText(await f.text());
}}
/>
</div>
<textarea
className="input min-h-32"
placeholder="Paste backup JSON here"
value={restoreText}
onChange={(e) => setRestoreText(e.target.value)}
/>
<div className="flex gap-2">
<button
type="button"
className="btn-subtle"
onClick={async () => {
const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=true', { method: 'POST', body: JSON.stringify({ data, confirm: false }) });
showToast('Restore dry-run passed.');
}}
>
Validate (dry-run)
</button>
<button
type="button"
className="btn-subtle"
onClick={async () => {
if (!confirm('Apply restore now? This replaces existing users, sessions, and links.')) return;
const data = JSON.parse(restoreText);
await api.request('/api/admin/restore?dry_run=false', { method: 'POST', body: JSON.stringify({ data, confirm: true }) });
showToast('Restore applied.');
window.location.reload();
}}
>
Apply restore
</button>
</div>
</div>
</div>
<div className="panel"> <div className="panel">
<ul className="admin-list"> <ul className="admin-list">
{links.map((link) => ( {orderedLinks.map((link, index) => (
<li key={link.id} className="admin-row"> <li
key={link.id}
className="admin-row"
draggable
onDragStart={() => setDraggingId(link.id)}
onDragOver={(event) => event.preventDefault()}
onDrop={async () => {
await handleDropOn(link.id);
}}
>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm text-slate-100">{link.name}</div> <div className="truncate text-sm text-slate-100">{link.name}</div>
<div className="truncate text-xs text-slate-500">{link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}</div> <div className="truncate text-xs text-slate-500">{link.category || 'General'} | {link.enabled ? 'enabled' : 'disabled'}</div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="button" className="btn-subtle" onClick={() => moveBy(link.id, -1)} disabled={index === 0}>Up</button>
<button type="button" className="btn-subtle" onClick={() => moveBy(link.id, 1)} disabled={index === orderedLinks.length - 1}>Down</button>
<button type="button" className="btn-subtle" onClick={() => startDuplicate(link)}>Duplicate</button>
<button type="button" className="btn-subtle" onClick={() => startEdit(link)}>Edit</button> <button type="button" className="btn-subtle" onClick={() => startEdit(link)}>Edit</button>
<button type="button" className="btn-subtle" onClick={() => onDelete(link.id)}>Delete</button> <button type="button" className="btn-subtle" onClick={() => onDelete(link.id)}>Delete</button>
</div> </div>

View File

@@ -5,6 +5,7 @@ export type LinkItem = {
url: string; url: string;
description: string; description: string;
category: string; category: string;
sort_order?: number;
enabled: boolean; enabled: boolean;
icon_url: string | null; icon_url: string | null;
}; };

View File

@@ -74,3 +74,19 @@ body {
.admin-row { .admin-row {
@apply flex items-center justify-between gap-3 py-2; @apply flex items-center justify-between gap-3 py-2;
} }
.toast-wrap {
@apply pointer-events-none fixed right-4 top-4 z-50;
}
.toast {
@apply rounded-md border px-3 py-2 text-xs shadow-lg;
}
.toast-success {
@apply border-emerald-800 bg-emerald-950/95 text-emerald-200;
}
.toast-error {
@apply border-rose-800 bg-rose-950/95 text-rose-200;
}

View File

@@ -129,3 +129,29 @@ def test_login_rate_limit_lockout(client: TestClient):
assert resp.status_code == 401 assert resp.status_code == 401
locked = client.post("/api/login", json={"username": "admin", "password": "wrong-password"}) locked = client.post("/api/login", json={"username": "admin", "password": "wrong-password"})
assert locked.status_code == 429 assert locked.status_code == 429
def test_backup_restore_dry_run_and_apply(client: TestClient):
client.post("/api/setup", json={"username": "admin", "password": "123456789012"})
client.post("/api/login", json={"username": "admin", "password": "123456789012"})
client.post(
"/api/links",
data={
"name": "Sonarr",
"url": "https://sonarr.example.com",
"description": "TV",
"category": "Arr",
"enabled": "true",
},
)
backup_resp = client.get("/api/admin/backup")
assert backup_resp.status_code == 200
backup = backup_resp.json()
assert isinstance(backup.get("users"), list)
assert isinstance(backup.get("links"), list)
dry = client.post("/api/admin/restore?dry_run=true", json={"data": backup, "confirm": False})
assert dry.status_code == 200
assert dry.json()["dry_run"] is True
apply_resp = client.post("/api/admin/restore?dry_run=false", json={"data": backup, "confirm": True})
assert apply_resp.status_code == 200
assert apply_resp.json()["ok"] is True