218 lines
7.8 KiB
JavaScript
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}`)
|
|
})
|