Compare commits

21 Commits

Author SHA1 Message Date
3969d3a0c6 Merge pull request 'ci: verify Docker image build on pull requests' (#1) from chore/pr-docker-build-verify into main
All checks were successful
docker / test (push) Successful in 11s
docker / build-and-push (push) Successful in 45s
docker / build-verify-pr (push) Has been skipped
Reviewed-on: #1
2026-05-22 22:19:17 +02: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
Space-Banane
17b4793a73 Stabilize test imports with explicit project root path
All checks were successful
docker / test (push) Successful in 10s
docker / build-and-push (push) Successful in 51s
2026-05-20 22:08:02 +02:00
Space-Banane
156485c67b Make backend importable in test environment
Some checks failed
docker / test (push) Failing after 8s
docker / build-and-push (push) Has been skipped
2026-05-20 22:07:12 +02:00
Space-Banane
2142826b07 Fix CI test DB host for service network
Some checks failed
docker / test (push) Failing after 11s
docker / build-and-push (push) Has been skipped
2026-05-20 22:06:37 +02:00
Space-Banane
be24e7c071 Add pytest suite and CI test gate
Some checks failed
docker / test (push) Failing after 1m18s
docker / build-and-push (push) Has been skipped
2026-05-20 22:04:41 +02:00
Space-Banane
18f2ec2937 Update README to match current backend behavior 2026-05-20 22:01:59 +02:00
Space-Banane
911d9ed683 Validate setup and login credential inputs
All checks were successful
docker / build-and-push (push) Successful in 49s
2026-05-20 21:58:11 +02:00
Space-Banane
94d12d55c6 Add optional DB write probe to readiness endpoint 2026-05-20 21:57:37 +02:00
Space-Banane
a185c91407 Add sliding session renewal and periodic token rotation 2026-05-20 21:57:14 +02:00
Space-Banane
972ccce62a Add optional CSRF enforcement for write routes
All checks were successful
docker / build-and-push (push) Successful in 49s
2026-05-20 21:55:30 +02:00
Space-Banane
7c06d31ac1 Validate link payloads and icon uploads 2026-05-20 21:54:53 +02:00
Space-Banane
ed886c956d Add login rate limiting with lockout window 2026-05-20 21:54:28 +02:00
10 changed files with 982 additions and 122 deletions

View File

@@ -6,10 +6,50 @@ on:
paths-ignore:
- '**/*.md'
- '**/*.txt'
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
- '**/*.txt'
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:11
env:
MARIADB_DATABASE: jellomator_test
MARIADB_USER: jellomator
MARIADB_PASSWORD: jellomator
MARIADB_ROOT_PASSWORD: root
ports:
- 3306:3306
options: >-
--health-cmd="mariadb-admin ping -h 127.0.0.1 -uroot -proot"
--health-interval=5s
--health-timeout=5s
--health-retries=20
env:
DB_HOST: mariadb
DB_PORT: "3306"
DB_USER: jellomator
DB_PASSWORD: jellomator
DB_NAME: jellomator_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Python dependencies
run: pip install -r backend/requirements-dev.txt
- name: Run backend tests
run: pytest -q
build-and-push:
needs: test
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -100,3 +140,19 @@ jobs:
print(f"link failed: status={exc.code} body={body}")
raise
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 }}

View File

