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.
72 lines
2.4 KiB
TypeScript
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',
|
|
}
|
|
}
|