@django-stack/cli 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/bin.js +840 -54
  2. package/dist/index.d.ts +43 -4
  3. package/dist/index.js +1 -1
  4. package/dist/{production-CGkI7oNe.js → production-Ct_qoeXk.js} +210 -7
  5. package/package.json +1 -1
  6. package/templates/backend/Dockerfile +4 -5
  7. package/templates/backend/config/settings.py +3 -0
  8. package/templates/backend/config/studio.py +4 -0
  9. package/templates/backend/content/apps.py +5 -1
  10. package/templates/backend/content/models.py +2 -2
  11. package/templates/backend/content/notifications.py +36 -0
  12. package/templates/backend/content/tests/test_api.py +32 -2
  13. package/templates/backend/pyproject.toml +1 -1
  14. package/templates/base/AGENTS.md +8 -8
  15. package/templates/base/Caddyfile +7 -1
  16. package/templates/base/README.md +3 -3
  17. package/templates/base/compose.yaml +37 -2
  18. package/templates/base/env.example +29 -0
  19. package/templates/frontend-astro/AGENTS.md +3 -3
  20. package/templates/frontend-astro/Dockerfile +7 -4
  21. package/templates/frontend-astro/astro.config.mjs +16 -5
  22. package/templates/frontend-astro/package.json +1 -1
  23. package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
  24. package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
  25. package/templates/frontend-astro/src/middleware.ts +9 -1
  26. package/templates/frontend-next/AGENTS.md +3 -3
  27. package/templates/frontend-next/Dockerfile +6 -3
  28. package/templates/frontend-next/package.json +3 -3
  29. package/templates/frontend-next/pnpm-workspace.yaml +11 -0
  30. package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
  31. package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
  32. package/templates/frontend-next/src/instrumentation.ts +12 -0
  33. package/templates/frontend-next/src/proxy.ts +4 -1
  34. package/templates/frontend-nuxt/AGENTS.md +3 -3
  35. package/templates/frontend-nuxt/Dockerfile +6 -3
  36. package/templates/frontend-nuxt/nuxt.config.ts +7 -3
  37. package/templates/frontend-nuxt/package.json +2 -2
  38. package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
  39. package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
  40. package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
  41. package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
  42. package/templates/frontend-sveltekit/AGENTS.md +3 -3
  43. package/templates/frontend-sveltekit/Dockerfile +7 -4
  44. package/templates/frontend-sveltekit/package.json +2 -2
  45. package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
  46. package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
  47. package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
  48. package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
  49. package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
  50. package/templates/frontend-sveltekit/vite.config.ts +10 -3
@@ -27,6 +27,12 @@
27
27
  header ?X-Frame-Options "DENY"
28
28
  header -Server
29
29
 
30
+ # A request ID for every request: Django and the frontend add it to
31
+ # their log lines and return it in X-Request-ID, and Caddy's own log
32
+ # line carries it as request_id. One sent by the client is replaced.
33
+ request_header X-Request-ID {http.request.uuid}
34
+ log_append request_id {http.request.uuid}
35
+
30
36
  # Uploads, when media is stored on the server instead of S3. No scripts
31
37
  # run from uploaded files, whatever their type.
