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.
This commit is contained in:
71
memento-note/lib/marketing/unsubscribe-token.ts
Normal file
71
memento-note/lib/marketing/unsubscribe-token.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user