mailship/src/server.ts
2026-09-18 15:38:46 +00:00

394 lines
No EOL
12 KiB
TypeScript

import { instrument } from 'succinct-async'
import express, { Request, Response, NextFunction } from 'express'
import rateLimit from 'express-rate-limit'
import { appSigner, BASE_URL, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL, CORS_ORIGIN } from './env.js'
import { render } from './templates.js'
import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js'
import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js'
import { load } from '@welshman/net'
import { getIdFilters, isRelayUrl } from '@welshman/util'
import crypto from 'crypto'
import { verifyEvent } from 'nostr-tools/pure'
// ── NIP-98 HTTP Auth ────────────────────────────────────────────────────
// Verify a NIP-98 Authorization header and return the authenticated pubkey,
// or null if the header is missing, malformed, or invalid.
//
// The client constructs the auth event via @welshman/util:
// makeHttpAuth(url, method, body) → event
// makeHttpAuthHeader(event) → "Nostr <base64>"
//
// We decode, verify kind=27235, verifyEvent, then check u / method / payload
// tags against the actual request URL / method / body.
const verifyNip98Auth = async (req: Request): Promise<string | null> => {
const authHeader = req.headers.authorization
if (!authHeader) return null
// Format: "Nostr <base64>"
const match = authHeader.match(/^Nostr\s+(.+)$/)
if (!match) return null
// Decode base64
let eventJson: string
try {
eventJson = Buffer.from(match[1], 'base64').toString('utf-8')
} catch {
return null
}
// Parse event
let event: any
try {
event = JSON.parse(eventJson)
} catch {
return null
}
// Must be kind 27235 (HTTP Auth)
if (event.kind !== 27235) return null
// Verify event signature and id hash
if (!verifyEvent(event)) return null
const tags = event.tags || []
// Find required tags
const uTag = tags.find((t: string[]) => t[0] === 'u')
const methodTag = tags.find((t: string[]) => t[0] === 'method')
const payloadTag = tags.find((t: string[]) => t[0] === 'payload')
// Build the full URL the server received
const expectedUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
// u tag must match the request URL exactly
if (!uTag || uTag[1] !== expectedUrl) return null
// method tag must match the HTTP method (upper case)
if (!methodTag || methodTag[1] !== req.method.toUpperCase()) return null
// For requests with a body, check payload tag is the SHA256 of the body
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method.toUpperCase())) {
if (req.body && Object.keys(req.body).length > 0) {
const bodyStr = JSON.stringify(req.body)
const expectedPayload = crypto.createHash('sha256').update(bodyStr).digest('hex')
if (!payloadTag || payloadTag[1] !== expectedPayload) return null
}
}
return event.pubkey as string
}
// ── Endpoints ──────────────────────────────────────────────────────────
export const server: express.Application = express()
// Behind a TLS-terminating reverse proxy (traefik in the ansible deploy).
// Without this, req.protocol stays "http" and verifyNip98Auth builds an
// expectedUrl of http://…, which no browser client will ever sign (clients
// sign https://…). Trust one proxy hop so req.protocol honors
// X-Forwarded-Proto and NIP-98 URL matching works.
server.set('trust proxy', 1)
// CORS middleware for browser-facing routes only.
// The browser hits /subscription with an Authorization header and Content-Type:
// application/json, which triggers a CORS preflight. Answer it and allow the
// configured origin so the client can register.
// Server-to-server routes (/notify) intentionally do NOT get CORS headers.
const corsMiddleware = (req: Request, res: Response, next: NextFunction) => {
// Skip server-to-server routes
if (req.path.startsWith('/notify')) {
return next()
}
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN)
res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
res.setHeader('Access-Control-Max-Age', '86400')
res.setHeader('Vary', 'Origin')
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
next()
}
server.use('/', corsMiddleware)
server.use(express.json())
// Rate limit for registration endpoints
server.use(
'/subscription',
rateLimit({
limit: 30,
windowMs: 5 * 60 * 1000,
validate: { xForwardedForHeader: false },
})
)
// Rate limit for notify endpoint
server.use(
'/notify',
rateLimit({
limit: 300,
windowMs: 60 * 1000,
validate: { xForwardedForHeader: false },
})
)
type Handler = (req: Request, res: Response) => Promise<any>
const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handler: Handler) => {
server[method](
path,
instrument(path, async (req: Request, res: Response, next: NextFunction) => {
try {
await handler(req, res)
} catch (e) {
next(e)
}
})
)
}
addRoute('get', '/', async (req: Request, res: Response) => {
res.json({
name: 'Mailship',
description: 'Email notification server for Nostr',
pubkey: await appSigner.getPubkey(),
software: 'https://gitea.coracle.social/mplorentz/mailship',
})
})
// Look up an existing email subscription for the authenticated pubkey, so
// clients can avoid re-registering (and re-confirming) when settings
// haven't changed. Requires NIP-98 HTTP auth proving the caller controls
// the pubkey.
addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
const pubkey = await verifyNip98Auth(req)
if (!pubkey) {
return res.status(401).json({ error: 'NIP-98 authorization required' })
}
const sub = await getSubscriptionByPubkey(pubkey)
if (!sub) {
return res.status(404).json({ error: 'Subscription not found' })
}
const callback = `${BASE_URL}/notify/${sub.id}`
res.json({
key: sub.key,
callback,
email: sub.email,
frequency: sub.frequency,
confirmed: Boolean(sub.confirmed_at),
})
})
// Subscribe to email digests (idempotent PUT upsert). Requires NIP-98 HTTP
// auth proving the caller controls the pubkey — the pubkey is extracted from
// the auth event, not from the request body.
addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
const { email, frequency } = req.body
const pubkey = await verifyNip98Auth(req)
if (!pubkey) {
return res.status(401).json({ error: 'NIP-98 authorization required' })
}
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'A valid email address is required' })
}
if (!['daily', 'weekly'].includes(frequency)) {
return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
}
try {
const result = await registerSubscription({ pubkey, email, frequency })
res.json(result)
} catch (error: any) {
// The subscription was still created, but sending the confirmation email
// may have failed. Always log it so SMTP issues are visible.
console.error('Failed to send confirmation email for', pubkey, error?.message || error)
// Look up the actual subscription key from the DB
const sub = await getSubscriptionByPubkey(pubkey)
if (sub) {
const callback = `${BASE_URL}/notify/${sub.id}`
res.json({ key: sub.key, callback })
} else {
console.error('Failed to register subscription:', error)
res.status(500).json({ error: 'Failed to register subscription' })
}
}
})
// Delete subscription. Requires NIP-98 HTTP auth proving the caller controls
// the pubkey that owns this subscription.
addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => {
const { key } = req.params
const pubkey = await verifyNip98Auth(req)
if (!pubkey) {
return res.status(401).json({ error: 'NIP-98 authorization required' })
}
const sub = await getSubscriptionByKey(key)
if (!sub) {
return res.status(404).json({ error: 'Subscription not found' })
}
if (sub.pubkey !== pubkey) {
return res.status(403).json({ error: 'Forbidden: you do not own this subscription' })
}
await unsubscribeAction({ token: key })
res.json({ ok: true })
})
// NIP-9a relay push callback
addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
const { id, relay, event } = req.body
if (!id || !relay) {
return res.status(400).json({ error: 'id and relay are required' })
}
// Reject non-ws:// relay URLs. load() from @welshman/net throws
// Invalid relay url asynchronously inside a batcher timer, which
// escapes the route's try/catch and becomes an unhandledRejection
// that would crash the server. Validate early to avoid calling
// load() with an unsupported scheme.
if (!isRelayUrl(relay)) {
return res.status(400).json({ error: 'Invalid relay URL. Only wss:// or ws:// relays are supported.' })
}
const sub = await getSubscriptionById(req.params.id)
if (!sub) {
// 404 signals the relay to delete the subscription
return res.status(404).json({ error: 'Subscription not found' })
}
if (!sub.confirmed_at || sub.unsubscribed_at) {
return res.status(404).json({ error: 'Subscription not active' })
}
try {
let storedEvent = event
if (storedEvent) {
// If the subscription requested include_event, verify and use it directly
if (storedEvent.id !== id || !validEvent(storedEvent)) {
return res.status(400).json({ error: 'Invalid event' })
}
} else {
// Otherwise fetch the full event from the relay
const [fetched] = await load({
relays: [relay],
filters: getIdFilters([id]),
})
storedEvent = fetched
if (!storedEvent) {
// Event not found at relay — don't 404, reflect that nothing was stored
return res.json({ ok: true, stored: false })
}
}
const stored = await insertEvent(id, sub.id, storedEvent, relay)
return res.json({ ok: true, stored })
} catch (error) {
console.error(`Failed to process notification for subscription ${sub.id}:`, error)
return res.status(500).json({ error: 'Internal server error' })
}
})
// ── Branding helper ──────────────────────────────────────────────────────
const brandingVars = () => ({
brandName: BRAND_NAME,
brandAccent: BRAND_ACCENT,
brandLogo: BRAND_LOGO,
settingsUrl: `${EVENT_VIEWER_URL}/settings/alerts`,
})
// Confirmation
addRoute('get', '/confirm', async (req: Request, res: Response) => {
if (typeof req.query.token !== 'string') {
return res.send(
await render('pages/confirm-error.html', {
message: 'No confirmation token was provided. Please check the link in your email and try again.',
...brandingVars(),
})
)
}
try {
const result = await confirmSubscriptionAction({ token: req.query.token })
if (result.alreadyConfirmed) {
return res.send(await render('pages/confirm-already.html', {
...brandingVars(),
}))
}
res.send(await render('pages/confirm-success.html', {
...brandingVars(),
}))
} catch (error) {
const isActionError = error instanceof ActionError
const message = isActionError ? String(error) : 'Oops, something went wrong on our end!'
res.send(await render('pages/confirm-error.html', {
message,
...brandingVars(),
}))
if (!isActionError) {
throw error
}
}
})
// Unsubscribe
addRoute('get', '/unsubscribe', async (req: Request, res: Response) => {
try {
await unsubscribeAction({ token: req.query.token as string })
} catch (error) {
// pass
}
res.send(await render('pages/unsubscribe.html'))
})
server.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err) {
if (err instanceof ActionError) {
res.status(400).json({ error: err.message })
} else {
console.log('Unhandled error', err)
res.status(500).json({ error: 'Internal server error' })
}
} else {
next()
}
})
// Validate an event's signature and that its id hash matches (defense against
// a malicious relay forwarding tampered content via include_event).
const validEvent = (event: any) => {
if (!event || typeof event !== 'object') return false
return verifyEvent(event)
}