Compare commits
2
Commits
7508f6e084
...
efecc500c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efecc500c4 | ||
|
|
490444032c |
No files matched your search
@@ -5,3 +5,7 @@ SMTP_USER=website@ciphercell.de
|
||||
SMTP_PASS=
|
||||
CONTACT_FROM=website@ciphercell.de
|
||||
CONTACT_TO=office@ciphercell.de
|
||||
# Signing secrets for the form's proof-of-work challenge (ALTCHA). Optional: without them each
|
||||
# start makes its own. Generate with: openssl rand -hex 32
|
||||
ALTCHA_HMAC_SECRET=
|
||||
ALTCHA_HMAC_KEY_SECRET=
|
||||
@@ -61,13 +61,20 @@ Environment; see *Contact form* below.
|
||||
- **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`
|
||||
(Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as
|
||||
Reply-To. With scripting the form sends in place and shows the result under the button;
|
||||
without, the handler redirects to `/contact/sent` or `/contact/error` (under `/de` for German).
|
||||
Both pages are `noindex` and left out of the sitemap. The handler caps size and field lengths,
|
||||
refuses cross-site posts, and allows 5 messages per address per 10 minutes and 60 per hour
|
||||
overall.
|
||||
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
|
||||
`noindex` and left out of the sitemap. The handler caps size and field lengths, refuses
|
||||
cross-site posts, and allows 5 messages per address per 10 minutes and 60 per hour overall.
|
||||
|
||||
In the container, nginx proxies `/contact` to the handler on `127.0.0.1:3000`; both are started
|
||||
Against spam, every message carries the answer to a proof-of-work challenge (ALTCHA,
|
||||
`altcha-lib`). The form fetches a signed challenge from `GET /contact/challenge` when the
|
||||
visitor first focuses it and solves it in the background (about half a second on a laptop, one
|
||||
to two on a phone). The handler refuses a post without a valid, unexpired, unused solution, or
|
||||
one sent less than 3 seconds after the challenge was issued. Nothing is stored in the browser
|
||||
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.
|
||||
|
||||
In the container, nginx proxies `/contact` and `/contact/challenge` 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,
|
||||
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.
|
||||
@@ -81,10 +88,11 @@ Environment; see *Contact form* below.
|
||||
| `CONTACT_TO` | `office@ciphercell.de` | Where messages go. |
|
||||
| `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). |
|
||||
| `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. |
|
||||
| `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
|
||||
`.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies `/contact`
|
||||
to it.
|
||||
`.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies `/contact` and
|
||||
`/contact/challenge` to it.
|
||||
- **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`,
|
||||
`list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is
|
||||
|
||||
+1
-1
@@ -13,5 +13,5 @@ export default defineConfig({
|
||||
},
|
||||
build: { inlineStylesheets: 'auto' },
|
||||
// In development the form posts to the contact handler started with `npm run contact`.
|
||||
vite: { server: { proxy: { '^/contact$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } },
|
||||
vite: { server: { proxy: { '^/contact(/challenge)?$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } },
|
||||
});
|
||||
@@ -43,6 +43,14 @@ server {
|
||||
proxy_intercept_errors on;
|
||||
error_page 502 503 504 = @contact_down;
|
||||
}
|
||||
# The proof-of-work challenge the form solves before it sends; issued by the same handler.
|
||||
location = /contact/challenge {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 10s;
|
||||
}
|
||||
# Handler not running: send the visitor to the error page in their language.
|
||||
location @contact_down {
|
||||
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; }
|
||||
|
||||
Generated
+119
-17
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter-tight": "^5.3.0",
|
||||
"altcha-lib": "^2.6.0",
|
||||
"astro": "^7.3.3",
|
||||
"sharp": "^0.35.4"
|
||||
},
|
||||
@@ -699,6 +700,7 @@
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
@@ -710,6 +712,7 @@
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -2095,6 +2098,7 @@
|
||||
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -2232,6 +2236,7 @@
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -2268,6 +2273,75 @@
|
||||
"ajv": "^8.0.0-beta.0"
|
||||
}
|
||||
},
|
||||
"node_modules/altcha-lib": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.6.0.tgz",
|
||||
"integrity": "sha512-mBp/KxMZmQkckWX5iM0gHqCm0qTLGhF9r8hhFjNrBpzP0qHZBadR9oafxVx9cTLHl4h2+f56N4hRIrZzvonYLQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"altcha-lib": "bin/cli.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/formbody": "^8.0.0 || ^9.0.0",
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"cookie-parser": "^1.0.0",
|
||||
"cors": "^2.0.0",
|
||||
"express": "^4.0.0 || ^5.0.0",
|
||||
"fastify": "^5.0.0",
|
||||
"h3": "^2.0.0",
|
||||
"hono": "^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@fastify/cookie": {
|
||||
"optional": true
|
||||
},
|
||||
"@fastify/cors": {
|
||||
"optional": true
|
||||
},
|
||||
"@fastify/formbody": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/common": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/platform-express": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/platform-fastify": {
|
||||
"optional": true
|
||||
},
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"cookie-parser": {
|
||||
"optional": true
|
||||
},
|
||||
"cors": {
|
||||
"optional": true
|
||||
},
|
||||
"express": {
|
||||
"optional": true
|
||||
},
|
||||
"fastify": {
|
||||
"optional": true
|
||||
},
|
||||
"h3": {
|
||||
"optional": true
|
||||
},
|
||||
"hono": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/am-i-vibing": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz",
|
||||
@@ -2864,6 +2938,7 @@
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -3083,23 +3158,6 @@
|
||||
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/h3": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
|
||||
"integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-es": "^1.2.3",
|
||||
"crossws": "^0.3.5",
|
||||
"defu": "^6.1.6",
|
||||
"destr": "^2.0.5",
|
||||
"iron-webcrypto": "^1.2.1",
|
||||
"node-mock-http": "^1.0.4",
|
||||
"radix3": "^1.1.2",
|
||||
"ufo": "^1.6.3",
|
||||
"uncrypto": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-html": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
|
||||
@@ -3912,6 +3970,7 @@
|
||||
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -4067,6 +4126,16 @@
|
||||
"@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": {
|
||||
"version": "0.10.5",
|
||||
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
|
||||
@@ -4236,6 +4305,19 @@
|
||||
"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": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
@@ -4388,6 +4470,7 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -4634,6 +4717,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unstorage/node_modules/h3": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
|
||||
"integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-es": "^1.2.3",
|
||||
"crossws": "^0.3.5",
|
||||
"defu": "^6.1.6",
|
||||
"destr": "^2.0.5",
|
||||
"iron-webcrypto": "^1.2.1",
|
||||
"node-mock-http": "^1.0.4",
|
||||
"radix3": "^1.1.2",
|
||||
"ufo": "^1.6.3",
|
||||
"uncrypto": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/verkit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.0.tgz",
|
||||
@@ -4679,6 +4779,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
@@ -5111,6 +5212,7 @@
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter-tight": "^5.3.0",
|
||||
"altcha-lib": "^2.6.0",
|
||||
"astro": "^7.3.3",
|
||||
"sharp": "^0.35.4"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#E9E5DF"/><text x="16" y="22" text-anchor="middle" font-family="Inter Tight, Inter, system-ui, sans-serif" font-weight="600" font-size="19" letter-spacing="-1" fill="#1A1A18">CC</text></svg>
|
||||
|
Before Width: | Height: | Size: 286 B |
+82
-4
@@ -1,14 +1,20 @@
|
||||
/**
|
||||
* The contact form handler. nginx proxies POST /contact here; everything else on the site stays static.
|
||||
*
|
||||
* The form posts first_name, last_name, email, message, lang and the honeypot website. A browser
|
||||
* with scripting asks for JSON and shows the result in place; without scripting the answer is a
|
||||
* redirect to /contact/sent or /contact/error (under /de for German).
|
||||
* 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
|
||||
* 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).
|
||||
* A post without a solved challenge is refused, so the form needs scripting; the address next
|
||||
* to it does not.
|
||||
*
|
||||
* Configuration comes from the environment only, see README.md.
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { createChallenge, randomInt, verifySolution } from 'altcha-lib';
|
||||
import { deriveKey } from 'altcha-lib/algorithms/pbkdf2';
|
||||
|
||||
const env = process.env;
|
||||
const PORT = Number(env.CONTACT_PORT || 3000);
|
||||
@@ -22,6 +28,11 @@ if (!env.SMTP_HOST || !FROM) {
|
||||
process.exit(78); // EX_CONFIG: the entrypoint does not restart on this one.
|
||||
}
|
||||
|
||||
/* Challenge secrets. Without them set, each start makes its own; challenges then only outlive a
|
||||
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_KEY_SECRET = env.ALTCHA_HMAC_KEY_SECRET || randomBytes(32).toString('hex');
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
@@ -38,6 +49,64 @@ const MAX = { first_name: 100, last_name: 100, email: 254, message: 5000 };
|
||||
const PER_IP = { count: 5, windowMs: 10 * 60 * 1000 };
|
||||
const GLOBAL = { count: 60, windowMs: 60 * 60 * 1000 };
|
||||
|
||||
/* Proof of work. About half a second of a laptop's time and one to two of a phone's, spent while
|
||||
the visitor is still typing; a script pays it for every message. The challenge is signed, so
|
||||
the server keeps nothing until a solution comes back. */
|
||||
const POW = { algorithm: 'PBKDF2/SHA-256', cost: 1000, counterMin: 2000, counterMax: 5000 };
|
||||
const CHALLENGE_TTL_MS = 30 * 60 * 1000;
|
||||
// Nobody writes a message in three seconds from first touching the form; a script does.
|
||||
const MIN_FILL_MS = 3000;
|
||||
// Solved challenges, kept until they expire, so one solution sends one message.
|
||||
const spent = new Map();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [sig, expires] of spent) if (expires < now) spent.delete(sig);
|
||||
}, 60 * 1000).unref();
|
||||
|
||||
function issueChallenge() {
|
||||
const now = Date.now();
|
||||
return createChallenge({
|
||||
algorithm: POW.algorithm,
|
||||
cost: POW.cost,
|
||||
counter: randomInt(POW.counterMin, POW.counterMax),
|
||||
deriveKey,
|
||||
data: { issued: now },
|
||||
expiresAt: new Date(now + CHALLENGE_TTL_MS),
|
||||
hmacSignatureSecret: ALTCHA_SECRET,
|
||||
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
|
||||
});
|
||||
}
|
||||
|
||||
/** The posted altcha field: base64 JSON of { challenge, solution }. */
|
||||
async function solved(field, now = Date.now()) {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(field || '', 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const { challenge, solution } = payload || {};
|
||||
if (!challenge?.signature || spent.has(challenge.signature)) return false;
|
||||
let result;
|
||||
try {
|
||||
result = await verifySolution({
|
||||
challenge,
|
||||
solution,
|
||||
deriveKey,
|
||||
hmacSignatureSecret: ALTCHA_SECRET,
|
||||
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!result.verified) return false;
|
||||
// Signed with the challenge, so it is the time the server issued it.
|
||||
const issued = Number(challenge.parameters.data?.issued);
|
||||
if (!(now - issued >= MIN_FILL_MS)) return false;
|
||||
spent.set(challenge.signature, challenge.parameters.expiresAt * 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
const hits = new Map();
|
||||
const globalHits = [];
|
||||
function limited(ip, now = Date.now()) {
|
||||
@@ -85,7 +154,7 @@ function readBody(req) {
|
||||
}
|
||||
|
||||
function reply(req, res, lang, outcome) {
|
||||
const status = { sent: 200, invalid: 400, limited: 429, failed: 502 }[outcome];
|
||||
const status = { sent: 200, invalid: 400, limited: 429, unverified: 403, failed: 502 }[outcome];
|
||||
if ((req.headers.accept || '').includes('application/json')) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ ok: outcome === 'sent', outcome }));
|
||||
@@ -99,6 +168,11 @@ function reply(req, res, lang, outcome) {
|
||||
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;
|
||||
}
|
||||
if (req.method !== 'POST' || path !== '/contact') { res.writeHead(404).end(); return; }
|
||||
|
||||
// A cross-site post is not a visitor using the form.
|
||||
@@ -121,6 +195,10 @@ const server = createServer(async (req, res) => {
|
||||
const data = validate(form);
|
||||
if (!data) { reply(req, res, lang, 'invalid'); return; }
|
||||
|
||||
// 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.
|
||||
if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified'); 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; }
|
||||
|
||||
Generated
+70
@@ -6,12 +6,82 @@
|
||||
"": {
|
||||
"name": "ciphercell-contact",
|
||||
"dependencies": {
|
||||
"altcha-lib": "^2.6.0",
|
||||
"nodemailer": "^7.0.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/altcha-lib": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.6.0.tgz",
|
||||
"integrity": "sha512-mBp/KxMZmQkckWX5iM0gHqCm0qTLGhF9r8hhFjNrBpzP0qHZBadR9oafxVx9cTLHl4h2+f56N4hRIrZzvonYLQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"altcha-lib": "bin/cli.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/formbody": "^8.0.0 || ^9.0.0",
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"cookie-parser": "^1.0.0",
|
||||
"cors": "^2.0.0",
|
||||
"express": "^4.0.0 || ^5.0.0",
|
||||
"fastify": "^5.0.0",
|
||||
"h3": "^2.0.0",
|
||||
"hono": "^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@fastify/cookie": {
|
||||
"optional": true
|
||||
},
|
||||
"@fastify/cors": {
|
||||
"optional": true
|
||||
},
|
||||
"@fastify/formbody": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/common": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/platform-express": {
|
||||
"optional": true
|
||||
},
|
||||
"@nestjs/platform-fastify": {
|
||||
"optional": true
|
||||
},
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"cookie-parser": {
|
||||
"optional": true
|
||||
},
|
||||
"cors": {
|
||||
"optional": true
|
||||
},
|
||||
"express": {
|
||||
"optional": true
|
||||
},
|
||||
"fastify": {
|
||||
"optional": true
|
||||
},
|
||||
"h3": {
|
||||
"optional": true
|
||||
},
|
||||
"hono": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"altcha-lib": "^2.6.0",
|
||||
"nodemailer": "^7.0.13"
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
|
||||
pointer-events: none;
|
||||
}
|
||||
.header__shell {
|
||||
padding-block: var(--space-3);
|
||||
padding-block: calc(var(--space-3) + var(--safe-top)) var(--space-3);
|
||||
transition:
|
||||
max-width var(--dur-slow) var(--ease),
|
||||
padding var(--dur-slow) var(--ease);
|
||||
@@ -68,8 +68,11 @@ const isCurrent = (href: string) => href.split('#')[0] === path && !href.include
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
/* Docked, the glass also fills the status-bar strip, so scrolled content never shows behind the
|
||||
clock; the bar's contents stay below it. */
|
||||
.header[data-scrolled] .header__bar {
|
||||
min-height: var(--header-height);
|
||||
min-height: var(--header-offset);
|
||||
padding-top: var(--safe-top);
|
||||
border-width: 0 0 var(--line);
|
||||
}
|
||||
.header[data-scrolled] .header__bar::after { content: none; }
|
||||
|
||||
@@ -51,6 +51,7 @@ const f = ui.contact.form;
|
||||
<button class="button" type="submit">{f.submit}</button>
|
||||
<p class="small muted"><span aria-hidden="true">* </span>{f.required}</p>
|
||||
</div>
|
||||
<noscript><p class="field--full small">{f.noscript}</p></noscript>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -227,8 +228,13 @@ const f = ui.contact.form;
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// With scripting the form sends in place and answers in a toast. Without it, the server
|
||||
// redirects to /contact/sent or /contact/error.
|
||||
// The form sends in place and answers in a toast. Every send carries the answer to a
|
||||
// proof-of-work challenge (ALTCHA), worked out in the background from the moment the visitor
|
||||
// first touches the form. Without scripting the server refuses the post and redirects to
|
||||
// /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 toast = document.querySelector<HTMLElement>('[data-toast]');
|
||||
if (form && toast) {
|
||||
@@ -238,6 +244,22 @@ const f = ui.contact.form;
|
||||
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', '');
|
||||
@@ -264,15 +286,21 @@ const f = ui.contact.form;
|
||||
form.setAttribute('aria-busy', 'true');
|
||||
show('sending', '');
|
||||
let outcome = 'failed';
|
||||
try {
|
||||
const res = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: new URLSearchParams(new FormData(form) as unknown as Record<string, string>),
|
||||
});
|
||||
outcome = (await res.json()).outcome ?? 'failed';
|
||||
} catch {
|
||||
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();
|
||||
|
||||
@@ -48,7 +48,7 @@ const ui = t(lang);
|
||||
align-items: flex-end;
|
||||
/* Runs up under the floating header so the glass bar sits on the image, fills the screen,
|
||||
and is never less than the headline needs. */
|
||||
margin-top: calc(-1 * var(--header-height));
|
||||
margin-top: calc(-1 * var(--header-offset));
|
||||
min-height: max(28rem, 100svh);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const objects = ['sphere', 'cube', 'plane', 'wave'] as const;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-6) var(--space-6) var(--space-7);
|
||||
background: var(--paper-deep);
|
||||
scroll-margin-top: calc(var(--header-height) + var(--space-5));
|
||||
scroll-margin-top: calc(var(--header-offset) + var(--space-5));
|
||||
}
|
||||
.stages__title {
|
||||
font-size: var(--step-title);
|
||||
|
||||
@@ -216,7 +216,7 @@ const fieldStyle = (paper: (typeof papers)[number]) =>
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.research__list { margin-top: var(--space-6); scroll-margin-top: calc(var(--header-height) + var(--space-5)); }
|
||||
.research__list { margin-top: var(--space-6); scroll-margin-top: calc(var(--header-offset) + var(--space-5)); }
|
||||
.research__item[hidden] { display: none; }
|
||||
|
||||
.paper__meta {
|
||||
|
||||
@@ -98,7 +98,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
|
||||
.shero {
|
||||
position: relative;
|
||||
/* Runs up under the floating header, as the home hero does, so the bar takes the night tone. */
|
||||
margin-top: calc(-1 * var(--header-height));
|
||||
margin-top: calc(-1 * var(--header-offset));
|
||||
overflow: hidden;
|
||||
}
|
||||
.shero__media { position: absolute; inset: 0; animation: shero-settle var(--dur-hero) var(--ease) both; transform-origin: 60% 50%; }
|
||||
@@ -121,7 +121,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: var(--space-8);
|
||||
padding-top: calc(var(--header-height) + var(--space-7));
|
||||
padding-top: calc(var(--header-offset) + var(--space-7));
|
||||
min-height: 100svh;
|
||||
align-content: end;
|
||||
}
|
||||
|
||||
+6
-4
@@ -143,7 +143,7 @@ const en = {
|
||||
},
|
||||
contact: {
|
||||
title: 'Talk to us.',
|
||||
body: 'Complex software, a tight compliance deadline, or a problem no one has solved yet. We respond within 24 hours.',
|
||||
body: 'Complex software, a tight deadline, or a problem no one has solved yet. We respond within 24 hours.',
|
||||
address: ['Innstr. 43', '94032 Passau, Germany'],
|
||||
phone: '+49 (0)851 / 509-3218',
|
||||
phoneHref: 'tel:+498515093218',
|
||||
@@ -159,6 +159,7 @@ const en = {
|
||||
sent: 'Message sent. We respond within 24 hours.',
|
||||
invalid: 'Please fill in every field and give a valid email address.',
|
||||
failed: 'Your message did not go through. Please try again, or write to office@ciphercell.de.',
|
||||
noscript: 'The form needs JavaScript. Without it, write to office@ciphercell.de.',
|
||||
toast: { sending: 'Sending', sent: 'Sent', failed: 'Not sent', close: 'Close' },
|
||||
},
|
||||
result: {
|
||||
@@ -309,7 +310,7 @@ const en = {
|
||||
},
|
||||
research: {
|
||||
title: ['Published', 'research.'],
|
||||
intro: 'Papers by our team on Open RAN, cloud-native infrastructure and hardware security. The method we sell grew out of this work.',
|
||||
intro: 'Expert perspectives on Open RAN security, cloud-native infrastructure, and hardware security, grounded in real-world engineering and backed by research.',
|
||||
sortLabel: 'Sort',
|
||||
sort: { cites: 'Most cited', newest: 'Newest', oldest: 'Oldest' },
|
||||
filterLabel: 'Category',
|
||||
@@ -458,7 +459,7 @@ const de: typeof en = {
|
||||
},
|
||||
contact: {
|
||||
title: 'Sprechen Sie mit uns.',
|
||||
body: 'Komplexe Software, eine knappe Compliance-Frist oder ein Problem, das noch niemand gelöst hat. Wir antworten innerhalb von 24 Stunden.',
|
||||
body: 'Komplexe Software, eine knappe Frist oder ein Problem, das noch niemand gelöst hat. Wir antworten innerhalb von 24 Stunden.',
|
||||
address: ['Innstr. 43', '94032 Passau, Deutschland'],
|
||||
phone: '+49 (0)851 / 509-3218',
|
||||
phoneHref: 'tel:+498515093218',
|
||||
@@ -474,6 +475,7 @@ const de: typeof en = {
|
||||
sent: 'Nachricht gesendet. Wir antworten innerhalb von 24 Stunden.',
|
||||
invalid: 'Bitte füllen Sie alle Felder aus und geben Sie eine gültige E-Mail-Adresse an.',
|
||||
failed: 'Ihre Nachricht ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
|
||||
noscript: 'Das Formular braucht JavaScript. Ohne schreiben Sie an office@ciphercell.de.',
|
||||
toast: { sending: 'Wird gesendet', sent: 'Gesendet', failed: 'Nicht gesendet', close: 'Schließen' },
|
||||
},
|
||||
result: {
|
||||
@@ -624,7 +626,7 @@ const de: typeof en = {
|
||||
},
|
||||
research: {
|
||||
title: ['Veröffentlichte', 'Forschung.'],
|
||||
intro: 'Arbeiten unseres Teams zu Open RAN, cloud-nativer Infrastruktur und Hardware-Sicherheit. Die Methode, die wir anbieten, ist aus dieser Arbeit entstanden.',
|
||||
intro: 'Experteneinblicke in Open-RAN-Sicherheit, Cloud-native Infrastruktur und Hardware-Sicherheit, basierend auf praxiserprobter Ingenieursarbeit und wissenschaftlich fundiert.',
|
||||
sortLabel: 'Sortieren',
|
||||
sort: { cites: 'Meistzitiert', newest: 'Neueste', oldest: 'Älteste' },
|
||||
filterLabel: 'Kategorie',
|
||||
|
||||
@@ -28,17 +28,20 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
{noindex && <meta name="robots" content="noindex" />}
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#E9E5DF" />
|
||||
<!-- Tints the browser chrome (the iOS status bar) to match the surface the page opens on:
|
||||
--night for a dark hero, --paper otherwise. -->
|
||||
<meta name="theme-color" content={tone === 'night' ? '#0E100F' : '#E9E5DF'} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
<link rel="alternate" hreflang="en" href={altEn} />
|
||||
<link rel="alternate" hreflang="de" href={altDe} />
|
||||
<link rel="alternate" hreflang="x-default" href={altEn} />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="preload" href="/fonts/inter-tight-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content={ui.meta.siteName} />
|
||||
|
||||
+3
-2
@@ -43,7 +43,7 @@ html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
[id] { scroll-margin-top: var(--header-height); }
|
||||
[id] { scroll-margin-top: var(--header-offset); }
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background: var(--paper);
|
||||
@@ -146,7 +146,8 @@ p { overflow-wrap: break-word; }
|
||||
width: 100%;
|
||||
max-width: var(--container);
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--gutter);
|
||||
padding-left: max(var(--gutter), var(--safe-left));
|
||||
padding-right: max(var(--gutter), var(--safe-right));
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
|
||||
@@ -137,6 +137,13 @@
|
||||
|
||||
/* Chrome */
|
||||
--header-height: 4.5rem;
|
||||
/* With viewport-fit=cover the page runs under the iOS status bar and notch; these are the
|
||||
strips the system keeps for itself (zero everywhere else). */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
/* What a full-bleed hero pulls up under: the header plus the status bar above it. */
|
||||
--header-offset: calc(var(--header-height) + var(--safe-top));
|
||||
|
||||
/* HUD frame (menu bar): one line width and colour for every line, borders and crosses alike. */
|
||||
--hud-line-width: 1px;
|
||||
|
||||
Reference in new issue
Block a user