/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/web/routes.mjs * Purpose : Server-rendered web UI — every HTML page of the forge * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import nunjucks from 'nunjucks'; import { join, posix } from 'node:path'; import { PROJECT_ROOT } from '../config.mjs'; import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs'; import { search } from '../lib/search.mjs'; import { contributionCalendar } from '../stats/activity.mjs'; import { languageColor } from '../stats/languages.mjs'; import { renderMarkdown } from '../render/markdown.mjs'; import { highlight, highlightFile, langForPath } from '../render/highlight.mjs'; import { renderMonogramPng } from '../render/og-image.mjs'; import { sendArchive } from '../git/archive.mjs'; import { ASSET_MIME, isValidAssetName, isValidTagName } from '../git/releases.mjs'; import { relativeTime, formatBytes, escapeHtml, identiconSvg, isValidRepoName, } from '../lib/util.mjs'; const BLOB_RENDER_LIMIT = 1024 * 1024; // 1 MB — beyond this, offer raw only const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.avif']); const RAW_MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.json': 'application/json', '.zip': 'application/zip', '.gz': 'application/gzip', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wasm': 'application/wasm', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', }; /** Build the Nunjucks environment with all filters/globals. */ export function setupViews(config) { const env = new nunjucks.Environment( new nunjucks.FileSystemLoader(join(PROJECT_ROOT, 'src/web/views'), { noCache: config.isDev }), { autoescape: true }, ); env.addFilter('reltime', (value) => (value ? relativeTime(value) : '')); env.addFilter('bytes', (value) => formatBytes(Number(value) || 0)); env.addFilter('num', (value) => new Intl.NumberFormat('en-US').format(Number(value) || 0)); env.addFilter('langcolor', (name) => languageColor(name)); env.addFilter('identicon', (email, size) => new nunjucks.runtime.SafeString(identiconSvg(String(email ?? ''), size ?? 20))); env.addFilter('datefull', (value) => { if (!value) return ''; const d = new Date(value); return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); }); env.addGlobal('owner', config.owner); env.addGlobal('publicUrl', config.publicUrl); return env; } /** Render a view with the standard page envelope. */ function makeRender(env, config) { return function render(reply, template, data) { const html = env.render(template, { ...data, canonical: `${config.publicUrl}${data.canonicalPath ?? '/'}`, ogImage: `${config.publicUrl}${data.ogImagePath ?? '/og/site.png'}`, }); reply.type('text/html; charset=utf-8'); return reply.send(html); }; } /** Shared header data for every repo page (tabs, refs, counts). */ async function repoShell(ctx, name) { const overview = await repoOverview(ctx, name); if (!overview) return null; const branches = await ctx.repos.branches(name); const tags = await ctx.repos.tags(name); return { overview, branches, tags }; } /** Breadcrumb segments for a tree path. */ function breadcrumbs(repo, kind, ref, path) { const crumbs = []; if (!path) return crumbs; const parts = path.split('/'); let acc = ''; for (const part of parts) { acc = acc === '' ? part : `${acc}/${part}`; crumbs.push({ name: part, href: `/${repo}/${kind}/${ref}/${acc}` }); } return crumbs; } /** Highlight the ordered lines of one diff hunk with the file's language. */ async function highlightHunk(lines, lang) { const fallback = () => lines.map((l) => escapeHtml(l.text)); if (!lang || lines.length > 500) return fallback(); try { const html = await highlight(lines.map((l) => l.text).join('\n'), lang); const parts = html.split('').slice(1); if (parts.length !== lines.length) return fallback(); return parts.map((part) => part .replace(/<\/span>\s*<\/code><\/pre>\s*$/s, '') .replace(/<\/span>\n?$/s, ''), ); } catch { return fallback(); } } /** Prepare a parsed diff for the template (adds highlighted line HTML). */ async function prepareDiff(files) { for (const file of files) { const lang = langForPath(file.newPath || file.oldPath); for (const hunk of file.hunks) { const content = hunk.lines.filter((l) => l.type !== 'meta'); const highlighted = await highlightHunk(content, lang); let i = 0; for (const line of hunk.lines) { line.html = line.type === 'meta' ? escapeHtml(line.text) : highlighted[i++]; } } } return files; } /** * Register all HTML routes + error pages. * @param {import('fastify').FastifyInstance} app * @param {object} ctx */ export async function registerWeb(app, ctx) { const { config } = ctx; const env = setupViews(config); const render = makeRender(env, config); ctx.views = env; /** Resolve `:repo` param or render 404. Returns name or null. */ function repoParam(request, reply) { const name = request.params.repo; if (!isValidRepoName(name) || !ctx.repos.exists(name)) { notFound(request, reply); return null; } return name; } function notFound(request, reply) { if (request.url.startsWith('/api/')) { return reply.code(404).send({ error: { code: 'not_found', message: 'no such endpoint' } }); } reply.code(404); return render(reply, 'error.njk', { title: '404 · SPB Git', status: 404, message: 'This ref does not exist in any timeline.', canonicalPath: request.url, }); } app.setNotFoundHandler((request, reply) => notFound(request, reply)); app.setErrorHandler((error, request, reply) => { request.log.error({ err: error }, 'unhandled error'); if (error.statusCode === 429) { return reply.code(429).send({ error: { code: 'rate_limited', message: 'Too many requests — slow down.' } }); } if (request.url.startsWith('/api/')) { return reply.code(500).send({ error: { code: 'internal', message: 'internal server error' } }); } reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 500); return render(reply, 'error.njk', { title: '500 · SPB Git', status: 500, message: 'Something went sideways in the reflog. The incident has been logged.', canonicalPath: request.url, }); }); // ───────────────────────── home ───────────────────────── app.get('/', async (request, reply) => { const [overviews, stats, heatmap] = await Promise.all([ allOverviews(ctx), siteStats(ctx), contributionCalendar(ctx), ]); const pinned = overviews.filter((o) => o.pinned).slice(0, 6); const activity = ctx.activity.recent(15); const topics = [...new Set(overviews.flatMap((o) => o.topics))].sort(); const languages = [...new Set(overviews.map((o) => o.topLanguage).filter(Boolean))].sort(); return render(reply, 'home.njk', { title: 'SPB Git — Simon-Pierre Boucher', description: 'Personal git platform of Simon-Pierre Boucher — every repository, public and clonable.', overviews, pinned, stats, activity, topics, languages, heatmap, canonicalPath: '/', }); }); // ───────────────────────── search ───────────────────────── app.get('/search', async (request, reply) => { const q = String(request.query.q ?? '').slice(0, 120); const results = q ? await search(ctx, q) : { repos: [], readmes: [] }; return render(reply, 'search.njk', { title: q ? `Search: ${q} · SPB Git` : 'Search · SPB Git', description: `Search results for ${q}`, q, results, canonicalPath: `/search`, }); }); // ───────────────────────── meta/polish routes ───────────────────────── app.get('/robots.txt', async (_request, reply) => { reply.type('text/plain'); return [ 'User-agent: *', 'Allow: /', 'Disallow: /archive/', 'Disallow: /internal/', `Sitemap: ${config.publicUrl}/sitemap.xml`, '', ].join('\n'); }); app.get('/sitemap.xml', async (_request, reply) => { const overviews = await allOverviews(ctx); const urls = [ { loc: `${config.publicUrl}/`, priority: '1.0' }, ...overviews.map((o) => ({ loc: `${config.publicUrl}/${o.name}`, lastmod: o.lastPush ? new Date(o.lastPush).toISOString() : undefined, priority: '0.8', })), ]; const body = urls .map((u) => [ ' ', ` ${escapeHtml(u.loc)}`, u.lastmod ? ` ${u.lastmod}` : null, ` ${u.priority}`, ' ', ].filter(Boolean).join('\n'), ) .join('\n'); reply.type('application/xml'); return `\n\n${body}\n\n`; }); app.get('/feed.atom', async (_request, reply) => { const events = ctx.activity.recent(30); const updated = events[0]?.at ?? new Date().toISOString(); const entries = events .map((e) => { const what = e.deleted ? `deleted ${e.refType} ${e.ref}` : `pushed ${e.commits} commit${e.commits === 1 ? '' : 's'} to ${e.ref}`; const id = `${config.publicUrl}/${e.repo}#${e.at}`; return [ ' ', ` ${escapeHtml(`${e.repo}: ${what}`)}`, ` `, ` ${escapeHtml(id)}`, ` ${escapeHtml(e.at)}`, ` ${escapeHtml(config.owner.name)}`, ` ${escapeHtml(`${config.owner.name} ${what} in ${e.repo}`)}`, ' ', ].join('\n'); }) .join('\n'); reply.type('application/atom+xml'); return [ '', '', ` SPB Git — activity`, ` `, ` `, ` ${config.publicUrl}/feed.atom`, ` ${escapeHtml(updated)}`, ` ${escapeHtml(config.owner.name)}${escapeHtml(config.owner.email)}`, entries, '', '', ].join('\n'); }); // Favicons + OG images (all generated, cached). app.get('/favicon.png', async (_request, reply) => { const path = ctx.cache.path('global', 'favicon-32.png'); let buf = ctx.cache.getBuffer(path); if (!buf) { buf = await renderMonogramPng(64); ctx.cache.set(path, buf); } reply.type('image/png').header('Cache-Control', 'public, max-age=604800'); return reply.send(buf); }); app.get('/apple-touch-icon.png', async (_request, reply) => { const path = ctx.cache.path('global', 'favicon-180.png'); let buf = ctx.cache.getBuffer(path); if (!buf) { buf = await renderMonogramPng(180); ctx.cache.set(path, buf); } reply.type('image/png').header('Cache-Control', 'public, max-age=604800'); return reply.send(buf); }); app.get('/favicon.ico', async (_request, reply) => reply.redirect('/favicon.png', 301)); app.get('/og/site.png', async (_request, reply) => { const buf = await ctx.ogImage(null); reply.type('image/png').header('Cache-Control', 'public, max-age=86400'); return reply.send(buf); }); app.get('/og/:file', async (request, reply) => { const file = String(request.params.file ?? ''); if (!file.endsWith('.png')) return notFound(request, reply); const name = file.slice(0, -4); if (!isValidRepoName(name) || !ctx.repos.exists(name)) return notFound(request, reply); const buf = await ctx.ogImage(name); reply.type('image/png').header('Cache-Control', 'public, max-age=3600'); return reply.send(buf); }); // ───────────────────────── raw files ───────────────────────── app.get('/raw/:repo/*', async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']); if (!resolved || resolved.path === '') return notFound(request, reply); const blob = await ctx.repos.blob(name, resolved.sha, resolved.path); if (!blob) return notFound(request, reply); const ext = posix.extname(resolved.path).toLowerCase(); let mime = RAW_MIME[ext]; if (!mime) mime = blob.binary ? 'application/octet-stream' : 'text/plain; charset=utf-8'; // Stored-XSS guard: never serve repo HTML as HTML. if (['.html', '.htm', '.xhtml'].includes(ext)) mime = 'text/plain; charset=utf-8'; const immutable = /^[0-9a-f]{40}$/.test(resolved.ref) || /^[0-9a-f]{7,40}$/.test(resolved.ref); reply .type(mime) .header('X-Content-Type-Options', 'nosniff') .header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox") .header('Cache-Control', immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=60'); return reply.send(blob.content); }); // ───────────────────────── archives ───────────────────────── app.get('/archive/:repo/*', async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const splat = String(request.params['*'] ?? ''); const match = /^(.+?)\.(zip|tar\.gz)$/.exec(splat); if (!match) return notFound(request, reply); const [, refPart, format] = match; const resolved = await ctx.repos.resolveRefAndPath(name, refPart); if (!resolved || resolved.path !== '') return notFound(request, reply); const safeRef = refPart.replaceAll('/', '-'); return sendArchive(ctx, name, resolved.sha, format, reply, `${name}-${safeRef}`); }); // ───────────────────────── release asset downloads ───────────────────────── app.get('/releases/:repo/:tag/:asset', async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const { tag, asset } = request.params; if (!isValidTagName(tag) || !isValidAssetName(asset)) return notFound(request, reply); const info = ctx.releases.stat(name, tag, asset); if (!info) return notFound(request, reply); const ext = posix.extname(asset).toLowerCase(); reply .type(ASSET_MIME[ext] ?? 'application/octet-stream') .header('Content-Length', info.size) .header('Content-Disposition', `attachment; filename="${asset}"`) .header('X-Content-Type-Options', 'nosniff') .header('Cache-Control', 'public, max-age=3600'); if (info.sha256) reply.header('X-Checksum-Sha256', info.sha256); return reply.send(ctx.releases.readStream(name, tag, asset)); }); // ───────────────────────── repo home ───────────────────────── app.get('/:repo', async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const shell = await repoShell(ctx, name); const { overview } = shell; let entries = []; let lastCommits = {}; let readme = null; if (!overview.empty) { entries = (await ctx.repos.tree(name, overview.head, '')) ?? []; const lcCache = ctx.cache.repoPath(name, overview.head, 'lastcommits-root.json'); lastCommits = await ctx.cache.remember(lcCache, () => ctx.repos.lastCommits(name, overview.head, '', entries.map((e) => e.name)), ); readme = await ctx.renderReadme(name, overview.head); } return render(reply, 'repo.njk', { title: `${name} · SPB Git`, description: overview.description || `${name} — a repository by ${config.owner.name}`, ...shell, tab: 'code', currentRef: overview.defaultBranch, entries, lastCommits, readme, canonicalPath: `/${name}`, ogImagePath: `/og/${name}.png`, }); }); // ───────────────────────── tree browsing ───────────────────────── app.get('/:repo/tree/*', async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']); if (!resolved) return notFound(request, reply); const entries = await ctx.repos.tree(name, resolved.sha, resolved.path); if (!entries) return notFound(request, reply); const shell = await repoShell(ctx, name); const lcKey = `lastcommits-${resolved.path.replaceAll('/', '_') || 'root'}.json`; const lastCommits = await ctx.cache.remember(ctx.cache.repoPath(name, resolved.sha, lcKey), () => ctx.repos.lastCommits(name, resolved.sha, resolved.path, entries.map((e) => e.name)), ); return render(reply, 'tree.njk', { title: `${resolved.path || name} at ${resolved.ref} · ${name} · SPB Git`, description: `Browse ${resolved.path || 'the root tree'} of ${name} at ${resolved.ref}`, ...shell, tab: 'code', currentRef: resolved.ref, path: resolved.path, parentPath: resolved.path.includes('/') ? resolved.path.slice(0, resolved.path.lastIndexOf('/')) : '', crumbs: breadcrumbs(name, 'tree', resolved.ref, resolved.path), entries, lastCommits, canonicalPath: `/${name}/tree/${resolved.ref}${resolved.path ? `/${resolved.path}` : ''}`, ogImagePath: `/og/${name}.png`, }); }); // ───────────────────────── blob view ───────────────────────── app.get('/:repo/blob/*', async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']); if (!resolved || resolved.path === '') return notFound(request, reply); const type = await ctx.repos.objectType(name, resolved.sha, resolved.path); if (type === 'tree') { return reply.redirect(`/${name}/tree/${resolved.ref}/${resolved.path}`); } const blob = await ctx.repos.blob(name, resolved.sha, resolved.path); if (!blob) return notFound(request, reply); const shell = await repoShell(ctx, name); const ext = posix.extname(resolved.path).toLowerCase(); const rawUrl = `/raw/${name}/${resolved.ref}/${resolved.path}`; const view = { kind: 'code', html: null, lines: 0, lang: null, tooLarge: false, isImage: false, isMarkdown: false, notebookNote: false, }; if (blob.binary || IMAGE_EXT.has(ext)) { view.kind = 'binary'; view.isImage = IMAGE_EXT.has(ext); } else if (blob.size > BLOB_RENDER_LIMIT) { view.kind = 'toolarge'; view.tooLarge = true; } else { const text = blob.content.toString('utf8'); if (['.md', '.markdown'].includes(ext) && request.query.plain !== '1') { view.kind = 'markdown'; view.isMarkdown = true; const basePath = resolved.path.includes('/') ? resolved.path.slice(0, resolved.path.lastIndexOf('/')) : '.'; view.html = await renderMarkdown(text, { repo: name, ref: resolved.ref, basePath, publicUrl: config.publicUrl, }); } else { let source = text; if (ext === '.ipynb') { view.notebookNote = true; try { source = JSON.stringify(JSON.parse(text), null, 2); } catch { source = text; } } const result = await highlightFile(source, resolved.path); view.html = result.html; view.lines = result.lines; view.lang = result.lang; if (['.md', '.markdown'].includes(ext)) view.isMarkdown = true; } } return render(reply, 'blob.njk', { title: `${resolved.path} at ${resolved.ref} · ${name} · SPB Git`, description: `${resolved.path} — ${name} at ${resolved.ref}`, ...shell, tab: 'code', currentRef: resolved.ref, refSha: resolved.sha, path: resolved.path, fileName: resolved.path.split('/').pop(), crumbs: breadcrumbs(name, 'blob', resolved.ref, resolved.path), blobSize: blob.size, rawUrl, view, canonicalPath: `/${name}/blob/${resolved.ref}/${resolved.path}`, ogImagePath: `/og/${name}.png`, }); }); // ───────────────────────── commits list ───────────────────────── const commitsHandler = async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const shell = await repoShell(ctx, name); const splat = request.params['*'] ?? ''; const resolved = await ctx.repos.resolveRefAndPath(name, splat); if (!resolved) return notFound(request, reply); const page = Math.max(1, Number(request.query.page) || 1); const path = typeof request.query.path === 'string' ? request.query.path : undefined; const { commits, hasNext } = await ctx.repos.log(name, resolved.sha, { page, path }); return render(reply, 'commits.njk', { title: `Commits on ${resolved.ref} · ${name} · SPB Git`, description: `Commit history of ${name} on ${resolved.ref}`, ...shell, tab: 'commits', currentRef: resolved.ref, commits, page, hasNext, filterPath: path ?? '', canonicalPath: `/${name}/commits/${resolved.ref}`, ogImagePath: `/og/${name}.png`, }); }; app.get('/:repo/commits', commitsHandler); app.get('/:repo/commits/*', commitsHandler); // ───────────────────────── single commit ───────────────────────── app.get('/:repo/commit/:sha', async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const shaParam = String(request.params.sha ?? ''); if (!/^[0-9a-f]{4,40}$/i.test(shaParam)) return notFound(request, reply); const commit = await ctx.repos.commit(name, shaParam); if (!commit) return notFound(request, reply); await prepareDiff(commit.files); const shell = await repoShell(ctx, name); return render(reply, 'commit.njk', { title: `${commit.subject} · ${commit.shortSha} · ${name} · SPB Git`, description: commit.subject, ...shell, tab: 'commits', currentRef: shell.overview.defaultBranch, commit, canonicalPath: `/${name}/commit/${commit.sha}`, ogImagePath: `/og/${name}.png`, }); }); // ───────────────────────── blame ───────────────────────── app.get('/:repo/blame/*', async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']); if (!resolved || resolved.path === '') return notFound(request, reply); const blame = await ctx.repos.blame(name, resolved.sha, resolved.path); if (!blame) return notFound(request, reply); const shell = await repoShell(ctx, name); // Age-scale each hunk 0..9 (newer = brighter accent). const dates = blame.hunks.map((h) => new Date(h.date).getTime()).filter(Number.isFinite); const min = Math.min(...dates); const max = Math.max(...dates); for (const hunk of blame.hunks) { const t = new Date(hunk.date).getTime(); hunk.age = max === min ? 0 : Math.round((1 - (t - min) / (max - min)) * 9); } return render(reply, 'blame.njk', { title: `Blame ${resolved.path} at ${resolved.ref} · ${name} · SPB Git`, description: `Blame view of ${resolved.path} in ${name}`, ...shell, tab: 'code', currentRef: resolved.ref, path: resolved.path, crumbs: breadcrumbs(name, 'blame', resolved.ref, resolved.path), blame, canonicalPath: `/${name}/blame/${resolved.ref}/${resolved.path}`, ogImagePath: `/og/${name}.png`, }); }); // ───────────────────────── branches / tags ───────────────────────── app.get('/:repo/branches', async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const shell = await repoShell(ctx, name); const def = shell.overview.defaultBranch; const branches = []; for (const branch of shell.branches) { const counts = branch.isDefault ? { ahead: 0, behind: 0 } : await ctx.repos.aheadBehind(name, def, branch.name); branches.push({ ...branch, ...counts }); } return render(reply, 'branches.njk', { title: `Branches · ${name} · SPB Git`, description: `Branches of ${name}`, ...shell, tab: 'branches', currentRef: def, branchesDetailed: branches, canonicalPath: `/${name}/branches`, ogImagePath: `/og/${name}.png`, }); }); const tagsHandler = async (request, reply) => { const name = repoParam(request, reply); if (!name) return reply; const shell = await repoShell(ctx, name); const assetsByTag = ctx.releases.list(name); const tagsWithAssets = shell.tags.map((tag) => { const key = tag.name.replaceAll('/', '_'); return { ...tag, key, assets: assetsByTag[key] ?? [] }; }); return render(reply, 'tags.njk', { title: `Releases · ${name} · SPB Git`, description: `Tags and releases of ${name}`, ...shell, tab: 'tags', currentRef: shell.overview.defaultBranch, tagsWithAssets, canonicalPath: `/${name}/tags`, ogImagePath: `/og/${name}.png`, }); }; app.get('/:repo/tags', tagsHandler); app.get('/:repo/releases', tagsHandler); }