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 " // // 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 => { const authHeader = req.headers.authorization if (!authHeader) return null // Format: "Nostr " 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 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) }