Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 26 additions & 1 deletion src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -81,6 +82,19 @@ function loadStatic(): Map<string, { body: Buffer; type: string }> {
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<string> {
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';
Expand Down Expand Up @@ -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)))
Expand All @@ -148,14 +163,24 @@ 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)}`);
return html(c, page('app.html'));
};
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();
Expand Down
22 changes: 22 additions & 0 deletions test/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof buildApp>;
Expand Down Expand Up @@ -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(/&copy;|©/g)?.length, p).toBe(1);
// No ad pressed against the footer.
expect(html.indexOf('cp-ad'), p).toBeLessThan(html.indexOf('<main'));
}
const css = await req('GET', '/assets/pfs-footer.css');
expect(css.status).toBe(200);
expect(css.headers.get('content-type')).toContain('text/css');
expect(await css.text()).toContain('.pfs-footer');
const ring = await (await req('GET', '/.well-known/openwebring.json')).json();
expect(ring.rings[0].ring).toContain('/ring/profullstack');
});
});

describe('accounts', () => {
it('signs up through the emailed link only, then signs in with the password', async () => {
jar.clear();
Expand Down
4 changes: 2 additions & 2 deletions web/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<meta name="description" content="SubmitFleet CLI, MCP server and HTTP API.">
<link rel="icon" href="/assets/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/assets/site.css">
<link rel="stylesheet" href="/assets/pfs-footer.css">
<script data-site="687dc08f-0b95-44ee-9774-b4d86cd4f932" src="https://crawlproof.com/stats.js" async></script>
</head>
<body>
Expand Down Expand Up @@ -68,7 +69,6 @@ <h2>Self-hosting</h2>
</section>
</main>


<footer class="site"><div class="wrap"><span>© 2026 Profullstack, Inc.</span><span><a href="/">Home</a><a href="https://github.com/profullstack/submitfleet">GitHub</a></span></div></footer>
{{FOOTER}}
</body>
</html>
9 changes: 2 additions & 7 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<meta property="og:type" content="website">
<link rel="icon" href="/assets/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/assets/site.css">
<link rel="stylesheet" href="/assets/pfs-footer.css">
<script data-site="687dc08f-0b95-44ee-9774-b4d86cd4f932" src="https://crawlproof.com/stats.js" async></script>
</head>
<body>
Expand Down Expand Up @@ -102,12 +103,6 @@ <h2>What is in the catalog</h2>
</section>
</main>


<footer class="site">
<div class="wrap">
<span>© 2026 Profullstack, Inc.</span>
<span><a href="/docs">Docs</a><a href="https://github.com/profullstack/submitfleet">GitHub</a><a href="/login">Sign in</a></span>
</div>
</footer>
{{FOOTER}}
</body>
</html>
Loading