diff --git a/.env.example b/.env.example index e39d22a..72f82e6 100644 --- a/.env.example +++ b/.env.example @@ -15,13 +15,13 @@ DEBUG=false SECRET_KEY=change-me-to-a-long-random-string # SECRET_KEY__FILE=/run/secrets/timetracker_secret_key -# Public URL of the site. Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS. +# Public URL(s) of the site — one URL or comma-separated list of full URLs. +# Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS from all listed URLs. APP_URL=https://tracker.kucharczyk.xyz +# APP_URL=https://tracker.kucharczyk.xyz,https://www.tracker.kucharczyk.xyz -# Optional explicit overrides (comma-separated). When set they win over APP_URL. -# Useful behind a reverse proxy, e.g. ALLOWED_HOSTS=* +# Override ALLOWED_HOSTS directly for edge cases (e.g. behind a reverse proxy). # ALLOWED_HOSTS=* -# CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz # Container timezone. TZ=Europe/Prague diff --git a/CLAUDE.md b/CLAUDE.md index eddb493..bfafbd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ All configurable Django settings are read through `config()` in `timetracker/con - `config(name, *, default, cast, allow_file, required_in_prod)`: `cast` handles `bool`/`list`/`int`/`Path`/callable; `allow_file=True` honors `NAME__FILE` (contents `.strip()`-ed); `required_in_prod=True` hard-fails when missing and DEBUG is off. - `DEBUG` defaults `True` (dev), turned off with `DEBUG=false`. `PROD` is a **deprecated alias** kept for one release. - `SECRET_KEY` is required in production (insecure default only in DEBUG); supports `SECRET_KEY__FILE`. -- `APP_URL` derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` when those aren't set explicitly; the two are never merged (different security checks) and each can be overridden directly. +- `APP_URL` accepts one full URL or a comma-separated list of full URLs; `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are derived from all listed URLs. `ALLOWED_HOSTS` can still be overridden directly (e.g. `ALLOWED_HOSTS=*` behind a reverse proxy); `CSRF_TRUSTED_ORIGINS` is always derived from `APP_URL`. - `TIME_ZONE` reads `TZ` (defaults `Europe/Prague` in debug, `UTC` in prod). - Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode. - **Container/entrypoint-only** flags (`PUID`, `PGID`, `CREATE_DEFAULT_SUPERUSER`, `STAGING`, `LOAD_SAMPLE_DATA`) live in `entrypoint.sh`, not the Python config — they are bootstrap concerns, not Django settings. diff --git a/docs/configuration.md b/docs/configuration.md index 59c019a..611106b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,9 +31,8 @@ remove that and `settings.ini` wins; remove that and the code default applies. |---------|------|---------|:---------:|-------------| | `SECRET_KEY` | str | insecure dev key | yes | Django secret key. **Required in production** (DEBUG off) — a missing value is a hard error, not a silent insecure fallback. | | `DEBUG` | bool | `true` (dev) | no | Debug mode. Turn **off** in production. Defaults on for local development. | -| `APP_URL` | str | `http://localhost:8000` | no | Public URL of the site. Derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` when those are not set explicitly. | -| `ALLOWED_HOSTS` | list | derived from `APP_URL` | no | Comma-separated hostnames. Overrides the `APP_URL` derivation. | -| `CSRF_TRUSTED_ORIGINS` | list | derived from `APP_URL` | no | Comma-separated full origins (`https://host`). Overrides the `APP_URL` derivation. | +| `APP_URL` | str (or comma-separated URLs) | `http://localhost:8000` | no | Public URL(s) of the site. One full URL or a comma-separated list. Derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from all listed URLs. | +| `ALLOWED_HOSTS` | list | derived from `APP_URL` | no | Comma-separated hostnames. Overrides the `APP_URL` derivation (useful for `ALLOWED_HOSTS=*` behind a reverse proxy). | | `TZ` | str | `Europe/Prague` (dev) / `UTC` (prod) | no | Time zone. | | `DATA_DIR` | path | project root | no | Directory holding the SQLite database. Also read by `entrypoint.sh`. | @@ -42,21 +41,31 @@ whitespace-trimmed, empty items dropped), `int`, `Path`, or any callable. ## APP_URL, ALLOWED_HOSTS and CSRF -`ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` guard different things — the `Host` -header versus cross-origin requests — so they are **never merged**. For the -common case you set only `APP_URL` and both are derived: +`APP_URL` accepts one full URL or a comma-separated list of full URLs. Both +`ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are derived from all listed URLs — +no need to repeat the same information in separate variables. + +Single domain (common case): ``` APP_URL=https://tracker.example.com -# -> ALLOWED_HOSTS = ["tracker.example.com"] +# -> ALLOWED_HOSTS = ["tracker.example.com"] # -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com"] ``` -Power users override either independently. A typical reverse-proxy setup: +Multiple domains: + +``` +APP_URL=https://tracker.example.com,https://www.tracker.example.com +# -> ALLOWED_HOSTS = ["tracker.example.com", "www.tracker.example.com"] +# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com", "https://www.tracker.example.com"] +``` + +`ALLOWED_HOSTS` can still be overridden directly for edge cases. A typical +reverse-proxy setup where the proxy validates the host: ``` ALLOWED_HOSTS=* -CSRF_TRUSTED_ORIGINS=https://tracker.example.com ``` ## Secrets and `__FILE` diff --git a/timetracker/settings.py b/timetracker/settings.py index 87e955b..4c9cd09 100644 --- a/timetracker/settings.py +++ b/timetracker/settings.py @@ -51,16 +51,18 @@ SECRET_KEY = config( required_in_prod=True, ) -# ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS are configured independently (they -# guard different things), but both default off a single user-facing APP_URL -# when not set explicitly. Power users override either one directly — e.g. -# ALLOWED_HOSTS=* behind a reverse proxy while CSRF stays locked to the domain. +# APP_URL accepts one or more comma-separated full URLs (single URL is the +# common case). Both ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS are derived from +# all listed URLs. ALLOWED_HOSTS can still be overridden directly for edge +# cases like ALLOWED_HOSTS=* behind a reverse proxy. APP_URL = config("APP_URL", default="http://localhost:8000") -_app_url = urlparse(APP_URL) +_parsed_app_urls = [urlparse(raw_url.strip()) for raw_url in APP_URL.split(",")] -ALLOWED_HOSTS = config("ALLOWED_HOSTS", default=None, cast=list) or [_app_url.hostname] -CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default=None, cast=list) or [ - f"{_app_url.scheme}://{_app_url.netloc}" +ALLOWED_HOSTS = config("ALLOWED_HOSTS", default=None, cast=list) or [ + parsed_url.hostname for parsed_url in _parsed_app_urls +] +CSRF_TRUSTED_ORIGINS = [ + f"{parsed_url.scheme}://{parsed_url.netloc}" for parsed_url in _parsed_app_urls ]