import { Router } from 'express'; import type { Cookie } from 'playwright'; import { z } from 'zod'; import type { WebContext } from '../context.js'; // Keep this server-side parser local: the server's tsconfig intentionally // builds only src/, while the dashboard compiles the shared API contract. const RedditCookieImportRequestSchema = z.strictObject({ cookies: z.array(z.strictObject({ name: z.string().min(1).max(256), value: z.string().max(16_384), domain: z.string().min(1).max(253), path: z.string().min(1).max(4_096).default('/'), expires: z.number().finite().optional(), httpOnly: z.boolean().optional(), secure: z.boolean().optional(), sameSite: z.enum(['strict', 'lax', 'none', 'no_restriction', 'unspecified']).optional(), })).min(1).max(100), matrix: z.strictObject({ homeserver: z.string().url(), accessToken: z.string().min(1).max(16_384), userId: z.string().min(1).max(512), capturedAt: z.number().finite(), }), }); function isRedditDomain(domain: string): boolean { const normalised = domain.toLowerCase().replace(/^\./, ''); return normalised === 'reddit.com' || normalised.endsWith('.reddit.com'); } function toPlaywrightCookie(cookie: { name: string; value: string; domain: string; path: string; expires?: number; httpOnly?: boolean; secure?: boolean; sameSite?: string }): Cookie { return { name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, expires: cookie.expires ?? -1, httpOnly: cookie.httpOnly ?? false, secure: cookie.secure ?? true, sameSite: cookie.sameSite === 'strict' ? 'Strict' : cookie.sameSite === 'none' || cookie.sameSite === 'no_restriction' ? 'None' : 'Lax', }; } export function createLoginRouter(ctx: WebContext): Router { const router = Router(); router.get('/login/status', async (_req, res) => { const runningAccounts = ctx.services.redditAccounts?.list().filter((account) => account.isRunning) ?? []; if (runningAccounts.length > 0) { const only = runningAccounts.length === 1 ? runningAccounts[0] : null; res.json({ ready: true, loggedIn: true, status: 'authenticated', accountId: only?.accountId ?? null, username: only?.redditUsername ?? null, }); return; } const browser = ctx.services.browser; if (!browser) { res.status(503).json({ ready: false, error: 'browser not initialised' }); return; } try { const accountAuth = ctx.services.accountAuth; const fullyAuthenticated = accountAuth ? await accountAuth.ensureFullyAuthenticated() : await browser.isSessionValid(); if (fullyAuthenticated) { const snapshot = accountAuth?.getSnapshot(); const username = snapshot?.redditUsername ?? await browser.getAuthenticatedUsername(); res.json({ ready: true, loggedIn: true, status: 'authenticated', ...(snapshot?.accountId ? { accountId: snapshot.accountId } : {}), username, }); return; } await browser.isSessionValid(); const browserState = browser.getSessionState(); res.json({ ready: true, loggedIn: false, status: browserState === 'unreachable' ? 'unreachable' : 'login-required', username: null, }); } catch (error) { ctx.logger.error('Failed to check login status:', error); res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' }); } }); router.post('/login/session-import', async (req, res) => { const importer = ctx.services.sessionImporter; if (!importer) { res.status(503).json({ ok: false, error: 'session importer not initialised' }); return; } const payload = RedditCookieImportRequestSchema.safeParse(req.body); if (!payload.success) { res.status(400).json({ ok: false, error: 'Invalid Reddit session export' }); return; } const cookies = payload.data.cookies.filter((cookie) => isRedditDomain(cookie.domain)); if (cookies.length === 0) { res.status(400).json({ ok: false, error: 'The export did not contain Reddit cookies' }); return; } try { const accounts = ctx.services.redditAccounts; if (!accounts) { await importer.import(cookies.map(toPlaywrightCookie), payload.data.matrix); ctx.logger.info(`Imported ${cookies.length} Reddit session cookies and verified the browser session`); res.json({ ok: true, status: 'authenticated' }); return; } const account = await accounts.importSession(cookies.map(toPlaywrightCookie), payload.data.matrix); ctx.logger.info(`Imported ${cookies.length} Reddit session cookies and verified the browser session`); res.json({ ok: true, status: 'authenticated', account }); } catch (error) { ctx.logger.error('Failed to import Reddit session cookies:', error); const message = error instanceof Error ? error.message : 'Could not import Reddit session'; res.status(401).json({ ok: false, error: message }); } }); router.get('/accounts', (_req, res) => { const accounts = ctx.services.redditAccounts; if (!accounts) { res.status(503).json({ error: 'Reddit account service not initialised' }); return; } res.json(accounts.list()); }); router.post('/accounts/:accountId/activate', async (req, res) => { const accounts = ctx.services.redditAccounts; if (!accounts) { res.status(503).json({ ok: false, error: 'Reddit account service not initialised' }); return; } try { const account = await accounts.activate(decodeURIComponent(req.params.accountId)); ctx.logger.info(`Activated Reddit account u/${account.redditUsername}`); res.json({ ok: true, account }); } catch (error) { ctx.logger.error('Failed to activate Reddit account:', error); res.status(401).json({ ok: false, error: error instanceof Error ? error.message : 'Could not activate Reddit account' }); } }); router.delete('/accounts/:accountId', async (req, res) => { const accounts = ctx.services.redditAccounts; if (!accounts) { res.status(503).json({ ok: false, error: 'Reddit account service not initialised' }); return; } const accountId = decodeURIComponent(req.params.accountId); try { const account = await accounts.remove(accountId); ctx.logger.info(`Removed Reddit account u/${account.redditUsername} and the content it owned`); res.json({ ok: true, account }); } catch (error) { const message = error instanceof Error ? error.message : 'Could not remove Reddit account'; if (message === 'Reddit account not found') { res.status(404).json({ ok: false, error: message }); return; } ctx.logger.error('Failed to remove Reddit account:', error); res.status(500).json({ ok: false, error: message }); } }); return router; }