From feb95eb0b8c895370af86b07830c47fe71a60833 Mon Sep 17 00:00:00 2001 From: Ategon Date: Thu, 9 Jul 2026 02:28:41 -0400 Subject: [PATCH] Add sign up system connected to programming.dev --- .../client/src/components/Login/Normal.tsx | 19 +- .../components/Login/ProgrammingDevSignup.tsx | 549 ++++++++++++++ packages/server/src/api/client/index.ts | 2 + .../src/api/client/programming_dev_signup.ts | 693 ++++++++++++++++++ packages/server/src/types.ts | 8 + 5 files changed, 1267 insertions(+), 4 deletions(-) create mode 100644 packages/client/src/components/Login/ProgrammingDevSignup.tsx create mode 100644 packages/server/src/api/client/programming_dev_signup.ts diff --git a/packages/client/src/components/Login/Normal.tsx b/packages/client/src/components/Login/Normal.tsx index f2ee23d..2983879 100644 --- a/packages/client/src/components/Login/Normal.tsx +++ b/packages/client/src/components/Login/Normal.tsx @@ -3,13 +3,16 @@ import { type DialogData } from "../../contexts/DialogContext"; import { useCallback, useState } from "react"; import { RECOMMENDED_INSTANCES } from "../../lib/constants"; import { Button } from "../core/Button"; +import { ProgrammingDevSignup } from "./ProgrammingDevSignup"; export const Normal = ({ state, }: { state: DialogData<"LOGIN_INFO"> & { mode: "NORMAL" }; }) => { - const [step, setStep] = useState<"WELCOME" | "NEW">("WELCOME"); + const [step, setStep] = useState< + "WELCOME" | "NEW" | "PROGRAMMING_DEV_SIGNUP" + >("WELCOME"); const WelcomeStep = useCallback( () => ( @@ -62,7 +65,7 @@ export const Normal = ({

), - [] + [], ); const NewStep = useCallback( @@ -96,9 +99,15 @@ export const Normal = ({ Return to this page after registering an account ), - [] + [], ); + if (step === "PROGRAMMING_DEV_SIGNUP") { + return ( + setStep("NEW")} /> + ); + } + return ( <> @@ -108,7 +117,9 @@ export const Normal = ({ {step === "WELCOME" && ( <> - + diff --git a/packages/client/src/components/Login/ProgrammingDevSignup.tsx b/packages/client/src/components/Login/ProgrammingDevSignup.tsx new file mode 100644 index 0000000..541e363 --- /dev/null +++ b/packages/client/src/components/Login/ProgrammingDevSignup.tsx @@ -0,0 +1,549 @@ +import { Alert, Input, Link, ModalBody, ModalFooter, Spinner } from "@heroui/react"; +import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "../core/Button"; + +type SignupView = + | "FORM" + | "REGISTERING" + | "WAITING_ACCOUNT_CREATION" + | "SENDING_HANDOFF" + | "WAITING_HANDOFF_REPLY" + | "REDIRECTING" + | "ERROR" + | "TIMED_OUT"; + +type MetadataResponse = { + success: true; + instance: { + captchaEnabled: boolean; + registrationMode?: string; + requireEmailVerification: boolean; + }; + handoff: { + enabled: boolean; + account?: string; + }; +}; + +type Captcha = { + uuid: string; + png: string; + wav?: string; +}; + +type CaptchaResponse = { + success: true; + captcha: Captcha; +}; + +type RegisterResponse = { + success: true; + state: "ACCOUNT_READY" | "WAITING_ACCOUNT_CREATION"; +}; + +type CheckResponse = + | { + success: true; + state: "WAITING_ACCOUNT_CREATION" | "WAITING_HANDOFF_REPLY"; + } + | { + success: true; + state: "HANDOFF_READY"; + redirect: string; + }; + +type ErrorResponse = { + success: false; + error?: string; + error_message?: string; +}; + +const POLL_INTERVALS_MS = [3_000, 5_000, 10_000, 20_000, 30_000]; +const POLL_TIMEOUT_MS = 15 * 60 * 1_000; + +export const ProgrammingDevSignup = ({ + loginUrl, + onBack, +}: { + loginUrl: string; + onBack: () => void; +}) => { + const [view, setView] = useState("FORM"); + const [metadata, setMetadata] = useState(); + const [captcha, setCaptcha] = useState(); + const [loadingMetadata, setLoadingMetadata] = useState(true); + const [loadingCaptcha, setLoadingCaptcha] = useState(false); + const [error, setError] = useState(); + const [registered, setRegistered] = useState(false); + + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordVerify, setPasswordVerify] = useState(""); + const [captchaAnswer, setCaptchaAnswer] = useState(""); + + const passwordRef = useRef(password); + const pollTimer = useRef(undefined); + const pollAttempt = useRef(0); + const pollStartedAt = useRef(undefined); + + useEffect(() => { + passwordRef.current = password; + }, [password]); + + const clearPollTimer = useCallback(() => { + if (typeof pollTimer.current !== "undefined") { + window.clearTimeout(pollTimer.current); + pollTimer.current = undefined; + } + }, []); + + const blockingProblem = useMemo(() => { + if (!metadata) return undefined; + if (metadata.handoff.enabled !== true || !metadata.handoff.account) { + return "Fediverse Auth handoff is not available right now."; + } + if (metadata.instance.registrationMode !== "RequireApplication") { + return "programming.dev changed its registration flow, so automatic signup is paused."; + } + if (metadata.instance.requireEmailVerification) { + return "programming.dev now requires email verification, so this signup flow needs to be updated first."; + } + if (!metadata.instance.captchaEnabled) { + return "programming.dev changed its captcha settings, so automatic signup is paused."; + } + return undefined; + }, [metadata]); + + const reloadCaptcha = useCallback(async () => { + setLoadingCaptcha(true); + setError(undefined); + + try { + const data = await requestJson( + "/api/signup/programming-dev/captcha", + ); + setCaptcha(data.captcha); + setCaptchaAnswer(""); + } catch (e) { + setError(getErrorMessage(e, "Could not load captcha.")); + setView("ERROR"); + } finally { + setLoadingCaptcha(false); + } + }, []); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + setLoadingMetadata(true); + setError(undefined); + + try { + const data = await requestJson( + "/api/signup/programming-dev/metadata", + ); + if (cancelled) return; + + setMetadata(data); + if (data.instance.captchaEnabled) { + await reloadCaptcha(); + } + } catch (e) { + if (!cancelled) { + setError(getErrorMessage(e, "Could not load signup metadata.")); + setView("ERROR"); + } + } finally { + if (!cancelled) setLoadingMetadata(false); + } + }; + + void load(); + + return () => { + cancelled = true; + clearPollTimer(); + void fetch("/api/signup/programming-dev", { method: "DELETE" }); + }; + }, [clearPollTimer, reloadCaptcha]); + + const pollCheck = useCallback(async () => { + clearPollTimer(); + + if (!pollStartedAt.current) pollStartedAt.current = Date.now(); + + if (Date.now() - pollStartedAt.current > POLL_TIMEOUT_MS) { + setPassword(""); + setView("TIMED_OUT"); + return; + } + + if (!passwordRef.current) { + setView("TIMED_OUT"); + return; + } + + setView((current) => + current === "WAITING_ACCOUNT_CREATION" || + current === "WAITING_HANDOFF_REPLY" + ? current + : "SENDING_HANDOFF", + ); + + try { + const data = await requestJson( + "/api/signup/programming-dev/check", + { + method: "POST", + body: JSON.stringify({ password: passwordRef.current }), + }, + ); + + if (data.state === "HANDOFF_READY") { + clearPollTimer(); + setView("REDIRECTING"); + window.location.assign(data.redirect); + return; + } + + setView(data.state); + + const attempt = pollAttempt.current++; + const delay = + attempt < POLL_INTERVALS_MS.length + ? POLL_INTERVALS_MS[attempt] + : 60_000; + pollTimer.current = window.setTimeout(() => void pollCheck(), delay); + } catch (e) { + setError(getErrorMessage(e, "Could not check the account yet.")); + setView("ERROR"); + } + }, [clearPollTimer]); + + const startPolling = useCallback(() => { + pollAttempt.current = 0; + pollStartedAt.current = Date.now(); + void pollCheck(); + }, [pollCheck]); + + const cancelSignup = useCallback(() => { + clearPollTimer(); + void fetch("/api/signup/programming-dev", { method: "DELETE" }); + onBack(); + }, [clearPollTimer, onBack]); + + const submitRegister = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + + if (blockingProblem) { + setError(blockingProblem); + setView("ERROR"); + return; + } + + if (password !== passwordVerify) { + setError("Passwords do not match."); + setView("ERROR"); + return; + } + + if (!captcha) { + setError("Captcha is not ready yet."); + setView("ERROR"); + return; + } + + setView("REGISTERING"); + setError(undefined); + + try { + const data = await requestJson( + "/api/signup/programming-dev/register", + { + method: "POST", + body: JSON.stringify({ + username, + email: email || undefined, + password, + passwordVerify, + captchaUuid: captcha.uuid, + captchaAnswer, + }), + }, + ); + + setRegistered(true); + setView( + data.state === "ACCOUNT_READY" + ? "SENDING_HANDOFF" + : "WAITING_ACCOUNT_CREATION", + ); + startPolling(); + } catch (e) { + setError(getErrorMessage(e, "Could not create the account.")); + setView("ERROR"); + void reloadCaptcha(); + } + }, + [ + blockingProblem, + captcha, + captchaAnswer, + email, + password, + passwordVerify, + reloadCaptcha, + startPolling, + username, + ], + ); + + const resumePolling = useCallback(() => { + if (!password) { + setError("Enter the password for the programming.dev account to keep checking."); + return; + } + + setError(undefined); + setView("SENDING_HANDOFF"); + startPolling(); + }, [password, startPolling]); + + const busy = + view === "REGISTERING" || + view === "SENDING_HANDOFF" || + view === "WAITING_ACCOUNT_CREATION" || + view === "WAITING_HANDOFF_REPLY" || + view === "REDIRECTING"; + + return ( + <> + +
+

Create a Fediverse account

+

+ Canvas will create a programming.dev account, then login through + Fediverse Auth. +

+
+ + {loadingMetadata && ( +
+ Loading programming.dev signup... +
+ )} + + {error && } + {blockingProblem && !error && ( + + )} + + {(view === "FORM" || (!registered && view === "ERROR") || view === "REGISTERING") && ( +
+ + + + + +
+
+ {captcha ? ( + programming.dev captcha + ) : ( +
+ {loadingCaptcha ? "Loading captcha..." : "Captcha unavailable"} +
+ )} + + {captcha?.wav && ( + + Audio + + )} +
+ +
+ +
+ + +
+
+ )} + + {busy && view !== "REGISTERING" && ( +
+ + {getBusyTitle(view)} +
+ )} + + {view === "ERROR" && registered && ( +
+ +
+ + +
+
+ )} + + {view === "TIMED_OUT" && ( +
+ + +
+ + +
+
+ )} +
+ + + {!busy && ( + + )} + {busy && } + + + ); +}; + +function getBusyTitle(view: SignupView): string { + switch (view) { + case "WAITING_ACCOUNT_CREATION": + return "Waiting for account creation..."; + case "SENDING_HANDOFF": + return "Sending Fediverse Auth handoff..."; + case "WAITING_HANDOFF_REPLY": + return "Waiting for Fediverse Auth reply..."; + case "REDIRECTING": + return "Redirecting..."; + default: + return "Working..."; + } +} + + + +async function requestJson(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(url, { + ...init, + credentials: "same-origin", + headers, + }); + const data = (await response.json().catch(() => ({}))) as T | ErrorResponse; + + if (!response.ok || (isObject(data) && data.success === false)) { + throw new Error(getErrorMessage(data, response.statusText)); + } + + return data as T; +} + +function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) return error.message; + if (isObject(error)) { + const message = error.error_message || error.error; + if (typeof message === "string" && message) return message; + } + return fallback; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/server/src/api/client/index.ts b/packages/server/src/api/client/index.ts index 4b77e8d..dc5d508 100644 --- a/packages/server/src/api/client/index.ts +++ b/packages/server/src/api/client/index.ts @@ -4,6 +4,7 @@ import { OpenIDController } from "../../controllers/OpenIDController"; import { prisma } from "../../lib/prisma"; import { AuthEndpoints } from "./auth"; import { CanvasEndpoints } from "./canvas"; +import { ProgrammingDevSignupEndpoints } from "./programming_dev_signup"; import { ReportEndpoints } from "./reports"; import SentryRouter from "./sentry"; import { UserEndpoints } from "./user"; @@ -20,6 +21,7 @@ app.use(SentryRouter); app.use(new AuthEndpoints().router); app.use("/reports", new ReportEndpoints().router); +app.use("/signup/programming-dev", new ProgrammingDevSignupEndpoints().router); app.use("/canvas", new CanvasEndpoints().router); app.use("/user", new UserEndpoints().router); app.use("/share", ShareEndpoints); diff --git a/packages/server/src/api/client/programming_dev_signup.ts b/packages/server/src/api/client/programming_dev_signup.ts new file mode 100644 index 0000000..faf14b4 --- /dev/null +++ b/packages/server/src/api/client/programming_dev_signup.ts @@ -0,0 +1,693 @@ +import { z } from "zod/v4"; + +import { + InvalidAuthMode, + OpenIDController, +} from "../../controllers/OpenIDController"; +import { Router } from "../lib/router"; + +const PROGRAMMING_DEV_ORIGIN = "https://programming.dev"; +const PROGRAMMING_DEV_API = `${PROGRAMMING_DEV_ORIGIN}/api/v3`; +const REQUIRED_APPLICATION_ANSWER = "I agree to the TOS and Privacy Policy"; +const HANDOFF_REPLY_GRACE_MS = 30_000; +const HANDOFF_RESEND_AFTER_MS = 60_000; + +const registerSchema = z + .object({ + username: z.string().trim().min(3).max(50), + password: z.string().min(10), + passwordVerify: z.string().min(10), + email: z.string().email().optional().or(z.literal("")), + captchaUuid: z.string().min(1), + captchaAnswer: z.string().trim().min(1), + }) + .refine((data) => data.password === data.passwordVerify, { + message: "Passwords do not match", + path: ["passwordVerify"], + }); + +const checkSchema = z.object({ + password: z.string().min(1), +}); + +type LemmyErrorResponse = { + error?: string; + message?: string; +}; + +type LemmyLoginResponse = { + jwt?: string; +}; + +type LemmySiteResponse = { + site_view: { + local_site: { + captcha_enabled?: boolean; + registration_mode?: string; + require_email_verification?: boolean; + }; + }; + my_user?: { + local_user_view: { + local_user: { + accepted_application: boolean; + }; + person: { + banned: boolean; + }; + }; + }; +}; + +type LemmyCaptchaResponse = { + ok?: { + png: string; + wav: string; + uuid: string; + }; +}; + +type LemmyResolveObjectResponse = { + person?: { + person: { + id: number; + actor_id: string; + }; + }; +}; + +type LemmyPrivateMessagesResponse = { + private_messages: Array<{ + private_message: { + content: string; + published: string; + deleted: boolean; + }; + creator: { + id: number; + actor_id: string; + }; + }>; +}; + +type FediverseAuthMetadata = { + fediverse?: { + account?: string; + }; + handoff?: + | { + enabled: true; + token: string; + } + | { + enabled: false; + }; +}; + +type HandoffActor = { + id: number; + actorId: string; +}; + +class LemmyRequestError extends Error { + constructor( + readonly status: number, + readonly body: LemmyErrorResponse, + ) { + super(body.error || body.message || `Lemmy request failed (${status})`); + } +} + +export class ProgrammingDevSignupEndpoints extends Router { + @Router.handler("get", "/metadata") + @Router.ratelimit("HIGH") + async metadata(_req: Router.Request, res: Router.Response) { + try { + const [site, handoff] = await Promise.all([ + lemmyRequest("/site"), + fetchFediAuthMetadata(), + ]); + + res.json({ + success: true, + instance: { + captchaEnabled: Boolean(site.site_view.local_site.captcha_enabled), + registrationMode: site.site_view.local_site.registration_mode, + requireEmailVerification: Boolean( + site.site_view.local_site.require_email_verification, + ), + }, + handoff: { + enabled: handoff.handoff?.enabled === true, + account: handoff.fediverse?.account, + }, + }); + } catch (e) { + res.status(502).json({ + success: false, + error: "metadata_fetch_failed", + error_message: e instanceof Error ? e.message : "Unknown error", + }); + } + } + + @Router.handler("get", "/captcha") + @Router.ratelimit("HIGH") + async captcha(_req: Router.Request, res: Router.Response) { + try { + const data = await lemmyRequest( + "/user/get_captcha", + ); + + if (!data.ok) { + res.status(502).json({ + success: false, + error: "captcha_unavailable", + }); + return; + } + + res.json({ + success: true, + captcha: data.ok, + }); + } catch (e) { + res.status(502).json({ + success: false, + error: "captcha_fetch_failed", + error_message: e instanceof Error ? e.message : "Unknown error", + }); + } + } + + @Router.handler("post", "/register") + @Router.ratelimit("HIGH") + @Router.body(registerSchema) + async register( + req: Router.Request>, + res: Router.Response, + ) { + const username = req.body.username.trim(); + const email = req.body.email?.trim() || undefined; + const captchaAnswer = req.body.captchaAnswer.trim(); + + try { + const result = await lemmyRequest("/user/register", { + method: "POST", + body: { + username, + password: req.body.password, + password_verify: req.body.passwordVerify, + email, + captcha_uuid: req.body.captchaUuid, + captcha_answer: captchaAnswer, + answer: REQUIRED_APPLICATION_ANSWER, + honeypot: "", + }, + }); + + req.session.programmingDevSignup = { + username, + registeredAt: new Date().toISOString(), + }; + await saveSession(req); + + res.json({ + success: true, + state: result.jwt ? "ACCOUNT_READY" : "WAITING_ACCOUNT_CREATION", + }); + } catch (e) { + const payload = normalizeLemmyError(e, "registration_failed"); + res.status(payload.status).json(payload.body); + } + } + + @Router.handler("post", "/check") + @Router.ratelimit("HIGH") + @Router.body(checkSchema) + async check( + req: Router.Request>, + res: Router.Response, + ) { + const signup = req.session.programmingDevSignup; + + if (!signup) { + res.status(400).json({ + success: false, + error: "signup_session_missing", + }); + return; + } + + let jwt: string; + try { + const login = await lemmyRequest("/user/login", { + method: "POST", + body: { + username_or_email: signup.username, + password: req.body.password, + }, + }); + + if (!login.jwt) { + res.json({ + success: true, + state: "WAITING_ACCOUNT_CREATION", + }); + return; + } + + jwt = login.jwt; + } catch (e) { + if (isPendingApprovalError(e)) { + res.json({ + success: true, + state: "WAITING_ACCOUNT_CREATION", + }); + return; + } + + const payload = normalizeLemmyError(e, "login_failed"); + res.status(payload.status).json(payload.body); + return; + } + + let site: LemmySiteResponse; + try { + site = await lemmyRequest("/site", { + auth: jwt, + }); + } catch (e) { + const payload = normalizeLemmyError(e, "account_lookup_failed"); + res.status(payload.status).json(payload.body); + return; + } + + const localUser = site.my_user?.local_user_view.local_user; + const person = site.my_user?.local_user_view.person; + + if (!localUser || !person) { + res.status(502).json({ + success: false, + error: "account_lookup_failed", + }); + return; + } + + if (person.banned) { + res.status(403).json({ + success: false, + error: "account_banned", + }); + return; + } + + if (!localUser.accepted_application) { + res.json({ + success: true, + state: "WAITING_ACCOUNT_CREATION", + }); + return; + } + + let handoffMetadata: FediverseAuthMetadata; + try { + handoffMetadata = await fetchFediAuthMetadata(); + } catch (e) { + res.status(502).json({ + success: false, + error: "handoff_metadata_failed", + error_message: e instanceof Error ? e.message : "Unknown error", + }); + return; + } + + if ( + handoffMetadata.handoff?.enabled !== true || + !handoffMetadata.handoff.token || + !handoffMetadata.fediverse?.account + ) { + res.status(502).json({ + success: false, + error: "handoff_unavailable", + }); + return; + } + + let actor: HandoffActor | null; + try { + actor = await getOrResolveHandoffActor( + req, + jwt, + handoffMetadata.fediverse.account, + ); + } catch (e) { + const payload = normalizeLemmyError(e, "handoff_actor_lookup_failed"); + res.status(payload.status).json(payload.body); + return; + } + + if (!actor) { + res.status(502).json({ + success: false, + error: "handoff_actor_not_found", + }); + return; + } + + let handoffMessageSentAt = parseOptionalDate( + req.session.programmingDevSignup?.handoffMessageSentAt, + ); + + if (!handoffMessageSentAt) { + try { + handoffMessageSentAt = await sendHandoffMessage( + req, + jwt, + actor, + handoffMetadata.handoff.token, + ); + } catch (e) { + const payload = normalizeLemmyError(e, "handoff_message_failed"); + res.status(payload.status).json(payload.body); + return; + } + } + + let reply: URL | null; + try { + reply = await findHandoffReply(jwt, actor, handoffMessageSentAt); + } catch (e) { + const payload = normalizeLemmyError(e, "handoff_reply_lookup_failed"); + res.status(payload.status).json(payload.body); + return; + } + + if (!reply) { + if ( + Date.now() - handoffMessageSentAt.getTime() > + HANDOFF_RESEND_AFTER_MS + ) { + try { + await sendHandoffMessage( + req, + jwt, + actor, + handoffMetadata.handoff.token, + ); + } catch (e) { + const payload = normalizeLemmyError(e, "handoff_message_failed"); + res.status(payload.status).json(payload.body); + return; + } + } + + res.json({ + success: true, + state: "WAITING_HANDOFF_REPLY", + }); + return; + } + + try { + const redirect = buildHandoffRedirect(reply); + res.json({ + success: true, + state: "HANDOFF_READY", + redirect, + }); + } catch (e) { + if (e instanceof InvalidAuthMode) { + res.status(400).json({ + success: false, + error: "auth_mode_invalid", + error_message: e.message, + }); + return; + } + + res.status(500).json({ + success: false, + error: "handoff_redirect_failed", + }); + } + } + + @Router.handler("delete", "/") + @Router.ratelimit("HIGH") + clear(req: Router.Request, res: Router.Response) { + req.session.programmingDevSignup = undefined; + req.session.save(() => { + res.json({ success: true }); + }); + } +} + +async function getOrResolveHandoffActor( + req: Router.Request, + jwt: string, + handle: string, +): Promise { + const existing = req.session.programmingDevSignup; + if ( + existing?.handoffActorId && + existing.handoffActorActorId && + existing.handoffActorHandle === handle + ) { + return { + id: existing.handoffActorId, + actorId: existing.handoffActorActorId, + }; + } + + const resolved = await lemmyRequest( + "/resolve_object", + { + auth: jwt, + query: { q: handle }, + }, + ); + + const person = resolved.person?.person; + if (!person) return null; + + req.session.programmingDevSignup = { + ...req.session.programmingDevSignup!, + handoffActorHandle: handle, + handoffActorId: person.id, + handoffActorActorId: person.actor_id, + }; + await saveSession(req); + + return { + id: person.id, + actorId: person.actor_id, + }; +} + +async function sendHandoffMessage( + req: Router.Request, + jwt: string, + actor: HandoffActor, + token: string, +): Promise { + const sentAt = new Date(); + + await lemmyRequest("/private_message", { + method: "POST", + auth: jwt, + body: { + recipient_id: actor.id, + content: [ + "Canvas is requesting a Fediverse Auth handoff for this newly-created account.", + "", + token, + "", + `Sent by Canvas${process.env.VERSION ? ` ${process.env.VERSION}` : ""}`, + ].join("\n"), + }, + }); + + req.session.programmingDevSignup = { + ...req.session.programmingDevSignup!, + handoffMessageSentAt: sentAt.toISOString(), + }; + await saveSession(req); + + return sentAt; +} + +async function findHandoffReply( + jwt: string, + actor: HandoffActor, + sentAt: Date, +): Promise { + const messages = await lemmyRequest( + "/private_message/list", + { + auth: jwt, + query: { + unread_only: false, + creator_id: actor.id, + limit: 20, + }, + }, + ); + + const earliest = sentAt.getTime() - HANDOFF_REPLY_GRACE_MS; + const authOrigin = new URL(process.env.AUTH_ENDPOINT).origin; + + for (const view of messages.private_messages) { + if (view.private_message.deleted) continue; + if (view.creator.actor_id !== actor.actorId) continue; + if (Date.parse(view.private_message.published) < earliest) continue; + + const url = extractHandoffUrl(view.private_message.content); + if (!url) continue; + if (url.origin !== authOrigin) continue; + + return url; + } + + return null; +} + +function parseOptionalDate(value: string | undefined): Date | null { + if (!value) return null; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + + return date; +} + +function buildHandoffRedirect(handoffUrl: URL): string { + const url = new URL(handoffUrl); + url.searchParams.set( + "return_uri", + OpenIDController.get().getAuthorizationURL(), + ); + return url.toString(); +} + +function extractHandoffUrl(content: string): URL | null { + const text = content + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/<[^>]*>/g, " "); + const match = text.match( + /https?:\/\/[^\s<>'"]+\/handoff\/[0-9a-zA-Z-]+[^\s<>'"]*/, + ); + if (!match) return null; + + try { + return new URL(match[0].replace(/[),.;]+$/, "")); + } catch { + return null; + } +} + +async function fetchFediAuthMetadata(): Promise { + const endpoint = new URL( + "/.well-known/com.sc07.fediverse-auth", + process.env.AUTH_ENDPOINT, + ); + const response = await fetch(endpoint); + if (!response.ok) { + throw new Error(`Fediverse Auth metadata returned ${response.status}`); + } + + return (await response.json()) as FediverseAuthMetadata; +} + +async function lemmyRequest( + path: string, + options: { + method?: "GET" | "POST"; + body?: Record; + query?: Record; + auth?: string; + } = {}, +): Promise { + const url = new URL(PROGRAMMING_DEV_API + path); + + for (const [key, value] of Object.entries(options.query || {})) { + if (typeof value !== "undefined") url.searchParams.set(key, String(value)); + } + + const headers: Record = { + Accept: "application/json", + }; + + if (options.body) headers["Content-Type"] = "application/json"; + if (options.auth) headers.Authorization = `Bearer ${options.auth}`; + + const response = await fetch(url, { + method: options.method || (options.body ? "POST" : "GET"), + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new LemmyRequestError(response.status, data as LemmyErrorResponse); + } + + return data as T; +} + +function normalizeLemmyError( + error: unknown, + fallback: string, +): { + status: number; + body: { success: false; error: string; error_message?: string }; +} { + if (error instanceof LemmyRequestError) { + return { + status: error.status, + body: { + success: false, + error: error.body.error || fallback, + error_message: error.body.message, + }, + }; + } + + return { + status: 500, + body: { + success: false, + error: fallback, + error_message: error instanceof Error ? error.message : "Unknown error", + }, + }; +} + +function isPendingApprovalError(error: unknown): boolean { + if (!(error instanceof LemmyRequestError)) return false; + + const message = [error.body.error, error.body.message] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return ( + message.includes("application") || + message.includes("registration") || + message.includes("not_approved") || + message.includes("not approved") || + message.includes("email_not_verified") + ); +} + +function saveSession(req: Router.Request): Promise { + return new Promise((resolve, reject) => { + req.session.save((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index fb954df..faf3097 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -5,6 +5,14 @@ import session from "express-session"; declare module "express-session" { interface SessionData { user: AuthSession; + programmingDevSignup?: { + username: string; + registeredAt: string; + handoffActorHandle?: string; + handoffActorId?: number; + handoffActorActorId?: string; + handoffMessageSentAt?: string; + }; } } -- GitLab