/** * PearDock website server: static dist/ + Gitea releases API proxy + dynamic sitemap. * * Routes: * GET /sitemap.xml → Google-compatible sitemap (generated from dist/) * GET /api/releases → list releases * GET /api/releases/tags/:tag → single release by tag * GET /api/releases/latest → latest non-draft (or rolling if present) * * Env: * PORT (default 4173) * DIST (default ./dist) * SITE_ORIGIN (default https://peardock.boats) * GITEA_API (default https://git.ssh.surf/api/v1/repos/snxraven/peardock) */ import http from 'node:http' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const PORT = Number(process.env.PORT || 4173) const DIST = path.resolve(process.env.DIST || path.join(__dirname, 'dist')) const SITE_ORIGIN = String(process.env.SITE_ORIGIN || 'https://peardock.boats').replace( /\/+$/, '' ) const GITEA_API = process.env.GITEA_API || 'https://git.ssh.surf/api/v1/repos/snxraven/peardock' const GITEA_WEB = process.env.GITEA_WEB || 'https://git.ssh.surf/snxraven/peardock' /** Paths that must not appear in the public sitemap (noindex / redirect stubs). */ const SITEMAP_EXCLUDE = new Set(['/legal/cookies']) const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml; charset=utf-8', '.webmanifest': 'application/manifest+json', } function send(res, status, body, headers = {}) { const buf = Buffer.isBuffer(body) ? body : Buffer.from(body ?? '') res.writeHead(status, { 'Content-Length': buf.length, ...headers, }) res.end(buf) } function sendJson(res, status, obj) { send(res, status, JSON.stringify(obj), { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=60', }) } async function proxyGitea(apiPath, search = '') { const url = `${GITEA_API}${apiPath}${search || ''}` const r = await fetch(url, { headers: { Accept: 'application/json', 'User-Agent': 'peardock-website-releases/1.0', }, }) const text = await r.text() let data try { data = text ? JSON.parse(text) : null } catch { data = { message: text || 'Invalid JSON from Gitea' } } return { status: r.status, data, url } } async function handleApi(req, res, url) { if (req.method === 'OPTIONS') { send(res, 204, '', { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }) return } if (req.method !== 'GET' && req.method !== 'HEAD') { sendJson(res, 405, { error: 'Method not allowed' }) return } try { // GET /api/releases if (url.pathname === '/api/releases' || url.pathname === '/api/releases/') { const qs = new URLSearchParams(url.search) if (!qs.has('limit')) qs.set('limit', '25') const { status, data } = await proxyGitea('/releases', `?${qs}`) // Annotate with canonical web links if (Array.isArray(data)) { for (const rel of data) { if (rel && !rel.html_url && rel.tag_name) { rel.html_url = `${GITEA_WEB}/releases/tag/${encodeURIComponent(rel.tag_name)}` } } } sendJson(res, status, { source: GITEA_WEB + '/releases', api: GITEA_API + '/releases', releases: data, }) return } // GET /api/releases/latest if (url.pathname === '/api/releases/latest') { // Prefer rolling tag if present, else first non-draft const list = await proxyGitea('/releases', '?limit=50') if (!Array.isArray(list.data)) { sendJson(res, list.status, list.data) return } const rolling = list.data.find((r) => r.tag_name === 'rolling' && !r.draft) const first = list.data.find((r) => !r.draft) || list.data[0] const pick = rolling || first if (!pick) { sendJson(res, 404, { error: 'No releases found' }) return } sendJson(res, 200, { source: GITEA_WEB + '/releases', release: pick, }) return } // GET /api/releases/tags/:tag const tagMatch = url.pathname.match(/^\/api\/releases\/tags\/([^/]+)\/?$/) if (tagMatch) { const tag = decodeURIComponent(tagMatch[1]) const { status, data } = await proxyGitea( `/releases/tags/${encodeURIComponent(tag)}` ) sendJson(res, status, { source: `${GITEA_WEB}/releases/tag/${encodeURIComponent(tag)}`, release: data, }) return } sendJson(res, 404, { error: 'Unknown API route', routes: [ 'GET /api/releases', 'GET /api/releases/latest', 'GET /api/releases/tags/:tag', ], }) } catch (err) { console.error('[api]', err) sendJson(res, 502, { error: 'Failed to reach Gitea releases API', message: err?.message || String(err), upstream: GITEA_API, }) } } function safeJoin(root, reqPath) { const decoded = decodeURIComponent(reqPath.split('?')[0]) const cleaned = path.normalize(decoded).replace(/^(\.\.(\/|\\|$))+/, '') const full = path.join(root, cleaned) if (!full.startsWith(root)) return null return full } function escapeXml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } /** dist-relative path → public URL path (clean URLs used by the site). */ function distHtmlToUrlPath(relPosix) { const rel = relPosix.replace(/\\/g, '/').replace(/^\//, '') if (rel === 'index.html') return '/' if (rel.endsWith('/index.html')) { return `/${rel.slice(0, -'index.html'.length)}` } if (rel.endsWith('.html')) return `/${rel.slice(0, -'.html'.length)}` return `/${rel}` } function walkHtmlFiles(dir, baseRel = '') { /** @type {{ rel: string, abs: string, mtime: Date }[]} */ const out = [] let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return out } for (const ent of entries) { if (ent.name.startsWith('.')) continue if (ent.name === 'assets' || ent.name === 'node_modules') continue const abs = path.join(dir, ent.name) const rel = baseRel ? `${baseRel}/${ent.name}` : ent.name if (ent.isDirectory()) { out.push(...walkHtmlFiles(abs, rel)) continue } if (ent.isFile() && ent.name.endsWith('.html')) { let mtime = new Date() try { mtime = fs.statSync(abs).mtime } catch { /* keep now */ } out.push({ rel: rel.replace(/\\/g, '/'), abs, mtime }) } } return out } function sitemapPriority(urlPath) { if (urlPath === '/') return '1.0' if (urlPath === '/download' || urlPath === '/releases') return '0.95' if (urlPath === '/learn/' || urlPath === '/docs/') return '0.9' if (urlPath === '/community') return '0.8' if (urlPath.startsWith('/docs/')) return '0.85' if (urlPath === '/legal/') return '0.5' if (urlPath.startsWith('/legal/')) return '0.4' return '0.7' } function sitemapChangefreq(urlPath) { if (urlPath === '/releases') return 'daily' if (urlPath === '/' || urlPath === '/download' || urlPath.startsWith('/docs')) return 'weekly' if (urlPath.startsWith('/legal')) return 'yearly' return 'monthly' } /** * Build a Google Search–compatible sitemap 0.9 document from dist HTML pages. * Strict sitemaps.org protocol only (loc, lastmod, changefreq, priority). * @see https://www.sitemaps.org/protocol.html * @see https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap */ function buildSitemapXml() { const pages = walkHtmlFiles(DIST) /** @type {Map} */ const byPath = new Map() for (const page of pages) { const urlPath = distHtmlToUrlPath(page.rel) const excludeKey = urlPath.replace(/\/+$/, '') || '/' if (SITEMAP_EXCLUDE.has(excludeKey) || SITEMAP_EXCLUDE.has(urlPath)) continue // W3C Datetime (date only is accepted by Google) const lastmod = page.mtime.toISOString().slice(0, 10) const existing = byPath.get(urlPath) if (existing && existing.lastmod >= lastmod) continue // Absolute HTTPS loc only — required by the protocol const loc = SITE_ORIGIN + (urlPath === '/' ? '/' : urlPath) byPath.set(urlPath, { loc, lastmod, changefreq: sitemapChangefreq(urlPath), priority: sitemapPriority(urlPath), }) } const entries = [...byPath.values()].sort((a, b) => { if (a.loc === `${SITE_ORIGIN}/`) return -1 if (b.loc === `${SITE_ORIGIN}/`) return 1 return a.loc.localeCompare(b.loc) }) // One declaration + default namespace only (no xhtml extras — single-language site) const parts = [ '', '', ] for (const e of entries) { parts.push( ' ', ` ${escapeXml(e.loc)}`, ` ${escapeXml(e.lastmod)}`, ` ${escapeXml(e.changefreq)}`, ` ${escapeXml(e.priority)}`, ' ' ) } parts.push('') // Trailing newline for POSIX-friendly files return parts.join('\n') + '\n' } function serveSitemap(req, res) { try { const xml = buildSitemapXml() // text/xml is what Google documents for sitemaps; charset for clarity const headers = { 'Content-Type': 'text/xml; charset=utf-8', 'Cache-Control': 'public, max-age=300', } if (req.method === 'HEAD') { res.writeHead(200, { ...headers, 'Content-Length': Buffer.byteLength(xml, 'utf8'), }) res.end() return } send(res, 200, xml, headers) } catch (err) { console.error('[sitemap]', err) send(res, 500, 'Sitemap generation failed', { 'Content-Type': 'text/plain; charset=utf-8', }) } } function resolveStatic(urlPath) { let p = urlPath if (p.endsWith('/')) p += 'index.html' let file = safeJoin(DIST, p) if (!file) return null if (existsFile(file)) return file // clean URLs: /releases → releases.html, /docs/foo → docs/foo.html if (!path.extname(file)) { const asHtml = file + '.html' if (existsFile(asHtml)) return asHtml const asIndex = path.join(file, 'index.html') if (existsFile(asIndex)) return asIndex } return null } function existsFile(f) { try { return fs.statSync(f).isFile() } catch { return false } } function serveStatic(req, res, url) { const file = resolveStatic(url.pathname === '/' ? '/index.html' : url.pathname) if (!file) { send(res, 404, 'Not found', { 'Content-Type': 'text/plain; charset=utf-8' }) return } const ext = path.extname(file).toLowerCase() const type = MIME[ext] || 'application/octet-stream' const stream = fs.createReadStream(file) res.writeHead(200, { 'Content-Type': type, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600', }) if (req.method === 'HEAD') { res.end() return } stream.pipe(res) stream.on('error', () => { if (!res.headersSent) send(res, 500, 'Read error') else res.end() }) } const server = http.createServer(async (req, res) => { const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`) // Health if (url.pathname === '/api/health') { sendJson(res, 200, { ok: true, gitea: GITEA_API, site: SITE_ORIGIN }) return } if (url.pathname.startsWith('/api/')) { await handleApi(req, res, url) return } if (req.method !== 'GET' && req.method !== 'HEAD') { send(res, 405, 'Method not allowed', { 'Content-Type': 'text/plain' }) return } // Dynamic sitemap (overrides any static dist/sitemap.xml) if (url.pathname === '/sitemap.xml' || url.pathname === '/sitemap.xml/') { serveSitemap(req, res) return } serveStatic(req, res, url) }) server.listen(PORT, () => { console.log(`[peardock-website] http://127.0.0.1:${PORT}`) console.log(`[peardock-website] dist=${DIST}`) console.log(`[peardock-website] origin=${SITE_ORIGIN}`) console.log(`[peardock-website] gitea=${GITEA_API}`) console.log(`[peardock-website] sitemap=/sitemap.xml releases=/releases api=/api/releases`) })