Compare commits

...
2 Commits
Author SHA1 Message Date
Felix Klement 2b7ac42d6b asdf 2026-10-05 21:32:07 +02:00
Felix Klement cd45c5d98f wasdf 2026-10-05 21:04:51 +02:00
26 changed files with 1064 additions and 355 deletions

No files matched your search

+5
View File
@@ -9,3 +9,8 @@ CONTACT_TO=office@ciphercell.de
# start makes its own. Generate with: openssl rand -hex 32 # start makes its own. Generate with: openssl rand -hex 32
ALTCHA_HMAC_SECRET= ALTCHA_HMAC_SECRET=
ALTCHA_HMAC_KEY_SECRET= ALTCHA_HMAC_KEY_SECRET=
# Signs the alpha sign-up confirmation links (openssl rand -hex 32). Set it, or links sent
# before a restart stop working.
NOTIFY_SECRET=
# Where confirmation links point. Default: https://www.ciphercell.de. Locally, uncomment:
# SITE_URL=http://localhost:4321
+2 -1
View File
@@ -10,12 +10,13 @@ RUN npm ci --no-audit --no-fund
COPY . . COPY . .
RUN npm run build RUN npm run build
# The contact handler's production dependencies (nodemailer only). # The form handler and its production dependencies (nodemailer, altcha-lib).
FROM node:22-alpine AS contact FROM node:22-alpine AS contact
WORKDIR /srv/contact WORKDIR /srv/contact
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund RUN npm ci --omit=dev --no-audit --no-fund
COPY server/contact.mjs ./ COPY server/contact.mjs ./
COPY server/mail ./mail
# Serve dist/ with nginx; POST /contact goes to the handler on 127.0.0.1:3000. # Serve dist/ with nginx; POST /contact goes to the handler on 127.0.0.1:3000.
FROM nginx:1.27-alpine AS runtime FROM nginx:1.27-alpine AS runtime
+16 -10
View File
@@ -44,7 +44,7 @@ Environment; see *Contact form* below.
| `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. | | `src/components/verifier/*` | The CipherVerifier product page, in page order: Hero (line burst), Intro (image band), HowItWorks (five group cards, one schematic each; on wide screens the heading is sticky and the cards stack under it as you scroll), Cards, Facts, Outro. |
| `src/components/verifier/SoonHero.astro`, `ComingSoon.astro` | The coming-soon version of `/cipherverifier`, shown while `verifierComingSoon` in `src/data/flags.ts` is true: hero on a build-time SVG relief of ridge lines in the logo blues, under the floating header, "Coming soon." with the alpha sign-up beside it, a status strip, and the product name set across the full width and cut by the hero's lower edge, release path, spec sheet and the first schematic. Copy in `ui.verifier.soon`. | | `src/components/verifier/SoonHero.astro`, `ComingSoon.astro` | The coming-soon version of `/cipherverifier`, shown while `verifierComingSoon` in `src/data/flags.ts` is true: hero on a build-time SVG relief of ridge lines in the logo blues, under the floating header, "Coming soon." with the alpha sign-up beside it, a status strip, and the product name set across the full width and cut by the hero's lower edge, release path, spec sheet and the first schematic. Copy in `ui.verifier.soon`. |
| `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. | | `src/components/verifier/diagrams.ts` | The five schematics as data: a hand-placed wide layout and an auto-generated tall spine per group. `Schematic.astro` renders either. Labels come from `ui.verifier.how.groups`; the tuple shape is in `types.ts`. Every element carries the `group` of the node it belongs to, which drives the hover focus and the draw-in order. |
| `server/contact.mjs` | The contact form handler: validates, rate-limits and sends by SMTP. Its own `package.json`, so the image only carries nodemailer. | | `server/contact.mjs`, `server/mail/` | The handler for both forms (contact, alpha sign-up): validates, checks the proof of work, rate-limits and sends by SMTP. `server/mail/confirm.mjs` lays out the confirmation email (tables, inline styles, logo as an inline attachment). Its own `package.json`, so the image only carries nodemailer and altcha-lib. |
| `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. | | `scripts/*.mjs` | Review tooling: `shot.mjs` (full page), `scroll-shots.mjs` (viewport by viewport), `tabs-test.mjs` (keyboard test for the sectors tabs), `contact-sheet.mjs`. Need `npx astro preview --port 4322`. |
| `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. | | `src/pages/_*.astro` | Shared page bodies. `index.astro` and `de/index.astro` render `_Home.astro` with a `lang` prop, and so on. |
| `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. | | `src/assets/images/` | Source images. Astro emits AVIF with a WebP fallback and explicit dimensions. |
@@ -59,7 +59,7 @@ Environment; see *Contact form* below.
## Things the host has to provide ## Things the host has to provide
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a - **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a
honeypot field `website` to `POST /contact`. That one path is handled by `server/contact.mjs` honeypot field `website` to `POST /contact`. That path is handled by `server/contact.mjs`
(Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as (Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as
Reply-To. The form sends in place and shows the result in a toast; a post that asks for HTML Reply-To. The form sends in place and shows the result in a toast; a post that asks for HTML
is redirected to `/contact/sent` or `/contact/error` (under `/de` for German). Both pages are is redirected to `/contact/sent` or `/contact/error` (under `/de` for German). Both pages are
@@ -74,7 +74,7 @@ Environment; see *Contact form* below.
and no third party is involved, so there is nothing to consent to. The form therefore needs and no third party is involved, so there is nothing to consent to. The form therefore needs
JavaScript; without it a `<noscript>` line points to the email address. JavaScript; without it a `<noscript>` line points to the email address.
In the container, nginx proxies `/contact` and `/contact/challenge` to the handler on `127.0.0.1:3000`; both are started In the container, nginx proxies `/contact`, `/contact/challenge`, `/notify` and `/notify/verify` to the handler on `127.0.0.1:3000`; both are started
by `docker/entrypoint.sh`, which restarts the handler if it crashes. If the handler is down, by `docker/entrypoint.sh`, which restarts the handler if it crashes. If the handler is down,
visitors are sent to the error page. Without SMTP settings the handler does not start and the visitors are sent to the error page. Without SMTP settings the handler does not start and the
rest of the site is served as usual. rest of the site is served as usual.
@@ -88,18 +88,24 @@ Environment; see *Contact form* below.
| `CONTACT_TO` | `office@ciphercell.de` | Where messages go. | | `CONTACT_TO` | `office@ciphercell.de` | Where messages go. |
| `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). | | `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). |
| `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. | | `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. |
| `NOTIFY_SECRET` | random per start | Signs alpha confirmation links (`openssl rand -hex 32`). Set it, or links sent before a restart stop working. |
| `SITE_URL` | `https://www.ciphercell.de` | Base of the confirmation links. |
| `ALTCHA_HMAC_SECRET`, `ALTCHA_HMAC_KEY_SECRET` | random per start | Signing secrets for the challenge (`openssl rand -hex 32`). Set them so a restart does not void challenges already handed out. | | `ALTCHA_HMAC_SECRET`, `ALTCHA_HMAC_KEY_SECRET` | random per start | Signing secrets for the challenge (`openssl rand -hex 32`). Set them so a restart does not void challenges already handed out. |
In Dokploy, set these under the application's Environment. Locally, put them in `.env` (see In Dokploy, set these under the application's Environment. Locally, put them in `.env` (see
`.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies `/contact` and `.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies the same paths to it.
`/contact/challenge` to it.
- **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier` - **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier`
shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`, shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
`list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is `list=cipherverifier-alpha`, the honeypot `website` and the proof-of-work answer `altcha` to
server-side and not part of this static build. It should store the address with the time of `POST /notify`, handled by `server/contact.mjs` like the contact form. It is double opt-in
consent, confirm it by double opt-in, and redirect back with a thank-you. The privacy page without a database: the handler emails the address a link to `/notify/confirm` carrying a
promises deletion after the alpha mail is sent. Set the flag to false to bring back the full signed token (address, language, time of consent; valid 7 days) and keeps nothing. That page
product page. posts the token to `POST /notify/verify` with a button (mail scanners open links, they do not
press buttons), and the handler sends the confirmed registration, with the consent wording and
both times, to `CONTACT_TO`. That mailbox is the list. The privacy page promises deletion
after the alpha mail is sent or on withdrawal. The result pages `/notify/confirmed` and
`/notify/error` are `noindex` and left out of the sitemap. Set the flag to false to bring back
the full product page.
- **Matomo.** The privacy page links to the existing opt-out on `cipher.uber.space`. The tracking - **Matomo.** The privacy page links to the existing opt-out on `cipher.uber.space`. The tracking
snippet itself is not included; add it in `src/layouts/Base.astro` if analytics should continue. snippet itself is not included; add it in `src/layouts/Base.astro` if analytics should continue.
+2 -2
View File
@@ -5,7 +5,7 @@ import sitemap from '@astrojs/sitemap';
export default defineConfig({ export default defineConfig({
site: 'https://www.ciphercell.de', site: 'https://www.ciphercell.de',
trailingSlash: 'never', trailingSlash: 'never',
integrations: [sitemap({ filter: (page) => !/\/contact\//.test(page), i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })], integrations: [sitemap({ filter: (page) => !/\/(contact|notify)\//.test(page), i18n: { defaultLocale: 'en', locales: { en: 'en-GB', de: 'de-DE' } } })],
i18n: { i18n: {
defaultLocale: 'en', defaultLocale: 'en',
locales: ['en', 'de'], locales: ['en', 'de'],
@@ -13,5 +13,5 @@ export default defineConfig({
}, },
build: { inlineStylesheets: 'auto' }, build: { inlineStylesheets: 'auto' },
// In development the form posts to the contact handler started with `npm run contact`. // In development the form posts to the contact handler started with `npm run contact`.
vite: { server: { proxy: { '^/contact(/challenge)?$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } }, vite: { server: { proxy: { '^/(contact(/challenge)?|notify(/verify)?)$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } },
}); });
+16
View File
@@ -51,6 +51,22 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 10s; proxy_read_timeout 10s;
} }
# The alpha sign-up, same handler: the form, and the confirmation from the link in its email.
location ~ ^/notify(/verify)?$ {
limit_except POST { deny all; }
client_max_body_size 64k;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
proxy_intercept_errors on;
error_page 502 503 504 = @notify_down;
}
location @notify_down {
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/notify/error; }
return 303 /notify/error;
}
# Handler not running: send the visitor to the error page in their language. # Handler not running: send the visitor to the error page in their language.
location @contact_down { location @contact_down {
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; } if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; }
-32
View File
@@ -700,7 +700,6 @@
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.3", "@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0" "tslib": "^2.4.0"
@@ -712,7 +711,6 @@
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -2098,7 +2096,6 @@
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
@@ -2236,7 +2233,6 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -2938,7 +2934,6 @@
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -3970,7 +3965,6 @@
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -4126,16 +4120,6 @@
"@rolldown/binding-win32-x64-msvc": "1.2.9" "@rolldown/binding-win32-x64-msvc": "1.2.9"
} }
}, },
"node_modules/rou3": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-1.0.0.tgz",
"integrity": "sha512-ySeaQkSrua+ut9g+65I7FHqOFBaY8tV0FmccXItoq0q21se32Q3u4YiW7/RqXSosWgYJaaKwRHXy3BST9PeCNQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/satteri": { "node_modules/satteri": {
"version": "0.10.5", "version": "0.10.5",
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
@@ -4305,19 +4289,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/srvx": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-1.0.5.tgz",
"integrity": "sha512-KvSKRpgPG/oaq3cyT614OQ2bAa7DynuGamivCZeoZzIUULOdbQz6wUtnnVuC4+DCx2Zglzo8v5gBYmWIWYhDxA==",
"license": "MIT",
"optional": true,
"bin": {
"srvx": "bin/srvx.mjs"
},
"engines": {
"node": ">=20.16.0"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "8.2.2", "version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -4470,7 +4441,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4779,7 +4749,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.7", "picomatch": "^4.0.7",
@@ -5212,7 +5181,6 @@
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"devOptional": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },
+166 -37
View File
@@ -1,20 +1,29 @@
/** /**
* The contact form handler. nginx proxies POST /contact here; everything else on the site stays static. * The handler for the site's two forms; nginx proxies their paths here and everything else stays
* static. Both send by SMTP.
* *
* The form posts first_name, last_name, email, message, lang, the honeypot website and altcha, the * Contact (POST /contact). The form posts first_name, last_name, email, message, lang, the honeypot website and altcha, the
* answer to a proof-of-work challenge from GET /contact/challenge (ALTCHA, solved in the browser * answer to a proof-of-work challenge from GET /contact/challenge (ALTCHA, solved in the browser
* while the visitor types). The browser asks for JSON and shows the result in place; a post that * while the visitor types). The browser asks for JSON and shows the result in place; a post that
* asks for HTML is answered with a redirect to /contact/sent or /contact/error (under /de for German). * asks for HTML is answered with a redirect to /contact/sent or /contact/error (under /de for German).
* A post without a solved challenge is refused, so the form needs scripting; the address next * A post without a solved challenge is refused, so the form needs scripting; the address next
* to it does not. * to it does not.
* *
* Alpha sign-up (POST /notify, POST /notify/verify). Double opt-in without a database: the form
* posts email, consent=yes, list, lang, website and altcha; the handler emails the address a link
* to /notify/confirm carrying a signed token (address, language, time of consent, expiry). That
* page posts the token back to /notify/verify, which checks the signature and sends the confirmed
* registration to CONTACT_TO. The office mailbox is the list. The confirmation is a button, not
* the link itself, because mail scanners open every link in a message.
*
* Configuration comes from the environment only, see README.md. * Configuration comes from the environment only, see README.md.
*/ */
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto'; import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { createChallenge, randomInt, verifySolution } from 'altcha-lib'; import { createChallenge, randomInt, verifySolution } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/pbkdf2'; import { deriveKey } from 'altcha-lib/algorithms/pbkdf2';
import { confirmMail } from './mail/confirm.mjs';
const env = process.env; const env = process.env;
const PORT = Number(env.CONTACT_PORT || 3000); const PORT = Number(env.CONTACT_PORT || 3000);
@@ -22,6 +31,8 @@ const HOST = env.CONTACT_HOST || '127.0.0.1';
const TO = env.CONTACT_TO || 'office@ciphercell.de'; const TO = env.CONTACT_TO || 'office@ciphercell.de';
const FROM = env.CONTACT_FROM || env.SMTP_USER; const FROM = env.CONTACT_FROM || env.SMTP_USER;
const SMTP_PORT = Number(env.SMTP_PORT || 587); const SMTP_PORT = Number(env.SMTP_PORT || 587);
// Confirmation links point here, never at the Host header a request happened to carry.
const SITE = (env.SITE_URL || 'https://www.ciphercell.de').replace(/\/$/, '');
if (!env.SMTP_HOST || !FROM) { if (!env.SMTP_HOST || !FROM) {
console.error('contact: SMTP_HOST and CONTACT_FROM (or SMTP_USER) must be set.'); console.error('contact: SMTP_HOST and CONTACT_FROM (or SMTP_USER) must be set.');
@@ -32,6 +43,9 @@ if (!env.SMTP_HOST || !FROM) {
restart if the visitor fetched a new one, which the form does after every send. */ restart if the visitor fetched a new one, which the form does after every send. */
const ALTCHA_SECRET = env.ALTCHA_HMAC_SECRET || randomBytes(32).toString('hex'); const ALTCHA_SECRET = env.ALTCHA_HMAC_SECRET || randomBytes(32).toString('hex');
const ALTCHA_KEY_SECRET = env.ALTCHA_HMAC_KEY_SECRET || randomBytes(32).toString('hex'); const ALTCHA_KEY_SECRET = env.ALTCHA_HMAC_KEY_SECRET || randomBytes(32).toString('hex');
/* Signs alpha confirmation links. Without it set, links sent before a restart stop working. */
const NOTIFY_SECRET = env.NOTIFY_SECRET || randomBytes(32).toString('hex');
if (!env.NOTIFY_SECRET) console.warn('contact: NOTIFY_SECRET not set; confirmation links will not survive a restart.');
const transport = nodemailer.createTransport({ const transport = nodemailer.createTransport({
host: env.SMTP_HOST, host: env.SMTP_HOST,
@@ -153,55 +167,68 @@ function readBody(req) {
}); });
} }
function reply(req, res, lang, outcome) { /* Alpha sign-up. */
const CONFIRM_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const NOTIFY_LIST = 'cipherverifier-alpha';
// The wording the visitor agreed to, recorded with the registration. Same text as
// soon.notify.consent in src/i18n/ui.ts; change both together.
const CONSENT = {
en: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.',
de: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.',
};
// Confirmed tokens, until they expire, so a second click does not file the address twice.
const confirmed = new Map();
setInterval(() => {
const now = Date.now();
for (const [mac, expires] of confirmed) if (expires < now) confirmed.delete(mac);
}, 60 * 60 * 1000).unref();
const mac = (body) => createHmac('sha256', NOTIFY_SECRET).update(body).digest();
function sign(payload) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${body}.${mac(body).toString('base64url')}`;
}
/** The payload of a token this handler signed, or null. */
function unsign(token) {
const [body, sig] = String(token || '').split('.');
if (!body || !sig) return null;
const expected = mac(body);
const given = Buffer.from(sig, 'base64url');
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return null;
try {
return { sig, ...JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) };
} catch {
return null;
}
}
/* Answers. A browser with scripting asks for JSON; a plain post is redirected to a page. */
const PAGES = {
contact: { sent: '/contact/sent', error: '/contact/error' },
notify: { sent: '/cipherverifier', error: '/notify/error' },
};
function reply(req, res, lang, outcome, pages = PAGES.contact) {
const status = { sent: 200, invalid: 400, limited: 429, unverified: 403, failed: 502 }[outcome]; const status = { sent: 200, invalid: 400, limited: 429, unverified: 403, failed: 502 }[outcome];
if ((req.headers.accept || '').includes('application/json')) { if ((req.headers.accept || '').includes('application/json')) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ ok: outcome === 'sent', outcome })); res.end(JSON.stringify({ ok: outcome === 'sent', outcome }));
return; return;
} }
const page = outcome === 'sent' ? 'sent' : 'error'; redirect(res, lang, outcome === 'sent' ? pages.sent : pages.error);
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}/contact/${page}`, 'Cache-Control': 'no-store' }); }
function redirect(res, lang, path) {
res.writeHead(303, { Location: `${lang === 'de' ? '/de' : ''}${path}`, 'Cache-Control': 'no-store' });
res.end(); res.end();
} }
const server = createServer(async (req, res) => { async function contact(req, res, form, lang) {
const path = (req.url || '').split('?')[0];
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
if (path === '/contact/challenge' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(await issueChallenge()));
return;
}
if (req.method !== 'POST' || path !== '/contact') { res.writeHead(404).end(); return; }
// A cross-site post is not a visitor using the form.
const origin = req.headers.origin;
const host = req.headers['x-forwarded-host'] || req.headers.host;
if (origin && host && new URL(origin).host !== host) { res.writeHead(403).end(); return; }
let form;
try {
form = new URLSearchParams(await readBody(req));
} catch {
res.writeHead(413).end();
return;
}
const lang = form.get('lang') === 'de' ? 'de' : 'en';
// Filled honeypot: answer as if sent, so the bot learns nothing.
if (form.get('website')) { reply(req, res, lang, 'sent'); return; }
const data = validate(form); const data = validate(form);
if (!data) { reply(req, res, lang, 'invalid'); return; } if (!data) { reply(req, res, lang, 'invalid'); return; }
// No solved challenge: a script, or a browser without scripting. Checked before the rate // No solved challenge: a script, or a browser without scripting. Checked before the rate
// limits, so unsolved posts cannot use up the budget meant for visitors. // limits, so unsolved posts cannot use up the budget meant for visitors.
if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified'); return; } if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified'); return; }
if (limited(clientIp(req))) { reply(req, res, lang, 'limited'); return; }
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
const ip = req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown';
if (limited(String(ip))) { reply(req, res, lang, 'limited'); return; }
const name = `${data.first_name} ${data.last_name}`; const name = `${data.first_name} ${data.last_name}`;
try { try {
@@ -226,6 +253,108 @@ const server = createServer(async (req, res) => {
console.error('contact: sending failed:', err.message); console.error('contact: sending failed:', err.message);
reply(req, res, lang, 'failed'); reply(req, res, lang, 'failed');
} }
}
/** Step one of the sign-up: a confirmation link to the address given. Nothing is kept. */
async function notify(req, res, form, lang) {
const email = oneLine(form.get('email') || '');
const valid = email.length <= MAX.email && EMAIL.test(email)
&& form.get('consent') === 'yes' && form.get('list') === NOTIFY_LIST;
if (!valid) { reply(req, res, lang, 'invalid', PAGES.notify); return; }
// The proof of work matters most here: this path sends mail to an address a stranger typed.
if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified', PAGES.notify); return; }
if (limited(clientIp(req))) { reply(req, res, lang, 'limited', PAGES.notify); return; }
const now = Date.now();
const token = sign({ email, lang, consented: now, expires: now + CONFIRM_TTL_MS });
const link = `${SITE}${lang === 'de' ? '/de' : ''}/notify/confirm?t=${token}`;
try {
await transport.sendMail({
from: { name: 'CipherCell', address: FROM },
to: email,
...confirmMail({ lang, email, link, site: SITE }),
});
reply(req, res, lang, 'sent', PAGES.notify);
} catch (err) {
console.error('notify: sending the confirmation failed:', err.message);
reply(req, res, lang, 'failed', PAGES.notify);
}
}
/** Step two: the link's token comes back; the confirmed registration goes to the office. */
async function verify(req, res, form) {
const t = unsign(form.get('t'));
const lang = t?.lang === 'de' ? 'de' : form.get('lang') === 'de' ? 'de' : 'en';
const now = Date.now();
if (!t || typeof t.email !== 'string' || !(t.expires > now) || !(t.consented > 0)) {
redirect(res, lang, '/notify/error');
return;
}
if (confirmed.has(t.sig)) { redirect(res, lang, '/notify/confirmed'); return; }
try {
await transport.sendMail({
from: { name: 'CipherCell website', address: FROM },
to: TO,
replyTo: t.email,
subject: `Alpha registration: ${t.email}`,
text: [
`Email: ${t.email}`,
`List: ${NOTIFY_LIST}`,
`Language: ${lang}`,
`Consent given: ${new Date(t.consented).toISOString()}`,
`Confirmed: ${new Date(now).toISOString()}`,
'',
'Consent wording:',
CONSENT[lang],
'',
'-- ',
'Confirmed by double opt-in on ciphercell.de. Per the privacy policy, delete this address',
'once the alpha notification has been sent, or when the person withdraws consent.',
].join('\n'),
});
confirmed.set(t.sig, t.expires);
redirect(res, lang, '/notify/confirmed');
} catch (err) {
console.error('notify: filing the registration failed:', err.message);
redirect(res, lang, '/notify/error');
}
}
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
const clientIp = (req) => String(req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown');
const ROUTES = { '/contact': contact, '/notify': notify, '/notify/verify': verify };
const server = createServer(async (req, res) => {
const path = (req.url || '').split('?')[0];
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
if (path === '/contact/challenge' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(await issueChallenge()));
return;
}
const route = ROUTES[path];
if (req.method !== 'POST' || !route) { res.writeHead(404).end(); return; }
// A cross-site post is not a visitor using the form.
const origin = req.headers.origin;
const host = req.headers['x-forwarded-host'] || req.headers.host;
if (origin && host && new URL(origin).host !== host) { res.writeHead(403).end(); return; }
let form;
try {
form = new URLSearchParams(await readBody(req));
} catch {
res.writeHead(413).end();
return;
}
const lang = form.get('lang') === 'de' ? 'de' : 'en';
// Filled honeypot: answer as if sent, so the bot learns nothing.
if (form.get('website')) { reply(req, res, lang, 'sent', path === '/contact' ? PAGES.contact : PAGES.notify); return; }
await route(req, res, form, lang);
}); });
server.listen(PORT, HOST, () => console.log(`contact: listening on ${HOST}:${PORT}, delivering to ${TO}`)); server.listen(PORT, HOST, () => console.log(`contact: listening on ${HOST}:${PORT}, delivering to ${TO}`));
+107
View File
@@ -0,0 +1,107 @@
/**
* The alpha sign-up confirmation email, in the site's voice and look: warm paper, warm black,
* one neo-grotesque, hairlines, one square button. Email clients ignore most CSS, so the layout
* is tables with inline styles, the type falls back from Inter Tight to Helvetica and Arial, and
* the logo travels as an inline attachment (cid) rather than a remote image.
*
* Colours are the tokens from src/styles/tokens.css, written out: email has no custom properties.
* The hairline is --line (ink at 14 %) flattened onto paper.
*/
import { fileURLToPath } from 'node:url';
const C = {
paper: '#E9E5DF',
ink: '#1A1A18',
muted: '#57564F',
line: '#CCC9C3',
};
const FONT = "'Inter Tight', Inter, 'Helvetica Neue', Helvetica, Arial, sans-serif";
const LOGO_CID = 'logo@ciphercell.de';
const LOGO = { path: fileURLToPath(new URL('./logo.png', import.meta.url)), width: 140, height: 32 };
const COPY = {
en: {
subject: 'Confirm your CipherVerifier alpha registration',
preheader: 'One click confirms your registration for the CipherVerifier alpha.',
title: 'Confirm your registration.',
body: (email) => `You registered ${email} for the CipherVerifier alpha. Confirm the address, and we will email you once, when the alpha is released. Nothing else.`,
button: 'Confirm registration',
expiry: 'The link is valid for 7 days. If you did not register, ignore this email. Your address is not stored.',
fallback: 'If the button does not work, open this address in your browser:',
},
de: {
subject: 'Bitte bestätigen Sie Ihre Registrierung für die CipherVerifier-Alpha',
preheader: 'Ein Klick bestätigt Ihre Registrierung für die Alpha von CipherVerifier.',
title: 'Registrierung bestätigen.',
body: (email) => `Sie haben ${email} für die Alpha von CipherVerifier registriert. Bestätigen Sie die Adresse, und wir schreiben Ihnen einmal, sobald die Alpha erscheint. Sonst nichts.`,
button: 'Registrierung bestätigen',
expiry: 'Der Link ist 7 Tage gültig. Wenn Sie sich nicht registriert haben, ignorieren Sie diese E-Mail. Ihre Adresse wird nicht gespeichert.',
fallback: 'Falls der Button nicht funktioniert, öffnen Sie diese Adresse im Browser:',
},
};
const ADDRESS = ['CipherCell', 'Innstr. 43, 94032 Passau'];
const SITE_LABEL = 'www.ciphercell.de';
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
/** { subject, text, html, attachments } for nodemailer. */
export function confirmMail({ lang, email, link, site }) {
const t = COPY[lang] ?? COPY.en;
const text = [
t.title,
'',
t.body(email),
'',
`${t.button}:`,
link,
'',
t.expiry,
'',
'-- ',
...ADDRESS,
SITE_LABEL,
].join('\n');
const p = (content, { size = 16, color = C.ink, lineHeight = 1.55, extra = '' } = {}) =>
`<p style="margin:0;font-family:${FONT};font-size:${size}px;line-height:${lineHeight};color:${color};${extra}">${content}</p>`;
const row = (content, padding) => `<tr><td style="padding:${padding};">${content}</td></tr>`;
const rule = `<tr><td style="padding:0;border-top:1px solid ${C.line};font-size:0;line-height:0;">&nbsp;</td></tr>`;
const html = `<!doctype html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>${esc(t.subject)}</title>
</head>
<body style="margin:0;padding:0;background:${C.paper};-webkit-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:${C.paper};">${esc(t.preheader)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${C.paper};">
<tr><td align="left" style="padding:40px 24px 48px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
${row(`<a href="${esc(site)}" style="text-decoration:none;"><img src="cid:${LOGO_CID}" width="${LOGO.width}" height="${LOGO.height}" alt="CipherCell" style="display:block;border:0;width:${LOGO.width}px;height:${LOGO.height}px;font-family:${FONT};font-size:18px;font-weight:600;color:${C.ink};"></a>`, '0 0 20px')}
${rule}
${row(p(esc(t.title), { size: 34, lineHeight: 1.08, extra: 'font-weight:500;letter-spacing:-0.02em;' }), '48px 0 20px')}
${row(p(esc(t.body(email))), '0 0 32px')}
${row(`<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td bgcolor="${C.ink}" style="background:${C.ink};">
<a href="${esc(link)}" style="display:inline-block;padding:15px 26px;font-family:${FONT};font-size:16px;line-height:1;font-weight:500;color:${C.paper};text-decoration:none;">${esc(t.button)}</a>
</td></tr></table>`, '0 0 32px')}
${row(p(esc(t.expiry), { size: 14, color: C.muted, lineHeight: 1.4 }), '0 0 16px')}
${row(p(`${esc(t.fallback)}<br><a href="${esc(link)}" style="color:${C.muted};text-decoration:underline;word-break:break-all;">${esc(link)}</a>`, { size: 14, color: C.muted, lineHeight: 1.4 }), '0 0 48px')}
${rule}
${row(p(`${ADDRESS.map(esc).join('<br>')}<br><a href="${esc(site)}" style="color:${C.muted};text-decoration:underline;">${SITE_LABEL}</a>`, { size: 14, color: C.muted, lineHeight: 1.4 }), '20px 0 0')}
</table>
</td></tr>
</table>
</body>
</html>`;
return {
subject: t.subject,
text,
html,
attachments: [{ filename: 'ciphercell.png', path: LOGO.path, cid: LOGO_CID }],
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+124
View File
@@ -0,0 +1,124 @@
---
/** A form's answer, as a toast in the lower right. Framed like the menu, on night. Driven by
scripts/send-form.ts, which finds it by the id of the form it answers. */
interface Props {
for: string;
labels: { sending: string; sent: string; failed: string; close: string };
}
const { for: formId, labels } = Astro.props;
---
<div
class="toast hud-frame"
data-toast={formId}
data-label-sending={labels.sending}
data-label-sent={labels.sent}
data-label-failed={labels.failed}
>
<div class="toast__head">
<p class="toast__label"><span data-toast-label></span><span class="toast__dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span></p>
<button class="toast__close" type="button" data-toast-close>{labels.close}</button>
</div>
<p class="toast__message" role="status" aria-live="polite" data-toast-message></p>
<span class="toast__timer" aria-hidden="true" data-toast-timer></span>
</div>
<style>
/* Toast: a night panel fixed in the lower right. The menu's corner crosses frame it; their arms
reach out onto the page, so they take the menu's line colour, not the night one. */
.toast {
--frame-color: var(--hud-line);
position: fixed;
right: var(--gutter);
bottom: var(--gutter);
z-index: 20;
width: min(26rem, calc(100% - 2 * var(--gutter)));
display: grid;
gap: var(--space-3);
padding: var(--space-4) var(--space-5) var(--space-5);
background: var(--night);
color: var(--ink-on-night);
/* Closed: lowered and slightly small, grown from its corner. Opening pops it up; closing
just sinks back, quicker and without the overshoot. */
opacity: 0;
visibility: hidden;
transform: translateY(var(--space-5)) scale(0.94);
transform-origin: bottom right;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease),
visibility 0s linear var(--dur-fast);
}
.toast[data-open] {
opacity: 1;
visibility: visible;
transform: none;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-slow) var(--ease-pop);
}
.toast__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.toast__label,
.toast__close {
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
line-height: 1;
letter-spacing: var(--track-caps);
text-transform: uppercase;
}
.toast__label { color: var(--ink-on-night); }
/* While sending, three dots after the label light up one after another. */
.toast__dots { display: none; }
.toast[data-state='sending'] .toast__dots { display: inline; }
.toast__dots span { animation: calc(4 * var(--dur-slow)) steps(1, end) infinite; }
.toast__dots span:nth-child(1) { animation-name: toast-dot-1; }
.toast__dots span:nth-child(2) { animation-name: toast-dot-2; }
.toast__dots span:nth-child(3) { animation-name: toast-dot-3; }
.toast__close {
color: var(--ink-muted-on-night);
padding: var(--space-2);
margin: calc(-1 * var(--space-2));
transition: color var(--dur-fast) var(--ease);
}
.toast__close:hover { color: var(--ink-on-night); }
.toast__close:focus-visible { outline-color: var(--ink-on-night); }
.toast__message {
font-size: var(--step-small);
line-height: var(--leading-small);
color: var(--ink-muted-on-night);
}
.toast__message:empty { display: none; }
/* A hairline counts down the time until the toast leaves, longer for an error, which has more
to read. Pointer or focus pauses it. */
.toast__timer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--hud-line-width);
background: var(--ink-muted-on-night);
transform-origin: left;
transform: scaleX(0);
}
.toast[data-open][data-state='sent'] .toast__timer { animation: toast-timer var(--dur-toast) linear forwards; }
.toast[data-open][data-state='failed'] .toast__timer { animation: toast-timer var(--dur-toast-error) linear forwards; }
.toast:hover .toast__timer,
.toast:focus-within .toast__timer { animation-play-state: paused; }
@keyframes toast-timer { from { transform: scaleX(1); } to { transform: scaleX(0); } }
/* One shared cycle in four beats: none, one, two, three dots. */
@keyframes toast-dot-1 { 0% { opacity: 0; } 25%, 100% { opacity: 1; } }
@keyframes toast-dot-2 { 0% { opacity: 0; } 50%, 100% { opacity: 1; } }
@keyframes toast-dot-3 { 0% { opacity: 0; } 75%, 100% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.toast, .toast[data-open] { transition: none; transform: none; }
.toast__dots span { animation: none; }
/* The timer still runs, since it dismisses the toast, but it is not drawn. */
.toast__timer { visibility: hidden; }
}
</style>
+7 -196
View File
@@ -1,4 +1,5 @@
--- ---
import Toast from '../Toast.astro';
import { t, type Lang } from '../../i18n/ui'; import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
@@ -18,6 +19,7 @@ const f = ui.contact.form;
<form <form
class="contact__form" class="contact__form"
id="contact-form"
action="/contact" action="/contact"
method="post" method="post"
lang={lang} lang={lang}
@@ -55,21 +57,7 @@ const f = ui.contact.form;
</form> </form>
</div> </div>
<!-- The form's answer, as a toast in the lower right. Framed like the menu, on night. --> <Toast for="contact-form" labels={{ ...f.toast }} />
<div
class="toast hud-frame"
data-toast
data-label-sending={f.toast.sending}
data-label-sent={f.toast.sent}
data-label-failed={f.toast.failed}
>
<div class="toast__head">
<p class="toast__label"><span data-toast-label></span><span class="toast__dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span></p>
<button class="toast__close" type="button" data-toast-close>{f.toast.close}</button>
</div>
<p class="toast__message" role="status" aria-live="polite" data-toast-message></p>
<span class="toast__timer" aria-hidden="true" data-toast-timer></span>
</div>
</section> </section>
<style> <style>
@@ -118,105 +106,6 @@ const f = ui.contact.form;
.contact__form[aria-busy='true'] .button { opacity: 0.6; cursor: progress; } .contact__form[aria-busy='true'] .button { opacity: 0.6; cursor: progress; }
/* Toast: a night panel fixed in the lower right. The menu's corner crosses frame it; their arms
reach out onto the page, so they take the menu's line colour, not the night one. */
.toast {
--frame-color: var(--hud-line);
position: fixed;
right: var(--gutter);
bottom: var(--gutter);
z-index: 20;
width: min(26rem, calc(100% - 2 * var(--gutter)));
display: grid;
gap: var(--space-3);
padding: var(--space-4) var(--space-5) var(--space-5);
background: var(--night);
color: var(--ink-on-night);
/* Closed: lowered and slightly small, grown from its corner. Opening pops it up; closing
just sinks back, quicker and without the overshoot. */
opacity: 0;
visibility: hidden;
transform: translateY(var(--space-5)) scale(0.94);
transform-origin: bottom right;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease),
visibility 0s linear var(--dur-fast);
}
.toast[data-open] {
opacity: 1;
visibility: visible;
transform: none;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-slow) var(--ease-pop);
}
.toast__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.toast__label,
.toast__close {
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
line-height: 1;
letter-spacing: var(--track-caps);
text-transform: uppercase;
}
.toast__label { color: var(--ink-on-night); }
/* While sending, three dots after the label light up one after another. */
.toast__dots { display: none; }
.toast[data-state='sending'] .toast__dots { display: inline; }
.toast__dots span { animation: calc(4 * var(--dur-slow)) steps(1, end) infinite; }
.toast__dots span:nth-child(1) { animation-name: toast-dot-1; }
.toast__dots span:nth-child(2) { animation-name: toast-dot-2; }
.toast__dots span:nth-child(3) { animation-name: toast-dot-3; }
.toast__close {
color: var(--ink-muted-on-night);
padding: var(--space-2);
margin: calc(-1 * var(--space-2));
transition: color var(--dur-fast) var(--ease);
}
.toast__close:hover { color: var(--ink-on-night); }
.toast__close:focus-visible { outline-color: var(--ink-on-night); }
.toast__message {
font-size: var(--step-small);
line-height: var(--leading-small);
color: var(--ink-muted-on-night);
}
.toast__message:empty { display: none; }
/* A hairline counts down the time until the toast leaves, longer for an error, which has more
to read. Pointer or focus pauses it. */
.toast__timer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--hud-line-width);
background: var(--ink-muted-on-night);
transform-origin: left;
transform: scaleX(0);
}
.toast[data-open][data-state='sent'] .toast__timer { animation: toast-timer var(--dur-toast) linear forwards; }
.toast[data-open][data-state='failed'] .toast__timer { animation: toast-timer var(--dur-toast-error) linear forwards; }
.toast:hover .toast__timer,
.toast:focus-within .toast__timer { animation-play-state: paused; }
@keyframes toast-timer { from { transform: scaleX(1); } to { transform: scaleX(0); } }
/* One shared cycle in four beats: none, one, two, three dots. */
@keyframes toast-dot-1 { 0% { opacity: 0; } 25%, 100% { opacity: 1; } }
@keyframes toast-dot-2 { 0% { opacity: 0; } 50%, 100% { opacity: 1; } }
@keyframes toast-dot-3 { 0% { opacity: 0; } 75%, 100% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.toast, .toast[data-open] { transition: none; transform: none; }
.toast__dots span { animation: none; }
/* The timer still runs, since it dismisses the toast, but it is not drawn. */
.toast__timer { visibility: hidden; }
}
@media (min-width: 48rem) { @media (min-width: 48rem) {
.contact__text { grid-column: 1 / span 5; } .contact__text { grid-column: 1 / span 5; }
.contact__form { grid-column: 7 / span 6; } .contact__form { grid-column: 7 / span 6; }
@@ -228,87 +117,9 @@ const f = ui.contact.form;
</style> </style>
<script> <script>
// The form sends in place and answers in a toast. Every send carries the answer to a // Sends in place and answers in the toast; see scripts/send-form.ts. Without scripting the
// proof-of-work challenge (ALTCHA), worked out in the background from the moment the visitor // server refuses the post and redirects to /contact/error, which points to the email address.
// first touches the form. Without scripting the server refuses the post and redirects to import { sendInPlace } from '../../scripts/send-form';
// /contact/error, which points to the email address.
import { solveChallenge, type Challenge } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2';
const form = document.querySelector<HTMLFormElement>('[data-contact]'); const form = document.querySelector<HTMLFormElement>('[data-contact]');
const toast = document.querySelector<HTMLElement>('[data-toast]'); if (form) sendInPlace(form);
if (form && toast) {
const label = toast.querySelector<HTMLElement>('[data-toast-label]')!;
const message = toast.querySelector<HTMLElement>('[data-toast-message]')!;
const timer = toast.querySelector<HTMLElement>('[data-toast-timer]')!;
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
// One challenge per message: fetched and solved once, spent by the send that carries it.
let proof: Promise<string | null> | null = null;
const solve = async (): Promise<string | null> => {
try {
const res = await fetch('/contact/challenge', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const challenge: Challenge = await res.json();
const solution = await solveChallenge({ challenge, deriveKey });
return solution ? btoa(JSON.stringify({ challenge, solution })) : null;
} catch {
return null;
}
};
const prepare = () => (proof ??= solve());
form.addEventListener('focusin', prepare);
const show = (state: 'sending' | 'sent' | 'failed', text: string) => {
toast.dataset.state = state;
toast.setAttribute('data-open', '');
// Restart the countdown for a second message in a row.
timer.style.animation = 'none';
void timer.offsetWidth;
timer.style.animation = '';
label.textContent = toast.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] ?? '';
// Set on the next frame, so the now visible live region announces it.
requestAnimationFrame(() => { message.textContent = text; });
};
const hide = () => {
if (toast.contains(document.activeElement)) submit.focus();
toast.removeAttribute('data-open');
};
timer.addEventListener('animationend', hide);
close.addEventListener('click', hide);
toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (form.getAttribute('aria-busy') === 'true') return;
form.setAttribute('aria-busy', 'true');
show('sending', '');
let outcome = 'failed';
const altcha = await prepare();
proof = null;
if (altcha) {
const body = new URLSearchParams(new FormData(form) as unknown as Record<string, string>);
body.set('altcha', altcha);
try {
const res = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body,
});
outcome = (await res.json()).outcome ?? 'failed';
} catch {
outcome = 'failed';
}
}
if (outcome === 'sent') {
form.reset();
show('sent', form.dataset.sent ?? '');
} else {
show('failed', (outcome === 'invalid' ? form.dataset.invalid : form.dataset.failed) ?? '');
}
form.removeAttribute('aria-busy');
});
}
</script> </script>
+2 -50
View File
@@ -1,13 +1,10 @@
--- ---
import Schematic from './Schematic.astro'; import Verticals from './Verticals.astro';
import { overview } from './diagrams';
import { t, type Lang } from '../../i18n/ui'; import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
const ui = t(lang); const ui = t(lang);
const s = ui.verifier.soon; const s = ui.verifier.soon;
const g = ui.verifier.how.groups[0];
const pair = overview(g);
/* The step that is current; earlier ones read as passed, later ones as open. */ /* The step that is current; earlier ones read as passed, later ones as open. */
const now = 0; const now = 0;
--- ---
@@ -30,30 +27,7 @@ const now = 0;
</div> </div>
</section> </section>
<section class="section spec" aria-labelledby="spec-title"> <Verticals lang={lang} />
<div class="container grid">
<div class="spec__head">
<h2 id="spec-title" class="h2">{s.spec.title}</h2>
<p class="muted">{s.spec.body}</p>
</div>
<dl class="spec__table">
{s.spec.rows.map((r) => (
<div class="spec__row">
<dt>{r.key}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
<figure class="spec__figure">
<div class="spec__drawing hud-frame">
<div class="spec__wide"><Schematic layout={pair.wide} title={g.title} desc={g.desc} variant="wide" id="soon-wide" /></div>
<div class="spec__tall"><Schematic layout={pair.tall} title={g.title} desc={g.desc} variant="tall" id="soon-tall" /></div>
</div>
<figcaption class="small muted">{s.spec.figure}</figcaption>
</figure>
</div>
</section>
<style> <style>
/* Release path: one hairline, one node per phase. */ /* Release path: one hairline, one node per phase. */
.track__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); align-content: start; } .track__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); align-content: start; }
@@ -100,30 +74,8 @@ const now = 0;
.track__step[data-state='next'] .track__name { color: var(--ink-muted); } .track__step[data-state='next'] .track__name { color: var(--ink-muted); }
.track__body { max-width: 22em; } .track__body { max-width: 22em; }
/* Spec sheet: key and value on hairlines, like a data sheet. */
.spec { border-top: var(--hairline) solid var(--line); }
.spec__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); align-content: start; }
.spec__head p { max-width: 28em; }
.spec__table { grid-column: 1 / -1; border-top: var(--hairline) solid var(--ink); }
.spec__row {
display: grid;
grid-template-columns: minmax(8rem, 1fr) 2fr;
gap: var(--gap);
padding-block: var(--space-4);
border-bottom: var(--hairline) solid var(--line);
}
.spec__row dt { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); padding-top: 0.15em; }
.spec__figure { grid-column: 1 / -1; display: grid; gap: var(--space-3); margin-top: var(--space-6); }
.spec__drawing { padding: var(--space-6); }
.spec__wide { display: none; }
@media (min-width: 48rem) { @media (min-width: 48rem) {
.track__head { grid-column: 1 / span 4; } .track__head { grid-column: 1 / span 4; }
.spec__head { grid-column: 1 / span 5; }
.spec__table { grid-column: 7 / span 6; }
.spec__wide { display: block; }
.spec__tall { display: none; }
.spec__drawing { padding: var(--space-7) var(--space-8); }
} }
/* Wide: the rail turns horizontal and runs across all three phases. */ /* Wide: the rail turns horizontal and runs across all three phases. */
@media (min-width: 64rem) { @media (min-width: 64rem) {
+25 -1
View File
@@ -1,4 +1,5 @@
--- ---
import Toast from '../Toast.astro';
import { localePath, t, type Lang } from '../../i18n/ui'; import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang } interface Props { lang: Lang }
const { lang } = Astro.props; const { lang } = Astro.props;
@@ -59,7 +60,18 @@ const ridges = Array.from({ length: rows }, (_, r) => {
<p class="shero__lead">{s.lead}</p> <p class="shero__lead">{s.lead}</p>
</div> </div>
<form class="signup hud-frame" id="notify" action="/notify" method="post" lang={lang} aria-labelledby="notify-title"> <form
class="signup hud-frame"
id="notify"
action="/notify"
method="post"
lang={lang}
aria-labelledby="notify-title"
data-notify
data-sent={n.status.sent}
data-invalid={n.status.invalid}
data-failed={n.status.failed}
>
<div class="signup__head"> <div class="signup__head">
<h2 id="notify-title" class="signup__title">{n.title}</h2> <h2 id="notify-title" class="signup__title">{n.title}</h2>
<p class="signup__body">{n.body}</p> <p class="signup__body">{n.body}</p>
@@ -79,6 +91,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
<label for="notify-consent">{n.consent} <a href={localePath(lang, '/privacy')}>{n.privacy}</a></label> <label for="notify-consent">{n.consent} <a href={localePath(lang, '/privacy')}>{n.privacy}</a></label>
</div> </div>
<button class="button signup__submit" type="submit">{n.submit}</button> <button class="button signup__submit" type="submit">{n.submit}</button>
<noscript><p class="signup__status">{n.noscript}</p></noscript>
</form> </form>
<!-- Status strip: the menu bar's HUD language, set along the foot of the hero. --> <!-- Status strip: the menu bar's HUD language, set along the foot of the hero. -->
@@ -92,6 +105,8 @@ const ridges = Array.from({ length: rows }, (_, r) => {
))} ))}
</dl> </dl>
</div> </div>
<Toast for="notify" labels={n.toast} />
</section> </section>
<style> <style>
@@ -199,6 +214,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
color: var(--ink-muted); color: var(--ink-muted);
} }
.signup__consent input { width: 1.1em; height: 1.1em; margin: 0.15em 0 0; accent-color: var(--paper); } .signup__consent input { width: 1.1em; height: 1.1em; margin: 0.15em 0 0; accent-color: var(--paper); }
.signup__status { font-size: var(--step-small); line-height: var(--leading-small); }
.signup__consent a { color: var(--ink); text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; } .signup__consent a { color: var(--ink); text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
/* Readout: stacked on small screens, one strip of cells on wide ones. */ /* Readout: stacked on small screens, one strip of cells on wide ones. */
@@ -262,3 +278,11 @@ const ridges = Array.from({ length: rows }, (_, r) => {
.readout__cell--head { place-content: center; text-align: center; } .readout__cell--head { place-content: center; text-align: center; }
} }
</style> </style>
<script>
// Sends in place and answers in the toast, like the contact form; see scripts/send-form.ts.
// Without scripting the server refuses the post and redirects to /notify/error.
import { sendInPlace } from '../../scripts/send-form';
const form = document.querySelector<HTMLFormElement>('[data-notify]');
if (form) sendInPlace(form);
</script>
+329
View File
@@ -0,0 +1,329 @@
---
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
const v = t(lang).verifier.soon.verticals;
/* The first tile is the vertical that is integrated; the others are open slots. */
const tiles = v.tiles.map((tile, i) => ({ ...tile, id: `vertical-${i + 1}`, kind: i === 0 ? 'telecom' : 'open' }));
---
<section class="section verticals" aria-labelledby="verticals-title">
<div class="container grid">
<div class="verticals__head">
<h2 id="verticals-title" class="h2">{v.title}</h2>
<p class="muted">{v.body}</p>
</div>
<div class="verticals__system" data-verticals>
<p class="verticals__platform small">{v.platform}</p>
<div class="verticals__tiles" role="tablist" aria-labelledby="verticals-title">
{tiles.map((tile, i) => (
<button
class="verticals__tile hud-frame"
type="button"
role="tab"
id={`${tile.id}-tab`}
aria-controls={`${tile.id}-panel`}
aria-selected={i === 0 ? 'true' : 'false'}
tabindex={i === 0 ? undefined : -1}
data-kind={tile.kind}
>
<span class="verticals__state small">
<span class="verticals__mark" aria-hidden="true" />
{tile.state}
</span>
{tile.kind === 'telecom' ? (
/* A lattice mast with its antenna panels, radiating to both sides, two cabinets at the foot. */
<svg class="verticals__drawing" viewBox="0 0 240 150" aria-hidden="true">
<g class="verticals__idea">
<path d="M104 132 L117 34 M136 132 L123 34 M117 34 L123 34 M120 34 L120 14" />
<path d="M105 124 L134 112 L107 100 L132 88 L109 76 L130 64 L112 52 L128 42" />
<path d="M110 40 h-5 v14 h5 z M130 40 h5 v14 h-5 z" />
<path d="M96 28 A30 30 0 0 0 96 66 M86 18 A44 44 0 0 0 86 76 M76 8 A58 58 0 0 0 76 86" />
<path d="M144 28 A30 30 0 0 1 144 66 M154 18 A44 44 0 0 1 154 76 M164 8 A58 58 0 0 1 164 86" />
</g>
<g class="verticals__ground">
<path d="M40 132 H200" />
<path d="M62 132 v-14 h18 v14 M160 132 v-14 h18 v14 M80 125 H104 M136 125 H160" />
</g>
</svg>
) : (
/* An empty module, drawn dashed: the slot the next vertical plugs into. */
<svg class="verticals__drawing" viewBox="0 0 240 150" aria-hidden="true">
<g class="verticals__idea">
<path d="M120 14 V34" />
<path class="verticals__dashed" d="M120 34 L162 56 L120 78 L78 56 Z M78 56 V104 L120 126 L162 104 V56 M120 78 V126" />
<path d="M120 49 v14 M113 56 h14" />
</g>
<g class="verticals__ground">
<path d="M40 132 H200" />
<path d="M60 22 v-8 h8 M180 22 v-8 h-8 M60 118 v8 h8 M180 118 v8 h-8" />
</g>
</svg>
)}
<span class="verticals__name">{tile.name}</span>
</button>
))}
</div>
<div class="verticals__panels">
{tiles.map((tile, i) => (
<div
class="verticals__panel"
role="tabpanel"
id={`${tile.id}-panel`}
aria-labelledby={`${tile.id}-tab`}
tabindex="0"
data-open={i === 0 ? '' : undefined}
>
<div class="verticals__copy">
<h3 class="h3">{tile.title}</h3>
<p class="muted">{tile.body}</p>
</div>
{'rows' in tile && tile.rows && (
<dl class="verticals__rows">
{tile.rows.map((r) => (
<div class="verticals__row">
<dt>{r.key}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
)}
{'cta' in tile && tile.cta && (
<p class="verticals__cta"><a class="button" href={`${localePath(lang, '/')}#contact`}>{tile.cta}</a></p>
)}
</div>
))}
</div>
</div>
</div>
</section>
<style>
/* Verticals: the platform on top, a bus below it, one tile per vertical plugged into the bus.
The selected tile turns night; its panel below says what the vertical covers. */
.verticals { border-top: var(--hairline) solid var(--line); }
.verticals__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); align-content: start; }
.verticals__head p { max-width: 24em; }
.verticals__system { grid-column: 1 / -1; display: grid; }
/* Phones: the platform sits left, a spine runs down from it and each tile plugs in from the
side. Wider: the platform is centred over a bus and each tile drops a stub from it. */
.verticals__system { --stub: var(--space-5); --spine: var(--space-4); --indent: var(--space-7); }
.verticals__platform {
justify-self: start;
position: relative;
padding: var(--space-2) var(--space-4);
border: var(--hairline) solid var(--ink);
font-weight: var(--weight-medium);
}
.verticals__platform::after {
content: '';
position: absolute;
top: 100%;
left: var(--spine);
height: var(--stub);
border-left: var(--hairline) solid var(--ink);
}
.verticals__tiles {
position: relative;
display: grid;
grid-auto-rows: 1fr;
gap: var(--gap);
margin-top: var(--stub);
padding-left: var(--indent);
}
/* .hud-frame redefines --line as a width, so the frame colour comes from the HUD token. */
.verticals__tile {
--frame-color: var(--hud-line);
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr);
grid-template-rows: auto 1fr;
column-gap: var(--space-4);
row-gap: var(--space-5);
padding: var(--space-4);
text-align: left;
font: inherit;
color: var(--ink);
background: var(--paper);
cursor: pointer;
transition:
background var(--dur) var(--ease),
color var(--dur) var(--ease);
}
/* The plug: down the spine from the tile above (or the platform), then across into the tile.
Rows share one height, so -50% of a tile reaches the middle of the one before it. */
.verticals__tile::before {
content: '';
position: absolute;
right: 100%;
width: calc(var(--indent) - var(--spine));
top: calc(-50% - var(--gap));
bottom: 50%;
border-left: var(--hairline) solid var(--ink);
border-bottom: var(--hairline) solid var(--ink);
}
.verticals__tile:first-child::before { top: calc(-1 * var(--stub)); }
/* An open slot is plugged in with a dashed line: reserved, not yet connected. */
.verticals__tile[data-kind='open']::before { border-left-style: dashed; border-bottom-style: dashed; }
.verticals__tile[aria-selected='true'] {
--frame-color: var(--ink-base);
--ink-muted: var(--ink-muted-on-night);
background: var(--night);
color: var(--ink-on-night);
cursor: default;
}
/* The ring sits outside the tile, on paper, so it keeps the paper ink even on the night tile. */
.verticals__tile:focus-visible { outline-color: var(--ink-base); }
/* Crosses only on the selected tile: it is the one under inspection. */
.verticals__tile[aria-selected='false']::after { opacity: 0; }
.verticals__tile::after { transition: opacity var(--dur) var(--ease); }
.verticals__state {
grid-column: 1;
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--ink-muted);
}
/* A square that fills when the tile is selected, like a selector on a panel. */
.verticals__mark {
width: 0.5rem;
height: 0.5rem;
border: var(--hairline) solid currentColor;
flex: none;
transition: background var(--dur) var(--ease);
}
.verticals__tile[aria-selected='true'] .verticals__mark { background: var(--ink-on-night); border-color: var(--ink-on-night); }
.verticals__drawing {
grid-column: 2;
grid-row: 1 / span 2;
width: 100%;
max-width: 22rem;
justify-self: center;
align-self: center;
height: auto;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: var(--hairline);
stroke-linecap: square;
vector-effect: non-scaling-stroke;
transform-origin: 50% 60%;
transition: transform var(--dur-slow) var(--ease);
}
.verticals__drawing path { vector-effect: non-scaling-stroke; }
.verticals__ground { opacity: 0.4; transition: opacity var(--dur-slow) var(--ease); }
.verticals__dashed { stroke-dasharray: 4 4; }
.verticals__name {
grid-column: 1;
align-self: end;
font-size: var(--step-h3);
font-weight: var(--weight-medium);
letter-spacing: var(--track-h3);
line-height: var(--leading-h3);
}
/* Pointing at a tile brings its drawing forward, as on the home page cards. */
@media (hover: hover) {
.verticals__tile[aria-selected='false']:hover { background: color-mix(in srgb, var(--ink) var(--tint-hover), var(--paper)); }
.verticals__tile:hover .verticals__drawing { transform: scale(1.03); }
.verticals__tile:hover .verticals__ground { opacity: 0.26; }
}
/* Panels share one cell, so switching crossfades without the section changing height. */
.verticals__panels {
display: grid;
margin-top: var(--space-6);
border-top: var(--hairline) solid var(--ink);
}
.verticals__panel {
grid-area: 1 / 1;
display: grid;
gap: var(--space-5);
align-content: start;
padding-top: var(--space-5);
opacity: 0;
visibility: hidden;
transition:
opacity var(--dur) var(--ease),
visibility 0s linear var(--dur);
}
.verticals__panel[data-open] {
opacity: 1;
visibility: visible;
transition: opacity var(--dur-slow) var(--ease) var(--dur-fast), visibility 0s;
}
.verticals__copy { display: grid; gap: var(--space-3); }
.verticals__copy p { max-width: var(--measure); }
.verticals__rows { border-top: var(--hairline) solid var(--line); }
.verticals__row {
display: grid;
grid-template-columns: minmax(7rem, 1fr) 2fr;
gap: var(--gap);
padding-block: var(--space-3);
border-bottom: var(--hairline) solid var(--line);
}
.verticals__row dt { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); padding-top: 0.15em; }
@media (min-width: 48rem) {
.verticals__system { --stub: var(--space-6); }
.verticals__platform { justify-self: center; }
.verticals__platform::after { left: 50%; }
.verticals__tiles {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: auto;
margin-top: calc(2 * var(--stub));
padding-left: 0;
}
/* The bus runs between the centres of the two tiles. */
.verticals__tiles::before {
content: '';
position: absolute;
top: calc(-1 * var(--stub));
left: calc(25% - var(--gap) / 4);
right: calc(25% - var(--gap) / 4);
border-top: var(--hairline) solid var(--ink);
}
.verticals__tile {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
row-gap: var(--space-4);
padding: var(--space-5);
}
.verticals__tile::before,
.verticals__tile:first-child::before {
top: auto;
bottom: 100%;
left: 50%;
right: auto;
width: 0;
height: var(--stub);
border-bottom: 0;
}
.verticals__state, .verticals__drawing, .verticals__name { grid-column: 1; grid-row: auto; }
.verticals__drawing { padding-block: var(--space-4); }
.verticals__panel { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); column-gap: var(--gap); }
.verticals__rows, .verticals__cta { align-self: start; margin-top: var(--space-1); }
}
@media (min-width: 64rem) {
.verticals__head { grid-column: 1 / span 4; }
.verticals__system { grid-column: 5 / span 8; }
}
@media (prefers-reduced-motion: reduce) {
.verticals__tile, .verticals__tile::after, .verticals__mark, .verticals__drawing, .verticals__ground, .verticals__panel, .verticals__panel[data-open] { transition: none; }
}
</style>
<script>
import { initTabs } from '../../scripts/tabs';
const root = document.querySelector<HTMLElement>('[data-verticals]');
if (root) initTabs(root);
</script>
+92 -24
View File
@@ -270,6 +270,30 @@ const en = {
consent: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.', consent: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.',
privacy: 'Privacy policy', privacy: 'Privacy policy',
submit: 'Register', submit: 'Register',
noscript: 'The form needs JavaScript. Without it, write to office@ciphercell.de.',
toast: { sending: 'Registering', sent: 'Link sent', failed: 'Not registered', close: 'Close' },
status: {
sent: 'Check your inbox. We sent a link to confirm your address.',
invalid: 'Please give a valid email address and agree to the notice.',
failed: 'Registration did not go through. Please try again, or write to office@ciphercell.de.',
},
pages: {
confirm: {
title: 'Confirm your registration.',
body: 'One click, and your address is registered for the CipherVerifier alpha. One email when it is released. Nothing else.',
submit: 'Confirm registration',
},
confirmed: {
title: 'Registered.',
body: 'Your address is confirmed. You will hear from us once, when the CipherVerifier alpha is released.',
link: 'Back to CipherVerifier',
},
error: {
title: 'Not registered.',
body: 'The link has expired or is incomplete, or the registration did not go through. Register again, or write to office@ciphercell.de.',
link: 'Register again',
},
},
}, },
track: { track: {
title: 'Release path.', title: 'Release path.',
@@ -280,19 +304,29 @@ const en = {
{ state: 'Later', name: 'General availability', body: 'Date to follow.' }, { state: 'Later', name: 'General availability', body: 'Date to follow.' },
], ],
}, },
spec: { verticals: {
title: 'What is being built.', title: 'Verticals.',
body: 'CipherVerifier takes a repository, runs seventeen static analysers and two bill-of-materials generators over it, reads each finding in its context and ranks what is real by security impact.', body: 'One pipeline underneath. Each vertical brings the knowledge of one domain to it.',
rows: [ platform: 'CipherVerifier platform',
{ key: 'Static analysers', value: '17, each in its own container' }, tiles: [
{ key: 'Languages', value: '14, plus Docker, Kubernetes, Helm, Terraform and CloudFormation' }, {
{ key: 'Bill of materials', value: '2 generators, merged into one CycloneDX document' }, state: 'Integrated',
{ key: 'Vocabulary', value: '970 CWE entries' }, name: 'Telecommunication security',
{ key: 'Pipeline', value: '5 stages, each switchable per repository' }, title: 'Built from Open RAN security research.',
{ key: 'Review', value: 'Two-stage verdict on every finding' }, body: 'The first vertical comes from where CipherCell started: the security of Open RAN and private 5G networks, worked on with the University of Passau and the xG-ALOE alliance.',
{ key: 'Deployment', value: 'Self-hosted' }, rows: [
{ key: 'Networks', value: 'Open RAN, private 5G' },
{ key: 'Infrastructure', value: 'Critical network infrastructure' },
],
},
{
state: 'Open',
name: 'Other',
title: 'Your domain next.',
body: 'The pipeline underneath is not tied to telecommunications. Further verticals are added where operators bring a domain to us.',
cta: 'Propose a vertical',
},
], ],
figure: 'Figure 1. One run, five stages.',
}, },
}, },
}, },
@@ -586,6 +620,30 @@ const de: typeof en = {
consent: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.', consent: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.',
privacy: 'Datenschutzerklärung', privacy: 'Datenschutzerklärung',
submit: 'Registrieren', submit: 'Registrieren',
noscript: 'Das Formular braucht JavaScript. Ohne schreiben Sie an office@ciphercell.de.',
toast: { sending: 'Wird registriert', sent: 'Link gesendet', failed: 'Nicht registriert', close: 'Schließen' },
status: {
sent: 'Bitte sehen Sie in Ihr Postfach. Wir haben Ihnen einen Link zur Bestätigung geschickt.',
invalid: 'Bitte geben Sie eine gültige E-Mail-Adresse an und stimmen Sie dem Hinweis zu.',
failed: 'Die Registrierung ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
},
pages: {
confirm: {
title: 'Registrierung bestätigen.',
body: 'Ein Klick, und Ihre Adresse ist für die Alpha von CipherVerifier registriert. Eine E-Mail, sobald sie erscheint. Sonst nichts.',
submit: 'Registrierung bestätigen',
},
confirmed: {
title: 'Registriert.',
body: 'Ihre Adresse ist bestätigt. Sie hören einmal von uns, sobald die Alpha von CipherVerifier erscheint.',
link: 'Zurück zu CipherVerifier',
},
error: {
title: 'Nicht registriert.',
body: 'Der Link ist abgelaufen oder unvollständig, oder die Registrierung ist nicht angekommen. Registrieren Sie sich erneut oder schreiben Sie an office@ciphercell.de.',
link: 'Erneut registrieren',
},
},
}, },
track: { track: {
title: 'Weg zum Release.', title: 'Weg zum Release.',
@@ -596,19 +654,29 @@ const de: typeof en = {
{ state: 'Später', name: 'Allgemeine Verfügbarkeit', body: 'Termin folgt.' }, { state: 'Später', name: 'Allgemeine Verfügbarkeit', body: 'Termin folgt.' },
], ],
}, },
spec: { verticals: {
title: 'Was entsteht.', title: 'Vertikalen.',
body: 'CipherVerifier nimmt ein Repository, lässt siebzehn statische Analysewerkzeuge und zwei Stücklisten-Generatoren darüber laufen, liest jeden Fund in seinem Kontext und ordnet, was echt ist, nach Sicherheitsrelevanz.', body: 'Darunter eine Pipeline. Jede Vertikale bringt das Wissen einer Domäne mit.',
rows: [ platform: 'Plattform CipherVerifier',
{ key: 'Statische Analyse', value: '17 Werkzeuge, jedes im eigenen Container' }, tiles: [
{ key: 'Sprachen', value: '14, dazu Docker, Kubernetes, Helm, Terraform und CloudFormation' }, {
{ key: 'Stückliste', value: '2 Generatoren, zu einem CycloneDX-Dokument zusammengeführt' }, state: 'Integriert',
{ key: 'Vokabular', value: '970 CWE-Einträge' }, name: 'Telekommunikations\u00ADsicherheit',
{ key: 'Pipeline', value: '5 Stufen, jede pro Repository abschaltbar' }, title: 'Aus der Open-RAN-Sicherheitsforschung.',
{ key: 'Urteil', value: 'Zweistufig, für jeden Fund' }, body: 'Die erste Vertikale kommt von dort, wo CipherCell begonnen hat: der Sicherheit von Open-RAN- und privaten 5G-Netzen, erarbeitet mit der Universität Passau und der xG-ALOE-Allianz.',
{ key: 'Betrieb', value: 'Selbst gehostet' }, rows: [
{ key: 'Netze', value: 'Open RAN, private 5G-Netze' },
{ key: 'Infrastruktur', value: 'Kritische Netzinfrastruktur' },
],
},
{
state: 'Offen',
name: 'Weitere',
title: 'Ihre Domäne als Nächstes.',
body: 'Die Pipeline darunter ist nicht an die Telekommunikation gebunden. Weitere Vertikalen entstehen dort, wo Betreiber uns eine Domäne bringen.',
cta: 'Vertikale vorschlagen',
},
], ],
figure: 'Abbildung 1. Ein Lauf, fünf Stufen.',
}, },
}, },
}, },
+45
View File
@@ -0,0 +1,45 @@
---
import Base from '../layouts/Base.astro';
import { localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang; page: 'confirm' | 'confirmed' | 'error' }
const { lang, page } = Astro.props;
const p = t(lang).verifier.soon.notify.pages;
const r = p[page];
const product = localePath(lang, '/cipherverifier');
---
<Base lang={lang} title={r.title} description={r.body} noindex>
<section class="section">
<div class="container grid">
<h1 class="display result__title">{r.title}</h1>
<div class="result__body">
<p class="muted">{r.body}</p>
{page === 'confirm' ? (
// The token travels in the link's query; the script copies it into the post. The
// button, not the link, confirms: mail scanners open links but do not press buttons.
<form action="/notify/verify" method="post" data-confirm>
<input type="hidden" name="lang" value={lang} />
<input type="hidden" name="t" value="" />
<button class="button" type="submit">{p.confirm.submit}</button>
</form>
) : (
<a class="link" href={page === 'error' ? `${product}#notify` : product}>{'link' in r ? r.link : ''}</a>
)}
</div>
</div>
</section>
</Base>
<script>
const form = document.querySelector<HTMLFormElement>('[data-confirm]');
const field = form?.querySelector<HTMLInputElement>('[name="t"]');
if (field) field.value = new URLSearchParams(location.search).get('t') ?? '';
</script>
<style>
.result__title { grid-column: 1 / -1; }
.result__body { grid-column: 1 / -1; display: grid; gap: var(--space-5); max-width: var(--measure); justify-items: start; }
@media (min-width: 48rem) {
.result__title { grid-column: 1 / span 8; }
.result__body { grid-column: 1 / span 5; }
}
</style>
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../../_NotifyPage.astro';
---
<NotifyPage lang="de" page="confirm" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../../_NotifyPage.astro';
---
<NotifyPage lang="de" page="confirmed" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../../_NotifyPage.astro';
---
<NotifyPage lang="de" page="error" />
+1 -1
View File
@@ -18,7 +18,7 @@ const ui = t('de');
<p>Wenn Sie uns per E-Mail oder auf anderem Wege kontaktieren, speichern wir Ihre übermittelten personenbezogenen Daten (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht), um Ihre Anfrage zu bearbeiten. Diese Daten werden ausschließlich für die Kommunikation mit Ihnen verwendet und nicht an Dritte weitergegeben. Rechtsgrundlage für die Verarbeitung der Daten ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Vertragsverhältnisses) oder Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Bearbeitung der Anfrage).</p> <p>Wenn Sie uns per E-Mail oder auf anderem Wege kontaktieren, speichern wir Ihre übermittelten personenbezogenen Daten (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht), um Ihre Anfrage zu bearbeiten. Diese Daten werden ausschließlich für die Kommunikation mit Ihnen verwendet und nicht an Dritte weitergegeben. Rechtsgrundlage für die Verarbeitung der Daten ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Vertragsverhältnisses) oder Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Bearbeitung der Anfrage).</p>
<h2>Benachrichtigung zur CipherVerifier-Alpha</h2> <h2>Benachrichtigung zur CipherVerifier-Alpha</h2>
<p>Wenn Sie sich für die Benachrichtigung zur Alpha von CipherVerifier eintragen, speichern wir Ihre E-Mail-Adresse und den Zeitpunkt Ihrer Einwilligung, um Ihnen mitzuteilen, wann die erste Alpha veröffentlicht ist. Wir nutzen sie für nichts anderes und geben sie nicht an Dritte weiter. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit per E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a> widerrufen; wir löschen Ihre Adresse dann. Nach dem Versand der Benachrichtigung löschen wir sie ebenfalls.</p> <p>Wenn Sie sich für die Benachrichtigung zur Alpha von CipherVerifier eintragen, speichern wir Ihre E-Mail-Adresse und den Zeitpunkt Ihrer Einwilligung, um Ihnen mitzuteilen, wann die erste Alpha veröffentlicht ist. Die Registrierung erfolgt im Double-Opt-in-Verfahren: Wir senden Ihnen zunächst eine E-Mail mit einem Bestätigungslink und speichern Ihre Adresse erst, wenn Sie bestätigt haben. Bestätigen Sie nicht innerhalb von 7 Tagen, verfällt der Link und es wird nichts gespeichert. Wir nutzen sie für nichts anderes und geben sie nicht an Dritte weiter. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit per E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a> widerrufen; wir löschen Ihre Adresse dann. Nach dem Versand der Benachrichtigung löschen wir sie ebenfalls.</p>
<h2>Eingebettete LinkedIn-Beiträge</h2> <h2>Eingebettete LinkedIn-Beiträge</h2>
<p>Einige Seiten unter Einblicke können einen Beitrag von LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Irland) anzeigen. Beim Aufruf der Seite wird der Beitrag nicht geladen. Erst wenn Sie auf die Schaltfläche zum Laden klicken, verbindet sich Ihr Browser mit den Servern von LinkedIn; diese erhalten Ihre IP-Adresse und die aufgerufene Seite und können Cookies setzen. Sind Sie bei LinkedIn angemeldet, kann LinkedIn den Besuch Ihrem Konto zuordnen. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO, die Sie mit diesem Klick erteilen. Zur Verarbeitung durch LinkedIn siehe dessen Datenschutzrichtlinie unter <a href="https://de.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p> <p>Einige Seiten unter Einblicke können einen Beitrag von LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Irland) anzeigen. Beim Aufruf der Seite wird der Beitrag nicht geladen. Erst wenn Sie auf die Schaltfläche zum Laden klicken, verbindet sich Ihr Browser mit den Servern von LinkedIn; diese erhalten Ihre IP-Adresse und die aufgerufene Seite und können Cookies setzen. Sind Sie bei LinkedIn angemeldet, kann LinkedIn den Besuch Ihrem Konto zuordnen. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO, die Sie mit diesem Klick erteilen. Zur Verarbeitung durch LinkedIn siehe dessen Datenschutzrichtlinie unter <a href="https://de.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p>
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../_NotifyPage.astro';
---
<NotifyPage lang="en" page="confirm" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../_NotifyPage.astro';
---
<NotifyPage lang="en" page="confirmed" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../_NotifyPage.astro';
---
<NotifyPage lang="en" page="error" />
+1 -1
View File
@@ -18,7 +18,7 @@ const ui = t('en');
<p>If you contact us by email or other means, we will store the personal data you provide (e.g. name, email address, content of the message) in order to process your request. This data will be used exclusively for communication with you and will not be passed on to third parties. The legal basis for processing the data is Art. 6 para. 1 lit. b GDPR (initiation of a contractual relationship) or Art. 6 para. 1 lit. f GDPR (legitimate interest in processing the request).</p> <p>If you contact us by email or other means, we will store the personal data you provide (e.g. name, email address, content of the message) in order to process your request. This data will be used exclusively for communication with you and will not be passed on to third parties. The legal basis for processing the data is Art. 6 para. 1 lit. b GDPR (initiation of a contractual relationship) or Art. 6 para. 1 lit. f GDPR (legitimate interest in processing the request).</p>
<h2>CipherVerifier alpha notification</h2> <h2>CipherVerifier alpha notification</h2>
<p>If you register for the CipherVerifier alpha notification, we store your email address and the time of your consent in order to tell you when the first alpha is released. We use it for nothing else and do not pass it on to third parties. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR. You can withdraw your consent at any time by email to <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>; we then delete your address. We also delete it once the notification has been sent.</p> <p>If you register for the CipherVerifier alpha notification, we store your email address and the time of your consent in order to tell you when the first alpha is released. Registration is double opt-in: we first send you an email with a link to confirm it, and only store your address once you have confirmed. If you do not confirm within 7 days, the link expires and nothing is kept. We use it for nothing else and do not pass it on to third parties. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR. You can withdraw your consent at any time by email to <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>; we then delete your address. We also delete it once the notification has been sent.</p>
<h2>Embedded LinkedIn posts</h2> <h2>Embedded LinkedIn posts</h2>
<p>Some pages in Insights can show a post from LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Ireland). The post is not loaded when you open the page. Only when you click the button to load it does your browser connect to LinkedIn's servers, which receive your IP address and the page you are on and may set cookies; if you are logged in to LinkedIn, it can link the visit to your account. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR, given by that click. For LinkedIn's processing, see its privacy policy at <a href="https://www.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p> <p>Some pages in Insights can show a post from LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Ireland). The post is not loaded when you open the page. Only when you click the button to load it does your browser connect to LinkedIn's servers, which receive your IP address and the page you are on and may set cookies; if you are logged in to LinkedIn, it can link the visit to your account. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR, given by that click. For LinkedIn's processing, see its privacy policy at <a href="https://www.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p>
+34
View File
@@ -0,0 +1,34 @@
/**
* Proof of work for the site's forms (ALTCHA). From the moment a visitor first focuses a form,
* a signed challenge is fetched from /contact/challenge and solved in the background; the send
* then carries the answer as the field `altcha`. One challenge per send: the server refuses a
* second use, so every send starts a new one.
*/
import { solveChallenge, type Challenge } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2';
async function solve(): Promise<string | null> {
try {
const res = await fetch('/contact/challenge', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const challenge: Challenge = await res.json();
const solution = await solveChallenge({ challenge, deriveKey });
return solution ? btoa(JSON.stringify({ challenge, solution })) : null;
} catch {
return null;
}
}
/** Starts solving on the form's first focus. `take()` waits for the answer and hands it over once. */
export function proofOfWork(form: HTMLFormElement) {
let proof: Promise<string | null> | null = null;
const prepare = () => (proof ??= solve());
form.addEventListener('focusin', prepare);
return {
async take(): Promise<string | null> {
const answer = await prepare();
proof = null;
return answer;
},
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Sends a form in place and answers in its toast (components/Toast.astro, found by the form's id).
* Every send carries the answer to a proof-of-work challenge (scripts/altcha.ts), worked out in
* the background from the moment the visitor first touches the form. The texts for the answer
* come from the form: data-sent, data-invalid, data-failed.
*/
import { proofOfWork } from './altcha';
type State = 'sending' | 'sent' | 'failed';
export function sendInPlace(form: HTMLFormElement) {
const toast = document.querySelector<HTMLElement>(`[data-toast="${form.id}"]`);
if (!toast) return;
const label = toast.querySelector<HTMLElement>('[data-toast-label]')!;
const message = toast.querySelector<HTMLElement>('[data-toast-message]')!;
const timer = toast.querySelector<HTMLElement>('[data-toast-timer]')!;
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
const pow = proofOfWork(form);
const show = (state: State, text: string) => {
toast.dataset.state = state;
toast.setAttribute('data-open', '');
// Restart the countdown for a second message in a row.
timer.style.animation = 'none';
void timer.offsetWidth;
timer.style.animation = '';
label.textContent = toast.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] ?? '';
// Set on the next frame, so the now visible live region announces it.
requestAnimationFrame(() => { message.textContent = text; });
};
const hide = () => {
if (toast.contains(document.activeElement)) submit.focus();
toast.removeAttribute('data-open');
};
timer.addEventListener('animationend', hide);
close.addEventListener('click', hide);
toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (form.getAttribute('aria-busy') === 'true') return;
form.setAttribute('aria-busy', 'true');
show('sending', '');
let outcome = 'failed';
const altcha = await pow.take();
if (altcha) {
const body = new URLSearchParams(new FormData(form) as unknown as Record<string, string>);
body.set('altcha', altcha);
try {
const res = await fetch(form.action, { method: 'POST', headers: { Accept: 'application/json' }, body });
outcome = (await res.json()).outcome ?? 'failed';
} catch {
outcome = 'failed';
}
}
if (outcome === 'sent') {
form.reset();
show('sent', form.dataset.sent ?? '');
} else {
show('failed', (outcome === 'invalid' ? form.dataset.invalid : form.dataset.failed) ?? '');
}
form.removeAttribute('aria-busy');
});
}