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
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,26 @@ on:
branches: [main]
pull_request:

# The token gets read access and nothing else. Nothing here pushes, comments
# or publishes, so nothing here should be able to.
permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Actions are pinned to a commit, not a tag. A tag can be moved to point
# at anything, and moving one is exactly how tj-actions/changed-files was
# turned against every workflow that referenced it by tag in March 2025.
# The comment after each pin is the tag it was taken from.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

# No `version:` here — package.json's `packageManager` field already
# pins pnpm, and specifying both makes the action refuse to run.
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
# The board runs .ts unbuilt through type stripping, which needs 24.
node-version: 24
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { pwaRoutes } from './routes/pwa.ts';
import { apiRoutes } from './routes/api.ts';
import { boardRoutes } from './routes/board.ts';
import { discoverRoutes } from './routes/discover.ts';
import { discoveryRoutes } from './routes/discovery.ts';
import { docsRoutes } from './routes/docs.ts';
import { mcpRoutes } from './routes/mcp.ts';
import { userRoutes } from './routes/user.ts';
Expand Down Expand Up @@ -81,6 +82,29 @@ export function createApp(registry: Registry, baseUrl: string): Hono<AppEnv> {
await next();
});

/*
* One canonical path, too.
*
* Every route here is written without a trailing slash, and a request for
* `/docs/` used to fall through to the 404 page. Crawlers try both forms
* constantly, and a link checker reports each one as a broken link on the
* page it came from. A permanent redirect keeps the slash-less form the only
* one that ever gets indexed. GET and HEAD only: a form posted to the wrong
* path should fail loudly rather than be silently re-addressed.
*/
app.use('*', async (c, next) => {
const url = new URL(c.req.url);
if (
url.pathname.length > 1 &&
url.pathname.endsWith('/') &&
(c.req.method === 'GET' || c.req.method === 'HEAD')
) {
url.pathname = url.pathname.replace(/\/+$/, '');
return c.redirect(url.pathname + url.search, 301);
}
await next();
});

