Compare commits

..

2 Commits

Author SHA1 Message Date
3310ae3926 #305: a full pond archive before deletion and before purge
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m19s
CI / Build container images (pull_request) Successful in 1m23s
CI / Auth e2e pack (pull_request) Successful in 8m55s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CD / Build and push images (push) Successful in 14s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m28s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m52s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m6s
CI / Import/export fidelity gate (push) Successful in 58s
Deleting a pond already had a strict prompt — typing the pond name, stricter
than a confirm dialog. That was never the gap. The gap is that the person who
deletes it loses access the moment they do: the pond leaves their view, only a
Site Admin can bring it back, and the export is no longer reachable for them.
So the archive is offered INSIDE the deletion flow, before the button.

What it contains, and why it is not the existing export:

- Every page the requester may read, as Markdown, as before.
- **Every attachment of the pond**, not only the embedded ones. An
  attachment nobody put on a page would otherwise vanish unnoticed — which
  is the whole reason this issue exists.
- `manifest.json`: pond settings (EFFECTIVE, defaults filled in — a
  preservation format must not require its reader to know Dorfteich's
  defaults), labels, the page hierarchy and sort keys, comments, and
  attachment metadata including the #199 hash so a reader can verify bytes.
  It extends the #210 manifest rather than adding a second descriptor, and
  carries an explicit `formatVersion`.
- `README.txt`, because the manifest is for machines: whoever unpacks a
  folder of Markdown a year from now must not believe they hold a one-click
  restore.

Decisions worth naming:

- **"Complete" describes the RESULT, not the route.** A pond admin who may
  read every page gets `complete: true`; only an archive that actually
  leaves pages out is incomplete. The Site-Admin route skips the read filter
  (an archive taken before an irreversible purge must not depend on which
  ponds the operator happens to be a member of) — those are two different
  questions and the first version of this conflated them.
- **The omission is named before the download**, with its number, in the UI
  and in the manifest. An archive silently missing content is worse than no
  archive, because it ends the search.
- **Not downloading stays allowed.** A pond of test pages should not require
  one, and the server cannot tell whether a file arrived anyway — so the
  finality is stated in text instead of enforced.
- **A plain link, not fetch-into-a-blob.** The api streams the ZIP; buffering
  a whole pond in the tab to draw a progress bar would trade memory for
  cosmetics. The browser reports progress and completion; what it cannot say
  — that the archive is being BUILT — is announced in a live region.
- Read trail unchanged in kind (ADR 0023): one `export` event per classified
  page before any classified byte enters the stream. Attachments never travel
  without their page, so the same events cover them.
- New audit action `pond.archived` (catalogue v1.7) with page and attachment
  counts, omitted pages, and completeness.

Format documented in `docs/architecture/pond-archive-format.md`, including
what is deliberately NOT in it (history, permissions, trash).

Verified by hand, not only asserted: a real pond's archive downloaded and
unpacked — README, manifest, three page files, the media file; the manifest's
effective settings, per-page classification, the VS-NfD frontmatter and
marking preserved in the classified page's Markdown, and the attachment's
sha256 present. Plus six api tests (including that an unembedded attachment
travels and that a Site Admin gets a complete archive without membership) and
the a11y pack 11/11 in both schemes, which now also scans the pond settings
screen.

Not done, because there is nothing to attach it to: the Site Admin's purge
dialog (#193) exists only as an api endpoint — there is no pond-trash UI in
the web app. The api half is here and tested, so it becomes a link when that
screen is built.
2026-08-01 20:24:35 +02:00
8752cf0c5a #179: negotiate the SPA shell's lang attribute in nginx
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m13s
CI / Build container images (pull_request) Successful in 1m21s
CI / Auth e2e pack (pull_request) Successful in 9m25s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 21s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m40s
CD / Promote to Int (push) Successful in 15s
CI / Lint, typecheck, test (push) Successful in 7m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m45s
CI / Import/export fidelity gate (push) Successful in 55s
`apps/web/index.html` carries a hard `lang="en"`. The app corrects it at
runtime (#163), but nginx answers every SPA route with that same file, so a
crawler or a no-JS visit — `/public/...` on prod is exactly that — saw `en`
for German content, permanently. WCAG 3.1.1 is about the delivered document,
not the one JavaScript later fixes.

nginx-only, no backend involved: a `map` on `Accept-Language` and a
`sub_filter` in the index.html path. Only the FIRST tag decides, which is
what "the browser's preferred language" means and mirrors #163 — `de-CH`
counts as German, `en-US,de` does not.

`Vary: Accept-Language` is new. The response now genuinely depends on a
request header, and without it a shared cache could hand one language's copy
to the other. Everything else in the location is untouched: same CSP, same
`Cache-Control: no-cache`, same `nosniff`.

The known limit is documented in the config rather than worked around: nginx
cannot read `instance.defaultLocale`, so an unlisted or absent
Accept-Language yields `en` even on a German instance. For public content
that is not the authoritative rendering anyway — the api's server shell
(`/api/v1/public/...`) already renders those with the instance locale.

Verified against a real nginx 1.27 (the image the stage runs) with the
config mounted as-is: `de-DE,de;q=0.9,en;q=0.8` and `de` yield `lang="de"`;
`en-US,en;q=0.9`, `en-US,en;q=0.9,de;q=0.8`, `fr-FR,fr` and a request with no
header yield `lang="en"`; an SPA route (`/public/teich/seite`) negotiates the
same way; the gzipped response is rewritten too, and a JavaScript asset comes
through byte-identical.
2026-08-01 20:01:53 +02:00

View File

@ -1,3 +1,23 @@
# The SPA shell's `lang` attribute, negotiated from the request (issue #179,
# WCAG 3.1.1). `apps/web/index.html` is a static file with a hard `lang="en"`;
# the app corrects it at runtime (#163), but a crawler or a no-JS visit of an
# SPA route which nginx answers with index.html would see `en` forever,
# even for German content.
#
# Only the FIRST tag of Accept-Language decides, which is what "the browser's
# preferred language" means and mirrors #163's semantics. `de-CH` counts as
# German; `en-US,de` does not, because that visitor asked for English first.
#
# Known limit, documented rather than worked around: nginx does not know
# `instance.defaultLocale` from the database, so a visitor with no (or an
# unlisted) Accept-Language gets `en` even on a German instance. For PUBLIC
# content that is not the authoritative rendering anyway the api's server
# shell (`/api/v1/public/...`) renders those with the instance locale.
map $http_accept_language $spa_lang {
default en;
~*^de de;
}
# SPA serving: static assets with long-lived caching, everything else # SPA serving: static assets with long-lived caching, everything else
# falls back to index.html (client-side routing). # falls back to index.html (client-side routing).
server { server {
@ -34,6 +54,17 @@ server {
# zero-third-party-request guarantee (security.md) is unaffected. # zero-third-party-request guarantee (security.md) is unaffected.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
# The shell's language (issue #179). Only the html document is
# rewritten, and only its first match `<html lang="en">` is the
# first and only occurrence in index.html. Everything else this
# location serves passes through untouched.
sub_filter_types text/html;
sub_filter_once on;
sub_filter 'lang="en"' 'lang="$spa_lang"';
# The response now depends on a request header, so shared caches must
# not serve one language's copy to the other. This location is
# `no-cache` anyway; the header states the dependency correctly.
add_header Vary "Accept-Language" always;
try_files $uri /index.html; try_files $uri /index.html;
} }
} }