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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-19 16:06:13 +02:00
parent fbc2deec45
commit f51f166fa1
14 changed files with 589 additions and 53 deletions
+12
View File
@@ -0,0 +1,12 @@
.venv/
__pycache__/
*.pyc
.git/
.gitignore
data/
*.db
fantasyidler_save.json
*.md
.dockerignore
Dockerfile
docker-compose.yml
+21
View File
@@ -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"]
+61 -13
View File
@@ -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/<id>.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/<viewer_id>.db`.
| Route | Beschreibung |
|-------|--------------|
| `GET /` | Startseite neuen Viewer anlegen |
| `POST /api/viewers` | Erstellt Viewer, liefert `{ viewer_id, url }` |
| `GET /v/<viewer_id>/` | Persönliches Dashboard |
| `GET /v/<viewer_id>/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/<neuer>` | 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/<id>/api/snapshot/latest` | Neuester Save des Viewers |
| `GET /v/<id>/api/snapshots` | Alle Snapshots |
| `GET /v/<id>/api/snapshots/<älter>/diff/<neuer>` | Vergleich |
| `GET /v/<id>/api/timeline` | Zeitreihe für Charts |
| `POST /v/<id>/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
+115 -34
View File
@@ -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_id>")
@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/<int:snapshot_id>")
def api_snapshot(snapshot_id: int):
data = get_snapshot(snapshot_id, db_path=DB_PATH)
@viewer_bp.route("/api/snapshot/<int:snapshot_id>")
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/<int:older_id>/diff/<int:newer_id>")
def api_diff(older_id: int, newer_id: int):
@viewer_bp.route("/api/snapshots/<int:older_id>/diff/<int:newer_id>")
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__":
+13
View File
@@ -0,0 +1,13 @@
services:
viewer:
build: .
ports:
- "5000:5000"
environment:
DATA_DIR: /data
volumes:
- viewer-data:/data
restart: unless-stopped
volumes:
viewer-data:
+1
View File
@@ -1 +1,2 @@
flask>=3.0
gunicorn>=22.0
+42 -6
View File
@@ -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 = `<p class='loading'>${esc(t("history.loading"))}</p>`;
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 = `<p class='empty-state'>${esc(diff.error)}</p>`;
+42
View File
@@ -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;
}
});
}
+17
View File
@@ -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:"
}
}
+17
View File
@@ -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:"
}
}
+118
View File
@@ -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;
+11
View File
@@ -7,6 +7,7 @@
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
<script src="/static/i18n.js" defer></script>
<script>window.VIEWER_ID = {{ viewer_id|tojson }};</script>
<script src="/static/app.js" defer></script>
</head>
<body>
@@ -46,6 +47,16 @@
<main class="main">
<header class="topbar">
<div id="viewer-link-banner" class="viewer-banner" hidden>
<div class="viewer-banner-text">
<strong data-i18n="viewer.linkTitle">Your personal link</strong>
<p class="viewer-banner-warning" data-i18n="viewer.linkWarning">
Save this link there is no login. Without it, your data is lost.
</p>
<code id="viewer-link-url" class="viewer-link-url"></code>
</div>
<button type="button" class="viewer-copy-btn" id="viewer-copy-link" data-i18n="viewer.copyLink">Copy link</button>
</div>
<div id="import-report" class="import-report" hidden></div>
<div id="character-header" class="character-header">
<span class="loading" data-i18n="app.loading">Loading save…</span>
+56
View File
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Idle Fantasy Viewer</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/i18n.js" defer></script>
<script src="/static/landing.js" defer></script>
</head>
<body class="landing-page">
<main class="landing-card">
<div class="brand landing-brand">
<span class="brand-icon"></span>
<div>
<h1 data-i18n="app.title">Idle Fantasy</h1>
<p class="subtitle" data-i18n="app.subtitle">Save Viewer</p>
</div>
</div>
<p class="landing-lead" data-i18n="viewer.landingLead">
Create your personal save viewer. No account just a private link to your data.
</p>
<ul class="landing-features">
<li data-i18n="viewer.featureDashboard">Skills, inventory, quests and history</li>
<li data-i18n="viewer.featureUpload">Import backups in the browser</li>
<li data-i18n="viewer.featurePrivate">Your data stays in your viewer only</li>
</ul>
<div class="landing-actions">
<button type="button" class="upload-btn landing-create" id="create-viewer" data-i18n="viewer.create">
Create my viewer
</button>
<p class="landing-hint" id="create-status" hidden></p>
</div>
<div class="landing-warning">
<strong data-i18n="viewer.warningTitle">Important</strong>
<p data-i18n="viewer.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.
</p>
</div>
<label class="lang-label landing-lang" for="locale-select">
<span data-i18n="settings.language">Language</span>
<select id="locale-select" class="select-input lang-select">
<option value="auto" data-i18n="settings.langAuto">Auto (browser)</option>
<option value="en" data-i18n="settings.langEn">English</option>
<option value="de" data-i18n="settings.langDe">Deutsch</option>
</select>
</label>
</main>
</body>
</html>
+63
View File
@@ -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