diff --git a/package.json b/package.json index eb2d91b..930731b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@hono/node-server": "2.1.5", "@libsql/client": "0.18.0", "@modelcontextprotocol/sdk": "1.32.1", + "@profullstack/footer": "^0.2.4", "@simplewebauthn/server": "^13.3.3", "hono": "4.13.13", "undici": "^7.30.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed4c953..4d044a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.32.1 version: 1.32.1(zod@4.6.7) + '@profullstack/footer': + specifier: ^0.2.4 + version: 0.2.4 '@simplewebauthn/server': specifier: ^13.3.3 version: 13.3.3 @@ -347,6 +350,16 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@profullstack/footer@0.2.4': + resolution: {integrity: sha512-2ErIzHJCDA2baP8aK6f7rCHohW4EX+wnp7D1mrVMQTUxRcyZwKzmho96hhEFJndXRBGgxT3H/78BHOeBfLxTzg==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + '@rolldown/binding-android-arm-eabi@1.2.13': resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1553,6 +1566,8 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@profullstack/footer@0.2.4': {} + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true diff --git a/src/web.ts b/src/web.ts index 886edbe..63bd5fa 100644 --- a/src/web.ts +++ b/src/web.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import type { Context, Hono } from 'hono'; import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { z } from 'zod'; +import { descriptorResponse, footerHtml, latestTemplate } from '@profullstack/footer'; import * as auth from './auth.js'; import { escapeHtml } from './adapters/tinylaunch.js'; import { loginMail, signupMail, type Mailer } from './mail.js'; @@ -81,6 +82,19 @@ function loadStatic(): Map { return files; } +export const SITE_URL = 'https://submitfleet.com/'; + +/** + * The shared Profullstack footer (links, copyright, << Profullstack >> webring), + * rendered on the server from the @latest template so the ring verifier, which + * reads HTML and runs no script, sees the ring links. Its CSS is served from + * /assets/pfs-footer.css (css: false here), so the footer does not depend on + * style-src allowing inline styles. + */ +export function siteFooter(links: { label: string; href: string }[]): Promise { + return footerHtml({ site: SITE_URL, links, css: false }); +} + function safeNext(raw: string | undefined): string { // Only same-site paths: never "//evil", never a scheme. return raw && /^\/(?!\/)[\w\-./?=&%#]*$/.test(raw) ? raw : '/app'; @@ -139,6 +153,7 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm app.get('/', async (c) => { const stats = await fleet.catalogStats(); const body = page('index.html') + .replace('{{FOOTER}}', await siteFooter([{ label: 'Docs', href: '/docs' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }, { label: 'Sign in', href: '/login' }])) .replaceAll('{{TOTAL}}', String(stats.total)) .replaceAll('{{FREE}}', String(stats.free)) .replaceAll('{{DIRECTORIES}}', String((stats.by_channel.directory ?? 0) + (stats.by_channel.citation ?? 0))) @@ -148,7 +163,10 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm }); for (const p of ['/signup', '/login']) app.get(p, (c) => html(c, page('auth.html'))); app.get('/auth/link', (c) => html(c, page('link.html'))); - app.get('/docs', (c) => html(c, page('docs.html'))); + app.get('/docs', async (c) => + html(c, page('docs.html').replace('{{FOOTER}}', await siteFooter([{ label: 'Home', href: '/' }, { label: 'GitHub', href: 'https://github.com/profullstack/submitfleet' }]))), + ); + app.get('/.well-known/openwebring.json', () => descriptorResponse({ site: SITE_URL, name: 'SubmitFleet' })); const appShell = async (c: Context) => { const user = await auth.sessionUser(fleet.db, getCookie(c, auth.SESSION_COOKIE)); if (!user) return c.redirect(`/login?next=${encodeURIComponent(new URL(c.req.url).pathname)}`); @@ -156,6 +174,13 @@ export function mountWeb(app: Hono<{ Variables: { actor: Actor } }>, fleet: Subm }; app.get('/app', appShell); app.get('/app/*', appShell); + // The footer template's CSS (@latest, same cache as the markup); before /assets/*. + app.get('/assets/pfs-footer.css', async (c) => + c.body((await latestTemplate()).css.replace(/@import[^;]*;?/gi, '').replace(/<\/?style/gi, ''), 200, { + 'content-type': 'text/css; charset=utf-8', + 'cache-control': 'public, max-age=300', + }), + ); app.get('/assets/*', (c) => { const f = files.get(c.req.path.replace(/^\//, '')); if (!f) return c.notFound(); diff --git a/test/web.test.ts b/test/web.test.ts index 0651672..fc12286 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -5,6 +5,9 @@ import { buildApp } from '../src/http.js'; import type { Mail } from '../src/mail.js'; import { harness, type Harness } from './helpers.js'; +// Render the footer from the bundled template: no CDN fetch in tests. +process.env.PROFULLSTACK_FOOTER_OFFLINE ??= '1'; + let h: Harness; let mails: Mail[]; let app: ReturnType; @@ -72,6 +75,25 @@ describe('pages', () => { }); }); +describe('webring footer', () => { + it('server-renders the Profullstack ring footer on the landing and docs pages, with its CSS from a route', async () => { + for (const p of ['/', '/docs']) { + const html = await (await req('GET', p)).text(); + expect(html, p).toContain('https://rssamplifier.com/ring/profullstack/next?from=https%3A%2F%2Fsubmitfleet.com%2F'); + expect(html, p).toContain('/assets/pfs-footer.css'); + expect(html.match(/©|©/g)?.length, p).toBe(1); + // No ad pressed against the footer. + expect(html.indexOf('cp-ad'), p).toBeLessThan(html.indexOf(' { it('signs up through the emailed link only, then signs in with the password', async () => { jar.clear(); diff --git a/web/docs.html b/web/docs.html index 5f750a3..e29439a 100644 --- a/web/docs.html +++ b/web/docs.html @@ -7,6 +7,7 @@ + @@ -68,7 +69,6 @@

Self-hosting

- - +{{FOOTER}} diff --git a/web/index.html b/web/index.html index a8ff287..49f8783 100644 --- a/web/index.html +++ b/web/index.html @@ -10,6 +10,7 @@ + @@ -102,12 +103,6 @@

What is in the catalog

- - +{{FOOTER}}