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 \
|
||||
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 .
|
||||
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 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"]
|
||||
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
|
||||
- **Import** via CLI or browser upload
|
||||
- **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
|
||||
|
||||
## Requirements
|
||||
@@ -37,15 +37,17 @@ The browser opens automatically at `http://127.0.0.1:5000/v/local/`.
|
||||
|
||||
### Docker (host for other players)
|
||||
|
||||
Designed to run **behind nginx Proxy Manager** — the container port is not published publicly by default.
|
||||
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The viewer is then available at `http://localhost:5000`:
|
||||
|
||||
1. Landing page → **Create my viewer**
|
||||
2. Save the personal link (bookmark) — **without the link, data cannot be recovered** (no login)
|
||||
3. Import backups in the browser
|
||||
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.
|
||||
3. Open your public URL → **Create my viewer**
|
||||
4. Save the personal link (bookmark) — **without the link, data cannot be recovered** (no login)
|
||||
5. Import backups in the browser
|
||||
|
||||
Data is stored in the Docker volume `viewer-data` (`/data/viewers/<id>.db`).
|
||||
|
||||
@@ -57,7 +59,12 @@ docker compose logs -f
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -107,6 +173,7 @@ Local CLI usage defaults to the `local` viewer (`/v/local/`).
|
||||
```
|
||||
idle-fantasy-viewer/
|
||||
├── app.py # Flask server and CLI
|
||||
├── security.py # Rate limits, headers, proxy trust
|
||||
├── viewers.py # Viewer IDs and isolation
|
||||
├── parser.py # Parse and normalize saves
|
||||
├── categories.py # Item categories (heuristics)
|
||||
@@ -115,6 +182,7 @@ idle-fantasy-viewer/
|
||||
├── docker-compose.yml
|
||||
├── requirements.txt
|
||||
├── static/
|
||||
│ ├── vendor/ # chart.umd.min.js (bundled)
|
||||
│ ├── i18n.js # Locale loading, t(), en fallback
|
||||
│ ├── locales/ # en.json, de.json
|
||||
│ ├── landing.js # Landing page
|
||||
@@ -128,12 +196,12 @@ idle-fantasy-viewer/
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `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/snapshots` | All snapshots |
|
||||
| `GET /v/<id>/api/snapshots/<older>/diff/<newer>` | Compare two snapshots |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ from db import (
|
||||
get_connection,
|
||||
timeline,
|
||||
)
|
||||
from security import (
|
||||
IMPORT_LIMIT,
|
||||
VIEWER_CREATE_LIMIT,
|
||||
configure_app,
|
||||
external_base_url,
|
||||
limiter,
|
||||
local_viewer_disabled,
|
||||
)
|
||||
from viewers import (
|
||||
LOCAL_VIEWER_ID,
|
||||
create_viewer,
|
||||
@@ -32,6 +40,7 @@ from viewers import (
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
configure_app(app)
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent / "data"))
|
||||
DB_PATH = DEFAULT_DB
|
||||
@@ -42,11 +51,12 @@ def get_data_dir() -> Path:
|
||||
|
||||
|
||||
def _viewer_url(viewer_id: str) -> str:
|
||||
base = request.host_url.rstrip("/")
|
||||
return f"{base}/v/{viewer_id}/"
|
||||
return f"{external_base_url()}/v/{viewer_id}/"
|
||||
|
||||
|
||||
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):
|
||||
abort(404)
|
||||
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)
|
||||
try:
|
||||
return jsonify(diff_snapshots(older_id, newer_id, db_path=db_path))
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
except ValueError:
|
||||
return jsonify({"error": "Snapshot not found"}), 404
|
||||
|
||||
|
||||
@viewer_bp.route("/api/timeline")
|
||||
@@ -104,16 +114,22 @@ def api_timeline(viewer_id: str):
|
||||
|
||||
|
||||
@viewer_bp.route("/api/import", methods=["POST"])
|
||||
@limiter.limit(IMPORT_LIMIT)
|
||||
def api_import(viewer_id: str):
|
||||
db_path = _resolve_viewer_db(viewer_id)
|
||||
|
||||
if "file" in request.files:
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "No file uploaded"}), 400
|
||||
f = request.files["file"]
|
||||
if not f.filename:
|
||||
return jsonify({"error": "No file selected"}), 400
|
||||
|
||||
upload_dir = get_data_dir() / "uploads"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = secure_filename(f.filename) or "upload.json"
|
||||
if not safe_name.lower().endswith(".json"):
|
||||
return jsonify({"error": "Only .json files are accepted"}), 400
|
||||
|
||||
tmp = upload_dir / f"_upload_{viewer_id}_{safe_name}"
|
||||
f.save(tmp)
|
||||
try:
|
||||
@@ -124,18 +140,6 @@ def api_import(viewer_id: str):
|
||||
return jsonify(result), 422
|
||||
return jsonify(result)
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
path = body.get("path")
|
||||
if not path:
|
||||
return jsonify({"error": "Provide file upload or JSON body with path"}), 400
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
return jsonify({"error": f"File not found: {path}"}), 404
|
||||
result = import_save(path, db_path=db_path)
|
||||
if result.get("error"):
|
||||
return jsonify(result), 422
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
app.register_blueprint(viewer_bp)
|
||||
|
||||
@@ -146,6 +150,7 @@ def landing():
|
||||
|
||||
|
||||
@app.post("/api/viewers")
|
||||
@limiter.limit(VIEWER_CREATE_LIMIT)
|
||||
def api_create_viewer():
|
||||
viewer_id = create_viewer(get_data_dir())
|
||||
return jsonify({
|
||||
@@ -154,6 +159,16 @@ def api_create_viewer():
|
||||
}), 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:
|
||||
report = result.get("import_report") or []
|
||||
if not report:
|
||||
|
||||
+15
-2
@@ -1,13 +1,26 @@
|
||||
services:
|
||||
viewer:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
expose:
|
||||
- "5000"
|
||||
environment:
|
||||
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:
|
||||
- viewer-data:/data
|
||||
restart: unless-stopped
|
||||
# Attach to your nginx Proxy Manager network (uncomment and set name):
|
||||
# networks:
|
||||
# - npm
|
||||
|
||||
volumes:
|
||||
viewer-data:
|
||||
|
||||
# networks:
|
||||
# npm:
|
||||
# external: true
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
flask>=3.0
|
||||
flask-limiter>=3.8
|
||||
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">
|
||||
<title>Idle Fantasy Viewer</title>
|
||||
<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>window.VIEWER_ID = {{ viewer_id|tojson }};</script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user