32
38
  handle_path /media/* {
@@ -59,6 +65,6 @@
59
65
 
60
66
  log {
61
67
  output stdout
62
- format console
68
+ format json
63
69
  }
64
70
  }
@@ -38,9 +38,9 @@ the frontend {{frontend_label}}'s, with the source mounted from `backend/` and
38
38
  |------|---------|
39
39
  | Run a manage.py command | `docker compose exec backend python manage.py <command>` |
40
40
  | Make migrations | `docker compose exec backend python manage.py makemigrations` |
41
- | Regenerate the API types | `npm run generate:types` |
42
- | Run the tests | `npm test` |
43
- | Check the setup | `npm run doctor` |
41
+ | Regenerate the API types | `{{pm_run}} generate:types` |
42
+ | Run the tests | `{{pm_run}} test` |
43
+ | Check the setup | `{{pm_run}} doctor` |
44
44
 
45
45
  With the `dstack` command installed (`npm i -g @django-stack/cli`),
46
46
  `dstack dev`, `dstack check`, `dstack manage <command>` and `dstack types` do
@@ -26,6 +26,14 @@ x-hardened: &hardened
26
26
  cap_drop:
27
27
  - ALL
28
28
 
29
+ # Every service: Docker keeps at most 5 log files of 10 MB, so logs never
30
+ # fill the disk.
31
+ x-logging: &logging
32
+ driver: json-file
33
+ options:
34
+ max-size: 10m
35
+ max-file: "5"
36
+
29
37
  x-frontend-env: &frontend-env
30
38
  SITE_URL: ${SITE_URL}
31
39
  API_URL: http://backend:8000/api
@@ -37,6 +45,13 @@ x-frontend-env: &frontend-env
37
45
  # Page analytics: the tracker loads from /_a/ on the site when set.
38
46
  UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
39
47
  UMAMI_URL: ${UMAMI_URL:-}
48
+ # The captcha widget of the contact form, unless CAPTCHA=off.
49
+ CAPTCHA: ${CAPTCHA:-on}
50
+ # Server errors to Sentry with ERRORS=sentry, as the backend's.
51
+ ERRORS: ${ERRORS:-email}
52
+ SENTRY_DSN: ${SENTRY_DSN:-}
53
+ SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
54
+ SENTRY_RELEASE: ${SENTRY_RELEASE:-}
40
55
 
41
56
  x-backend-depends: &backend-depends
42
57
  db:
@@ -60,6 +75,14 @@ x-backend-health: &backend-health
60
75
  start_period: 60s
61
76
  retries: 30
62
77
 
78
+ # The worker stamps a heartbeat every minute; unhealthy when it stopped.
79
+ x-worker-health: &worker-health
80
+ test: ["CMD", "python", "manage.py", "worker_alive"]
81
+ interval: 30s
82
+ timeout: 20s
83
+ start_period: 120s
84
+ retries: 3
85
+
63
86
  services:
64
87
  # Development ------------------------------------------------------------
65
88
 
@@ -78,6 +101,7 @@ services:
78
101
  - "127.0.0.1:${BACKEND_PORT:-8000}:8000"
79
102
  depends_on: *backend-depends
80
103
  healthcheck: *backend-health
104
+ logging: *logging
81
105
  restart: unless-stopped
82
106
 
83
107
  worker:
@@ -96,6 +120,8 @@ services:
96
120
  depends_on:
97
121
  backend:
98
122
  condition: service_healthy
123
+ healthcheck: *worker-health
124
+ logging: *logging
99
125
  restart: unless-stopped
100
126
 
101
127
  frontend:
@@ -112,6 +138,7 @@ services:
112
138
  depends_on:
113
139
  backend:
114
140
  condition: service_healthy
141
+ logging: *logging
115
142
  restart: unless-stopped
116
143
 
117
144
  mailpit:
@@ -119,6 +146,7 @@ services:
119
146
  image: axllent/mailpit:v1.27
120
147
  ports:
121
148
  - "127.0.0.1:${MAIL_PORT:-8025}:8025"
149
+ logging: *logging
122
150
  restart: unless-stopped
123
151
 
124
152
  # A local S3 service, so development runs the same storage code as
@@ -132,6 +160,7 @@ services:
132
160
  - s3data:/data
133
161
  ports:
134
162
  - "127.0.0.1:${S3_PORT:-8333}:8333"
163
+ logging: *logging
135
164
  restart: unless-stopped
136
165
 
137
166
  # Production -------------------------------------------------------------
@@ -160,6 +189,7 @@ services:
160
189
  resources:
161
190
  limits:
162
191
  memory: 768M
192
+ logging: *logging
163
193
  restart: unless-stopped
164
194
 
165
195
  worker-prod:
@@ -185,13 +215,13 @@ services:
185
215
  depends_on:
186
216
  backend-prod:
187
217
  condition: service_healthy
188
- healthcheck:
189
- disable: true
218
+ healthcheck: *worker-health
190
219
  deploy:
191
220
  replicas: ${TASK_WORKERS:-1}
192
221
  resources:
193
222
  limits:
194
223
  memory: 512M
224
+ logging: *logging
195
225
  restart: unless-stopped
196
226
 
197
227
  frontend-prod:
@@ -212,6 +242,7 @@ services:
212
242
  resources:
213
243
  limits:
214
244
  memory: 512M
245
+ logging: *logging
215
246
  restart: unless-stopped
216
247
 
217
248
  # The only service with public ports: HTTPS for SITE_URL with a
@@ -244,6 +275,7 @@ services:
244
275
  resources:
245
276
  limits:
246
277
  memory: 256M
278
+ logging: *logging
247
279
  restart: unless-stopped
248
280
 
249
281
  # Shared -----------------------------------------------------------------
@@ -276,6 +308,7 @@ services:
276
308
  resources:
277
309
  limits:
278
310
  memory: 512M
311
+ logging: *logging
279
312
  restart: unless-stopped
280
313
 
281
314
  db:
@@ -298,6 +331,7 @@ services:
298
331
  resources:
299
332
  limits:
300
333
  memory: 1G
334
+ logging: *logging
301
335
  restart: unless-stopped
302
336
 
303
337
  # Valkey (the valkey profile), for CACHE_URL or TASKS_URL redis://cache.
@@ -318,6 +352,7 @@ services:
318
352
  resources:
319
353
  limits:
320
354
  memory: 256M
355
+ logging: *logging
321
356
  restart: unless-stopped
322
357
 
323
358
  volumes:
@@ -114,3 +114,32 @@ BACKUP_TIME=03:00
114
114
  BACKUP_KEEP=14
115
115
  BACKUP_MEDIA=1
116
116
  BACKUP_S3_BUCKET=
117
+ # Requested after each daily backup: the URL of a Healthchecks.io check or
118
+ # an Uptime Kuma push monitor, which alerts you when the pings stop.
119
+ BACKUP_PING_URL=
120
+
121
+ # The captcha of the contact form: a proof of work the visitor's browser
122
+ # solves, served by Django at /api/cap/, with no third party. CAPTCHA=off
123
+ # turns it off for the backend and the frontend.
124
+ CAPTCHA=on
125
+
126
+ # Error reports (with DJANGO_DEBUG=0): each new error of the backend, the
127
+ # worker or a backup is mailed to ERROR_EMAIL (comma separated) or, when it
128
+ # is empty, to the superusers, and shows in the admin's notifications. The
129
+ # same error is mailed once an hour at most. ERRORS=off turns it off.
130
+ ERRORS=email
131
+ ERROR_EMAIL=
132
+ # ERRORS=sentry sends the errors of the backend, the worker and the
133
+ # frontend's server to Sentry instead: create a Django project on sentry.io
134
+ # and paste its DSN here. SENTRY_TRACES_SAMPLE_RATE (0 to 1) also sends that
135
+ # share of the backend's requests as performance traces.
136
+ SENTRY_DSN=
137
+ SENTRY_ENVIRONMENT=production
138
+ SENTRY_TRACES_SAMPLE_RATE=0
139
+
140
+ # Logs: one JSON object per line in production, plain text in development
141
+ # (LOG_FORMAT=json or plain). LOG_ACCESS=0 turns off Django's access log.
142
+ # See the logs page of the DjangoStack docs.
143
+ # LOG_FORMAT=json
144
+ # LOG_LEVEL=INFO
145
+ # LOG_ACCESS=1
@@ -12,7 +12,7 @@ src/layouts/Base.astro the page shell: head, menus, footer, preview banner
12
12
  src/components/ Blocks.astro maps block types to components in components/blocks/
13
13
  src/lib/api.ts getContent(): content helpers with the read-only key, drafts in preview
14
14
  src/lib/cache.ts the in-memory page cache that /revalidate empties
15
- src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
15
+ src/lib/headless.ts generated API types; do not edit, run `{{pm_run}} generate:types`
16
16
  src/middleware.ts page cache, editor-managed redirects, security headers
17
17
  astro.config.mjs server output, Content-Security-Policy, the dev proxy to Django
18
18
  ```
@@ -39,5 +39,5 @@ Run them in the `frontend` container (`docker compose exec frontend <command>`):
39
39
 
40
40
  | Task | Command |
41
41
  |------|---------|
42
- | Type-check | `npm run check` |
43
- | Test | `npm test` |
42
+ | Type-check | `{{pm_run}} check` |
43
+ | Test | `{{pm_run}} test` |
@@ -7,10 +7,13 @@ WORKDIR /app
7
7
  ENV ASTRO_TELEMETRY_DISABLED=1
8
8
 
9
9
  FROM base AS deps
10
- COPY package.json package-lock.json* ./
10
+ {{pm_setup}}
11
+ COPY {{pm_manifests}} ./
11
12
  COPY vendor/ vendor/
12
- RUN --mount=type=cache,target=/root/.npm \
13
- if [ -f package-lock.json ]; then npm ci; else npm install; fi
13
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
14
+ RUN {{pm_install}}
15
+ # The package manager only installs: npm, which comes with Node.js, runs
16
+ # the scripts of package.json below.
14
17
 
15
18
  FROM deps AS dev
16
19
  EXPOSE 3000
@@ -21,7 +24,7 @@ CMD ["sh", "-c", "rm -f .astro/dev.json && exec npm run dev"]
21
24
 
22
25
  FROM deps AS build
23
26
  COPY . .
24
- RUN npm run build && npm prune --omit=dev
27
+ RUN npm run build && {{pm_prune}}
25
28
 
26
29
  FROM base AS prod
27
30
  ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import node from "@astrojs/node";
3
+ import { CAPTCHA_CSP, CAPTCHA_STYLE_HASH } from "@django-stack/utils";
3
4
  import { defineConfig } from "astro/config";
4
5
 
5
6
  // The Django backend: the frontend container reaches it on the Compose network.
@@ -15,14 +16,24 @@ export default defineConfig({
15
16
  server: { host: true, port: 3000 },
16
17
  // A Content-Security-Policy on every page: scripts and styles only from
17
18
  // this site, with hashes for the ones Astro inlines. src/middleware.ts adds
18
- // the origin of the media files.
19
+ // the origin of the media files. CAPTCHA_CSP allows the captcha widget of
20
+ // the contact form: its solver, its style and its workers.
19
21
  security: {
20
22
  csp: {
21
- directives: ["default-src 'self'", "base-uri 'self'", "form-action 'self'", "object-src 'none'"],
23
+ directives: [
24
+ "default-src 'self'",
25
+ "base-uri 'self'",
26
+ "form-action 'self'",
27
+ "object-src 'none'",
28
+ `worker-src ${CAPTCHA_CSP["worker-src"].join(" ")}`,
29
+ ],
22
30
  // Scripts from this site, such as the analytics tracker at /_a/script.js.
23
- scriptDirective: { resources: ["'self'"] },
24
- // Inline style attributes, such as the focal point of an image.
25
- styleDirective: { resources: ["'self'", { resource: "'unsafe-inline'", kind: "attribute" }] },
31
+ scriptDirective: { resources: ["'self'", ...CAPTCHA_CSP["script-src"]] },
32
+ styleDirective: {
33
+ // Inline style attributes, such as the focal point of an image.
34
+ resources: ["'self'", { resource: "'unsafe-inline'", kind: "attribute" }],
35
+ hashes: [/** @type {`sha256-${string}`} */ (CAPTCHA_STYLE_HASH.replaceAll("'", ""))],
36
+ },
26
37
  },
