@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.
- package/dist/bin.js +840 -54
- package/dist/index.d.ts +43 -4
- package/dist/index.js +1 -1
- package/dist/{production-CGkI7oNe.js → production-Ct_qoeXk.js} +210 -7
- package/package.json +1 -1
- package/templates/backend/Dockerfile +4 -5
- package/templates/backend/config/settings.py +3 -0
- package/templates/backend/config/studio.py +4 -0
- package/templates/backend/content/apps.py +5 -1
- package/templates/backend/content/models.py +2 -2
- package/templates/backend/content/notifications.py +36 -0
- package/templates/backend/content/tests/test_api.py +32 -2
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/AGENTS.md +8 -8
- package/templates/base/Caddyfile +7 -1
- package/templates/base/README.md +3 -3
- package/templates/base/compose.yaml +37 -2
- package/templates/base/env.example +29 -0
- package/templates/frontend-astro/AGENTS.md +3 -3
- package/templates/frontend-astro/Dockerfile +7 -4
- package/templates/frontend-astro/astro.config.mjs +16 -5
- package/templates/frontend-astro/package.json +1 -1
- package/templates/frontend-astro/pnpm-workspace.yaml +17 -0
- package/templates/frontend-astro/src/components/ContactForm.astro +19 -3
- package/templates/frontend-astro/src/middleware.ts +9 -1
- package/templates/frontend-next/AGENTS.md +3 -3
- package/templates/frontend-next/Dockerfile +6 -3
- package/templates/frontend-next/package.json +3 -3
- package/templates/frontend-next/pnpm-workspace.yaml +11 -0
- package/templates/frontend-next/src/app/[...path]/page.tsx +2 -1
- package/templates/frontend-next/src/components/ContactForm.tsx +14 -3
- package/templates/frontend-next/src/instrumentation.ts +12 -0
- package/templates/frontend-next/src/proxy.ts +4 -1
- package/templates/frontend-nuxt/AGENTS.md +3 -3
- package/templates/frontend-nuxt/Dockerfile +6 -3
- package/templates/frontend-nuxt/nuxt.config.ts +7 -3
- package/templates/frontend-nuxt/package.json +2 -2
- package/templates/frontend-nuxt/pnpm-workspace.yaml +13 -0
- package/templates/frontend-nuxt/server/plugins/errors.ts +14 -0
- package/templates/frontend-nuxt/server/routes/_content/layout.get.ts +4 -0
- package/templates/frontend-nuxt/src/components/ContactForm.vue +15 -2
- package/templates/frontend-sveltekit/AGENTS.md +3 -3
- package/templates/frontend-sveltekit/Dockerfile +7 -4
- package/templates/frontend-sveltekit/package.json +2 -2
- package/templates/frontend-sveltekit/pnpm-workspace.yaml +11 -0
- package/templates/frontend-sveltekit/src/hooks.server.ts +13 -1
- package/templates/frontend-sveltekit/src/lib/components/ContactForm.svelte +15 -2
- package/templates/frontend-sveltekit/src/routes/+layout.server.ts +3 -0
- package/templates/frontend-sveltekit/src/routes/[...path]/+page.svelte +1 -1
- package/templates/frontend-sveltekit/vite.config.ts +10 -3
package/templates/base/Caddyfile
CHANGED
|
@@ -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
|
|
68
|
+
format json
|
|
63
69
|
}
|
|
64
70
|
}
|
package/templates/base/README.md
CHANGED
|
@@ -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 | `
|
|
42
|
-
| Run the tests | `
|
|
43
|
-
| Check the setup | `
|
|
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 `
|
|
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 | `
|
|
43
|
-
| 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
|
-
|
|
10
|
+
{{pm_setup}}
|
|
11
|
+
COPY {{pm_manifests}} ./
|
|
11
12
|
COPY vendor/ vendor/
|
|
12
|
-
|
|
13
|
-
|
|
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 &&
|
|
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: [
|
|
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
|
-
|
|
25
|
-
|
|
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: {
|
|
@@ -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
|
-
*
|
|
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)
|
|
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
|
|
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 `
|
|
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 | `
|
|
59
|
-
| 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
|
-
|
|
10
|
+
{{pm_setup}}
|
|
11
|
+
COPY {{pm_manifests}} ./
|
|
11
12
|
COPY vendor/ vendor/
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
17
|
-
"@django-stack/react": "^0.
|
|
18
|
-
"@django-stack/utils": "^0.
|
|
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
|
|
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)
|
|
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 `
|
|
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 | `
|
|
64
|
-
| 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
|
-
|
|
10
|
+
{{pm_setup}}
|
|
11
|
+
COPY {{pm_manifests}} ./
|
|
11
12
|
COPY vendor/ vendor/
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
19
|
-
"@django-stack/vue": "^0.
|
|
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
|