Files
Momento/memento-note/lib/marketing/unsubscribe-token.ts
Antigravity 360e58e473
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m58s
CI / Deploy production (on server) (push) Successful in 26s
feat(admin): consentement aux annonces et désabonnement sans connexion
Les comptes déjà créés ne sont pas inscrits. Le choix est facultatif à l’inscription et dans les paramètres. Un lien public demande confirmation avant d’arrêter les actualités, sans toucher aux messages de compte.
2026-09-06 09:23:10 +00:00

72 lines
2.4 KiB
TypeScript

import { createHmac, timingSafeEqual } from 'crypto'
const TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000
function getSecret(): string {
const secret = process.env.NEXTAUTH_SECRET
if (!secret) {
throw new Error('NEXTAUTH_SECRET is required for unsubscribe tokens')
}
return secret
}
export type UnsubscribeTokenPayload = {
userId: string
issuedAt: number
}
export function createUnsubscribeToken(userId: string): string {
const issuedAt = Date.now()
const inner = Buffer.from(`${userId}.${issuedAt}`, 'utf8').toString('base64url')
const sig = createHmac('sha256', getSecret()).update(inner).digest('base64url')
return `${inner}.${sig}`
}
export function verifyUnsubscribeToken(token: string): UnsubscribeTokenPayload | null {
try {
const trimmed = token.trim()
const lastDot = trimmed.lastIndexOf('.')
if (lastDot <= 0) return null
const sig = trimmed.slice(lastDot + 1)
const inner = trimmed.slice(0, lastDot)
const expected = createHmac('sha256', getSecret()).update(inner).digest('base64url')
const sigBuf = Buffer.from(sig)
const expectedBuf = Buffer.from(expected)
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
return null
}
const decoded = Buffer.from(inner, 'base64url').toString('utf8')
const firstDot = decoded.indexOf('.')
if (firstDot <= 0) return null
const userId = decoded.slice(0, firstDot)
const issuedAt = Number(decoded.slice(firstDot + 1))
if (!userId || !Number.isFinite(issuedAt)) return null
if (Date.now() - issuedAt > TOKEN_TTL_MS) return null
return { userId, issuedAt }
} catch {
return null
}
}
export function publicAppOrigin(): string {
return (process.env.NEXTAUTH_URL || '').replace(/\/$/, '')
}
export function unsubscribePageUrl(token: string): string {
return `${publicAppOrigin()}/unsubscribe?token=${encodeURIComponent(token)}`
}
export function unsubscribeOneClickUrl(token: string): string {
return `${publicAppOrigin()}/api/marketing/unsubscribe?token=${encodeURIComponent(token)}`
}
/** Headers for future campaign messages (RFC 8058). */
export function listUnsubscribeHeaders(token: string): Record<string, string> {
const page = unsubscribePageUrl(token)
const oneClick = unsubscribeOneClickUrl(token)
return {
'List-Unsubscribe': `<${oneClick}>, <${page}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
}
}