diff --git a/README.md b/README.md index 0fdf507..171e718 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Jellomator -Dark, SQLite-backed dashboard for Arr* services and custom links. +Dark dashboard for Arr* services and custom links. ## Features @@ -8,10 +8,10 @@ Dark, SQLite-backed dashboard for Arr* services and custom links. - Cookie-based admin auth - Public dashboard with search/filter - Dedicated protected admin page at `/admin` -- Link CRUD backed by SQLite -- Icon blobs stored in SQLite +- Link CRUD backed by MariaDB +- Icon blobs stored in the database - Single-container deployment -- Seeded Arr* presets on first run +- Admin-managed service links ## Local Dev @@ -30,14 +30,7 @@ Open `/admin` for the protected management page. docker compose up --build ``` -The app uses exactly one bind mount: - -`./jellomator.sqlite:/app/data/jellomator.sqlite` - -## SQLite - -All data lives in SQLite, including uploaded icon blobs. There is no separate uploads directory. -The first-run setup also seeds editable defaults for Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Bazarr, qBittorrent, Jellyfin, Jellyseerr, and Overseerr. +The app expects a MariaDB instance configured through environment variables. ## Gitea CI/CD diff --git a/TODO.md b/TODO.md index cb38654..edb454d 100644 --- a/TODO.md +++ b/TODO.md @@ -4,10 +4,10 @@ Concrete follow-up work for Jellomator. ## P0 -- Add a backup and restore flow for `jellomator.sqlite` in the admin UI. +- Add a backup and restore flow for the database in the admin UI. - Let an admin download the current database. - Let an admin upload a replacement database after confirmation. - - Validate that the uploaded file is SQLite before swapping it in. + - Validate the uploaded file before swapping it in. - Add a basic health endpoint for Docker and orchestration. - Return `200` when the app can read and write the database. - Return `503` if startup initialization or DB access fails. @@ -21,7 +21,7 @@ Concrete follow-up work for Jellomator. ## P1 - Add drag-and-drop ordering for service cards. - - Persist display order in SQLite. + - Persist display order in the database. - Support moving a card up, down, or to the top in admin. - Add a featured/pinned flag for important links. - Keep pinned links above the normal list. @@ -41,9 +41,6 @@ Concrete follow-up work for Jellomator. ## P2 -- Add more presets for common self-hosted apps. - - Suggested first set: Paperless-ngx, Immich, Grafana, Home Assistant, Vaultwarden. - - Make each preset editable after insertion. - Add JSON import/export for services. - Include metadata and icon blobs in the export format. - Support importing a whole dashboard from a single file. @@ -63,5 +60,4 @@ Concrete follow-up work for Jellomator. - Add a toast system for save, delete, and upload actions. - Add Open Graph metadata for better link previews. - Add structured JSON logging for auth and CRUD events. -- Add a small command or script to reset seed data for local development. - Add a CI verification step that builds the container image after publish. diff --git a/backend/main.py b/backend/main.py index b19d47f..cf466cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,51 +1,84 @@ from __future__ import annotations import secrets -import sqlite3 +import os from datetime import datetime +from contextlib import contextmanager from pathlib import Path from typing import Optional import bcrypt +import pymysql from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -DB_PATH = Path("/app/data/jellomator.sqlite") STATIC_DIR = Path("frontend/dist") SESSION_COOKIE = "jellomator_session" +DB_HOST = os.getenv("DB_HOST", "mariadb") +DB_PORT = int(os.getenv("DB_PORT", "3306")) +DB_USER = os.getenv("DB_USER", "jellomator") +DB_PASSWORD = os.getenv("DB_PASSWORD", "jellomator") +DB_NAME = os.getenv("DB_NAME", "jellomator") app = FastAPI(title="Jellomator") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) -PRESET_LINKS = [ - {"name": "Sonarr", "url": "http://sonarr:8989", "description": "TV library automation", "category": "Arr*", "enabled": True}, - {"name": "Radarr", "url": "http://radarr:7878", "description": "Movie library automation", "category": "Arr*", "enabled": True}, - {"name": "Lidarr", "url": "http://lidarr:8686", "description": "Music library automation", "category": "Arr*", "enabled": True}, - {"name": "Readarr", "url": "http://readarr:8787", "description": "Book library automation", "category": "Arr*", "enabled": True}, - {"name": "Prowlarr", "url": "http://prowlarr:9696", "description": "Indexer management", "category": "Arr*", "enabled": True}, - {"name": "Bazarr", "url": "http://bazarr:6767", "description": "Subtitle management", "category": "Arr*", "enabled": True}, - {"name": "qBittorrent", "url": "http://qbittorrent:8080", "description": "Torrent client", "category": "Downloads", "enabled": True}, - {"name": "Jellyfin", "url": "http://jellyfin:8096", "description": "Media server", "category": "Media", "enabled": True}, - {"name": "Jellyseerr", "url": "http://jellyseerr:5055", "description": "Request management", "category": "Requests", "enabled": True}, - {"name": "Overseerr", "url": "http://overseerr:5055", "description": "Request management", "category": "Requests", "enabled": True}, -] - +@contextmanager def db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn + conn = pymysql.connect( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD, + cursorclass=pymysql.cursors.DictCursor, + autocommit=True, + ) + try: + with conn.cursor() as cur: + cur.execute(f"create database if not exists `{DB_NAME}` default charset=utf8mb4") + conn.select_db(DB_NAME) + yield conn + finally: + conn.close() def init_db(): - DB_PATH.parent.mkdir(parents=True, exist_ok=True) with db() as c: - c.executescript(""" - create table if not exists users(id integer primary key, username text unique, password_hash blob, role text not null); - create table if not exists sessions(token text primary key, user_id integer not null, created_at text not null); - create table if not exists links(id integer primary key, name text not null, url text not null, description text, category text, icon_blob blob, icon_mime text, icon_url text, enabled integer not null default 1, created_at text not null, updated_at text not null); - """) + with c.cursor() as cur: + cur.execute(""" + create table if not exists users( + id bigint auto_increment primary key, + username varchar(255) not null unique, + password_hash varbinary(255) not null, + role varchar(32) not null + ) engine=InnoDB default charset=utf8mb4 + """) + cur.execute(""" + create table if not exists sessions( + token varchar(255) primary key, + user_id bigint not null, + created_at varchar(64) not null, + index (user_id), + constraint sessions_user_fk foreign key (user_id) references users(id) on delete cascade + ) engine=InnoDB default charset=utf8mb4 + """) + cur.execute(""" + create table if not exists links( + id bigint auto_increment primary key, + name varchar(255) not null, + url text not null, + description text, + category varchar(255), + icon_blob longblob, + icon_mime varchar(255), + icon_url text, + enabled tinyint(1) not null default 1, + created_at varchar(64) not null, + updated_at varchar(64) not null + ) engine=InnoDB default charset=utf8mb4 + """) init_db() class SetupIn(BaseModel): @@ -69,8 +102,10 @@ def current_user(request: Request): if not token: return None with db() as c: - row = c.execute("select u.username,u.role from sessions s join users u on u.id=s.user_id where s.token=?", (token,)).fetchone() - return dict(row) if row else None + 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,)) + row = cur.fetchone() + return row if row else None def require_admin(request: Request): user = current_user(request) @@ -78,39 +113,36 @@ def require_admin(request: Request): raise HTTPException(401, "Unauthorized") return user -def seed_preset_links(conn: sqlite3.Connection): - now = datetime.utcnow().isoformat() - for preset in PRESET_LINKS: - conn.execute( - """insert into links(name,url,description,category,enabled,created_at,updated_at) - values (?,?,?,?,?,?,?)""", - (preset["name"], preset["url"], preset["description"], preset["category"], int(preset["enabled"]), now, now), - ) - @app.get("/api/me") def me(request: Request): with db() as c: - needs_setup = c.execute("select count(*) from users").fetchone()[0] == 0 + with c.cursor() as cur: + cur.execute("select count(*) as count from users") + needs_setup = cur.fetchone()["count"] == 0 return {"needs_setup": needs_setup, "current_user": current_user(request)} @app.post("/api/setup") def setup(inp: SetupIn): with db() as c: - if c.execute("select count(*) from users").fetchone()[0] > 0: - raise HTTPException(400, "Setup already complete") - pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt()) - c.execute("insert into users(username,password_hash,role) values (?,?,?)", (inp.username, pw, "admin")) - seed_preset_links(c) + with c.cursor() as cur: + cur.execute("select count(*) as count from users") + if cur.fetchone()["count"] > 0: + raise HTTPException(400, "Setup already complete") + pw = bcrypt.hashpw(inp.password.encode(), bcrypt.gensalt()) + cur.execute("insert into users(username,password_hash,role) values (%s,%s,%s)", (inp.username, pw, "admin")) return {"ok": True} @app.post("/api/login") def login(inp: LoginIn): with db() as c: - row = c.execute("select id,password_hash from users where username=?", (inp.username,)).fetchone() + with c.cursor() as cur: + cur.execute("select id,password_hash from users where username=%s", (inp.username,)) + row = cur.fetchone() if not row or not bcrypt.checkpw(inp.password.encode(), row["password_hash"]): raise HTTPException(401, "Invalid credentials") token = secrets.token_urlsafe(32) - c.execute("insert into sessions(token,user_id,created_at) values (?,?,?)", (token, row["id"], datetime.utcnow().isoformat())) + 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())) response = JSONResponse({"ok": True}) response.set_cookie(SESSION_COOKIE, token, httponly=True, samesite="lax", secure=False, path="/") return response @@ -119,7 +151,9 @@ def login(inp: LoginIn): def logout(request: Request): token = request.cookies.get(SESSION_COOKIE) with db() as c: - if token: c.execute("delete from sessions where token=?", (token,)) + if token: + with c.cursor() as cur: + cur.execute("delete from sessions where token=%s", (token,)) resp = JSONResponse({"ok": True}) resp.delete_cookie(SESSION_COOKIE, path="/") return resp @@ -127,7 +161,9 @@ def logout(request: Request): @app.get("/api/links") def links(): with db() as c: - rows = c.execute("select * from links order by enabled desc, category, name").fetchall() + with c.cursor() as cur: + cur.execute("select * from links order by enabled desc, category, name") + rows = cur.fetchall() out = [] for r in rows: icon_url = None @@ -141,7 +177,9 @@ def links(): @app.get("/api/links/{link_id}") def get_link(link_id: int): with db() as c: - row = c.execute("select * from links where id=?", (link_id,)).fetchone() + with c.cursor() as cur: + cur.execute("select * from links where id=%s", (link_id,)) + row = cur.fetchone() if not row: raise HTTPException(404, "Not found") icon_url = f"/api/links/{row['id']}/icon" if row["icon_blob"] else row["icon_url"] @@ -165,15 +203,18 @@ def create_link( icon_mime = icon.content_type now = datetime.utcnow().isoformat() with db() as c: - c.execute("""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) - values (?,?,?,?,?,?,?,?,?,?)""", + with c.cursor() as cur: + cur.execute("""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) + values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", (name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now)) return {"ok": True} @app.get("/api/links/{link_id}/icon") def link_icon(link_id: int): with db() as c: - row = c.execute("select icon_blob,icon_mime from links where id=?", (link_id,)).fetchone() + with c.cursor() as cur: + cur.execute("select icon_blob,icon_mime from links where id=%s", (link_id,)) + row = cur.fetchone() if not row or not row["icon_blob"]: raise HTTPException(404, "Not found") return Response(content=row["icon_blob"], media_type=row["icon_mime"] or "image/png") @@ -182,7 +223,8 @@ def link_icon(link_id: int): def delete_link(request: Request, link_id: int): require_admin(request) with db() as c: - c.execute("delete from links where id=?", (link_id,)) + with c.cursor() as cur: + cur.execute("delete from links where id=%s", (link_id,)) return {"ok": True} @app.patch("/api/links/{link_id}") @@ -204,12 +246,13 @@ def update_link( icon_mime = icon.content_type now = datetime.utcnow().isoformat() with db() as c: - if icon_blob: - c.execute("""update links set name=?,url=?,description=?,category=?,icon_blob=?,icon_mime=?,icon_url=?,enabled=?,updated_at=? where id=?""", - (name,url,description,category,icon_blob,icon_mime,icon_url,int(enabled),now,link_id)) - else: - c.execute("""update links set name=?,url=?,description=?,category=?,icon_url=?,enabled=?,updated_at=? where id=?""", - (name,url,description,category,icon_url,int(enabled),now,link_id)) + with c.cursor() as cur: + if icon_blob: + cur.execute("""update links set name=%s,url=%s,description=%s,category=%s,icon_blob=%s,icon_mime=%s,icon_url=%s,enabled=%s,updated_at=%s where id=%s""", + (name,url,description,category,icon_blob,icon_mime,icon_url,int(enabled),now,link_id)) + else: + cur.execute("""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)) return {"ok": True} if STATIC_DIR.exists(): diff --git a/backend/requirements.txt b/backend/requirements.txt index dead6c8..248c014 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,4 @@ bcrypt==4.2.1 jinja2==3.1.5 pydantic==2.10.6 python-multipart==0.0.20 +pymysql==1.1.1 diff --git a/docker-compose.yml b/docker-compose.yml index 41e563e..5e28d7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,23 @@ services: + mariadb: + image: mariadb:11 + environment: + MARIADB_DATABASE: jellomator + MARIADB_USER: jellomator + MARIADB_PASSWORD: jellomator + MARIADB_ROOT_PASSWORD: root + ports: + - "3306:3306" + jellomator: build: . ports: - "6363:6363" - volumes: - - ./jellomator.sqlite:/app/data/jellomator.sqlite + depends_on: + - mariadb + environment: + DB_HOST: mariadb + DB_PORT: "3306" + DB_USER: jellomator + DB_PASSWORD: jellomator + DB_NAME: jellomator diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 80411a6..9f7ee50 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -11,19 +11,6 @@ type LinkForm = { enabled: boolean; }; -const presetCards = [ - { name: 'Sonarr', category: 'Arr*', url: 'http://sonarr:8989', description: 'TV library automation' }, - { name: 'Radarr', category: 'Arr*', url: 'http://radarr:7878', description: 'Movie library automation' }, - { name: 'Lidarr', category: 'Arr*', url: 'http://lidarr:8686', description: 'Music library automation' }, - { name: 'Readarr', category: 'Arr*', url: 'http://readarr:8787', description: 'Book library automation' }, - { name: 'Prowlarr', category: 'Arr*', url: 'http://prowlarr:9696', description: 'Indexer management' }, - { name: 'Bazarr', category: 'Arr*', url: 'http://bazarr:6767', description: 'Subtitle management' }, - { name: 'qBittorrent', category: 'Downloads', url: 'http://qbittorrent:8080', description: 'Torrent client' }, - { name: 'Jellyfin', category: 'Media', url: 'http://jellyfin:8096', description: 'Media server' }, - { name: 'Jellyseerr', category: 'Requests', url: 'http://jellyseerr:5055', description: 'Request management' }, - { name: 'Overseerr', category: 'Requests', url: 'http://overseerr:5055', description: 'Request management' }, -]; - export default function App() { const [state, setState] = useState(null); const [links, setLinks] = useState([]); @@ -97,18 +84,6 @@ export default function App() { if (editing?.id === id) setEditing(null); await refresh(); }} - onSeedPreset={async (preset) => { - const fd = toFormData({ - name: preset.name, - url: preset.url, - description: preset.description, - category: preset.category, - icon_url: '', - enabled: true, - }); - await api.request('/api/links', { method: 'POST', body: fd }); - await refresh(); - }} /> @@ -182,7 +157,7 @@ function Hero({ currentUser, onAdmin, onLogout }: { currentUser: string | null;
Jellomator

Your media stack, one click away.

-

Curated links for Arr* services and custom tools, served from a single SQLite-backed container.

+

Curated links for Arr* services and custom tools, served from a single database-backed container.

{currentUser ? : null} @@ -198,7 +173,7 @@ function AdminHeader({ currentUser, onBack, onLogout }: { currentUser: string |
Protected admin
-

Manage links and presets

+

Manage links

{currentUser ? `Signed in as ${currentUser}` : 'Sign in required'}

@@ -304,14 +279,12 @@ function AdminPage({ setEditing, onSave, onDelete, - onSeedPreset, }: { links: LinkItem[]; editing: LinkItem | null; setEditing: (link: LinkItem | null) => void; onSave: (payload: LinkForm, file?: File | null) => Promise; onDelete: (id: number) => Promise; - onSeedPreset: (preset: (typeof presetCards)[number]) => Promise; }) { const [form, setForm] = useState(emptyForm()); const [file, setFile] = useState(null); @@ -387,22 +360,6 @@ function AdminPage({
-
-
-
-

Seeded presets

-

These were inserted on first run and can be edited like any other link.

-
-
-
- {presetCards.map((preset) => ( - - ))} -
-
-

Existing links