diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 9eccc69..4ef1ce1 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -28,7 +28,7 @@ import { validateStorageTemplate, } from "../services/printService"; import { getUserMakerworldCookie, setUserMakerworldCookie } from "../services/makerworldCookieService"; -import { verifyMakerworldCookie } from "../services/makerworldCloudApi"; +import { type MakerworldCookieCheck, verifyMakerworldCookie } from "../services/makerworldCloudApi"; import { verifyThingiverseAccessToken } from "../services/thingiverseApi"; import { SLICER_IDS, getUserSlicer, setUserSlicer } from "../services/slicerPreferenceService"; import { THEME_SELECTIONS, getUserTheme, setUserTheme } from "../services/themePreferenceService"; @@ -282,6 +282,17 @@ router.get( // tab) and deliberately swallows failures, so adding a live network test there would only add // latency for no benefit. Only ProfilePage's manual Save (settingsApi.updateMakerworld) sends // `verify: true`. +const MAKERWORLD_UNVERIFIABLE_MESSAGES: Record< + Extract["reason"], + string +> = { + cloudflare_no_flaresolverr: + "Couldn't check this cookie: MakerWorld's Cloudflare protection blocked the request and FlareSolverr isn't configured. Set FLARESOLVERR_URL and try again.", + flaresolverr_failed: + "Couldn't check this cookie: MakerWorld's Cloudflare protection blocked the request and FlareSolverr didn't get past it. Make sure FlareSolverr is running and reachable at FLARESOLVERR_URL, then try again.", + network: "Couldn't check this cookie: MakerWorld didn't respond. Check this server's internet connection and try again.", +}; + const makerworldSettingsSchema = z.object({ cookie: z.string().nullable(), verify: z.boolean().optional() }); router.patch( "/settings/makerworld", @@ -290,11 +301,16 @@ router.patch( const trimmed = (body.cookie ?? "").trim(); // Only a new, non-empty cookie needs testing -- clearing it (trimmed === "") always // succeeds, since there's nothing to verify a logged-out state against. - if (body.verify && trimmed && !(await verifyMakerworldCookie(trimmed))) { - throw new HttpError( - 422, - "Couldn't verify this MakerWorld cookie -- it may be invalid or expired. Copy a fresh Cookie header from a logged-in makerworld.com tab and try again.", - ); + if (body.verify && trimmed) { + const check = await verifyMakerworldCookie(trimmed); + if (check.result === "invalid") { + throw new HttpError( + 422, + "MakerWorld rejected this cookie -- it may be invalid or expired. Copy a fresh Cookie header from a logged-in makerworld.com tab and try again.", + ); + } + // 503, not 422: nothing is known to be wrong with the cookie, the check itself couldn't run. + if (check.result === "unverifiable") throw new HttpError(503, MAKERWORLD_UNVERIFIABLE_MESSAGES[check.reason]); } const configured = await setUserMakerworldCookie(req.userId!, body.cookie); res.json({ configured }); diff --git a/backend/src/services/makerworldCloudApi.ts b/backend/src/services/makerworldCloudApi.ts index 1dc778d..1d530de 100644 --- a/backend/src/services/makerworldCloudApi.ts +++ b/backend/src/services/makerworldCloudApi.ts @@ -101,15 +101,23 @@ export function extractMakerworldBearerToken(rawCookieOrToken: string | null | u return null; } +/** Outcome of verifyMakerworldCookie. "unverifiable" means the check never got an answer about + * the cookie itself -- Cloudflare challenged the request and FlareSolverr is unset, unreachable, + * or couldn't solve it, or MakerWorld didn't respond -- so the cookie may be perfectly fine. */ +export type MakerworldCookieCheck = + | { result: "valid" } + | { result: "invalid" } + | { result: "unverifiable"; reason: "cloudflare_no_flaresolverr" | "flaresolverr_failed" | "network" }; + /** Called before storing a cookie pasted into Profile > MakerWorld (see routes/settings.ts' * PATCH /settings/makerworld), so a stale/expired/mistyped paste is rejected up front instead of * only surfacing as a failed import later. A malformed paste (extractMakerworldBearerToken * finding no usable token at all) is rejected without a network call; otherwise this is the * same self-profile request api.bambulab.com/MakerWorld clients use right after login, so a 200 * here means the account is genuinely logged in, not just that the string looks token-shaped. */ -export async function verifyMakerworldCookie(rawCookieOrToken: string): Promise { +export async function verifyMakerworldCookie(rawCookieOrToken: string): Promise { const bearerToken = extractMakerworldBearerToken(rawCookieOrToken); - if (!bearerToken) return false; + if (!bearerToken) return { result: "invalid" }; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CLOUD_API_TIMEOUT_MS); try { @@ -123,18 +131,21 @@ export async function verifyMakerworldCookie(rawCookieOrToken: string): Promise< redirect: "follow", signal: controller.signal, }); - if (res.status === 403 && isFlaresolverrEnabled() && looksLikeCloudflareBlock(res.headers)) { + if (res.status === 403 && looksLikeCloudflareBlock(res.headers)) { + // A Cloudflare challenge is about this server's IP, not the token -- it says nothing + // either way about the cookie, so without a way past it the answer is "couldn't check". + if (!isFlaresolverrEnabled()) return { result: "unverifiable", reason: "cloudflare_no_flaresolverr" }; // This endpoint answers 200 with an empty body through FlareSolverr's browser when the // session isn't actually logged in, so a bare status check would accept any string here. // Assert on the parsed body instead -- only a genuinely authenticated session has a `uid`. const solved = await fetchViaFlaresolverr(SELF_PREFERENCE_URL, `token=${bearerToken}`); - if (!solved) return false; + if (!solved) return { result: "unverifiable", reason: "flaresolverr_failed" }; const data = extractJsonFromBrowserBody(solved.body); - return isRecord(data) && data.uid != null; + return isRecord(data) && data.uid != null ? { result: "valid" } : { result: "invalid" }; } - return res.ok; + return res.ok ? { result: "valid" } : { result: "invalid" }; } catch { - return false; + return { result: "unverifiable", reason: "network" }; } finally { clearTimeout(timeout); }