Compare commits
24 Commits
69957f6848
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3969d3a0c6 | |||
| 1edb89884b | |||
| 0f106e3544 | |||
| a4b645bab6 | |||
| 87c610b8d7 | |||
| 58f7702074 | |||
| 7cdf9b95f7 | |||
|
|
fd874c9499 | ||
|
|
791126cdd0 | ||
|
|
643785ad1e | ||
|
|
17b4793a73 | ||
|
|
156485c67b | ||
|
|
2142826b07 | ||
|
|
be24e7c071 | ||
|
|
18f2ec2937 | ||
|
|
911d9ed683 | ||
|
|
94d12d55c6 | ||
|
|
a185c91407 | ||
|
|
972ccce62a | ||
|
|
7c06d31ac1 | ||
|
|
ed886c956d | ||
|
|
dde83a2417 | ||
|
|
637cfe967f | ||
|
|
94392c2c99 |
@@ -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 }}
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -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,6 +38,35 @@ 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_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
|
## Gitea CI/CD
|
||||||
|
|
||||||
Add these secrets in Gitea:
|
Add these secrets in Gitea:
|
||||||
|
|||||||
111
TODO.md
111
TODO.md
@@ -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
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Backend package marker for tests and tooling imports.
|
||||||
522
backend/main.py
522
backend/main.py
@@ -2,10 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import os
|
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 contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import pymysql
|
import pymysql
|
||||||
@@ -18,6 +24,21 @@ from pydantic import BaseModel
|
|||||||
STATIC_DIR = Path("frontend/dist")
|
STATIC_DIR = Path("frontend/dist")
|
||||||
PUBLIC_DIR = Path("public")
|
PUBLIC_DIR = Path("public")
|
||||||
SESSION_COOKIE = "jellomator_session"
|
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_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")
|
||||||
@@ -26,6 +47,32 @@ DB_NAME = os.getenv("DB_NAME", "jellomator")
|
|||||||
|
|
||||||
app = FastAPI(title="Jellomator")
|
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_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")
|
||||||
|
def healthz():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/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}
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -63,10 +110,18 @@ def init_db():
|
|||||||
token varchar(255) primary key,
|
token varchar(255) primary key,
|
||||||
user_id bigint not null,
|
user_id bigint not null,
|
||||||
created_at varchar(64) not null,
|
created_at varchar(64) not null,
|
||||||
|
expires_at varchar(64) null,
|
||||||
|
last_seen_at varchar(64) null,
|
||||||
index (user_id),
|
index (user_id),
|
||||||
constraint sessions_user_fk foreign key (user_id) references users(id) on delete cascade
|
constraint sessions_user_fk foreign key (user_id) references users(id) on delete cascade
|
||||||
) engine=InnoDB default charset=utf8mb4
|
) engine=InnoDB default charset=utf8mb4
|
||||||
""")
|
""")
|
||||||
|
cur.execute("show columns from sessions like 'expires_at'")
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute("alter table sessions add column expires_at varchar(64) null after created_at")
|
||||||
|
cur.execute("show columns from sessions like 'last_seen_at'")
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
cur.execute("alter table sessions add column last_seen_at varchar(64) null after expires_at")
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
create table if not exists links(
|
create table if not exists links(
|
||||||
id bigint auto_increment primary key,
|
id bigint auto_increment primary key,
|
||||||
@@ -74,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()
|
||||||
|
|
||||||
|
|
||||||
@@ -104,15 +193,115 @@ class LoginIn(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
def current_user(request: Request):
|
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, 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("select u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=%s", (token,))
|
cur.execute(
|
||||||
|
"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()
|
row = cur.fetchone()
|
||||||
return row if row else None
|
if not row:
|
||||||
|
return None
|
||||||
|
expires_at = row.get("expires_at")
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if expires_at:
|
||||||
|
try:
|
||||||
|
if now >= datetime.fromisoformat(expires_at):
|
||||||
|
cur.execute("delete from sessions where token=%s", (token,))
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
cur.execute("delete from sessions where token=%s", (token,))
|
||||||
|
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(
|
||||||
|
"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):
|
def require_admin(request: Request):
|
||||||
@@ -122,6 +311,80 @@ def require_admin(request: Request):
|
|||||||
return user
|
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:
|
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:
|
||||||
@@ -131,17 +394,69 @@ def link_name_exists(conn, name: str, *, exclude_id: int | None = None) -> bool:
|
|||||||
return cur.fetchone() is not None
|
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")
|
@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")
|
||||||
@@ -149,27 +464,62 @@ 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(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 db() as c:
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
cur.execute("select id,password_hash from users where username=%s", (inp.username,))
|
cur.execute("select id,password_hash from users where username=%s", (inp.username,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row or not bcrypt.checkpw(inp.password.encode(), row["password_hash"]):
|
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")
|
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)
|
token = secrets.token_urlsafe(32)
|
||||||
with c.cursor() as cur:
|
with c.cursor() as cur:
|
||||||
cur.execute("insert into sessions(token,user_id,created_at) values (%s,%s,%s)", (token, row["id"], datetime.utcnow().isoformat()))
|
now = utc_now_iso()
|
||||||
|
cur.execute(
|
||||||
|
"insert into sessions(token,user_id,created_at,expires_at,last_seen_at) values (%s,%s,%s,%s,%s)",
|
||||||
|
(token, row["id"], now, expires_at_iso(), now),
|
||||||
|
)
|
||||||
response = JSONResponse({"ok": True})
|
response = JSONResponse({"ok": True})
|
||||||
response.set_cookie(SESSION_COOKIE, token, httponly=True, samesite="lax", secure=False, path="/")
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=SESSION_COOKIE_SECURE,
|
||||||
|
max_age=SESSION_TTL_SECONDS,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
log_event(request, "auth.login_success", username=inp.username)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/logout")
|
@app.post("/api/logout")
|
||||||
def logout(request: Request):
|
def logout(request: Request):
|
||||||
|
require_csrf(request)
|
||||||
token = request.cookies.get(SESSION_COOKIE)
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
with db() as c:
|
with db() as c:
|
||||||
if token:
|
if token:
|
||||||
@@ -177,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
|
||||||
|
|
||||||
|
|
||||||
@@ -184,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:
|
||||||
@@ -221,21 +572,20 @@ def create_link(
|
|||||||
icon: UploadFile | None = File(None),
|
icon: UploadFile | None = File(None),
|
||||||
):
|
):
|
||||||
require_admin(request)
|
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:
|
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 = 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:
|
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}
|
||||||
|
|
||||||
|
|
||||||
@@ -253,9 +603,11 @@ def link_icon(link_id: int):
|
|||||||
@app.delete("/api/links/{link_id}")
|
@app.delete("/api/links/{link_id}")
|
||||||
def delete_link(request: Request, link_id: int):
|
def delete_link(request: Request, link_id: int):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
require_csrf(request)
|
||||||
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}
|
||||||
|
|
||||||
|
|
||||||
@@ -272,11 +624,10 @@ def update_link(
|
|||||||
icon: UploadFile | None = File(None),
|
icon: UploadFile | None = File(None),
|
||||||
):
|
):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
icon_blob = icon_mime = None
|
require_csrf(request)
|
||||||
if icon:
|
validate_link_payload(name, url, description, category, icon_url)
|
||||||
icon_blob = icon.file.read()
|
icon_blob, icon_mime = read_icon_blob(icon)
|
||||||
icon_mime = icon.content_type
|
now = utc_now_db()
|
||||||
now = datetime.utcnow().isoformat()
|
|
||||||
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")
|
||||||
@@ -291,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():
|
||||||
|
|||||||
3
backend/requirements-dev.txt
Normal file
3
backend/requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.5
|
||||||
|
httpx==0.28.1
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
157
tests/test_api.py
Normal 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
|
||||||
Reference in New Issue
Block a user