fix: Makerworld import issues + allowing import different profiles

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-24 19:15:17 +02:00
1 parent e5eb3f9818
commit 303ab918ea
16 files changed
+559 -92

No files matched your search

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Plate" ADD COLUMN "sourceInstanceId" TEXT,
ADD COLUMN "contentSha256" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Plate_printId_sourceInstanceId_key" ON "Plate"("printId", "sourceInstanceId");
+21 -11
View File
@@ -343,19 +343,29 @@ model AuthorLink {
}
model Plate {
id String @id @default(cuid())
printId String
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
position Int
filename String
mime String
size Int @default(0)
storagePath String @unique
sourcePath String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
printId String
print Print @relation(fields: [printId], references: [id], onDelete: Cascade)
position Int
filename String
mime String
size Int @default(0)
storagePath String @unique
sourcePath String?
// The MakerWorld print profile ("instance") this file was imported from -- one Print per
// MakerWorld design, one Plate per imported profile, since each profile's 3MF carries its own
// print settings and can't be merged. Null for everything else, and for plates imported before
// this was tracked (see importService.ts's addMakerworldProfileToPrint for how those get adopted).
sourceInstanceId String?
// SHA-256 (hex) of the file, computed lazily and only when needed: matching a freshly
// downloaded MakerWorld profile against untagged plates (addMakerworldProfileToPrint). Plate
// files are never rewritten in place, so once set it stays valid.
contentSha256 String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([printId, position])
@@unique([printId, sourceInstanceId])
@@index([printId])
}
+7 -2
View File
@@ -53,6 +53,7 @@ const importRequestSchema = z.object({
// fallback) entirely, since those are the only two call sites that can trip MakerWorld's
// CAPTCHA and its 2-hour blanket cooloff (see makerworldCaptcha.ts).
resolved_download_url: z.string().nullable().optional(),
resolved_instance_id: z.string().nullable().optional(),
});
// The frontend normally sends the browser's own locally-stored MakerWorld cookie on every
@@ -70,8 +71,12 @@ router.post(
asyncHandler(async (req, res) => {
const body = await withStoredMakerworldCookie(req.userId!, parseBody(importRequestSchema, req.body));
const url = await normalizeImportUrl(body.url);
const { print, plates, author, previewImages } = await importPrintFromUrl(req.userId!, url, body);
res.json(toPrintOut(print, plates, [], null, author, previewImages));
const result = await importPrintFromUrl(req.userId!, url, body);
const { print, plates, author, previewImages } = result;
// Lets a client tell "added this MakerWorld profile to a model you already had" apart from
// a fresh import and from a no-op (see importService.ts's addMakerworldProfileToPrint).
const importOutcome = result.alreadyImported ? "already_imported" : result.profileAdded ? "profile_added" : "created";
res.json({ ...toPrintOut(print, plates, [], null, author, previewImages), import_outcome: importOutcome });
void createLog({ userId: req.userId!, action: "model_imported", targetId: print.id, details: { name: print.name, url } });
}),
);
+39 -8
View File
@@ -471,8 +471,16 @@ export type ImportedPageMetadata = {
* resolveCategoryIdByCategory). Empty/null when the resolver doesn't expose categories. */
siteCategoryIds: number[];
categorySite: "makerworld" | "thingiverse" | "printables" | null;
/** Which MakerWorld print profile the resolved download belongs to, when known -- lets
* importPrintFromUrl add a second profile of an already-imported design as its own plate
* instead of treating it as a duplicate. Unset for every other provider. */
makerworldProfile?: MakerworldProfileRef;
};
/** `instanceId` is the profile the downloaded file came from; null when the resolver couldn't
* tell. */
export type MakerworldProfileRef = { instanceId: string | null };
export type ImportedAuthorInfo = {
provider: string;
externalId: string;
@@ -521,9 +529,12 @@ export function extractPageMetadata(html: string, pageHost: string): ImportedPag
return meta;
}
function makerworldInstanceIdFromNextData(data: unknown): string | null {
/** Same precedence as makerworldCloudApi.ts's resolveMakerworldViaCloudApi: the profile named
* in the URL hash (only if this design actually has it), then the default, then the first. */
function makerworldInstanceIdFromNextData(data: unknown, requestedInstanceId: string | null): string | null {
const design = getPath(data, "props", "pageProps", "design") as Record<string, unknown> | undefined;
if (!design) return null;
if (requestedInstanceId && makerworldDesignHasInstance(design, requestedInstanceId)) return requestedInstanceId;
const defaultInstance = design.defaultInstanceId;
if (defaultInstance) return String(defaultInstance);
const instances = design.instances;
@@ -537,6 +548,14 @@ function makerworldInstanceIdFromNextData(data: unknown): string | null {
return null;
}
function makerworldDesignHasInstance(design: Record<string, unknown>, instanceId: string): boolean {
const instances = design.instances;
if (!Array.isArray(instances)) return false;
return instances.some(
(inst) => inst && typeof inst === "object" && String((inst as Record<string, unknown>).id) === instanceId,
);
}
function makerworldNonceFromNextData(data: unknown): string | null {
const nonce = getPath(data, "props", "pageProps", "x-nonce");
return typeof nonce === "string" && nonce.trim() ? nonce : null;
@@ -572,32 +591,44 @@ async function fetchMakerworldModelDownloadUrl(
return data ? extractDownloadUrlFromResponse(data, apiUrl) : null;
}
/** `requestedInstanceId` is the print profile from the page URL's #profileId-… hash, passed in
* separately because `pageUrl` here is the fetch response's URL, which never carries a hash.
* Every fallback past the instance-scoped endpoint is model-level, i.e. the default profile's
* file, so the returned profile says so. */
export async function resolveMakerworldDownloadUrl(
html: string,
pageUrl: string,
makerworldCookie: string | null,
): Promise<string | null> {
requestedInstanceId: string | null = null,
): Promise<{ downloadUrl: string; profile: MakerworldProfileRef } | null> {
const nextData = extractNextDataJson(html);
let designId: string | null = null;
let nonce: string | null = null;
let instanceId: string | null = null;
let defaultInstanceId: string | null = null;
const asDefault = (downloadUrl: string) => ({ downloadUrl, profile: { instanceId: defaultInstanceId } });
if (nextData) {
const url = findDownloadUrlInJson(nextData, pageUrl);
if (url) return url;
instanceId = makerworldInstanceIdFromNextData(nextData, requestedInstanceId);
defaultInstanceId = makerworldInstanceIdFromNextData(nextData, null);
// A generic URL found in the page data belongs to whichever profile the page embedded (the
// default), so it's only trusted when no other profile was asked for.
if (!requestedInstanceId || instanceId !== requestedInstanceId) {
const url = findDownloadUrlInJson(nextData, pageUrl);
if (url) return asDefault(url);
}
designId = makerworldDesignIdFromNextData(nextData);
nonce = makerworldNonceFromNextData(nextData);
instanceId = makerworldInstanceIdFromNextData(nextData);
}
if (instanceId) {
const url = await fetchMakerworldInstanceDownloadUrl(instanceId, pageUrl, makerworldCookie);
if (url) return url;
if (url) return { downloadUrl: url, profile: { instanceId } };
}
const modelId = designId || makerworldModelIdFromUrl(pageUrl);
if (!modelId) return null;
const url = await fetchMakerworldModelDownloadUrl(modelId, pageUrl, nonce, makerworldCookie);
if (url) return url;
if (url) return asDefault(url);
const apiCandidates = [
`https://makerworld.com/api/v1/models/${modelId}`,
@@ -609,7 +640,7 @@ export async function resolveMakerworldDownloadUrl(
const data = await fetchJsonFromUrl(apiUrl, pageUrl);
if (!data) continue;
const found = findDownloadUrlInJson(data, apiUrl);
if (found) return found;
if (found) return asDefault(found);
}
return null;
}
+144 -9
View File
@@ -47,7 +47,7 @@ import {
} from "./printablesApi";
import { getThingiverseAccessToken } from "./settingsService";
import { upsertAuthorFromImport } from "./authorService";
import { createPrint, type NewPlateInput, type PrintMetaInput } from "./printCreation";
import { addPlatesToPrint, createPrint, resolvePlateFilePath, type NewPlateInput, type PrintMetaInput } from "./printCreation";
import { plateThumbExists, saveThumbFromBytes } from "./printService";
import { addPreviewImage } from "./previewImageService";
import { prisma } from "../db";
@@ -70,6 +70,9 @@ export type ImportRequestBody = ImportCookies & {
* in that page's context, so real cookies attach automatically). When present, openImportResponse
* skips both of its own resolution paths for this url -- see its use there. */
resolved_download_url?: string | null;
/** 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;
/** 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,
@@ -268,6 +271,7 @@ export async function openImportResponse(
author: extracted.author ?? inheritedMeta.author,
siteCategoryIds: extracted.siteCategoryIds.length ? extracted.siteCategoryIds : inheritedMeta.siteCategoryIds,
categorySite: extracted.categorySite ?? inheritedMeta.categorySite,
makerworldProfile: inheritedMeta.makerworldProfile,
};
if (pageHost.endsWith("makerworld.com")) {
// Same short-circuit as the cloud API above -- resolveMakerworldDownloadUrl's own
@@ -275,9 +279,15 @@ export async function openImportResponse(
// CAPTCHA cooloff, so a client-supplied resolution skips it rather than resolving twice.
if (body.resolved_download_url) {
downloadUrl = body.resolved_download_url;
resolvedMeta.makerworldProfile = { instanceId: body.resolved_instance_id ?? null };
} else {
if (!makerworldCookie) makerworldCookie = resolveMakerworldCookie(body);
downloadUrl = await resolveMakerworldDownloadUrl(html, finalUrl, makerworldCookie);
const requestedInstanceId = parseMakerworldModelUrl(validatedUrl)?.requestedInstanceId ?? null;
const resolved = await resolveMakerworldDownloadUrl(html, finalUrl, makerworldCookie, requestedInstanceId);
if (resolved) {
downloadUrl = resolved.downloadUrl;
resolvedMeta.makerworldProfile = resolved.profile;
}
}
}
if (!downloadUrl) {
@@ -329,7 +339,15 @@ export async function downloadImportToTemp(
url: string,
body: ImportRequestBody,
): Promise<{ tempPath: string; filename: string; mime: string; meta: ImportedPageMetadata }> {
const { response, finalUrl, meta } = await openImportResponse(url, body);
return saveImportResponseToTemp(await openImportResponse(url, body), body);
}
/** The second half of downloadImportToTemp, split out so a caller can look at the resolved
* metadata first and skip downloading the body entirely (see addMakerworldProfileToPrint). */
async function saveImportResponseToTemp(
{ response, finalUrl, meta }: OpenImportResult,
body: ImportRequestBody,
): Promise<{ tempPath: string; filename: string; mime: string; meta: ImportedPageMetadata }> {
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > IMPORT_MAX_BYTES) {
await response.body?.cancel().catch(() => undefined);
@@ -470,7 +488,16 @@ export function identifySourceModel(url: string): { provider: string; externalId
return null;
}
export type ImportStatus = { recognized: boolean; already_imported: boolean; print_id: string | null };
/** `state` refines already_imported for a MakerWorld URL naming a print profile, where the model
* can be in the library without that profile's file: "profile_missing" when every plate on the
* print is a known, different profile, "profile_unknown" when some plates predate profile
* tracking and might be it. already_imported stays for older extension versions. */
export type ImportStatus = {
recognized: boolean;
already_imported: boolean;
print_id: string | null;
state: "not_imported" | "imported" | "profile_missing" | "profile_unknown";
};
/** Cheap "is this page importable, and have I already imported it" check -- unlike
* inspectImportLink, this never fetches the provider's page itself, so it's safe to call on
@@ -480,12 +507,22 @@ export type ImportStatus = { recognized: boolean; already_imported: boolean; pri
* sense for a single model, never a listing page. */
export async function checkImportStatus(userId: string, url: string): Promise<ImportStatus> {
const source = identifySourceModel(url);
if (!source) return { recognized: false, already_imported: false, print_id: null };
if (!source) return { recognized: false, already_imported: false, print_id: null, state: "not_imported" };
const print = await prisma.print.findFirst({
where: { userId, sourceProvider: source.provider, sourceExternalId: source.externalId },
select: { id: true },
select: { id: true, plates: { select: { sourceInstanceId: true } } },
});
return { recognized: true, already_imported: Boolean(print), print_id: print?.id ?? null };
if (!print) return { recognized: true, already_imported: false, print_id: null, state: "not_imported" };
// A MakerWorld URL naming a specific profile only counts as imported once that profile's file
// is on the print (see addMakerworldProfileToPrint). Without a hash there's no way to tell
// which profile is meant short of asking MakerWorld -- not worth it on every page view -- so
// any imported profile counts.
const requestedInstanceId = source.provider === "makerworld" ? parseMakerworldModelUrl(url)?.requestedInstanceId : null;
if (requestedInstanceId && !print.plates.some((plate) => plate.sourceInstanceId === requestedInstanceId)) {
const state = print.plates.some((plate) => plate.sourceInstanceId == null) ? "profile_unknown" : "profile_missing";
return { recognized: true, already_imported: false, print_id: print.id, state };
}
return { recognized: true, already_imported: true, print_id: print.id, state: "imported" };
}
/** The inverse of identifySourceModel above -- rebuilds the original model page URL from the
@@ -517,6 +554,94 @@ async function findExistingImportedPrint(
return { print, plates, author: print.author, previewImages };
}
type ExistingImportedPrint = NonNullable<Awaited<ReturnType<typeof findExistingImportedPrint>>>;
async function sha256OfFile(filePath: string): Promise<string> {
const hash = crypto.createHash("sha256");
for await (const chunk of fsSync.createReadStream(filePath)) hash.update(chunk as Buffer);
return hash.digest("hex");
}
/** A plate's stored contentSha256, computing (and storing) it on first use. Null when its file
* can't be read. */
async function plateContentSha256(plate: Plate): Promise<string | null> {
if (plate.contentSha256) return plate.contentSha256;
const filePath = resolvePlateFilePath(plate);
if (!filePath || !fsSync.existsSync(filePath)) return null;
const sha = await sha256OfFile(filePath);
await prisma.plate.update({ where: { id: plate.id }, data: { contentSha256: sha } });
return sha;
}
/** Re-import of a MakerWorld design that's already a Print: each print profile has its own 3MF
* (with its own print settings, so they can't be merged into one file), so a profile not on the
* print yet is added as another plate rather than treated as a duplicate. The print's own
* metadata/images are left as they are.
*
* Plates without a sourceInstanceId (imported before profiles were tracked, or uploaded by hand)
* could be any profile, so the downloaded file is compared against them by SHA-256 first: a
* match tags that plate instead of adding a copy. Once a print's plates are all tagged, this
* never downloads anything for a profile it already has. */
async function addMakerworldProfileToPrint(
existing: ExistingImportedPrint,
url: string,
body: ImportRequestBody,
): Promise<ExistingImportedPrint & { alreadyImported: boolean; profileAdded?: boolean }> {
const alreadyImported = { ...existing, alreadyImported: true };
const hasProfile = (instanceId: string) => existing.plates.some((plate) => plate.sourceInstanceId === instanceId);
// Checked before any MakerWorld request: no hash (and no profile from the extension) means
// "whichever profile is the default", which can only be told apart from what's already
// imported by asking MakerWorld -- and a collection re-import runs this for every design it
// already has, where that would mean one extra (CAPTCHA-prone) lookup per design.
const wanted = body.resolved_instance_id ?? parseMakerworldModelUrl(url)?.requestedInstanceId ?? null;
if (!wanted || hasProfile(wanted)) return alreadyImported;
const opened = await openImportResponse(url, body);
const instanceId = opened.meta.makerworldProfile?.instanceId ?? null;
// Unknown profile (can't dedupe it), or the resolver fell back to one already on the print
// (e.g. the hash named a profile this design doesn't have, so it resolved the default).
if (!instanceId || hasProfile(instanceId)) {
await opened.response.body?.cancel().catch(() => undefined);
return alreadyImported;
}
const { tempPath, filename, mime } = await saveImportResponseToTemp(opened, body);
try {
const untagged = existing.plates.filter((plate) => plate.sourceInstanceId == null);
if (untagged.length) {
const downloadedSha = await sha256OfFile(tempPath);
for (const plate of untagged) {
if ((await plateContentSha256(plate)) !== downloadedSha) continue;
try {
await prisma.plate.update({ where: { id: plate.id }, data: { sourceInstanceId: instanceId } });
} catch (err) {
// Race guard: a concurrent import of this same profile tagged/added it first.
if (!isUniqueConstraintError(err)) throw err;
}
return { ...alreadyImported, plates: await platesOf(existing.print.id) };
}
}
try {
await addPlatesToPrint(existing.print.userId, existing.print.id, [
{ filename, mime, tempFilePath: tempPath, sourceInstanceId: instanceId },
]);
} catch (err) {
// Race guard: a concurrent import of this same profile added it first.
if (isUniqueConstraintError(err)) return alreadyImported;
throw err;
}
return { ...existing, plates: await platesOf(existing.print.id), alreadyImported: false, profileAdded: true };
} finally {
if (fsSync.existsSync(tempPath)) await fs.rm(tempPath, { force: true }).catch(() => undefined);
}
}
function platesOf(printId: string): Promise<Plate[]> {
return prisma.plate.findMany({ where: { printId }, orderBy: { position: "asc" } });
}
/** Bulk version of findExistingImportedPrint's lookup -- used by the collection/likes ".../entries"
* routes to flag which entries in a listing the user already has, *before* they pick what to
* import, instead of only finding out one by one as each import attempt hits the unique
@@ -745,10 +870,20 @@ export async function importPrintFromUrl(
userId: string,
url: string,
body: ImportRequestBody,
): Promise<{ print: Print; plates: Plate[]; author: Author | null; previewImages: PreviewImage[]; alreadyImported: boolean }> {
): Promise<{
print: Print;
plates: Plate[];
author: Author | null;
previewImages: PreviewImage[];
alreadyImported: boolean;
/** Set when an existing MakerWorld print gained another profile's file (see
* addMakerworldProfileToPrint) rather than being created. */
profileAdded?: boolean;
}> {
const source = identifySourceModel(url);
if (source) {
const existing = await findExistingImportedPrint(userId, source);
if (existing && source.provider === "makerworld") return addMakerworldProfileToPrint(existing, url, body);
if (existing) return { ...existing, alreadyImported: true };
}
@@ -778,7 +913,7 @@ export async function importPrintFromUrl(
let result: { print: Print; plates: Plate[] };
try {
result = await createPrint(userId, printMeta, path.parse(filename).name, [
{ filename, mime, tempFilePath: tempPath },
{ filename, mime, tempFilePath: tempPath, sourceInstanceId: meta.makerworldProfile?.instanceId ?? null },
]);
} catch (err) {
// Race guard: another concurrent import of the same source model won between our
@@ -374,6 +374,7 @@ export async function resolveMakerworldViaCloudApi(
author,
siteCategoryIds,
categorySite: siteCategoryIds.length ? "makerworld" : null,
makerworldProfile: { instanceId: selected.id != null ? String(selected.id) : null },
},
};
}
+22 -11
View File
@@ -30,6 +30,8 @@ export type NewPlateInput = {
tempFilePath?: string; // moved (renamed) into managed storage, source is deleted
copyFromPath?: string; // copied into managed storage, source left intact
sourcePath?: string; // no-copy: file stays at this path, storagePath is still rendered/recorded
/** The MakerWorld profile this file was imported from -- see Plate.sourceInstanceId. */
sourceInstanceId?: string | null;
};
export type PrintMetaInput = {
@@ -137,17 +139,26 @@ async function createPlateAtPosition(
const size = await resolveSize(input, effectivePath);
const mime = input.mime || guessMimeFromPath(desiredFilename) || "application/octet-stream";
const record = await prisma.plate.create({
data: {
printId: print.id,
position,
filename: desiredFilename,
mime,
size,
storagePath,
sourcePath: input.sourcePath ?? null,
},
});
let record: Plate;
try {
record = await prisma.plate.create({
data: {
printId: print.id,
position,
filename: desiredFilename,
mime,
size,
storagePath,
sourcePath: input.sourcePath ?? null,
sourceInstanceId: input.sourceInstanceId ?? null,
},
});
} catch (err) {
// e.g. a concurrent import of the same MakerWorld profile winning the (printId,
// sourceInstanceId) unique index -- don't leave the file we just placed orphaned in storage.
if (effectivePath && !input.sourcePath) await fs.rm(effectivePath, { force: true }).catch(() => undefined);
throw err;
}
await thumbnailAndSniff(record.id, desiredFilename, mime, effectivePath);
return { record, effectivePath };
+137 -2
View File
@@ -1,11 +1,11 @@
import { beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { identifySourceModel, importPrintFromUrl } from "../src/services/importService";
import { checkImportStatus, identifySourceModel, importPrintFromUrl } from "../src/services/importService";
import { createJob, getActiveJob, updateJob } from "../src/services/importJobService";
import { createNotification, listNotifications, markAllRead } from "../src/services/notificationService";
@@ -97,6 +97,141 @@ describe("import dedup", () => {
});
});
// Distinct bytes per MakerWorld profile, like the real thing -- what the SHA-256 match keys on.
function profileFileContents(instanceId: string) {
return `solid profile-${instanceId} endsolid`;
}
describe("MakerWorld print profiles", () => {
// One design, several print profiles ("instances"), each with its own 3MF -- see
// importService.ts's addMakerworldProfileToPrint. Every import here goes through the extension's
// pre-resolved path (resolved_download_url + profile ids), so the only fetches are the model page
// itself and the file; both are intercepted.
const designId = "888777";
const pageUrl = `https://makerworld.com/en/models/${designId}-profile-slug`;
const originalFetch = global.fetch;
function mockMakerworldFetch() {
const fetchMock = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
if (url.startsWith("https://makerworld.com/en/models/")) {
return new Response("<html><head><title>Profile Test</title></head><body></body></html>", {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
const file = url.match(/^https:\/\/makerworld\.com\/files\/profile-(\w+)\.stl$/);
if (file) {
return new Response(profileFileContents(file[1]), { headers: { "content-type": "application/octet-stream" } });
}
throw new Error(`Unexpected fetch to ${url}`);
});
global.fetch = fetchMock as unknown as typeof fetch;
return fetchMock;
}
function importProfile(url: string, instanceId: string | null) {
return importPrintFromUrl(userId, url, {
url,
tags: [],
resolved_download_url: `https://makerworld.com/files/profile-${instanceId ?? "unknown"}.stl`,
resolved_instance_id: instanceId,
});
}
async function createLegacyPrint(contents: string) {
const uploadRes = await request(app).post("/api/upload").set(auth()).attach("files", tmpFile("legacy-profile.stl", contents));
expect(uploadRes.status).toBe(200);
const printId = uploadRes.body.prints[0].id as string;
await prisma.print.update({ where: { id: printId }, data: { sourceProvider: "makerworld", sourceExternalId: designId } });
return printId;
}
afterEach(async () => {
global.fetch = originalFetch;
const prints = await prisma.print.findMany({ where: { userId, sourceProvider: "makerworld", sourceExternalId: designId } });
for (const print of prints) await request(app).delete(`/api/print/${print.id}`).set(auth());
});
it("adds a second profile as another plate on the same print, and skips a profile it already has", async () => {
mockMakerworldFetch();
const first = await importProfile(pageUrl, "100");
expect(first.alreadyImported).toBe(false);
expect(first.plates.map((plate) => plate.sourceInstanceId)).toEqual(["100"]);
const second = await importProfile(`${pageUrl}#profileId-200`, "200");
expect(second.alreadyImported).toBe(false);
expect(second.print.id).toBe(first.print.id);
expect(second.plates.map((plate) => plate.sourceInstanceId)).toEqual(["100", "200"]);
const fetchMock = mockMakerworldFetch();
const again = await importProfile(`${pageUrl}#profileId-200`, "200");
expect(again.alreadyImported).toBe(true);
expect(again.plates).toHaveLength(2);
expect(fetchMock).not.toHaveBeenCalled();
});
it("reports what POST /import did via import_outcome", async () => {
mockMakerworldFetch();
const post = (url: string, instanceId: string) =>
request(app)
.post("/api/import")
.set(auth())
.send({ url, resolved_download_url: `https://makerworld.com/files/profile-${instanceId}.stl`, resolved_instance_id: instanceId });
expect((await post(pageUrl, "100")).body.import_outcome).toBe("created");
expect((await post(`${pageUrl}#profileId-200`, "200")).body.import_outcome).toBe("profile_added");
expect((await post(`${pageUrl}#profileId-200`, "200")).body.import_outcome).toBe("already_imported");
});
it("treats a bare model URL as already imported without asking MakerWorld", async () => {
mockMakerworldFetch();
await importProfile(pageUrl, "100");
const fetchMock = mockMakerworldFetch();
const result = await importPrintFromUrl(userId, pageUrl, { url: pageUrl, tags: [] });
expect(result.alreadyImported).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
});
it("reports import status per profile when the URL names one", async () => {
expect((await checkImportStatus(userId, `${pageUrl}#profileId-200`)).state).toBe("not_imported");
mockMakerworldFetch();
await importProfile(`${pageUrl}#profileId-200`, "200");
const imported = await checkImportStatus(userId, `${pageUrl}#profileId-200`);
expect(imported).toMatchObject({ already_imported: true, state: "imported" });
const missing = await checkImportStatus(userId, `${pageUrl}#profileId-300`);
expect(missing).toMatchObject({ already_imported: false, state: "profile_missing", print_id: imported.print_id });
expect((await checkImportStatus(userId, pageUrl)).state).toBe("imported");
});
it("reports a profile as unknown while the print has plates from before profiles were tracked", async () => {
await createLegacyPrint("solid anything endsolid");
const status = await checkImportStatus(userId, `${pageUrl}#profileId-300`);
expect(status).toMatchObject({ already_imported: false, state: "profile_unknown" });
expect((await checkImportStatus(userId, pageUrl)).state).toBe("imported");
});
it("tags an untagged plate whose file matches the downloaded profile instead of adding a copy", async () => {
// Deliberately a non-default profile -- older imports could come from any profile.
const printId = await createLegacyPrint(profileFileContents("200"));
mockMakerworldFetch();
const other = await importProfile(`${pageUrl}#profileId-100`, "100");
expect(other.alreadyImported).toBe(false);
expect(other.profileAdded).toBe(true);
expect(other.plates.map((plate) => plate.sourceInstanceId)).toEqual([null, "100"]);
const matched = await importProfile(`${pageUrl}#profileId-200`, "200");
expect(matched.alreadyImported).toBe(true);
expect(matched.print.id).toBe(printId);
expect(matched.plates.map((plate) => plate.sourceInstanceId)).toEqual(["200", "100"]);
// The hash is stored on first use, so later comparisons don't re-read the file.
expect(matched.plates[0].contentSha256).toMatch(/^[0-9a-f]{64}$/);
});
});
describe("import job lock", () => {
it("blocks a new batch import while one is already running, and reports it via the active/job endpoints", async () => {
const job = await createJob(userId, "COLLECTION", {
+15 -7
View File
@@ -171,15 +171,23 @@ async function pollJobToCompletion(jobId) {
* the import (and any collection filing) runs to completion regardless of what the calling page
* does next -- only the reply back to a since-destroyed content script can get lost, never the
* work itself. */
async function handleImportSingle({ url, entries, collectionId, resolvedDownloadUrl }) {
async function handleImportSingle({ url, entries, collectionId, resolved }) {
// For a MakerWorld model, content.js resolves the actual download URL itself, straight from
// the live page (see its resolveMakerworldDownloadUrlFromPage) -- passing it through as
// resolved_download_url lets the backend skip its own resolution entirely (both its
// api.bambulab.com cloud-API call and its api/v1 HTML-scrape fallback), which are the only two
// places able to trip MakerWorld's CAPTCHA and the resulting 2-hour account-wide lockout. Left
// undefined/null for anything else, or when the page-side resolution didn't find one -- the
// backend then falls back to resolving it exactly as before.
const extra = resolvedDownloadUrl ? { resolved_download_url: resolvedDownloadUrl } : null;
// backend then falls back to resolving it exactly as before. The profile ids tell the backend
// which MakerWorld print profile that file is, so importing another profile of a model that's
// already in the library adds it as a second file instead of being skipped as a duplicate.
const extra =
resolved && resolved.downloadUrl
? {
resolved_download_url: resolved.downloadUrl,
resolved_instance_id: resolved.instanceId || null,
}
: null;
let print;
if (entries) {
const { job_id } = await apiCall("POST", "/import/zip", { url, entries, ...extra });
@@ -385,16 +393,16 @@ async function advanceMakerworldJob(tabId) {
// This matters most right here, not just for a plain single-model import: it's this per-model
// loop that fires MakerWorld resolution calls back-to-back, exactly the pattern that trips its
// CAPTCHA. Best-effort -- if the content script isn't ready yet or the page structure doesn't
// match, resolvedDownloadUrl stays null and the backend just resolves it the old way.
let resolvedDownloadUrl = null;
// match, `resolved` stays null and the backend just resolves it the old way.
let resolved = null;
try {
const res = await withTimeout(chrome.tabs.sendMessage(tabId, { type: "RESOLVE_MAKERWORLD_DOWNLOAD_URL" }), MAKERWORLD_DOWNLOAD_RESOLVE_TIMEOUT_MS);
if (res && res.ok) resolvedDownloadUrl = res.downloadUrl;
if (res && res.ok) resolved = res.resolved;
} catch {
// No listener yet, or the tab navigated away already -- fine, see above.
}
try {
await handleImportSingle({ url: currentUrl, collectionId: job.collectionId, resolvedDownloadUrl });
await handleImportSingle({ url: currentUrl, collectionId: job.collectionId, resolved });
} catch (err) {
// A manual "Import next" (see handleForceAdvanceMakerworldJob) may have already moved the job
// past this step while this request was still in flight -- if so, this failure is stale and
+5 -1
View File
@@ -43,7 +43,11 @@ function thingportIsMakerworldModelUrl(url) {
}
if (!parsed.hostname.toLowerCase().endsWith("makerworld.com")) return null;
const m = parsed.pathname.match(/\/models?\/(\d+)/i);
return m ? { designId: m[1] } : null;
if (!m) return null;
// The print profile picked on the page, if any (e.g. #profileId-123456) -- same as the
// backend's parseMakerworldModelUrl.
const hashMatch = parsed.hash.match(/profileid-(\d+)/i);
return { designId: m[1], requestedInstanceId: hashMatch ? hashMatch[1] : null };
}
/** Mirrors the backend's buildImportSourceUrl (importService.ts) for MakerWorld -- lets the
+18
View File
@@ -20,6 +20,24 @@
}
.tg-fab:hover { transform: scale(1.06); }
.tg-fab img { width: 34px; height: 34px; pointer-events: none; }
/* The model's already in the library, only this page's MakerWorld print profile might not be --
see content.js's addProfileHtml. A checkmark badge rather than a different icon, so it still
reads as Thingport but not as "you don't have this yet". */
.tg-fab-in-library::after {
content: "\2713";
position: absolute;
top: -2px;
right: -2px;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #ffffff;
box-sizing: border-box;
background: #00b800;
color: #ffffff;
font: 700 11px/16px -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
text-align: center;
}
/* `all: initial` below resets `display` to its CSS-initial value ("inline") as an *author* rule,
which otherwise beats the UA stylesheet's `[hidden] { display: none }` regardless of the
+120 -36
View File
@@ -48,8 +48,8 @@
await injectStyles(shadowRoot);
const button = document.createElement("button");
button.className = "tg-fab";
button.setAttribute("aria-label", "Import to Thingport");
button.className = context.library ? "tg-fab tg-fab-in-library" : "tg-fab";
button.setAttribute("aria-label", context.library ? "Add this print profile to Thingport" : "Import to Thingport");
button.innerHTML = `<img src="${ICON_URL}" alt="" />`;
button.addEventListener("click", togglePanel);
shadowRoot.appendChild(button);
@@ -141,6 +141,14 @@
}
async function loadSingleItem() {
// Nothing inspect would tell us matters here (a MakerWorld profile is always a single 3MF),
// and inspecting would make the backend resolve the download from MakerWorld just to show
// the panel.
if (context.library) {
renderPanel(addProfileHtml());
bindImportButton(() => runDirectImport());
return;
}
renderPanel(`<div class="tg-status">Checking link…</div>`);
const { provider, type } = context.classification;
// Printables' generic page-fetch flow is Cloudflare-gated (inspectImportLink would just fail)
@@ -183,6 +191,35 @@
`;
}
/** The model's already in the library, but this page's print profile isn't known to be --
* each MakerWorld profile has its own 3MF (own print settings), stored as its own file on the
* one model. No collection picker: the model's collections are already whatever they are. */
function addProfileHtml() {
const { state, printId } = context.library;
const profileName = currentMakerworldProfileTitle();
const profileLabel = profileName ? `the "${escapeHtml(profileName)}" profile` : "this print profile";
const hint =
state === "profile_missing"
? `You already have this model. Add ${profileLabel} as another file on it?`
: `This model is in your library. Add ${profileLabel} if you don't have it yet -- if one of the model's files already is this profile, nothing is downloaded twice.`;
const modelLink = printId ? `${context.instanceUrl}/models/${printId}` : `${context.instanceUrl}/models`;
return `
<div class="tg-title">In your library</div>
<div class="tg-hint">${hint}</div>
<button class="tg-btn" data-action="import">Add profile</button>
<a class="tg-btn tg-btn-secondary tg-link" href="${escapeHtml(modelLink)}" target="_blank" rel="noopener noreferrer">Open model in Thingport</a>
`;
}
/** The title of the print profile named in this page's URL hash, read from the page's own data
* -- null when there's no hash or the page data is stale (see readMakerworldDesignForPage). */
function currentMakerworldProfileTitle() {
const page = readMakerworldDesignForPage(context.url);
if (!page || !page.requestedInstanceId || !Array.isArray(page.design.instances)) return null;
const instance = page.design.instances.find((inst) => inst && String(inst.id) === page.requestedInstanceId);
return instance && typeof instance.title === "string" && instance.title.trim() ? instance.title.trim() : null;
}
async function zipChoiceHtml(filename) {
const collectionHtml = await collectionOptionsHtml();
return `
@@ -250,9 +287,9 @@
// `classification` (captured above, before any await) rather than `context.classification`
// afterward, for the same reason `url`/`instanceUrl` are destructured up front: an SPA nav
// mid-flight nulls `context` out from under this still-running function.
const resolvedDownloadUrl =
const resolved =
classification.provider === "makerworld" && classification.type === "model"
? await resolveMakerworldDownloadUrl().catch(() => null)
? await resolveMakerworldDownloadUrl(url).catch(() => null)
: null;
try {
// One message, not two -- import (and, per collectionId, filing the result into a
@@ -260,8 +297,15 @@
// whether this tab/page is still around by the time it finishes (see background.js's
// handleImportSingle for why that matters: a content script's own execution ends the
// moment the page navigates or fully reloads, but the service worker doesn't).
const print = await call("IMPORT_SINGLE", { url, entries: opts && opts.entries, collectionId, resolvedDownloadUrl }).then(unwrap);
renderPanel(successHtml(print ? `${instanceUrl}/models/${print.id}` : `${instanceUrl}/models`));
const print = await call("IMPORT_SINGLE", { url, entries: opts && opts.entries, collectionId, resolved }).then(unwrap);
const link = print ? `${instanceUrl}/models/${print.id}` : `${instanceUrl}/models`;
if (print && print.import_outcome === "profile_added") {
renderPanel(successHtml(link, "Added this print profile's file to the model you already had.", "Profile added"));
} else if (print && print.import_outcome === "already_imported") {
renderPanel(successHtml(link, "This print profile's file was already on the model -- nothing new was added.", "Already in your library"));
} else {
renderPanel(successHtml(link));
}
} catch (err) {
renderPanel(errorHtml(err));
}
@@ -376,41 +420,74 @@
/** The single entry point every caller below uses: tries the real-click capture first (far
* higher fidelity, since it's not a guess), falling back to reconstructing the API call
* ourselves only if that didn't pan out. */
async function resolveMakerworldDownloadUrl() {
* ourselves only if that didn't pan out. `pageUrl` is the model page the caller means to
* import, captured before any await -- see readMakerworldDesignForPage for why it's needed.
* Resolves to { downloadUrl, instanceId } (the MakerWorld print profile the file belongs to,
* null when unknown -- the backend uses it to add a second profile of an already-imported
* model as its own file), or null. */
async function resolveMakerworldDownloadUrl(pageUrl) {
const viaClick = await captureDownloadUrlViaRealClick().catch(() => null);
if (viaClick) return viaClick;
return resolveMakerworldDownloadUrlFromPage().catch(() => null);
if (viaClick) {
// The page's own button downloads whichever profile the page has selected, which follows
// the URL hash. Without trustworthy page data, the hash is all there is to go on.
const page = readMakerworldDesignForPage(pageUrl);
if (!page) {
const parsed = thingportIsMakerworldModelUrl(pageUrl);
return { downloadUrl: viaClick, instanceId: (parsed && parsed.requestedInstanceId) || null };
}
return { downloadUrl: viaClick, instanceId: pickMakerworldInstanceId(page.design, page.requestedInstanceId) };
}
return resolveMakerworldDownloadUrlFromPage(pageUrl).catch(() => null);
}
/** The page's own MakerWorld design data, or null if missing -- or stale: Next.js only writes
* __NEXT_DATA__ on a full page load, so after a client-side route change from one model to
* another it still describes the first-loaded design, and resolving from it would pair this
* page's metadata with that other model's 3MF. */
function readMakerworldDesignForPage(pageUrl) {
const nextData = readMakerworldNextData();
if (!nextData) return null;
const design = getPath(nextData, "props", "pageProps", "design");
if (!design || typeof design !== "object") return null;
const expected = thingportIsMakerworldModelUrl(pageUrl);
if (!expected || design.id == null || String(design.id) !== expected.designId) return null;
const nonce = getPath(nextData, "props", "pageProps", "x-nonce");
return {
design,
nonce: typeof nonce === "string" && nonce.trim() ? nonce : null,
requestedInstanceId: expected.requestedInstanceId,
};
}
/** Same precedence as the backend's resolveMakerworldViaCloudApi: the requested profile (only
* if this design actually has it), then the design's default, then the first one. */
function pickMakerworldInstanceId(design, requestedInstanceId) {
const instances = Array.isArray(design.instances) ? design.instances.filter((inst) => inst && inst.id) : [];
if (requestedInstanceId && instances.some((inst) => String(inst.id) === requestedInstanceId)) return requestedInstanceId;
if (design.defaultInstanceId) return String(design.defaultInstanceId);
return instances.length ? String(instances[0].id) : null;
}
/** Mirrors the backend's own two-step MakerWorld resolution (importResolvers.ts's
* resolveMakerworldDownloadUrl) -- instance-scoped endpoint first, model-scoped as a fallback
* -- just run from the page itself instead of the server. Fallback only -- see
* resolveMakerworldDownloadUrl above, which tries the real-click capture first. */
async function resolveMakerworldDownloadUrlFromPage() {
const nextData = readMakerworldNextData();
if (!nextData) return null;
const design = getPath(nextData, "props", "pageProps", "design");
if (!design || typeof design !== "object") return null;
const nonce = getPath(nextData, "props", "pageProps", "x-nonce");
const validNonce = typeof nonce === "string" && nonce.trim() ? nonce : null;
let instanceId = design.defaultInstanceId ? String(design.defaultInstanceId) : null;
if (!instanceId && Array.isArray(design.instances)) {
const first = design.instances.find((inst) => inst && inst.id);
if (first) instanceId = String(first.id);
}
async function resolveMakerworldDownloadUrlFromPage(pageUrl) {
// Stale or missing page data -- let the backend resolve from the URL instead.
const page = readMakerworldDesignForPage(pageUrl);
if (!page) return null;
const { design, nonce } = page;
const instanceId = pickMakerworldInstanceId(design, page.requestedInstanceId);
if (instanceId) {
const apiUrl = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=`;
const data = await fetchMakerworldApiJson(apiUrl, validNonce);
const url = extractDownloadUrlFromJson(data);
if (url) return url;
const downloadUrl = extractDownloadUrlFromJson(await fetchMakerworldApiJson(apiUrl, nonce));
if (downloadUrl) return { downloadUrl, instanceId };
}
const modelId = design.id ? String(design.id) : null;
if (!modelId) return null;
const apiUrl = `https://makerworld.com/api/v1/models/${modelId}/download`;
return extractDownloadUrlFromJson(await fetchMakerworldApiJson(apiUrl, validNonce));
// Model-level, i.e. the default profile's file.
const apiUrl = `https://makerworld.com/api/v1/models/${String(design.id)}/download`;
const downloadUrl = extractDownloadUrlFromJson(await fetchMakerworldApiJson(apiUrl, nonce));
return downloadUrl ? { downloadUrl, instanceId: pickMakerworldInstanceId(design, null) } : null;
}
// -- Batch flow (a collection/Likes listing page) ---------------------------------------------
@@ -707,9 +784,9 @@
// -- Shared result states -----------------------------------------------------------------
function successHtml(link, note) {
function successHtml(link, note, title = "Imported!") {
return `
<div class="tg-title">Imported!</div>
<div class="tg-title">${escapeHtml(title)}</div>
${note ? `<div class="tg-hint">${escapeHtml(note)}</div>` : ""}
<a class="tg-btn tg-link" href="${escapeHtml(link)}" target="_blank" rel="noopener noreferrer">Open in Thingport</a>
`;
@@ -833,6 +910,10 @@
return;
}
// Set when the model is already in the library but this page's MakerWorld print profile might
// not be (see the backend's checkImportStatus) -- the icon and panel then offer to add the
// profile's file to the existing model rather than presenting it as a new import.
let library = null;
if (classification.kind === "single") {
try {
const status = await api("GET", `/import/status?url=${encodeURIComponent(location.href)}`);
@@ -841,6 +922,9 @@
reportTabIconState(false);
return;
}
if (status.state === "profile_missing" || status.state === "profile_unknown") {
library = { state: status.state, printId: status.print_id };
}
} catch {
// If the status check fails (instance unreachable, bad credentials, etc.) still show the
// icon -- the panel's own error state will surface the real problem when they try it.
@@ -848,7 +932,7 @@
}
if (myToken !== initToken) return;
context = { url: location.href, instanceUrl: stateRes.data.instanceUrl, classification };
context = { url: location.href, instanceUrl: stateRes.data.instanceUrl, classification, library };
await mount();
if (myToken !== initToken) return;
reportTabIconState(true);
@@ -914,9 +998,9 @@
// back-to-back resolution pattern most likely to trip MakerWorld's CAPTCHA.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || message.type !== "RESOLVE_MAKERWORLD_DOWNLOAD_URL") return undefined;
resolveMakerworldDownloadUrl()
.then((downloadUrl) => sendResponse({ ok: true, downloadUrl }))
.catch(() => sendResponse({ ok: true, downloadUrl: null }));
resolveMakerworldDownloadUrl(location.href)
.then((resolved) => sendResponse({ ok: true, resolved }))
.catch(() => sendResponse({ ok: true, resolved: null }));
return true; // keep the message channel open for the async sendResponse above
});
+5 -1
View File
@@ -64,8 +64,12 @@ type ImportLinkPayload = {
makerworld_cookie?: string;
};
/** What POST /import did -- "profile_added" when a MakerWorld model already in the library
* gained another print profile's file instead of a new model being created. */
export type ImportOutcome = "created" | "profile_added" | "already_imported";
export const importsApi = {
fromLink: async (payload: ImportLinkPayload): Promise<Print> => {
fromLink: async (payload: ImportLinkPayload): Promise<Print & { import_outcome?: ImportOutcome }> => {
const res = await fetch(`${apiBase()}/import`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
@@ -2,7 +2,7 @@ import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { UnauthorizedError } from "../../api/client";
import { importsApi } from "../../api/imports";
import { importsApi, type ImportOutcome } from "../../api/imports";
import { printsApi, type Print } from "../../api/prints";
import { entriesFromFileList, uploadEntriesToCategory } from "../../utils/uploadTree";
import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../utils/zipUtils";
@@ -183,6 +183,17 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
/>
);
const showImportedToast = (imported: Print & { import_outcome?: ImportOutcome }) => {
const name = imported.title || imported.name;
const key =
imported.import_outcome === "profile_added"
? "uploadBar.profileAdded"
: imported.import_outcome === "already_imported"
? "uploadBar.alreadyInLibrary"
: "uploadBar.imported";
showToast({ message: t(key, { name }) });
};
const submitImport = async (rawUrl: string) => {
const url = rawUrl.trim();
if (!url) return;
@@ -290,7 +301,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
// Both always resolve to several files; skip straight past the inspect/zip-picker steps
// -- the backend already splits them into plates automatically.
const imported = await importsApi.fromLink(payload);
showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) });
showImportedToast(imported);
onUploaded();
openForViewing(imported);
return;
@@ -299,7 +310,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
const inspect = await importsApi.inspectLink(payload);
if (!inspect.is_zip) {
const imported = await importsApi.fromLink(payload);
showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) });
showImportedToast(imported);
onUploaded();
openForViewing(imported);
return;
@@ -310,7 +321,7 @@ export function useUploadImport({ onUploaded, categoryId, makerworldCookie, onUn
onImportAsZip: async () => {
try {
const imported = await importsApi.fromLink(payload);
showToast({ message: t("uploadBar.imported", { name: imported.title || imported.name }) });
showImportedToast(imported);
onUploaded();
openForViewing(imported);
} catch (err) {
+2
View File
@@ -66,6 +66,8 @@
"multiplateImportFailedWithMessage": "Multi-plate import ({{message}})",
"multiplateImportFailed": "Multi-plate import",
"imported": "Imported \"{{name}}\"",
"profileAdded": "Added a print profile to \"{{name}}\"",
"alreadyInLibrary": "\"{{name}}\" is already in your library",
"uploaded": "Uploaded \"{{name}}\"",
"uploadedMultiple": "Uploaded {{count}} models"
},
+2
View File
@@ -66,6 +66,8 @@
"multiplateImportFailedWithMessage": "Kelių plokščių importas ({{message}})",
"multiplateImportFailed": "Kelių plokščių importas",
"imported": "Importuota „{{name}}“",
"profileAdded": "Pridėtas spausdinimo profilis prie „{{name}}“",
"alreadyInLibrary": "„{{name}}“ jau yra jūsų bibliotekoje",
"uploaded": "Įkelta „{{name}}“",
"uploadedMultiple": "Įkelta {{count}} modelių"
},