updated for reversed proxy
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user