fix(import): limit manual CAPTCHA cooloff lifts to one per 5 minutes, require value on captcha keys

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-14 14:43:08 +02:00
1 parent e15653ca56
commit bf083bcb26
4 files changed
+61 -12

No files matched your search

+3 -3
View File
@@ -16,7 +16,7 @@ import {
type QueuedLinkOptions,
} from "./importJobRunner";
import { getUserMakerworldCookie } from "./makerworldCookieService";
import { clearCaptchaCooloff } from "./makerworldCaptcha";
import { liftCaptchaCooloffByHand } from "./makerworldCaptcha";
/** The shared body a LINKS job reruns with: its stored payload plus the owner's saved MakerWorld
* cookie. The cookie is never stored on the job. */
@@ -84,7 +84,7 @@ export async function startLinksJob(
},
});
if (!count) throw new HttpError(409, "This import changed while it was being started, reload and try again");
if (opts.manual) clearCaptchaCooloff();
if (opts.manual) liftCaptchaCooloffByHand();
const body = await linksJobBody(job);
return { run: runLinksImportJob(job.id, job.userId, body) };
}
@@ -301,7 +301,7 @@ async function drainBulkQueue(): Promise<void> {
/** Bulk runs are started by hand, so they lift the CAPTCHA cooloff once (see startLinksJob). Not
* per job: after a CAPTCHA, the jobs behind it should fail fast, not each probe MakerWorld again. */
function enqueueBulk(entries: BulkEntry[]): number {
if (entries.length) clearCaptchaCooloff();
if (entries.length) liftCaptchaCooloffByHand();
const known = new Set([bulkCurrent, ...bulkQueue.map((e) => e.jobId)]);
const fresh = entries.filter((e) => !known.has(e.jobId));
bulkQueue.push(...fresh);
+18 -3
View File
@@ -8,9 +8,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
// The cloud API and collection listing share one cooldown so one can't re-trip the other.
// Only a captcha key or the reply's own error text counts: a design whose title or summary says
// "robot" would otherwise fail as a CAPTCHA on every retry, and start the cooloff for everyone.
// The key needs a value too, so a flag like {"captchaRequired": false} isn't a challenge.
export function isCaptchaChallenge(data: unknown): boolean {
if (!isRecord(data)) return false;
if (Object.keys(data).some((key) => key.toLowerCase().includes("captcha"))) return true;
if (Object.entries(data).some(([key, value]) => key.toLowerCase().includes("captcha") && Boolean(value))) {
return true;
}
return ["error", "message", "msg"].some((key) => {
const value = data[key];
return typeof value === "string" && /captcha|robot/i.test(value);
@@ -26,7 +29,11 @@ export const MAKERWORLD_CAPTCHA_MESSAGE =
// The block is IP-scoped and clears on its own after 1-4 hours; retrying into it extends it.
// The cooloff only needs to stop a batch import hammering a known block, so 2h is enough.
const CAPTCHA_COOLOFF_MS = 2 * 60 * 60 * 1000;
// Any member's retry can lift the cooloff, and it's shared: without a gap, clicking Retry over and
// over would send MakerWorld a request each time and keep extending the block.
const MANUAL_LIFT_INTERVAL_MS = 5 * 60 * 1000;
let captchaBlockedUntil = 0;
let lastManualLift = 0;
export function makerworldCaptchaCooloffActive(): boolean {
return Date.now() < captchaBlockedUntil;
@@ -37,11 +44,19 @@ export function noteCaptchaChallenge(): void {
}
/** For a run someone starts by hand: they may have just solved the puzzle in the browser, which
* the cooloff can't know. A block that's still there costs one request before it trips again. */
export function clearCaptchaCooloff(): void {
* the cooloff can't know. A block that's still there costs one request before it trips again, and
* until MANUAL_LIFT_INTERVAL_MS has passed another start or retry fails on the cooloff instead. */
export function liftCaptchaCooloffByHand(): void {
if (!makerworldCaptchaCooloffActive() || Date.now() - lastManualLift < MANUAL_LIFT_INTERVAL_MS) return;
lastManualLift = Date.now();
captchaBlockedUntil = 0;
}
export function resetCaptchaCooloffForTest(): void {
captchaBlockedUntil = 0;
lastManualLift = 0;
}
export class MakerworldCaptchaError extends Error {
constructor() {
super(MAKERWORLD_CAPTCHA_MESSAGE);
+2 -2
View File
@@ -30,7 +30,7 @@ vi.mock("../src/services/notificationService", async (importOriginal) => {
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { clearCaptchaCooloff, makerworldCaptchaCooloffActive } from "../src/services/makerworldCaptcha";
import { makerworldCaptchaCooloffActive, resetCaptchaCooloffForTest } from "../src/services/makerworldCaptcha";
const app = createApp();
const stamp = Date.now();
@@ -202,7 +202,7 @@ describe("the import queue", () => {
designCalls.clear();
gone.clear();
challenged.clear();
clearCaptchaCooloff();
resetCaptchaCooloffForTest();
inFlight = 0;
maxInFlight = 0;
notifyDelay.ms = 0;
+38 -4
View File
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearCaptchaCooloff,
isCaptchaChallenge,
liftCaptchaCooloffByHand,
makerworldCaptchaCooloffActive,
noteCaptchaChallenge,
resetCaptchaCooloffForTest,
} from "../src/services/makerworldCaptcha";
describe("isCaptchaChallenge", () => {
@@ -20,6 +21,12 @@ describe("isCaptchaChallenge", () => {
expect(isCaptchaChallenge({ message: "success", url: "https://cdn.example.com/robot-arm.3mf" })).toBe(false);
});
it("needs a value on a captcha key, so a flag saying there's none isn't a challenge", () => {
expect(isCaptchaChallenge({ id: 3, captchaRequired: false })).toBe(false);
expect(isCaptchaChallenge({ captchaId: "" })).toBe(false);
expect(isCaptchaChallenge({ captcha: null })).toBe(false);
});
it("ignores anything that isn't an object", () => {
expect(isCaptchaChallenge(null)).toBe(false);
expect(isCaptchaChallenge("captcha")).toBe(false);
@@ -28,12 +35,39 @@ describe("isCaptchaChallenge", () => {
});
describe("the CAPTCHA cooloff", () => {
afterEach(() => clearCaptchaCooloff());
afterEach(() => {
vi.useRealTimers();
resetCaptchaCooloffForTest();
});
it("can be lifted by hand once a person has solved the puzzle", () => {
noteCaptchaChallenge();
expect(makerworldCaptchaCooloffActive()).toBe(true);
clearCaptchaCooloff();
liftCaptchaCooloffByHand();
expect(makerworldCaptchaCooloffActive()).toBe(false);
});
it("is lifted by hand at most once every five minutes, so retrying can't keep probing a block", () => {
vi.useFakeTimers({ now: new Date("2026-10-14T10:00:00Z") });
noteCaptchaChallenge();
liftCaptchaCooloffByHand();
// The retry met the CAPTCHA again.
noteCaptchaChallenge();
vi.setSystemTime(new Date("2026-10-14T10:04:00Z"));
liftCaptchaCooloffByHand();
expect(makerworldCaptchaCooloffActive()).toBe(true);
vi.setSystemTime(new Date("2026-10-14T10:05:00Z"));
liftCaptchaCooloffByHand();
expect(makerworldCaptchaCooloffActive()).toBe(false);
});
it("doesn't spend the five minutes when there's nothing to lift", () => {
vi.useFakeTimers({ now: new Date("2026-10-14T10:00:00Z") });
liftCaptchaCooloffByHand();
noteCaptchaChallenge();
vi.setSystemTime(new Date("2026-10-14T10:01:00Z"));
liftCaptchaCooloffByHand();
expect(makerworldCaptchaCooloffActive()).toBe(false);
});
});