updated for reversed proxy

This commit is contained in:
2026-07-17 17:53:36 +02:00
parent f1ed598ebe
commit 2cda77b9a5
4 changed files with 108 additions and 35 deletions
+17 -3
View File
@@ -10,11 +10,11 @@ Amounts and worked/vacation/sick statuses selected for dates are stored persiste
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt 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, 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. 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. 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. 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 ## Import the Excel calendar
Preview an import without changing the database: 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. Add `--apply` to import. The command backs up the existing SQLite file before updating it.
On the iPhone, visit `http://<your-computer-on-the-local-network>:9009`. On the iPhone, visit `http://<your-computer-on-the-local-network>:9009/fatima/`.
Waitress listens on all network interfaces with the command above. For anything 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. exposed beyond a trusted home network, put it behind an HTTPS reverse proxy.
+2
View File
@@ -1,6 +1,8 @@
flask: flask:
# Generate with: python -c "import secrets; print(secrets.token_hex(32))" # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
secret_key: "replace-with-a-long-random-value" 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: auth:
username: "your-username" username: "your-username"
+37 -1
View File
@@ -10,6 +10,34 @@ from flask import Flask, jsonify, redirect, render_template, request, session, u
import yaml 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): def load_yaml_config(path):
config_path = Path(path) config_path = Path(path)
if not config_path.is_file(): if not config_path.is_file():
@@ -28,8 +56,12 @@ def load_yaml_config(path):
"SECRET_KEY": flask_config.get("secret_key"), "SECRET_KEY": flask_config.get("secret_key"),
"APP_USERNAME": auth.get("username"), "APP_USERNAME": auth.get("username"),
"APP_PASSWORD": auth.get("password"), "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: if missing:
raise RuntimeError(f"Missing or empty values in {config_path}: {', '.join(missing)}") raise RuntimeError(f"Missing or empty values in {config_path}: {', '.join(missing)}")
presets = amount_config.get("presets", ["55", "27.50"]) 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"): for name in ("SECRET_KEY", "APP_USERNAME", "APP_PASSWORD"):
if os.environ.get(name): if os.environ.get(name):
app.config[name] = os.environ[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: if test_config:
app.config.update(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) Path(app.config["DATABASE"]).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(app.config["DATABASE"]) as database: with sqlite3.connect(app.config["DATABASE"]) as database:
+52 -31
View File
@@ -1,9 +1,9 @@
import tempfile import tempfile
from app import create_app from fatima import create_app
def client(): def client(url_prefix="/fatima"):
database = tempfile.NamedTemporaryFile(suffix=".sqlite") database = tempfile.NamedTemporaryFile(suffix=".sqlite")
app = create_app({ app = create_app({
"TESTING": True, "TESTING": True,
@@ -11,6 +11,7 @@ def client():
"APP_USERNAME": "user", "APP_USERNAME": "user",
"APP_PASSWORD": "pass", "APP_PASSWORD": "pass",
"DATABASE": database.name, "DATABASE": database.name,
"URL_PREFIX": url_prefix,
}) })
test_client = app.test_client() test_client = app.test_client()
test_client._database_file = database test_client._database_file = database
@@ -18,59 +19,79 @@ def client():
def test_calendar_requires_login(): def test_calendar_requires_login():
response = client().get("/") response = client().get("/fatima/")
assert response.status_code == 302 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(): def test_login_and_logout():
test_client = client() 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.status_code == 302
assert response.headers["Location"].endswith("/") assert response.headers["Location"].endswith("/fatima/")
assert test_client.get("/").status_code == 200 assert test_client.get("/fatima/").status_code == 200
assert test_client.post("/logout").headers["Location"].endswith("/login") assert test_client.post("/fatima/logout").headers["Location"].endswith("/fatima/login")
def test_bad_login_shows_error(): 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 response.status_code == 200
assert b"not correct" in response.data assert b"not correct" in response.data
def test_choice_is_saved_and_rendered(): def test_choice_is_saved_and_rendered():
test_client = client() 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/choices", json={"date": "2026-07-15", "amount": "27.50"}) response = test_client.post("/fatima/api/choices", json={"date": "2026-07-15", "amount": "27.50"})
assert response.status_code == 200 assert response.status_code == 200
assert response.json["amount_cents"] == 2750 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(): def test_choice_rejects_invalid_amount():
test_client = client() 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/choices", json={"date": "2026-07-15", "amount": "-1"}) response = test_client.post("/fatima/api/choices", json={"date": "2026-07-15", "amount": "-1"})
assert response.status_code == 400 assert response.status_code == 400
def test_day_status_is_saved_and_rendered(): def test_day_status_is_saved_and_rendered():
test_client = client() 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( response = test_client.post(
"/api/statuses", "/fatima/api/statuses",
json={"date": "2026-07-15", "status": "vacation"}, json={"date": "2026-07-15", "status": "vacation"},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json["status"] == "vacation" 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(): def test_day_status_rejects_unknown_value():
test_client = client() 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( response = test_client.post(
"/api/statuses", "/fatima/api/statuses",
json={"date": "2026-07-15", "status": "elsewhere"}, json={"date": "2026-07-15", "status": "elsewhere"},
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -78,14 +99,14 @@ def test_day_status_rejects_unknown_value():
def test_delete_removes_amount_and_status(): def test_delete_removes_amount_and_status():
test_client = client() test_client = client()
test_client.post("/login", data={"username": "user", "password": "pass"}) test_client.post("/fatima/login", data={"username": "user", "password": "pass"})
test_client.post("/api/choices", json={"date": "2026-07-15", "amount": "55"}) test_client.post("/fatima/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/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.status_code == 200
assert response.json["deleted"] is True 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": 5500' not in page
assert b'"2026-07-15": "sick"' 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 from datetime import date
test_client = client() 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 year = date.today().year
worked_date = f"{year}-01-10" worked_date = f"{year}-01-10"
vacation_date = f"{year}-02-10" vacation_date = f"{year}-02-10"
test_client.post("/api/choices", json={"date": worked_date, "amount": "55"}) test_client.post("/fatima/api/choices", json={"date": worked_date, "amount": "55"})
test_client.post("/api/choices", json={"date": vacation_date, "amount": "27.50"}) test_client.post("/fatima/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/statuses", json={"date": vacation_date, "status": "vacation"})
response = test_client.get("/report") response = test_client.get("/fatima/report")
assert response.status_code == 200 assert response.status_code == 200
assert b"\xe2\x82\xac55.00" in response.data assert b"\xe2\x82\xac55.00" in response.data
assert b"\xe2\x82\xac27.50" 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(): def test_report_can_navigate_to_another_year():
test_client = client() 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.get("/report?year=2025") response = test_client.get("/fatima/report?year=2025")
assert response.status_code == 200 assert response.status_code == 200
assert b"2025 report" in response.data assert b"2025 report" in response.data
assert b"year=2024" in response.data assert b"year=2024" in response.data