feat: replace browser login with imported Reddit sessions

This commit is contained in:
jjbrunton committed 2026-08-08 16:48:59 +01:00
1 parent 2b0545afcf
commit c483c7b8be
76 files changed
+2048 -2828

No files matched your search

+68 -37
View File
@@ -1,62 +1,93 @@
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();
// Info the dashboard uses to decide whether to show the VNC iframe and
// which port to point it at.
router.get('/login/info', (_req, res) => {
const novncPort = parseInt(process.env.NOVNC_PORT ?? '6080', 10);
res.json({
vncEnabled: Boolean(process.env.DISPLAY),
novncPort,
publicUrl: process.env.NOVNC_PUBLIC_URL || null,
passwordRequired: Boolean(process.env.VNC_PASSWORD),
});
});
router.get('/login/status', async (_req, res) => {
const browser = ctx.services.browser;
const accountAuth = ctx.services.accountAuth;
if (!browser || !accountAuth) {
if (!browser) {
res.status(503).json({ ready: false, error: 'browser not initialised' });
return;
}
try {
const loggedIn = await accountAuth.ensureFullyAuthenticated();
const browserState = browser.getSessionState();
res.json({
ready: true,
loggedIn,
status: loggedIn
? 'authenticated'
: browserState === 'unreachable'
? 'unreachable'
: 'login-required',
});
const loggedIn = await browser.isSessionValid();
const username = loggedIn ? await browser.getAuthenticatedUsername() : null;
res.json({ ready: true, loggedIn, status: browser.getSessionState(), username });
} catch (error) {
ctx.logger.error('Failed to check login status:', error);
res.status(500).json({ ready: true, loggedIn: false, error: 'check failed' });
}
});
// Restart into a completely native Chrome process. The container supervisor
// does not enable DevTools until the human explicitly completes the handoff.
router.post('/login/start', async (_req, res) => {
const nativeLogin = ctx.services.nativeLogin;
if (!nativeLogin) {
res.status(503).json({ ok: false, error: 'native login unavailable' });
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 {
ctx.services.accountAuth?.invalidate('Native Reddit login requested');
nativeLogin.request();
ctx.logger.info('Restarting into native Chrome login mode; bot logic remains locked');
res.status(202).json({ ok: true, restarting: true });
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' });
} catch (error) {
ctx.logger.error('Failed to start native login mode:', error);
res.status(500).json({ ok: false, error: 'handoff failed' });
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 });
}
});