2026-08-18 16:21:22 +00:00
|
|
|
import { instrument } from 'succinct-async'
|
|
|
|
|
import express, { Request, Response, NextFunction } from 'express'
|
|
|
|
|
import rateLimit from 'express-rate-limit'
|
2026-09-14 20:08:40 +00:00
|
|
|
import { appSigner, BASE_URL, BRAND_NAME, BRAND_ACCENT, BRAND_LOGO, EVENT_VIEWER_URL, CORS_ORIGIN } from './env.js'
|
2026-08-18 16:21:22 +00:00
|
|
|
import { render } from './templates.js'
|
|
|
|
|
import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js'
|
2026-08-18 16:26:07 +00:00
|
|
|
import { getSubscriptionById, insertEvent, getSubscriptionByKey, getSubscriptionByPubkey } from './database.js'
|
2026-08-18 16:21:22 +00:00
|
|
|
import { load } from '@welshman/net'
|
|
|
|
|
import { getIdFilters } from '@welshman/util'
|
2026-09-14 17:04:26 +00:00
|
|
|
import crypto from 'crypto'
|
2026-08-27 15:28:03 +00:00
|
|
|
import { verifyEvent } from 'nostr-tools/pure'
|
2026-08-18 16:21:22 +00:00
|
|
|
|
2026-09-14 17:04:26 +00:00
|
|
|
// ── 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 ──────────────────────────────────────────────────────────
|
2026-08-18 16:21:22 +00:00
|
|
|
|
|
|
|
|
export const server: express.Application = express()
|
|
|
|
|
|
2026-09-10 15:29:18 +00:00
|
|
|
// CORS middleware for browser-facing routes only.
|
2026-08-20 16:42:31 +00:00
|
|
|
// 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.
|
2026-09-10 15:29:18 +00:00
|
|
|
// 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()
|
|
|
|
|
}
|
2026-08-20 16:42:31 +00:00
|
|
|
|
2026-09-10 15:29:18 +00:00
|
|
|
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN)
|
2026-09-03 15:47:21 +00:00
|
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
|
2026-08-20 16:42:31 +00:00
|
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
|
|
|
|
res.setHeader('Access-Control-Max-Age', '86400')
|
2026-09-10 15:29:18 +00:00
|
|
|
res.setHeader('Vary', 'Origin')
|
2026-08-20 16:42:31 +00:00
|
|
|
|
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
|
|
|
return res.sendStatus(204)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
next()
|
2026-09-10 15:29:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
server.use('/', corsMiddleware)
|
2026-08-20 16:42:31 +00:00
|
|
|
|
2026-08-18 16:21:22 +00:00
|
|
|
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>
|
|
|
|
|
|
2026-09-03 15:47:21 +00:00
|
|
|
const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handler: Handler) => {
|
2026-08-18 16:21:22 +00:00
|
|
|
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) => {
|
2026-08-18 16:35:20 +00:00
|
|
|
res.json({
|
|
|
|
|
name: 'Mailship',
|
|
|
|
|
description: 'Email notification server for Nostr',
|
|
|
|
|
pubkey: await appSigner.getPubkey(),
|
|
|
|
|
software: 'https://gitea.coracle.social/mplorentz/mailship',
|
|
|
|
|
})
|
2026-08-18 16:21:22 +00:00
|
|
|
})
|
|
|
|
|
|
2026-09-14 17:04:26 +00:00
|
|
|
// 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.
|
2026-09-03 15:47:21 +00:00
|
|
|
addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
|
2026-09-14 17:04:26 +00:00
|
|
|
const pubkey = await verifyNip98Auth(req)
|
2026-09-03 15:47:21 +00:00
|
|
|
|
2026-09-14 17:04:26 +00:00
|
|
|
if (!pubkey) {
|
|
|
|
|
return res.status(401).json({ error: 'NIP-98 authorization required' })
|
2026-09-03 15:47:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sub = await getSubscriptionByPubkey(pubkey)
|
|
|
|
|
|
|
|
|
|
if (!sub) {
|
|
|
|
|
return res.status(404).json({ error: 'Subscription not found' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 20:08:40 +00:00
|
|
|
const callback = `${BASE_URL}/notify/${sub.id}`
|
2026-09-03 15:47:21 +00:00
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
key: sub.key,
|
|
|
|
|
callback,
|
|
|
|
|
email: sub.email,
|
|
|
|
|
frequency: sub.frequency,
|
|
|
|
|
confirmed: Boolean(sub.confirmed_at),
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-14 17:04:26 +00:00
|
|
|
// 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.
|
2026-09-03 15:47:21 +00:00
|
|
|
addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
|
2026-09-14 17:04:26 +00:00
|
|
|
const { email, frequency } = req.body
|
|
|
|
|
|
|
|
|
|
const pubkey = await verifyNip98Auth(req)
|
|
|
|
|
|
|
|
|
|
if (!pubkey) {
|
|
|
|
|
return res.status(401).json({ error: 'NIP-98 authorization required' })
|
|
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
|
|
|
|
|
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) {
|
2026-08-25 19:15:33 +00:00
|
|
|
// 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)
|
|
|
|
|
|
2026-08-18 16:26:07 +00:00
|
|
|
// Look up the actual subscription key from the DB
|
|
|
|
|
const sub = await getSubscriptionByPubkey(pubkey)
|
|
|
|
|
if (sub) {
|
2026-09-14 20:08:40 +00:00
|
|
|
const callback = `${BASE_URL}/notify/${sub.id}`
|
2026-08-18 16:26:07 +00:00
|
|
|
res.json({ key: sub.key, callback })
|
2026-08-18 16:21:22 +00:00
|
|
|
} else {
|
|
|
|
|
console.error('Failed to register subscription:', error)
|
|
|
|
|
res.status(500).json({ error: 'Failed to register subscription' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-14 17:04:26 +00:00
|
|
|
// Delete subscription. Requires NIP-98 HTTP auth proving the caller controls
|
|
|
|
|
// the pubkey that owns this subscription.
|
2026-08-18 16:21:22 +00:00
|
|
|
addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => {
|
|
|
|
|
const { key } = req.params
|
|
|
|
|
|
2026-09-14 17:04:26 +00:00
|
|
|
const pubkey = await verifyNip98Auth(req)
|
|
|
|
|
|
|
|
|
|
if (!pubkey) {
|
|
|
|
|
return res.status(401).json({ error: 'NIP-98 authorization required' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 16:21:22 +00:00
|
|
|
const sub = await getSubscriptionByKey(key)
|
|
|
|
|
|
|
|
|
|
if (!sub) {
|
|
|
|
|
return res.status(404).json({ error: 'Subscription not found' })
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 17:04:26 +00:00
|
|
|
if (sub.pubkey !== pubkey) {
|
|
|
|
|
return res.status(403).json({ error: 'Forbidden: you do not own this subscription' })
|
|
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
|
|
|
|
|
await unsubscribeAction({ token: key })
|
|
|
|
|
res.json({ ok: true })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// NIP-9a relay push callback
|
|
|
|
|
addRoute('post', '/notify/:id', async (req: Request, res: Response) => {
|
2026-08-27 15:28:03 +00:00
|
|
|
const { id, relay, event } = req.body
|
2026-08-18 16:21:22 +00:00
|
|
|
|
|
|
|
|
if (!id || !relay) {
|
|
|
|
|
return res.status(400).json({ error: 'id and relay are required' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-08-27 15:28:03 +00:00
|
|
|
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]),
|
|
|
|
|
})
|
2026-08-18 16:21:22 +00:00
|
|
|
|
2026-08-27 15:28:03 +00:00
|
|
|
storedEvent = fetched
|
|
|
|
|
|
|
|
|
|
if (!storedEvent) {
|
2026-09-14 17:42:11 +00:00
|
|
|
// Event not found at relay — don't 404, reflect that nothing was stored
|
|
|
|
|
return res.json({ ok: true, stored: false })
|
2026-08-27 15:28:03 +00:00
|
|
|
}
|
2026-08-18 16:21:22 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-27 15:28:03 +00:00
|
|
|
const stored = await insertEvent(id, sub.id, storedEvent, relay)
|
2026-08-18 16:21:22 +00:00
|
|
|
|
|
|
|
|
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' })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Confirmation
|
|
|
|
|
addRoute('get', '/confirm', async (req: Request, res: Response) => {
|
|
|
|
|
if (typeof req.query.token !== 'string') {
|
|
|
|
|
return res.send(
|
|
|
|
|
await render('pages/confirm-error.html', {
|
2026-09-03 15:47:21 +00:00
|
|
|
message: 'No confirmation token was provided. Please check the link in your email and try again.',
|
|
|
|
|
brandName: BRAND_NAME,
|
|
|
|
|
brandAccent: BRAND_ACCENT,
|
|
|
|
|
brandLogo: BRAND_LOGO,
|
|
|
|
|
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
|
2026-08-18 16:21:22 +00:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await confirmSubscriptionAction({ token: req.query.token })
|
|
|
|
|
|
2026-08-27 14:30:46 +00:00
|
|
|
res.send(await render('pages/confirm-success.html', {
|
|
|
|
|
brandName: BRAND_NAME,
|
|
|
|
|
brandAccent: BRAND_ACCENT,
|
|
|
|
|
brandLogo: BRAND_LOGO,
|
|
|
|
|
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
|
|
|
|
|
}))
|
2026-08-18 16:21:22 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
const isActionError = error instanceof ActionError
|
|
|
|
|
const message = isActionError ? String(error) : 'Oops, something went wrong on our end!'
|
|
|
|
|
|
2026-09-03 15:47:21 +00:00
|
|
|
res.send(await render('pages/confirm-error.html', {
|
|
|
|
|
message,
|
|
|
|
|
brandName: BRAND_NAME,
|
|
|
|
|
brandAccent: BRAND_ACCENT,
|
|
|
|
|
brandLogo: BRAND_LOGO,
|
|
|
|
|
settingsUrl: `${EVENT_VIEWER_URL.replace(/\/$/, '')}/settings/alerts`,
|
|
|
|
|
}))
|
2026-08-18 16:21:22 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 16:26:07 +00:00
|
|
|
res.send(await render('pages/unsubscribe.html'))
|
2026-08-18 16:21:22 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
}
|
2026-08-27 15:28:03 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|