Compare commits

..

18 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
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
11 changed files with 874 additions and 115 deletions

View File

@@ -6,10 +6,50 @@ on:
paths-ignore: paths-ignore:
- '**/*.md' - '**/*.md'
- '**/*.txt' - '**/*.txt'
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
- '**/*.txt'
workflow_dispatch: workflow_dispatch:
jobs: 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: build-and-push:
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
@@ -100,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

@@ -6,23 +6,29 @@ Dark dashboard for Arr* services and custom links.
- First-run admin setup - First-run admin setup
- Cookie-based admin auth - Cookie-based admin auth
- Health endpoint at `/healthz`
- Readiness endpoint at `/readyz` (optional DB write probe)
- Public dashboard with search/filter - Public dashboard with search/filter
- Dedicated protected admin page at `/admin` - Dedicated protected admin page at `/admin`
- Link CRUD backed by MariaDB - Link CRUD backed by MariaDB
- Icon blobs stored in the database - Icon blobs stored in the database
- Single-container deployment - 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
```bash ```bash
npm install npm install
pip install -r backend/requirements.txt
npm run dev npm run dev
``` ```
Backend runs on `http://localhost:6363`. Backend runs on `http://localhost:6363`.
Open `/admin` for the protected management page. Open `/admin` for the protected management page.
Ensure MariaDB is running and reachable by the backend `DB_*` variables.
## Docker ## Docker
@@ -32,15 +38,34 @@ docker compose up --build
The app expects a MariaDB instance configured through environment variables. 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 and Cookie Env Vars
- `SESSION_TTL_SECONDS` (default: `86400`) - `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) - `SESSION_COOKIE_SECURE` (default: `false`, set `true` in production HTTPS)
- `REQUIRE_CSRF` (default: `false`, checks same-origin/same-referer for write routes when enabled) - `REQUIRE_CSRF` (default: `false`, checks same-origin/same-referer for write routes when enabled)
- `LOGIN_MAX_ATTEMPTS` (default: `5`) - `LOGIN_MAX_ATTEMPTS` (default: `5`)
- `LOGIN_WINDOW_SECONDS` (default: `300`) - `LOGIN_WINDOW_SECONDS` (default: `300`)
- `LOGIN_LOCKOUT_SECONDS` (default: `900`) - `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`) - `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 ## Gitea CI/CD

111
TODO.md
View File

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

@@ -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
@@ -22,6 +26,7 @@ PUBLIC_DIR = Path("public")
SESSION_COOKIE = "jellomator_session" SESSION_COOKIE = "jellomator_session"
SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "86400")) 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_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_MAX_ATTEMPTS = int(os.getenv("LOGIN_MAX_ATTEMPTS", "5"))
LOGIN_WINDOW_SECONDS = int(os.getenv("LOGIN_WINDOW_SECONDS", "300")) LOGIN_WINDOW_SECONDS = int(os.getenv("LOGIN_WINDOW_SECONDS", "300"))
LOGIN_LOCKOUT_SECONDS = int(os.getenv("LOGIN_LOCKOUT_SECONDS", "900")) LOGIN_LOCKOUT_SECONDS = int(os.getenv("LOGIN_LOCKOUT_SECONDS", "900"))
@@ -32,6 +37,8 @@ MAX_DESCRIPTION_LEN = int(os.getenv("MAX_DESCRIPTION_LEN", "2000"))
MAX_ICON_URL_LEN = int(os.getenv("MAX_ICON_URL_LEN", "2048")) 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))) 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"} 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_HOST = os.getenv("DB_HOST", "mariadb")
DB_PORT = int(os.getenv("DB_PORT", "3306")) DB_PORT = int(os.getenv("DB_PORT", "3306"))
DB_USER = os.getenv("DB_USER", "jellomator") DB_USER = os.getenv("DB_USER", "jellomator")
@@ -42,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")
@@ -50,12 +60,16 @@ def healthz():
@app.get("/readyz") @app.get("/readyz")
def readyz(): def readyz(write_test: bool = False):
try: try:
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute("select 1 as ok") cur.execute("select 1 as ok")
cur.fetchone() 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: except Exception:
raise HTTPException(503, "Database not ready") raise HTTPException(503, "Database not ready")
return {"ok": True} return {"ok": True}
@@ -115,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()
@@ -145,23 +193,45 @@ 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()
def current_user(request: Request): def current_user(request: Request, response: Response | None = None):
token = request.cookies.get(SESSION_COOKIE) token = request.cookies.get(SESSION_COOKIE)
if not token: if not token:
return None return None
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute( 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,), (token,),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -177,9 +247,35 @@ def current_user(request: Request):
except ValueError: except ValueError:
cur.execute("delete from sessions where token=%s", (token,)) cur.execute("delete from sessions where token=%s", (token,))
return None return None
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( cur.execute(
"update sessions set last_seen_at=%s where token=%s", "update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s",
(utc_now_iso(), token), (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"]} return {"username": row["username"], "role": row["role"]}
@@ -229,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:
@@ -244,6 +400,15 @@ def validate_http_url(value: str, field_name: str = "url") -> None:
raise HTTPException(422, f"{field_name} must be a valid http(s) URL") 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: def validate_length(value: str | None, limit: int, field_name: str) -> None:
if value is not None and len(value) > limit: if value is not None and len(value) > limit:
raise HTTPException(422, f"{field_name} exceeds max length of {limit}") raise HTTPException(422, f"{field_name} exceeds max length of {limit}")
@@ -270,17 +435,28 @@ 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): def me(request: Request, response: Response):
current = current_user(request, response)
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute("select count(*) as count from users") cur.execute("select count(*) as count from users")
needs_setup = cur.fetchone()["count"] == 0 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") @app.post("/api/setup")
def setup(inp: SetupIn): def setup(inp: SetupIn):
validate_credentials(inp.username, inp.password)
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute("select count(*) as count from users") cur.execute("select count(*) as count from users")
@@ -288,16 +464,19 @@ 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}
@app.post("/api/login") @app.post("/api/login")
def login(request: Request, inp: LoginIn): def login(request: Request, inp: LoginIn):
validate_credentials(inp.username, inp.password)
now_ts = time.time() now_ts = time.time()
prune_login_tracking(now_ts) prune_login_tracking(now_ts)
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:
@@ -309,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()
@@ -329,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
@@ -342,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
@@ -349,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:
@@ -388,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}
@@ -421,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}
@@ -440,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")
@@ -455,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

@@ -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 [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;
}

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