From f51f166fa1a3a0dc85a9a22a71916040a8384110 Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 19 Jun 2026 16:06:13 +0200 Subject: [PATCH] Add Docker deployment and per-player secret-link viewers. Each player gets an isolated SQLite viewer via a unique URL without login, with landing page warnings to save the link and compose-based hosting for sharing with others. Co-authored-by: Cursor --- .dockerignore | 12 ++++ Dockerfile | 21 ++++++ README.md | 74 ++++++++++++++++---- app.py | 149 +++++++++++++++++++++++++++++++---------- docker-compose.yml | 13 ++++ requirements.txt | 1 + static/app.js | 48 +++++++++++-- static/landing.js | 42 ++++++++++++ static/locales/de.json | 17 +++++ static/locales/en.json | 17 +++++ static/style.css | 118 ++++++++++++++++++++++++++++++++ templates/index.html | 11 +++ templates/landing.html | 56 ++++++++++++++++ viewers.py | 63 +++++++++++++++++ 14 files changed, 589 insertions(+), 53 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 static/landing.js create mode 100644 templates/landing.html create mode 100644 viewers.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..537cb14 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.venv/ +__pycache__/ +*.pyc +.git/ +.gitignore +data/ +*.db +fantasyidler_save.json +*.md +.dockerignore +Dockerfile +docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2281e5e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DATA_DIR=/data + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py db.py parser.py categories.py validation.py viewers.py ./ +COPY templates/ templates/ +COPY static/ static/ + +RUN mkdir -p /data/viewers /data/uploads + +VOLUME ["/data"] +EXPOSE 5000 + +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "app:app"] diff --git a/README.md b/README.md index 4405010..ff20157 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ Lokaler Web-Viewer für Backups des Android-Spiels **Idle Fantasy**. Parst `fant - **Inventar** mit Textsuche, Kategorie-Filtern, Sortierung und gruppierten Tabellen - **SQLite-Verlauf** – mehrere Backups importieren, Snapshots vergleichen, Coins-/Level-Charts - **Import** per CLI oder Upload im Browser -- Läuft nur lokal (`127.0.0.1`) +- **Multi-User** ohne Login – jeder Spieler erhält einen eigenen Viewer über einen geheimen Link +- **Docker** – für Betrieb auf einem Server - **i18n** – Englisch als Standard/Fallback, Deutsch optional; automatische Browser-Sprache oder manuelle Auswahl in der Sidebar ## Voraussetzungen @@ -32,7 +33,31 @@ pip install -r requirements.txt python app.py fantasyidler_save.json ``` -Der Browser öffnet sich automatisch unter `http://127.0.0.1:5000`. +Der Browser öffnet sich automatisch unter `http://127.0.0.1:5000/v/local/`. + +### Docker (für andere Spieler hosten) + +```powershell +docker compose up -d --build +``` + +Der Viewer ist dann unter `http://localhost:5000` erreichbar: + +1. Startseite → **Meinen Viewer erstellen** +2. Persönlichen Link speichern (Bookmark) – **ohne Link sind die Daten nicht wiederherstellbar** (kein Login) +3. Backups im Browser importieren + +Daten liegen im Docker-Volume `viewer-data` (`/data/viewers/.db`). + +```powershell +# Logs +docker compose logs -f + +# Stoppen +docker compose down +``` + +Umgebungsvariable `DATA_DIR` (Standard in Docker: `/data`) legt den Speicherort fest. ### Weitere Optionen @@ -43,13 +68,31 @@ python app.py --import backup2.json # Anderen Port, Browser nicht öffnen python app.py fantasyidler_save.json --port 8080 --no-browser -# Eigene SQLite-Datenbank +# Eigene SQLite-Datenbank (Legacy, ein Datei-Modus) python app.py --db data\meine_history.db fantasyidler_save.json + +# Server für Netzwerk/Docker binden +python app.py --host 0.0.0.0 --no-browser ``` ### Backups im Browser importieren -Im Tab **Inventar** (Sidebar unten): **Backup importieren** – wählt eine `.json`-Datei. Duplikate (gleicher Datei-Hash) werden übersprungen. +Sidebar unten: **Backup importieren** – wählt eine `.json`-Datei. Duplikate (gleicher Datei-Hash) werden übersprungen. + +## Multi-User (ohne Login) + +Jeder Viewer hat eine eigene SQLite-Datenbank unter `data/viewers/.db`. + +| Route | Beschreibung | +|-------|--------------| +| `GET /` | Startseite – neuen Viewer anlegen | +| `POST /api/viewers` | Erstellt Viewer, liefert `{ viewer_id, url }` | +| `GET /v//` | Persönliches Dashboard | +| `GET /v//api/...` | API für diesen Viewer | + +Die `viewer_id` ist ein zufälliges Token (URL-safe). Wer den Link kennt, hat Zugriff – es gibt kein Passwort und keine Wiederherstellung bei verlorenem Link. + +Lokale CLI-Nutzung nutzt standardmäßig den Viewer `local` (`/v/local/`). ## Sprache / i18n @@ -64,28 +107,33 @@ Im Tab **Inventar** (Sidebar unten): **Backup importieren** – wählt eine `.js ``` idle-fantasy-viewer/ ├── app.py # Flask-Server und CLI +├── viewers.py # Viewer-IDs und Isolation ├── parser.py # Save parsen und normalisieren ├── categories.py # Item-Kategorien (Heuristiken) ├── db.py # SQLite Snapshots, Diff, Timeline +├── Dockerfile +├── docker-compose.yml ├── requirements.txt ├── static/ │ ├── i18n.js # Locale-Laden, t(), Fallback en │ ├── locales/ # en.json, de.json +│ ├── landing.js # Startseite │ └── app.js # Dashboard-UI ├── templates/ # HTML -└── data/ # history.db (wird angelegt, gitignored) +└── data/ # viewers/*.db (gitignored) ``` -## API (lokal) +## API | Endpunkt | Beschreibung | |----------|--------------| -| `GET /` | Dashboard | -| `GET /api/snapshot/latest` | Neuester normalisierter Save | -| `GET /api/snapshots` | Alle Snapshots | -| `GET /api/snapshots/<älter>/diff/` | Vergleich zweier Backups | -| `GET /api/timeline` | Zeitreihe für Charts | -| `POST /api/import` | JSON-Upload oder `{"path": "..."}` | +| `GET /` | Startseite | +| `POST /api/viewers` | Neuen Viewer erstellen | +| `GET /v//api/snapshot/latest` | Neuester Save des Viewers | +| `GET /v//api/snapshots` | Alle Snapshots | +| `GET /v//api/snapshots/<älter>/diff/` | Vergleich | +| `GET /v//api/timeline` | Zeitreihe für Charts | +| `POST /v//api/import` | JSON-Upload | ## Save-Format @@ -93,7 +141,7 @@ Die Backup-Datei enthält u. a. doppelt JSON-kodierte Felder (`skillLevels`, `in ## Hinweise -- `data/history.db` speichert importierte Snapshots lokal; nicht mit ins Repo committen (steht in `.gitignore`). +- `data/viewers/` speichert pro Spieler eine SQLite-Datei; nicht mit ins Repo committen (steht in `.gitignore`). - Der Viewer ist ein inoffizielles Hilfstool, nicht mit dem Spiel verbunden. ## Robustheit bei Spiel-Updates diff --git a/app.py b/app.py index 3eac56a..aa2034b 100644 --- a/app.py +++ b/app.py @@ -1,15 +1,16 @@ #!/usr/bin/env python3 -"""Idle Fantasy Save Viewer – local Flask server.""" +"""Idle Fantasy Save Viewer – Flask server with per-viewer secret URLs.""" from __future__ import annotations import argparse -import json +import os import sys import webbrowser from pathlib import Path -from flask import Flask, jsonify, render_template, request +from flask import Blueprint, Flask, abort, jsonify, render_template, request +from werkzeug.utils import secure_filename from db import ( DEFAULT_DB, @@ -22,59 +23,101 @@ from db import ( get_connection, timeline, ) +from viewers import ( + LOCAL_VIEWER_ID, + create_viewer, + ensure_local_viewer, + is_valid_viewer_id, + viewer_db_path, +) + app = Flask(__name__) + +DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data")) DB_PATH = DEFAULT_DB -@app.route("/") -def index(): - return render_template("index.html") +def get_data_dir() -> Path: + return DATA_DIR -@app.route("/api/snapshot/latest") -def api_latest(): - data = get_latest_snapshot(db_path=DB_PATH) +def _viewer_url(viewer_id: str) -> str: + base = request.host_url.rstrip("/") + return f"{base}/v/{viewer_id}/" + + +def _resolve_viewer_db(viewer_id: str) -> Path: + if not is_valid_viewer_id(viewer_id): + abort(404) + db_path = viewer_db_path(viewer_id, get_data_dir()) + if not db_path.exists(): + abort(404) + return db_path + + +viewer_bp = Blueprint("viewer", __name__, url_prefix="/v/") + + +@viewer_bp.route("/") +def viewer_index(viewer_id: str): + _resolve_viewer_db(viewer_id) + return render_template("index.html", viewer_id=viewer_id) + + +@viewer_bp.route("/api/snapshot/latest") +def api_latest(viewer_id: str): + db_path = _resolve_viewer_db(viewer_id) + data = get_latest_snapshot(db_path=db_path) if not data: return jsonify({"error": "No snapshots imported yet"}), 404 return jsonify(data) -@app.route("/api/snapshot/") -def api_snapshot(snapshot_id: int): - data = get_snapshot(snapshot_id, db_path=DB_PATH) +@viewer_bp.route("/api/snapshot/") +def api_snapshot(viewer_id: str, snapshot_id: int): + db_path = _resolve_viewer_db(viewer_id) + data = get_snapshot(snapshot_id, db_path=db_path) if not data: return jsonify({"error": "Snapshot not found"}), 404 return jsonify(data) -@app.route("/api/snapshots") -def api_snapshots(): - return jsonify(list_snapshots(db_path=DB_PATH)) +@viewer_bp.route("/api/snapshots") +def api_snapshots(viewer_id: str): + db_path = _resolve_viewer_db(viewer_id) + return jsonify(list_snapshots(db_path=db_path)) -@app.route("/api/snapshots//diff/") -def api_diff(older_id: int, newer_id: int): +@viewer_bp.route("/api/snapshots//diff/") +def api_diff(viewer_id: str, older_id: int, newer_id: int): + db_path = _resolve_viewer_db(viewer_id) try: - return jsonify(diff_snapshots(older_id, newer_id, db_path=DB_PATH)) + return jsonify(diff_snapshots(older_id, newer_id, db_path=db_path)) except ValueError as e: return jsonify({"error": str(e)}), 404 -@app.route("/api/timeline") -def api_timeline(): - return jsonify(timeline(db_path=DB_PATH)) +@viewer_bp.route("/api/timeline") +def api_timeline(viewer_id: str): + db_path = _resolve_viewer_db(viewer_id) + return jsonify(timeline(db_path=db_path)) -@app.route("/api/import", methods=["POST"]) -def api_import(): +@viewer_bp.route("/api/import", methods=["POST"]) +def api_import(viewer_id: str): + db_path = _resolve_viewer_db(viewer_id) + if "file" in request.files: f = request.files["file"] if not f.filename: return jsonify({"error": "No file selected"}), 400 - tmp = Path(DB_PATH.parent) / f"_upload_{f.filename}" + upload_dir = get_data_dir() / "uploads" + upload_dir.mkdir(parents=True, exist_ok=True) + safe_name = secure_filename(f.filename) or "upload.json" + tmp = upload_dir / f"_upload_{viewer_id}_{safe_name}" f.save(tmp) try: - result = import_save(tmp, db_path=DB_PATH) + result = import_save(tmp, db_path=db_path) finally: tmp.unlink(missing_ok=True) if result.get("error"): @@ -88,12 +131,29 @@ def api_import(): path = Path(path) if not path.exists(): return jsonify({"error": f"File not found: {path}"}), 404 - result = import_save(path, db_path=DB_PATH) + result = import_save(path, db_path=db_path) if result.get("error"): return jsonify(result), 422 return jsonify(result) +app.register_blueprint(viewer_bp) + + +@app.route("/") +def landing(): + return render_template("landing.html") + + +@app.post("/api/viewers") +def api_create_viewer(): + viewer_id = create_viewer(get_data_dir()) + return jsonify({ + "viewer_id": viewer_id, + "url": _viewer_url(viewer_id), + }), 201 + + def _print_import_report(result: dict) -> None: report = result.get("import_report") or [] if not report: @@ -108,14 +168,31 @@ def main() -> int: parser.add_argument("save_file", nargs="?", help="Save JSON to import on start") parser.add_argument("--import", dest="import_file", metavar="FILE", help="Import save without starting server") parser.add_argument("--port", type=int, default=5000) + parser.add_argument("--host", default="127.0.0.1", help="Bind host (use 0.0.0.0 in Docker)") parser.add_argument("--no-browser", action="store_true") - parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="SQLite database path") + parser.add_argument("--db", type=Path, help="SQLite path (legacy single-file mode)") + parser.add_argument("--viewer", default=LOCAL_VIEWER_ID, help="Viewer id for CLI (default: local)") args = parser.parse_args() - global DB_PATH - DB_PATH = args.db + global DATA_DIR, DB_PATH + DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data")) - conn = get_connection(DB_PATH) + if args.db: + DB_PATH = args.db + db_path = DB_PATH + viewer_id = None + else: + viewer_id = ensure_local_viewer(DATA_DIR) if args.viewer == LOCAL_VIEWER_ID else args.viewer + if not is_valid_viewer_id(viewer_id): + print(f"Error: invalid viewer id: {viewer_id}", file=sys.stderr) + return 1 + db_path = viewer_db_path(viewer_id, DATA_DIR) + if not db_path.exists(): + conn = get_connection(db_path) + init_db(conn) + conn.close() + + conn = get_connection(db_path) init_db(conn) conn.close() @@ -125,7 +202,7 @@ def main() -> int: if not path.exists(): print(f"Error: file not found: {path}", file=sys.stderr) return 1 - result = import_save(path, db_path=DB_PATH) + result = import_save(path, db_path=db_path) if result.get("error"): print(f"Import failed: {result['error']}", file=sys.stderr) _print_import_report(result) @@ -146,11 +223,15 @@ def main() -> int: if args.import_file and not args.save_file: return 0 - url = f"http://127.0.0.1:{args.port}" + if viewer_id: + url = f"http://{args.host}:{args.port}/v/{viewer_id}/" + else: + url = f"http://{args.host}:{args.port}/" print(f"Starting server at {url}") - if not args.no_browser: + if not args.no_browser and args.host in ("127.0.0.1", "localhost"): webbrowser.open(url) - app.run(host="127.0.0.1", port=args.port, debug=False) + app.run(host=args.host, port=args.port, debug=False) + return 0 if __name__ == "__main__": diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f53f61f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + viewer: + build: . + ports: + - "5000:5000" + environment: + DATA_DIR: /data + volumes: + - viewer-data:/data + restart: unless-stopped + +volumes: + viewer-data: diff --git a/requirements.txt b/requirements.txt index 001e7c4..59710ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask>=3.0 +gunicorn>=22.0 diff --git a/static/app.js b/static/app.js index 03ee940..21da0dd 100644 --- a/static/app.js +++ b/static/app.js @@ -38,15 +38,51 @@ const CATEGORY_I18N_KEYS = { document.addEventListener("DOMContentLoaded", init); +function apiBase() { + const vid = window.VIEWER_ID; + return vid ? `/v/${vid}/api` : "/api"; +} + +function viewerPageUrl() { + const vid = window.VIEWER_ID; + if (!vid) return window.location.href; + return `${window.location.origin}/v/${vid}/`; +} + async function init() { await I18n.init(); applyStaticI18n(); setupLanguage(); + setupViewerBanner(); setupNav(); setupUpload(); await loadData(); } +function setupViewerBanner() { + const vid = window.VIEWER_ID; + if (!vid || vid === "local") return; + + const banner = document.getElementById("viewer-link-banner"); + const urlEl = document.getElementById("viewer-link-url"); + const copyBtn = document.getElementById("viewer-copy-link"); + const url = viewerPageUrl(); + + banner.hidden = false; + urlEl.textContent = url; + + copyBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(url); + const prev = copyBtn.textContent; + copyBtn.textContent = t("viewer.copied"); + setTimeout(() => { copyBtn.textContent = prev; }, 2000); + } catch { + window.prompt(t("viewer.copyPrompt"), url); + } + }); +} + function categoryLabel(cat) { const key = CATEGORY_I18N_KEYS[cat]; return key ? t(key) : cat; @@ -96,7 +132,7 @@ function setupUpload() { if (!file) return; const fd = new FormData(); fd.append("file", file); - const res = await fetch("/api/import", { method: "POST", body: fd }); + const res = await fetch(`${apiBase()}/import`, { method: "POST", body: fd }); const result = await res.json(); if (!res.ok || result.error) { showImportFailure(result); @@ -185,9 +221,9 @@ function renderImportReport(meta) { async function loadData() { try { - const res = await fetch("/api/snapshot/latest"); + const res = await fetch(`${apiBase()}/snapshot/latest`); if (!res.ok) { - showEmpty(t("empty.noSave")); + showEmpty(window.VIEWER_ID ? t("empty.noSaveWeb") : t("empty.noSave")); return; } state.data = await res.json(); @@ -629,8 +665,8 @@ async function loadHistoryTab() { panel.innerHTML = `

