diff --git a/README.md b/README.md index 1e1c208..7382145 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ Amounts and worked/vacation/sick statuses selected for dates are stored persiste python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -waitress-serve --host=0.0.0.0 --port=9009 app:app +waitress-serve --host=0.0.0.0 --port=9009 fatima:app ``` To use a different port, substitute it in the command (for example, -`--port=8080`). The application exposes its WSGI callable as `app:app`, so it +`--port=8080`). The application exposes its WSGI callable as `fatima:app`, so it can also be used by platforms that ask for an application entry point. Set the username, password, and Flask signing key in `config.yaml` before starting. This file is excluded from Git. Use `config.example.yaml` as the documented template when setting up another machine. @@ -30,6 +30,20 @@ amounts: The €0 and custom-amount controls remain available automatically. +## Reverse proxy path + +The app is served below `/fatima` by default. Configure the external path in +`config.yaml`: + +```yaml +flask: + url_prefix: "/fatima" +``` + +Set `URL_PREFIX` in the environment to override this value at deployment time. +Use an empty value (`url_prefix: ""` or `URL_PREFIX=`) to serve from `/` instead. +The reverse proxy must preserve the prefix when forwarding requests to Waitress. + ## Import the Excel calendar Preview an import without changing the database: @@ -40,7 +54,7 @@ python scripts/import_excel.py /path/to/fatima.xlsx Add `--apply` to import. The command backs up the existing SQLite file before updating it. -On the iPhone, visit `http://:9009`. +On the iPhone, visit `http://:9009/fatima/`. Waitress listens on all network interfaces with the command above. For anything exposed beyond a trusted home network, put it behind an HTTPS reverse proxy. diff --git a/config.example.yaml b/config.example.yaml index 0b1abd9..6b308a8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,6 +1,8 @@ flask: # Generate with: python -c "import secrets; print(secrets.token_hex(32))" secret_key: "replace-with-a-long-random-value" + # External path used by the reverse proxy. Use "" to serve from the domain root. + url_prefix: "/fatima" auth: username: "your-username" diff --git a/app.py b/flask_fatima.py similarity index 87% rename from app.py rename to flask_fatima.py index eaaaee4..bb6980e 100644 --- a/app.py +++ b/flask_fatima.py @@ -10,6 +10,34 @@ from flask import Flask, jsonify, redirect, render_template, request, session, u import yaml +def normalize_url_prefix(value): + if not isinstance(value, str): + raise RuntimeError("flask.url_prefix must be a string") + value = value.strip() + if not value or value == "/": + return "" + return f"/{value.strip('/')}" + + +class UrlPrefixMiddleware: + """Mount a WSGI application below a configurable URL prefix.""" + + def __init__(self, application, prefix): + self.application = application + self.prefix = prefix + + def __call__(self, environ, start_response): + path = environ.get("PATH_INFO", "") + if self.prefix and path != self.prefix and not path.startswith(f"{self.prefix}/"): + start_response("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")]) + return [b"Not Found"] + + if self.prefix: + environ["SCRIPT_NAME"] = f"{environ.get('SCRIPT_NAME', '')}{self.prefix}" + environ["PATH_INFO"] = path[len(self.prefix):] or "/" + return self.application(environ, start_response) + + def load_yaml_config(path): config_path = Path(path) if not config_path.is_file(): @@ -28,8 +56,12 @@ def load_yaml_config(path): "SECRET_KEY": flask_config.get("secret_key"), "APP_USERNAME": auth.get("username"), "APP_PASSWORD": auth.get("password"), + "URL_PREFIX": normalize_url_prefix(flask_config.get("url_prefix", "/fatima")), } - missing = [name for name, value in required.items() if not isinstance(value, str) or not value] + missing = [ + name for name, value in required.items() + if name != "URL_PREFIX" and (not isinstance(value, str) or not value) + ] if missing: raise RuntimeError(f"Missing or empty values in {config_path}: {', '.join(missing)}") presets = amount_config.get("presets", ["55", "27.50"]) @@ -58,8 +90,12 @@ def create_app(test_config=None): for name in ("SECRET_KEY", "APP_USERNAME", "APP_PASSWORD"): if os.environ.get(name): app.config[name] = os.environ[name] + if "URL_PREFIX" in os.environ: + app.config["URL_PREFIX"] = normalize_url_prefix(os.environ["URL_PREFIX"]) if test_config: app.config.update(test_config) + app.config["URL_PREFIX"] = normalize_url_prefix(app.config["URL_PREFIX"]) + app.wsgi_app = UrlPrefixMiddleware(app.wsgi_app, app.config["URL_PREFIX"]) Path(app.config["DATABASE"]).parent.mkdir(parents=True, exist_ok=True) with sqlite3.connect(app.config["DATABASE"]) as database: diff --git a/tests/test_app.py b/tests/test_app.py index f1612d4..e4f5838 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,9 +1,9 @@ import tempfile -from app import create_app +from fatima import create_app -def client(): +def client(url_prefix="/fatima"): database = tempfile.NamedTemporaryFile(suffix=".sqlite") app = create_app({ "TESTING": True, @@ -11,6 +11,7 @@ def client(): "APP_USERNAME": "user", "APP_PASSWORD": "pass", "DATABASE": database.name, + "URL_PREFIX": url_prefix, }) test_client = app.test_client() test_client._database_file = database @@ -18,59 +19,79 @@ def client(): def test_calendar_requires_login(): - response = client().get("/") + response = client().get("/fatima/") assert response.status_code == 302 - assert response.headers["Location"].endswith("/login") + assert response.headers["Location"].endswith("/fatima/login") + + +def test_prefix_applies_to_generated_links_and_static_assets(): + test_client = client() + test_client.post("/fatima/login", data={"username": "user", "password": "pass"}) + page = test_client.get("/fatima/") + + assert b'href="/fatima/static/styles.css"' in page.data + assert b'src="/fatima/static/calendar.js"' in page.data + assert b'data-save-url="/fatima/api/choices"' in page.data + assert test_client.get("/").status_code == 404 + + +def test_url_prefix_is_configurable_and_can_be_disabled(): + custom_client = client("/calendar") + assert custom_client.get("/calendar/").headers["Location"].endswith("/calendar/login") + assert custom_client.get("/fatima/").status_code == 404 + + root_client = client("") + assert root_client.get("/").headers["Location"].endswith("/login") def test_login_and_logout(): test_client = client() - response = test_client.post("/login", data={"username": "user", "password": "pass"}) + response = test_client.post("/fatima/login", data={"username": "user", "password": "pass"}) assert response.status_code == 302 - assert response.headers["Location"].endswith("/") - assert test_client.get("/").status_code == 200 - assert test_client.post("/logout").headers["Location"].endswith("/login") + assert response.headers["Location"].endswith("/fatima/") + assert test_client.get("/fatima/").status_code == 200 + assert test_client.post("/fatima/logout").headers["Location"].endswith("/fatima/login") def test_bad_login_shows_error(): - response = client().post("/login", data={"username": "user", "password": "wrong"}) + response = client().post("/fatima/login", data={"username": "user", "password": "wrong"}) assert response.status_code == 200 assert b"not correct" in response.data def test_choice_is_saved_and_rendered(): test_client = client() - test_client.post("/login", data={"username": "user", "password": "pass"}) - response = test_client.post("/api/choices", json={"date": "2026-07-15", "amount": "27.50"}) + test_client.post("/fatima/login", data={"username": "user", "password": "pass"}) + response = test_client.post("/fatima/api/choices", json={"date": "2026-07-15", "amount": "27.50"}) assert response.status_code == 200 assert response.json["amount_cents"] == 2750 - assert b'"2026-07-15": 2750' in test_client.get("/").data + assert b'"2026-07-15": 2750' in test_client.get("/fatima/").data def test_choice_rejects_invalid_amount(): test_client = client() - test_client.post("/login", data={"username": "user", "password": "pass"}) - response = test_client.post("/api/choices", json={"date": "2026-07-15", "amount": "-1"}) + test_client.post("/fatima/login", data={"username": "user", "password": "pass"}) + response = test_client.post("/fatima/api/choices", json={"date": "2026-07-15", "amount": "-1"}) assert response.status_code == 400 def test_day_status_is_saved_and_rendered(): test_client = client() - test_client.post("/login", data={"username": "user", "password": "pass"}) + test_client.post("/fatima/login", data={"username": "user", "password": "pass"}) response = test_client.post( - "/api/statuses", + "/fatima/api/statuses", json={"date": "2026-07-15", "status": "vacation"}, ) assert response.status_code == 200 assert response.json["status"] == "vacation" - assert b'"2026-07-15": "vacation"' in test_client.get("/").data + assert b'"2026-07-15": "vacation"' in test_client.get("/fatima/").data def test_day_status_rejects_unknown_value(): test_client = client() - test_client.post("/login", data={"username": "user", "password": "pass"}) + test_client.post("/fatima/login", data={"username": "user", "password": "pass"}) response = test_client.post( - "/api/statuses", + "/fatima/api/statuses", json={"date": "2026-07-15", "status": "elsewhere"}, ) assert response.status_code == 400 @@ -78,14 +99,14 @@ def test_day_status_rejects_unknown_value(): def test_delete_removes_amount_and_status(): test_client = client() - test_client.post("/login", data={"username": "user", "password": "pass"}) - test_client.post("/api/choices", json={"date": "2026-07-15", "amount": "55"}) - test_client.post("/api/statuses", json={"date": "2026-07-15", "status": "sick"}) + test_client.post("/fatima/login", data={"username": "user", "password": "pass"}) + test_client.post("/fatima/api/choices", json={"date": "2026-07-15", "amount": "55"}) + test_client.post("/fatima/api/statuses", json={"date": "2026-07-15", "status": "sick"}) - response = test_client.post("/api/delete-date", json={"date": "2026-07-15"}) + response = test_client.post("/fatima/api/delete-date", json={"date": "2026-07-15"}) assert response.status_code == 200 assert response.json["deleted"] is True - page = test_client.get("/").data + page = test_client.get("/fatima/").data assert b'"2026-07-15": 5500' not in page assert b'"2026-07-15": "sick"' not in page @@ -94,15 +115,15 @@ def test_report_groups_money_and_days_by_status(): from datetime import date test_client = client() - test_client.post("/login", data={"username": "user", "password": "pass"}) + test_client.post("/fatima/login", data={"username": "user", "password": "pass"}) year = date.today().year worked_date = f"{year}-01-10" vacation_date = f"{year}-02-10" - test_client.post("/api/choices", json={"date": worked_date, "amount": "55"}) - test_client.post("/api/choices", json={"date": vacation_date, "amount": "27.50"}) - test_client.post("/api/statuses", json={"date": vacation_date, "status": "vacation"}) + test_client.post("/fatima/api/choices", json={"date": worked_date, "amount": "55"}) + test_client.post("/fatima/api/choices", json={"date": vacation_date, "amount": "27.50"}) + test_client.post("/fatima/api/statuses", json={"date": vacation_date, "status": "vacation"}) - response = test_client.get("/report") + response = test_client.get("/fatima/report") assert response.status_code == 200 assert b"\xe2\x82\xac55.00" in response.data assert b"\xe2\x82\xac27.50" in response.data @@ -111,8 +132,8 @@ def test_report_groups_money_and_days_by_status(): def test_report_can_navigate_to_another_year(): test_client = client() - test_client.post("/login", data={"username": "user", "password": "pass"}) - response = test_client.get("/report?year=2025") + test_client.post("/fatima/login", data={"username": "user", "password": "pass"}) + response = test_client.get("/fatima/report?year=2025") assert response.status_code == 200 assert b"2025 report" in response.data assert b"year=2024" in response.data