Skip to content

Commit 08a1cf4

Browse files
ralyodioclaude
andauthored
footer 0.4.0: inline newsletter feedback; nonce + pit.world in CSP (#14)
Bumps @profullstack/footer to ^0.4.0 (lockfile 0.4.0). 0.4.0 posts the footer newsletter form as JSON to https://pit.world/api/subscribe from a small inline script and shows "Email confirmation sent" or "Failed: <why>" inline. CSP changes so that actually runs here: - script-src had no 'unsafe-inline', so / and /docs now mint a per-response nonce, add 'nonce-…' to script-src and pass it to footerHtml({ nonce }). - connect-src adds https://pit.world (the JSON POST). - form-action adds https://pit.world (the no-script fallback POST, which 'self' silently dropped before). tsc --noEmit and test/web.test.ts pass locally. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8f4fd0c commit 08a1cf4

3 files changed

Lines changed: 31 additions & 16 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"@hono/node-server": "2.1.5",
5252
"@libsql/client": "0.18.0",
5353
"@modelcontextprotocol/sdk": "1.32.2",
54-
"@profullstack/footer": "^0.2.6",
54+
"@profullstack/footer": "^0.4.0",
5555
"@profullstack/profile": "0.1.6",
5656
"@profullstack/x402-gateway": "^0.13.1",
5757
"@simplewebauthn/server": "^14.0.3",

‎pnpm-lock.yaml‎

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/web.ts‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server';
2+
import { randomBytes } from 'node:crypto';
23
import { readdirSync, readFileSync } from 'node:fs';
34
import { dirname, extname, join } from 'node:path';
45
import { fileURLToPath } from 'node:url';
@@ -28,8 +29,20 @@ const TYPES: Record<string, string> = {
2829
'.html': 'text/html; charset=utf-8',
2930
};
3031

32+
// pit.world: the footer's newsletter form POSTs to pit.world/api/subscribe (connect-src),
33+
// falling back to a plain form POST to pit.world/subscribe without script (form-action).
3134
export const PAGE_CSP =
32-
"default-src 'self'; script-src 'self' https://crawlproof.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://crawlproof.com; font-src 'self'; frame-src https://crawlproof.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self' http://127.0.0.1:* http://localhost:*";
35+
"default-src 'self'; script-src 'self' https://crawlproof.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://crawlproof.com https://pit.world; font-src 'self'; frame-src https://crawlproof.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self' https://pit.world http://127.0.0.1:* http://localhost:*";
36+
37+
/** PAGE_CSP plus a per-response nonce in script-src, for the footer's inline subscribe script. */
38+
export function pageCsp(nonce?: string): string {
39+
return nonce ? PAGE_CSP.replace("script-src 'self'", `script-src 'self' 'nonce-${nonce}'`) : PAGE_CSP;
40+
}
41+
42+
/** A fresh CSP nonce for one response. */
43+
export function newNonce(): string {
44+
return randomBytes(16).toString('base64');
45+
}
3346

3447
export interface WebOptions {
3548
mailer: Mailer;
@@ -92,8 +105,8 @@ export const SITE_URL = 'https://submitfleet.com/';
92105
* /assets/pfs-footer.css (css: false here), so the footer does not depend on
93106
* style-src allowing inline styles.
94107
*/
95-
export function siteFooter(links: { label: string; href: string }[]): Promise<string> {
96-
return footerHtml({ site: SITE_URL, links, css: false });
108+
export function siteFooter(links: { label: string; href: string }[], nonce?: string): Promise<string> {
109+
return footerHtml({ site: SITE_URL, links, css: false, nonce });
97110
}
98111

99112
function safeNext(raw: string | undefined): string {
@@ -109,8 +122,8 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm
109122
const o = origin(c);
110123
return { origin: o, rpID: new URL(o).hostname, rpName: 'SubmitFleet' };
111124
};
112-
const html = async (c: Context, body: string, status = 200) => {
113-
c.header('content-security-policy', PAGE_CSP);
125+
const html = async (c: Context, body: string, status = 200, nonce?: string) => {
126+
c.header('content-security-policy', pageCsp(nonce));
114127
c.header('cache-control', 'no-store');
115128
return c.html(await withPitFooter(body), status as 200);
116129
};
@@ -153,23 +166,25 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm
153166
// ── Pages ──
154167
app.get('/', async (c) => {
155168
const stats = await fleet.catalogStats();
169+
const nonce = newNonce();
156170
const body = page('index.html')
157-
.replace('{{FOOTER}}', await siteFooter([{ label: 'Docs', href: '/docs' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }, { label: 'Sign in', href: '/login' }]))
171+
.replace('{{FOOTER}}', await siteFooter([{ label: 'Docs', href: '/docs' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }, { label: 'Sign in', href: '/login' }], nonce))
158172
.replaceAll('{{TOTAL}}', String(stats.total))
159173
.replaceAll('{{FREE}}', String(stats.free))
160174
.replaceAll('{{DIRECTORIES}}', String((stats.by_channel.directory ?? 0) + (stats.by_channel.citation ?? 0)))
161175
.replaceAll('{{LAUNCH}}', String(stats.by_channel['product-launch-site'] ?? 0))
162176
.replaceAll('{{AUTOMATED}}', String(stats.automated));
163-
return html(c, body);
177+
return html(c, body, 200, nonce);
164178
});
165179
// Sign-up asks for the profile fields (the form has its own email box); sign-in never does.
166180
const signupFields = renderProfileFields({ fields: ['first_name', 'last_name', 'company_domain', 'job_title', 'linkedin_url'] });
167181
app.get('/signup', (c) => html(c, page('auth.html').replace('{{PROFILE_FIELDS}}', signupFields)));
168182
app.get('/login', (c) => html(c, page('auth.html').replace('{{PROFILE_FIELDS}}', '')));
169183
app.get('/auth/link', (c) => html(c, page('link.html')));
170-
app.get('/docs', async (c) =>
171-
html(c, page('docs.html').replace('{{FOOTER}}', await siteFooter([{ label: 'Home', href: '/' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }]))),
172-
);
184+
app.get('/docs', async (c) => {
185+
const nonce = newNonce();
186+
return html(c, page('docs.html').replace('{{FOOTER}}', await siteFooter([{ label: 'Home', href: '/' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }], nonce)), 200, nonce);
187+
});
173188
app.get('/.well-known/openwebring.json', () => descriptorResponse({ site: SITE_URL, name: 'SubmitFleet' }));
174189
const appShell = async (c: Context) => {
175190
const user = await auth.sessionUser(fleet.db, getCookie(c, auth.SESSION_COOKIE));

0 commit comments

Comments
 (0)