app.use('*', async (c, next) => {
// A bearer token identifies API clients (the TUI); a cookie identifies
// browsers. A token never confers admin, whatever it was minted with.
Expand Down Expand Up @@ -112,8 +136,19 @@ export function createApp(registry: Registry, baseUrl: string): Hono<AppEnv> {
* ad.js renders its creative into a srcdoc iframe, and a srcdoc document
* inherits this policy.
*/
const secure = baseUrl.startsWith('https://');
app.use('*', async (c, next) => {
await next();

// On every response, not only pages: a browser learns the rule from
// whichever response it sees first, and that is as likely to be the
// stylesheet as the document. Only when the board is actually served over
// https — the header is ignored on http, but sending it there would
// still be a lie about the deployment.
if (secure && !c.res.headers.has('strict-transport-security')) {
c.res.headers.set('strict-transport-security', 'max-age=31536000; includeSubDomains');
}

if (!c.res.headers.get('content-type')?.includes('text/html')) return;

const base: Record<string, string[]> = {
Expand Down Expand Up @@ -150,6 +185,29 @@ export function createApp(registry: Registry, baseUrl: string): Hono<AppEnv> {
);
c.res.headers.set('referrer-policy', 'strict-origin-when-cross-origin');
c.res.headers.set('x-content-type-options', 'nosniff');
// frame-ancestors above is the real control; this is the same rule for
// the clients that predate it.
c.res.headers.set('x-frame-options', 'DENY');
// The board asks for none of these, so say so.
c.res.headers.set(
'permissions-policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()',
);

/*
* A page is personal the moment somebody is signed in — it carries their
* name, their unread count, their draft — so it must never sit in a shared
* cache. A guest's page is the same for every guest and can be held for a
* minute, which is what lets a crawler or a CDN revalidate cheaply. Vary
* on the cookie so the two never share an entry.
*/
if (!c.res.headers.has('cache-control')) {
c.res.headers.set(
'cache-control',
c.get('viewer')?.user ? 'private, no-cache' : 'public, max-age=60, must-revalidate',
);
c.res.headers.append('vary', 'Cookie');
}
});

app.route('/', pwaRoutes(services));
Expand All @@ -164,6 +222,7 @@ export function createApp(registry: Registry, baseUrl: string): Hono<AppEnv> {
app.route('/', adminRoutes(services));
app.route('/', userRoutes(services));
app.route('/', docsRoutes(services));
app.route('/', discoveryRoutes(services));
app.route('/', discoverRoutes(services));
app.route('/', writeRoutes(services));
app.route('/', boardRoutes(services));
Expand Down
46 changes: 45 additions & 1 deletion apps/server/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export interface RenderOptions {
* which is how an error page ends up being served as a successful one.
*/
status?: 200 | 400 | 403 | 404 | 410 | 500;
/** JSON-LD graphs specific to this page, after the site-wide ones. */
jsonLd?: unknown[];
body: unknown;
}

Expand Down Expand Up @@ -141,10 +143,52 @@ export async function render(
const nav = await bus.applyFilter('nav:items', navFor(viewer, url.pathname), renderContext);

const skin = skinOf(settings as Record<string, unknown>);
const boardName = String(settings['board.name'] ?? 'tsbb');
const tagline = String(settings['board.tagline'] ?? '').trim();
const operator = String(settings['board.operator'] ?? '').trim() || undefined;

/*
* Structured data on every page: the site and who publishes it. Search
* engines and answer engines resolve the board as an entity from this rather
* than guessing it from the footer, and the SearchAction is what lets one
* offer a search box for the board. A page adds its own graphs after these.
*/
const jsonLd: unknown[] = [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: boardName,
url: services.baseUrl,
...(tagline ? { description: tagline } : {}),
inLanguage: String(settings['board.language'] ?? 'en'),
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${new URL('/search', services.baseUrl).toString()}?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
...(operator
? {
publisher: {
'@type': 'Organization',
name: operator,
url: services.baseUrl,
},
}
: {}),
},
...(options.jsonLd ?? []),
];

const markup = await Layout({
title: options.title,
description: options.description,
boardName: String(settings['board.name'] ?? 'tsbb'),
boardName,
tagline,
operator,
jsonLd,
viewer,
nav,
unread,
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,10 @@ export function adminRoutes(services: Services) {
// --- Board settings -----------------------------------------------------

const SETTING_GROUPS: { title: string; keys: string[] }[] = [
{ title: 'Identity', keys: ['board.name', 'board.tagline', 'board.description'] },
{
title: 'Identity',
keys: ['board.name', 'board.tagline', 'board.description', 'board.operator', 'board.contactEmail'],
},
{
title: 'Appearance',
keys: [
Expand All @@ -227,6 +230,8 @@ export function adminRoutes(services: Services) {
];

const HELP: Record<string, string> = {
'board.contactEmail':
'Shown on the About page and published in /.well-known/security.txt. Leave empty to use the address the board sends from.',
'board.skin':
'modern is cards and generous spacing. classic is a 2000s bulletin board: boxy, dense, gradient title bars. terminal is neutral surfaces, hairline rules and monospace chrome. Same board either way — only the stylesheet changes.',
'board.theme':
Expand Down
76 changes: 73 additions & 3 deletions apps/server/src/routes/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Hono } from 'hono';
import { all, one } from '@tsbb/db';
import type { Context } from 'hono';
import { html } from 'hono/html';
import { excerpt } from '@tsbb/markup';
import {
ancestryOf,
breadcrumb,
Expand Down Expand Up @@ -59,7 +60,8 @@ export function boardRoutes(services: Services) {
const below = await slot(c, services, 'board:below_categories');
const stats = await boardStats();

const body = html`${trusted(above)}
const body = html`${boardHero(settings, viewer)}
${trusted(above)}
${viewer.user && tree.length
? ReadBar({ unread: unreadInTree(tree), action: '/read', label: 'Mark all read', scope: 'on the board' })
: ''}
Expand Down Expand Up @@ -93,7 +95,8 @@ export function boardRoutes(services: Services) {

return render(c, services, {
title: String(settings['board.name'] ?? 'Forums'),
description: String(settings['board.description'] ?? settings['board.tagline'] ?? ''),
description:
String(settings['board.description'] ?? '').trim() || String(settings['board.tagline'] ?? ''),
feedUrl: '/feed.xml',
body,
});
Expand Down Expand Up @@ -280,6 +283,36 @@ async function boardStats(): Promise<BoardStats> {
};
}

/**
* The front page's heading.
*
* A board index used to open straight into its first category, which left the
* page without an <h1> and left a crawler to guess what the site was from the
* footer. The name and tagline are the answer, stated once. A guest also gets
* the way in; a member already knows it and gets the compact form.
*/
function boardHero(settings: Settings, viewer: Viewer) {
const name = String(settings['board.name'] ?? 'tsbb');
const tagline = String(settings['board.tagline'] ?? '').trim();
const description = String(settings['board.description'] ?? '').trim();
const mode = String(settings['registration.mode'] ?? 'open');

return html`<section class="hero${viewer.user ? ' hero-compact' : ''}">
<div class="hero-text">
<h1 class="hero-title">${name}</h1>
${tagline ? html`<p class="hero-tagline">${tagline}</p>` : ''}
${description && !viewer.user ? html`<p class="hero-description">${description}</p>` : ''}
</div>
${viewer.user
? ''
: html`<div class="row hero-actions">
${mode !== 'closed' ? LinkButton('Join the board', '/signup', { size: 'sm' }) : ''}
${LinkButton('About', '/about', { size: 'sm', variant: 'outline' })}
${LinkButton('Docs', '/docs', { size: 'sm', variant: 'ghost' })}
</div>`}
</section>`;
}

function boardStatsPanel(stats: BoardStats) {
return Card(html`
${CardHeader('Board statistics')}
Expand Down Expand Up @@ -494,10 +527,47 @@ async function topicPage(c: Context<AppEnv>, services: Services) {
: ''}
${trusted(below)}`;

/*
* The thread as structured data. DiscussionForumPosting is the type search
* engines actually use for forum threads: the opening post is the posting
* and the replies on this page are its comments. Author URLs point at the
* profile, so the same person on two threads resolves to one entity.
*/
const opening = posts[0];
const person = (name: string | null) =>
name ? { '@type': 'Person', name, url: new URL(`/u/${name}`, baseUrl).toString() } : undefined;
const topicUrl = new URL(`/t/${canonicalHandle}`, baseUrl).toString();
const discussion = {
'@context': 'https://schema.org',
'@type': 'DiscussionForumPosting',
'@id': topicUrl,
url: topicUrl,
headline: topic.title,
...(opening ? { text: excerpt(opening.body, opening.bodyFormat, 500) } : {}),
datePublished: new Date(topic.createdAt).toISOString(),
...(topic.lastPostAt ? { dateModified: new Date(topic.lastPostAt).toISOString() } : {}),
...(opening?.authorName ? { author: person(opening.authorName) } : {}),
isPartOf: { '@type': 'WebPage', name: forum.name, url: new URL(`/f/${forum.slug}`, baseUrl).toString() },
commentCount: topic.replyCount,
interactionStatistic: [
{ '@type': 'InteractionCounter', interactionType: 'https://schema.org/CommentAction', userInteractionCount: topic.replyCount },
{ '@type': 'InteractionCounter', interactionType: 'https://schema.org/ViewAction', userInteractionCount: topic.viewCount },
],
comment: posts.slice(page === 1 ? 1 : 0).map((post) => ({
'@type': 'Comment',
url: new URL(`/t/${canonicalHandle}/p/${post.id}`, baseUrl).toString(),
text: excerpt(post.body, post.bodyFormat, 300),
datePublished: new Date(post.createdAt).toISOString(),
...(post.authorName ? { author: person(post.authorName) } : {}),
})),
};

return render(c, services, {
title: topic.title,
canonical: new URL(`/t/${canonicalHandle}`, baseUrl).toString(),
description: opening ? excerpt(opening.body, opening.bodyFormat, 160) : undefined,
canonical: topicUrl,
feedUrl: `/t/${canonicalHandle}/feed.xml`,
jsonLd: [discussion],
body,
});
}
Expand Down
Loading
Loading