427 lines
13 KiB
JavaScript
427 lines
13 KiB
JavaScript
/**
|
||
* 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, '"')
|
||
.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<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`)
|
||
})
|