Harden app for production behind nginx Proxy Manager.
Remove path-based import, add rate limits and upload caps, security headers, proxy trust, bundled Chart.js, non-root Docker, and NPM deployment docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+12
-4
@@ -4,18 +4,26 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
DATA_DIR=/data
|
DATA_DIR=/data \
|
||||||
|
TRUST_PROXY=1 \
|
||||||
|
DISABLE_LOCAL_VIEWER=1 \
|
||||||
|
PREFERRED_URL_SCHEME=https \
|
||||||
|
MAX_UPLOAD_MB=10
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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 app.py db.py parser.py categories.py validation.py viewers.py security.py ./
|
||||||
COPY templates/ templates/
|
COPY templates/ templates/
|
||||||
COPY static/ static/
|
COPY static/ static/
|
||||||
|
|
||||||
RUN mkdir -p /data/viewers /data/uploads
|
RUN mkdir -p /data/viewers /data/uploads \
|
||||||
|
&& useradd --create-home --uid 1000 --shell /usr/sbin/nologin appuser \
|
||||||
|
&& chown -R appuser:appuser /app /data
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "app:app"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ A web viewer for backups of the Android game **Idle Fantasy**. Parses `fantasyid
|
|||||||
- **SQLite history** — import multiple backups, compare snapshots, coins/level charts
|
- **SQLite history** — import multiple backups, compare snapshots, coins/level charts
|
||||||
- **Import** via CLI or browser upload
|
- **Import** via CLI or browser upload
|
||||||
- **Multi-user** without login — each player gets their own viewer via a secret link
|
- **Multi-user** without login — each player gets their own viewer via a secret link
|
||||||
- **Docker** — ready to run on a server
|
- **Docker** — ready to run behind nginx Proxy Manager
|
||||||
- **i18n** — English as default/fallback, German optional; automatic browser language or manual selection in the sidebar
|
- **i18n** — English as default/fallback, German optional; automatic browser language or manual selection in the sidebar
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -37,15 +37,17 @@ The browser opens automatically at `http://127.0.0.1:5000/v/local/`.
|
|||||||
|
|
||||||
### Docker (host for other players)
|
### Docker (host for other players)
|
||||||
|
|
||||||
|
Designed to run **behind nginx Proxy Manager** — the container port is not published publicly by default.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The viewer is then available at `http://localhost:5000`:
|
1. Attach the `viewer` service to your NPM Docker network (see `docker-compose.yml` comments).
|
||||||
|
2. In NPM: new Proxy Host → forward to `viewer:5000`, enable SSL.
|
||||||
1. Landing page → **Create my viewer**
|
3. Open your public URL → **Create my viewer**
|
||||||
2. Save the personal link (bookmark) — **without the link, data cannot be recovered** (no login)
|
4. Save the personal link (bookmark) — **without the link, data cannot be recovered** (no login)
|
||||||
3. Import backups in the browser
|
5. Import backups in the browser
|
||||||
|
|
||||||
Data is stored in the Docker volume `viewer-data` (`/data/viewers/<id>.db`).
|
Data is stored in the Docker volume `viewer-data` (`/data/viewers/<id>.db`).
|
||||||
|
|
||||||
@@ -57,7 +59,12 @@ docker compose logs -f
|
|||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
The `DATA_DIR` environment variable (default in Docker: `/data`) sets the storage location.
|
For local Docker testing without NPM, temporarily add to `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
```
|
||||||
|
|
||||||
### More options
|
### More options
|
||||||
|
|
||||||
@@ -92,7 +99,66 @@ Each viewer has its own SQLite database at `data/viewers/<viewer_id>.db`.
|
|||||||
|
|
||||||
The `viewer_id` is a random URL-safe token. Anyone with the link has access — there is no password and no recovery if the link is lost.
|
The `viewer_id` is a random URL-safe token. Anyone with the link has access — there is no password and no recovery if the link is lost.
|
||||||
|
|
||||||
Local CLI usage defaults to the `local` viewer (`/v/local/`).
|
Local CLI usage defaults to the `local` viewer (`/v/local/`). In Docker/production, `/v/local/` is disabled (`DISABLE_LOCAL_VIEWER=1`).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The app uses **secret-link access** (no accounts). Suitable for sharing with trusted players when deployed behind HTTPS.
|
||||||
|
|
||||||
|
### Built-in protections
|
||||||
|
|
||||||
|
| Measure | Default (Docker) |
|
||||||
|
|---------|------------------|
|
||||||
|
| Upload size limit | 10 MB (`MAX_UPLOAD_MB`) |
|
||||||
|
| Viewer creation rate limit | 5 / minute per IP |
|
||||||
|
| Import rate limit | 20 / hour per IP |
|
||||||
|
| Path-based import | **Removed** (upload only) |
|
||||||
|
| `/v/local/` in production | Disabled |
|
||||||
|
| Reverse-proxy headers | `TRUST_PROXY=1` (ProxyFix) |
|
||||||
|
| HTTPS links | `PREFERRED_URL_SCHEME=https` |
|
||||||
|
| Security headers | CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy` |
|
||||||
|
| Chart.js | Bundled locally (no CDN) |
|
||||||
|
| Container user | Non-root (`appuser`, uid 1000) |
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DATA_DIR` | `./data` | SQLite and upload storage |
|
||||||
|
| `TRUST_PROXY` | `1` in Docker | Trust `X-Forwarded-*` from nginx |
|
||||||
|
| `PREFERRED_URL_SCHEME` | `https` in Docker | Scheme for generated viewer URLs |
|
||||||
|
| `DISABLE_LOCAL_VIEWER` | `1` in Docker | Block predictable `/v/local/` |
|
||||||
|
| `MAX_UPLOAD_MB` | `10` | Max upload body size |
|
||||||
|
| `RATE_LIMIT_VIEWER_CREATE` | `5 per minute` | Limit for `POST /api/viewers` |
|
||||||
|
| `RATE_LIMIT_IMPORT` | `20 per hour` | Limit for `POST .../import` |
|
||||||
|
|
||||||
|
### nginx Proxy Manager
|
||||||
|
|
||||||
|
Recommended NPM settings:
|
||||||
|
|
||||||
|
- **SSL** with Force SSL
|
||||||
|
- **Block Common Exploits** enabled
|
||||||
|
- Forward headers: `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Real-IP` (default)
|
||||||
|
- Optional **Advanced** config:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=viewer_create:10m rate=5r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=viewer_api:10m rate=30r/s;
|
||||||
|
|
||||||
|
location /api/viewers {
|
||||||
|
limit_req zone=viewer_create burst=2 nodelay;
|
||||||
|
proxy_pass http://viewer:5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
limit_req zone=viewer_api burst=50 nodelay;
|
||||||
|
proxy_pass http://viewer:5000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** expose port `5000` publicly — only NPM should reach the container.
|
||||||
|
|
||||||
## Language / i18n
|
## Language / i18n
|
||||||
|
|
||||||
@@ -107,6 +173,7 @@ Local CLI usage defaults to the `local` viewer (`/v/local/`).
|
|||||||
```
|
```
|
||||||
idle-fantasy-viewer/
|
idle-fantasy-viewer/
|
||||||
├── app.py # Flask server and CLI
|
├── app.py # Flask server and CLI
|
||||||
|
├── security.py # Rate limits, headers, proxy trust
|
||||||
├── viewers.py # Viewer IDs and isolation
|
├── viewers.py # Viewer IDs and isolation
|
||||||
├── parser.py # Parse and normalize saves
|
├── parser.py # Parse and normalize saves
|
||||||
├── categories.py # Item categories (heuristics)
|
├── categories.py # Item categories (heuristics)
|
||||||
@@ -115,6 +182,7 @@ idle-fantasy-viewer/
|
|||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── static/
|
├── static/
|
||||||
|
│ ├── vendor/ # chart.umd.min.js (bundled)
|
||||||
│ ├── i18n.js # Locale loading, t(), en fallback
|
│ ├── i18n.js # Locale loading, t(), en fallback
|
||||||
│ ├── locales/ # en.json, de.json
|
│ ├── locales/ # en.json, de.json
|
||||||
│ ├── landing.js # Landing page
|
│ ├── landing.js # Landing page
|
||||||
@@ -128,12 +196,12 @@ idle-fantasy-viewer/
|
|||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `GET /` | Landing page |
|
| `GET /` | Landing page |
|
||||||
| `POST /api/viewers` | Create a new viewer |
|
| `POST /api/viewers` | Create a new viewer (rate limited) |
|
||||||
| `GET /v/<id>/api/snapshot/latest` | Latest save for the viewer |
|
| `GET /v/<id>/api/snapshot/latest` | Latest save for the viewer |
|
||||||
| `GET /v/<id>/api/snapshots` | All snapshots |
|
| `GET /v/<id>/api/snapshots` | All snapshots |
|
||||||
| `GET /v/<id>/api/snapshots/<older>/diff/<newer>` | Compare two snapshots |
|
| `GET /v/<id>/api/snapshots/<older>/diff/<newer>` | Compare two snapshots |
|
||||||
| `GET /v/<id>/api/timeline` | Time series for charts |
|
| `GET /v/<id>/api/timeline` | Time series for charts |
|
||||||
| `POST /v/<id>/api/import` | JSON upload |
|
| `POST /v/<id>/api/import` | JSON file upload (`.json` only, rate limited) |
|
||||||
|
|
||||||
## Save format
|
## Save format
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ from db import (
|
|||||||
get_connection,
|
get_connection,
|
||||||
timeline,
|
timeline,
|
||||||
)
|
)
|
||||||
|
from security import (
|
||||||
|
IMPORT_LIMIT,
|
||||||
|
VIEWER_CREATE_LIMIT,
|
||||||
|
configure_app,
|
||||||
|
external_base_url,
|
||||||
|
limiter,
|
||||||
|
local_viewer_disabled,
|
||||||
|
)
|
||||||
from viewers import (
|
from viewers import (
|
||||||
LOCAL_VIEWER_ID,
|
LOCAL_VIEWER_ID,
|
||||||
create_viewer,
|
create_viewer,
|
||||||
@@ -32,6 +40,7 @@ from viewers import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
configure_app(app)
|
||||||
|
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
|
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
|
||||||
DB_PATH = DEFAULT_DB
|
DB_PATH = DEFAULT_DB
|
||||||
@@ -42,11 +51,12 @@ def get_data_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _viewer_url(viewer_id: str) -> str:
|
def _viewer_url(viewer_id: str) -> str:
|
||||||
base = request.host_url.rstrip("/")
|
return f"{external_base_url()}/v/{viewer_id}/"
|
||||||
return f"{base}/v/{viewer_id}/"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_viewer_db(viewer_id: str) -> Path:
|
def _resolve_viewer_db(viewer_id: str) -> Path:
|
||||||
|
if viewer_id == LOCAL_VIEWER_ID and local_viewer_disabled():
|
||||||
|
abort(404)
|
||||||
if not is_valid_viewer_id(viewer_id):
|
if not is_valid_viewer_id(viewer_id):
|
||||||
abort(404)
|
abort(404)
|
||||||
db_path = viewer_db_path(viewer_id, get_data_dir())
|
db_path = viewer_db_path(viewer_id, get_data_dir())
|
||||||
@@ -93,8 +103,8 @@ def api_diff(viewer_id: str, older_id: int, newer_id: int):
|
|||||||
db_path = _resolve_viewer_db(viewer_id)
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
return jsonify({"error": str(e)}), 404
|
return jsonify({"error": "Snapshot not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
@viewer_bp.route("/api/timeline")
|
@viewer_bp.route("/api/timeline")
|
||||||
@@ -104,34 +114,28 @@ def api_timeline(viewer_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@viewer_bp.route("/api/import", methods=["POST"])
|
@viewer_bp.route("/api/import", methods=["POST"])
|
||||||
|
@limiter.limit(IMPORT_LIMIT)
|
||||||
def api_import(viewer_id: str):
|
def api_import(viewer_id: str):
|
||||||
db_path = _resolve_viewer_db(viewer_id)
|
db_path = _resolve_viewer_db(viewer_id)
|
||||||
|
|
||||||
if "file" in request.files:
|
if "file" not in request.files:
|
||||||
f = request.files["file"]
|
return jsonify({"error": "No file uploaded"}), 400
|
||||||
if not f.filename:
|
f = request.files["file"]
|
||||||
return jsonify({"error": "No file selected"}), 400
|
if not f.filename:
|
||||||
upload_dir = get_data_dir() / "uploads"
|
return jsonify({"error": "No file selected"}), 400
|
||||||
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)
|
|
||||||
finally:
|
|
||||||
tmp.unlink(missing_ok=True)
|
|
||||||
if result.get("error"):
|
|
||||||
return jsonify(result), 422
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
body = request.get_json(silent=True) or {}
|
upload_dir = get_data_dir() / "uploads"
|
||||||
path = body.get("path")
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
if not path:
|
safe_name = secure_filename(f.filename) or "upload.json"
|
||||||
return jsonify({"error": "Provide file upload or JSON body with path"}), 400
|
if not safe_name.lower().endswith(".json"):
|
||||||
path = Path(path)
|
return jsonify({"error": "Only .json files are accepted"}), 400
|
||||||
if not path.exists():
|
|
||||||
return jsonify({"error": f"File not found: {path}"}), 404
|
tmp = upload_dir / f"_upload_{viewer_id}_{safe_name}"
|
||||||
result = import_save(path, db_path=db_path)
|
f.save(tmp)
|
||||||
|
try:
|
||||||
|
result = import_save(tmp, db_path=db_path)
|
||||||
|
finally:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
return jsonify(result), 422
|
return jsonify(result), 422
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
@@ -146,6 +150,7 @@ def landing():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/viewers")
|
@app.post("/api/viewers")
|
||||||
|
@limiter.limit(VIEWER_CREATE_LIMIT)
|
||||||
def api_create_viewer():
|
def api_create_viewer():
|
||||||
viewer_id = create_viewer(get_data_dir())
|
viewer_id = create_viewer(get_data_dir())
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -154,6 +159,16 @@ def api_create_viewer():
|
|||||||
}), 201
|
}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def request_too_large(_exc):
|
||||||
|
return jsonify({"error": "Upload too large"}), 413
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(429)
|
||||||
|
def rate_limit_exceeded(_exc):
|
||||||
|
return jsonify({"error": "Too many requests"}), 429
|
||||||
|
|
||||||
|
|
||||||
def _print_import_report(result: dict) -> None:
|
def _print_import_report(result: dict) -> None:
|
||||||
report = result.get("import_report") or []
|
report = result.get("import_report") or []
|
||||||
if not report:
|
if not report:
|
||||||
|
|||||||
+15
-2
@@ -1,13 +1,26 @@
|
|||||||
services:
|
services:
|
||||||
viewer:
|
viewer:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
expose:
|
||||||
- "5000:5000"
|
- "5000"
|
||||||
environment:
|
environment:
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
|
TRUST_PROXY: "1"
|
||||||
|
DISABLE_LOCAL_VIEWER: "1"
|
||||||
|
PREFERRED_URL_SCHEME: https
|
||||||
|
MAX_UPLOAD_MB: "10"
|
||||||
|
RATE_LIMIT_VIEWER_CREATE: "5 per minute"
|
||||||
|
RATE_LIMIT_IMPORT: "20 per hour"
|
||||||
volumes:
|
volumes:
|
||||||
- viewer-data:/data
|
- viewer-data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Attach to your nginx Proxy Manager network (uncomment and set name):
|
||||||
|
# networks:
|
||||||
|
# - npm
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
viewer-data:
|
viewer-data:
|
||||||
|
|
||||||
|
# networks:
|
||||||
|
# npm:
|
||||||
|
# external: true
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
flask>=3.0
|
flask>=3.0
|
||||||
|
flask-limiter>=3.8
|
||||||
gunicorn>=22.0
|
gunicorn>=22.0
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
"""Application security: limits, proxy trust, headers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask, request
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")
|
||||||
|
|
||||||
|
DEFAULT_MAX_UPLOAD_MB = 10
|
||||||
|
VIEWER_CREATE_LIMIT = os.environ.get("RATE_LIMIT_VIEWER_CREATE", "5 per minute")
|
||||||
|
IMPORT_LIMIT = os.environ.get("RATE_LIMIT_IMPORT", "20 per hour")
|
||||||
|
|
||||||
|
|
||||||
|
def local_viewer_disabled() -> bool:
|
||||||
|
return os.environ.get("DISABLE_LOCAL_VIEWER", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def trust_proxy_enabled() -> bool:
|
||||||
|
return os.environ.get("TRUST_PROXY", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def configure_app(flask_app: Flask) -> None:
|
||||||
|
max_mb = int(os.environ.get("MAX_UPLOAD_MB", DEFAULT_MAX_UPLOAD_MB))
|
||||||
|
flask_app.config["MAX_CONTENT_LENGTH"] = max_mb * 1024 * 1024
|
||||||
|
|
||||||
|
if trust_proxy_enabled():
|
||||||
|
flask_app.wsgi_app = ProxyFix(
|
||||||
|
flask_app.wsgi_app,
|
||||||
|
x_for=1,
|
||||||
|
x_proto=1,
|
||||||
|
x_host=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
limiter.init_app(flask_app)
|
||||||
|
|
||||||
|
@flask_app.after_request
|
||||||
|
def _security_headers(response):
|
||||||
|
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"frame-ancestors 'self'"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def external_base_url() -> str:
|
||||||
|
"""Build public base URL (respects reverse proxy and PREFERRED_URL_SCHEME)."""
|
||||||
|
preferred = os.environ.get("PREFERRED_URL_SCHEME", "").strip()
|
||||||
|
if preferred:
|
||||||
|
return f"{preferred}://{request.host}"
|
||||||
|
return request.host_url.rstrip("/")
|
||||||
Vendored
+20
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Idle Fantasy Viewer</title>
|
<title>Idle Fantasy Viewer</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<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/vendor/chart.umd.min.js" defer></script>
|
||||||
<script src="/static/i18n.js" defer></script>
|
<script src="/static/i18n.js" defer></script>
|
||||||
<script>window.VIEWER_ID = {{ viewer_id|tojson }};</script>
|
<script>window.VIEWER_ID = {{ viewer_id|tojson }};</script>
|
||||||
<script src="/static/app.js" defer></script>
|
<script src="/static/app.js" defer></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user