@@ -6,23 +6,29 @@ Dark dashboard for Arr* services and custom links.
- First-run admin setup
- Cookie-based admin auth
- Health endpoint at `/healthz`
- Readiness endpoint at `/readyz` (optional DB write probe)
- Public dashboard with search/filter
- Dedicated protected admin page at `/admin`
- Link CRUD backed by MariaDB
- Icon blobs stored in the database
- Single-container deployment
- Containerized app deployment (requires MariaDB)
- Admin-managed service links
- Admin backup/export and restore with dry-run validation
- Structured JSON logs with request IDs (`x-request-id`)
## Local Dev
```bash
npm install
pip install -r backend/requirements.txt
npm run dev
```
Backend runs on `http://localhost:6363`.
Open `/admin` for the protected management page.
Ensure MariaDB is running and reachable by the backend `DB_*` variables.
## Docker
@@ -32,10 +38,34 @@ docker compose up --build
The app expects a MariaDB instance configured through environment variables.
### Health Endpoints
- `GET /healthz` returns `{"ok": true}` when the app process is up
- `GET /readyz` returns `{"ok": true}` when database checks pass
- `GET /readyz?write_test=true` additionally verifies DB writes using a temporary table
### Session and Cookie Env Vars
- `SESSION_TTL_SECONDS` (default: `86400`)
- `SESSION_ROTATE_SECONDS` (default: `3600`, rotate active session token when exceeded)
- `SESSION_COOKIE_SECURE` (default: `false`, set `true` in production HTTPS)
- `REQUIRE_CSRF` (default: `false`, checks same-origin/same-referer for write routes when enabled)
- `LOGIN_MAX_ATTEMPTS` (default: `5`)
- `LOGIN_WINDOW_SECONDS` (default: `300`)
- `LOGIN_LOCKOUT_SECONDS` (default: `900`)
- `MAX_NAME_LEN` (default: `255`)
- `MAX_CATEGORY_LEN` (default: `255`)
- `MAX_DESCRIPTION_LEN` (default: `2000`)
- `MAX_ICON_URL_LEN` (default: `2048`)
- `MAX_ICON_BYTES` (default: `2097152`)
- `USERNAME_MAX_LEN` (default: `64`)
- `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

111
TODO.md
View File

@@ -1,63 +1,68 @@
# TODO
Concrete follow-up work for Jellomator.
Concrete follow-up work for Jellomator, prioritized by implementation risk and user impact.
## P0
## P0 - Security and Reliability
- Add a backup and restore flow for the database in the admin UI.
- Let an admin download the current database.
- Let an admin upload a replacement database after confirmation.
- Validate the uploaded file before swapping it in.
- Add a basic health endpoint for Docker and orchestration.
- Return `200` when the app can read and write the database.
- Return `503` if startup initialization or DB access fails.
- Add login rate limiting.
- Track failed attempts per session or IP.
- Temporarily block repeated failures.
- Add session expiry controls.
- Expire idle admin sessions after a configurable period.
- Renew active sessions on successful requests.
- [x] Add session expiry and rotation.
- [x] Add `expires_at` and `last_seen_at` to `sessions`.
- [x] Reject expired tokens in `current_user`.
- [x] Rotate session token on login and periodically on use.
- [x] Harden auth endpoints.
- [x] Add login rate limiting by IP + username pair.
- [x] Add brute-force lockout window with clear error message.
- [x] Add optional CSRF protection for cookie-authenticated write routes.
- [x] Fix cookie/security defaults for deployment.
- [x] Set cookie `secure` from environment (true in production).
- [x] Make cookie max-age configurable.
- [x] Keep `httponly` and `samesite=lax`.
- [x] Add input and payload validation.
- [x] Validate URL scheme for links (`http`/`https` only).
- [x] Enforce max lengths for `name`, `category`, `description`, and `icon_url`.
- [x] Validate uploaded icon type and max file size before reading blob.
- [x] Add health/readiness endpoints.
- [x] `/healthz` returns `200` when process is up.
- [x] `/readyz` checks DB query + optional write test and returns `503` on failure.
## P1
## P1 - Data Model and Backend Quality
- Add drag-and-drop ordering for service cards.
- Persist display order in the database.
- Support moving a card up, down, or to the top in admin.
- Add a featured/pinned flag for important links.
- Keep pinned links above the normal list.
- Let admins toggle pinned status from the edit form.
- Add multi-category support.
- Store categories as a normalized table or join table.
- Allow filtering by more than one category in the dashboard.
- Add duplicate/cloning for existing links.
- Pre-fill a new form from an existing service.
- Keep the original service unchanged.
- Add a password autofill helper for first-run setup.
- Offer a generated strong password suggestion on the setup screen.
- Let the admin copy it or autofill the password fields.
- Add a public read-only mode.
- Hide admin-only links from the dashboard.
- Keep the same UI but remove edit affordances.
- [x] Replace string timestamps with DB-native datetime.
- [x] Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`.
- [x] Use UTC consistently for writes and reads.
- [x] Add display ordering support.
- [x] Add `sort_order` column and stable ordering fallback by `name`.
- [x] Update read query to order by `enabled desc`, `sort_order`, `name`.
- [x] Remove duplicate connection pattern in create flow.
- [x] Use one DB transaction/connection per request path where possible.
- [x] Add backup and restore flow in admin API/UI.
- [x] Download full export.
- [x] Upload validated import with explicit confirmation.
- [x] Add dry-run validation mode before apply.
- [x] Add structured logging.
- [x] Log auth attempts, CRUD actions, and restore events with request IDs.
## P2
## P2 - UX and Product Improvements
- Add JSON import/export for services.
- Include metadata and icon blobs in the export format.
- Support importing a whole dashboard from a single file.
- Add better icon handling.
- Show initials when no icon exists.
- Allow cropping or centering uploaded icons.
- Add audit history for admin changes.
- Record create, update, delete, and preset actions.
- Show a simple timeline in the admin area.
- Add a compact dashboard mode.
- Reduce card padding and text size.
- Make it easier to scan large lists of links.
- [x] Replace browser `alert()` with inline form errors/toasts.
- [x] Show server errors near submit controls.
- [x] Add success toasts for create/update/delete.
- [x] Remove forced reload in auth forms.
- [x] Replace `location.reload()` with state refresh only.
- [x] Keep SPA navigation predictable on setup/login/logout.
- [x] Add drag-and-drop ordering in admin.
- [x] Persist `sort_order` updates.
- [x] Provide keyboard-accessible move controls as fallback.
- [x] Add duplicate/cloning for links.
- [x] Pre-fill form from an existing link.
- [x] Save as new record with unique name validation.
- [x] Add public read-only mode toggle.
- [x] Hide admin entry points and editing affordances for non-admin view.
## P3
## P3 - Nice-to-Have
- Add keyboard shortcuts for search and quick launch.
- Add a toast system for save, delete, and upload actions.
- Add Open Graph metadata for better link previews.
- Add structured JSON logging for auth and CRUD events.
- Add a CI verification step that builds the container image after publish.
- Add multi-category support with normalization.
- Add audit history timeline in admin.
- Add JSON import/export for services with icons.
- Add keyboard shortcuts for search/quick launch.
- Add Open Graph metadata and richer SEO tags.
- [x] Add CI verification that builds container image for pull requests.

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend package marker for tests and tooling imports.

