Files
sapien/server/server.js
2026-05-13 22:19:37 -05:00

218 lines
7.8 KiB
JavaScript

import fs from 'node:fs/promises'
import { existsSync, createReadStream } from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { decryptMembership, encryptMembership } from './encryption.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..')
const dataDir = path.join(rootDir, 'server', 'data')
const membershipsFile = path.join(dataDir, 'memberships.json')
const distDir = path.join(rootDir, 'dist')
const port = Number(process.env.PORT || 3001)
const adminPubkeys = [
'7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617',
'2be35e9237eaf98fe0a7d1ce3ceb666dccde9c8cc800ff52f138a62c91d90783',
]
let memberships = []
const json = (res, status, body) => {
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
})
res.end(JSON.stringify(body))
}
const ensureStore = async () => {
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 })
if (!existsSync(membershipsFile)) {
await fs.writeFile(membershipsFile, '[]', { mode: 0o600 })
}
}
const loadMemberships = async () => {
await ensureStore()
memberships = JSON.parse(await fs.readFile(membershipsFile, 'utf8'))
}
const saveMemberships = async () => {
await fs.writeFile(membershipsFile, JSON.stringify(memberships, null, 2), { mode: 0o600 })
}
const readBody = async (req) => {
const chunks = []
let size = 0
for await (const chunk of req) {
size += chunk.length
if (size > 8 * 1024 * 1024) throw new Error('Request body too large')
chunks.push(chunk)
}
return chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}
}
const cleanText = (value, max = 160) =>
String(value || '')
.normalize('NFKC')
.replace(/[\u0000-\u001f\u007f<>`{}[\]\\]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, max)
const validateMember = (data) => {
const member = {
membershipId: cleanText(data.membershipId, 32).toUpperCase(),
userId: cleanText(data.userId, 80),
fullName: cleanText(data.fullName, 80),
email: cleanText(data.email, 160).toLowerCase(),
phone: cleanText(data.phone, 32),
signature: String(data.signature || ''),
signedDate: String(data.signedDate || ''),
createdAt: String(data.createdAt || new Date().toISOString()),
expiresAt: String(data.expiresAt || ''),
status: data.status === 'inactive' ? 'inactive' : 'active',
npub: cleanText(data.npub, 80),
nsecHash: cleanText(data.nsecHash, 64).toLowerCase(),
}
const errors = []
if (!/^L484-\d{4}-[A-Z0-9]{6}$/.test(member.membershipId)) errors.push('Invalid membership ID.')
if (member.fullName.length < 2) errors.push('Full name is required.')
if (member.email && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(member.email)) errors.push('Invalid email.')
if (member.phone && !/^[0-9()+.\-\s]{7,32}$/.test(member.phone)) errors.push('Invalid phone.')
if (!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(member.signature)) errors.push('Signature is required.')
if (Number.isNaN(new Date(member.signedDate).getTime())) errors.push('Invalid signed date.')
if (Number.isNaN(new Date(member.createdAt).getTime())) errors.push('Invalid created date.')
if (member.expiresAt && Number.isNaN(new Date(member.expiresAt).getTime())) errors.push('Invalid expiry date.')
if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(member.npub)) errors.push('Invalid npub.')
if (!/^[0-9a-f]{64}$/.test(member.nsecHash)) errors.push('Invalid nsec hash.')
if (!member.expiresAt) {
const expiresAt = new Date(member.createdAt)
expiresAt.setFullYear(expiresAt.getFullYear() + 1)
member.expiresAt = expiresAt.toISOString()
}
return { member, errors }
}
const requireAdmin = (req, res) => {
const auth = req.headers.authorization || ''
const pubkey = auth.startsWith('Bearer ') ? auth.slice(7).trim().toLowerCase() : ''
if (!adminPubkeys.includes(pubkey)) {
json(res, 403, { error: 'Admin access required.' })
return false
}
return true
}
const serveStatic = (req, res) => {
const requested = decodeURIComponent(new URL(req.url, `http://${req.headers.host}`).pathname)
const filePath = requested === '/'
? path.join(distDir, 'index.html')
: path.join(distDir, requested)
const safePath = filePath.startsWith(distDir) && existsSync(filePath) ? filePath : path.join(distDir, 'index.html')
const ext = path.extname(safePath)
const types = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.avif': 'image/avif',
'.json': 'application/json',
}
res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' })
createReadStream(safePath).pipe(res)
}
const handleApi = async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
if (req.method === 'GET' && url.pathname === '/api/health') {
return json(res, 200, { ok: true })
}
if (req.method === 'POST' && url.pathname === '/api/membership/create') {
const { member, errors } = validateMember(await readBody(req))
if (errors.length) return json(res, 400, { error: 'Validation failed.', errors })
const existingIndex = memberships.findIndex((item) =>
item.membershipId === member.membershipId ||
item.userId === member.userId ||
item.npub === member.npub ||
item.nsecHash === member.nsecHash
)
const encrypted = encryptMembership(member)
if (existingIndex >= 0) {
memberships[existingIndex] = encrypted
} else {
memberships.unshift(encrypted)
}
await saveMemberships()
return json(res, existingIndex >= 0 ? 200 : 201, { success: true, membership: member })
}
if (req.method === 'GET' && url.pathname === '/api/membership/check') {
const membershipId = cleanText(url.searchParams.get('membershipId'), 32).toUpperCase()
const userId = cleanText(url.searchParams.get('userId'), 80)
const npub = cleanText(url.searchParams.get('npub'), 80)
const found = memberships.find((item) =>
(membershipId && item.membershipId === membershipId) ||
(userId && item.userId === userId) ||
(npub && item.npub === npub)
)
return json(res, 200, found
? { hasMembership: true, membership: decryptMembership(found) }
: { hasMembership: false })
}
if (req.method === 'POST' && url.pathname === '/api/membership/recover') {
const body = await readBody(req)
const nsecHash = cleanText(body.nsecHash, 64).toLowerCase()
const found = memberships.find((item) => item.nsecHash === nsecHash)
return json(res, found ? 200 : 404, found
? { success: true, membership: decryptMembership(found) }
: { error: 'No membership found for that nsec.' })
}
if (req.method === 'GET' && url.pathname === '/api/memberships') {
if (!requireAdmin(req, res)) return
return json(res, 200, {
success: true,
memberships: memberships.map(decryptMembership),
})
}
if (req.method === 'DELETE' && url.pathname === '/api/membership') {
if (!requireAdmin(req, res)) return
const { membershipId } = await readBody(req)
const id = cleanText(membershipId, 32).toUpperCase()
const before = memberships.length
memberships = memberships.filter((item) => item.membershipId !== id)
await saveMemberships()
return json(res, 200, { success: true, deleted: before - memberships.length })
}
json(res, 404, { error: 'Not found.' })
}
await loadMemberships()
http.createServer(async (req, res) => {
try {
if (req.url.startsWith('/api/')) {
await handleApi(req, res)
} else {
serveStatic(req, res)
}
} catch (error) {
console.error(error)
json(res, 500, { error: error.message || 'Server error.' })
}
}).listen(port, '127.0.0.1', () => {
console.log(`L484 server listening on http://127.0.0.1:${port}`)
})