${esc(t("history.loading"))}

`; const [snapRes, tlRes] = await Promise.all([ - fetch("/api/snapshots"), - fetch("/api/timeline"), + fetch(`${apiBase()}/snapshots`), + fetch(`${apiBase()}/timeline`), ]); state.snapshots = await snapRes.json(); state.timeline = await tlRes.json(); @@ -756,7 +792,7 @@ async function runDiff() { } const older = Math.min(h.olderId, h.newerId); const newer = Math.max(h.olderId, h.newerId); - const res = await fetch(`/api/snapshots/${older}/diff/${newer}`); + const res = await fetch(`${apiBase()}/snapshots/${older}/diff/${newer}`); const diff = await res.json(); if (diff.error) { el.innerHTML = `

${esc(diff.error)}

`; diff --git a/static/landing.js b/static/landing.js new file mode 100644 index 0000000..cb6a8d4 --- /dev/null +++ b/static/landing.js @@ -0,0 +1,42 @@ +document.addEventListener("DOMContentLoaded", async () => { + await I18n.init(); + applyStaticI18n(); + setupLanguage(); + setupCreate(); +}); + +function applyStaticI18n() { + document.querySelectorAll("[data-i18n]").forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); +} + +function setupLanguage() { + const sel = document.getElementById("locale-select"); + sel.value = I18n.getPreference(); + sel.addEventListener("change", async (e) => { + await I18n.setPreference(e.target.value); + applyStaticI18n(); + }); +} + +function setupCreate() { + const btn = document.getElementById("create-viewer"); + const status = document.getElementById("create-status"); + btn.addEventListener("click", async () => { + btn.disabled = true; + status.hidden = false; + status.textContent = t("viewer.creating"); + status.className = "landing-hint"; + try { + const res = await fetch("/api/viewers", { method: "POST" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || t("viewer.createFailed")); + window.location.href = data.url; + } catch (err) { + status.textContent = err.message; + status.className = "landing-hint landing-hint-error"; + btn.disabled = false; + } + }); +} diff --git a/static/locales/de.json b/static/locales/de.json index 75a5920..b9de6af 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -26,6 +26,7 @@ }, "empty": { "noSave": "Kein Save importiert. Starte mit: python app.py fantasyidler_save.json", + "noSaveWeb": "Noch kein Save importiert. Importiere ein Backup über den Button in der Sidebar.", "loadError": "Fehler beim Laden: {message}", "unknown": "Unbekannt", "none": "Keine", @@ -174,5 +175,21 @@ "gems_jewelry": "Edelsteine & Schmuck", "potions_brews": "Tränke & Brauerei", "misc": "Sonstiges" + }, + "viewer": { + "landingLead": "Erstelle deinen persönlichen Save-Viewer. Kein Konto – nur ein privater Link zu deinen Daten.", + "featureDashboard": "Skills, Inventar, Quests und Verlauf", + "featureUpload": "Backups im Browser importieren", + "featurePrivate": "Deine Daten bleiben nur in deinem Viewer", + "create": "Meinen Viewer erstellen", + "creating": "Viewer wird erstellt…", + "createFailed": "Viewer konnte nicht erstellt werden", + "warningTitle": "Wichtig", + "warningBody": "Es gibt keinen Login. Dein Viewer ist nur über seinen einzigartigen Link erreichbar. Link speichern oder bookmarken – ohne ihn sind deine Daten nicht wiederherstellbar.", + "linkTitle": "Dein persönlicher Link", + "linkWarning": "Link speichern – es gibt keinen Login. Ohne Link sind deine Daten weg.", + "copyLink": "Link kopieren", + "copied": "Kopiert!", + "copyPrompt": "Viewer-Link kopieren:" } } diff --git a/static/locales/en.json b/static/locales/en.json index 8d3300d..50505f0 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -26,6 +26,7 @@ }, "empty": { "noSave": "No save imported. Start with: python app.py fantasyidler_save.json", + "noSaveWeb": "No save imported yet. Import a backup using the sidebar button.", "loadError": "Failed to load: {message}", "unknown": "Unknown", "none": "None", @@ -174,5 +175,21 @@ "gems_jewelry": "Gems & Jewelry", "potions_brews": "Potions & Brews", "misc": "Misc" + }, + "viewer": { + "landingLead": "Create your personal save viewer. No account – just a private link to your data.", + "featureDashboard": "Skills, inventory, quests and history", + "featureUpload": "Import backups in the browser", + "featurePrivate": "Your data stays in your viewer only", + "create": "Create my viewer", + "creating": "Creating viewer…", + "createFailed": "Could not create viewer", + "warningTitle": "Important", + "warningBody": "There is no login. Your viewer is only accessible via its unique link. Bookmark or save the link – without it, your data cannot be recovered.", + "linkTitle": "Your personal link", + "linkWarning": "Save this link – there is no login. Without it, your data is lost.", + "copyLink": "Copy link", + "copied": "Copied!", + "copyPrompt": "Copy your viewer link:" } } diff --git a/static/style.css b/static/style.css index e07ce1d..2d7ac90 100644 --- a/static/style.css +++ b/static/style.css @@ -597,6 +597,124 @@ tr:hover td { background: var(--bg-hover); } .list-compact li:last-child { border-bottom: none; } +/* Landing page */ +.landing-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: var(--bg); +} + +.landing-card { + width: 100%; + max-width: 520px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px; +} + +.landing-brand { margin-bottom: 20px; } + +.landing-lead { + color: var(--text-muted); + line-height: 1.5; + margin: 0 0 16px; +} + +.landing-features { + margin: 0 0 24px; + padding-left: 1.2rem; + color: var(--text); + line-height: 1.6; +} + +.landing-actions { margin-bottom: 20px; } + +.landing-create { + width: 100%; + border: none; + cursor: pointer; +} + +.landing-hint { + margin: 10px 0 0; + font-size: 0.85rem; + color: var(--text-muted); +} + +.landing-hint-error { color: #f87171; } + +.landing-warning { + background: rgba(251, 191, 36, 0.08); + border: 1px solid rgba(251, 191, 36, 0.35); + border-radius: 8px; + padding: 12px 14px; + margin-bottom: 20px; + font-size: 0.88rem; + line-height: 1.5; +} + +.landing-warning strong { color: #fbbf24; } + +.landing-lang { margin-top: 8px; } + +/* Viewer link banner */ +.viewer-banner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; + padding: 12px 16px; + border-radius: var(--radius); + border: 1px solid rgba(251, 191, 36, 0.35); + background: rgba(251, 191, 36, 0.08); +} + +.viewer-banner-text { + flex: 1; + min-width: 0; +} + +.viewer-banner-text strong { + display: block; + color: #fbbf24; + margin-bottom: 4px; +} + +.viewer-banner-warning { + margin: 0 0 8px; + font-size: 0.85rem; + color: var(--text-muted); +} + +.viewer-link-url { + display: block; + word-break: break-all; + font-size: 0.8rem; + color: var(--text); + background: var(--bg); + padding: 6px 8px; + border-radius: 6px; +} + +.viewer-copy-btn { + flex-shrink: 0; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-hover); + color: var(--text); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; +} + +.viewer-copy-btn:hover { background: var(--accent-dim); color: #fff; } + @media (max-width: 768px) { .sidebar { position: relative; diff --git a/templates/index.html b/templates/index.html index e090060..81a265d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,6 +7,7 @@ + @@ -46,6 +47,16 @@
+
Loading save… diff --git a/templates/landing.html b/templates/landing.html new file mode 100644 index 0000000..b984f95 --- /dev/null +++ b/templates/landing.html @@ -0,0 +1,56 @@ + + + + + + Idle Fantasy Viewer + + + + + +
+
+ +
+

Idle Fantasy

+

Save Viewer

+
+
+ +

+ Create your personal save viewer. No account – just a private link to your data. +

+ +
    +
  • Skills, inventory, quests and history
  • +
  • Import backups in the browser
  • +
  • Your data stays in your viewer only
  • +
+ +
+ + +
+ +
+ Important +

+ There is no login. Your viewer is only accessible via its unique link. + Bookmark or save the link – without it, your data cannot be recovered. +

+
+ + +
+ + diff --git a/viewers.py b/viewers.py new file mode 100644 index 0000000..a3bee84 --- /dev/null +++ b/viewers.py @@ -0,0 +1,63 @@ +"""Per-viewer isolation via secret URL tokens (no login).""" + +from __future__ import annotations + +import re +import secrets +from pathlib import Path + +from db import get_connection, init_db + +VIEWER_ID_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$") +LOCAL_VIEWER_ID = "local" + + +def generate_viewer_id() -> str: + return secrets.token_urlsafe(16) + + +def is_valid_viewer_id(viewer_id: str) -> bool: + if viewer_id == LOCAL_VIEWER_ID: + return True + return bool(viewer_id and VIEWER_ID_RE.match(viewer_id)) + + +def viewers_dir(data_dir: Path) -> Path: + return data_dir / "viewers" + + +def viewer_db_path(viewer_id: str, data_dir: Path) -> Path: + return viewers_dir(data_dir) / f"{viewer_id}.db" + + +def viewer_exists(viewer_id: str, data_dir: Path) -> bool: + return viewer_db_path(viewer_id, data_dir).exists() + + +def create_viewer(data_dir: Path) -> str: + """Create a new viewer with an empty SQLite database.""" + data_dir.mkdir(parents=True, exist_ok=True) + viewers_dir(data_dir).mkdir(parents=True, exist_ok=True) + + for _ in range(10): + viewer_id = generate_viewer_id() + db_path = viewer_db_path(viewer_id, data_dir) + if db_path.exists(): + continue + conn = get_connection(db_path) + init_db(conn) + conn.close() + return viewer_id + + raise RuntimeError("Could not allocate a unique viewer id") + + +def ensure_local_viewer(data_dir: Path) -> str: + """CLI default viewer – not secret, for local single-user use.""" + db_path = viewer_db_path(LOCAL_VIEWER_ID, data_dir) + if db_path.exists(): + return LOCAL_VIEWER_ID + conn = get_connection(db_path) + init_db(conn) + conn.close() + return LOCAL_VIEWER_ID