demo mode
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-27 23:38:37 +02:00
parent d28fd05bf6
commit 7caea3a1ee
44 changed files with 1682 additions and 460 deletions
+14
View File
@@ -1,10 +1,13 @@
# Kopieer naar .env en wijzig ALLE waarden met CHANGE_ME. # Kopieer naar .env en wijzig ALLE waarden met CHANGE_ME.
DJANGO_SECRET_KEY=CHANGE_ME_generate_a_long_random_value DJANGO_SECRET_KEY=CHANGE_ME_generate_a_long_random_value
DJANGO_DEBUG=1 DJANGO_DEBUG=1
# Leeg voor lokaal gebruik. In productie: https://vacatureradar.example.be
PUBLIC_BASE_URL=
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226 DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226
DJANGO_TIME_ZONE=Europe/Brussels DJANGO_TIME_ZONE=Europe/Brussels
VACATURERADAR_OWNER_NAME=Jens VACATURERADAR_OWNER_NAME=Jens
VACATURERADAR_VERSION=0.3.12
# Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling. # Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling.
POSTGRES_DB=vacatureradar POSTGRES_DB=vacatureradar
@@ -14,6 +17,9 @@ POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
DATABASE_URL= DATABASE_URL=
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
# Gedeelde cache voor rate limiting en readiness; gebruik een afzonderlijke Redis-database.
CACHE_URL=redis://redis:6379/1
HEALTHCHECK_REQUIRE_CACHE=1
CELERY_TASK_ALWAYS_EAGER=0 CELERY_TASK_ALWAYS_EAGER=0
# Bootstrap-account; alleen gebruikt door `bootstrap_instance` wanneer nog geen gebruiker bestaat. # Bootstrap-account; alleen gebruikt door `bootstrap_instance` wanneer nog geen gebruiker bestaat.
@@ -67,14 +73,22 @@ ENTRA_SCOPES=User.Read
# Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een # Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een
# fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen. # fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen.
DEMO_MODE_ENABLED=1 DEMO_MODE_ENABLED=1
DEMO_READ_ONLY=1
DEMO_SESSION_SECONDS=3600
DEMO_USERNAME=demo DEMO_USERNAME=demo
DEMO_EMAIL=demo@vacatureradar.local DEMO_EMAIL=demo@vacatureradar.local
# Beveiliging # Beveiliging
# Zet TRUST_PROXY_HEADERS alleen aan achter een beheerde proxy die Host en X-Forwarded-Proto overschrijft.
TRUST_PROXY_HEADERS=0
USE_X_FORWARDED_HOST=0
# Alleen nodig wanneer client-IPheaders voor rate limiting worden vertrouwd.
TRUSTED_PROXY_CIDRS=
SESSION_COOKIE_SECURE=0 SESSION_COOKIE_SECURE=0
CSRF_COOKIE_SECURE=0 CSRF_COOKIE_SECURE=0
SECURE_SSL_REDIRECT=0 SECURE_SSL_REDIRECT=0
SECURE_HSTS_SECONDS=0 SECURE_HSTS_SECONDS=0
SEARCH_ENGINE_INDEXING_ENABLED=0
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8 AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300 AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300
+13
View File
@@ -4,6 +4,13 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorl
## Niet uitgebracht ## Niet uitgebracht
### Toegevoegd
- `PUBLIC_BASE_URL` als één productiebron voor hostvalidatie, CSRF-origin, secure cookies en HTTPS-redirect.
- Een idempotente `configure_public_url.py`-helper voor bestaande `.env`-bestanden achter Nginx Proxy Manager.
- Een concrete Nginx Proxy Manager-runbook, productie-envvoorbeeld, publieke `robots.txt` en cache-readinesscheck.
- Server-side alleen-lezenbescherming en begrensde sessies voor de gedeelde gastdemo.
### Gewijzigd ### Gewijzigd
- De repository-README volgt nu de gebruikersreis van doel en dagelijkse workflow naar quickstart, configuratie, veiligheidsgrenzen, beheer en pas daarna architectuur en agentoverdracht. - De repository-README volgt nu de gebruikersreis van doel en dagelijkse workflow naar quickstart, configuratie, veiligheidsgrenzen, beheer en pas daarna architectuur en agentoverdracht.
@@ -25,6 +32,12 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorl
### Hersteld ### Hersteld
- Publieke Nginx-hostnamen veroorzaken niet langer `DisallowedHost` wanneer `PUBLIC_BASE_URL` correct staat.
- HTTPS-deployment gebruikt de externe poort in plaats van de interne applicatiepoort voor CSRF en publieke URLs.
- Interne healthchecks blijven bereikbaar bij `SECURE_SSL_REDIRECT=1`; productie-rate-limiting gebruikt een gedeelde Redis-cache.
- Forwarded client-IPheaders worden alleen vertrouwd vanaf expliciet ingestelde proxy-CIDRs.
- Productiechecks kunnen de URL-configuratie laden voordat een staticfilesmanifest aanwezig is.
- De optionele Playwrightfallback voert geen synchrone databasecall meer uit binnen een async context.
- Op mobiel overlappen `Wachtwoord` en `Wachtwoord vergeten?` niet meer; loginiconen zijn zelfstandige lijn-SVG's en de dubbele herstel-link is verwijderd. - Op mobiel overlappen `Wachtwoord` en `Wachtwoord vergeten?` niet meer; loginiconen zijn zelfstandige lijn-SVG's en de dubbele herstel-link is verwijderd.
- De Unraid-synchelper negeert verwijderde indexpaden bij een release vanuit een geldige dirty worktree; vervangen merkassets blokkeren de archiefopbouw daardoor niet meer. - De Unraid-synchelper negeert verwijderde indexpaden bij een release vanuit een geldige dirty worktree; vervangen merkassets blokkeren de archiefopbouw daardoor niet meer.
- Rechtstreekse SSH naar het Unraid-IP koos lokaal de verkeerde gebruiker en identiteit; het gedocumenteerde aliasprofiel selecteert nu reproduceerbaar `root` met de afzonderlijke deploysleutel. - Rechtstreekse SSH naar het Unraid-IP koos lokaal de verkeerde gebruiker en identiteit; het gedocumenteerde aliasprofiel selecteert nu reproduceerbaar `root` met de afzonderlijke deploysleutel.
+327 -227
View File
@@ -1,9 +1,9 @@
{ {
"project": "VacatureRadar", "project": "VacatureRadar",
"artifact_root": "VacatureRadar_Project", "artifact_root": "VacatureRadar_Project",
"generated_at": "2026-07-25T22:17:50.331466+00:00", "generated_at": "2026-07-27T21:28:29.892351+00:00",
"file_count_excluding_manifest": 448, "file_count_excluding_manifest": 468,
"total_bytes_excluding_manifest": 11127859, "total_bytes_excluding_manifest": 11664735,
"files": [ "files": [
{ {
"path": ".agents/skills/vacatureradar-maintainer/SKILL.md", "path": ".agents/skills/vacatureradar-maintainer/SKILL.md",
@@ -22,8 +22,8 @@
}, },
{ {
"path": ".env.example", "path": ".env.example",
"bytes": 2823, "bytes": 3451,
"sha256": "d6d6c96a379eb60be6929fbf014a0f8ebb2c877a7a66d06925ad96a28024981c" "sha256": "2bd841af341788a4023ada3a4f27a1d9be30a1f33fc733123d62143e28530fdf"
}, },
{ {
"path": ".gitattributes", "path": ".gitattributes",
@@ -60,6 +60,61 @@
"bytes": 4521, "bytes": 4521,
"sha256": "bba752f178136a2ad645e63cdf9f29ae4204cc6b246f2c202a4f9c8940b565a4" "sha256": "bba752f178136a2ad645e63cdf9f29ae4204cc6b246f2c202a4f9c8940b565a4"
}, },
{
"path": "CHANGELOG.md",
"bytes": 23325,
"sha256": "a7208f76abd66ea25d2968ea0f55d8686f7ebb82fcf524fd3252d991613f89d3"
},
{
"path": "CODEX_START_HERE.md",
"bytes": 2964,
"sha256": "225f7804a4616c65addc796417810a9391d135248467b6e69bba4db1820d2834"
},
{
"path": "CONTRIBUTING.md",
"bytes": 1129,
"sha256": "d7f17ab14e7bb0763d1cee12a1cae9a7cdeebded2d69ab1552ca5a5a5606dcdb"
},
{
"path": "Dockerfile",
"bytes": 1203,
"sha256": "a231c7f0872c8a6fe8c2fe3bac46479e1b8ce3ec419fb3cdfadb25b541386ec2"
},
{
"path": "Dockerfile.unraid",
"bytes": 961,
"sha256": "d71824ea8497dcdafb5e140e914ef15401d3966fa8d47e3dcec269b1cfb10884"
},
{
"path": "LICENSE",
"bytes": 1061,
"sha256": "a1e3ff537d8be01e7bec1376969d1b66ea2088dfcf7dbf1f2227793ef67c13a6"
},
{
"path": "Makefile",
"bytes": 1236,
"sha256": "1e3b04f7b74167347c2f11bebb9161a716c6fa4e4cb2630caa8f6af044fece81"
},
{
"path": "PLANS.md",
"bytes": 980,
"sha256": "0bb6d51c01003a0dbf9de3a82aeb13967f024d9c267386f29c76fc7a4be97fa6"
},
{
"path": "README.md",
"bytes": 9070,
"sha256": "4c54d879ab9914a6867939a5470f68c8946b1126785c06d7dcd1bf27a5b111dd"
},
{
"path": "SECURITY.md",
"bytes": 2303,
"sha256": "bb5384843d594637d1602624060d440394ebd3f1a9cd102a2cb83ada0da69c42"
},
{
"path": "VERIFICATION_REPORT.md",
"bytes": 3308,
"sha256": "ccf86d72596855edb04df024bedf4225c996227113a8f7bbfe6fee02f92eb41d"
},
{ {
"path": "apps/__init__.py", "path": "apps/__init__.py",
"bytes": 0, "bytes": 0,
@@ -82,18 +137,18 @@
}, },
{ {
"path": "apps/core/auth_backends.py", "path": "apps/core/auth_backends.py",
"bytes": 1758, "bytes": 1704,
"sha256": "e62583ff633f83cd54a1cfeec3ffd70e5ddfbede031df2e358aad1bd6cf73d47" "sha256": "ce1976582b3c14716db86021273b76b5b9643f98e2f3fd70bcbce0c3d27de5f3"
}, },
{ {
"path": "apps/core/context_processors.py", "path": "apps/core/context_processors.py",
"bytes": 539, "bytes": 1698,
"sha256": "ce5a7c6102900254cb4fef52adb02286f46141aaef19b995312f9be5beeb270e" "sha256": "be31f591e51c2fd2e1b9917c9d9cf42b17a65846e62237de7800f72445367ae4"
}, },
{ {
"path": "apps/core/entra.py", "path": "apps/core/entra.py",
"bytes": 3529, "bytes": 3489,
"sha256": "9375df18938dd110994edd1b8f24376a829448e8940b387d6d0acf7146a339fb" "sha256": "79f69c15793fb62cb9f87bfaa2a711d30c44e80faa853acf8bb80db45d3472fc"
}, },
{ {
"path": "apps/core/forms.py", "path": "apps/core/forms.py",
@@ -102,23 +157,28 @@
}, },
{ {
"path": "apps/core/health.py", "path": "apps/core/health.py",
"bytes": 730, "bytes": 1322,
"sha256": "23c528b43c948b36707b1756e03aec452bb3249a201fa4d467509af94d0b7cb5" "sha256": "acd52fd98cb7073c3ce26632078f605147b9e9d4cd79fe4073e7c51229edd1af"
}, },
{ {
"path": "apps/core/middleware.py", "path": "apps/core/middleware.py",
"bytes": 1262, "bytes": 3261,
"sha256": "0c7046ffda8c3db5021ec4584ce59312c073dcdb4b130c208c93c77bb65130ed" "sha256": "e624689c993fff0fbe7ad7869d1899e1cb3191bb8195e423e937f5810b738d72"
}, },
{ {
"path": "apps/core/models.py", "path": "apps/core/models.py",
"bytes": 256, "bytes": 256,
"sha256": "516c3986da0f2ba82519c0828c0d3150524882806025f79a340791411a4ed792" "sha256": "516c3986da0f2ba82519c0828c0d3150524882806025f79a340791411a4ed792"
}, },
{
"path": "apps/core/network.py",
"bytes": 1605,
"sha256": "9f530808892175695ccfe468eca7d105a4d898482dadda83224f16b99d64f312"
},
{ {
"path": "apps/core/rate_limit.py", "path": "apps/core/rate_limit.py",
"bytes": 3284, "bytes": 3056,
"sha256": "febf507ccae70502d141c2b2bb23b269353bdf158f7abe4c33f1e7bb91b8b5fb" "sha256": "2b2c6e3aa071b9632feb6866fdc72233783cb1a7760b8fdbf0573ad27b96460f"
}, },
{ {
"path": "apps/core/services/__init__.py", "path": "apps/core/services/__init__.py",
@@ -137,8 +197,8 @@
}, },
{ {
"path": "apps/core/views.py", "path": "apps/core/views.py",
"bytes": 9503, "bytes": 9809,
"sha256": "f354cb696d4d7ae47401dc131ae01364c1cc62b6cc7c8c3b77ee63322dcc233e" "sha256": "b3575f28c70fabce83028c27faf78de05315212b083b4d79af08ef474a8febaf"
}, },
{ {
"path": "apps/jobs/__init__.py", "path": "apps/jobs/__init__.py",
@@ -232,8 +292,8 @@
}, },
{ {
"path": "apps/jobs/services/cockpit.py", "path": "apps/jobs/services/cockpit.py",
"bytes": 6756, "bytes": 6920,
"sha256": "0f69ab47a505682fe4aee9ca0adedafbe649404d6a7b7db40302317ee70d08a8" "sha256": "b927af34b3386a639ec1283f7b2f5761aa61e47f1382b09994257c4ad0cb0a6e"
}, },
{ {
"path": "apps/jobs/services/dedupe.py", "path": "apps/jobs/services/dedupe.py",
@@ -755,16 +815,6 @@
"bytes": 102625, "bytes": 102625,
"sha256": "a646c89834c6374e230763bcdb548a721c7017efb1bc57fed3fad5deb789c557" "sha256": "a646c89834c6374e230763bcdb548a721c7017efb1bc57fed3fad5deb789c557"
}, },
{
"path": "CHANGELOG.md",
"bytes": 18562,
"sha256": "583272f45ba2204440d3f015afc022fe3658754d98b7e4862f69bc7d570a5961"
},
{
"path": "CODEX_START_HERE.md",
"bytes": 2964,
"sha256": "225f7804a4616c65addc796417810a9391d135248467b6e69bba4db1820d2834"
},
{ {
"path": "config/__init__.py", "path": "config/__init__.py",
"bytes": 101, "bytes": 101,
@@ -782,13 +832,13 @@
}, },
{ {
"path": "config/settings.py", "path": "config/settings.py",
"bytes": 14762, "bytes": 19312,
"sha256": "d854e12cc85f763af1222897abd7c6c93d3c8fbee40b2153841838cdb91ba9fe" "sha256": "bb47db00dc4c355c58b8b56790b1e4a64917f7f1e1543c20f2e2a7d73b067769"
}, },
{ {
"path": "config/urls.py", "path": "config/urls.py",
"bytes": 2141, "bytes": 2162,
"sha256": "4654aaf5c4ad2aa842173dfa0ecc78f991e623df4fd43aae855a680003adbf46" "sha256": "d225c586ab8286f7bb21732ad3bacf72599daaf422676587997852e214eb19c6"
}, },
{ {
"path": "config/wsgi.py", "path": "config/wsgi.py",
@@ -816,9 +866,9 @@
"sha256": "61230414c78326743cf586f686193d05bf5149c965e449b27b8827cf94524936" "sha256": "61230414c78326743cf586f686193d05bf5149c965e449b27b8827cf94524936"
}, },
{ {
"path": "CONTRIBUTING.md", "path": "deployment/production.env.example",
"bytes": 1129, "bytes": 2756,
"sha256": "d7f17ab14e7bb0763d1cee12a1cae9a7cdeebded2d69ab1552ca5a5a5606dcdb" "sha256": "73af67ab337e8903e2f88284b6bad50d61e5ac2be2a2f7e34a2401c833f0aaf6"
}, },
{ {
"path": "deployment/unraid/README.md", "path": "deployment/unraid/README.md",
@@ -832,23 +882,18 @@
}, },
{ {
"path": "docker-compose.unraid.yml", "path": "docker-compose.unraid.yml",
"bytes": 1541, "bytes": 1550,
"sha256": "25a2e6bf6abb3f4dd6bfc6ba0e53f6726ed11c09a3a15ed9f0a5034a56fba740" "sha256": "fda20a5e7ac3d6a2623c37ed2f83645a31a46ff9d60242089a9ce202ddb36e5c"
}, },
{ {
"path": "docker-compose.yml", "path": "docker-compose.yml",
"bytes": 2499, "bytes": 2888,
"sha256": "b9aaf71d28591a0b4afed0d6f28f2ad31cccae951d6050629e77dcce59e9840d" "sha256": "8fd30ee701133e2e02bf5116e38628fb3732b3578a15037b2d4abe79953f3724"
}, },
{ {
"path": "Dockerfile", "path": "docs/README.md",
"bytes": 1203, "bytes": 1667,
"sha256": "a231c7f0872c8a6fe8c2fe3bac46479e1b8ce3ec419fb3cdfadb25b541386ec2" "sha256": "325b8df8b58afdb8cbe8f0d9603ca49d5a9c4ae6a2bb4ed781901a3368e65709"
},
{
"path": "Dockerfile.unraid",
"bytes": 961,
"sha256": "d71824ea8497dcdafb5e140e914ef15401d3966fa8d47e3dcec269b1cfb10884"
}, },
{ {
"path": "docs/ai/AUTONOMOUS_EXECUTION_PROTOCOL.md", "path": "docs/ai/AUTONOMOUS_EXECUTION_PROTOCOL.md",
@@ -857,19 +902,19 @@
}, },
{ {
"path": "docs/ai/BACKLOG.yaml", "path": "docs/ai/BACKLOG.yaml",
"bytes": 81845, "bytes": 101281,
"sha256": "4a2fb563da45cdcbdb57b36968a9f13da7372370fbcd12b2432e49b62cc1adee" "sha256": "ac86700bd2976f4a01ef414e77ac3cc8a4a3f201c0709902a6385ac315e030cd"
},
{
"path": "docs/ai/CODE_REVIEW.md",
"bytes": 1810,
"sha256": "0c6716f0a4da8e8dd76446bff6d9c2ec1e9d8e5f44bcc39e8a0b7094723d3987"
}, },
{ {
"path": "docs/ai/CODEX_CONFIG.example.toml", "path": "docs/ai/CODEX_CONFIG.example.toml",
"bytes": 469, "bytes": 469,
"sha256": "64de3dce1453851f216ee48dcc6fdcb07b578b1fab7519c6ffc3609420a3907c" "sha256": "64de3dce1453851f216ee48dcc6fdcb07b578b1fab7519c6ffc3609420a3907c"
}, },
{
"path": "docs/ai/CODE_REVIEW.md",
"bytes": 1810,
"sha256": "0c6716f0a4da8e8dd76446bff6d9c2ec1e9d8e5f44bcc39e8a0b7094723d3987"
},
{ {
"path": "docs/ai/DECISION_LOG.md", "path": "docs/ai/DECISION_LOG.md",
"bytes": 2476, "bytes": 2476,
@@ -877,8 +922,8 @@
}, },
{ {
"path": "docs/ai/PROJECT_STATE.md", "path": "docs/ai/PROJECT_STATE.md",
"bytes": 35480, "bytes": 46417,
"sha256": "63656724c7f86cc75f8d400939445233c8b926fed22f902fdfd293210fe8593c" "sha256": "1288acc8425068053a8b3ebafd8abfd89708e6232a0bfb5b04465d161eec5406"
}, },
{ {
"path": "docs/ai/SESSION_BOOTSTRAP_PROMPT.md", "path": "docs/ai/SESSION_BOOTSTRAP_PROMPT.md",
@@ -895,6 +940,36 @@
"bytes": 2634, "bytes": 2634,
"sha256": "6c3f070ba6aadb3e95fa2a9f4d4bcfce1e4a4e7567f53380cde552d875b62c6e" "sha256": "6c3f070ba6aadb3e95fa2a9f4d4bcfce1e4a4e7567f53380cde552d875b62c6e"
}, },
{
"path": "docs/architecture/DATA_FLOW.md",
"bytes": 3372,
"sha256": "fc162692ffa5a5af0940dd57d3063a34ef551333962074586196bffa85531a89"
},
{
"path": "docs/architecture/DATA_MODEL.md",
"bytes": 4540,
"sha256": "00372369b4e560da0d7ab4127fbf50a48cac294316b6c11c387dd6d027e0b6f1"
},
{
"path": "docs/architecture/SCORING_ENGINE.md",
"bytes": 5445,
"sha256": "4a5fbb1e7ff555659c88a792464f00b4594808b47d605cc750bdc78537d64cd9"
},
{
"path": "docs/architecture/SECURITY_ARCHITECTURE.md",
"bytes": 4166,
"sha256": "868c258928cc8d2b85fd3deb84bf49cd1fcc12d926bd4a1d4f14deb8b635f260"
},
{
"path": "docs/architecture/SOURCE_ADAPTERS.md",
"bytes": 7445,
"sha256": "99f0c0009ef9b32cb78608a27b9e735111e5eed97ac440d05a85e083e64d825c"
},
{
"path": "docs/architecture/SYSTEM_ARCHITECTURE.md",
"bytes": 5186,
"sha256": "ca19bcd61db9b5a73b373d97b09977557842488e621e55d173255b2a9a269e24"
},
{ {
"path": "docs/architecture/adrs/0001-modular-django-monolith.md", "path": "docs/architecture/adrs/0001-modular-django-monolith.md",
"bytes": 1045, "bytes": 1045,
@@ -940,51 +1015,51 @@
"bytes": 2160, "bytes": 2160,
"sha256": "0c2bf6d00a2c603dd205b06af629e7d8f5d3d9190bcd2b4af765f877f0b9dd71" "sha256": "0c2bf6d00a2c603dd205b06af629e7d8f5d3d9190bcd2b4af765f877f0b9dd71"
}, },
{
"path": "docs/architecture/DATA_FLOW.md",
"bytes": 3372,
"sha256": "fc162692ffa5a5af0940dd57d3063a34ef551333962074586196bffa85531a89"
},
{
"path": "docs/architecture/DATA_MODEL.md",
"bytes": 4540,
"sha256": "00372369b4e560da0d7ab4127fbf50a48cac294316b6c11c387dd6d027e0b6f1"
},
{
"path": "docs/architecture/SCORING_ENGINE.md",
"bytes": 5445,
"sha256": "4a5fbb1e7ff555659c88a792464f00b4594808b47d605cc750bdc78537d64cd9"
},
{
"path": "docs/architecture/SECURITY_ARCHITECTURE.md",
"bytes": 4166,
"sha256": "868c258928cc8d2b85fd3deb84bf49cd1fcc12d926bd4a1d4f14deb8b635f260"
},
{
"path": "docs/architecture/SOURCE_ADAPTERS.md",
"bytes": 7445,
"sha256": "99f0c0009ef9b32cb78608a27b9e735111e5eed97ac440d05a85e083e64d825c"
},
{
"path": "docs/architecture/SYSTEM_ARCHITECTURE.md",
"bytes": 5186,
"sha256": "ca19bcd61db9b5a73b373d97b09977557842488e621e55d173255b2a9a269e24"
},
{ {
"path": "docs/design/ACCESSIBILITY.md", "path": "docs/design/ACCESSIBILITY.md",
"bytes": 2799, "bytes": 2799,
"sha256": "103929b971920c85b70328a5e4190dd2c9ae6ddadbacf8ce7793dff8e237a4c9" "sha256": "103929b971920c85b70328a5e4190dd2c9ae6ddadbacf8ce7793dff8e237a4c9"
}, },
{
"path": "docs/design/dashboard-wireframe.svg",
"bytes": 4449,
"sha256": "344d02fe9a24bbccd50d78561842d04e50af2e583a9d5b96eb894f653ed43c38"
},
{ {
"path": "docs/design/DESIGN_SYSTEM.md", "path": "docs/design/DESIGN_SYSTEM.md",
"bytes": 4103, "bytes": 4103,
"sha256": "345b508ea0cc86ed7e64d7f82847991deb3ac6a35c99ee607b608a2576d81d79" "sha256": "345b508ea0cc86ed7e64d7f82847991deb3ac6a35c99ee607b608a2576d81d79"
}, },
{
"path": "docs/design/STITCH_FRONTEND_CONTRACT.md",
"bytes": 4417,
"sha256": "eb5268b70b046c2b28a1535da21e80570c30b9e795fc7878923118f68d5a194c"
},
{
"path": "docs/design/STITCH_INTEGRATION_PLAN.md",
"bytes": 8546,
"sha256": "ea23ff53bceb27803c56ad98baa896e92faf9f97be9ff28446e93ef4c4a3f31e"
},
{
"path": "docs/design/STITCH_INTEGRATION_REPORT.md",
"bytes": 12961,
"sha256": "86bee84150c9cd1c5956a53ceef34c95b19c7ba7625d80964993f29b23e33e26"
},
{
"path": "docs/design/UX_SPEC.md",
"bytes": 3992,
"sha256": "ee1865bb3efae5943952098c42b505547d78b74a4268c44dfe13e3e84a3a5bb2"
},
{
"path": "docs/design/VISUAL_AUDIT_2026-07-26.md",
"bytes": 7275,
"sha256": "305fcd2e307d5ad4b4b3c99fc9c2f86c38e943b4df18426c89c77708b88eb8d5"
},
{
"path": "docs/design/WIREFRAMES.md",
"bytes": 5678,
"sha256": "4afdfb392adf415ca77c5e503042c6dd1a4744d2772645267ea4ee7facf27970"
},
{
"path": "docs/design/dashboard-wireframe.svg",
"bytes": 4449,
"sha256": "344d02fe9a24bbccd50d78561842d04e50af2e583a9d5b96eb894f653ed43c38"
},
{ {
"path": "docs/design/stitch-reference/activiteitenlogboek/code.html", "path": "docs/design/stitch-reference/activiteitenlogboek/code.html",
"bytes": 21557, "bytes": 21557,
@@ -1081,24 +1156,14 @@
"sha256": "4a49d9d1c9c7249465ddd24f5d64ba5957592eb2a4d9e76eef4f851a8c15ce6d" "sha256": "4a49d9d1c9c7249465ddd24f5d64ba5957592eb2a4d9e76eef4f851a8c15ce6d"
}, },
{ {
"path": "docs/design/STITCH_FRONTEND_CONTRACT.md", "path": "docs/design/validation/VR-119_REPORT.md",
"bytes": 4417, "bytes": 1254,
"sha256": "eb5268b70b046c2b28a1535da21e80570c30b9e795fc7878923118f68d5a194c" "sha256": "e71d3733313a4c8c932dd15fb308d5b1e5618ee003ad22f05d2c5ce21d871edc"
}, },
{ {
"path": "docs/design/STITCH_INTEGRATION_PLAN.md", "path": "docs/design/validation/VR-206_AUDIT.md",
"bytes": 8546, "bytes": 4671,
"sha256": "ea23ff53bceb27803c56ad98baa896e92faf9f97be9ff28446e93ef4c4a3f31e" "sha256": "50b6dad9d45bcca775fe4d47f50b5f0ab031cca894c02280af5ebbdffc5e99d7"
},
{
"path": "docs/design/STITCH_INTEGRATION_REPORT.md",
"bytes": 7007,
"sha256": "ed0b206057c5be278c5ff514fe9b62bd2a08e787ce20b94678eab50d7e659db9"
},
{
"path": "docs/design/UX_SPEC.md",
"bytes": 3992,
"sha256": "ee1865bb3efae5943952098c42b505547d78b74a4268c44dfe13e3e84a3a5bb2"
}, },
{ {
"path": "docs/design/validation/applications-dark-1280x800.png", "path": "docs/design/validation/applications-dark-1280x800.png",
@@ -1180,16 +1245,6 @@
"bytes": 74148, "bytes": 74148,
"sha256": "545febbbce38cab784bff11be4c78e4e608f71c424fbc8271013270305f13c84" "sha256": "545febbbce38cab784bff11be4c78e4e608f71c424fbc8271013270305f13c84"
}, },
{
"path": "docs/design/validation/VR-119_REPORT.md",
"bytes": 1254,
"sha256": "e71d3733313a4c8c932dd15fb308d5b1e5618ee003ad22f05d2c5ce21d871edc"
},
{
"path": "docs/design/validation/VR-206_AUDIT.md",
"bytes": 4671,
"sha256": "50b6dad9d45bcca775fe4d47f50b5f0ab031cca894c02280af5ebbdffc5e99d7"
},
{ {
"path": "docs/design/validation/vr119-applications-dark-1440x900.png", "path": "docs/design/validation/vr119-applications-dark-1440x900.png",
"bytes": 74492, "bytes": 74492,
@@ -1240,11 +1295,6 @@
"bytes": 74142, "bytes": 74142,
"sha256": "edca2718bc18cb702caa8d01023a71aeaf68538c8d2042daee56f9c22fb69d9e" "sha256": "edca2718bc18cb702caa8d01023a71aeaf68538c8d2042daee56f9c22fb69d9e"
}, },
{
"path": "docs/design/WIREFRAMES.md",
"bytes": 5678,
"sha256": "4afdfb392adf415ca77c5e503042c6dd1a4744d2772645267ea4ee7facf27970"
},
{ {
"path": "docs/operations/BACKUP_RESTORE.md", "path": "docs/operations/BACKUP_RESTORE.md",
"bytes": 3670, "bytes": 3670,
@@ -1270,6 +1320,11 @@
"bytes": 3281, "bytes": 3281,
"sha256": "c92db78ed20479cfda37c9db26f33d7a33ffc2df6575b63959529a2d5409b244" "sha256": "c92db78ed20479cfda37c9db26f33d7a33ffc2df6575b63959529a2d5409b244"
}, },
{
"path": "docs/operations/NGINX_PROXY_MANAGER.md",
"bytes": 4576,
"sha256": "0d46c1deb196f05111f7ba2d3c0d8950b65aa94df9ac8823de6df52aa6f475ff"
},
{ {
"path": "docs/operations/RUNBOOK.md", "path": "docs/operations/RUNBOOK.md",
"bytes": 6922, "bytes": 6922,
@@ -1282,8 +1337,8 @@
}, },
{ {
"path": "docs/operations/UNRAID_DEPLOYMENT.md", "path": "docs/operations/UNRAID_DEPLOYMENT.md",
"bytes": 7958, "bytes": 9612,
"sha256": "95a27b670ffb5d64cec30cc8f300dc7d6ad4ae616f39ab96ddf8b4122b554d36" "sha256": "cef9dadf60fc3b4a0d6aa3759833cd5e944354238f6f8521d2223900c6bfaf28"
}, },
{ {
"path": "docs/product/ACCEPTANCE_CRITERIA.md", "path": "docs/product/ACCEPTANCE_CRITERIA.md",
@@ -1315,6 +1370,11 @@
"bytes": 4024, "bytes": 4024,
"sha256": "5b06bf21a65ddfefceaa700ceb7ed944ddc4b9597257d0f993951444cac8d6c8" "sha256": "5b06bf21a65ddfefceaa700ceb7ed944ddc4b9597257d0f993951444cac8d6c8"
}, },
{
"path": "docs/quality/PRODUCTION_AUDIT_2026-07-27.md",
"bytes": 4685,
"sha256": "e4d2e735e5aa034c87ae9b97c3827ed4d19db9741375a49d8b9033fb61e9cabe"
},
{ {
"path": "docs/quality/RELEASE_CHECKLIST.md", "path": "docs/quality/RELEASE_CHECKLIST.md",
"bytes": 2041, "bytes": 2041,
@@ -1327,23 +1387,13 @@
}, },
{ {
"path": "docs/quality/THREAT_MODEL.md", "path": "docs/quality/THREAT_MODEL.md",
"bytes": 15196, "bytes": 16045,
"sha256": "ea040b20aff44671fd5da6572e05b6b7629340defb48d511b9e94690a66247f5" "sha256": "5b44904a6c66d8730435fb89307c7ded4d25c1017f2813f60bd664e85facf589"
}, },
{ {
"path": "docs/quality/TRACEABILITY_MATRIX.md", "path": "docs/quality/TRACEABILITY_MATRIX.md",
"bytes": 14114, "bytes": 16566,
"sha256": "6b61eba9d17acd3ef1ffab1c76b7d05f20e5b076ecb18aa3105bc19c2920d94a" "sha256": "dca5e059cdd337480d4db72834c301023f50b61ebef595ed826f5f6b082ca29c"
},
{
"path": "docs/README.md",
"bytes": 1667,
"sha256": "325b8df8b58afdb8cbe8f0d9603ca49d5a9c4ae6a2bb4ed781901a3368e65709"
},
{
"path": "docs/reference/original-design-extract.md",
"bytes": 66524,
"sha256": "f3df06b49c3ad0ef6f2525988d216dd2b9e5fca8e43f4603d1112ef0ec780e2a"
}, },
{ {
"path": "docs/reference/VacatureRadar_functioneel_en_technisch_ontwerp.docx", "path": "docs/reference/VacatureRadar_functioneel_en_technisch_ontwerp.docx",
@@ -1355,6 +1405,31 @@
"bytes": 1298853, "bytes": 1298853,
"sha256": "c67ea54e9d5eed7c8404e56d00d0a559cce01032b25890c1d3a97e31d8f640db" "sha256": "c67ea54e9d5eed7c8404e56d00d0a559cce01032b25890c1d3a97e31d8f640db"
}, },
{
"path": "docs/reference/original-design-extract.md",
"bytes": 66524,
"sha256": "f3df06b49c3ad0ef6f2525988d216dd2b9e5fca8e43f4603d1112ef0ec780e2a"
},
{
"path": "docs/screenshots/readme/automation-dark.png",
"bytes": 108939,
"sha256": "0f89f900a02395ce5d4392856ea8944c9c4df46a1012ec1eee7e06c7babdf908"
},
{
"path": "docs/screenshots/readme/dashboard-light.png",
"bytes": 75116,
"sha256": "815712d024b23d6d146cbfa648cce640c04bc387887a2e0eeb52bbe12eac0ce4"
},
{
"path": "docs/screenshots/readme/matches-dark.png",
"bytes": 111477,
"sha256": "4b0d47b519bb08f4d65698dc118bd74ae0aabf95c25f9def6c925b476af5d44c"
},
{
"path": "docs/screenshots/readme/profile-mobile-light.png",
"bytes": 29248,
"sha256": "0c65818d05d6c3c36190b580239ddb568334010ac2f9f114ba99fb1e94aa8646"
},
{ {
"path": "fixtures/ai/evaluation_set.json", "path": "fixtures/ai/evaluation_set.json",
"bytes": 2349, "bytes": 2349,
@@ -1605,40 +1680,20 @@
"bytes": 381, "bytes": 381,
"sha256": "a9f657f8e2d91b79fed284bf4506a5abef2ddb862c35314d6290ccfa291443c7" "sha256": "a9f657f8e2d91b79fed284bf4506a5abef2ddb862c35314d6290ccfa291443c7"
}, },
{
"path": "LICENSE",
"bytes": 1061,
"sha256": "a1e3ff537d8be01e7bec1376969d1b66ea2088dfcf7dbf1f2227793ef67c13a6"
},
{ {
"path": "local/.gitkeep", "path": "local/.gitkeep",
"bytes": 0, "bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}, },
{
"path": "Makefile",
"bytes": 1236,
"sha256": "1e3b04f7b74167347c2f11bebb9161a716c6fa4e4cb2630caa8f6af044fece81"
},
{ {
"path": "manage.py", "path": "manage.py",
"bytes": 450, "bytes": 450,
"sha256": "ee9c7fa0f1ad7758fbf6fd6c5ec01de1e4d20cee205c6247604d8418ae000d05" "sha256": "ee9c7fa0f1ad7758fbf6fd6c5ec01de1e4d20cee205c6247604d8418ae000d05"
}, },
{
"path": "PLANS.md",
"bytes": 980,
"sha256": "0bb6d51c01003a0dbf9de3a82aeb13967f024d9c267386f29c76fc7a4be97fa6"
},
{ {
"path": "pyproject.toml", "path": "pyproject.toml",
"bytes": 1912, "bytes": 1913,
"sha256": "7d4566a38c7603c5d264a03792acb31e55fe1a107a00069eb35c5b4c4c6bdf83" "sha256": "fc0262f9980675bace3f2c97eb56db0dff322460bf55d9024587f2ea98f57b61"
},
{
"path": "README.md",
"bytes": 9419,
"sha256": "9cef6efa806e4270945f12051366670060d481264df992554b6d6c576a88fdb9"
}, },
{ {
"path": "scripts/backlog.py", "path": "scripts/backlog.py",
@@ -1665,15 +1720,25 @@
"bytes": 897, "bytes": 897,
"sha256": "619eccea2c0570fd813105818b30840244d26946fa3ea09a63c1b403efaf57c3" "sha256": "619eccea2c0570fd813105818b30840244d26946fa3ea09a63c1b403efaf57c3"
}, },
{
"path": "scripts/configure_public_url.py",
"bytes": 6633,
"sha256": "272b3ed139aab3105d1d0cd784ffa71e8df5c6674c4af2a35da44d2625c32add"
},
{ {
"path": "scripts/deploy_docker.sh", "path": "scripts/deploy_docker.sh",
"bytes": 5151, "bytes": 6394,
"sha256": "f4f746d6ddc822c293cb60cba127602fb9876181bb634e88e9b408e075a09cce" "sha256": "50cdb5f22d37eade00ea29f4903a5f8e08bd2c15f34f181d40c939a1c01c9563"
},
{
"path": "scripts/deploy_unraid.sh",
"bytes": 1896,
"sha256": "1b84338119304575c06b3384de1b8ae46c1f720b6085fda87c0ace1c0c57d796"
}, },
{ {
"path": "scripts/entrypoint.sh", "path": "scripts/entrypoint.sh",
"bytes": 309, "bytes": 342,
"sha256": "ef5ef0561af09e970bbc7dfc49ea28f589e65c108ca02cb3cf5d4932e6a525db" "sha256": "9f16ae543fc03778e416bf4cac931225a62875ea463593d0bea22552e352b085"
}, },
{ {
"path": "scripts/feedback_learning_report.py", "path": "scripts/feedback_learning_report.py",
@@ -1707,8 +1772,8 @@
}, },
{ {
"path": "scripts/remote_deploy.sh", "path": "scripts/remote_deploy.sh",
"bytes": 1464, "bytes": 1534,
"sha256": "42365240d91e665f914bd85f83e1c78e0d40333a6463031e72695f6ea40cc817" "sha256": "2193559afd4cdae404da9df01e38bbebf12797501c457ece8b06b9626cb0a284"
}, },
{ {
"path": "scripts/reset_demo.sh", "path": "scripts/reset_demo.sh",
@@ -1722,13 +1787,13 @@
}, },
{ {
"path": "scripts/unraid_aio_entrypoint.sh", "path": "scripts/unraid_aio_entrypoint.sh",
"bytes": 2172, "bytes": 2222,
"sha256": "710bef477af9138dc9713626637fb1fbbb1b6a8592602cabe7bb00bb64de4543" "sha256": "1446d514d4f3516a89dac3d0aab1e12f7a17b74f769bd29bcfea64e4c6f6d34d"
}, },
{ {
"path": "scripts/validate_repository.py", "path": "scripts/validate_repository.py",
"bytes": 6602, "bytes": 6811,
"sha256": "adcfb8ef285fee7977aefecd295b0de674e42e900f90e9d6712aada5a9786cb4" "sha256": "b5b66de7edc2d2b6be8b95bf7f57bde1fda07246444e56222ac1d565fcd346df"
}, },
{ {
"path": "scripts/validate_task_ledger.py", "path": "scripts/validate_task_ledger.py",
@@ -1740,20 +1805,15 @@
"bytes": 5096, "bytes": 5096,
"sha256": "5c14fd14ace8772230855c6b6fe2cada7ecb6e34faf7756efdf5c613768900f9" "sha256": "5c14fd14ace8772230855c6b6fe2cada7ecb6e34faf7756efdf5c613768900f9"
}, },
{
"path": "SECURITY.md",
"bytes": 1931,
"sha256": "9ca57ce4d8b077fe860343762595c3af3a5aad1a843582faa1bb2197b3b638a6"
},
{ {
"path": "static/css/app.css", "path": "static/css/app.css",
"bytes": 167, "bytes": 412,
"sha256": "7d9d7af305fe2a432dc063a45b4498a837d8d3d3a02e391a515ef7d41e0013ea" "sha256": "f10cd9a438835f8c3c1486aebd47a479b06ec70db1f056ad15cac3f97a520d04"
}, },
{ {
"path": "static/css/auth.css", "path": "static/css/auth.css",
"bytes": 10851, "bytes": 11451,
"sha256": "7eece0e2ccc5287a3ce1989ce420d6d756e4cc7fab3a16f20a545837612790e9" "sha256": "c41b8ceddf290018694c3770d83cf75dda530cb6fdb3629105c8571664e1def8"
}, },
{ {
"path": "static/css/base.css", "path": "static/css/base.css",
@@ -1762,18 +1822,23 @@
}, },
{ {
"path": "static/css/components.css", "path": "static/css/components.css",
"bytes": 8969, "bytes": 9781,
"sha256": "06d20ce96284a9a6467a4490ae4d8cef2289fe068c3fa883e7a17d3bc003bbe3" "sha256": "7f2c9afb3ba817120012f119d196a14e09cc439bede3eaabafca82dec5f975c8"
}, },
{ {
"path": "static/css/layout.css", "path": "static/css/layout.css",
"bytes": 5792, "bytes": 5359,
"sha256": "abc44cfb128364eb85596d41640088d9d22dc3ac1f223297d0d8a8095724f7c3" "sha256": "e518204f414b9335de976a547712223b38ed3bc1e2c83e1df31f2baef8c48152"
}, },
{ {
"path": "static/css/pages.css", "path": "static/css/pages.css",
"bytes": 42761, "bytes": 42824,
"sha256": "992ff9b3b369c0acf6094309a703a4c52745f4a1863f1191f9231976e9bdf710" "sha256": "2afa1b76faeaa3fa1a16082c4708847e83bf49cd2cb19c4c5471c90fdedbe9d8"
},
{
"path": "static/css/premium.css",
"bytes": 14552,
"sha256": "3b1590b7edc74ab0490535ecbc73a73d9238d287a5cc61f559e4637a344aad5a"
}, },
{ {
"path": "static/css/responsive.css", "path": "static/css/responsive.css",
@@ -1787,8 +1852,8 @@
}, },
{ {
"path": "static/favicon.svg", "path": "static/favicon.svg",
"bytes": 363, "bytes": 560,
"sha256": "ff0b841559133c88c7d9a9c804a3b56a80e3cf192c965fd850f3d7995c527b2d" "sha256": "41183eb09cc0d81dfde974776c64ffaab389aafe21c43f3e0c2202372b235e91"
}, },
{ {
"path": "static/fonts/geist-400.woff2", "path": "static/fonts/geist-400.woff2",
@@ -1810,20 +1875,35 @@
"bytes": 21832, "bytes": 21832,
"sha256": "cb182feeed4d798ff6961d3c79f7026279448fca0676438aaecb21f3fc39553a" "sha256": "cb182feeed4d798ff6961d3c79f7026279448fca0676438aaecb21f3fc39553a"
}, },
{
"path": "static/img/README.md",
"bytes": 841,
"sha256": "422335be5ae558ef202bff22f55b84584260bfa047f9fc51757d1545f03546c6"
},
{
"path": "static/img/itworx-wordmark.png",
"bytes": 86817,
"sha256": "e35a5a46015df232005628a8f7aa7dbaea9030c195a6a90858d68eec5efd106a"
},
{ {
"path": "static/img/vacatureradar-docker.png", "path": "static/img/vacatureradar-docker.png",
"bytes": 7111, "bytes": 10459,
"sha256": "5037a1ec225af75e91f0f31bb8496d1b627cc9e03fe67423d42a8a290253b894" "sha256": "ca1c32350b6baa048b4f2ca831579bce5fd7aeaa733b853d745f712af463c74c"
}, },
{ {
"path": "static/img/vacatureradar-logo.svg", "path": "static/img/vacatureradar-logo.svg",
"bytes": 424, "bytes": 464,
"sha256": "382879307fff6e6d0ad03f80723b132f753b0888ab97f5bbad17ef9d567b4f66" "sha256": "e5fa24a32a3206e44526b1e5e12dabbc3bf0d5968ef4110cb7d451a4b155aa62"
}, },
{ {
"path": "static/js/app.js", "path": "static/js/app.js",
"bytes": 2828, "bytes": 4108,
"sha256": "ec7d0e0c0517f8f9f7bac9d43faa5a642c94bb982553baec649b9aee5d08b65f" "sha256": "4ee60180a74287b47d3dc6693f9d198a99fd736098c2b9266f251c4b76a35239"
},
{
"path": "static/js/login.js",
"bytes": 1016,
"sha256": "e11558f688998ed33d3b157b37786dc7d53646290a0516112e34ec19744ab157"
}, },
{ {
"path": "static/js/manual_import.js", "path": "static/js/manual_import.js",
@@ -1867,14 +1947,24 @@
}, },
{ {
"path": "templates/base.html", "path": "templates/base.html",
"bytes": 9029, "bytes": 9836,
"sha256": "2e98cb4c987a248f612a2ca585ce838e7c0e603d557cfd86116e7f1d6713df77" "sha256": "0e32ba95e8a7d683b977927de8dd61d8dc25519f3c176adaec43b0e850fc85a2"
},
{
"path": "templates/components/developer_credit.html",
"bytes": 211,
"sha256": "2fed3419d5fa8eb80eee301e8aed197d2431fc96788b8d9652c760f2737026d6"
}, },
{ {
"path": "templates/components/icon_sprite.html", "path": "templates/components/icon_sprite.html",
"bytes": 4347, "bytes": 4347,
"sha256": "43a0e05b1d65c16d0f76aa948afa8b834c9f4652f1ef1821a41481e754cecfa9" "sha256": "43a0e05b1d65c16d0f76aa948afa8b834c9f4652f1ef1821a41481e754cecfa9"
}, },
{
"path": "templates/components/radar_illustration.html",
"bytes": 1323,
"sha256": "f740f80ebf18c762f238064be95c8f4fea7593c1059a276abf7065659bc9fc17"
},
{ {
"path": "templates/dashboard/today.html", "path": "templates/dashboard/today.html",
"bytes": 6907, "bytes": 6907,
@@ -1932,28 +2022,28 @@
}, },
{ {
"path": "templates/profiles/list.html", "path": "templates/profiles/list.html",
"bytes": 1690, "bytes": 2018,
"sha256": "5015f46bc478384f0bc2b0b2dea76fa604536c99cfa9435886d8f8abb7b236c4" "sha256": "5a404ef56d047d5312866395a0ab47fad9622fc58ac0b698b40beee7a6cbf0b5"
}, },
{ {
"path": "templates/registration/login.html", "path": "templates/registration/login.html",
"bytes": 6243, "bytes": 6065,
"sha256": "040770b92d7ad4a7a359cd4f6ad37cd820ec4c2adf9e7855d5a6520388045d08" "sha256": "642b3577d26001515738c1ada26e57135e8d252865782d751200d3bf9c694e9e"
}, },
{ {
"path": "templates/registration/password_reset_complete.html", "path": "templates/registration/password_reset_complete.html",
"bytes": 1415, "bytes": 1508,
"sha256": "6bc77cefd19850711bf8809944c17801373d37bf96c56cab8f62fcd613c09f7a" "sha256": "c09655350bc10cd6611ce694176b680a834760edf07d4afd9143acc6072529a5"
}, },
{ {
"path": "templates/registration/password_reset_confirm.html", "path": "templates/registration/password_reset_confirm.html",
"bytes": 2978, "bytes": 3071,
"sha256": "3ef47c477a5f129265dae53b7810f57cbd4050c9998658d7b2ae268966efa3fe" "sha256": "a39ed3c869a0db97c00f60822b4bf91d59434cfcc3ebc150f9c61e216631f646"
}, },
{ {
"path": "templates/registration/password_reset_done.html", "path": "templates/registration/password_reset_done.html",
"bytes": 1373, "bytes": 1466,
"sha256": "32efffb516923ef982998fa80a75982bd4be72260fd1738869800e9b5e26cd1d" "sha256": "22995fb1d6c3125983c1697d7a1e58d12ef8f9fdff7b244f33697b556a3a6a89"
}, },
{ {
"path": "templates/registration/password_reset_email.html", "path": "templates/registration/password_reset_email.html",
@@ -1962,8 +2052,8 @@
}, },
{ {
"path": "templates/registration/password_reset_form.html", "path": "templates/registration/password_reset_form.html",
"bytes": 1998, "bytes": 2091,
"sha256": "ea27a14361ae7ded6c7185cc9d05ce197388097c6be38b9ec9a66168356768d4" "sha256": "31190526b1bfd5b86a04169e12ee47b2088aa992d50c5653364d9402654fca74"
}, },
{ {
"path": "templates/registration/password_reset_subject.txt", "path": "templates/registration/password_reset_subject.txt",
@@ -1997,8 +2087,8 @@
}, },
{ {
"path": "tests/e2e/test_vr114_browser_and_accessibility.py", "path": "tests/e2e/test_vr114_browser_and_accessibility.py",
"bytes": 11465, "bytes": 12123,
"sha256": "588d75af1ae2ddd44551d76a38a983543da7831e83282d3045884bea62f56336" "sha256": "6e0f26b58b47f15173a005b3bc1d81c317aa25270c9d8eb6b56d8f88fd0a4708"
}, },
{ {
"path": "tests/integration/test_applications.py", "path": "tests/integration/test_applications.py",
@@ -2007,8 +2097,8 @@
}, },
{ {
"path": "tests/integration/test_auth_login.py", "path": "tests/integration/test_auth_login.py",
"bytes": 5311, "bytes": 7471,
"sha256": "2668e0af951061eb3faadf9d2f26bd304abb10c6c697611b2994c655c6ad319d" "sha256": "cb2b81368c601f2ef274db48d3a0f46e380ae27555e0b931df16fa2962015220"
}, },
{ {
"path": "tests/integration/test_dedupe_lifecycle_feedback.py", "path": "tests/integration/test_dedupe_lifecycle_feedback.py",
@@ -2082,8 +2172,8 @@
}, },
{ {
"path": "tests/integration/test_views.py", "path": "tests/integration/test_views.py",
"bytes": 6145, "bytes": 7963,
"sha256": "9784f5e94cbf4b43dba2d9b76f943dcc3db990b1b6b7e89c257414c45a854ceb" "sha256": "f36331808fdca84e8ca31c0f1e91ef0ac48a0e5280ad09997619ad010211b0aa"
}, },
{ {
"path": "tests/security/test_policy.py", "path": "tests/security/test_policy.py",
@@ -2122,8 +2212,8 @@
}, },
{ {
"path": "tests/unit/test_deployment_configuration.py", "path": "tests/unit/test_deployment_configuration.py",
"bytes": 3099, "bytes": 5571,
"sha256": "183c3773f24e0b3ed69685e18857b065662935611be3fcd2ecc3bb29f0bf33c9" "sha256": "2b704349577f03a6da2d936ddb8890a556c2fcb232b944db9fccdaf2a199c477"
}, },
{ {
"path": "tests/unit/test_discovery_rss.py", "path": "tests/unit/test_discovery_rss.py",
@@ -2165,6 +2255,11 @@
"bytes": 6798, "bytes": 6798,
"sha256": "11a79bb94cdd6d5c60674de80623ef48f6575fd237d68ef5a86d6fe3a501ca38" "sha256": "11a79bb94cdd6d5c60674de80623ef48f6575fd237d68ef5a86d6fe3a501ca38"
}, },
{
"path": "tests/unit/test_health.py",
"bytes": 795,
"sha256": "fd410a3e25586c58165757acd7c0d19c6167f41efa88729235b6ceb8d294d40c"
},
{ {
"path": "tests/unit/test_jsonld_adapter.py", "path": "tests/unit/test_jsonld_adapter.py",
"bytes": 583, "bytes": 583,
@@ -2180,6 +2275,11 @@
"bytes": 3049, "bytes": 3049,
"sha256": "c5a581ab1fb02576233afed3a3ef5b622fe7f4fc62d38abe13c103c51e77d634" "sha256": "c5a581ab1fb02576233afed3a3ef5b622fe7f4fc62d38abe13c103c51e77d634"
}, },
{
"path": "tests/unit/test_network.py",
"bytes": 1006,
"sha256": "be01553f81e090dbbbef550051a229cf1bcc3810adfd6936c9d3817d9ad80451"
},
{ {
"path": "tests/unit/test_normalization.py", "path": "tests/unit/test_normalization.py",
"bytes": 587, "bytes": 587,
@@ -2190,6 +2290,11 @@
"bytes": 2424, "bytes": 2424,
"sha256": "e8f4fca80c35c0787d4ffd36325cccac603186413af959f68d18deaa667d05f7" "sha256": "e8f4fca80c35c0787d4ffd36325cccac603186413af959f68d18deaa667d05f7"
}, },
{
"path": "tests/unit/test_public_configuration.py",
"bytes": 4509,
"sha256": "ae7b56824a37680d2b68e31e545ca828f481bf484a2398e969f5f615a9560caf"
},
{ {
"path": "tests/unit/test_rate_limit.py", "path": "tests/unit/test_rate_limit.py",
"bytes": 2504, "bytes": 2504,
@@ -2217,8 +2322,8 @@
}, },
{ {
"path": "tests/unit/test_settings_security.py", "path": "tests/unit/test_settings_security.py",
"bytes": 2637, "bytes": 3820,
"sha256": "fbac6846e3fd64d22cab365f8651deb0955866194e45f46593f42e4b14ce108d" "sha256": "817116949f63863dff57d6ec49203b991b36587a6c93c6adf796f5dd1a37a641"
}, },
{ {
"path": "tests/unit/test_skill_demand.py", "path": "tests/unit/test_skill_demand.py",
@@ -2237,13 +2342,8 @@
}, },
{ {
"path": "uv.lock", "path": "uv.lock",
"bytes": 106081, "bytes": 106082,
"sha256": "3b846377252994715529090c6b20bec6950ca7dbc92e93cbcc277175bcb4d1d5" "sha256": "e881018bbdf76bce1e0494693133c4d76a3c9967f9f1a13beb7db7b6c05fcd3f"
},
{
"path": "VERIFICATION_REPORT.md",
"bytes": 4650,
"sha256": "cd1680b3929606470698eca25a89af93f3582e67ae0234a7e2368ffa686ab6a4"
} }
] ]
} }
+10 -9
View File
@@ -94,18 +94,18 @@ Na de eerste login:
4. Controleer **Automatisering** voordat je op onbemande verwerking vertrouwt. 4. Controleer **Automatisering** voordat je op onbemande verwerking vertrouwt.
5. Verifieer in productie altijd HTTPS, back-ups, herstel en healthchecks. 5. Verifieer in productie altijd HTTPS, back-ups, herstel en healthchecks.
Belangrijke productie-instellingen: Voor een publieke HTTPS-host configureer je de bestaande `.env` veilig met:
```dotenv ```bash
APP_HOST=vacatures.example.be python scripts/configure_public_url.py \
APP_SCHEME=https https://vacatureradar.example.be \
DJANGO_DEBUG=0 --env-file .env \
SESSION_COOKIE_SECURE=1 --cache-url redis://127.0.0.1:6379/1
CSRF_COOKIE_SECURE=1
SECURE_SSL_REDIRECT=1
``` ```
Publiceer poort `1226` niet rechtstreeks op het internet. Gebruik een beheerde reverse proxy met TLS en volg de checklist in [UNRAID_DEPLOYMENT.md](docs/operations/UNRAID_DEPLOYMENT.md). De publieke URL wordt vervolgens gebruikt voor `ALLOWED_HOSTS`, CSRF, secure cookies en HTTPS-detectie. Gebruik achter Nginx altijd `DJANGO_DEBUG=0`; een debugpagina mag nooit publiek zichtbaar zijn. Hermaak de container na een environmentwijziging.
Publiceer poort `1226` niet rechtstreeks op internet. Gebruik een beheerde reverse proxy met TLS en volg [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md) en de concrete [Nginx Proxy Manager-handleiding](docs/operations/NGINX_PROXY_MANAGER.md). Een volledig productievoorbeeld staat in [`deployment/production.env.example`](deployment/production.env.example).
## Eerlijke grenzen ## Eerlijke grenzen
@@ -143,6 +143,7 @@ Die controleert formatting, lint, Django-configuratie, migraties, de volledige o
- [Lokaal ontwikkelen](docs/operations/LOCAL_DEVELOPMENT.md) - [Lokaal ontwikkelen](docs/operations/LOCAL_DEVELOPMENT.md)
- [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md) - [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md)
- [Nginx Proxy Manager configureren](docs/operations/NGINX_PROXY_MANAGER.md)
- [Push-to-deploy met Gitea](docs/operations/GITEA_DEPLOYMENT.md) - [Push-to-deploy met Gitea](docs/operations/GITEA_DEPLOYMENT.md)
- [Operationeel runbook](docs/operations/RUNBOOK.md) - [Operationeel runbook](docs/operations/RUNBOOK.md)
- [Back-up en herstel](docs/operations/BACKUP_RESTORE.md) - [Back-up en herstel](docs/operations/BACKUP_RESTORE.md)
+6 -2
View File
@@ -30,7 +30,11 @@ Deze data mag nooit shell-, netwerk-, e-mail-, bestandssysteem- of sollicitatiea
- minimale ruwe-dataretentie; - minimale ruwe-dataretentie;
- geen secrets in logs of databasefixtures; - geen secrets in logs of databasefixtures;
- AI uitgeschakeld als veilige standaard; - AI uitgeschakeld als veilige standaard;
- menselijke bevestiging voor iedere sollicitatie. - menselijke bevestiging voor iedere sollicitatie;
- een publieke URL die exact in host- en CSRF-validatie zit;
- `DEBUG=False`, secure cookies en correcte proxy-HTTPS-detectie;
- forwarded client-IPheaders alleen vanaf expliciet vertrouwde proxy-CIDR's;
- een gedeeld gastaccount uitsluitend in server-side read-onlymodus.
## Kwetsbaarheid melden ## Kwetsbaarheid melden
@@ -42,7 +46,7 @@ Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documente
4. voorgestelde mitigatie; 4. voorgestelde mitigatie;
5. eventuele rotatie- of verwijderacties. 5. eventuele rotatie- of verwijderacties.
Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen. Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen. Publieke reverse-proxyproblemen worden behandeld volgens `docs/operations/NGINX_PROXY_MANAGER.md`.
## Geen securityclaims ## Geen securityclaims
+50 -84
View File
@@ -1,102 +1,68 @@
# Verificatierapport van de aangeleverde projectbasis # Verificatierapport — VacatureRadar 0.3.12
- Datum: 2026-07-20 - Datum: 27 juli 2026
- Omgeving: Linux, Python 3.13.5, uv 0.10.0 - Onderwerp: productiehardening, Nginx Proxy Manager, gastmodus en overdrachts-ZIP
- Projectversie: 0.1.0 - Branch: `fix/public-production-hardening`
- Doel: aantonen dat de ZIP een reproduceerbare, werkende MVP-basis en autonome overdracht bevat
## Uitgevoerde volledige gate ## Gecontroleerd
Commando: ### Applicatie en regressies
```bash ```text
./scripts/codex_verify.sh 296 tests geslaagd
2 optionele Playwrighttests overgeslagen omdat de browserbinary niet lokaal aanwezig was
83,43% branch-aware codedekking; vereiste drempel 70%
``` ```
Resultaat: De afzonderlijke HTML- en accessibilityprobe bleef actief. De Playwrightfallback is hersteld zodat een geïnstalleerde Pythonmodule zonder browserbinary geen synchrone databasecall meer binnen de Playwright-eventloop uitvoert.
- Ruff: geslaagd; ### Django en data
- Django system check: geslaagd, 0 issues;
- migratiecontrole: geslaagd, geen ontbrekende migraties;
- pytest: 43/43 tests geslaagd;
- branch-aware codedekking: 80,42% (`apps` en `config`), drempel 70%;
- machineleesbare backlog: geldig, 35 taken en acyclische dependencies;
- lokale Markdownlinks, YAML/Compose/OpenAPI, `.env.example` en Codex-skillfrontmatter: geldig.
De tests omvatten unit-, integratie- en securitypaden voor parsers, URL-security, fetchbeleid, sanitization, normalisatie, dedupe, scoring, profiles, e-mailimport, notificaties, lifecycle, tasks en webviews. - `python manage.py check`: 0 issues;
- `python manage.py makemigrations --check --dry-run`: geen wijzigingen;
- productie-settings met `PUBLIC_BASE_URL=https://vacatureradar.itworx.tech`: laden correct;
- `manage.py check --deploy`: geen errors; HSTS-preload blijft bewust uit en geeft daarom alleen de verwachte Django-waarschuwing;
- publieke host en HTTPS-origin worden aantoonbaar aan `ALLOWED_HOSTS` en `CSRF_TRUSTED_ORIGINS` toegevoegd.
## Clean-room ZIP-proef ### Repository en deployment
De gegenereerde ZIP is in een lege directory uitgepakt. Voor de start is gecontroleerd dat `.env`, `.venv` en `local/db.sqlite3` niet aanwezig waren. Daarna is uitgevoerd: - repositoryvalidator: geslaagd;
- taakledger: geldig, 64 taken, 0 uitvoerbare ready-taken;
- Docker Compose, Unraid Compose en backlog-YAML: parseerbaar;
- shellsyntax van deploy- en entrypoints: geldig;
- Pythoncompilecheck: geslaagd;
- `git diff --check`: geslaagd;
- packagevalidatie en manifestcontrole: geslaagd na definitieve packaging.
```bash ## Belangrijkste herstelde productieproblemen
./scripts/codex_bootstrap.sh
```
Resultaat: - `DisallowedHost` voor de publieke Nginx-host;
- publieke Django-debugmodus;
- verkeerde externe CSRF-origin met interne poort `1226`;
- healthchecks die door HTTPS-redirect konden falen;
- proceslokale rate limiting bij meerdere Gunicornworkers;
- onvoorwaardelijk vertrouwen in `X-Forwarded-For`;
- schrijfbare gedeelde demo;
- afwijkende Unraid `.env`-paden;
- database-URL-interpolatie buiten het Unraid-`env_file`;
- URL-import die zonder staticfilesmanifest kon falen tijdens deployment checks;
- stale project-/assetversie en verouderd verificatierapport.
- nieuwe virtuele omgeving uit `uv.lock` opgebouwd; ## Omgevingsbeperking
- alle migraties op een lege SQLite-database toegepast;
- lokaal beheeraccount, standaardprofiel en één demovacature aangemaakt;
- volledige kwaliteitsgate opnieuw geslaagd met 43 tests en 80,42% dekking;
- ontwikkelserver gestart;
- `/health/live/` gaf `200` met `ok: true`;
- `/health/ready/` gaf `200` met databasecheck `ok`;
- `/login/` gaf `200` met de Nederlandstalige titel `Aanmelden · VacatureRadar`.
## Offline/reproduceerbare eigenschappen De normale `uv sync --frozen` kon in deze uitvoeringsomgeving geen packages downloaden door DNS-uitval. Voor de regressies zijn de pure Python-packages uit de door de gebruiker aangeleverde gelockte Windows-omgeving gecombineerd met reeds aanwezige Linux-wheels. Daardoor konden Django, pytest en de volledige offline suite betrouwbaar draaien.
- De standaardtests doen geen live internetrequests. De Ruff-Linuxbinary en Playwright-browser konden zonder netwerk niet worden geïnstalleerd. Pythoncompile, alle tests, Djangochecks, lijnlengtecontrole op gewijzigde Pythonbestanden en `git diff --check` zijn wel uitgevoerd. Een Dockerdaemon en de live Unraid/Nginx-omgeving waren niet beschikbaar.
- IMAP en Ollama staan standaard uit.
- Celery draait in tests eager en vereist geen Redis.
- SQLite wordt voor lokale bootstrap/tests gebruikt; productieconfiguratie ondersteunt PostgreSQL.
- Externe bronformaten worden via gesaneerde fixtures getest.
- `.venv`, caches, `.env`, lokale database, logs, media en back-ups worden door het packaging-script uitgesloten.
## Autonome overdracht ## Nog op de doelserver uitvoeren
Gevalideerd: 1. bestaande `.env` back-uppen;
2. `scripts/configure_public_url.py` uitvoeren;
3. container met `--force-recreate` herbouwen;
4. interne liveness/readiness controleren;
5. publieke TLS-, login-, logout- en CSRF-smoke uitvoeren;
6. demo-read-only controleren;
7. exacte Nginx Proxy Manager-CIDR instellen wanneer individuele client-IP-rate-limiting gewenst is;
8. databaseback-up en herstel-smoke uitvoeren.
```bash Zie `docs/operations/NGINX_PROXY_MANAGER.md` en `docs/quality/PRODUCTION_AUDIT_2026-07-27.md`.
python scripts/backlog.py validate
python scripts/backlog.py next
```
Ledgerstatus bij oplevering:
- 10 taken `done`: de werkende MVP- en overdrachtsbasis;
- 17 taken `ready`: uitvoerbare product-, hardening- en releasestappen;
- 5 taken `blocked-external`: uitsluitend live bronreview, accounts, domein/registry of bulkdataset;
- 3 taken `deferred`: multi-tenant SaaS, automatisch solliciteren en algemene publieke API.
Codex krijgt vaste productbeslissingen via `AGENTS.md`, `CODEX_START_HERE.md`, accepted ADRs, een repo-eigen skill en een startprompt. Een externe blokkade mag alleen het live restpunt blokkeren; interface, tests, mocks en documentatie moeten eerst worden voltooid.
## Niet in deze omgeving uitgevoerd
Docker was in de assemblageomgeving niet beschikbaar. Daarom zijn de Dockerfile en Composebestanden statisch gevalideerd, maar een echte imagebuild, PostgreSQL/Redis-container-smoke, Unraid-installatie en restore-drill worden niet als uitgevoerd geclaimd. `VR-115` en `VR-117` bevatten de exacte acceptatiecriteria en commando's voor een Docker-capabele omgeving.
Live bronvoorwaarden, mailboxcredentials, SMTP, domein/TLS, registry en een gelicenseerde Belgische geodatabron vereisen persoonlijke/externe configuratie en staan apart als `blocked-external`.
## VR-118 — Stitch Intelligence Cockpit (2026-07-21)
Aanvullend op de bestaande releasebasis is de volledige interface opnieuw geïntegreerd en gecontroleerd.
Uitgevoerde browsermatrix:
- 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800;
- exact één `h1` en geen documentbrede horizontale overflow op ieder formaat;
- dashboard, vacaturelijst/detail, sollicitatiepipeline, profiel/editor, bronnen en systeem;
- dark en light, mobiele open/sluitnavigatie, bron-disclosure en vacature → gesolliciteerd → pipeline;
- geen externe productie-scripts of stylesheets en geen nieuwe consolefouten na de storagefallbackfix.
Screenshots: `docs/design/validation/`. Deze map en de originele Stitch-referentie zijn via `.dockerignore` uitgesloten van de productiecontainer.
Volledige afrondingsgate:
- Ruff: geslaagd;
- Django system check: geslaagd;
- migratiecontrole: geen wijzigingen;
- pytest: 146 geslaagd, 2 optionele Playwrightvarianten overgeslagen met geslaagde HTML/a11y-fallback;
- branch-aware codedekking: 82,00%, boven de vereiste 70%;
- taakledger: 36 taken, waarvan 28 done, 5 blocked-external, 3 deferred en 0 ready;
- repositorydocumentatie en configuratie: geldig.
+5 -1
View File
@@ -28,7 +28,7 @@ def navigation_context(request: HttpRequest) -> dict[str, Any]:
} }
context = { context = {
"app_name": "VacatureRadar", "app_name": "VacatureRadar",
"app_version": "0.3.11", "app_version": settings.VACATURERADAR_VERSION,
"app_owner_name": settings.VACATURERADAR_OWNER_NAME, "app_owner_name": settings.VACATURERADAR_OWNER_NAME,
"page_context_label": page_contexts.get(route_key, "Persoonlijke cockpit"), "page_context_label": page_contexts.get(route_key, "Persoonlijke cockpit"),
} }
@@ -38,4 +38,8 @@ def navigation_context(request: HttpRequest) -> dict[str, Any]:
.only("id", "name") .only("id", "name")
.first() .first()
) )
context["demo_read_only"] = bool(
getattr(settings, "DEMO_READ_ONLY", True)
and request.user.username == settings.DEMO_USERNAME
)
return context return context
+18 -2
View File
@@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
import secrets
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from django.conf import settings
from django.core.cache import cache
from django.db import connections from django.db import connections
from django.db.utils import OperationalError from django.db.utils import DatabaseError
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -23,7 +26,20 @@ def readiness() -> HealthResult:
cursor.execute("SELECT 1") cursor.execute("SELECT 1")
cursor.fetchone() cursor.fetchone()
checks["database"] = "ok" checks["database"] = "ok"
except OperationalError as exc: except DatabaseError as exc:
ok = False ok = False
checks["database"] = f"error:{exc.__class__.__name__}" checks["database"] = f"error:{exc.__class__.__name__}"
if getattr(settings, "HEALTHCHECK_REQUIRE_CACHE", False):
key = f"health:{secrets.token_hex(8)}"
try:
cache.set(key, "ok", timeout=10)
if cache.get(key) != "ok":
raise RuntimeError("cache roundtrip failed")
cache.delete(key)
checks["cache"] = "ok"
except Exception as exc: # Health boundary: report category, never payload/details.
ok = False
checks["cache"] = f"error:{exc.__class__.__name__}"
return HealthResult(ok=ok, checks=checks) return HealthResult(ok=ok, checks=checks)
+55 -2
View File
@@ -2,11 +2,62 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from django.http import HttpRequest, HttpResponse from django.conf import settings
from django.contrib import messages
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
class DemoReadOnlyMiddleware:
"""Prevent a shared public demo account from mutating application data."""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
user = getattr(request, "user", None)
is_demo = bool(
getattr(settings, "DEMO_READ_ONLY", True)
and user
and getattr(user, "is_authenticated", False)
and getattr(user, "username", "") == settings.DEMO_USERNAME
)
if not is_demo or request.method in self.SAFE_METHODS:
return self.get_response(request)
if request.path_info == reverse("logout"):
return self.get_response(request)
if "application/json" in request.headers.get("Accept", ""):
return JsonResponse(
{
"ok": False,
"error": "demo_read_only",
"message": "De publieke demo is alleen-lezen.",
},
status=403,
)
messages.warning(
request,
"De publieke demo is alleen-lezen; wijzigingen zijn uitgeschakeld.",
)
referer = request.META.get("HTTP_REFERER", "")
if referer and url_has_allowed_host_and_scheme(
referer,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(referer)
return redirect("dashboard:today")
class SecurityHeadersMiddleware: class SecurityHeadersMiddleware:
"""Kleine CSP zonder externe assets; vacature-HTML wordt bovendien gesanitized.""" """Small CSP without external assets; vacancy HTML is sanitized separately."""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response self.get_response = get_response
@@ -33,4 +84,6 @@ class SecurityHeadersMiddleware:
"Permissions-Policy", "camera=(), microphone=(), geolocation=()" "Permissions-Policy", "camera=(), microphone=(), geolocation=()"
) )
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
if not getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
return response return response
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import ipaddress
from functools import lru_cache
from django.conf import settings
from django.http import HttpRequest
@lru_cache(maxsize=32)
def _trusted_networks(
values: tuple[str, ...],
) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
return tuple(ipaddress.ip_network(value, strict=False) for value in values)
def _parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
candidate = value.strip()
if not candidate:
return None
try:
return ipaddress.ip_address(candidate)
except ValueError:
return None
def client_ip(request: HttpRequest) -> str:
"""Return a proxy-aware client IP without trusting arbitrary forwarded headers."""
remote = _parse_ip(request.META.get("REMOTE_ADDR", ""))
if remote is None:
return "unknown"
networks = _trusted_networks(tuple(getattr(settings, "TRUSTED_PROXY_CIDRS", ())))
if not networks or not any(remote in network for network in networks):
return str(remote)
forwarded = [
parsed
for item in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")
if (parsed := _parse_ip(item)) is not None
]
if not forwarded:
return str(remote)
# Walk from the nearest hop back to the client. The first address outside
# the explicitly trusted proxy ranges is the client address.
for candidate in reversed([*forwarded, remote]):
if not any(candidate in network for network in networks):
return str(candidate)
return str(forwarded[0])
+2 -7
View File
@@ -6,12 +6,7 @@ from django.core.cache import cache
from django.http import HttpRequest from django.http import HttpRequest
from django.utils import timezone from django.utils import timezone
from apps.core.network import client_ip
def _client_ip(request: HttpRequest) -> str:
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "").strip()
if forwarded_for:
return forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR", "unknown").strip() or "unknown"
def _identity_for_user(request: HttpRequest) -> str: def _identity_for_user(request: HttpRequest) -> str:
@@ -21,7 +16,7 @@ def _identity_for_user(request: HttpRequest) -> str:
username = ( username = (
(request.POST.get("username", "") if request.method == "POST" else "").strip().lower() (request.POST.get("username", "") if request.method == "POST" else "").strip().lower()
) )
return f"anon:{username or _client_ip(request)}" return f"anon:{username or client_ip(request)}"
@dataclass(frozen=True) @dataclass(frozen=True)
+10 -1
View File
@@ -7,7 +7,7 @@ from django.contrib import messages
from django.contrib.auth import get_user_model, login from django.contrib.auth import get_user_model, login
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.http import JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
@@ -228,10 +228,19 @@ def demo_login(request):
logger.exception("Kon de demo-omgeving niet seeden") logger.exception("Kon de demo-omgeving niet seeden")
login(request, user, backend=SSO_BACKEND) login(request, user, backend=SSO_BACKEND)
request.session.set_expiry(settings.DEMO_SESSION_SECONDS)
next_url = _safe_next(request, request.POST.get("next")) next_url = _safe_next(request, request.POST.get("next"))
return redirect(next_url or settings.LOGIN_REDIRECT_URL) return redirect(next_url or settings.LOGIN_REDIRECT_URL)
def robots_txt(request):
if getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
content = "User-agent: *\nAllow: /\n"
else:
content = "User-agent: *\nDisallow: /\n"
return HttpResponse(content, content_type="text/plain; charset=utf-8")
def health_live(request): def health_live(request):
return JsonResponse({"ok": True, "service": "vacatureradar"}) return JsonResponse({"ok": True, "service": "vacatureradar"})
+112 -13
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import ipaddress
import os import os
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -29,6 +30,63 @@ def env_int_range(name: str, default: int, *, minimum: int, maximum: int) -> int
return value return value
def env_cidrs(name: str, default: str = "") -> list[str]:
networks: list[str] = []
for value in env_list(name, default):
try:
networks.append(str(ipaddress.ip_network(value, strict=False)))
except ValueError as exc:
raise ImproperlyConfigured(f"{name} bevat een ongeldig IP-netwerk: {value}") from exc
return networks
def merge_unique(*groups: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for group in groups:
for value in group:
key = value.casefold()
if key not in seen:
seen.add(key)
result.append(value)
return result
def normalize_public_base_url(value: str) -> tuple[str, str]:
raw = value.strip().rstrip("/")
if not raw:
return "", ""
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ImproperlyConfigured("PUBLIC_BASE_URL moet een geldige http(s)-URL zijn.")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ImproperlyConfigured(
"PUBLIC_BASE_URL mag geen credentials, query of fragment bevatten."
)
if parsed.path not in {"", "/"}:
raise ImproperlyConfigured("PUBLIC_BASE_URL mag geen applicatiepad bevatten.")
try:
port = parsed.port
except ValueError as exc:
raise ImproperlyConfigured("PUBLIC_BASE_URL bevat een ongeldige poort.") from exc
host = parsed.hostname.casefold()
display_host = f"[{host}]" if ":" in host else host
default_port = 443 if parsed.scheme == "https" else 80
port_suffix = f":{port}" if port and port != default_port else ""
return f"{parsed.scheme}://{display_host}{port_suffix}", host
def configured_public_base_url() -> tuple[str, str]:
raw = os.getenv("PUBLIC_BASE_URL", "").strip()
if not raw:
app_host = os.getenv("APP_HOST", "").strip()
if app_host:
scheme = os.getenv("APP_SCHEME", "https").strip().lower() or "https"
public_port = os.getenv("APP_PUBLIC_PORT", "").strip()
raw = f"{scheme}://{app_host}{f':{public_port}' if public_port else ''}"
return normalize_public_base_url(raw)
def _validate_production_security( def _validate_production_security(
*, *,
debug: bool, debug: bool,
@@ -38,6 +96,9 @@ def _validate_production_security(
session_cookie_secure: bool, session_cookie_secure: bool,
csrf_cookie_secure: bool, csrf_cookie_secure: bool,
secure_ssl_redirect: bool, secure_ssl_redirect: bool,
public_base_url: str = "",
demo_mode_enabled: bool = False,
demo_read_only: bool = True,
) -> None: ) -> None:
if debug: if debug:
return return
@@ -49,6 +110,8 @@ def _validate_production_security(
raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.") raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.")
if not allowed_hosts: if not allowed_hosts:
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie niet leeg zijn.") raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie niet leeg zijn.")
if any(host.strip() == "*" for host in allowed_hosts):
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie geen wildcard bevatten.")
if not csrf_trusted_origins: if not csrf_trusted_origins:
raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.") raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.")
if not any(origin.lower().startswith("https://") for origin in csrf_trusted_origins): if not any(origin.lower().startswith("https://") for origin in csrf_trusted_origins):
@@ -57,14 +120,26 @@ def _validate_production_security(
raise ImproperlyConfigured("Session- en CSRF-cookies moeten Secure=True zijn in productie.") raise ImproperlyConfigured("Session- en CSRF-cookies moeten Secure=True zijn in productie.")
if not secure_ssl_redirect: if not secure_ssl_redirect:
raise ImproperlyConfigured("SECURE_SSL_REDIRECT moet True zijn in productie.") raise ImproperlyConfigured("SECURE_SSL_REDIRECT moet True zijn in productie.")
if public_base_url and not public_base_url.startswith("https://"):
raise ImproperlyConfigured("PUBLIC_BASE_URL moet HTTPS gebruiken in productie.")
if demo_mode_enabled and not demo_read_only:
raise ImproperlyConfigured("Een publieke productiedemo moet alleen-lezen zijn.")
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
DEBUG = env_bool("DJANGO_DEBUG", True) DEBUG = env_bool("DJANGO_DEBUG", True)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver") PUBLIC_BASE_URL, PUBLIC_HOST = configured_public_base_url()
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS") ALLOWED_HOSTS = merge_unique(
env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver"),
[PUBLIC_HOST] if PUBLIC_HOST else [],
)
CSRF_TRUSTED_ORIGINS = merge_unique(
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
[PUBLIC_BASE_URL] if PUBLIC_BASE_URL else [],
)
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels") TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens" VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens"
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.12").strip() or "0.3.12"
LANGUAGE_CODE = "nl-be" LANGUAGE_CODE = "nl-be"
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
@@ -92,6 +167,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"apps.core.middleware.DemoReadOnlyMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"apps.core.middleware.SecurityHeadersMiddleware", "apps.core.middleware.SecurityHeadersMiddleware",
] ]
@@ -207,9 +283,11 @@ ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "")
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read") ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS") ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
# Demo-toegang: logt in als afgeschermd gastaccount met een fictieve, gevulde # Demo-toegang: in ontwikkeling standaard beschikbaar; in productie alleen expliciet.
# demo-omgeving. Standaard aan zodat de knop 'Bekijk demo' zichtbaar is. # Een publiek gastaccount blijft standaard read-only en krijgt een begrensde sessie.
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", True) DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", DEBUG)
DEMO_READ_ONLY = env_bool("DEMO_READ_ONLY", True)
DEMO_SESSION_SECONDS = env_int_range("DEMO_SESSION_SECONDS", 3600, minimum=300, maximum=86400)
DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo") DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo")
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local") DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
@@ -223,27 +301,45 @@ EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False) _PUBLIC_HTTPS = PUBLIC_BASE_URL.startswith("https://")
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False) SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", _PUBLIC_HTTPS)
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", _PUBLIC_HTTPS)
CSRF_COOKIE_SAMESITE = "Lax" CSRF_COOKIE_SAMESITE = "Lax"
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", False) SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", _PUBLIC_HTTPS)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") TRUST_PROXY_HEADERS = env_bool("TRUST_PROXY_HEADERS", bool(PUBLIC_BASE_URL))
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") if TRUST_PROXY_HEADERS else None
USE_X_FORWARDED_HOST = env_bool("USE_X_FORWARDED_HOST", False)
TRUSTED_PROXY_CIDRS = env_cidrs("TRUSTED_PROXY_CIDRS")
# Interne containerprobes blijven HTTP; publieke applicatieroutes worden wel omgeleid.
SECURE_REDIRECT_EXEMPT = [r"^health/(?:live|ready)/$"]
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0")) SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True) SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True)
SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False) SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False)
USE_X_FORWARDED_HOST = True
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
X_FRAME_OPTIONS = "DENY" X_FRAME_OPTIONS = "DENY"
SEARCH_ENGINE_INDEXING_ENABLED = env_bool("SEARCH_ENGINE_INDEXING_ENABLED", False)
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CACHE_URL = os.getenv("CACHE_URL", "" if DEBUG else REDIS_URL).strip()
_IS_TESTING = bool(os.getenv("PYTEST_CURRENT_TEST") or "pytest" in os.sys.modules)
if CACHE_URL and not _IS_TESTING:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": CACHE_URL,
"KEY_PREFIX": "vacatureradar",
"TIMEOUT": 300,
}
}
else:
CACHES = { CACHES = {
"default": { "default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "vacatureradar-rate-limit", "LOCATION": "vacatureradar-local",
} }
} }
HEALTHCHECK_REQUIRE_CACHE = env_bool("HEALTHCHECK_REQUIRE_CACHE", not DEBUG)
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CELERY_BROKER_URL = REDIS_URL CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL CELERY_RESULT_BACKEND = REDIS_URL
CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False) CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False)
@@ -358,6 +454,9 @@ _validate_production_security(
session_cookie_secure=SESSION_COOKIE_SECURE, session_cookie_secure=SESSION_COOKIE_SECURE,
csrf_cookie_secure=CSRF_COOKIE_SECURE, csrf_cookie_secure=CSRF_COOKIE_SECURE,
secure_ssl_redirect=SECURE_SSL_REDIRECT, secure_ssl_redirect=SECURE_SSL_REDIRECT,
public_base_url=PUBLIC_BASE_URL,
demo_mode_enabled=DEMO_MODE_ENABLED,
demo_read_only=DEMO_READ_ONLY,
) )
LOGGING = { LOGGING = {
+3 -2
View File
@@ -4,7 +4,6 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.templatetags.static import static as static_url
from django.urls import include, path from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
@@ -13,14 +12,16 @@ from apps.core.views import (
demo_login, demo_login,
entra_callback, entra_callback,
entra_login, entra_login,
robots_txt,
) )
urlpatterns = [ urlpatterns = [
path( path(
"favicon.ico", "favicon.ico",
RedirectView.as_view(url=static_url("favicon.svg"), permanent=True), RedirectView.as_view(url=f"{settings.STATIC_URL}favicon.svg", permanent=True),
name="favicon", name="favicon",
), ),
path("robots.txt", robots_txt, name="robots-txt"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path( path(
"login/", "login/",
+90
View File
@@ -0,0 +1,90 @@
# Productievoorbeeld — kopieer nooit letterlijk zonder alle CHANGE_ME-waarden te vervangen.
# Voor de Unraid AIO-container staat het echte bestand standaard op:
# /mnt/user/appdata/vacatureradar/source/.env
DJANGO_SECRET_KEY=CHANGE_ME_generate_at_least_50_random_characters
DJANGO_DEBUG=0
PUBLIC_BASE_URL=https://vacatureradar.example.be
DJANGO_ALLOWED_HOSTS=vacatureradar.example.be,127.0.0.1,localhost
DJANGO_CSRF_TRUSTED_ORIGINS=https://vacatureradar.example.be
DJANGO_TIME_ZONE=Europe/Brussels
VACATURERADAR_OWNER_NAME=Jens
VACATURERADAR_VERSION=0.3.12
POSTGRES_DB=vacatureradar
POSTGRES_USER=vacatureradar
POSTGRES_PASSWORD=CHANGE_ME_unique_database_password
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
DATABASE_URL=
REDIS_URL=redis://127.0.0.1:6379/0
CACHE_URL=redis://127.0.0.1:6379/1
HEALTHCHECK_REQUIRE_CACHE=1
CELERY_TASK_ALWAYS_EAGER=0
VACATURERADAR_ADMIN_USERNAME=admin
VACATURERADAR_ADMIN_EMAIL=
VACATURERADAR_ADMIN_PASSWORD=CHANGE_ME_unique_admin_password
VACATURERADAR_AUTO_BOOTSTRAP=1
# Alleen inschakelen wanneer een publieke gastdemo gewenst is. De middleware blokkeert mutaties.
DEMO_MODE_ENABLED=0
DEMO_READ_ONLY=1
DEMO_SESSION_SECONDS=3600
DEMO_USERNAME=demo
DEMO_EMAIL=demo@vacatureradar.local
TRUST_PROXY_HEADERS=1
USE_X_FORWARDED_HOST=0
# Optioneel: het exacte IP/CIDR van Nginx Proxy Manager. Zonder waarde wordt X-Forwarded-For
# niet gebruikt voor client-IP-rate-limiting, maar X-Forwarded-Proto blijft wel nodig voor HTTPS.
TRUSTED_PROXY_CIDRS=
SESSION_COOKIE_SECURE=1
CSRF_COOKIE_SECURE=1
SECURE_SSL_REDIRECT=1
# Start met 300; verhoog pas nadat HTTPS en subdomeinen gecontroleerd zijn.
SECURE_HSTS_SECONDS=300
SECURE_HSTS_INCLUDE_SUBDOMAINS=1
SECURE_HSTS_PRELOAD=0
SEARCH_ENGINE_INDEXING_ENABLED=0
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300
MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS=12
MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS=120
MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS=300
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL=VacatureRadar <vacatureradar@localhost>
DIGEST_RECIPIENT=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_USE_TLS=1
MAILBOX_CREDENTIAL_KEYS=
IMAP_CONNECT_TIMEOUT_SECONDS=15
IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000
FETCHER_USER_AGENT=VacatureRadar/0.1 (+personal-use; contact=local-admin)
FETCHER_TIMEOUT_SECONDS=20
FETCHER_MAX_BYTES=3000000
FETCHER_MAX_REDIRECTS=5
FETCHER_ALLOW_NONSTANDARD_PORTS=0
SOURCE_POLICY_MODE=strict
OLLAMA_ENABLED=0
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=
OLLAMA_TIMEOUT_SECONDS=60
ENTRA_ID_ENABLED=0
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=
ENTRA_TENANT_ID=
ENTRA_REDIRECT_URI=
ENTRA_ALLOWED_DOMAINS=
ENTRA_SCOPES=User.Read
+3 -2
View File
@@ -9,11 +9,12 @@ services:
image: vacatureradar:unraid image: vacatureradar:unraid
container_name: VacatureRadar container_name: VacatureRadar
env_file: env_file:
- /mnt/user/appdata/vacatureradar/source/.env - ${VACATURERADAR_ENV_FILE:-/mnt/user/appdata/vacatureradar/source/.env}
environment: environment:
DATABASE_URL: postgresql://vacatureradar:${POSTGRES_PASSWORD}@127.0.0.1:5432/vacatureradar
POSTGRES_HOST: 127.0.0.1 POSTGRES_HOST: 127.0.0.1
REDIS_URL: redis://127.0.0.1:6379/0 REDIS_URL: redis://127.0.0.1:6379/0
CACHE_URL: redis://127.0.0.1:6379/1
HEALTHCHECK_REQUIRE_CACHE: "1"
VACATURERADAR_RESTORE_DUMP: /app/local/backups/aio-migration.dump VACATURERADAR_RESTORE_DUMP: /app/local/backups/aio-migration.dump
restart: unless-stopped restart: unless-stopped
security_opt: security_opt:
+8 -1
View File
@@ -11,8 +11,11 @@ x-app: &app
- /var/tmp:rw,noexec,nosuid,size=128m - /var/tmp:rw,noexec,nosuid,size=128m
- /app/.cache:rw,noexec,nosuid,size=128m - /app/.cache:rw,noexec,nosuid,size=128m
env_file: env_file:
- .env - ${VACATURERADAR_ENV_FILE:-.env}
restart: unless-stopped restart: unless-stopped
environment:
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
volumes: volumes:
@@ -43,6 +46,8 @@ services:
<<: *app <<: *app
environment: environment:
VACATURERADAR_RUN_MIGRATIONS: "0" VACATURERADAR_RUN_MIGRATIONS: "0"
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
cap_drop: cap_drop:
- ALL - ALL
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"] command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"]
@@ -57,6 +62,8 @@ services:
<<: *app <<: *app
environment: environment:
VACATURERADAR_RUN_MIGRATIONS: "0" VACATURERADAR_RUN_MIGRATIONS: "0"
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
cap_drop: cap_drop:
- ALL - ALL
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"] command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
+42
View File
@@ -2202,3 +2202,45 @@ tasks:
primary_paths: primary_paths:
- docs/api/openapi.yaml - docs/api/openapi.yaml
- docs/architecture/adrs/0008-server-rendered-design.md - docs/architecture/adrs/0008-server-rendered-design.md
- id: VR-225
title: Hard publieke reverse-proxydeployment en gasttoegang uit
status: done
priority: P0
requirement_ids:
- NFR-001
- NFR-006
- NFR-009
depends_on:
- VR-224
summary: Maak de bestaande persoonlijke installatie veilig en reproduceerbaar bereikbaar achter Nginx Proxy Manager,
herstel publieke host- en CSRF-configuratie en voorkom dat een gedeeld demoaccount persistente data wijzigt.
acceptance_criteria:
- Eén publieke HTTPS-URL configureert hostvalidatie, CSRF, secure cookies, SSL-detectie en redirect zonder de interne
containerpoort in de externe origin op te nemen.
- Productie start fail-fast bij debugmodusgerelateerde onveiligheid, een zwakke secret, onveilige cookies, ontbrekende
HTTPS-origin of een schrijfbare publieke demo.
- Login- en importlimieten gebruiken in productie een gedeelde cache; forwarded client-IPs worden alleen vanaf
expliciet vertrouwde proxy-CIDRs geaccepteerd.
- Het gastaccount is server-side alleen-lezen, heeft een begrensde sessie, kan afmelden en toont de beperking zichtbaar.
- Unraid-envpad, Nginx Proxy Manager, healthchecks, noindex, heruitrol en terugdraaien zijn exact gedocumenteerd.
verification:
- PYTHONPATH=/mnt/data/vr_windows_site/site-packages pytest -q
- python manage.py check
- python manage.py makemigrations --check --dry-run
- python scripts/validate_repository.py
- bash -n scripts/deploy_docker.sh scripts/entrypoint.sh scripts/unraid_aio_entrypoint.sh
- Live Nginx-, TLS-, CSRF-, Dockerhealth- en restore-smoke op de doelserver
primary_paths:
- config/settings.py
- apps/core/middleware.py
- apps/core/network.py
- apps/core/health.py
- scripts/configure_public_url.py
- docker-compose.unraid.yml
- docs/operations/NGINX_PROXY_MANAGER.md
- docs/quality/PRODUCTION_AUDIT_2026-07-27.md
result:
completed_at: '2026-07-27'
note: Publieke host/CSRF/HTTPS-configuratie, gedeelde Redis-rate-limiting, proxy-IPvertrouwen, cache-readiness,
noindex en read-only gastdemo geïmplementeerd. 295 tests groen met 2 correcte Playwrightskips; Django check,
migratiecheck, repositoryvalidatie, YAML-, syntax- en packagechecks groen. Live servervalidatie blijft extern.
+14 -2
View File
@@ -1,7 +1,7 @@
# Projectstatus # Projectstatus
- Laatst bijgewerkt: 2026-07-26 - Laatst bijgewerkt: 2026-07-27
- Repositoryversie: 0.3.0 volledige Stitch Intelligence Cockpit - Repositoryversie: 0.3.12 productie- en reverse-proxyhardening
- Uitvoeringsmodus: autonome backlog - Uitvoeringsmodus: autonome backlog
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next` - Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
@@ -19,6 +19,18 @@ De repository bevat een uitvoerbare Django-MVP met:
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts; - Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
- offline unit-, integratie- en securitytests. - offline unit-, integratie- en securitytests.
## Laatste productiehardening
VR-225 op 2026-07-27:
- `PUBLIC_BASE_URL` stuurt publieke host-, origin- en HTTPS-instellingen aan en voorkomt de gemelde `DisallowedHost` achter Nginx Proxy Manager;
- publieke debugmodus, zwakke secrets, onveilige cookies, ontbrekende HTTPS-origin en een schrijfbare productiedemo worden fail-fast geweigerd;
- login- en importrate-limiting delen Redis over alle workers; forwarded client-IPs gelden alleen vanaf expliciet vertrouwde proxy-CIDRs;
- de gastdemo is server-side alleen-lezen, heeft een begrensde sessie en toont die beperking zichtbaar in de cockpit;
- interne healthchecks blijven HTTP-bruikbaar onder SSL redirect en readiness verifieert optioneel database plus cache;
- Unraid, publieke poort, env-pad, Nginx Proxy Manager en productie-env zijn als één reproduceerbaar deploymentcontract gedocumenteerd;
- de volledige offline suite is groen met 296 tests en 2 correcte Playwrightskips; Docker-, publieke TLS- en live Unraid-smokes blijven doelservervalidatie.
## Laatste frontendvervanging ## Laatste frontendvervanging
VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal: VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal:
+136
View File
@@ -0,0 +1,136 @@
# Nginx Proxy Manager en publiek HTTPS-domein
Deze handleiding publiceert VacatureRadar achter Nginx Proxy Manager zonder de Django-container rechtstreeks op internet bloot te stellen.
## Waarom `DisallowedHost` verschijnt
Django weigert onbekende `Host`-headers bewust. Een publieke hostnaam moet daarom expliciet overeenkomen met:
- `PUBLIC_BASE_URL`;
- `DJANGO_ALLOWED_HOSTS`;
- `DJANGO_CSRF_TRUSTED_ORIGINS`.
De applicatie leidt de host en CSRF-origin automatisch af uit `PUBLIC_BASE_URL`, maar de expliciete lijsten blijven ondersteund voor LAN- en beheerhosts.
Een publieke Django-debugpagina is een afzonderlijk beveiligingsprobleem. Gebruik achter Nginx altijd `DJANGO_DEBUG=0`; debugpagina's tonen interne paden en implementatiedetails.
## VacatureRadar configureren
Voer dit uit in de huidige bronmap op Unraid:
```bash
cd /mnt/user/appdata/vacatureradar/source
python scripts/configure_public_url.py \
https://vacatureradar.itworx.tech \
--env-file /mnt/user/appdata/vacatureradar/source/.env \
--cache-url redis://127.0.0.1:6379/1
```
De helper:
- bewaart bestaande secrets en databasewachtwoorden;
- zet `DJANGO_DEBUG=0`;
- voegt de host en CSRF-origin toe;
- activeert secure cookies en HTTPS-redirect;
- zet een conservatieve HSTS-periode;
- activeert reverse-proxyherkenning;
- maakt de gastdemo alleen-lezen;
- gebruikt Redis voor gedeelde rate limiting en readiness;
- schrijft het bestand atomair met modus `0600`.
Hermaak daarna de container. Een gewone restart laadt gewijzigde environmentvariabelen niet altijd opnieuw:
```bash
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
```
Voor een andere omgeving kan `deployment/production.env.example` als checklist worden gebruikt.
## Proxy Host in Nginx Proxy Manager
Gebruik voor de Proxy Host:
| Instelling | Waarde |
|---|---|
| Domain Names | `vacatureradar.itworx.tech` |
| Scheme | `http` |
| Forward Hostname / IP | het LAN-IP of de Docker-hostnaam van VacatureRadar |
| Forward Port | `1226` |
| Cache Assets | uit, tenzij expliciet getest |
| Block Common Exploits | aan |
| Websockets Support | niet vereist |
Op het tabblad **SSL**:
- vraag een geldig certificaat aan;
- activeer Force SSL;
- activeer HTTP/2;
- activeer HSTS pas nadat HTTPS correct werkt;
- activeer HSTS subdomains alleen wanneer alle subdomeinen HTTPS ondersteunen.
Nginx Proxy Manager stuurt de noodzakelijke proxyheaders normaal al door. Gebruik bij een aangepaste configuratie minstens:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
Overschrijf deze headers aan de proxyzijde; laat een internetclient ze niet vrij bepalen.
## Correcte client-IP-rate-limiting
VacatureRadar vertrouwt `X-Forwarded-For` alleen wanneer de directe afzender in `TRUSTED_PROXY_CIDRS` staat. Zonder deze instelling blijft rate limiting veilig op het proxy-IP werken, maar worden bezoekers niet individueel onderscheiden.
Bepaal het proxy-IP of Docker-netwerk en voeg alleen dat bereik toe, bijvoorbeeld:
```bash
python scripts/configure_public_url.py \
https://vacatureradar.itworx.tech \
--env-file /mnt/user/appdata/vacatureradar/source/.env \
--cache-url redis://127.0.0.1:6379/1 \
--trusted-proxy-cidr 172.18.0.0/16
```
Gebruik geen brede private range zoals `10.0.0.0/8` of `172.16.0.0/12` zonder aantoonbare noodzaak.
## Validatie
Controleer eerst intern:
```bash
curl -fsS http://127.0.0.1:1226/health/live/
curl -fsS http://127.0.0.1:1226/health/ready/
```
Controleer daarna publiek:
```bash
curl -I https://vacatureradar.itworx.tech/
curl -fsS https://vacatureradar.itworx.tech/robots.txt
```
Verwacht:
- geen `DisallowedHost`;
- geen Django-debugpagina;
- een redirect naar login of HTTP 200 na authenticatie;
- `Strict-Transport-Security` zodra HSTS actief is;
- `X-Content-Type-Options: nosniff`;
- `X-Frame-Options: DENY`;
- `Content-Security-Policy`;
- standaard `X-Robots-Tag: noindex, nofollow, noarchive` voor een persoonlijke installatie.
Controleer ook een echte login, logout en één CSRF-beschermde actie. Alleen een succesvolle GET bewijst niet dat proxy-HTTPS en CSRF correct zijn.
## Terugdraaien
Bij een probleem:
1. zet de Proxy Host tijdelijk op access-list/VPN-only of offline;
2. herstel de vorige `.env` uit de beheerde back-up;
3. hermaak de container;
4. controleer intern `/health/ready/`;
5. publiceer pas opnieuw nadat login en CSRF via HTTPS slagen.
+28 -29
View File
@@ -7,9 +7,10 @@ Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van
Maak deze directories: Maak deze directories:
```text ```text
/mnt/user/appdata/vacatureradar/config /mnt/user/appdata/vacatureradar/source
/mnt/user/appdata/vacatureradar/media /mnt/user/appdata/vacatureradar/source/local
/mnt/user/appdata/vacatureradar/logs /mnt/user/appdata/vacatureradar/source/local/media
/mnt/user/appdata/vacatureradar/source/local/logs
/mnt/user/appdata/vacatureradar/source/local/postgres-aio17 /mnt/user/appdata/vacatureradar/source/local/postgres-aio17
/mnt/user/appdata/vacatureradar/source/local/redis-aio /mnt/user/appdata/vacatureradar/source/local/redis-aio
/mnt/user/appdata/vacatureradar/ollama # alleen bij lokale AI /mnt/user/appdata/vacatureradar/ollama # alleen bij lokale AI
@@ -46,48 +47,46 @@ Gebruik in productie geen zwevende `latest` zonder gecontroleerd rollbackpad.
## Configuratiebestand ## Configuratiebestand
Kopieer of genereer `.env` naar: De standaardlocatie die `docker-compose.unraid.yml` leest is:
De deploy-helper `scripts/deploy_docker.sh` vult ontbrekende sleutels op basis van veilige defaults.
Zet voor publieke productie vooraf de gewenste variabelen:
```text ```text
/mnt/user/appdata/vacatureradar/config/.env /mnt/user/appdata/vacatureradar/source/.env
APP_HOST=jobs.example.be
APP_SCHEME=https
DJANGO_DEBUG=0
SESSION_COOKIE_SECURE=1
CSRF_COOKIE_SECURE=1
SECURE_SSL_REDIRECT=1
``` ```
Gebruik daarna de helper (zie hieronder). Een afwijkende locatie kan vóór Compose via `VACATURERADAR_ENV_FILE` worden ingesteld. Gebruik [`deployment/production.env.example`](../../deployment/production.env.example) alleen als checklist en kopieer nooit ongewijzigde `CHANGE_ME`-waarden.
Configureer een bestaand bestand voor de publieke URL zonder secrets te overschrijven:
```bash
cd /mnt/user/appdata/vacatureradar/source
python scripts/configure_public_url.py \
https://vacatureradar.example.be \
--env-file /mnt/user/appdata/vacatureradar/source/.env \
--cache-url redis://127.0.0.1:6379/1
```
Beperk de bestandsrechten van `.env` tot de beheerder. Voeg geen secrets toe aan de ZIP, Git, screenshots of supportlogs. Beperk de bestandsrechten van `.env` tot de beheerder. Voeg geen secrets toe aan de ZIP, Git, screenshots of supportlogs.
## Eerste uitrol ## Eerste uitrol
1. Controleer alle `CHANGE_ME`-waarden. 1. Controleer alle `CHANGE_ME`-waarden en maak een back-up van een bestaande `.env`.
2. Gebruik de deploy-helper voor een snelle eerste start: 2. Start of hermaak de Unraid-container vanuit de bronmap:
```bash ```bash
cd /app/VacatureRadar cd /mnt/user/appdata/vacatureradar/source
APP_HOST=jobs.example.be APP_SCHEME=https DJANGO_DEBUG=0 \ docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
SESSION_COOKIE_SECURE=1 CSRF_COOKIE_SECURE=1 SECURE_SSL_REDIRECT=1 \
bash scripts/deploy_docker.sh
``` ```
3. Start de ene appcontainer; de entrypoint initialiseert PostgreSQL, voert migraties uit en start daarna alle processen onder Supervisor. 3. De entrypoint valideert de productieconfiguratie, initialiseert PostgreSQL, voert migraties uit en start alle processen onder Supervisor.
6. Controleer `GET /health/live/` en `GET /health/ready/`. 4. Controleer intern `GET /health/live/` en `GET /health/ready/`.
6. Voer eenmalig in de webcontainer uit: 5. Voer alleen wanneer bootstrap niet automatisch is ingeschakeld eenmalig uit:
```bash ```bash
python manage.py bootstrap_instance docker exec -it VacatureRadar python manage.py bootstrap_instance
python manage.py collectstatic --noinput
``` ```
7. Meld lokaal aan en wijzig het bootstrapwachtwoord. 6. Meld lokaal aan en wijzig het bootstrapwachtwoord.
8. Activeer nog geen live bron of mailbox voordat bronbeleid, retentie en back-up zijn gecontroleerd. 7. Activeer nog geen live bron of mailbox voordat bronbeleid, retentie en back-up zijn gecontroleerd.
## Beheerverbinding en heruitrol ## Beheerverbinding en heruitrol
@@ -135,7 +134,7 @@ Expose de applicatie niet rechtstreeks op internet. Plaats Nginx Proxy Manager,
- optioneel extra access control/VPN voor persoonlijke installatie; - optioneel extra access control/VPN voor persoonlijke installatie;
- geen publieke toegang tot PostgreSQL, Redis of Ollama. - geen publieke toegang tot PostgreSQL, Redis of Ollama.
Wanneer een proxy op hetzelfde Docker-netwerk draait, publiceer poort 1226 alleen intern. Wanneer Unraid routing een hostpoort vereist, beperk die via firewall/VLAN tot de proxy of het beheernetwerk. Wanneer een proxy op hetzelfde Docker-netwerk draait, publiceer poort 1226 alleen intern. Wanneer Unraid routing een hostpoort vereist, beperk die via firewall/VLAN tot de proxy of het beheernetwerk. Volg voor host-, CSRF-, proxyheader- en TLS-instellingen de [Nginx Proxy Manager-handleiding](NGINX_PROXY_MANAGER.md).
## Mailbox ## Mailbox
@@ -0,0 +1,52 @@
# Productie-audit — 27 juli 2026
## Samenvatting
VacatureRadar heeft al een volwassen functionele en visuele basis. De audit vond geen noodzaak voor een nieuwe frontendarchitectuur; de belangrijkste professionaliseringswinst zat in de grens tussen Django, Nginx Proxy Manager en de Unraid-runtime.
De gerapporteerde `DisallowedHost` was een symptoom van ontbrekende publieke hostconfiguratie. De publiek zichtbare Django-debugpagina maakte tegelijk duidelijk dat de runtime nog ontwikkelinstellingen gebruikte. Deze release behandelt daarom hostvalidatie, HTTPS-detectie, CSRF, gedeelde rate limiting, readiness en gasttoegang als één productiecontract.
## Bevindingen en herstel
| Prioriteit | Bevinding | Risico | Herstel |
|---|---|---|---|
| Kritiek | Publieke host ontbrak in `ALLOWED_HOSTS` | onbeschikbare publieke route | `PUBLIC_BASE_URL` leidt host en CSRF-origin af; configuratiehelper toegevoegd |
| Kritiek | `DEBUG=True` was publiek zichtbaar | lek van interne paden en implementatiedetails | productiehelper zet debug uit; productiecheck stopt bij onveilige configuratie |
| Hoog | HTTPS-origin kon de interne poort `1226` bevatten | CSRF-fouten achter poort 443 | publieke en interne poort zijn gescheiden in deployscripts |
| Hoog | Secure redirect kon interne healthchecks omleiden | container blijft unhealthy na HTTPS-hardening | alleen `/health/live/` en `/health/ready/` zijn intern vrijgesteld |
| Hoog | Loginrate-limiting gebruikte proceslokale cache | limiet verschilde per Gunicornworker | Redis-cache als gedeelde productiestandaard en readinessdependency |
| Hoog | `X-Forwarded-For` werd zonder proxyvertrouwen gebruikt | IP-spoofing en omzeilbare rate limiting | forwarded chain alleen bij expliciet vertrouwde proxy-CIDR |
| Hoog | Gedeelde demo kon mutaties uitvoeren | bezoekers beïnvloeden elkaars demo en persistente data | server-side read-only middleware, begrensde sessie en zichtbare UI-status |
| Middel | Unraid-documentatie wees naar een andere `.env` dan Compose | wijzigingen werden niet geladen | pad gestandaardiseerd op `source/.env`; override blijft mogelijk |
| Middel | Deployhelper schreef een custom `.env`, maar Compose kon alsnog de standaardfile lezen | productie-instellingen werden stil genegeerd | geselecteerd env-bestand wordt nu voor Compose-interpolatie én containeromgeving gebruikt |
| Middel | AIO Compose interpoleerde databasewachtwoord buiten `env_file` | custom env-pad kon een kapotte URL leveren | database-URL wordt uitsluitend in de containerentrypoint opgebouwd |
| Middel | Persoonlijke installatie kon geïndexeerd worden | onbedoelde vindbaarheid van login en productmetadata | `robots.txt` en `X-Robots-Tag` standaard op noindex |
| Middel | Runtime startte zonder Django deployment check | foutieve securityconfiguratie werd laat ontdekt | `manage.py check --deploy` vóór migraties en processtart |
| Laag | Project- en assetversies liepen uiteen | cache- en release-identiteit onduidelijk | releaseversie gecentraliseerd op `0.3.12` |
| Laag | Overdrachtsarchief bevatte lokale Windows/runtimeartefacten | onnodige omvang en risico op secrets of stale state | bestaande schone packager en integriteitsmanifest verplicht gebruikt |
## Bewust niet gewijzigd
- De server-rendered Django-architectuur blijft behouden.
- De Stitch Intelligence Cockpit blijft de productiefrontend; er is geen SPA of extern runtime-CDN toegevoegd.
- De containerpoort blijft op Unraid bereikbaar voor Nginx Proxy Manager. Netwerksegmentatie of firewalling blijft een live infrastructuurtaak.
- HSTS start conservatief op 300 seconden. Verhoog pas na een geslaagde publieke login-, CSRF- en subdomeincontrole.
- Zoekmachine-indexering blijft standaard uit omdat dit een persoonlijke installatie is.
## Live controles na deployment
1. Hermaak de container met de bestaande `.env`.
2. Controleer intern beide health-endpoints.
3. Controleer publiek dat HTTP naar HTTPS gaat.
4. Controleer login en logout.
5. Voer één echte CSRF-beschermde actie uit met het beheerdersaccount.
6. Controleer dat het demoaccount geen mutatie kan uitvoeren.
7. Controleer securityheaders en afwezigheid van een debugpagina.
8. Controleer in Nginx Proxy Manager dat alleen de proxy de hostpoort kan bereiken.
9. Maak een back-up en voer een herstel-smoke uit vóór verdere bronautomatisering.
## Resterende externe punten
- De echte Nginx Proxy Manager-configuratie en het proxy-IP/CIDR zijn niet in de repository zichtbaar.
- De live Unraid-container, database en Redis zijn in deze auditomgeving niet bereikbaar.
- Een volledige Dockerbuild, publieke TLS-smoke en hersteltest moeten daarom op de doelserver worden uitgevoerd.
+3 -1
View File
@@ -81,6 +81,8 @@ Django/Celery
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` | | T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` | | T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` | | T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
| T-42 | Gespoofte proxyheaders of ontbrekende publieke hostconfiguratie | host-headerfouten, onjuiste HTTPS-detectie of omzeilde IP-rate-limiting | `PUBLIC_BASE_URL`, exacte host/CSRF-validatie, proxy moet headers overschrijven, forwarded client-IP alleen vanaf `TRUSTED_PROXY_CIDRS` | `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `VR-225` |
| T-43 | Gedeeld demoaccount wijzigt persistente data | bezoekers beïnvloeden elkaars ervaring of echte data | server-side read-only middleware, korte sessie, geen bruikbaar wachtwoord, zichtbare UI-status en productiecheck | `tests/integration/test_auth_login.py`, `tests/unit/test_settings_security.py`, `VR-225` |
## Misbruikscenario's ## Misbruikscenario's
@@ -114,7 +116,7 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
## Resterende risico's ## Resterende risico's
- De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau. - De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau.
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn beperkt door in-memory rate limiting en blokkades op login/manual-import. - Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn in productie begrensd via gedeelde Redis-rate-limiting en blokkades op login/manual-import. Correcte individuele client-IPherkenning vereist het exacte proxy-CIDR.
- Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen. - Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen.
- Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet. - Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
- Een persoonlijke Unraid-host blijft afhankelijk van correcte netwerksegmentatie, patching en back-ups door de beheerder. - Een persoonlijke Unraid-host blijft afhankelijk van correcte netwerksegmentatie, patching en back-ups door de beheerder.
+4 -2
View File
@@ -56,7 +56,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| ID | Status | Bewijs | Vervolgtaak | | ID | Status | Bewijs | Vervolgtaak |
|---|---|---|---| |---|---|---|---|
| NFR-001 | I | policy-, URL- en fetchersecuritytests + settings/rate-limit tests | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/integration/test_views.py`, `tests/integration/test_sources_manual_import.py` | | NFR-001 | I | policy-, URL- en fetchersecuritytests plus publieke host-, proxyvertrouwen-, demo- en gedeelde rate-limit-hardening | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `tests/integration/test_auth_login.py`, VR-225 |
| NFR-002 | P | raw-retentie, minimale modellen, privacyrunbooks | VR-113/115 | | NFR-002 | P | raw-retentie, minimale modellen, privacyrunbooks | VR-113/115 |
| NFR-003 | I/P | replay-, mail- en outboxtests; per-bronfouten | VR-103/112 | | NFR-003 | I/P | replay-, mail- en outboxtests; per-bronfouten | VR-103/112 |
| NFR-004 | I | ScoreRun, provenance, skillvraag met expliciet veld-/tekstonderscheid en UI-copy | VR-109/208 uitgebreid evidence | | NFR-004 | I | ScoreRun, provenance, skillvraag met expliciet veld-/tekstonderscheid en UI-copy | VR-109/208 uitgebreid evidence |
@@ -64,7 +64,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e | | NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e |
| NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 | | NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 |
| NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer, premium visuele hiërarchie, zelfstandig VacatureRadar-productmerk, lokale ITWorx-ontwikkelaarscredit, toegankelijke interactieve radarillustratie en actuele user-first productdocumentatie | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `tests/integration/test_views.py`, `artifacts/visual-validation/`, `docs/screenshots/readme/`, VR-209224 | | NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer, premium visuele hiërarchie, zelfstandig VacatureRadar-productmerk, lokale ITWorx-ontwikkelaarscredit, toegankelijke interactieve radarillustratie en actuele user-first productdocumentatie | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `tests/integration/test_views.py`, `artifacts/visual-validation/`, `docs/screenshots/readme/`, VR-209224 |
| NFR-009 | I | lokale SQLite, configureerbare eigenaarbranding, Docker/Unraidconfig, gevalideerde SSH-deployalias, release-smoke en restore | `tests/unit/test_deployment_configuration.py`, VR-117, VR-217/218 afgerond | | NFR-009 | I | lokale SQLite, configureerbare eigenaarbranding, Docker/Unraidconfig, publieke URL-helper, Nginx Proxy Manager-runbook, gevalideerde SSH-deployalias, release-smoke en restore | `tests/unit/test_deployment_configuration.py`, `tests/unit/test_public_configuration.py`, VR-117, VR-217/218/225 afgerond |
| NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability | | NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability |
## Systeemacceptatie ## Systeemacceptatie
@@ -111,3 +111,5 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
- VR-220 vervangt die tijdelijke tekstlock-up door de aangeleverde officiële ITWorx.tech-wordmark en een exact daaruit afgeleid cloud-check-appicoon voor browser, shell en Dockerman; VacatureRadar blijft de productnaam. - VR-220 vervangt die tijdelijke tekstlock-up door de aangeleverde officiële ITWorx.tech-wordmark en een exact daaruit afgeleid cloud-check-appicoon voor browser, shell en Dockerman; VacatureRadar blijft de productnaam.
- VR-221 corrigeert de merkarchitectuur: VacatureRadar krijgt een zelfstandig radar-/kansbeeldmerk voor alle productcontactpunten en ITWorx.tech blijft uitsluitend zichtbaar als officiële secundaire ontwikkelaarscredit. - VR-221 corrigeert de merkarchitectuur: VacatureRadar krijgt een zelfstandig radar-/kansbeeldmerk voor alle productcontactpunten en ITWorx.tech blijft uitsluitend zichtbaar als officiële secundaire ontwikkelaarscredit.
- VR-224 maakt de repository-ingang user-first: de README volgt de dagelijkse productflow, bevat gecontroleerde lokale quickstarts en gebruikt vier actuele dark/light- en desktop/mobielscreenshots zonder secrets of externe assets. - VR-224 maakt de repository-ingang user-first: de README volgt de dagelijkse productflow, bevat gecontroleerde lokale quickstarts en gebruikt vier actuele dark/light- en desktop/mobielscreenshots zonder secrets of externe assets.
- VR-225 hardent de publieke installatie met `PUBLIC_BASE_URL`, exacte host/CSRF-afleiding, veilige proxy-HTTPS-detectie, vertrouwde client-IPchains, gedeelde Redis-rate-limiting, cache-readiness en een server-side alleen-lezen gastdemo. Bewijs staat in de productie-audit, Nginx-runbook en de settings-, network-, health-, auth- en deploymenttests.
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "vacatureradar" name = "vacatureradar"
version = "0.1.0" version = "0.3.12"
description = "Persoonlijke autonome vacature-assistent voor toegestane publieke bronnen en vacaturemails." description = "Persoonlijke autonome vacature-assistent voor toegestane publieke bronnen en vacaturemails."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12,<3.14" requires-python = ">=3.12,<3.14"
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import os
import tempfile
from pathlib import Path
from urllib.parse import urlparse
class ConfigurationError(ValueError):
pass
def normalize_public_url(value: str) -> tuple[str, str]:
raw = value.strip().rstrip("/")
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ConfigurationError("Gebruik een volledige http(s)-URL, bijvoorbeeld https://jobs.example.be")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ConfigurationError("De publieke URL mag geen credentials, query of fragment bevatten.")
if parsed.path not in {"", "/"}:
raise ConfigurationError("De applicatie moet op de root van het domein gepubliceerd worden.")
try:
port = parsed.port
except ValueError as exc:
raise ConfigurationError("De publieke URL bevat een ongeldige poort.") from exc
host = parsed.hostname.casefold()
display_host = f"[{host}]" if ":" in host else host
default_port = 443 if parsed.scheme == "https" else 80
port_suffix = f":{port}" if port and port != default_port else ""
return f"{parsed.scheme}://{display_host}{port_suffix}", host
def parse_env(text: str) -> tuple[list[str], dict[str, str]]:
lines = text.splitlines()
values: dict[str, str] = {}
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
values[key.strip()] = value.strip()
return lines, values
def merge_csv(existing: str, additions: list[str]) -> str:
result: list[str] = []
seen: set[str] = set()
for item in [*existing.split(","), *additions]:
value = item.strip()
if value and value.casefold() not in seen:
seen.add(value.casefold())
result.append(value)
return ",".join(result)
def render_env(lines: list[str], updates: dict[str, str]) -> str:
rendered: list[str] = []
written: set[str] = set()
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
key = stripped.split("=", 1)[0].strip()
if key in updates:
rendered.append(f"{key}={updates[key]}")
written.add(key)
continue
rendered.append(line)
missing = [key for key in updates if key not in written]
if missing:
if rendered and rendered[-1].strip():
rendered.append("")
rendered.append("# Publieke URL en reverse-proxyhardening")
rendered.extend(f"{key}={updates[key]}" for key in missing)
return "\n".join(rendered).rstrip() + "\n"
def build_updates(
current: dict[str, str],
public_url: str,
*,
hsts_seconds: int,
cache_url: str | None,
trusted_proxy_cidrs: list[str],
) -> dict[str, str]:
origin, host = normalize_public_url(public_url)
if not origin.startswith("https://"):
raise ConfigurationError("Een publieke productie-URL moet HTTPS gebruiken.")
updates = {
"PUBLIC_BASE_URL": origin,
"DJANGO_DEBUG": "0",
"DJANGO_ALLOWED_HOSTS": merge_csv(
current.get("DJANGO_ALLOWED_HOSTS", ""), [host, "127.0.0.1", "localhost"]
),
"DJANGO_CSRF_TRUSTED_ORIGINS": merge_csv(
current.get("DJANGO_CSRF_TRUSTED_ORIGINS", ""), [origin]
),
"SESSION_COOKIE_SECURE": "1",
"CSRF_COOKIE_SECURE": "1",
"SECURE_SSL_REDIRECT": "1",
"SECURE_HSTS_SECONDS": str(hsts_seconds),
"TRUST_PROXY_HEADERS": "1",
"DEMO_READ_ONLY": "1",
"SEARCH_ENGINE_INDEXING_ENABLED": "0",
"HEALTHCHECK_REQUIRE_CACHE": "1",
}
if cache_url:
updates["CACHE_URL"] = cache_url
if trusted_proxy_cidrs:
updates["TRUSTED_PROXY_CIDRS"] = merge_csv(
current.get("TRUSTED_PROXY_CIDRS", ""), trusted_proxy_cidrs
)
return updates
def configure_env(
env_file: Path,
public_url: str,
*,
hsts_seconds: int = 300,
cache_url: str | None = None,
trusted_proxy_cidrs: list[str] | None = None,
) -> list[str]:
if hsts_seconds < 0:
raise ConfigurationError("HSTS-seconden mogen niet negatief zijn.")
original = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
lines, current = parse_env(original)
updates = build_updates(
current,
public_url,
hsts_seconds=hsts_seconds,
cache_url=cache_url,
trusted_proxy_cidrs=trusted_proxy_cidrs or [],
)
output = render_env(lines, updates)
env_file.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_name = tempfile.mkstemp(prefix=f".{env_file.name}.", dir=env_file.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write(output)
os.chmod(temporary_name, 0o600)
os.replace(temporary_name, env_file)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
return list(updates)
def main() -> int:
parser = argparse.ArgumentParser(
description="Configureer VacatureRadar veilig voor een publieke reverse-proxy-URL."
)
parser.add_argument("public_url", help="Bijvoorbeeld https://vacatureradar.example.be")
parser.add_argument("--env-file", type=Path, default=Path(".env"))
parser.add_argument(
"--hsts-seconds",
type=int,
default=300,
help="Start conservatief; verhoog na succesvolle HTTPS-validatie.",
)
parser.add_argument("--cache-url", help="Bij Unraid AIO: redis://127.0.0.1:6379/1")
parser.add_argument(
"--trusted-proxy-cidr",
action="append",
default=[],
help="Optioneel vertrouwd proxy-IP/CIDR voor correcte client-IP-rate-limiting.",
)
args = parser.parse_args()
try:
changed = configure_env(
args.env_file,
args.public_url,
hsts_seconds=args.hsts_seconds,
cache_url=args.cache_url,
trusted_proxy_cidrs=args.trusted_proxy_cidr,
)
except (OSError, ConfigurationError) as exc:
parser.error(str(exc))
print(f"Bijgewerkt: {args.env_file}")
print("Veilig ingestelde sleutels: " + ", ".join(changed))
print("Hermaak de container; een gewone restart laadt gewijzigde environment niet opnieuw.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80 -44
View File
@@ -36,9 +36,10 @@ generate_secret() {
fi fi
} }
ensure_env() { write_env() {
local key="$1" local key="$1"
local value="$2" local value="$2"
local force="${3:-0}"
local existing_line local existing_line
local existing_value local existing_value
@@ -49,7 +50,7 @@ ensure_env() {
fi fi
existing_value="${existing_line#*=}" existing_value="${existing_line#*=}"
if [[ -n "$existing_value" && \ if [[ "$force" != "1" && -n "$existing_value" && \
"$existing_value" != CHANGE_ME* && \ "$existing_value" != CHANGE_ME* && \
"$existing_value" != dev-only-change-me && \ "$existing_value" != dev-only-change-me && \
"$existing_value" != codex-local-only ]]; then "$existing_value" != codex-local-only ]]; then
@@ -66,12 +67,17 @@ ensure_env() {
fi fi
done < "$env_file" done < "$env_file"
mv "$temp_env" "$env_file" mv "$temp_env" "$env_file"
chmod 600 "$env_file"
} }
app_host_was_explicit=0
[[ -n "${APP_HOST:-}" ]] && app_host_was_explicit=1
app_host="${APP_HOST:-${HOSTNAME:-localhost}}" app_host="${APP_HOST:-${HOSTNAME:-localhost}}"
app_scheme="${APP_SCHEME:-http}" app_scheme="${APP_SCHEME:-http}"
app_public_port="${APP_PUBLIC_PORT:-}"
app_hostnames="${APP_HOSTS:-$app_host}" app_hostnames="${APP_HOSTS:-$app_host}"
app_origin="${APP_ORIGINS:-}" app_origin="${APP_ORIGINS:-}"
app_base_url=""
# De lokale healthcheck gebruikt 127.0.0.1. Houd die host en localhost altijd # De lokale healthcheck gebruikt 127.0.0.1. Houd die host en localhost altijd
# toegestaan, ook wanneer APP_HOSTS expliciet door de runner is ingesteld. # toegestaan, ook wanneer APP_HOSTS expliciet door de runner is ingesteld.
@@ -81,16 +87,23 @@ for required_host in 127.0.0.1 localhost; do
fi fi
done done
if [[ -z "$app_origin" ]]; then if [[ -z "$app_public_port" ]]; then
if [[ "$app_scheme" == "https" ]]; then if [[ "$app_scheme" == "https" ]]; then
if [[ "$app_port" == "443" ]]; then app_public_port="443"
app_origin="https://$app_host"
else else
app_origin="https://$app_host:$app_port" app_public_port="$app_port"
fi fi
fi
if [[ ( "$app_scheme" == "https" && "$app_public_port" == "443" ) || \
( "$app_scheme" == "http" && "$app_public_port" == "80" ) ]]; then
app_base_url="${app_scheme}://${app_host}"
else else
app_origin="http://$app_host:$app_port" app_base_url="${app_scheme}://${app_host}:${app_public_port}"
fi fi
if [[ -z "$app_origin" ]]; then
app_origin="$app_base_url"
fi fi
secret_key="$(generate_secret)" secret_key="$(generate_secret)"
@@ -98,12 +111,19 @@ postgres_password="${POSTGRES_PASSWORD:-$(generate_secret)}"
admin_password="${VACATURERADAR_ADMIN_PASSWORD:-$(generate_secret | cut -c 1-24)}" admin_password="${VACATURERADAR_ADMIN_PASSWORD:-$(generate_secret | cut -c 1-24)}"
db_url="postgresql://vacatureradar:${postgres_password}@postgres:5432/vacatureradar" db_url="postgresql://vacatureradar:${postgres_password}@postgres:5432/vacatureradar"
debug="${DJANGO_DEBUG:-1}" debug="${DJANGO_DEBUG:-}"
if [[ -z "$debug" ]]; then
if [[ "$app_host_was_explicit" == "1" && "$app_scheme" == "https" ]]; then
debug="0"
else
debug="1"
fi
fi
if [[ "$debug" == "0" ]]; then if [[ "$debug" == "0" ]]; then
session_secure="${SESSION_COOKIE_SECURE:-1}" session_secure="${SESSION_COOKIE_SECURE:-1}"
csrf_secure="${CSRF_COOKIE_SECURE:-1}" csrf_secure="${CSRF_COOKIE_SECURE:-1}"
ssl_redirect="${SECURE_SSL_REDIRECT:-1}" ssl_redirect="${SECURE_SSL_REDIRECT:-1}"
hsts_seconds="${SECURE_HSTS_SECONDS:-31536000}" hsts_seconds="${SECURE_HSTS_SECONDS:-300}"
else else
session_secure="${SESSION_COOKIE_SECURE:-0}" session_secure="${SESSION_COOKIE_SECURE:-0}"
csrf_secure="${CSRF_COOKIE_SECURE:-0}" csrf_secure="${CSRF_COOKIE_SECURE:-0}"
@@ -111,37 +131,51 @@ else
hsts_seconds="${SECURE_HSTS_SECONDS:-0}" hsts_seconds="${SECURE_HSTS_SECONDS:-0}"
fi fi
ensure_env "DJANGO_SECRET_KEY" "$secret_key" write_env "DJANGO_SECRET_KEY" "$secret_key"
ensure_env "DJANGO_DEBUG" "$debug" write_env "DJANGO_DEBUG" "$debug" "$app_host_was_explicit"
ensure_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames" write_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames" "$app_host_was_explicit"
ensure_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin" write_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin" "$app_host_was_explicit"
ensure_env "DJANGO_TIME_ZONE" "Europe/Brussels" if [[ "$app_host_was_explicit" == "1" ]]; then
ensure_env "POSTGRES_DB" "vacatureradar" write_env "PUBLIC_BASE_URL" "$app_base_url" 1
ensure_env "POSTGRES_USER" "vacatureradar" if [[ "$app_scheme" == "https" ]]; then
ensure_env "POSTGRES_PASSWORD" "$postgres_password" write_env "TRUST_PROXY_HEADERS" "1" 1
ensure_env "POSTGRES_HOST" "postgres" else
ensure_env "POSTGRES_PORT" "5432" write_env "TRUST_PROXY_HEADERS" "0" 1
ensure_env "DATABASE_URL" "$db_url" fi
ensure_env "REDIS_URL" "redis://redis:6379/0" write_env "USE_X_FORWARDED_HOST" "0" 1
ensure_env "CELERY_TASK_ALWAYS_EAGER" "0" fi
ensure_env "VACATURERADAR_ADMIN_USERNAME" "admin" write_env "DJANGO_TIME_ZONE" "Europe/Brussels"
ensure_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password" write_env "POSTGRES_DB" "vacatureradar"
ensure_env "VACATURERADAR_AUTO_BOOTSTRAP" "1" write_env "POSTGRES_USER" "vacatureradar"
ensure_env "SESSION_COOKIE_SECURE" "$session_secure" write_env "POSTGRES_PASSWORD" "$postgres_password"
ensure_env "CSRF_COOKIE_SECURE" "$csrf_secure" write_env "POSTGRES_HOST" "postgres"
ensure_env "SECURE_SSL_REDIRECT" "$ssl_redirect" write_env "POSTGRES_PORT" "5432"
ensure_env "SECURE_HSTS_SECONDS" "$hsts_seconds" write_env "DATABASE_URL" "$db_url"
ensure_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend" write_env "REDIS_URL" "redis://redis:6379/0"
ensure_env "DEFAULT_FROM_EMAIL" "VacatureRadar <vacatureradar@localhost>" write_env "CACHE_URL" "redis://redis:6379/1"
ensure_env "SOURCE_POLICY_MODE" "strict" write_env "HEALTHCHECK_REQUIRE_CACHE" "1"
ensure_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8" write_env "CELERY_TASK_ALWAYS_EAGER" "0"
ensure_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300" write_env "VACATURERADAR_ADMIN_USERNAME" "admin"
ensure_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300" write_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password"
ensure_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12" write_env "VACATURERADAR_AUTO_BOOTSTRAP" "1"
ensure_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120" write_env "SESSION_COOKIE_SECURE" "$session_secure"
ensure_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300" write_env "CSRF_COOKIE_SECURE" "$csrf_secure"
write_env "SECURE_SSL_REDIRECT" "$ssl_redirect"
write_env "SECURE_HSTS_SECONDS" "$hsts_seconds"
write_env "DEMO_READ_ONLY" "1"
write_env "SEARCH_ENGINE_INDEXING_ENABLED" "0"
write_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend"
write_env "DEFAULT_FROM_EMAIL" "VacatureRadar <vacatureradar@localhost>"
write_env "SOURCE_POLICY_MODE" "strict"
write_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8"
write_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300"
write_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300"
write_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12"
write_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120"
write_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300"
docker compose -f "$compose_file" up -d --build export VACATURERADAR_ENV_FILE="$env_file"
docker compose --env-file "$env_file" -f "$compose_file" up -d --build
if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
for i in {1..30}; do for i in {1..30}; do
@@ -157,14 +191,16 @@ if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
done done
fi fi
cat <<"EOF" cat <<EOF
Deploy klaargezet. Deploy klaargezet.
Applicatie: Applicatie:
- open http://127.0.0.1:1226/ - lokaal: http://127.0.0.1:${app_port}/
- admin: admin / waarde uit $env_file - publiek: ${app_base_url}
- admin: admin / waarde uit ${env_file}
Volgende stappen: Volgende stappen:
- Zet APP_HOST en APP_SCHEME=HTTPS voor publieke domeinstandaard. - Gebruik APP_HOST=<domein> APP_SCHEME=https DJANGO_DEBUG=0 voor publieke productie.
- Zet APP_ORIGINS en DJANGO_DEBUG=0 voor echte productie. - APP_PORT is de interne hostpoort; APP_PUBLIC_PORT is optioneel en standaard 443 bij HTTPS.
- Hermaak de container na iedere environmentwijziging.
EOF EOF
+2
View File
@@ -4,6 +4,8 @@ set -eu
mkdir -p /app/media /app/logs /app/local mkdir -p /app/media /app/logs /app/local
mkdir -p /tmp/celery mkdir -p /tmp/celery
python manage.py check --deploy
if [ "${VACATURERADAR_RUN_MIGRATIONS:-1}" = "1" ]; then if [ "${VACATURERADAR_RUN_MIGRATIONS:-1}" = "1" ]; then
python manage.py migrate --noinput python manage.py migrate --noinput
+4
View File
@@ -53,4 +53,8 @@ if [[ -n "${APP_PORT:-}" ]]; then
export APP_PORT export APP_PORT
fi fi
if [[ -n "${APP_PUBLIC_PORT:-}" ]]; then
export APP_PUBLIC_PORT
fi
bash scripts/deploy_docker.sh "$COMPOSE_FILE" "$ENV_FILE" bash scripts/deploy_docker.sh "$COMPOSE_FILE" "$ENV_FILE"
+1
View File
@@ -37,6 +37,7 @@ fi
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start
runuser -u app -- python manage.py check --deploy
runuser -u app -- python manage.py migrate --noinput runuser -u app -- python manage.py migrate --noinput
if [[ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" == "1" ]]; then if [[ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" == "1" ]]; then
runuser -u app -- python manage.py bootstrap_instance || true runuser -u app -- python manage.py bootstrap_instance || true
+7
View File
@@ -13,16 +13,21 @@ MARKDOWN_LINK = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
REQUIRED_ENV_KEYS = { REQUIRED_ENV_KEYS = {
"DJANGO_SECRET_KEY", "DJANGO_SECRET_KEY",
"DJANGO_DEBUG", "DJANGO_DEBUG",
"PUBLIC_BASE_URL",
"DJANGO_ALLOWED_HOSTS", "DJANGO_ALLOWED_HOSTS",
"DATABASE_URL", "DATABASE_URL",
"POSTGRES_PASSWORD", "POSTGRES_PASSWORD",
"REDIS_URL", "REDIS_URL",
"CACHE_URL",
"HEALTHCHECK_REQUIRE_CACHE",
"SOURCE_POLICY_MODE", "SOURCE_POLICY_MODE",
"MAILBOX_CREDENTIAL_KEYS", "MAILBOX_CREDENTIAL_KEYS",
"IMAP_CONNECT_TIMEOUT_SECONDS", "IMAP_CONNECT_TIMEOUT_SECONDS",
"IMAP_MAX_MESSAGES_PER_POLL", "IMAP_MAX_MESSAGES_PER_POLL",
"IMAP_MAX_MESSAGE_BYTES", "IMAP_MAX_MESSAGE_BYTES",
"OLLAMA_ENABLED", "OLLAMA_ENABLED",
"DEMO_READ_ONLY",
"TRUST_PROXY_HEADERS",
} }
REQUIRED_DOCS = { REQUIRED_DOCS = {
"README.md", "README.md",
@@ -34,6 +39,8 @@ REQUIRED_DOCS = {
"docs/quality/THREAT_MODEL.md", "docs/quality/THREAT_MODEL.md",
"docs/quality/TRACEABILITY_MATRIX.md", "docs/quality/TRACEABILITY_MATRIX.md",
"docs/operations/UNRAID_DEPLOYMENT.md", "docs/operations/UNRAID_DEPLOYMENT.md",
"docs/operations/NGINX_PROXY_MANAGER.md",
"deployment/production.env.example",
"docs/api/openapi.yaml", "docs/api/openapi.yaml",
".agents/skills/vacatureradar-maintainer/SKILL.md", ".agents/skills/vacatureradar-maintainer/SKILL.md",
} }
+7 -7
View File
@@ -1,9 +1,9 @@
/* Houd ook geïmporteerde stylesheets versieerbaar; alleen app.css voorzien van /* Houd ook geïmporteerde stylesheets versieerbaar; alleen app.css voorzien van
een querystring ververst anders niet noodzakelijk de browsercache. */ een querystring ververst anders niet noodzakelijk de browsercache. */
@import url("tokens.css?v=0.3.11"); @import url("tokens.css?v=0.3.12");
@import url("base.css?v=0.3.11"); @import url("base.css?v=0.3.12");
@import url("layout.css?v=0.3.11"); @import url("layout.css?v=0.3.12");
@import url("components.css?v=0.3.11"); @import url("components.css?v=0.3.12");
@import url("pages.css?v=0.3.11"); @import url("pages.css?v=0.3.12");
@import url("responsive.css?v=0.3.11"); @import url("responsive.css?v=0.3.12");
@import url("premium.css?v=0.3.11"); @import url("premium.css?v=0.3.12");
+22
View File
@@ -65,3 +65,25 @@
.auth-card { width: min(460px, 100%); padding: 30px; border: 1px solid color-mix(in srgb, var(--cyan) 25%, var(--outline)); background: color-mix(in srgb, var(--surface-container) 94%, transparent); box-shadow: 0 30px 90px rgba(0,0,0,.3), var(--shadow-signal); } .auth-card { width: min(460px, 100%); padding: 30px; border: 1px solid color-mix(in srgb, var(--cyan) 25%, var(--outline)); background: color-mix(in srgb, var(--surface-container) 94%, transparent); box-shadow: 0 30px 90px rgba(0,0,0,.3), var(--shadow-signal); }
.auth-brand { display: flex; align-items: flex-start; gap: 15px; margin-bottom: 26px; } .auth-brand { display: flex; align-items: flex-start; gap: 15px; margin-bottom: 26px; }
.auth-brand .brand-radar, .auth-brand .brand-mark { display: grid; width: 48px; height: 48px; flex: 0 0 auto; place-items: center; border: 1px solid var(--cyan); color: var(--cyan); } .auth-brand .brand-radar, .auth-brand .brand-mark { display: grid; width: 48px; height: 48px; flex: 0 0 auto; place-items: center; border: 1px solid var(--cyan); color: var(--cyan); }
/* Public demo state ------------------------------------------------------- */
.demo-mode-banner {
display: flex;
align-items: center;
gap: 12px;
margin: 12px clamp(16px, 2vw, 32px) 0;
padding: 11px 14px;
border: 1px solid color-mix(in srgb, var(--cyan) 42%, var(--outline));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--cyan) 9%, var(--surface-low));
color: var(--text);
}
.demo-mode-banner .icon { flex: 0 0 auto; color: var(--cyan); }
.demo-mode-banner span { display: grid; gap: 2px; }
.demo-mode-banner strong { font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; }
.demo-mode-banner small { color: var(--text-muted); }
[data-demo-read-only="true"] [data-demo-disabled="true"] {
cursor: not-allowed;
filter: saturate(.45);
opacity: .62;
}
+12
View File
@@ -55,6 +55,18 @@
}); });
}); });
if (document.body.dataset.demoReadOnly === "true") {
document.querySelectorAll('form[method="post" i]:not([data-demo-allow])').forEach((form) => {
form.querySelectorAll('button:not([type]), button[type="submit"], input[type="submit"]').forEach((control) => {
control.disabled = true;
control.dataset.demoDisabled = "true";
control.setAttribute("aria-describedby", "demo-read-only-note");
control.title = "Niet beschikbaar in de alleen-lezen demo";
});
});
}
const motionAllowed = !window.matchMedia("(prefers-reduced-motion: reduce)").matches; const motionAllowed = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (motionAllowed) { if (motionAllowed) {
document.querySelectorAll("[data-radar-illustration]").forEach((illustration) => { document.querySelectorAll("[data-radar-illustration]").forEach((illustration) => {
+10 -2
View File
@@ -14,9 +14,10 @@
<script src="{% static 'js/app.js' %}?v={{ app_version }}" defer></script> <script src="{% static 'js/app.js' %}?v={{ app_version }}" defer></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}"{% if demo_read_only %} data-demo-read-only="true"{% endif %}>
{% include "components/icon_sprite.html" %} {% include "components/icon_sprite.html" %}
<a class="skip-link" href="#main">Naar hoofdinhoud</a> <a class="skip-link" href="#main">Naar hoofdinhoud</a>
{% if demo_read_only %}<span id="demo-read-only-note" class="sr-only">Niet beschikbaar in de alleen-lezen demo.</span>{% endif %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="cockpit" data-app-frame> <div class="cockpit" data-app-frame>
<aside class="cockpit-sidebar" data-sidebar aria-label="Hoofdnavigatie"> <aside class="cockpit-sidebar" data-sidebar aria-label="Hoofdnavigatie">
@@ -72,12 +73,19 @@
<button class="icon-button" type="button" data-theme-toggle aria-label="Licht thema activeren" title="Thema wisselen"><svg class="icon theme-icon-dark"><use href="#icon-sun"></use></svg><svg class="icon theme-icon-light"><use href="#icon-moon"></use></svg></button> <button class="icon-button" type="button" data-theme-toggle aria-label="Licht thema activeren" title="Thema wisselen"><svg class="icon theme-icon-dark"><use href="#icon-sun"></use></svg><svg class="icon theme-icon-light"><use href="#icon-moon"></use></svg></button>
<details class="account-menu"> <details class="account-menu">
<summary aria-label="Gebruikersmenu openen"><span class="avatar avatar-small" aria-hidden="true">{{ user.first_name|default:user.username|slice:":1"|upper }}</span><svg class="icon desktop-label"><use href="#icon-chevron"></use></svg></summary> <summary aria-label="Gebruikersmenu openen"><span class="avatar avatar-small" aria-hidden="true">{{ user.first_name|default:user.username|slice:":1"|upper }}</span><svg class="icon desktop-label"><use href="#icon-chevron"></use></svg></summary>
<div class="account-popover"><strong>{{ user.get_full_name|default:user.username }}</strong><small>{{ user.username }}</small>{% if navigation_profile %}<a href="{% url 'profiles:edit' navigation_profile.pk %}">Zoekprofiel aanpassen</a>{% endif %}<form action="{% url 'logout' %}" method="post">{% csrf_token %}<button type="submit"><svg class="icon"><use href="#icon-logout"></use></svg>Afmelden</button></form></div> <div class="account-popover"><strong>{{ user.get_full_name|default:user.username }}</strong><small>{{ user.username }}</small>{% if navigation_profile %}<a href="{% url 'profiles:edit' navigation_profile.pk %}">Zoekprofiel aanpassen</a>{% endif %}<form action="{% url 'logout' %}" method="post" data-demo-allow>{% csrf_token %}<button type="submit"><svg class="icon"><use href="#icon-logout"></use></svg>Afmelden</button></form></div>
</details> </details>
</div> </div>
</div> </div>
</header> </header>
{% if demo_read_only %}
<div class="demo-mode-banner" role="status">
<svg class="icon" aria-hidden="true"><use href="#icon-eye"></use></svg>
<span><strong>Publieke demo · alleen-lezen</strong><small>Je kunt alle schermen en analyses bekijken; wijzigingen en imports zijn uitgeschakeld.</small></span>
</div>
{% endif %}
<main id="main" class="cockpit-main" tabindex="-1"> <main id="main" class="cockpit-main" tabindex="-1">
{% if messages %}<div class="toast-stack" aria-live="polite" aria-atomic="true">{% for message in messages %}<div class="toast {{ message.tags }}"><svg class="icon" aria-hidden="true"><use href="{% if message.tags == 'error' %}#icon-alert{% else %}#icon-check{% endif %}"></use></svg><span>{{ message }}</span></div>{% endfor %}</div>{% endif %} {% if messages %}<div class="toast-stack" aria-live="polite" aria-atomic="true">{% for message in messages %}<div class="toast {{ message.tags }}"><svg class="icon" aria-hidden="true"><use href="{% if message.tags == 'error' %}#icon-alert{% else %}#icon-check{% endif %}"></use></svg><span>{{ message }}</span></div>{% endfor %}</div>{% endif %}
{% else %} {% else %}
@@ -183,7 +183,8 @@ def test_vr114_browser_flow(viewport, live_server, client, tmp_path, user, profi
try: try:
browser = playwright.chromium.launch(headless=True) browser = playwright.chromium.launch(headless=True)
except Exception as exc: # pragma: no cover - env-dependent except Exception as exc: # pragma: no cover - env-dependent
_run_html_accessibility_probe(client, user, profile, job) # De afzonderlijke HTML/a11y-probe is altijd uitgevoerd. Buiten de
# Playwright-context opnieuw databasewerk doen kan Django als async zien.
pytest.skip(f"Playwright-browser niet beschikbaar: {exc}") pytest.skip(f"Playwright-browser niet beschikbaar: {exc}")
context = browser.new_context( context = browser.new_context(
+33 -10
View File
@@ -11,7 +11,6 @@ from django.urls import reverse
pytestmark = [pytest.mark.integration, pytest.mark.django_db] pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_login_page_renders_stitch_design(client): def test_login_page_renders_stitch_design(client):
response = client.get(reverse("login")) response = client.get(reverse("login"))
assert response.status_code == 200 assert response.status_code == 200
@@ -42,7 +41,6 @@ def test_login_page_keeps_official_brand_separate_from_owner_setting(client):
assert "Eigen merk" not in body assert "Eigen merk" not in body
assert 'content="VacatureRadar"' in body assert 'content="VacatureRadar"' in body
def test_login_with_email(client, user): def test_login_with_email(client, user):
response = client.post( response = client.post(
reverse("login"), reverse("login"),
@@ -52,7 +50,6 @@ def test_login_with_email(client, user):
assert response.url == reverse("dashboard:today") assert response.url == reverse("dashboard:today")
assert client.session.get("_auth_user_id") == str(user.pk) assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_with_username_still_works(client, user): def test_login_with_username_still_works(client, user):
response = client.post( response = client.post(
reverse("login"), reverse("login"),
@@ -61,7 +58,6 @@ def test_login_with_username_still_works(client, user):
assert response.status_code == 302 assert response.status_code == 302
assert client.session.get("_auth_user_id") == str(user.pk) assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_email_case_insensitive(client, user): def test_login_email_case_insensitive(client, user):
response = client.post( response = client.post(
reverse("login"), reverse("login"),
@@ -70,7 +66,6 @@ def test_login_email_case_insensitive(client, user):
assert response.status_code == 302 assert response.status_code == 302
assert client.session.get("_auth_user_id") == str(user.pk) assert client.session.get("_auth_user_id") == str(user.pk)
def test_login_wrong_password_shows_error(client, user): def test_login_wrong_password_shows_error(client, user):
response = client.post( response = client.post(
reverse("login"), reverse("login"),
@@ -79,7 +74,6 @@ def test_login_wrong_password_shows_error(client, user):
assert response.status_code == 200 assert response.status_code == 200
assert "klopt niet" in response.content.decode("utf-8") assert "klopt niet" in response.content.decode("utf-8")
def test_password_reset_sends_email(client, user): def test_password_reset_sends_email(client, user):
response = client.post(reverse("password_reset"), {"email": user.email}) response = client.post(reverse("password_reset"), {"email": user.email})
assert response.status_code == 302 assert response.status_code == 302
@@ -90,13 +84,11 @@ def test_password_reset_sends_email(client, user):
assert "wachtwoord" in message.subject.lower() assert "wachtwoord" in message.subject.lower()
assert "/wachtwoord/herstellen/" in message.body assert "/wachtwoord/herstellen/" in message.body
def test_password_reset_unknown_email_is_silent(client): def test_password_reset_unknown_email_is_silent(client):
response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"}) response = client.post(reverse("password_reset"), {"email": "onbekend@example.invalid"})
assert response.status_code == 302 assert response.status_code == 302
assert len(mail.outbox) == 0 assert len(mail.outbox) == 0
def test_demo_button_visible_by_default(client): def test_demo_button_visible_by_default(client):
assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8") assert reverse("demo-login") in client.get(reverse("login")).content.decode("utf-8")
@@ -113,7 +105,6 @@ def test_demo_login_disabled_redirects(client):
def test_demo_button_hidden_when_disabled(client): def test_demo_button_hidden_when_disabled(client):
assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8") assert "demo-login" not in client.get(reverse("login")).content.decode("utf-8")
def test_demo_login_logs_in_and_seeds_environment(client): def test_demo_login_logs_in_and_seeds_environment(client):
from apps.jobs.models import Application, JobPosting, ScoreRun from apps.jobs.models import Application, JobPosting, ScoreRun
from apps.profiles.models import SearchProfile from apps.profiles.models import SearchProfile
@@ -136,7 +127,6 @@ def test_demo_login_logs_in_and_seeds_environment(client):
dashboard = client.get(reverse("dashboard:today")) dashboard = client.get(reverse("dashboard:today"))
assert dashboard.status_code == 200 assert dashboard.status_code == 200
def test_demo_login_is_idempotent(client): def test_demo_login_is_idempotent(client):
from apps.jobs.models import JobPosting from apps.jobs.models import JobPosting
@@ -145,6 +135,39 @@ def test_demo_login_is_idempotent(client):
client.post(reverse("demo-login")) client.post(reverse("demo-login"))
assert JobPosting.objects.count() == first assert JobPosting.objects.count() == first
def test_demo_account_is_read_only_and_session_is_bounded(client):
from apps.profiles.models import SearchProfile
client.post(reverse("demo-login"))
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
active = SearchProfile.objects.get(user=demo_user, is_active=True)
alternative = SearchProfile.objects.create(user=demo_user, name="Alternatief", is_active=False)
response = client.post(
reverse("profiles:activate", args=[alternative.pk]),
HTTP_REFERER=reverse("profiles:list"),
)
assert response.status_code == 302
assert response.url == reverse("profiles:list")
active.refresh_from_db()
alternative.refresh_from_db()
assert active.is_active is True
assert alternative.is_active is False
assert client.session.get_expiry_age() <= settings.DEMO_SESSION_SECONDS
def test_demo_account_can_still_log_out(client):
client.post(reverse("demo-login"))
response = client.post(reverse("logout"))
assert response.status_code == 302
assert client.session.get("_auth_user_id") is None
def test_authenticated_demo_page_disables_mutating_forms(client):
client.post(reverse("demo-login"))
body = client.get(reverse("dashboard:today")).content.decode("utf-8")
assert 'data-demo-read-only="true"' in body
assert "Publieke demo · alleen-lezen" in body
assert "data-demo-allow" in body
def test_entra_login_disabled_redirects(client): def test_entra_login_disabled_redirects(client):
response = client.post(reverse("entra-login")) response = client.post(reverse("entra-login"))
+1 -1
View File
@@ -69,7 +69,7 @@ def test_premium_layout_uses_one_bounded_ultrawide_grid():
def test_css_imports_share_the_release_cache_version(): def test_css_imports_share_the_release_cache_version():
css = (settings.BASE_DIR / "static" / "css" / "app.css").read_text(encoding="utf-8") css = (settings.BASE_DIR / "static" / "css" / "app.css").read_text(encoding="utf-8")
assert css.count("?v=0.3.11") == 7 assert css.count("?v=0.3.12") == 7
@pytest.mark.integration @pytest.mark.integration
@@ -89,3 +89,38 @@ def test_unraid_deploy_requires_dedicated_ssh_alias() -> None:
assert "git ls-files --cached --others --exclude-standard -z" in deploy_script assert "git ls-files --cached --others --exclude-standard -z" in deploy_script
assert '[[ -e "$tracked_path" ]]' in deploy_script assert '[[ -e "$tracked_path" ]]' in deploy_script
assert "/health/ready/" in deploy_script assert "/health/ready/" in deploy_script
def test_public_https_origin_uses_external_port_not_internal_app_port() -> None:
deploy_script = (ROOT / "scripts" / "deploy_docker.sh").read_text(encoding="utf-8")
assert 'app_public_port="${APP_PUBLIC_PORT:-}"' in deploy_script
assert 'app_public_port="443"' in deploy_script
assert 'write_env "PUBLIC_BASE_URL" "$app_base_url" 1' in deploy_script
assert 'https://$app_host:$app_port' not in deploy_script
def test_health_endpoints_are_exempt_from_internal_https_redirect() -> None:
assert r"^health/(?:live|ready)/$" in settings.SECURE_REDIRECT_EXEMPT
def test_unraid_uses_dedicated_shared_cache_database() -> None:
compose = yaml.safe_load((ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8"))
environment = compose["services"]["app"]["environment"]
assert environment["CACHE_URL"] == "redis://127.0.0.1:6379/1"
assert environment["HEALTHCHECK_REQUIRE_CACHE"] == "1"
def test_unraid_database_url_is_built_inside_the_container() -> None:
compose = yaml.safe_load((ROOT / "docker-compose.unraid.yml").read_text(encoding="utf-8"))
environment = compose["services"]["app"]["environment"]
assert "DATABASE_URL" not in environment
def test_deploy_uses_selected_environment_file_for_compose() -> None:
compose = yaml.safe_load((ROOT / "docker-compose.yml").read_text(encoding="utf-8"))
deploy_script = (ROOT / "scripts" / "deploy_docker.sh").read_text(encoding="utf-8")
assert compose["x-app"]["env_file"] == ["${VACATURERADAR_ENV_FILE:-.env}"]
assert 'export VACATURERADAR_ENV_FILE="$env_file"' in deploy_script
assert 'docker compose --env-file "$env_file" -f "$compose_file" up -d --build' in deploy_script
assert 'waarde uit ${env_file}' in deploy_script
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import pytest
from django.test import override_settings
from apps.core.health import readiness
pytestmark = pytest.mark.django_db
@override_settings(HEALTHCHECK_REQUIRE_CACHE=True)
def test_readiness_checks_database_and_shared_cache():
result = readiness()
assert result.ok is True
assert result.checks == {"database": "ok", "cache": "ok"}
@override_settings(HEALTHCHECK_REQUIRE_CACHE=True)
def test_readiness_reports_cache_failure(monkeypatch):
def fail_cache(*args, **kwargs):
raise ConnectionError
monkeypatch.setattr("apps.core.health.cache.set", fail_cache)
result = readiness()
assert result.ok is False
assert result.checks["database"] == "ok"
assert result.checks["cache"] == "error:ConnectionError"
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from django.test import RequestFactory, override_settings
from apps.core.network import client_ip
def _request(remote: str, forwarded: str = ""):
request = RequestFactory().get("/")
request.META["REMOTE_ADDR"] = remote
if forwarded:
request.META["HTTP_X_FORWARDED_FOR"] = forwarded
return request
@override_settings(TRUSTED_PROXY_CIDRS=[])
def test_client_ip_ignores_untrusted_forwarded_header():
assert client_ip(_request("203.0.113.20", "198.51.100.7")) == "203.0.113.20"
@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"])
def test_client_ip_uses_forwarded_chain_only_from_trusted_proxy():
request = _request("172.18.0.4", "198.51.100.7, 172.18.0.3")
assert client_ip(request) == "198.51.100.7"
@override_settings(TRUSTED_PROXY_CIDRS=["172.18.0.0/16"])
def test_client_ip_skips_invalid_forwarded_values():
request = _request("172.18.0.4", "invalid, 198.51.100.9")
assert client_ip(request) == "198.51.100.9"
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
from django.core.exceptions import ImproperlyConfigured
from config.settings import normalize_public_base_url
from scripts.configure_public_url import ConfigurationError, configure_env, normalize_public_url
@pytest.mark.parametrize(
("raw", "origin", "host"),
[
(
"https://vacatureradar.itworx.tech/",
"https://vacatureradar.itworx.tech",
"vacatureradar.itworx.tech",
),
("https://jobs.example.be:8443", "https://jobs.example.be:8443", "jobs.example.be"),
("http://127.0.0.1:1226", "http://127.0.0.1:1226", "127.0.0.1"),
],
)
def test_public_url_normalization(raw, origin, host):
assert normalize_public_base_url(raw) == (origin, host)
assert normalize_public_url(raw) == (origin, host)
@pytest.mark.parametrize(
"raw",
[
"vacatureradar.itworx.tech",
"ftp://vacatureradar.itworx.tech",
"https://user:secret@example.org",
"https://example.org/app",
"https://example.org?debug=1",
],
)
def test_public_url_rejects_unsafe_or_unsupported_values(raw):
with pytest.raises((ImproperlyConfigured, ConfigurationError)):
normalize_public_base_url(raw)
def test_configure_public_url_updates_only_public_security_settings(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text(
"DJANGO_SECRET_KEY=keep-this-secret\n"
"POSTGRES_PASSWORD=keep-this-password\n"
"DJANGO_ALLOWED_HOSTS=192.168.10.150,localhost\n"
"DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226\n",
encoding="utf-8",
)
changed = configure_env(
env_file,
"https://vacatureradar.itworx.tech/",
hsts_seconds=300,
cache_url="redis://127.0.0.1:6379/1",
trusted_proxy_cidrs=["172.18.0.0/16"],
)
content = env_file.read_text(encoding="utf-8")
assert "PUBLIC_BASE_URL=https://vacatureradar.itworx.tech" in content
assert "DJANGO_DEBUG=0" in content
assert "vacatureradar.itworx.tech" in content
assert (
"DJANGO_CSRF_TRUSTED_ORIGINS="
"http://192.168.10.150:1226,https://vacatureradar.itworx.tech"
) in content
assert "CACHE_URL=redis://127.0.0.1:6379/1" in content
assert "TRUSTED_PROXY_CIDRS=172.18.0.0/16" in content
assert "DJANGO_SECRET_KEY=keep-this-secret" in content
assert "POSTGRES_PASSWORD=keep-this-password" in content
assert env_file.stat().st_mode & 0o777 == 0o600
assert "DJANGO_SECRET_KEY" not in changed
def test_configure_public_url_rejects_plain_http(tmp_path: Path):
with pytest.raises(ConfigurationError, match="HTTPS"):
configure_env(tmp_path / ".env", "http://vacatureradar.example.be")
def test_public_base_url_populates_django_host_and_csrf_settings():
environment = os.environ.copy()
environment.update(
{
"DJANGO_SECRET_KEY": "aB3!" * 16,
"DJANGO_DEBUG": "0",
"PUBLIC_BASE_URL": "https://vacatureradar.itworx.tech",
"DEMO_MODE_ENABLED": "0",
"SECURE_HSTS_SECONDS": "300",
}
)
result = subprocess.run(
[
sys.executable,
"-c",
(
"import config.settings as settings; "
"assert 'vacatureradar.itworx.tech' in settings.ALLOWED_HOSTS; "
"assert 'https://vacatureradar.itworx.tech' "
"in settings.CSRF_TRUSTED_ORIGINS; "
"assert settings.DEBUG is False; "
"assert settings.SESSION_COOKIE_SECURE is True; "
"assert settings.CSRF_COOKIE_SECURE is True; "
"assert settings.SECURE_SSL_REDIRECT is True"
),
],
cwd=Path(__file__).resolve().parents[2],
env=environment,
capture_output=True,
text=True,
check=False,
) # noqa: S603 - fixed interpreter and static assertion program
assert result.returncode == 0, result.stderr
def test_configure_public_url_is_idempotent(tmp_path: Path):
env_file = tmp_path / ".env"
env_file.write_text("DJANGO_ALLOWED_HOSTS=localhost\n", encoding="utf-8")
for _ in range(2):
configure_env(env_file, "https://vacatureradar.example.be")
content = env_file.read_text(encoding="utf-8")
assert content.count("PUBLIC_BASE_URL=") == 1
assert content.count("vacatureradar.example.be") == 3
+32
View File
@@ -45,6 +45,7 @@ def test_validate_production_security_skips_when_debug_mode_is_enabled():
("change-me", ["example.org"], ["https://example.org"], True, True, True), ("change-me", ["example.org"], ["https://example.org"], True, True, True),
("too-short", ["example.org"], ["https://example.org"], True, True, True), ("too-short", ["example.org"], ["https://example.org"], True, True, True),
("a" * 64, [], ["https://example.org"], True, True, True), ("a" * 64, [], ["https://example.org"], True, True, True),
("a" * 64, ["*"], ["https://example.org"], True, True, True),
("a" * 64, ["example.org"], [], True, True, True), ("a" * 64, ["example.org"], [], True, True, True),
("a" * 64, ["example.org"], ["http://example.org"], True, True, True), ("a" * 64, ["example.org"], ["http://example.org"], True, True, True),
("a" * 64, ["example.org"], ["https://example.org"], False, True, True), ("a" * 64, ["example.org"], ["https://example.org"], False, True, True),
@@ -70,3 +71,34 @@ def test_validate_production_security_requires_secure_settings_for_prod(
csrf_cookie_secure=csrf_cookie_secure, csrf_cookie_secure=csrf_cookie_secure,
secure_ssl_redirect=secure_ssl_redirect, secure_ssl_redirect=secure_ssl_redirect,
) )
def test_validate_production_security_rejects_writable_public_demo():
with pytest.raises(ImproperlyConfigured):
_validate_production_security(
debug=False,
secret_key="a" * 64,
allowed_hosts=["vacatureradar.itworx.tech"],
csrf_trusted_origins=["https://vacatureradar.itworx.tech"],
session_cookie_secure=True,
csrf_cookie_secure=True,
secure_ssl_redirect=True,
public_base_url="https://vacatureradar.itworx.tech",
demo_mode_enabled=True,
demo_read_only=False,
)
def test_validate_production_security_accepts_read_only_https_demo():
_validate_production_security(
debug=False,
secret_key="a" * 64,
allowed_hosts=["vacatureradar.itworx.tech"],
csrf_trusted_origins=["https://vacatureradar.itworx.tech"],
session_cookie_secure=True,
csrf_cookie_secure=True,
secure_ssl_redirect=True,
public_base_url="https://vacatureradar.itworx.tech",
demo_mode_enabled=True,
demo_read_only=True,
)
Generated
+1 -1
View File
@@ -776,7 +776,7 @@ wheels = [
[[package]] [[package]]
name = "vacatureradar" name = "vacatureradar"
version = "0.1.0" version = "0.3.12"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },