@django-stack/cli 0.9.0 → 0.11.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 +65 -20
- package/dist/index.d.ts +24 -9
- package/dist/index.js +1 -1
- package/dist/{production-M2SsuuBb.js → production-WThC1aS2.js} +267 -30
- package/package.json +1 -1
- package/templates/backend/Dockerfile +7 -2
- package/templates/backend/config/settings.py +13 -0
- package/templates/backend/config/urls.py +11 -0
- package/templates/backend/content/admin.py +14 -5
- package/templates/backend/content/models.py +2 -2
- package/templates/backend/content/tests/test_api.py +15 -3
- package/templates/backend/pyproject.toml +1 -1
- package/templates/base/.github/workflows/deploy.yml +10 -1
- package/templates/base/AGENTS.md +86 -8
- package/templates/base/Caddyfile +24 -0
- package/templates/base/README.md +74 -6
- package/templates/base/SECURITY.md +2 -0
- package/templates/base/compose.yaml +28 -2
- package/templates/base/env.example +37 -0
- package/templates/frontend-astro/AGENTS.md +3 -3
- package/templates/frontend-astro/Dockerfile +6 -3
- 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 +5 -2
- 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 +5 -2
- 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 +6 -3
- 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/frontend-templates/template.json +5 -0
- package/templates/frontend-templates/website/__init__.py +0 -0
- package/templates/frontend-templates/website/apps.py +6 -0
- package/templates/frontend-templates/website/context_processors.py +16 -0
- package/templates/frontend-templates/website/forms.py +20 -0
- package/templates/frontend-templates/website/static/website/favicon.svg +1 -0
- package/templates/frontend-templates/website/static/website/global.css +232 -0
- package/templates/frontend-templates/website/templates/website/404.html +10 -0
- package/templates/frontend-templates/website/templates/website/base.html +44 -0
- package/templates/frontend-templates/website/templates/website/blocks/call_to_action.html +5 -0
- package/templates/frontend-templates/website/templates/website/blocks/image.html +4 -0
- package/templates/frontend-templates/website/templates/website/blocks/quote.html +4 -0
- package/templates/frontend-templates/website/templates/website/blocks/text.html +2 -0
- package/templates/frontend-templates/website/templates/website/home.html +8 -0
- package/templates/frontend-templates/website/templates/website/includes/contact_form.html +21 -0
- package/templates/frontend-templates/website/templates/website/includes/post_card.html +9 -0
- package/templates/frontend-templates/website/templates/website/includes/post_list.html +11 -0
- package/templates/frontend-templates/website/templates/website/page.html +10 -0
- package/templates/frontend-templates/website/templates/website/post.html +20 -0
- package/templates/frontend-templates/website/templates/website/post_list.html +11 -0
- package/templates/frontend-templates/website/templates/website/robots.txt +5 -0
- package/templates/frontend-templates/website/templates/website/rss.xml +16 -0
- package/templates/frontend-templates/website/templates/website/sitemap.xml +8 -0
- package/templates/frontend-templates/website/tests/__init__.py +0 -0
- package/templates/frontend-templates/website/tests/test_site.py +102 -0
- package/templates/frontend-templates/website/urls.py +17 -0
- package/templates/frontend-templates/website/views.py +176 -0
|
@@ -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
|
# No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
|
|
13
|
-
RUN
|
|
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.11.0",
|
|
17
|
+
"@django-stack/react": "^0.11.0",
|
|
18
|
+
"@django-stack/utils": "^0.11.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
|
# No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
|
|
13
|
-
RUN
|
|
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.11.0",
|
|
19
|
+
"@django-stack/vue": "^0.11.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
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { reportError } from "@django-stack/utils";
|
|
2
|
+
|
|
3
|
+
/** Server errors go to Sentry with ERRORS=sentry; Nuxt logs them as well. */
|
|
4
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
5
|
+
nitroApp.hooks.hook("error", async (error, { event }) => {
|
|
6
|
+
const status = (error as { statusCode?: number }).statusCode ?? 500;
|
|
7
|
+
if (status < 500) return;
|
|
8
|
+
await reportError(error, {
|
|
9
|
+
method: event?.method,
|
|
10
|
+
path: event?.path,
|
|
11
|
+
requestId: event?.headers.get("x-request-id"),
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { captchaEnabled } from "@django-stack/utils";
|
|
2
|
+
|
|
1
3
|
/** What every page shows: the site settings and the menus. */
|
|
2
4
|
export default defineEventHandler(async (event) => {
|
|
3
5
|
const data = await cachedContent(event, async () => {
|
|
@@ -14,5 +16,7 @@ export default defineEventHandler(async (event) => {
|
|
|
14
16
|
preview: previewToken(event) !== null,
|
|
15
17
|
// Page analytics: Umami's tracker, served from this site at /_a/.
|
|
16
18
|
analytics: process.env.UMAMI_WEBSITE_ID || null,
|
|
19
|
+
// The captcha widget of the contact form, unless CAPTCHA=off.
|
|
20
|
+
captcha: captchaEnabled(process.env.CAPTCHA),
|
|
17
21
|
};
|
|
18
22
|
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
The contact form. It posts to Django's public create route for form
|
|
3
3
|
submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
|
|
4
|
-
limited per IP address
|
|
4
|
+
limited per IP address and, unless CAPTCHA=off, needs the token of the
|
|
5
|
+
captcha widget. Submissions appear in the admin.
|
|
5
6
|
-->
|
|
6
7
|
<script setup lang="ts">
|
|
8
|
+
import { resetCaptcha } from "@django-stack/utils";
|
|
9
|
+
import { Captcha } from "@django-stack/vue";
|
|
10
|
+
|
|
11
|
+
const { data: layout } = useLayoutContent();
|
|
7
12
|
const status = ref("");
|
|
8
13
|
const sending = ref(false);
|
|
9
14
|
|
|
@@ -18,8 +23,15 @@ async function submit(event: Event) {
|
|
|
18
23
|
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
|
19
24
|
});
|
|
20
25
|
if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
|
|
21
|
-
if (!response.ok)
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const errors = await response.json().catch(() => ({}));
|
|
28
|
+
// A token is valid once, so the widget verifies again.
|
|
29
|
+
resetCaptcha(form);
|
|
30
|
+
if (errors.captcha) throw new Error("Verify that you are human, then send the message again.");
|
|
31
|
+
throw new Error("The message could not be sent. Check the fields and try again.");
|
|
32
|
+
}
|
|
22
33
|
form.reset();
|
|
34
|
+
resetCaptcha(form);
|
|
23
35
|
status.value = "Thank you, your message was sent.";
|
|
24
36
|
} catch (error) {
|
|
25
37
|
status.value = error instanceof Error ? error.message : String(error);
|
|
@@ -34,6 +46,7 @@ async function submit(event: Event) {
|
|
|
34
46
|
<label>Name <input name="name" required maxlength="200" autocomplete="name" /></label>
|
|
35
47
|
<label>Email <input name="email" type="email" required autocomplete="email" /></label>
|
|
36
48
|
<label>Message <textarea name="message" required maxlength="5000" rows="6"></textarea></label>
|
|
49
|
+
<Captcha v-if="layout?.captcha !== false" />
|
|
37
50
|
<button class="button" type="submit" :disabled="sending">Send</button>
|
|
38
51
|
<p class="status" role="status" aria-live="polite">{{ status }}</p>
|
|
39
52
|
</form>
|
|
@@ -13,7 +13,7 @@ src/routes/+layout.svelte the page shell: SEO tags, menus, footer, preview ba
|
|
|
13
13
|
src/lib/components/ the post list and the contact form
|
|
14
14
|
src/lib/server/api.ts getContent(): content helpers with the read-only key, drafts in preview
|
|
15
15
|
src/lib/server/cache.ts the in-memory page cache that /revalidate empties
|
|
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
|
src/hooks.server.ts page cache, editor-managed redirects, security headers
|
|
18
18
|
vite.config.ts SvelteKit's options (Node adapter, Content-Security-Policy) and the dev proxy to Django
|
|
19
19
|
```
|
|
@@ -49,5 +49,5 @@ Run them in the `frontend` container (`docker compose exec frontend <command>`):
|
|
|
49
49
|
|
|
50
50
|
| Task | Command |
|
|
51
51
|
|------|---------|
|
|
52
|
-
| Type-check | `
|
|
53
|
-
| Test | `
|
|
52
|
+
| Type-check | `{{pm_run}} check` |
|
|
53
|
+
| Test | `{{pm_run}} test` |
|
|
@@ -6,10 +6,13 @@ FROM node:22-alpine AS base
|
|
|
6
6
|
WORKDIR /app
|
|
7
7
|
|
|
8
8
|
FROM base AS deps
|
|
9
|
-
|
|
9
|
+
{{pm_setup}}
|
|
10
|
+
COPY {{pm_manifests}} ./
|
|
10
11
|
COPY vendor/ vendor/
|
|
11
12
|
# No BuildKit cache mounts: Railway rejects them, and the layer is cached anyway.
|
|
12
|
-
RUN
|
|
13
|
+
RUN {{pm_install}}
|
|
14
|
+
# The package manager only installs: npm, which comes with Node.js, runs
|
|
15
|
+
# the scripts of package.json below.
|
|
13
16
|
|
|
14
17
|
FROM deps AS dev
|
|
15
18
|
EXPOSE 3000
|
|
@@ -17,7 +20,7 @@ CMD ["npm", "run", "dev"]
|
|
|
17
20
|
|
|
18
21
|
FROM deps AS build
|
|
19
22
|
COPY . .
|
|
20
|
-
RUN npm run build &&
|
|
23
|
+
RUN npm run build && {{pm_prune}}
|
|
21
24
|
|
|
22
25
|
FROM base AS prod
|
|
23
26
|
# The proxy in front sets X-Forwarded-Proto, so links and cookies use https.
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@django-headless/client": "^1.1.1",
|
|
22
|
-
"@django-stack/svelte": "^0.
|
|
23
|
-
"@django-stack/utils": "^0.
|
|
22
|
+
"@django-stack/svelte": "^0.11.0",
|
|
23
|
+
"@django-stack/utils": "^0.11.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@sveltejs/adapter-node": "^6.0.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,5 +1,6 @@
|
|
|
1
|
+
import { reportError } from "@django-stack/utils";
|
|
1
2
|
import { redirect } from "@sveltejs/kit";
|
|
2
|
-
import type { Handle } from "@sveltejs/kit/hooks";
|
|
3
|
+
import type { Handle, HandleServerError } from "@sveltejs/kit/hooks";
|
|
3
4
|
import { getContent, PREVIEW_COOKIE } from "#lib/server/api.ts";
|
|
4
5
|
import { getCached, store } from "#lib/server/cache.ts";
|
|
5
6
|
import { dev } from "$app/env";
|
|
@@ -68,3 +69,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|
|
68
69
|
}
|
|
69
70
|
return response;
|
|
70
71
|
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Errors of the site's code are logged, as SvelteKit does by default, and
|
|
75
|
+
* go to Sentry with ERRORS=sentry. Errors such as a 404 are not.
|
|
76
|
+
*/
|
|
77
|
+
export const handleError: HandleServerError = async ({ kind, error, issues, event }) => {
|
|
78
|
+
if (kind === "validation") console.error("Remote function schema validation failed:", issues);
|
|
79
|
+
if (kind !== "unknown") return;
|
|
80
|
+
console.error(error instanceof Error ? error.stack : String(error));
|
|
81
|
+
await reportError(error, { request: event.request });
|
|
82
|
+
};
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
The contact form. It posts to Django's public create route for form
|
|
3
3
|
submissions (PUBLIC_CREATE in backend/config/settings.py), which is rate
|
|
4
|
-
limited per IP address
|
|
4
|
+
limited per IP address and, unless CAPTCHA=off, needs the token of the
|
|
5
|
+
captcha widget. Submissions appear in the admin.
|
|
5
6
|
-->
|
|
6
7
|
<script lang="ts">
|
|
8
|
+
import { Captcha } from "@django-stack/svelte";
|
|
9
|
+
import { resetCaptcha } from "@django-stack/utils";
|
|
10
|
+
|
|
11
|
+
let { captcha = true }: { captcha?: boolean } = $props();
|
|
7
12
|
let status = $state("");
|
|
8
13
|
let sending = $state(false);
|
|
9
14
|
|
|
@@ -19,8 +24,15 @@
|
|
|
19
24
|
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
|
20
25
|
});
|
|
21
26
|
if (response.status === 429) throw new Error("Too many messages; try again in a minute.");
|
|
22
|
-
if (!response.ok)
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
const errors = await response.json().catch(() => ({}));
|
|
29
|
+
// A token is valid once, so the widget verifies again.
|
|
30
|
+
resetCaptcha(form);
|
|
31
|
+
if (errors.captcha) throw new Error("Verify that you are human, then send the message again.");
|
|
32
|
+
throw new Error("The message could not be sent. Check the fields and try again.");
|
|
33
|
+
}
|
|
23
34
|
form.reset();
|
|
35
|
+
resetCaptcha(form);
|
|
24
36
|
status = "Thank you, your message was sent.";
|
|
25
37
|
} catch (error) {
|
|
26
38
|
status = error instanceof Error ? error.message : String(error);
|
|
@@ -34,6 +46,7 @@
|
|
|
34
46
|
<label>Name <input name="name" required maxlength="200" autocomplete="name" /></label>
|
|
35
47
|
<label>Email <input name="email" type="email" required autocomplete="email" /></label>
|
|
36
48
|
<label>Message <textarea name="message" required maxlength="5000" rows="6"></textarea></label>
|
|
49
|
+
{#if captcha}<Captcha />{/if}
|
|
37
50
|
<button class="button" type="submit" disabled={sending}>Send</button>
|
|
38
51
|
<p class="status" role="status" aria-live="polite">{status}</p>
|
|
39
52
|
</form>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captchaEnabled } from "@django-stack/utils";
|
|
1
2
|
import { getContent, PREVIEW_COOKIE, SITE_URL } from "#lib/server/api.ts";
|
|
2
3
|
import type { LayoutServerLoad } from "./$types";
|
|
3
4
|
|
|
@@ -16,5 +17,7 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
|
|
|
16
17
|
siteUrl: SITE_URL,
|
|
17
18
|
// Page analytics: Umami's tracker, served from this site at /_a/.
|
|
18
19
|
analytics: process.env.UMAMI_WEBSITE_ID || null,
|
|
20
|
+
// The captcha widget of the contact form, unless CAPTCHA=off.
|
|
21
|
+
captcha: captchaEnabled(process.env.CAPTCHA),
|
|
19
22
|
};
|
|
20
23
|
};
|