mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
feat: accept MakerWorld page data sent with an extension import
With the download already resolved in the browser, the extension can now send the model page's own design data, so the backend no longer fetches the model page (often Cloudflare-blocked, failing the whole import). The data only counts for the model in the URL, only MakerWorld CDN image URLs are fetched, and the author it names can create an author record but not overwrite one. Imports without it read the page as before.
This commit is contained in:
1 parent
e87eb39ab6
commit
616a8b82c3
5 files changed
+140
-10
No files matched your search
@@ -61,6 +61,9 @@ const importRequestSchema = z.object({
|
||||
// CAPTCHA and its 2-hour blanket cooloff (see makerworldCaptcha.ts).
|
||||
resolved_download_url: z.string().nullable().optional(),
|
||||
resolved_instance_id: z.string().nullable().optional(),
|
||||
// Sent with resolved_download_url: the page's own design data, so the backend needn't fetch
|
||||
// the model page for the model's details -- see importService.ts's makerworldMetaFromExtension.
|
||||
makerworld_design: z.record(z.unknown()).nullable().optional(),
|
||||
});
|
||||
|
||||
// The frontend normally sends the browser's own locally-stored MakerWorld cookie on every
|
||||
|
||||
@@ -35,15 +35,17 @@ export async function upsertAuthorFromImport(info: ImportedAuthorInfo | null): P
|
||||
// A field the new import didn't get is left as it was rather than erased: a MakerWorld
|
||||
// import often only has the design's short creator summary (the fuller profile endpoint
|
||||
// being behind Cloudflare), and shouldn't wipe a bio/links/cover an earlier import fetched.
|
||||
update: {
|
||||
name: info.name ?? undefined,
|
||||
handle: info.handle ?? undefined,
|
||||
bio: info.bio ?? undefined,
|
||||
bioTranslated: info.bioTranslated ?? undefined,
|
||||
links: info.links.length ? info.links : undefined,
|
||||
avatarUrl: info.avatarUrl ?? undefined,
|
||||
backgroundUrl: info.backgroundUrl ?? undefined,
|
||||
},
|
||||
update: info.unverified
|
||||
? {}
|
||||
: {
|
||||
name: info.name ?? undefined,
|
||||
handle: info.handle ?? undefined,
|
||||
bio: info.bio ?? undefined,
|
||||
bioTranslated: info.bioTranslated ?? undefined,
|
||||
links: info.links.length ? info.links : undefined,
|
||||
avatarUrl: info.avatarUrl ?? undefined,
|
||||
backgroundUrl: info.backgroundUrl ?? undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -561,6 +561,10 @@ export type ImportedAuthorInfo = {
|
||||
links: string[];
|
||||
avatarUrl: string | null;
|
||||
backgroundUrl: string | null;
|
||||
/** Set when this came from the client (the extension's copy of the page -- see
|
||||
* importService.ts's makerworldMetaFromExtension) rather than from the site itself: it may
|
||||
* create the author's record, but never overwrites one. */
|
||||
unverified?: boolean;
|
||||
};
|
||||
|
||||
export function emptyImportedPageMetadata(): ImportedPageMetadata {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
extractPageMetadata,
|
||||
findDownloadUrl,
|
||||
makerworldHtmlHeaders,
|
||||
makerworldMetaFromDesign,
|
||||
resolveMakerworldCookie,
|
||||
resolveMakerworldDownloadUrl,
|
||||
type ImportCookies,
|
||||
@@ -78,6 +79,12 @@ export type ImportRequestBody = ImportCookies & {
|
||||
/** Sent alongside resolved_download_url: the MakerWorld profile that URL downloads -- see
|
||||
* ImportedPageMetadata.makerworldProfile. Null when the extension couldn't tell. */
|
||||
resolved_instance_id?: string | null;
|
||||
/** Sent alongside resolved_download_url: the MakerWorld design as the live page embeds it
|
||||
* (its __NEXT_DATA__ design, trimmed to what an import uses), so the model's details come
|
||||
* from the page the user is on rather than from the backend fetching that page again -- a
|
||||
* request Cloudflare often blocks. Client-supplied, so see makerworldMetaFromExtension for
|
||||
* how far it's trusted. */
|
||||
makerworld_design?: Record<string, unknown> | null;
|
||||
/** Internal only -- never comes from the request body/schema. Set by runCollectionImportJob
|
||||
* on each per-design body it builds for a MakerWorld collection batch import, and read
|
||||
* wherever a MakerWorld-bound call happens along this whole chain (tryMakerworldCloudApi,
|
||||
@@ -229,6 +236,44 @@ export async function fetchMakerworldPageAuthor(
|
||||
return completeMakerworldAuthor(extractPageMetadata(buffer.toString("utf-8"), "makerworld.com").author, paceMs);
|
||||
}
|
||||
|
||||
/** Whether `url` is on MakerWorld's image CDN -- where every cover, gallery picture and avatar
|
||||
* in a design lives. */
|
||||
function isMakerworldCdnUrl(url: string | null | undefined): url is string {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "https:" && parsed.hostname.endsWith(".bblmw.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The import's metadata from the design the Thingport Grab extension read off the live model
|
||||
* page (body.makerworld_design) -- null when there's none, or it's for another model, so the
|
||||
* backend reads the page itself instead. It comes from the client, so the image URLs the backend
|
||||
* will fetch are limited to MakerWorld's own CDN, and the author it names can create an author
|
||||
* record but not overwrite one (see ImportedAuthorInfo.unverified). */
|
||||
async function makerworldMetaFromExtension(url: string, body: ImportRequestBody): Promise<ImportedPageMetadata | null> {
|
||||
const design = body.makerworld_design;
|
||||
const parsed = parseMakerworldModelUrl(url);
|
||||
if (!design || !parsed || design.id == null || String(design.id) !== parsed.designId) return null;
|
||||
const meta = makerworldMetaFromDesign(design);
|
||||
meta.previewImageUrl = isMakerworldCdnUrl(meta.previewImageUrl) ? meta.previewImageUrl : null;
|
||||
meta.galleryImages = meta.galleryImages.filter((image) => isMakerworldCdnUrl(image.url));
|
||||
const basicAuthor = meta.author && {
|
||||
...meta.author,
|
||||
avatarUrl: isMakerworldCdnUrl(meta.author.avatarUrl) ? meta.author.avatarUrl : null,
|
||||
unverified: true,
|
||||
};
|
||||
const author = await completeMakerworldAuthor(basicAuthor, body.makerworldPaceMs);
|
||||
return {
|
||||
...meta,
|
||||
author,
|
||||
creator: author?.name ?? meta.creator,
|
||||
makerworldProfile: { instanceId: body.resolved_instance_id ?? null },
|
||||
};
|
||||
}
|
||||
|
||||
type MakerworldCloudShortcut = { downloadUrl: string; meta: ImportedPageMetadata };
|
||||
|
||||
/** Attempts the api.bambulab.com resolution path for a MakerWorld model URL. Returns null
|
||||
@@ -293,6 +338,14 @@ export async function openImportResponse(
|
||||
return openImportResponse(cloudResolved.downloadUrl, body, validatedUrl, depth + 1, cloudResolved.meta);
|
||||
}
|
||||
}
|
||||
// The extension resolved the download *and* sent the page's own design data: nothing left to
|
||||
// ask MakerWorld, so the model page isn't fetched at all.
|
||||
if (depth === 0 && host.endsWith("makerworld.com") && body.resolved_download_url) {
|
||||
const fromExtension = await makerworldMetaFromExtension(validatedUrl, body);
|
||||
if (fromExtension) {
|
||||
return openImportResponse(body.resolved_download_url, body, validatedUrl, depth + 1, fromExtension);
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { "User-Agent": IMPORT_USER_AGENT, Accept: "*/*" };
|
||||
let makerworldCookie: string | null = null;
|
||||
|
||||
@@ -310,6 +310,22 @@ describe("importing from MakerWorld", () => {
|
||||
return `<html><head><title>Classic Benchy</title></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify(nextData)}</script></body></html>`;
|
||||
}
|
||||
|
||||
/** The design as the Thingport Grab extension sends it with an import -- the page's own
|
||||
* __NEXT_DATA__ design, trimmed (see the extension's makerworldDesignForImport). */
|
||||
function extensionDesign(designId: string): Record<string, unknown> {
|
||||
return {
|
||||
id: Number(designId),
|
||||
title: "Classic Benchy",
|
||||
summary: "<p>The <b>classic</b> calibration boat.</p>",
|
||||
tags: ["benchy", "calibration"],
|
||||
coverUrl: "https://makerworld.bblmw.com/makerworld/model/benchy/cover.jpg",
|
||||
designCreator: creator,
|
||||
designExtension: {
|
||||
design_pictures: [{ name: "side.jpg", url: "https://makerworld.bblmw.com/makerworld/model/benchy/side.jpg" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const expected = (overrides: Partial<Expected> = {}): Expected => ({
|
||||
title: "Classic Benchy",
|
||||
descriptionIncludes: "classic calibration boat",
|
||||
@@ -341,7 +357,7 @@ describe("importing from MakerWorld", () => {
|
||||
await expectFullyImported(res.body.id, expected());
|
||||
});
|
||||
|
||||
it("from the Thingport Grab extension (download link resolved in the browser): still links the author", async () => {
|
||||
it("from an older Thingport Grab extension (download link resolved in the browser, no page data): reads the page", async () => {
|
||||
const designId = String((stamp % 1_000_000_000) + 10);
|
||||
const instanceId = String((stamp % 1_000_000_000) + 11);
|
||||
mockFetch({
|
||||
@@ -365,6 +381,58 @@ describe("importing from MakerWorld", () => {
|
||||
await expectFullyImported(res.body.id, expected());
|
||||
});
|
||||
|
||||
it("from the Thingport Grab extension with the page's data: doesn't fetch the model page at all", async () => {
|
||||
const designId = String((stamp % 1_000_000_000) + 40);
|
||||
const instanceId = String((stamp % 1_000_000_000) + 41);
|
||||
// No route for the model page: fetching it (often Cloudflare-blocked for a server) fails the test.
|
||||
mockFetch({
|
||||
"https://makerworld.com/api/v1/design-user-service/user/profile/": cloudflareChallenge,
|
||||
"https://makerworld.bblmw.com/makerworld/model/benchy/benchy.3mf": threeMf,
|
||||
"https://makerworld.bblmw.com/makerworld/model/benchy/cover.jpg": png,
|
||||
"https://makerworld.bblmw.com/makerworld/model/benchy/side.jpg": png,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/import")
|
||||
.set({ ...auth(), "X-Thingport-Client": "grab" })
|
||||
.send({
|
||||
url: `https://makerworld.com/en/models/${designId}-classic-benchy`,
|
||||
resolved_download_url: "https://makerworld.bblmw.com/makerworld/model/benchy/benchy.3mf",
|
||||
resolved_instance_id: instanceId,
|
||||
makerworld_design: extensionDesign(designId),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await expectFullyImported(res.body.id, expected());
|
||||
});
|
||||
|
||||
it("from the Thingport Grab extension with the page's data: trusts it no further than MakerWorld's own", async () => {
|
||||
const designId = String((stamp % 1_000_000_000) + 50);
|
||||
const instanceId = String((stamp % 1_000_000_000) + 51);
|
||||
mockFetch({
|
||||
"https://makerworld.com/api/v1/design-user-service/user/profile/": cloudflareChallenge,
|
||||
"https://makerworld.bblmw.com/makerworld/model/benchy/benchy.3mf": threeMf,
|
||||
"https://makerworld.bblmw.com/makerworld/model/benchy/cover.jpg": png,
|
||||
// Anything off MakerWorld's CDN would fail the test here if the backend fetched it.
|
||||
});
|
||||
|
||||
const design = extensionDesign(designId);
|
||||
design.designCreator = { ...creator, name: "Impostor", avatar: "https://evil.example.com/avatar.png" };
|
||||
design.designExtension = { design_pictures: [{ name: "x.jpg", url: "http://169.254.169.254/latest/meta-data" }] };
|
||||
const res = await request(app)
|
||||
.post("/api/import")
|
||||
.set({ ...auth(), "X-Thingport-Client": "grab" })
|
||||
.send({
|
||||
url: `https://makerworld.com/en/models/${designId}-classic-benchy`,
|
||||
resolved_download_url: "https://makerworld.bblmw.com/makerworld/model/benchy/benchy.3mf",
|
||||
resolved_instance_id: instanceId,
|
||||
makerworld_design: design,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// Linked to the (already known) author, whose record the client's version didn't overwrite;
|
||||
// only the cover was fetched.
|
||||
await expectFullyImported(res.body.id, expected({ previewImages: 1 }));
|
||||
});
|
||||
|
||||
/** A web-app import with a MakerWorld login, which goes through MakerWorld's own API. */
|
||||
async function importWithLogin(designId: string, profileReachable: boolean) {
|
||||
const profileId = String(Number(designId) + 1);
|
||||
|
||||
Reference in new issue
Block a user