Files
peardock-website/server.mjs
T
2026-07-11 20:01:07 -04:00

427 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/** 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 Searchcompatible 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<string, { loc: string, lastmod: string, changefreq: string, priority: string }>} */
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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
]
for (const e of entries) {
parts.push(
' <url>',
` <loc>${escapeXml(e.loc)}</loc>`,
` <lastmod>${escapeXml(e.lastmod)}</lastmod>`,
` <changefreq>${escapeXml(e.changefreq)}</changefreq>`,
` <priority>${escapeXml(e.priority)}</priority>`,
' </url>'
)
}
parts.push('</urlset>')
// 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`)
})