27
38
  },
28
39
  vite: {
@@ -16,7 +16,7 @@
16
16
  "@astrojs/node": "^11.1.9",
17
17
  "@astrojs/rss": "^4.0.19",
18
18
  "@django-headless/client": "^1.1.1",
19
- "@django-stack/utils": "^0.8.1",
19
+ "@django-stack/utils": "^0.10.0",
20
20
  "astro": "^7.4.5"
21
21
  },
22
22
  "devDependencies": {
@@ -0,0 +1,17 @@
1
+ # pnpm's settings for the frontend (pnpm 11 and later).
2
+
3
+ # Resolve only versions published at least a day ago, so a compromised
4
+ # release is usually caught before it reaches you. DjangoStack's own
5
+ # packages are exempt, so a project made on a release day installs.
6
+ minimumReleaseAge: 1440
7
+ minimumReleaseAgeExclude:
8
+ - "@django-stack/*"
9
+
10
+ # Which dependencies may run install scripts; pnpm refuses others.
11
+ allowBuilds: {}
12
+
13
+ # The peer ranges of @astrojs/check allow a 2.0 beta of @emnapi, which pnpm
14
+ # picks and which crashes `astro check`. Keep @emnapi on 1.x, as npm does.
15
+ overrides:
16
+ "@emnapi/core": ^1.11.3
17
+ "@emnapi/runtime": ^1.11.3
@@ -1,20 +1,29 @@
1
1
  ---
2
2
  /**
3
3
  * The contact form. It posts to Django's public create route for form
4
- * submissions (PUBLIC_CREATE in backend/config/settings.py), which is
5
- * rate limited per IP address. Submissions appear in the admin.
4
+ * submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
5
+ * limited per IP address and, unless CAPTCHA=off, needs the token of the
6
+ * captcha widget. Submissions appear in the admin.
6
7
  */
8
+ import { CAPTCHA_ATTRIBUTES, captchaEnabled } from "@django-stack/utils";
9
+
10
+ const captcha = captchaEnabled(process.env.CAPTCHA);
7
11
  ---
8
12
 
9
13
  <form class="contact-form" data-endpoint="/api/content.formsubmission">
10
14
  <label>Name <input name="name" required maxlength="200" autocomplete="name" /></label>
11
15
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
12
16
  <label>Message <textarea name="message" required maxlength="5000" rows="6"></textarea></label>
17
+ {captcha && <cap-widget {...CAPTCHA_ATTRIBUTES}></cap-widget>}
13
18
  <button class="button" type="submit">Send</button>
14
19
  <p class="status" role="status" aria-live="polite"></p>
15
20
  </form>
16
21
 
17
22
  <script>
23
+ import { loadCaptcha, resetCaptcha } from "@django-stack/utils";
24
+
25
+ if (document.querySelector("form.contact-form cap-widget")) void loadCaptcha();
26
+
18
27
  for (const form of document.querySelectorAll<HTMLFormElement>("form.contact-form")) {
19
28
  form.addEventListener("submit", async (event) => {
20
29
  event.preventDefault();
@@ -29,8 +38,15 @@
29
38
  body: JSON.stringify(Object.fromEntries(new FormData(form))),
30
39
  });
31
40
  if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
32
- if (!response.ok) throw new Error("The message could not be sent. Check the fields and try again.");
41
+ if (!response.ok) {
42
+ const errors = await response.json().catch(() => ({}));
43
+ // A token is valid once, so the widget verifies again.
44
+ resetCaptcha(form);
45
+ if (errors.captcha) throw new Error("Verify that you are human, then send the message again.");
46
+ throw new Error("The message could not be sent. Check the fields and try again.");
47
+ }
33
48
  form.reset();
49
+ resetCaptcha(form);
34
50
  status.textContent = "Thank you, your message was sent.";
35
51
  } catch (error) {
36
52
  status.textContent = error instanceof Error ? error.message : String(error);
@@ -1,4 +1,5 @@
1
1
  import { defineMiddleware } from "astro:middleware";
2
+ import { reportError } from "@django-stack/utils";
2
3
  import { getContent, PREVIEW_COOKIE } from "./lib/api";
3
4
  import { getCached, store } from "./lib/cache";
4
5
 
@@ -38,7 +39,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
38
39
  if (hit) return hit;
39
40
  }
40
41
 
41
- let response = await next();
42
+ let response: Response;
43
+ try {
44
+ response = await next();
45
+ } catch (error) {
46
+ // Sent to Sentry with ERRORS=sentry; Astro logs it and renders the error page.
47
+ await reportError(error, { request });
48
+ throw error;
49
+ }
42
50
 
43
51
  // Redirects that editors manage in the admin, for paths that do not exist.
44
52
  if (response.status === 404 && request.method === "GET") {
@@ -12,7 +12,7 @@ src/app/ routes: /, /[...path], /blog, /blog/[slug], /category/[
12
12
  src/app/layout.tsx the page shell: menus, footer, preview banner
13
13
  src/components/ the post list, the menu links and the contact form
14
14
  src/lib/api.ts getContent(): content helpers with the read-only key, drafts in preview; pageMetadata()
15
- src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
15
+ src/lib/headless.ts generated API types; do not edit, run `{{pm_run}} generate:types`
16
16
  src/proxy.ts Content-Security-Policy with a nonce, security headers, the dev proxy to Django
17
17
  next.config.ts standalone output, the in-memory data cache
18
18
  ```
@@ -55,5 +55,5 @@ Run them in the `frontend` container (`docker compose exec frontend <command>`):
55
55
 
56
56
  | Task | Command |
57
57
  |------|---------|
58
- | Type-check | `npm run check` |
59
- | Test | `npm test` |
58
+ | Type-check | `{{pm_run}} check` |
59
+ | Test | `{{pm_run}} test` |
@@ -7,10 +7,13 @@ WORKDIR /app
7
7
  ENV NEXT_TELEMETRY_DISABLED=1
8
8
 
9
9
  FROM base AS deps
10
- COPY package.json package-lock.json* ./
10
+ {{pm_setup}}
11
+ COPY {{pm_manifests}} ./
11
12
  COPY vendor/ vendor/
12
- RUN --mount=type=cache,target=/root/.npm \
13
- if [ -f package-lock.json ]; then npm ci; else npm install; fi
13
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
14
+ RUN {{pm_install}}
15
+ # The package manager only installs: npm, which comes with Node.js, runs
16
+ # the scripts of package.json below.
14
17
 
15
18
  FROM deps AS dev
16
19
  EXPOSE 3000
@@ -13,9 +13,9 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@django-headless/client": "^1.1.1",
16
- "@django-stack/next": "^0.8.1",
17
- "@django-stack/react": "^0.8.1",
18
- "@django-stack/utils": "^0.8.1",
16
+ "@django-stack/next": "^0.10.0",
17
+ "@django-stack/react": "^0.10.0",
18
+ "@django-stack/utils": "^0.10.0",
19
19
  "next": "^16.4.4",
20
20
  "react": "^19.3.1",
21
21
  "react-dom": "^19.3.1",
@@ -0,0 +1,11 @@
1
+ # pnpm's settings for the frontend (pnpm 11 and later).
2
+
3
+ # Resolve only versions published at least a day ago, so a compromised
4
+ # release is usually caught before it reaches you. DjangoStack's own
5
+ # packages are exempt, so a project made on a release day installs.
6
+ minimumReleaseAge: 1440
7
+ minimumReleaseAgeExclude:
8
+ - "@django-stack/*"
9
+
10
+ # Which dependencies may run install scripts; pnpm refuses others.
11
+ allowBuilds: {}
@@ -1,4 +1,5 @@
1
1
  import { Blocks } from "@django-stack/react";
2
+ import { captchaEnabled } from "@django-stack/utils";
2
3
  import type { Metadata } from "next";
3
4
  import { ContactForm } from "@/components/ContactForm";
4
5
  import { getContent, notFoundOrRedirect, pageMetadata } from "@/lib/api";
@@ -22,7 +23,7 @@ export default async function ContentPage({ params }: Props) {
22
23
  <article className="narrow">
23
24
  <h1>{page.title}</h1>
24
25
  <Blocks blocks={page.body} />
25
- {page.path === "contact" ? <ContactForm /> : null}
26
+ {page.path === "contact" ? <ContactForm captcha={captchaEnabled(process.env.CAPTCHA)} /> : null}
26
27
  </article>
27
28
  );
28
29
  }
@@ -3,11 +3,14 @@
3
3
  /**
4
4
  * The contact form. It posts to Django's public create route for form
5
5
  * submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
6
- * limited per IP address. Submissions appear in the admin.
6
+ * limited per IP address and, unless CAPTCHA=off, needs the token of the
7
+ * captcha widget. Submissions appear in the admin.
7
8
  */
9
+ import { Captcha } from "@django-stack/react";
10
+ import { resetCaptcha } from "@django-stack/utils";
8
11
  import { type FormEvent, useState } from "react";
9
12
 
10
- export function ContactForm() {
13
+ export function ContactForm({ captcha = true }: { captcha?: boolean }) {
11
14
  const [status, setStatus] = useState("");
12
15
  const [sending, setSending] = useState(false);
13
16
 
@@ -23,8 +26,15 @@ export function ContactForm() {
23
26
  body: JSON.stringify(Object.fromEntries(new FormData(form))),
24
27
  });
25
28
  if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
26
- if (!response.ok) throw new Error("The message could not be sent. Check the fields and try again.");
29
+ if (!response.ok) {
30
+ const errors = await response.json().catch(() => ({}));
31
+ // A token is valid once, so the widget verifies again.
32
+ resetCaptcha(form);
33
+ if (errors.captcha) throw new Error("Verify that you are human, then send the message again.");
34
+ throw new Error("The message could not be sent. Check the fields and try again.");
35
+ }
27
36
  form.reset();
37
+ resetCaptcha(form);
28
38
  setStatus("Thank you, your message was sent.");
29
39
  } catch (error) {
30
40
  setStatus(error instanceof Error ? error.message : String(error));
@@ -44,6 +54,7 @@ export function ContactForm() {
44
54
  <label>
45
55
  Message <textarea name="message" required maxLength={5000} rows={6} />
46
56
  </label>
57
+ {captcha ? <Captcha /> : null}
47
58
  <button className="button" type="submit" disabled={sending}>
48
59
  Send
49
60
  </button>
@@ -0,0 +1,12 @@
1
+ import { reportError } from "@django-stack/utils";
2
+ import type { Instrumentation } from "next";
3
+
4
+ /** Server errors go to Sentry with ERRORS=sentry; Next.js logs them as well. */
5
+ export const onRequestError: Instrumentation.onRequestError = async (error, request) => {
6
+ const id = request.headers["x-request-id"];
7
+ await reportError(error, {
8
+ method: request.method,
9
+ path: request.path,
10
+ requestId: Array.isArray(id) ? id[0] : id,
11
+ });
12
+ };
@@ -1,4 +1,5 @@
1
1
  import { contentSecurityPolicy, mediaOrigins, PREVIEW_COOKIE, SECURITY_HEADERS } from "@django-stack/next";
2
+ import { captchaEnabled } from "@django-stack/utils";
2
3
  import { type NextRequest, NextResponse } from "next/server";
3
4
 
4
5
  const dev = process.env.NODE_ENV === "development";
@@ -11,6 +12,8 @@ const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
11
12
  const TRACKER_PATHS = /^\/_a\/(script\.js|api\/send)$/;
12
13
  const umami = process.env.UMAMI_URL || "http://umami:3000";
13
14
  const media = mediaOrigins();
15
+ // The captcha widget of the contact form, unless CAPTCHA=off.
16
+ const captcha = captchaEnabled(process.env.CAPTCHA);
14
17
 
15
18
  export function proxy(request: NextRequest) {
16
19
  const { pathname, search } = request.nextUrl;
@@ -23,7 +26,7 @@ export function proxy(request: NextRequest) {
23
26
 
24
27
  // A fresh nonce for every page: Next adds it to the scripts it renders.
25
28
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
26
- const policy = contentSecurityPolicy({ nonce, mediaOrigins: media, dev });
29
+ const policy = contentSecurityPolicy({ nonce, mediaOrigins: media, dev, captcha });
27
30
  const headers = new Headers(request.headers);
28
31
  headers.set("Content-Security-Policy", policy);
29
32
 
@@ -13,7 +13,7 @@ src/pages/ routes: /, /[...path], /blog, /blog/[slug], /catego
13
13
  src/layouts/default.vue the page shell: menus, footer, preview banner
14
14
  src/components/ the post list and the contact form
15
15
  src/composables/ useContent() loads from server/routes/_content and follows redirects; usePageSeo() sets the SEO tags
16
- src/lib/headless.ts generated API types; do not edit, run `npm run generate:types`
16
+ src/lib/headless.ts generated API types; do not edit, run `{{pm_run}} generate:types`
17
17
  server/routes/_content/ the content the pages load, cached in memory
18
18
  server/routes/ sitemap.xml, rss.xml, robots.txt, /preview, /revalidate
19
19
  server/utils/content.ts getContent(): content helpers with the read-only key, drafts in preview
@@ -60,5 +60,5 @@ Run them in the `frontend` container (`docker compose exec frontend <command>`):
60
60
 
61
61
  | Task | Command |
62
62
  |------|---------|
63
- | Type-check | `npm run check` |
64
- | Test | `npm test` |
63
+ | Type-check | `{{pm_run}} check` |
64
+ | Test | `{{pm_run}} test` |
@@ -7,10 +7,13 @@ WORKDIR /app
7
7
  ENV NUXT_TELEMETRY_DISABLED=1
8
8
 
9
9
  FROM base AS deps
10
- COPY package.json package-lock.json* ./
10
+ {{pm_setup}}
11
+ COPY {{pm_manifests}} ./
11
12
  COPY vendor/ vendor/
12
- RUN --mount=type=cache,target=/root/.npm \
13
- if [ -f package-lock.json ]; then npm ci --ignore-scripts; else npm install --ignore-scripts; fi
13
+ # No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
14
+ RUN {{pm_install_no_scripts}}
15
+ # The package manager only installs: npm, which comes with Node.js, runs
16
+ # the scripts of package.json below.
14
17
 
15
18
  FROM deps AS dev
16
19
  EXPOSE 3000
@@ -1,3 +1,5 @@
1
+ import { CAPTCHA_CSP } from "@django-stack/utils";
2
+
1
3
  // The Django backend: the frontend container reaches it on the Compose network.
2
4
  const backend = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:8000";
3
5
  // Umami, for page analytics: only its tracker and page views, under /_a/.
@@ -42,7 +44,8 @@ export default defineNuxtConfig({
42
44
  // Security headers, with a Content-Security-Policy on every page in
43
45
  // production: scripts and styles only from this site, with a nonce for
44
46
  // the ones Nuxt inlines. server/plugins/media-origins.ts adds the origin
45
- // of the media files.
47
+ // of the media files. CAPTCHA_CSP allows the captcha widget of the contact
48
+ // form: its solver, its style and its workers.
46
49
  security: {
47
50
  nonce: true,
48
51
  rateLimiter: false,
@@ -52,8 +55,9 @@ export default defineNuxtConfig({
52
55
  process.env.NODE_ENV === "production"
53
56
  ? {
54
57
  "default-src": ["'self'"],
55
- "script-src": ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'"],
56
- "style-src": ["'self'", "'nonce-{{nonce}}'"],
58
+ "script-src": ["'self'", "'nonce-{{nonce}}'", "'strict-dynamic'", ...CAPTCHA_CSP["script-src"]],
59
+ "style-src": ["'self'", "'nonce-{{nonce}}'", ...CAPTCHA_CSP["style-src"]],
60
+ "worker-src": [...CAPTCHA_CSP["worker-src"]],
57
61
  // Inline style attributes, such as the focal point of an image.
58
62
  "style-src-attr": ["'unsafe-inline'"],
59
63
  "img-src": ["'self'", "data:"],
@@ -15,8 +15,8 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@django-headless/client": "^1.1.1",
18
- "@django-stack/utils": "^0.8.1",
19
- "@django-stack/vue": "^0.8.1",
18
+ "@django-stack/utils": "^0.10.0",
19
+ "@django-stack/vue": "^0.10.0",
20
20
  "nuxt": "^4.6.3",
21
21
  "nuxt-security": "^2.7.0",
22
22
  "vue": "^3.5.44",
@@ -0,0 +1,13 @@
1
+ # pnpm's settings for the frontend (pnpm 11 and later).
2
+
3
+ # Resolve only versions published at least a day ago, so a compromised
4
+ # release is usually caught before it reaches you. DjangoStack's own
5
+ # packages are exempt, so a project made on a release day installs.
6
+ minimumReleaseAge: 1440
7
+ minimumReleaseAgeExclude:
8
+ - "@django-stack/*"
9
+
10
+ # Which dependencies may run install scripts; pnpm refuses others.
11
+ allowBuilds:
12
+ # Its install script checks its binary.
13
+ esbuild: true