mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-20 21:59:53 -05:00
fix: distinguish Thingiverse rate limits from auth errors and add AI run logging
This commit is contained in:
1 parent
97f17e4fa3
commit
c5e5ab460e
10 files changed
+101
-28
No files matched your search
@@ -30,6 +30,8 @@ import {
|
||||
fetchThingiverseLikesSummary,
|
||||
listThingiverseUserCollections,
|
||||
resolveThingiverseUser,
|
||||
ThingiverseAuthError,
|
||||
ThingiverseRateLimitError,
|
||||
} from "./thingiverseApi";
|
||||
|
||||
export const BROWSABLE_PROVIDERS = ["makerworld", "printables", "thingiverse"] as const;
|
||||
@@ -93,7 +95,8 @@ export async function setProviderAccount(
|
||||
});
|
||||
if (!user) await deletePrintablesListings(userId);
|
||||
} else if (provider === "thingiverse") {
|
||||
const user = value ? await resolveThingiverseUser(value, await requireThingiverseToken()) : null;
|
||||
const token = value ? await requireThingiverseToken() : null;
|
||||
const user = token ? await providerCall(() => resolveThingiverseUser(value!, token)) : null;
|
||||
if (value && !user) throw new HttpError(404, `Thingiverse has no user "${value}"`);
|
||||
await prisma.user.update({ where: { id: userId }, data: { thingiverseUsername: user?.username ?? null } });
|
||||
} else {
|
||||
@@ -127,6 +130,8 @@ async function providerCall<T>(call: () => Promise<T>): Promise<T> {
|
||||
if (err instanceof MakerworldAuthError) {
|
||||
throw new HttpError(400, "MakerWorld didn't accept your cookie. Update it in Configuration > Providers.");
|
||||
}
|
||||
if (err instanceof ThingiverseRateLimitError) throw new HttpError(429, err.message);
|
||||
if (err instanceof ThingiverseAuthError) throw new HttpError(400, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -185,10 +190,12 @@ export async function listProviderCollections(
|
||||
if (!user?.thingiverseUsername) throw new HttpError(409, "Tell Thingport your Thingiverse username first");
|
||||
const username = user.thingiverseUsername;
|
||||
// Thingiverse shows anyone's likes, so they sync like a collection; a failed count costs only them.
|
||||
const [likes, collections] = await Promise.all([
|
||||
thingiverseLikesCard(username, token).catch(() => null),
|
||||
listThingiverseUserCollections(username, token),
|
||||
]);
|
||||
const [likes, collections] = await providerCall(() =>
|
||||
Promise.all([
|
||||
thingiverseLikesCard(username, token).catch(() => null),
|
||||
listThingiverseUserCollections(username, token),
|
||||
]),
|
||||
);
|
||||
cards = [...(likes ? [likes] : []), ...collections];
|
||||
}
|
||||
return withSyncs(userId, provider, cards);
|
||||
@@ -200,7 +207,8 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
if (!source) throw new HttpError(400, "That isn't a MakerWorld, Printables or Thingiverse collection link");
|
||||
let card: ProviderCollectionCard | null;
|
||||
if (source.likesOf && source.provider === "thingiverse") {
|
||||
card = await thingiverseLikesCard(source.likesOf, await requireThingiverseToken());
|
||||
const token = await requireThingiverseToken();
|
||||
card = await providerCall(() => thingiverseLikesCard(source.likesOf!, token));
|
||||
} else if (source.likesOf) {
|
||||
// Printables shows likes to their owner only, so only the user's own, as Grab read them.
|
||||
const likes = await pushedLikes(userId);
|
||||
@@ -220,7 +228,8 @@ export async function lookupProviderCollection(userId: string, url: string): Pro
|
||||
(await pushedPrintablesCards(userId)).collections?.find((pushed) => pushed.id === source.externalId) ??
|
||||
null;
|
||||
} else {
|
||||
card = await fetchThingiverseCollectionCard(source.externalId, await requireThingiverseToken());
|
||||
const token = await requireThingiverseToken();
|
||||
card = await providerCall(() => fetchThingiverseCollectionCard(source.externalId, token));
|
||||
}
|
||||
if (!card) throw new HttpError(404, "That collection can't be found. It may be private or deleted.");
|
||||
return (await withSyncs(userId, source.provider, [card]))[0];
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
looksLikeCloudflareBlock,
|
||||
shouldProxyHost,
|
||||
} from "./flaresolverr";
|
||||
import { getFlaresolverrUrl } from "./settingsService";
|
||||
|
||||
// The official API needs an app Access Token (not a session cookie). It's still behind Cloudflare
|
||||
// and returns a sticky 429 challenge after a few rapid requests, hence ThingiverseRateLimitError
|
||||
@@ -31,12 +32,19 @@ export class ThingiverseAuthError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Cloudflare's 429 challenge, distinct from a 404 or rejected token: the fix is to wait. */
|
||||
const FLARESOLVERR_HINTS = {
|
||||
missing: " Setting up FlareSolverr in Admin Settings > Connections gets past it.",
|
||||
failed:
|
||||
" FlareSolverr couldn't get past it either: it may be down or unreachable. Check it in Admin Settings > Connections.",
|
||||
};
|
||||
|
||||
/** Cloudflare's 429 challenge, distinct from a 404 or rejected token: the fix is to wait, or a working FlareSolverr. */
|
||||
export class ThingiverseRateLimitError extends Error {
|
||||
constructor() {
|
||||
constructor(flaresolverr?: keyof typeof FLARESOLVERR_HINTS) {
|
||||
super(
|
||||
"Thingiverse blocked this request with a rate-limit challenge (Cloudflare). This usually " +
|
||||
"clears after a while -- wait, then retry the same import.",
|
||||
"clears after a while -- wait, then try again." +
|
||||
(flaresolverr ? FLARESOLVERR_HINTS[flaresolverr] : ""),
|
||||
);
|
||||
this.name = "ThingiverseRateLimitError";
|
||||
}
|
||||
@@ -126,6 +134,10 @@ async function fetchThingiverseApi(
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
// An unsolved challenge is a 403 too; it says nothing about the token.
|
||||
if (!viaBrowser && (res.status === 429 || res.headers.get("cf-mitigated") === "challenge")) {
|
||||
throw new ThingiverseRateLimitError((await getFlaresolverrUrl()) ? "failed" : "missing");
|
||||
}
|
||||
if (res.status === 429) throw new ThingiverseRateLimitError();
|
||||
if (res.status === 401 || res.status === 403) throw new ThingiverseAuthError();
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
|
||||
@@ -96,6 +96,13 @@ beforeAll(async () => {
|
||||
// Thingiverse's API as it answers for a user with two likes and one collection.
|
||||
const thingiverse = url.match(/^https:\/\/api\.thingiverse\.com(\/[^?]*)/);
|
||||
if (thingiverse) {
|
||||
if (url.includes("access_token=rejected-token")) return json({}, 401);
|
||||
if (url.includes("access_token=challenged-token")) {
|
||||
return new Response("<title>Just a moment...</title>", {
|
||||
status: 403,
|
||||
headers: { "cf-mitigated": "challenge" },
|
||||
});
|
||||
}
|
||||
const path = thingiverse[1];
|
||||
if (path === `/users/${THINGIVERSE_USER}`) return json({ name: THINGIVERSE_USER, like_count: 2 });
|
||||
if (path === `/users/${THINGIVERSE_USER}/likes`) {
|
||||
@@ -229,6 +236,22 @@ describe("Thingiverse likes in the Browse tab", () => {
|
||||
thumbnails: ["https://cdn.thingiverse.com/hook.jpg", "https://cdn.thingiverse.com/clip.jpg"],
|
||||
});
|
||||
});
|
||||
|
||||
it("says the instance's token was rejected, rather than failing", async () => {
|
||||
await setThingiverseAccessToken("rejected-token");
|
||||
const list = await request(app).get("/api/provider-collections").query({ provider: "thingiverse" }).set(auth());
|
||||
expect(list.status).toBe(400);
|
||||
expect(list.body.detail).toMatch(/Access Token .* was rejected/);
|
||||
await setThingiverseAccessToken("test-token");
|
||||
});
|
||||
|
||||
it("tells a Cloudflare challenge from a rejected token", async () => {
|
||||
await setThingiverseAccessToken("challenged-token");
|
||||
const list = await request(app).get("/api/provider-collections").query({ provider: "thingiverse" }).set(auth());
|
||||
expect(list.status).toBe(429);
|
||||
expect(list.body.detail).toMatch(/Cloudflare.*Setting up FlareSolverr/);
|
||||
await setThingiverseAccessToken("test-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("turning sync on from the app", () => {
|
||||
|
||||
@@ -35,7 +35,9 @@ export type LogAction =
|
||||
| "collection_item_added"
|
||||
| "collection_item_removed"
|
||||
| "collection_sync_enabled"
|
||||
| "collection_sync_disabled";
|
||||
| "collection_sync_disabled"
|
||||
| "ai_categorization_run"
|
||||
| "ai_description_run";
|
||||
|
||||
export type LogEntry = {
|
||||
id: string;
|
||||
|
||||
@@ -199,8 +199,9 @@ function AppLayoutShell({
|
||||
<PageHeaderContext.Provider value={setPageHeader}>{children}</PageHeaderContext.Provider>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Both can run at once, so they stack here rather than each pinning itself to the bottom. */}
|
||||
<Box sx={{ position: "fixed", left: 0, right: 0, bottom: 0, zIndex: (theme) => theme.zIndex.snackbar }}>
|
||||
{/* Both can run at once, so they stack here rather than each pinning itself to the bottom. Under
|
||||
dialogs and their backdrop, like the top bar. */}
|
||||
<Box sx={{ position: "fixed", left: 0, right: 0, bottom: 0, zIndex: (theme) => theme.zIndex.appBar }}>
|
||||
<AiRunProgressBar kind="categorization" />
|
||||
<AiRunProgressBar kind="descriptions" />
|
||||
<ImportProgressBar />
|
||||
|
||||
@@ -927,10 +927,14 @@
|
||||
"collection_sync_enabled": "Collection sync turned on",
|
||||
"collection_sync_disabled": "Collection sync turned off",
|
||||
"authors_linked": "Linked missing authors",
|
||||
"descriptions_refetched": "Refetched model descriptions"
|
||||
"descriptions_refetched": "Refetched model descriptions",
|
||||
"ai_categorization_run": "AI categorization run",
|
||||
"ai_description_run": "AI description improvement run"
|
||||
},
|
||||
"authorsLinked": "{{count}} model(s) linked",
|
||||
"descriptionsRefetched": "{{email}} — {{updated}} of {{total}} description(s) changed"
|
||||
"descriptionsRefetched": "{{email}} — {{updated}} of {{total}} description(s) changed",
|
||||
"aiRunSummary": "{{done}} of {{total}} checked — {{suggested}} suggested, {{applied}} applied, {{failed}} failed",
|
||||
"aiRunStopped": "(stopped)"
|
||||
},
|
||||
"rendering": {
|
||||
"heading": "Rendering",
|
||||
|
||||
@@ -935,10 +935,14 @@
|
||||
"collection_sync_enabled": "Įjungtas kolekcijos sinchronizavimas",
|
||||
"collection_sync_disabled": "Išjungtas kolekcijos sinchronizavimas",
|
||||
"authors_linked": "Susieti trūkstami autoriai",
|
||||
"descriptions_refetched": "Iš naujo gauti modelių aprašymai"
|
||||
"descriptions_refetched": "Iš naujo gauti modelių aprašymai",
|
||||
"ai_categorization_run": "AI kategorizavimo paleidimas",
|
||||
"ai_description_run": "AI aprašymų tobulinimo paleidimas"
|
||||
},
|
||||
"authorsLinked": "Susieta modelių: {{count}}",
|
||||
"descriptionsRefetched": "{{email}} — pakeista aprašymų: {{updated}} iš {{total}}"
|
||||
"descriptionsRefetched": "{{email}} — pakeista aprašymų: {{updated}} iš {{total}}",
|
||||
"aiRunSummary": "Patikrinta {{done}} iš {{total}} — pasiūlyta {{suggested}}, pritaikyta {{applied}}, nepavyko {{failed}}",
|
||||
"aiRunStopped": "(sustabdyta)"
|
||||
},
|
||||
"rendering": {
|
||||
"heading": "Atvaizdavimas",
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function AiDescriptionsPanel({ onUnauthorized, onRunStarted }: Pr
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const showToast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const { running, startRun } = useAiDescriptionRun();
|
||||
const { run, running, startRun } = useAiDescriptionRun();
|
||||
const [suggestions, setSuggestions] = useState<{ total: number; items: AiDescriptionSuggestion[] } | null>(null);
|
||||
const [applied, setApplied] = useState<{ total: number; items: AiDescriptionApplied[] } | null>(null);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
@@ -72,14 +72,16 @@ export default function AiDescriptionsPanel({ onUnauthorized, onRunStarted }: Pr
|
||||
]);
|
||||
setSuggestions(pending);
|
||||
setApplied(done);
|
||||
setSelected([]);
|
||||
// Only what's still listed stays ticked, so a run adding suggestions doesn't undo a selection.
|
||||
setSelected((ids) => ids.filter((id) => pending.items.some((item) => item.id === id)));
|
||||
}, []);
|
||||
|
||||
// Again whenever a run stops, since a run adds suggestions.
|
||||
// On opening, as a run adds suggestions or applies rewrites, and again once it stops.
|
||||
const progress = running ? `${run?.suggested ?? 0}/${run?.applied ?? 0}` : "idle";
|
||||
useEffect(() => {
|
||||
if (running) return;
|
||||
void load().catch(fail);
|
||||
}, [running, load, fail]);
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
}, [progress, load, fail]);
|
||||
|
||||
const act = async (work: () => Promise<unknown>, done: string) => {
|
||||
setBusy(true);
|
||||
|
||||
@@ -50,6 +50,8 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
|
||||
collection_item_removed: "default",
|
||||
collection_sync_enabled: "info",
|
||||
collection_sync_disabled: "default",
|
||||
ai_categorization_run: "info",
|
||||
ai_description_run: "info",
|
||||
};
|
||||
|
||||
function isoDateOnly(d: Date): string {
|
||||
@@ -137,6 +139,18 @@ export default function LogsPage({ onUnauthorized }: Props) {
|
||||
const failed = typeof d.failed === "number" ? d.failed : 0;
|
||||
return t("adminSettings.logs.importSummary", { label, imported, failed });
|
||||
}
|
||||
case "ai_categorization_run":
|
||||
case "ai_description_run": {
|
||||
const count = (key: string) => (typeof d[key] === "number" ? d[key] : 0);
|
||||
const summary = t("adminSettings.logs.aiRunSummary", {
|
||||
done: count("done"),
|
||||
total: count("total"),
|
||||
suggested: count("suggested"),
|
||||
applied: count("applied"),
|
||||
failed: count("failed"),
|
||||
});
|
||||
return d.cancelled === true ? `${summary} ${t("adminSettings.logs.aiRunStopped")}` : summary;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function AiCategorizationPanel({
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [suggestionsLoading, setSuggestionsLoading] = useState(true);
|
||||
const { running, startRun } = useAiRun();
|
||||
const { run, running, startRun } = useAiRun();
|
||||
|
||||
const loadSuggestions = async (offset = 0, append = false) => {
|
||||
const result = await aiCategorizationApi.suggestions(50, offset);
|
||||
@@ -73,9 +73,9 @@ export default function AiCategorizationPanel({
|
||||
if (!append) setSelected([]);
|
||||
};
|
||||
|
||||
// Reloaded whenever a run stops, since a run adds suggestions.
|
||||
// On opening, as a run adds suggestions, and again once it stops.
|
||||
const progress = running ? (run?.suggested ?? 0) : -1;
|
||||
useEffect(() => {
|
||||
if (running) return;
|
||||
let active = true;
|
||||
setSuggestionsLoading(true);
|
||||
void aiCategorizationApi
|
||||
@@ -84,7 +84,8 @@ export default function AiCategorizationPanel({
|
||||
if (!active) return;
|
||||
setItems(result.items);
|
||||
setTotal(result.total);
|
||||
setSelected([]);
|
||||
// Only what's still listed stays ticked, so a run adding suggestions doesn't undo a selection.
|
||||
setSelected((ids) => ids.filter((id) => result.items.some((item) => item.id === id)));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
@@ -96,11 +97,11 @@ export default function AiCategorizationPanel({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [running, onUnauthorized, showToast]);
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
}, [progress, onUnauthorized, showToast]);
|
||||
|
||||
// Counts on opening, and again after a run, which moves models between the groups.
|
||||
useEffect(() => {
|
||||
if (running) return;
|
||||
let active = true;
|
||||
void aiCategorizationApi
|
||||
.runState()
|
||||
@@ -116,6 +117,7 @@ export default function AiCategorizationPanel({
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
}, [running, onUnauthorized, showToast]);
|
||||
|
||||
const start = async () => {
|
||||
|
||||
Reference in new issue
Block a user