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'
|
|
|
|
|
import { appSigner } from './env.js'
|
|
|
|
|
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'
|
|
|
|
|
|
|
|
|
|
// Endpoints
|
|
|
|
|
|
|
|
|
|
export const server: express.Application = express()
|
|
|
|
|
|
|
|
|
|
server.use(express.json())
|
|
|
|
|
|
|
|
|
|
server.use(express.static('web/dist'))
|
|
|
|
|
|
|
|
|
|
// 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' | '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.send(await render('../web/dist/index.html'))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Subscribe to email digests
|
|
|
|
|
addRoute('post', '/subscription/email', async (req: Request, res: Response) => {
|
|
|
|
|
const { email, frequency, pubkey } = req.body
|
|
|
|
|
|
|
|
|
|
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"' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!pubkey) {
|
|
|
|
|
return res.status(400).json({ error: 'pubkey is required' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO: Verify NIP-98 auth header
|
|
|
|
|
// const auth = req.headers.authorization
|
|
|
|
|
// if (!auth) return res.status(401).json({ error: 'NIP-98 authorization required' })
|
|
|
|
|
// Verify using @welshman/util makeHttpAuth
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await registerSubscription({ pubkey, email, frequency })
|
|
|
|
|
res.json(result)
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
// If the error is just Postmark failing, the subscription was still created
|
2026-08-18 16:26:07 +00:00
|
|
|
// Look up the actual subscription key from the DB
|
|
|
|
|
const sub = await getSubscriptionByPubkey(pubkey)
|
|
|
|
|
if (sub) {
|
|
|
|
|
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
|
|
|
|
|
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' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Delete subscription
|
|
|
|
|
addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => {
|
|
|
|
|
const { key } = req.params
|
|
|
|
|
|
|
|
|
|
const sub = await getSubscriptionByKey(key)
|
|
|
|
|
|
|
|
|
|
if (!sub) {
|
|
|
|
|
return res.status(404).json({ error: 'Subscription not found' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TODO: Verify NIP-98 auth header matches sub.pubkey
|
|
|
|
|
|
|
|
|
|
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 } = req.body
|
|
|
|
|
|
|
|
|
|
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' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch the full event from the relay
|
|
|
|
|
try {
|
|
|
|
|
const [event] = await load({
|
|
|
|
|
relays: [relay],
|
|
|
|
|
filters: getIdFilters([id]),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!event) {
|
|
|
|
|
// Event not found at relay — don't 404, just skip
|
|
|
|
|
return res.json({ ok: true, skipped: true })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stored = await insertEvent(id, sub.id, event, 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' })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 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.',
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await confirmSubscriptionAction({ token: req.query.token })
|
|
|
|
|
|
|
|
|
|
res.send(await render('pages/confirm-success.html'))
|
|
|
|
|
} 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 }))
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
})
|