@@ -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
|
||||||
|
|||||||
@@ -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 URL’s.
|
||||||
|
- 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-CIDR’s.
|
||||||
|
- 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
@@ -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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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/",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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-IP’s worden alleen vanaf
|
||||||
|
expliciet vertrouwde proxy-CIDR’s 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.
|
||||||
|
|||||||
@@ -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-IP’s gelden alleen vanaf expliciet vertrouwde proxy-CIDR’s;
|
||||||
|
- 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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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-209–224 |
|
| 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-209–224 |
|
||||||
| 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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user