View File

@@ -2,10 +2,16 @@ from __future__ import annotations
import secrets
import os
from datetime import datetime
import time
import json
import logging
import uuid
import base64
from datetime import datetime, timezone
from contextlib import contextmanager
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
import bcrypt
import pymysql
@@ -20,6 +26,19 @@ PUBLIC_DIR = Path("public")
SESSION_COOKIE = "jellomator_session"
SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "86400"))
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "false").lower() in ("1", "true", "yes", "on")
SESSION_ROTATE_SECONDS = int(os.getenv("SESSION_ROTATE_SECONDS", "3600"))
LOGIN_MAX_ATTEMPTS = int(os.getenv("LOGIN_MAX_ATTEMPTS", "5"))
LOGIN_WINDOW_SECONDS = int(os.getenv("LOGIN_WINDOW_SECONDS", "300"))
LOGIN_LOCKOUT_SECONDS = int(os.getenv("LOGIN_LOCKOUT_SECONDS", "900"))
REQUIRE_CSRF = os.getenv("REQUIRE_CSRF", "false").lower() in ("1", "true", "yes", "on")
MAX_NAME_LEN = int(os.getenv("MAX_NAME_LEN", "255"))
MAX_CATEGORY_LEN = int(os.getenv("MAX_CATEGORY_LEN", "255"))
MAX_DESCRIPTION_LEN = int(os.getenv("MAX_DESCRIPTION_LEN", "2000"))
MAX_ICON_URL_LEN = int(os.getenv("MAX_ICON_URL_LEN", "2048"))
MAX_ICON_BYTES = int(os.getenv("MAX_ICON_BYTES", str(2 * 1024 * 1024)))
ALLOWED_ICON_MIME = {"image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml", "image/x-icon"}
USERNAME_MAX_LEN = int(os.getenv("USERNAME_MAX_LEN", "64"))
PASSWORD_MIN_LEN = int(os.getenv("PASSWORD_MIN_LEN", "12"))
DB_HOST = os.getenv("DB_HOST", "mariadb")
DB_PORT = int(os.getenv("DB_PORT", "3306"))
DB_USER = os.getenv("DB_USER", "jellomator")
@@ -28,6 +47,11 @@ DB_NAME = os.getenv("DB_NAME", "jellomator")
app = FastAPI(title="Jellomator")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
login_attempts: dict[str, list[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")
@@ -36,12 +60,16 @@ def healthz():
@app.get("/readyz")
def readyz():
def readyz(write_test: bool = False):
try:
with db() as c:
with c.cursor() as cur:
cur.execute("select 1 as ok")
cur.fetchone()
if write_test:
cur.execute("create temporary table if not exists readyz_probe(id int)")
cur.execute("insert into readyz_probe(id) values (1)")
cur.execute("truncate table readyz_probe")
except Exception:
raise HTTPException(503, "Database not ready")
return {"ok": True}
@@ -101,14 +129,48 @@ def init_db():
url text not null,
description text,
category varchar(255),
sort_order int not null default 0,
icon_blob longblob,
icon_mime varchar(255),
icon_url text,
enabled tinyint(1) not null default 1,
created_at varchar(64) not null,
updated_at varchar(64) not null
created_at datetime not null,
updated_at datetime not null
) 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()
@@ -131,23 +193,45 @@ class LoginIn(BaseModel):
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:
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:
now = datetime.utcnow().timestamp()
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
def current_user(request: Request):
def current_user(request: Request, response: Response | None = None):
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
with db() as c:
with c.cursor() as cur:
cur.execute(
"select s.expires_at,u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=%s",
"select s.created_at,s.last_seen_at,s.expires_at,u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=%s",
(token,),
)
row = cur.fetchone()
@@ -163,13 +247,63 @@ def current_user(request: Request):
except ValueError:
cur.execute("delete from sessions where token=%s", (token,))
return None
cur.execute(
"update sessions set last_seen_at=%s where token=%s",
(utc_now_iso(), token),
)
now = datetime.utcnow()
now_iso = now.isoformat()
new_expires_at = expires_at_iso()
last_seen_at = row.get("last_seen_at") or row.get("created_at")
should_rotate = False
if last_seen_at:
try:
should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS
except ValueError:
should_rotate = True
if should_rotate and response is not None:
new_token = secrets.token_urlsafe(32)
cur.execute(
"update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s",
(new_token, now_iso, new_expires_at, token),
)
response.set_cookie(
SESSION_COOKIE,
new_token,
httponly=True,
samesite="lax",
secure=SESSION_COOKIE_SECURE,
max_age=SESSION_TTL_SECONDS,
path="/",
)
else:
cur.execute(
"update sessions set last_seen_at=%s,expires_at=%s where token=%s",
(now_iso, new_expires_at, token),
)
return {"username": row["username"], "role": row["role"]}
def client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def login_key(request: Request, username: str) -> str:
return f"{client_ip(request)}::{username.strip().lower()}"
def prune_login_tracking(now: float) -> None:
for key, until in list(login_lockouts.items()):
if until <= now:
del login_lockouts[key]
cutoff = now - LOGIN_WINDOW_SECONDS
for key, entries in list(login_attempts.items()):
filtered = [t for t in entries if t >= cutoff]
if filtered:
login_attempts[key] = filtered
else:
del login_attempts[key]
def require_admin(request: Request):
user = current_user(request)
if not user:
@@ -177,6 +311,80 @@ def require_admin(request: Request):
return user
def require_csrf(request: Request):
if not REQUIRE_CSRF:
return
origin = request.headers.get("origin")
referer = request.headers.get("referer")
target = f"{request.url.scheme}://{request.url.netloc}"
if origin and origin != target:
raise HTTPException(403, "Invalid CSRF origin")
if referer and not referer.startswith(target):
raise HTTPException(403, "Invalid CSRF referer")
if not origin and not referer:
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:
with conn.cursor() as cur:
if exclude_id is None:
@@ -186,17 +394,69 @@ def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool:
return cur.fetchone() is not None
def validate_http_url(value: str, field_name: str = "url") -> None:
parsed = urlparse((value or "").strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise HTTPException(422, f"{field_name} must be a valid http(s) URL")
def validate_credentials(username: str, password: str) -> None:
if not username or len(username.strip()) == 0:
raise HTTPException(422, "username is required")
if len(username) > USERNAME_MAX_LEN:
raise HTTPException(422, f"username exceeds max length of {USERNAME_MAX_LEN}")
if len(password or "") < PASSWORD_MIN_LEN:
raise HTTPException(422, f"password must be at least {PASSWORD_MIN_LEN} characters")
def validate_length(value: str | None, limit: int, field_name: str) -> None:
if value is not None and len(value) > limit:
raise HTTPException(422, f"{field_name} exceeds max length of {limit}")
def validate_link_payload(name: str, url: str, description: str, category: str, icon_url: str | None) -> None:
validate_length(name, MAX_NAME_LEN, "name")
validate_length(description, MAX_DESCRIPTION_LEN, "description")
validate_length(category, MAX_CATEGORY_LEN, "category")
if icon_url:
validate_length(icon_url, MAX_ICON_URL_LEN, "icon_url")
validate_http_url(icon_url, "icon_url")
validate_http_url(url, "url")
def read_icon_blob(icon: UploadFile | None) -> tuple[bytes | None, str | None]:
if not icon:
return None, None
if icon.content_type not in ALLOWED_ICON_MIME:
raise HTTPException(422, "Unsupported icon file type")
blob = icon.file.read(MAX_ICON_BYTES + 1)
if len(blob) > MAX_ICON_BYTES:
raise HTTPException(422, f"Icon exceeds max size of {MAX_ICON_BYTES} bytes")
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")
def me(request: Request):
def me(request: Request, response: Response):
current = current_user(request, response)
with db() as c:
with c.cursor() as cur:
cur.execute("select count(*) as count from users")
needs_setup = cur.fetchone()["count"] == 0
return {"needs_setup": needs_setup, "current_user": current_user(request)}
return {"needs_setup": needs_setup, "current_user": current}
@app.post("/api/setup")
def setup(inp: SetupIn):
validate_credentials(inp.username, inp.password)
with db() as c:
with c.cursor() as cur:
cur.execute("select count(*) as count from users")
@@ -204,17 +464,38 @@ def setup(inp: SetupIn):
raise HTTPException(400, "Setup already complete")
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"))
log_event(None, "auth.setup_complete", username=inp.username)
return {"ok": True}
@app.post("/api/login")
def login(inp: LoginIn):
def login(request: Request, inp: LoginIn):
validate_credentials(inp.username, inp.password)
now_ts = time.time()
prune_login_tracking(now_ts)
key = login_key(request, inp.username)
locked_until = login_lockouts.get(key)
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.")
with db() as c:
with c.cursor() as cur:
cur.execute("select id,password_hash from users where username=%s", (inp.username,))
row = cur.fetchone()
if not row or not bcrypt.checkpw(inp.password.encode(), row["password_hash"]):
attempts = login_attempts.get(key, [])
attempts.append(now_ts)
login_attempts[key] = [t for t in attempts if t >= now_ts - LOGIN_WINDOW_SECONDS]
if len(login_attempts[key]) >= LOGIN_MAX_ATTEMPTS:
login_lockouts[key] = now_ts + LOGIN_LOCKOUT_SECONDS
log_event(request, "auth.login_failed", username=inp.username)
raise HTTPException(401, "Invalid credentials")
login_attempts.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)
with c.cursor() as cur:
now = utc_now_iso()
@@ -232,11 +513,13 @@ def login(inp: LoginIn):
max_age=SESSION_TTL_SECONDS,
path="/",
)
log_event(request, "auth.login_success", username=inp.username)
return response
@app.post("/api/logout")
def logout(request: Request):
require_csrf(request)
token = request.cookies.get(SESSION_COOKIE)
with db() as c:
if token:
@@ -244,6 +527,7 @@ def logout(request: Request):
cur.execute("delete from sessions where token=%s", (token,))
resp = JSONResponse({"ok": True})
resp.delete_cookie(SESSION_COOKIE, path="/")
log_event(request, "auth.logout")
return resp
@@ -251,7 +535,7 @@ def logout(request: Request):
def links():
with db() as c:
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()
out = []
for r in rows:
@@ -288,21 +572,20 @@ def create_link(
icon: UploadFile | None = File(None),
):
require_admin(request)
require_csrf(request)
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:
if link_name_exists(c, name):
raise HTTPException(409, "Link name already exists")
icon_blob = icon_mime = None
if icon:
icon_blob = icon.file.read()
icon_mime = icon.content_type
now = datetime.utcnow().isoformat()
with db() as c:
with c.cursor() as cur:
cur.execute(
"""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now),
"""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)""",
(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}
@@ -320,9 +603,11 @@ def link_icon(link_id: int):
@app.delete("/api/links/{link_id}")
def delete_link(request: Request, link_id: int):
require_admin(request)
require_csrf(request)
with db() as c:
with c.cursor() as cur:
cur.execute("delete from links where id=%s", (link_id,))
log_event(request, "links.delete", link_id=link_id)
return {"ok": True}
@@ -339,11 +624,10 @@ def update_link(
icon: UploadFile | None = File(None),
):
require_admin(request)
icon_blob = icon_mime = None
if icon:
icon_blob = icon.file.read()
icon_mime = icon.content_type
now = datetime.utcnow().isoformat()
require_csrf(request)
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:
if link_name_exists(c, name, exclude_id=link_id):
raise HTTPException(409, "Link name already exists")
@@ -358,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""",
(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}
@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():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
if PUBLIC_DIR.exists():

View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.5
httpx==0.28.1

View File

@@ -16,6 +16,8 @@ export default function App() {
const [links, setLinks] = useState<LinkItem[]>([]);
const [page, setPage] = useState<Page>('loading');
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() {
const current = await api.request<SetupState>('/api/me');
@@ -28,14 +30,21 @@ export default function App() {
if (!current.current_user) {
setPage(path.startsWith('/admin') ? 'login' : 'dashboard');
} else {
setPage(path.startsWith('/admin') ? 'admin' : 'dashboard');
setPage(path.startsWith('/admin') && !publicMode ? 'admin' : 'dashboard');
}
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(() => {
refresh().catch(() => setPage('setup'));
}, []);
}, [publicMode]);
useEffect(() => {
const handlePopState = () => {
@@ -67,22 +76,24 @@ export default function App() {
onSave={async (payload, file, editingId) => {
const fd = toFormData(payload, file);
const url = editingId ? `/api/links/${editingId}` : '/api/links';
try {
await api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd });
} catch (err) {
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 api.request(url, { method: editingId ? 'PATCH' : 'POST', body: fd });
showToast(editingId ? 'Link updated.' : 'Link created.');
await refresh();
}}
onDelete={async (id) => {
await api.request(`/api/links/${id}`, { method: 'DELETE' });
showToast('Link deleted.');
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>
</Shell>
@@ -91,11 +102,20 @@ export default function App() {
return (
<Shell>
{toast ? <Toast message={toast.message} tone={toast.tone} /> : null}
<Dashboard
links={links}
query={query}
setQuery={setQuery}
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')}
onLogout={async () => {
await api.request('/api/logout', { method: 'POST' });
@@ -127,6 +147,14 @@ function Shell({ children }: { children: ReactNode }) {
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 }) {
return <div className="grid min-h-screen place-items-center p-8 text-slate-500">{children}</div>;
}
@@ -136,6 +164,9 @@ function Dashboard({
query,
setQuery,
canLogout,
canAdmin,
publicMode,
onTogglePublicMode,
onAdmin,
onLogout,
}: {
@@ -143,6 +174,9 @@ function Dashboard({
query: string;
setQuery: (value: string) => void;
canLogout: boolean;
canAdmin: boolean;
publicMode: boolean;
onTogglePublicMode: () => void;
onAdmin: () => void;
onLogout: () => Promise<void>;
}) {
@@ -172,8 +206,11 @@ function Dashboard({
<header className="row-between">
<h1 className="title-sm">Jellomator</h1>
<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}
<button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button>
{canAdmin ? <button type="button" className="btn-subtle" onClick={onAdmin}>Admin</button> : null}
</div>
</header>
@@ -243,6 +280,7 @@ function SetupPage({ onDone }: { onDone: () => Promise<void> }) {
action="Create admin"
onSubmit={async (fd) => {
await api.request('/api/setup', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
nav('/admin');
await onDone();
}}
fields={['username', 'password']}
@@ -257,7 +295,6 @@ function LoginPage({ onDone }: { onDone: () => Promise<void> }) {
action="Sign in"
onSubmit={async (fd) => {
await api.request('/api/login', { method: 'POST', body: JSON.stringify(Object.fromEntries(fd)) });
nav('/');
await onDone();
}}
fields={['username', 'password']}
@@ -276,20 +313,28 @@ function AuthCard({
fields: string[];
onSubmit: (fd: FormData) => Promise<void>;
}) {
const [submitError, setSubmitError] = useState<string | null>(null);
return (
<div className="grid min-h-screen place-items-center p-4">
<form
className="panel w-full max-w-sm space-y-3"
onSubmit={async (e) => {
e.preventDefault();
await onSubmit(new FormData(e.currentTarget));
location.reload();
setSubmitError(null);
try {
await onSubmit(new FormData(e.currentTarget));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setSubmitError(message || 'Request failed. Please try again.');
}
}}
>
<h1 className="title-sm">{title}</h1>
{fields.map((f) => (
<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>
</form>
</div>
@@ -300,14 +345,26 @@ function AdminPage({
links,
onSave,
onDelete,
onReorder,
showToast,
}: {
links: LinkItem[];
onSave: (payload: LinkForm, file?: File | null, editingId?: 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 [file, setFile] = useState<File | 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) => {
setEditingId(link.id);
@@ -322,47 +379,178 @@ 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 = () => {
setEditingId(null);
setFile(null);
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 (
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
<form
className="panel space-y-2"
onSubmit={async (event) => {
event.preventDefault();
await onSave(form, file, editingId ?? undefined);
reset();
}}
>
<input className="input" value={form.name} placeholder="name" onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<input className="input" value={form.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
<input className="input" value={form.icon_url} placeholder="icon URL" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<label className="flex items-center gap-2 text-xs text-slate-400">
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
Enabled
</label>
<div className="flex gap-2 pt-1">
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
<div className="space-y-4">
<form
className="panel space-y-2"
onSubmit={async (event) => {
event.preventDefault();
setSubmitError(null);
try {
await onSave(form, file, editingId ?? undefined);
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.url} placeholder="url" onChange={(e) => setForm({ ...form, url: e.target.value })} required />
<input className="input" value={form.description} placeholder="description" onChange={(e) => setForm({ ...form, description: e.target.value })} />
<input className="input" value={form.category} placeholder="category" onChange={(e) => setForm({ ...form, category: e.target.value })} />
<input className="input" value={form.icon_url} placeholder="icon URL" onChange={(e) => setForm({ ...form, icon_url: e.target.value })} />
<input className="input" type="file" accept="image/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<label className="flex items-center gap-2 text-xs text-slate-400">
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
Enabled
</label>
<div className="flex gap-2 pt-1">
<button type="submit" className="btn-subtle">{editingId ? 'Update' : 'Add'}</button>
{editingId ? <button type="button" className="btn-subtle" onClick={reset}>Cancel</button> : null}
</div>
{submitError ? <p className="text-xs text-rose-400">{submitError}</p> : null}
</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>
</form>
</div>
<div className="panel">
<ul className="admin-list">
{links.map((link) => (
<li key={link.id} className="admin-row">
{orderedLinks.map((link, index) => (
<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="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>
<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={() => onDelete(link.id)}>Delete</button>
</div>

View File

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

View File

@@ -74,3 +74,19 @@ body {
.admin-row {
@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;
}

157
tests/test_api.py Normal file
View File

@@ -0,0 +1,157 @@
import importlib
import os
import sys
import time
from pathlib import Path
from typing import Iterator
import pymysql
import pytest
from fastapi.testclient import TestClient
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
def wait_for_db(host: str, port: int, user: str, password: str, timeout_seconds: int = 60) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
try:
conn = pymysql.connect(host=host, port=port, user=user, password=password, autocommit=True)
conn.close()
return
except Exception:
time.sleep(1)
raise RuntimeError("MariaDB did not become ready in time")
@pytest.fixture(scope="session")
def app_module():
os.environ.setdefault("DB_HOST", "127.0.0.1")
os.environ.setdefault("DB_PORT", "3306")
os.environ.setdefault("DB_USER", "jellomator")
os.environ.setdefault("DB_PASSWORD", "jellomator")
os.environ.setdefault("DB_NAME", "jellomator_test")
wait_for_db(
host=os.environ["DB_HOST"],
port=int(os.environ["DB_PORT"]),
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
)
module = importlib.import_module("backend.main")
return module
@pytest.fixture()
def client(app_module) -> Iterator[TestClient]:
with app_module.db() as conn:
with conn.cursor() as cur:
cur.execute("delete from sessions")
cur.execute("delete from users")
cur.execute("delete from links")
app_module.login_attempts.clear()
app_module.login_lockouts.clear()
with TestClient(app_module.app) as test_client:
yield test_client
def test_healthz(client: TestClient):
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_readyz(client: TestClient):
resp = client.get("/readyz")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_setup_and_login(client: TestClient):
setup_resp = client.post("/api/setup", json={"username": "admin", "password": "123456789012"})
assert setup_resp.status_code == 200
assert setup_resp.json() == {"ok": True}
login_resp = client.post("/api/login", json={"username": "admin", "password": "123456789012"})
assert login_resp.status_code == 200
assert login_resp.json() == {"ok": True}
assert "jellomator_session=" in login_resp.headers.get("set-cookie", "")
def test_link_crud_with_auth(client: TestClient):
client.post("/api/setup", json={"username": "admin", "password": "123456789012"})
login_resp = client.post("/api/login", json={"username": "admin", "password": "123456789012"})
assert login_resp.status_code == 200
create_resp = client.post(
"/api/links",
data={
"name": "Prowlarr",
"url": "https://prowlarr.example.com",
"description": "Indexer manager",
"category": "Arr",
"enabled": "true",
},
)
assert create_resp.status_code == 200
assert create_resp.json() == {"ok": True}
list_resp = client.get("/api/links")
assert list_resp.status_code == 200
rows = list_resp.json()
assert len(rows) == 1
assert rows[0]["name"] == "Prowlarr"
link_id = rows[0]["id"]
patch_resp = client.patch(
f"/api/links/{link_id}",
data={
"name": "Prowlarr Updated",
"url": "https://prowlarr.example.com/new",
"description": "Updated",
"category": "Arr",
"enabled": "true",
},
)
assert patch_resp.status_code == 200
assert patch_resp.json() == {"ok": True}
delete_resp = client.delete(f"/api/links/{link_id}")
assert delete_resp.status_code == 200
assert delete_resp.json() == {"ok": True}
def test_login_rate_limit_lockout(client: TestClient):
client.post("/api/setup", json={"username": "admin", "password": "123456789012"})
for _ in range(5):
resp = client.post("/api/login", json={"username": "admin", "password": "wrong-password"})
assert resp.status_code == 401
locked = client.post("/api/login", json={"username": "admin", "password": "wrong-password"})
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