import 'npm:iterator-polyfill' // Polyfill AsyncIterator import { FreshContext } from '$fresh/server.ts' import { Contact, type Mail, send } from '@cohabit/cohamail/mod.ts' import { magicLinkTemplate } from '@cohabit/cohamail/templates/mod.ts' import { SessionHandlers } from '../../../src/session/mod.ts' import { respondApi } from '../../../src/utils.ts' import { sleep } from '@jotsr/delayed' import { User } from '@cohabit/ressources_manager/src/models/mod.ts' import { db } from '../../../src/db/mod.ts' type MagicLinkInfos = { remoteId: string email: string timestamp: number } export async function getUserByMail(email: string): Promise { const [user] = await db.ressource.user .list((user) => user.mail === email) .take(1) .toArray() return user } export const handler: SessionHandlers = { async POST(request, ctx) { const { email } = await request.json() as { email: string } // check email before continue if (!/\S+@\S+\.\S+/.test(email)) { return respondApi('error', new SyntaxError('empty or invalid email'), 400) } const user = await getUserByMail(email) // generate magic link const token = crypto.randomUUID() const endpoint = `${ctx.url.origin}/api/magiclink?token=${token}&redirect=/profil` // save token to session ctx.state.session.flash(`MAGIC_LINK__${token}`, { email, remoteId: remoteId(request, ctx), timestamp: Date.now(), }) // send mail to user try { if (user) { const ip = ctx.remoteAddr.hostname const device = request.headers.get('Sec-Ch-Ua-Platform') ?? undefined await sendMagicLink(user, { device, ip, endpoint }) } else { //! perform wait to prevent time attacks await sleep(Math.random() * 5_000 + 2_000) //between 2s and 7s } return respondApi('success') } catch (error) { console.error('MAGIC_LINK_SENDING', error) return respondApi( 'error', new Error(`unable to send mail to ${email}`), 500, ) } }, async GET(request, ctx) { const token = ctx.url.searchParams.get('token') const redirect = ctx.url.searchParams.get('redirect') // no token if (token === null) { return respondApi('error', 'no token provided', 400) } // wrong or timeout token const entry = ctx.state.session.get(`MAGIC_LINK__${token}`) const lifespan = Date.now() - 10 * 60 * 1_000 // ten minutes if (entry === undefined || entry.timestamp < lifespan) { return respondApi('error', 'wrong token or timeout exceeded', 401) } // check remote id (same user/machine that has query the token) if (entry.remoteId === remoteId(request, ctx)) { const user = await getUserByMail(entry.email) ctx.state.session.set('user', user) if (redirect) { return Response.redirect(new URL(redirect, ctx.basePath)) } return respondApi('success', user) } return respondApi( 'error', new Error( 'invalid id, use the same device/ip to query token and verify token', ), 401, ) }, } function remoteId( { headers }: { headers: Headers }, { remoteAddr }: { remoteAddr: FreshContext['remoteAddr'] }, ): string { const forwardedAddress = headers.get('X-FORWARDED-FOR') const forwardedProto = headers.get('X-FORWARDED-PROTO') if (forwardedAddress && forwardedProto) { return `${forwardedProto}://${forwardedAddress}` } return `(${remoteAddr.transport}):${remoteAddr.hostname}:${remoteAddr.port}` } async function sendMagicLink( { firstname, lastname, mail }: User, { device, ip, endpoint }: { device?: string; ip?: string; endpoint: string }, ): Promise { const message: Mail = { from: Contact.expand('contact'), to: [Contact.fromString(`${firstname} ${lastname} <${mail}>`)], subject: 'Lien de connection pour FabLab Coh@bit', body: magicLinkTemplate.builder({ device, ip, endpoint, })!, options: { cc: [], cci: [], attachments: [], }, } await send(message) }