Files
peardata-website/server.mjs
T
2026-07-30 20:08:50 -04:00

427 lines
13 KiB
JavaScript

/**
* PearData 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://peardata.rest)
* GITEA_API (default https://git.ssh.surf/api/v1/repos/snxraven/peardata)
*/
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://peardata.rest').replace(
/\/+$/,
''
)
const GITEA_API =
process.env.GITEA_API || 'https://git.ssh.surf/api/v1/repos/snxraven/peardata'
const GITEA_WEB =
process.env.GITEA_WEB || 'https://git.ssh.surf/snxraven/peardata'
/** 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': 'peardata-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 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<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(`[peardata-website] http://127.0.0.1:${PORT}`)
console.log(`[peardata-website] dist=${DIST}`)
console.log(`[peardata-website] origin=${SITE_ORIGIN}`)
console.log(`[peardata-website] gitea=${GITEA_API}`)
console.log(`[peardata-website] sitemap=/sitemap.xml releases=/releases api=/api/releases`)
})