mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
fix(import): limit manual CAPTCHA cooloff lifts to one per 5 minutes, require value on captcha keys
This commit is contained in:
1 parent
e15653ca56
commit
bf083bcb26
4 files changed
+61
-12
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user