diff --git a/migrations/versions/155-prints-library.sql b/migrations/versions/155-prints-library.sql new file mode 100644 index 000000000..881e15a8d --- /dev/null +++ b/migrations/versions/155-prints-library.sql @@ -0,0 +1,101 @@ +-- 155-prints-library.sql — the Prints library (server/prints.ts, +-- src/types/prints.ts): a team's 3D print files, organised two ways at once. +-- print_folders: where a file lives. parent_id NULL is the library root. +-- Names are unique per parent, ignoring case. +-- print_files: one row per file; the bytes live on the VM's disk under +-- PRINTS_DIR at storage_key (never derived from a user-typed name), with +-- an optional PNG thumbnail at thumb_key (thumb_size counts toward the +-- workspace quota with size). Smart groups are computed from +-- these details: robot_id (its season comes from the robot) or season, +-- cad_part_id (its assembly section is the subsystem and its quantity the +-- copies needed unless set here), material, status. source is 'upload' +-- or 'control-kit'; ck_part_key is the Control Kit identity it prints. +-- print_collections / print_collection_files: groups a team makes itself +-- ("Comp spares", "Worlds kit"). A file belongs to any number of them +-- without being copied. +-- print_activity: every change, never updated. +-- manage_prints: the role permission for uploading, moving, renaming, +-- deleting and editing details. Every role that can manage CAD gets +-- it (admins have "*"); members without it view and download. +-- New tables only; nothing existing changes. +BEGIN; + +CREATE TABLE IF NOT EXISTS print_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + parent_id INTEGER, + name TEXT NOT NULL, + created_by INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_print_folders_name ON print_folders (team_id, COALESCE(parent_id, 0), name COLLATE NOCASE); + +CREATE TABLE IF NOT EXISTS print_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + folder_id INTEGER, + name TEXT NOT NULL, + kind TEXT NOT NULL, + size INTEGER NOT NULL, + sha256 TEXT NOT NULL, + storage_key TEXT NOT NULL, + thumb_key TEXT, + thumb_size INTEGER NOT NULL DEFAULT 0, + notes TEXT NOT NULL DEFAULT '', + robot_id INTEGER, + season INTEGER, + cad_part_id INTEGER, + subsystem TEXT, + material TEXT, + status TEXT NOT NULL DEFAULT 'to_print', + copies_needed INTEGER, + copies_printed INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'upload', + ck_part_key TEXT, + uploaded_by INTEGER, + uploaded_by_name TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_print_files_name ON print_files (team_id, COALESCE(folder_id, 0), name COLLATE NOCASE); +CREATE INDEX IF NOT EXISTS idx_print_files_team ON print_files (team_id, folder_id); + +CREATE TABLE IF NOT EXISTS print_collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_by INTEGER, + created_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_print_collections_name ON print_collections (team_id, name COLLATE NOCASE); + +CREATE TABLE IF NOT EXISTS print_collection_files ( + collection_id INTEGER NOT NULL, + file_id INTEGER NOT NULL, + team_id INTEGER NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (collection_id, file_id) +); +CREATE INDEX IF NOT EXISTS idx_print_collection_files_file ON print_collection_files (file_id); + +CREATE TABLE IF NOT EXISTS print_activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL, + file_id INTEGER, + folder_id INTEGER, + member_id INTEGER, + member_name TEXT, + action TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '{}', + at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_print_activity_team ON print_activity (team_id, id); +CREATE INDEX IF NOT EXISTS idx_print_activity_file ON print_activity (file_id); + +UPDATE roles SET permissions = json_insert(permissions, '$[#]', 'manage_prints') +WHERE json_valid(permissions) + AND EXISTS (SELECT 1 FROM json_each(permissions) WHERE value = 'manage_cad') + AND NOT EXISTS (SELECT 1 FROM json_each(permissions) WHERE value = 'manage_prints'); + +COMMIT; diff --git a/server.ts b/server.ts index 07060067e..b8bc6ebf6 100644 --- a/server.ts +++ b/server.ts @@ -96,6 +96,7 @@ import { registerNotebookReviewRoutes } from "./server/notebookReviews.js"; import { registerChecklistRoutes } from "./server/checklists.js"; import { createControlKitLink, deleteGrantsSql } from "./server/controlKitLink.js"; import { createCadRobots, defaultRobotId, teamRobotId } from "./server/cadRobots.js"; +import { createPrints, PRINTS_WORKSPACE_DELETE } from "./server/prints.js"; import { registerStickyNoteRoutes } from "./server/stickyNotes.js"; import { isRefType, resolveRef, type RefDeps } from "./server/refs.js"; import { createRefsFilter, refsBlock, stripModelRefs, validateRefs } from "./server/brunoRefs.js"; @@ -2024,6 +2025,7 @@ const ROLE_PERMISSIONS = [ { key: "manage_checklists", label: "Manage checklists" }, { key: "manage_documentation", label: "Manage documentation" }, { key: "manage_cad", label: "Manage CAD robots and part choices" }, + { key: "manage_prints", label: "Upload and organize 3D prints" }, { key: "edit_notebook", label: "Edit notebook pages" }, { key: "organize_notebook", label: "Organize notebooks" }, { key: "delete_notebook", label: "Delete notebook items" }, @@ -2096,14 +2098,14 @@ async function ensureRolesSeeded(teamId: number | null | undefined) { if (!names.has("Member")) { const memberRole = (await dbRun( "INSERT INTO roles (team_id, name, color, permissions, position, is_system) VALUES (?,?,?,?,?,1)", - teamId, "Member", "#71717A", JSON.stringify(["view_ai", "manage_calendar", "manage_communications", "manage_tasks", "manage_outreach", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad"]), 1 + teamId, "Member", "#71717A", JSON.stringify(["view_ai", "manage_calendar", "manage_communications", "manage_tasks", "manage_outreach", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad", "manage_prints"]), 1 )) as any; memberRoleId = Number(memberRole.lastInsertRowid); } if (!names.has("Verified Member")) { await dbRun( "INSERT INTO roles (team_id, name, color, permissions, position, is_system) VALUES (?,?,?,?,?,1)", - teamId, "Verified Member", "#22C55E", JSON.stringify(["view_ai", "manage_inventory", "manage_calendar", "manage_tasks", "manage_documentation", "manage_communications", "manage_outreach", "manage_attendance", "manage_code", "manage_budget", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad"]), 2 + teamId, "Verified Member", "#22C55E", JSON.stringify(["view_ai", "manage_inventory", "manage_calendar", "manage_tasks", "manage_documentation", "manage_communications", "manage_outreach", "manage_attendance", "manage_code", "manage_budget", "manage_checklists", "edit_notebook", "organize_notebook", "manage_cad", "manage_prints"]), 2 ); } @@ -2402,12 +2404,17 @@ async function startServer() { // are registered with the CAD routes, behind the guard. // CAD by season and robot; Control Kit reads the part choices made in it. const cadRobots = createCadRobots({ dbGet, dbAll, dbRun, dbBatchResults, requireAuth: requireAuth as any, hasPerm }); + // The Prints library (server/prints.ts): 3D print files on the VM's disk, in folders and groups. + const prints = createPrints({ dbGet, dbAll, dbRun, dbBatchResults, requireAuth: requireAuth as any, hasPerm, env: process.env }); const controlKit = createControlKitLink({ dbGet, dbAll, dbRun, dbBatchResults, env: process.env, decisionsFor: cadRobots.decisionsFor, requireAuth: requireAuth as any, authEmail: (auth) => authEmail(auth as any), // Declared further down; only called per request, after startup. storeFile: (opts) => storeFile(opts), fileUrl: (id) => fileUrl(id), deleteStoredFile: (url) => deleteStoredFileByUrl(url), + // Its STEP zip's 3D-print files are filed in the Prints library (server/prints.ts), when the member who linked + // Control Kit may change it (manage_prints). + fileStepZip: async (o) => ((await hasPerm({ memberId: o.memberId, teamId: o.teamId }, "manage_prints")) ? prints.fileControlKitStepZip(o) : null), appUrl: (req) => (process.env.APP_URL || "").replace(/\/$/, "") || getBaseUrl(req), }); controlKit.registerBearerRoutes(app); @@ -5420,6 +5427,9 @@ async function startServer() { { sql: "DELETE FROM cad_parts WHERE team_id = ?", args: [teamId] }, { sql: "DELETE FROM cad_imports WHERE team_id = ?", args: [teamId] }, { sql: "DELETE FROM cad_docs WHERE team_id = ?", args: [teamId] }, + // The Prints library: rows here, bytes on disk after the batch commits. + ...PRINTS_WORKSPACE_DELETE.others.map((sql) => ({ sql, args: [teamId] })), + { sql: PRINTS_WORKSPACE_DELETE.files, args: [teamId] }, { sql: "DELETE FROM checkin_sessions WHERE team_id = ?", args: [teamId] }, // References teams without a cascade. { sql: "DELETE FROM call_moderation_log WHERE team_id = ?", args: [teamId] }, @@ -5454,6 +5464,7 @@ async function startServer() { const deletedIds = (sql: string) => (results[stmts.findIndex((s) => s.sql === sql)]?.rows || []).map((row: any) => Number(row.id)); // Remove the private object copies only after the database deletion commits. for (const fileId of deletedIds(FILES_STMT)) await deleteStoredRow(fileId); + await prints.removeStored(results[stmts.findIndex((s) => s.sql === PRINTS_WORKSPACE_DELETE.files)]?.rows || []).catch((e) => console.error("Deleting print files failed:", e?.message || e)); for (const imageId of deletedIds(IMAGES_STMT)) await deleteMessageImageRow(imageId).catch((e) => console.error("Deleting a chat image failed:", e?.message || e)); for (const id of memberIds) if (id !== keepMemberId) disconnectMember(teamId, id); } @@ -16438,6 +16449,7 @@ NOTES>>>`; return auth.teamId as number; }; cadRobots.registerRoutes(app); + prints.registerRoutes(app); // Lists take ?robot= to show one robot's rows; a new row goes to the robot it names, else the team's latest main robot. const cadRobotFilter = async (teamId: number, q: unknown) => { if (q === undefined || q === "") return { sql: "", args: [] as any[] }; diff --git a/server/__tests__/prints.test.ts b/server/__tests__/prints.test.ts new file mode 100644 index 000000000..9af3b62e5 --- /dev/null +++ b/server/__tests__/prints.test.ts @@ -0,0 +1,592 @@ +// @vitest-environment node +/** + * The Prints library (migration 155, server/prints.ts): names and paths, path + * traversal, the per-file limit, the workspace quota and the disk reserve, + * role permissions, folders (move/rename edge cases, delete with files), + * groups (status, collections), downloads and ZIPs, the activity record, and + * filing a Control Kit print from inside the server. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { mkdtempSync, readdirSync, statSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inflateRawSync } from "node:zlib"; +import { PassThrough } from "node:stream"; +import { writeFileSync } from "node:fs"; +import { startTestServer, seedTeam, seedMember, type TestServer } from "./helpers/testServer"; +import { withSession } from "./helpers/session"; +import { cleanName, splitPath, uniqueName, printKind, safeName, matchesFilters, groupLine, folderAndBelow, type PrintFile } from "../../src/types/prints"; +import { PrintStore, createPrints, controlKitPrintPath, printLimits, partForStepFile } from "../prints"; +import { createHash } from "node:crypto"; +import { seasonName } from "../../src/types/cadBom"; + +const CK_SECRET = "test-control-kit-secret"; +const CK_REDIRECT = "http://127.0.0.1:8787/control-point/callback"; +import { crc32, writeZip, uniqueZipNames } from "../zipStream"; + +vi.setConfig({ testTimeout: 60_000 }); + +let t: TestServer; +let dir = ""; +let team = 0, other = 0; +let admin = "", student = "", lead = "", outsider = ""; +const MB = 1024 * 1024; + +/** A tiny ASCII STL. */ +const stl = (name = "cube") => Buffer.from(`solid ${name}\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid ${name}\n`); + +async function uploadFile(session: string, opts: { name: string; bytes?: Buffer; folderId?: number | null; field?: string; nameField?: string; thumb?: Buffer }) { + const form = new FormData(); + if (opts.nameField !== undefined) form.append("name", opts.nameField); + if (opts.folderId != null) form.append("folderId", String(opts.folderId)); + form.append(opts.field ?? "file", new Blob([opts.bytes ?? stl()]), opts.name); + if (opts.thumb) form.append("thumbnail", new Blob([opts.thumb], { type: "image/png" }), "thumb.png"); + const headers = withSession(new Headers(), session); + const res = await fetch(`${t.base}/api/prints/files`, { method: "POST", body: form, headers }); + return { status: res.status, body: await res.json().catch(() => null) }; +} +const get = (path: string, session: string) => t.api(path, { session }); +const del = (path: string, session: string) => t.api(path, { method: "DELETE", session }); +const lib = async (session = admin) => (await get("/api/prints/library", session)).body; +const raw = async (path: string, session: string) => { + const res = await fetch(`${t.base}${path}`, { headers: withSession(new Headers(), session) }); + return { status: res.status, headers: res.headers, bytes: Buffer.from(await res.arrayBuffer()) }; +}; +const storedFiles = (): string[] => { + const out: string[] = []; + const walk = (d: string) => { for (const f of readdirSync(d)) { const p = join(d, f); statSync(p).isDirectory() ? walk(p) : out.push(p); } }; + if (existsSync(join(dir, "files"))) walk(join(dir, "files")); + return out; +}; + +/** Read a ZIP's central directory and inflate each entry, checking its CRC. */ +function unzip(buf: Buffer): Record { + const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + expect(end).toBeGreaterThan(-1); + const count = buf.readUInt16LE(end + 10); + let p = buf.readUInt32LE(end + 16); + const out: Record = {}; + for (let i = 0; i < count; i++) { + expect(buf.readUInt32LE(p)).toBe(0x02014b50); + const crc = buf.readUInt32LE(p + 16), csize = buf.readUInt32LE(p + 20), nlen = buf.readUInt16LE(p + 28), elen = buf.readUInt16LE(p + 30), clen = buf.readUInt16LE(p + 32); + const local = buf.readUInt32LE(p + 42); + const name = buf.subarray(p + 46, p + 46 + nlen).toString("utf8"); + const lnlen = buf.readUInt16LE(local + 26), lelen = buf.readUInt16LE(local + 28); + const data = inflateRawSync(buf.subarray(local + 30 + lnlen + lelen, local + 30 + lnlen + lelen + csize)); + expect(crc32(data)).toBe(crc); + out[name] = data; + p += 46 + nlen + elen + clen; + } + return out; +} + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), "cp-prints-store-")); + t = await startTestServer("cp-prints-", { PRINTS_DIR: dir, PRINTS_MAX_FILE_MB: "1", PRINTS_QUOTA_MB: "3", PRINTS_MIN_FREE_MB: "0", CONTROL_KIT_CLIENT_SECRET: CK_SECRET, CONTROL_KIT_REDIRECT_URIS: CK_REDIRECT }); + team = await seedTeam(t.db, "HORS"); + other = await seedTeam(t.db, "Other"); + const adminId = await seedMember(t.db, team, "Ada", "ada@test.local", "admin"); + const studentId = await seedMember(t.db, team, "Stu", "stu@test.local"); + const leadId = await seedMember(t.db, team, "Lee", "lee@test.local"); + const outsiderId = await seedMember(t.db, other, "Otto", "otto@test.local", "admin"); + const role = Number((await t.db.execute({ sql: "INSERT INTO roles (team_id, name, color, permissions, position, is_system) VALUES (?, 'Print lead', '#ffc700', '[\"manage_prints\"]', 5, 0)", args: [team] })).lastInsertRowid); + await t.db.execute({ sql: "INSERT INTO member_roles (member_id, role_id) VALUES (?, ?)", args: [leadId, role] }); + admin = await t.session(adminId); + student = await t.session(studentId); + lead = await t.session(leadId); + outsider = await t.session(outsiderId); +}, 120_000); +afterAll(async () => { await t?.stop(); }); + +describe("names, paths and storage keys (pure)", () => { + it("refuses names that could leave their folder or break an OS", () => { + for (const bad of ["", " ", ".", "..", "../x.stl", "a/b.stl", "a\\b.stl", "C:evil.stl", "x?.stl", "bad\u0000.stl", "trailing.", "x".repeat(121)]) expect(cleanName(bad)).toBeNull(); + expect(cleanName(" Intake roller v2.stl ")).toBe("Intake roller v2.stl"); + expect(cleanName("2025–26 DECODE")).toBe("2025–26 DECODE"); + }); + it("splits a path into safe segments, and refuses traversal", () => { + expect(splitPath("Season / Robot / Control Kit")).toEqual(["Season", "Robot", "Control Kit"]); + expect(splitPath(["a", "..", "b"])).toBeNull(); + expect(splitPath("")).toBeNull(); + expect(safeName("Arm/Claw: v2")).toBe("Arm-Claw- v2"); + expect(safeName("..")).toBe("Untitled"); + expect(controlKitPrintPath(2025, "Bot/1")).toEqual(["2025–26 DECODE", "Bot-1", "Control Kit"]); + }); + it("numbers a taken name, keeping the extension", () => { + expect(uniqueName("a.stl", new Set(["a.stl"]))).toBe("a (2).stl"); + expect(uniqueName("a (2).stl", new Set(["a (2).stl", "a.stl"]))).toBe("a (3).stl"); + expect(uniqueName("A.STL", new Set(["b.stl"]))).toBe("A.STL"); + }); + it("knows the print formats by extension", () => { + expect(["x.stl", "x.3MF", "x.step", "x.stp", "x.obj", "x.gcode", "x.bgcode"].map(printKind)).toEqual(["stl", "3mf", "step", "step", "obj", "gcode", "gcode"]); + expect(printKind("x.exe")).toBeNull(); + expect(printKind("stl")).toBeNull(); + }); + it("never turns anything but a random key into a disk path", () => { + const s = new PrintStore(mkdtempSync(join(tmpdir(), "cp-prints-keys-"))); + const k = s.newKey(7); + expect(s.pathOf(k)).toContain(join("files", "7")); + for (const bad of ["../etc/passwd", "7/../../x", "7/abc", "/7/" + "a".repeat(32), "7\\" + "a".repeat(32)]) expect(() => s.pathOf(bad)).toThrow(); + }); + it("reads the limits from the environment", () => { + expect(printLimits({})).toEqual({ maxFile: 100 * MB, quota: 2048 * MB, minFree: 5120 * MB }); + expect(printLimits({ PRINTS_MAX_FILE_MB: "50", PRINTS_QUOTA_MB: "-1", PRINTS_MIN_FREE_MB: "0" })).toEqual({ maxFile: 50 * MB, quota: 2048 * MB, minFree: 0 }); + }); + it("combines group filters and counts what's left to print", () => { + const f = (o: Partial): PrintFile => ({ id: 1, folderId: null, name: "x.stl", kind: "stl", size: 1, createdAt: "", updatedAt: "", uploadedBy: null, notes: "", robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: "to_print", copiesNeeded: null, copiesNeededFrom: null, copiesPrinted: 0, collections: [], hasThumb: false, source: "upload", ...o }); + const files = [f({ subsystem: "Intake", material: "PETG" }), f({ subsystem: "intake", material: "PLA" }), f({ subsystem: "Intake", material: "PETG", status: "printed" }), f({ subsystem: "Lift", material: "PETG", status: "failed" })]; + const hit = files.filter((x) => matchesFilters(x, { subsystem: "Intake", material: "PETG", status: "to_print" })); + expect(hit).toHaveLength(1); + expect(groupLine("Intake", files.filter((x) => matchesFilters(x, { subsystem: "INTAKE" })))).toBe("Intake · 3 files · 2 still to print"); + expect(matchesFilters(f({ name: "Roller hub.stl" }), { query: "hub roll" })).toBe(true); + expect(folderAndBelow([{ id: 1, parentId: null, name: "a", createdAt: "", updatedAt: "" }, { id: 2, parentId: 1, name: "b", createdAt: "", updatedAt: "" }, { id: 3, parentId: 2, name: "c", createdAt: "", updatedAt: "" }], 2)).toEqual(new Set([2, 3])); + }); + it("writes a ZIP any unzipper reads, with UTF-8 names and unique paths", async () => { + const d = mkdtempSync(join(tmpdir(), "cp-zip-")); + writeFileSync(join(d, "a"), "hello"); + writeFileSync(join(d, "b"), Buffer.alloc(200_000, 7)); + const out = new PassThrough(); + const chunks: Buffer[] = []; + out.on("data", (c) => chunks.push(c)); + const names = uniqueZipNames(["2025–26/a.stl", "2025–26/A.stl", "b.gcode"]); + expect(names).toEqual(["2025–26/a.stl", "2025–26/A (2).stl", "b.gcode"]); + await writeZip(out, [{ name: names[0], path: join(d, "a"), mtime: new Date() }, { name: names[2], path: join(d, "b"), mtime: new Date() }]); + const files = unzip(Buffer.concat(chunks)); + expect(files["2025–26/a.stl"].toString()).toBe("hello"); + expect(files["b.gcode"].length).toBe(200_000); + }); +}); + +describe("permissions", () => { + it("lets every member view, and only manage_prints change", async () => { + expect((await get("/api/prints/library", student)).status).toBe(200); + expect((await lib(student)).canEdit).toBe(false); + expect((await lib(lead)).canEdit).toBe(true); + expect((await uploadFile(student, { name: "nope.stl" })).status).toBe(403); + expect((await t.post("/api/prints/folders", { name: "Nope" }, student)).status).toBe(403); + expect((await t.post("/api/prints/collections", { name: "Nope" }, student)).status).toBe(403); + const up = await uploadFile(lead, { name: "lead.stl" }); + expect(up.status).toBe(200); + expect((await t.patch(`/api/prints/files/${up.body.id}`, { status: "printed" }, student)).status).toBe(403); + expect((await del(`/api/prints/files/${up.body.id}`, student)).status).toBe(403); + // A member can still download it. + const dl = await raw(`/api/prints/files/${up.body.id}/download`, student); + expect(dl.status).toBe(200); + expect(dl.bytes.equals(stl())).toBe(true); + expect(dl.headers.get("content-disposition")).toContain('filename="lead.stl"'); + await del(`/api/prints/files/${up.body.id}`, lead); + }); + it("keeps another workspace out: its ids are 404", async () => { + const up = await uploadFile(admin, { name: "ours.stl" }); + const folder = await t.post("/api/prints/folders", { name: "Ours" }, admin); + expect((await get(`/api/prints/files/${up.body.id}`, outsider)).status).toBe(404); + expect((await raw(`/api/prints/files/${up.body.id}/download`, outsider)).status).toBe(404); + expect((await t.patch(`/api/prints/files/${up.body.id}`, { name: "theirs.stl" }, outsider)).status).toBe(404); + expect((await del(`/api/prints/folders/${folder.body.id}`, outsider)).status).toBe(404); + expect((await raw(`/api/prints/zip?files=${up.body.id}`, outsider)).status).toBe(404); + expect((await uploadFile(outsider, { name: "x.stl", folderId: folder.body.id })).status).toBe(404); + expect((await t.post("/api/prints/files/bulk", { ids: [up.body.id], action: "delete" }, outsider)).status).toBe(404); + expect((await lib(outsider)).files).toEqual([]); + expect((await get(`/api/prints/files/${up.body.id}`, admin)).status).toBe(200); + await del(`/api/prints/files/${up.body.id}`, admin); + await del(`/api/prints/folders/${folder.body.id}`, admin); + }); +}); + +describe("uploads", () => { + it("stores bytes under a random key whatever the name says", async () => { + const before = storedFiles().length; + const sneaky = await uploadFile(admin, { name: "../../../escape.stl" }); + // The browser-style name is reduced to its last part by the form, or refused. + expect([200, 400]).toContain(sneaky.status); + const traversal = await uploadFile(admin, { name: "ok.stl", nameField: "../../escape.stl" }); + expect(traversal.status).toBe(400); + const after = storedFiles(); + expect(after.every((p) => p.startsWith(join(dir, "files")) && /[0-9a-f]{32}(\.thumb\.png)?$/.test(p))).toBe(true); + expect(existsSync(join(dir, "..", "escape.stl"))).toBe(false); + if (sneaky.status === 200) { expect(sneaky.body.name).not.toContain("/"); await del(`/api/prints/files/${sneaky.body.id}`, admin); } + expect(storedFiles().length).toBe(before); + }); + it("takes only print formats, and keeps UTF-8 names", async () => { + const bad = await uploadFile(admin, { name: "virus.exe" }); + expect(bad.status).toBe(400); + const empty = await uploadFile(admin, { name: "empty.stl", bytes: Buffer.alloc(0) }); + expect(empty.status).toBe(400); + const ok = await uploadFile(admin, { name: "x.3mf", nameField: "Rädchen – v2.3mf", bytes: Buffer.from("PK fake 3mf") }); + expect(ok.status).toBe(200); + expect(ok.body.name).toBe("Rädchen – v2.3mf"); + await del(`/api/prints/files/${ok.body.id}`, admin); + }); + it("refuses a file over the per-file limit", async () => { + const big = await uploadFile(admin, { name: "big.stl", bytes: Buffer.alloc(Math.round(1.2 * MB), 1) }); + expect(big.status).toBe(413); + expect(big.body.code).toBe("too_large"); + expect(readdirSync(join(dir, "tmp"))).toEqual([]); + }); + it("refuses an upload past the workspace quota, counting every file", async () => { + const ids: number[] = []; + for (let i = 0; i < 3; i++) { + const r = await uploadFile(admin, { name: `fill${i}.stl`, bytes: Buffer.alloc(Math.round(0.9 * MB), 2) }); + expect(r.status).toBe(200); + ids.push(r.body.id); + } + const over = await uploadFile(admin, { name: "over.stl", bytes: Buffer.alloc(Math.round(0.5 * MB), 3) }); + expect(over.status).toBe(413); + expect(over.body.code).toBe("quota"); + expect((await lib()).usage).toMatchObject({ quota: 3 * MB, maxFile: 1 * MB }); + const r = await t.post("/api/prints/files/bulk", { ids, action: "delete" }, admin); + expect(r.body.files).toBe(3); + expect((await uploadFile(admin, { name: "fits.stl", bytes: Buffer.alloc(Math.round(0.5 * MB), 3) })).status).toBe(200); + const fits = (await lib()).files.find((f: any) => f.name === "fits.stl"); + await del(`/api/prints/files/${fits.id}`, admin); + }); + it("numbers a second upload of the same name, and keeps a PNG thumbnail", async () => { + const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(40)]); + const a = await uploadFile(admin, { name: "Bracket.stl", thumb: png }); + const b = await uploadFile(admin, { name: "bracket.stl" }); + expect(a.body.name).toBe("Bracket.stl"); + expect(b.body).toMatchObject({ name: "bracket (2).stl", renamed: true }); + const th = await raw(`/api/prints/files/${a.body.id}/thumbnail`, student); + expect(th.status).toBe(200); + expect(th.headers.get("content-type")).toBe("image/png"); + expect((await raw(`/api/prints/files/${b.body.id}/thumbnail`, admin)).status).toBe(404); + await t.post("/api/prints/files/bulk", { ids: [a.body.id, b.body.id], action: "delete" }, admin); + }); +}); + +describe("folders, moves and renames", () => { + it("keeps names unique per folder, ignoring case", async () => { + const a = await t.post("/api/prints/folders", { name: "Intake" }, admin); + expect(a.status).toBe(200); + expect((await t.post("/api/prints/folders", { name: "intake" }, admin)).status).toBe(409); + expect((await t.post("/api/prints/folders", { name: "../x" }, admin)).status).toBe(400); + const inner = await t.post("/api/prints/folders", { name: "Intake", parentId: a.body.id }, admin); + expect(inner.status).toBe(200); + const deeper = await t.post("/api/prints/folders", { name: "Rollers", parentId: inner.body.id }, admin); + // A folder can't go inside itself or anything below it. + expect((await t.patch(`/api/prints/folders/${a.body.id}`, { parentId: a.body.id }, admin)).status).toBe(400); + expect((await t.patch(`/api/prints/folders/${a.body.id}`, { parentId: deeper.body.id }, admin)).status).toBe(400); + // Moving the inner "Intake" to the root collides with the outer one. + expect((await t.patch(`/api/prints/folders/${inner.body.id}`, { parentId: null }, admin)).status).toBe(409); + expect((await t.patch(`/api/prints/folders/${inner.body.id}`, { parentId: null, name: "Intake 2" }, admin)).status).toBe(200); + // Into a folder that doesn't exist: 404, nothing changes. + expect((await t.patch(`/api/prints/folders/${inner.body.id}`, { parentId: 999999 }, admin)).status).toBe(404); + const l = await lib(); + expect(l.folders.find((f: any) => f.id === inner.body.id)).toMatchObject({ parentId: null, name: "Intake 2" }); + expect(l.folders.find((f: any) => f.id === deeper.body.id)).toMatchObject({ parentId: inner.body.id }); + for (const id of [a.body.id, inner.body.id]) await del(`/api/prints/folders/${id}`, admin); + }); + it("moves a file into a folder with the same name as '(2)', and refuses a rename that collides", async () => { + const f = await t.post("/api/prints/folders", { name: "Lift" }, admin); + const inside = await uploadFile(admin, { name: "spool.stl", folderId: f.body.id }); + const outside = await uploadFile(admin, { name: "spool.stl" }); + const moved = await t.patch(`/api/prints/files/${outside.body.id}`, { folderId: f.body.id }, admin); + expect(moved.status).toBe(200); + expect(moved.body).toMatchObject({ folderId: f.body.id, name: "spool (2).stl" }); + expect((await t.patch(`/api/prints/files/${moved.body.id}`, { name: "SPOOL.stl" }, admin)).status).toBe(409); + expect((await t.patch(`/api/prints/files/${moved.body.id}`, { name: "spool.step" }, admin)).status).toBe(400); + expect((await t.patch(`/api/prints/files/${moved.body.id}`, { name: "a/b.stl" }, admin)).status).toBe(400); + expect((await t.patch(`/api/prints/files/${moved.body.id}`, { name: "spool spare.stl" }, admin)).body.name).toBe("spool spare.stl"); + // Bulk move back to the root: no collision now. + const back = await t.post("/api/prints/files/bulk", { ids: [inside.body.id, moved.body.id], action: "move", folderId: null }, admin); + expect(back.status).toBe(200); + const names = (await lib()).files.filter((x: any) => x.folderId === null).map((x: any) => x.name).sort(); + expect(names).toEqual(expect.arrayContaining(["spool spare.stl", "spool.stl"])); + await t.post("/api/prints/files/bulk", { ids: [inside.body.id, moved.body.id], action: "delete" }, admin); + await del(`/api/prints/folders/${f.body.id}`, admin); + }); + it("asks before deleting a folder that holds files, says how many, then removes their bytes", async () => { + const top = await t.post("/api/prints/folders", { name: "Old season" }, admin); + const sub = await t.post("/api/prints/folders", { name: "Arm", parentId: top.body.id }, admin); + await uploadFile(admin, { name: "a.stl", folderId: top.body.id }); + await uploadFile(admin, { name: "b.stl", folderId: sub.body.id }); + const before = storedFiles().length; + const ask = await del(`/api/prints/folders/${top.body.id}`, admin); + expect(ask.status).toBe(409); + expect(ask.body).toMatchObject({ files: 2, folders: 1 }); + // A stale confirmation (someone added a file since) asks again. + expect((await del(`/api/prints/folders/${top.body.id}?files=1`, admin)).status).toBe(409); + const ok = await del(`/api/prints/folders/${top.body.id}?files=2`, admin); + expect(ok.body).toMatchObject({ success: true, files: 2 }); + expect(storedFiles().length).toBe(before - 2); + const l = await lib(); + expect(l.folders.some((f: any) => f.id === top.body.id || f.id === sub.body.id)).toBe(false); + // An empty folder goes without asking. + const empty = await t.post("/api/prints/folders", { name: "Empty" }, admin); + expect((await del(`/api/prints/folders/${empty.body.id}`, admin)).status).toBe(200); + }); +}); + +describe("review fixes", () => { + it("moves a file onto a same-named one as '(2)' with the bulk move the page uses", async () => { + const f = await t.post("/api/prints/folders", { name: "Drop target" }, admin); + await uploadFile(admin, { name: "a.stl", folderId: f.body.id }); + const outside = await uploadFile(admin, { name: "a.stl" }); + const r = await t.post("/api/prints/files/bulk", { ids: [outside.body.id], action: "move", folderId: f.body.id }, admin); + expect(r.status).toBe(200); + expect(r.body.files).toBe(1); + const names = (await lib()).files.filter((x: any) => x.folderId === f.body.id).map((x: any) => x.name).sort(); + expect(names).toEqual(["a (2).stl", "a.stl"]); + // Moves, uploads and new folders into a folder that is gone are refused. + await del(`/api/prints/folders/${f.body.id}?files=2`, admin); + const loose = await uploadFile(admin, { name: "loose.stl" }); + expect((await t.post("/api/prints/files/bulk", { ids: [loose.body.id], action: "move", folderId: f.body.id }, admin)).status).toBe(404); + expect((await t.patch(`/api/prints/files/${loose.body.id}`, { folderId: f.body.id }, admin)).status).toBe(404); + expect((await uploadFile(admin, { name: "late.stl", folderId: f.body.id })).status).toBe(404); + expect((await t.post("/api/prints/folders", { name: "Child", parentId: f.body.id }, admin)).status).toBe(404); + await del(`/api/prints/files/${loose.body.id}`, admin); + }); + it("fills the copies printed when one file is marked printed", async () => { + const f = await uploadFile(admin, { name: "four.stl" }); + await t.patch(`/api/prints/files/${f.body.id}`, { copiesNeeded: 4, copiesPrinted: 1 }, admin); + const r = await t.patch(`/api/prints/files/${f.body.id}`, { status: "printed" }, admin); + expect(r.body).toMatchObject({ status: "printed", copiesPrinted: 4 }); + await del(`/api/prints/files/${f.body.id}`, admin); + }); + it("refuses a write meant for another workspace (a request sent before a workspace switch)", async () => { + const r = await t.api("/api/prints/folders", { method: "POST", body: JSON.stringify({ name: "Stale" }), session: admin, headers: { "x-cp-prints-team": String(other) } }); + expect(r.status).toBe(409); + expect(r.body.code).toBe("workspace_changed"); + const ok = await t.api("/api/prints/folders", { method: "POST", body: JSON.stringify({ name: "Fresh" }), session: admin, headers: { "x-cp-prints-team": String(team) } }); + expect(ok.status).toBe(200); + expect((await lib()).teamId).toBe(team); + await del(`/api/prints/folders/${ok.body.id}`, admin); + }); + it("counts thumbnails in the quota", async () => { + const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(100_000)]); + const before = (await lib()).usage.used; + const f = await uploadFile(admin, { name: "thumbed.stl", thumb: png }); + expect((await lib()).usage.used - before).toBe(stl().length + png.length); + await del(`/api/prints/files/${f.body.id}`, admin); + }); +}); + +describe("groups, details and downloads", () => { + it("files one print in several collections without copying it, and marks a selection printed", async () => { + const a = await uploadFile(admin, { name: "hub.stl" }); + const b = await uploadFile(admin, { name: "roller.stl" }); + const spares = await t.post("/api/prints/collections", { name: "Comp spares" }, admin); + const worlds = await t.post("/api/prints/collections", { name: "Worlds kit" }, admin); + expect((await t.post("/api/prints/collections", { name: "comp SPARES" }, admin)).status).toBe(409); + await t.post("/api/prints/files/bulk", { ids: [a.body.id, b.body.id], action: "collect", collectionId: spares.body.id }, admin); + await t.post("/api/prints/files/bulk", { ids: [a.body.id], action: "collect", collectionId: worlds.body.id }, admin); + await t.patch(`/api/prints/files/${a.body.id}`, { subsystem: "Intake", material: "petg", copiesNeeded: 4, notes: "Print at 40% infill" }, admin); + let l = await lib(); + const hub = l.files.find((f: any) => f.id === a.body.id); + expect(hub).toMatchObject({ subsystem: "Intake", subsystemFrom: "manual", material: "PETG", copiesNeeded: 4, copiesNeededFrom: "manual", notes: "Print at 40% infill", status: "to_print" }); + expect(hub.collections.sort()).toEqual([spares.body.id, worlds.body.id].sort()); + expect(l.collections.find((c: any) => c.id === spares.body.id).files).toBe(2); + expect(storedFiles().filter((p) => !p.endsWith(".thumb.png")).length).toBe(l.files.length); + // Marking printed counts every copy needed. + await t.post("/api/prints/files/bulk", { ids: [a.body.id, b.body.id], action: "status", status: "printed" }, admin); + l = await lib(); + expect(l.files.find((f: any) => f.id === a.body.id)).toMatchObject({ status: "printed", copiesPrinted: 4 }); + expect(l.files.find((f: any) => f.id === b.body.id)).toMatchObject({ status: "printed", copiesPrinted: 0 }); + await t.post("/api/prints/files/bulk", { ids: [b.body.id], action: "status", status: "failed" }, admin); + // Reaching the copies needed by hand marks it printed. + await t.patch(`/api/prints/files/${a.body.id}`, { status: "to_print", copiesPrinted: 0 }, admin); + const back = await t.patch(`/api/prints/files/${a.body.id}`, { copiesPrinted: 4 }, admin); + expect(back.body.status).toBe("printed"); + // Deleting a collection keeps the files. + await del(`/api/prints/collections/${worlds.body.id}`, admin); + l = await lib(); + expect(l.files.find((f: any) => f.id === a.body.id).collections).toEqual([spares.body.id]); + // Every change is recorded. + const act = (await get(`/api/prints/activity?file=${a.body.id}`, student)).body; + expect(act.map((x: any) => x.action)).toEqual(expect.arrayContaining(["upload", "collect", "edit", "status"])); + expect(act[0].by).toBe("Ada"); + await t.post("/api/prints/files/bulk", { ids: [a.body.id, b.body.id], action: "delete" }, admin); + await del(`/api/prints/collections/${spares.body.id}`, admin); + }); + it("links a CAD part: its section is the subsystem and its quantity the copies needed", async () => { + const robot = Number((await t.db.execute({ sql: "INSERT INTO cad_robots (team_id, season, name, is_main, created_at, updated_at) VALUES (?, 2025, 'Comp bot', 1, 'x', 'x')", args: [team] })).lastInsertRowid); + const part = Number((await t.db.execute({ sql: "INSERT INTO cad_parts (team_id, name, section, quantity, source, unit_cost, status, notes, created_at, updated_at, robot_id) VALUES (?, 'Roller hub', 'Intake', 6, 'printed', 0, 'to_order', '', 'x', 'x', ?)", args: [team, robot] })).lastInsertRowid); + const otherPart = Number((await t.db.execute({ sql: "INSERT INTO cad_parts (team_id, name, section, quantity, source, unit_cost, status, notes, created_at, updated_at) VALUES (?, 'Theirs', 'Lift', 1, 'printed', 0, 'to_order', '', 'x', 'x')", args: [other] })).lastInsertRowid); + const f = await uploadFile(admin, { name: "roller hub.stl" }); + expect((await t.patch(`/api/prints/files/${f.body.id}`, { cadPartId: otherPart }, admin)).status).toBe(404); + const r = await t.patch(`/api/prints/files/${f.body.id}`, { cadPartId: part }, admin); + expect(r.body).toMatchObject({ robotId: robot, season: 2025, subsystem: "Intake", subsystemFrom: "cad", copiesNeeded: 6, copiesNeededFrom: "cad", cadPart: { id: part, name: "Roller hub" } }); + const own = await t.patch(`/api/prints/files/${f.body.id}`, { subsystem: "Outtake" }, admin); + expect(own.body).toMatchObject({ subsystem: "Outtake", subsystemFrom: "manual" }); + expect((await t.patch(`/api/prints/files/${f.body.id}`, { season: 1990 }, admin)).status).toBe(400); + await del(`/api/prints/files/${f.body.id}`, admin); + }); + it("zips a folder with its subfolders, and a group's chosen files in their folders", async () => { + const top = await t.post("/api/prints/folders", { name: "2025–26 DECODE" }, admin); + const sub = await t.post("/api/prints/folders", { name: "Intake", parentId: top.body.id }, admin); + const a = await uploadFile(admin, { name: "a.stl", folderId: top.body.id, bytes: stl("a") }); + const b = await uploadFile(admin, { name: "b.gcode", folderId: sub.body.id, bytes: Buffer.from("G28\nG1 X10\n") }); + const z = await raw(`/api/prints/zip?folder=${top.body.id}`, student); + expect(z.status).toBe(200); + expect(z.headers.get("content-disposition")).toContain("filename*=UTF-8''2025%E2%80%9326%20DECODE.zip"); + const files = unzip(z.bytes); + expect(Object.keys(files).sort()).toEqual(["Intake/b.gcode", "a.stl"]); + expect(files["a.stl"].equals(stl("a"))).toBe(true); + const g = await raw(`/api/prints/zip?files=${a.body.id},${b.body.id}&name=Intake`, student); + expect(Object.keys(unzip(g.bytes)).sort()).toEqual(["2025–26 DECODE/Intake/b.gcode", "2025–26 DECODE/a.stl"]); + expect(g.headers.get("content-disposition")).toContain('filename="Intake.zip"'); + // A group of any size goes through a ticket, valid only for the person who asked. + const tk = await t.post("/api/prints/zip", { ids: [a.body.id, b.body.id], name: "Intake + PETG" }, student); + expect(tk.body.url).toMatch(/^\/api\/prints\/zip\?ticket=/); + expect((await raw(tk.body.url, admin)).status).toBe(404); + const viaTicket = await raw(tk.body.url, student); + expect(Object.keys(unzip(viaTicket.bytes)).sort()).toEqual(["2025–26 DECODE/Intake/b.gcode", "2025–26 DECODE/a.stl"]); + // One use; ids of files that don't exist (or are another workspace's) never make a ticket. + expect((await raw(tk.body.url, student)).status).toBe(404); + expect((await t.post("/api/prints/zip", { ids: [999_999, 999_998] }, student)).status).toBe(404); + expect((await t.post("/api/prints/zip", { ids: Array.from({ length: 65_536 }, (_, i) => i + 1) }, student)).status).toBe(413); + const emptyFolder = await t.post("/api/prints/folders", { name: "Nothing" }, admin); + expect((await raw(`/api/prints/zip?folder=${emptyFolder.body.id}`, admin)).status).toBe(404); + await del(`/api/prints/folders/${top.body.id}?files=2`, admin); + await del(`/api/prints/folders/${emptyFolder.body.id}`, admin); + }); +}); + +describe("filing a Control Kit print from inside the server", () => { + it("files under Season / Robot / Control Kit, links the part, and replaces its own earlier send", async () => { + // The test process builds its own Prints module on the same database and storage, as server.ts does. + const norm = (a: any[]) => a.map((v) => (v === undefined ? null : v)); + const exec = async (sql: string, args: any[]) => t.db.execute({ sql, args: norm(args) }); + const prints = createPrints({ + dbGet: async (sql, ...args) => (await exec(sql, args)).rows[0], + dbAll: async (sql, ...args) => (await exec(sql, args)).rows as any[], + dbRun: async (sql, ...args) => { const r = await exec(sql, args); return { lastInsertRowid: Number(r.lastInsertRowid), changes: r.rowsAffected }; }, + dbBatchResults: async (stmts) => (await t.db.batch(stmts.map((s) => ({ sql: s.sql, args: norm(s.args ?? []) })), "write")).map((r) => ({ lastInsertRowid: Number(r.lastInsertRowid), changes: r.rowsAffected, rows: r.rows as any[] })), + requireAuth: async () => null, hasPerm: async () => false, + env: { PRINTS_DIR: dir, PRINTS_MAX_FILE_MB: "1", PRINTS_QUOTA_MB: "3", PRINTS_MIN_FREE_MB: "0" }, + }); + const robot = Number((await t.db.execute({ sql: "INSERT INTO cad_robots (team_id, season, name, is_main, created_at, updated_at) VALUES (?, 2025, 'Practice/bot', 0, 'x', 'x')", args: [team] })).lastInsertRowid); + const part = Number((await t.db.execute({ sql: "INSERT INTO cad_parts (team_id, name, section, quantity, source, unit_cost, status, notes, created_at, updated_at, robot_id, ck_part_key) VALUES (?, 'Claw finger', 'Claw', 2, 'printed', 0, 'to_order', '', 'x', 'x', ?, 'doc1:ps:JHD:default')", args: [team, robot] })).lastInsertRowid); + const first = await prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "Claw finger.step", bytes: Buffer.from("ISO-10303-21; v1"), ckPartKey: "doc1:ps:JHD:default", sentBy: "Ada" }); + expect(first).toMatchObject({ name: "Claw finger.step", replaced: false }); + let l = await lib(); + const path = []; + for (let id = l.folders.find((f: any) => f.id === first.folderId)?.id; id != null;) { const f = l.folders.find((x: any) => x.id === id); path.unshift(f.name); id = f.parentId; } + expect(path).toEqual(["2025–26 DECODE", "Practice-bot", "Control Kit"]); + expect(l.files.find((f: any) => f.id === first.fileId)).toMatchObject({ source: "control-kit", robotId: robot, cadPart: { id: part }, subsystem: "Claw", copiesNeeded: 2, uploadedBy: "Ada via Control Kit", kind: "step" }); + // Printed, then Control Kit sends the same bytes (nothing changes) and then new ones (back to To print). + await t.post("/api/prints/files/bulk", { ids: [first.fileId], action: "status", status: "printed" }, admin); + expect(await prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "Claw finger.step", bytes: Buffer.from("ISO-10303-21; v1"), ckPartKey: "doc1:ps:JHD:default" })).toMatchObject({ unchanged: true }); + const again = await prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "Claw finger.step", bytes: Buffer.from("ISO-10303-21; v2"), ckPartKey: "doc1:ps:JHD:default" }); + expect(again).toMatchObject({ fileId: first.fileId, replaced: true }); + l = await lib(); + expect(l.files.find((f: any) => f.id === first.fileId).status).toBe("to_print"); + expect((await raw(`/api/prints/files/${first.fileId}/download`, admin)).bytes.toString()).toBe("ISO-10303-21; v2"); + // A file a person put there under the same name is kept; Control Kit's gets "(2)". + const mine = await uploadFile(admin, { name: "Bracket.step", folderId: first.folderId, bytes: Buffer.from("mine") }); + const theirs = await prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "Bracket.step", bytes: Buffer.from("ck") }); + expect(theirs.name).toBe("Bracket (2).step"); + expect((await raw(`/api/prints/files/${mine.body.id}/download`, admin)).bytes.toString()).toBe("mine"); + await expect(prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "x.exe", bytes: Buffer.from("x") })).rejects.toMatchObject({ code: "unsupported" }); + await expect(prints.fileControlKitPrint({ teamId: team, robotId: robot, name: "big.step", bytes: Buffer.alloc(2 * MB) })).rejects.toMatchObject({ code: "too_large" }); + await expect(prints.fileControlKitPrint({ teamId: other, robotId: robot, name: "x.step", bytes: Buffer.from("x") })).rejects.toThrow(); + const act = (await get(`/api/prints/activity?file=${first.fileId}`, admin)).body; + expect(act.map((x: any) => x.action)).toEqual(expect.arrayContaining(["upload", "status", "replace"])); + }); +}); + +describe("Control Kit's STEP zip", () => { + it("reads which CAD part a STEP file name points at", () => { + const ids = ["d:ps:JHD:default", "d:ps:JHE:default", "d:ps:K2:size=10 mm"]; + expect(partForStepFile("Claw_finger__JHD.step", ids)).toBe("d:ps:JHD:default"); + expect(partForStepFile("Claw_finger__JHD_2.step", ids)).toBe("d:ps:JHD:default"); + let h = 0; + for (const ch of "size=10 mm") h = (h * 31 + ch.charCodeAt(0)) >>> 0; + expect(partForStepFile(`Spacer__K2__cfg${h.toString(36).slice(0, 5)}.step`, ids)).toBe("d:ps:K2:size=10 mm"); + expect(partForStepFile("Spacer__K2.step", ids)).toBeNull(); + expect(partForStepFile("loose.step", ids)).toBeNull(); + }); + it("files the 3D-print STEPs of a zip Control Kit uploads after a send under Season / Robot / Control Kit", async () => { + const r = await t.post("/api/integrations/control-kit/authorize", { state: "s", redirect_uri: CK_REDIRECT }, admin); + const code = new URL(r.body.redirect).searchParams.get("code")!; + const tok = await fetch(`${t.base}/api/integrations/control-kit/token`, { method: "POST", headers: { authorization: `Basic ${Buffer.from(`control-kit:${CK_SECRET}`).toString("base64")}`, "content-type": "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code, redirect_uri: CK_REDIRECT }) }); + const token = (await tok.json()).access_token as string; + const ck = async (path: string, init: { body?: any; raw?: Buffer; headers?: Record }) => { + const headers: Record = { authorization: `Bearer ${token}`, ...init.headers }; + if (init.body !== undefined) headers["content-type"] = "application/json"; + const res = await fetch(`${t.base}/api/integrations/control-kit/workspaces/${team}${path}`, { method: "POST", headers, body: init.raw ?? JSON.stringify(init.body) }); + return { status: res.status, body: await res.json().catch(() => null) }; + }; + const cad = (part: string, name: string) => ({ name, section: "Other", quantity: 3, source: "other", unit_cost: 0, status: "to_order", notes: "", + extra: { key: `line-${part}`, identity: `docZ:ps:${part}:default`, documentId: "docZ", elementId: "ps", partId: part, configuration: "default" } }); + const sent = await ck("/bom", { body: { + idempotencyKey: "prints-zip-1", source: "control-kit", bundleVersion: 2, + assembly: { name: "Arm", documentId: "docZ", elementId: "asmZ", onshapeUrl: "https://cad.onshape.com/documents/docZ", assemblyUrl: "https://cad.onshape.com/documents/docZ/w/w1/e/asmZ", documentName: "Arm" }, + revision: { type: "m", id: "m1" }, generatedAt: "2026-10-28T10:00:00.000Z", lines: [], suppressedLines: [], missingMetadataLines: [], suppressedCount: 0, + totals: { uniqueLines: 2, totalInstances: 6, totalCostUsd: null, outOfStockLines: 0 }, cadParts: [cad("JHD", "Claw finger"), cad("K2", "Arm plate")], + controlPointCadUrl: "https://tryctrlpoint.org/cad-parts", + files: [{ name: "Arm STEP.zip", mediaType: "application/zip", sizeBytes: 9, sha256: "ab".repeat(32), delivery: "reference", reason: "Uploaded after this bundle." }], filesNotIncluded: [], + } }); + expect(sent.status).toBe(200); + const robotId = Number(((await t.db.execute({ sql: "SELECT robot_id FROM cad_imports WHERE id = ?", args: [sent.body.importId] })).rows[0] as any).robot_id); + const robot = (await t.db.execute({ sql: "SELECT name, season FROM cad_robots WHERE id = ?", args: [robotId] })).rows[0] as any; + // The zip as Control Kit builds it: process folders and a manifest. + const d = mkdtempSync(join(tmpdir(), "cp-ckzip-")); + const files: Record = { + "Arm_STEP/3D_print/Claw_finger__JHD.step": "ISO-10303-21;\nclaw\nEND-ISO-10303-21;\n", + "Arm_STEP/CNC/Arm_plate__K2.step": "ISO-10303-21;\nplate\nEND-ISO-10303-21;\n", + "Arm_STEP/MANIFEST.txt": "Control Kit STEP export\n\nFile | Part | Qty | Process | Units | Onshape source\nClaw_finger__JHD.step | Claw finger | 3 | 3D print | mm | x\nArm_plate__K2.step | Arm plate | 3 | CNC | mm | x\n", + }; + const entries = Object.entries(files).map(([name, text], i) => { const p = join(d, String(i)); writeFileSync(p, text); return { name, path: p, mtime: new Date() }; }); + const out = new PassThrough(); + const chunks: Buffer[] = []; + out.on("data", (c) => chunks.push(c)); + await writeZip(out, entries); + const zip = Buffer.concat(chunks); + const up = await ck(`/imports/${sent.body.importId}/files?name=${encodeURIComponent("Arm STEP.zip")}`, { raw: zip, headers: { "content-type": "application/zip", "x-content-sha256": createHash("sha256").update(zip).digest("hex") } }); + expect(up.status).toBe(200); + let hit: any = null; + for (let i = 0; i < 60 && !hit; i++) { + hit = (await lib()).files.find((f: any) => f.name === "Claw finger.step" && f.robotId === robotId); + if (!hit) await new Promise((res) => setTimeout(res, 250)); + } + expect(hit).toMatchObject({ source: "control-kit", kind: "step", robotId, copiesNeeded: 3, copiesNeededFrom: "cad", cadPart: { name: "Claw finger" } }); + const l = await lib(); + const path: string[] = []; + for (let id = hit.folderId; id != null;) { const f = l.folders.find((x: any) => x.id === id); path.unshift(f.name); id = f.parentId; } + expect(path).toEqual([seasonName(Number(robot.season)), String(robot.name).replace(/\//g, "-"), "Control Kit"]); + // CNC parts are not prints. + expect(l.files.some((f: any) => f.name.startsWith("Arm plate"))).toBe(false); + expect((await raw(`/api/prints/files/${hit.id}/download`, admin)).bytes.toString()).toContain("claw"); + // Someone whose role can't change Prints sends from Control Kit: the zip is stored as a CAD doc, nothing is filed. + const sr = await t.post("/api/integrations/control-kit/authorize", { state: "s", redirect_uri: CK_REDIRECT }, student); + const scode = new URL(sr.body.redirect).searchParams.get("code")!; + const stok = await fetch(`${t.base}/api/integrations/control-kit/token`, { method: "POST", headers: { authorization: `Basic ${Buffer.from(`control-kit:${CK_SECRET}`).toString("base64")}`, "content-type": "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code: scode, redirect_uri: CK_REDIRECT }) }); + const studentToken = (await stok.json()).access_token as string; + const zip2Files = { "Arm_STEP/3D_print/Viewer_part__JHD.step": "ISO-10303-21;\nviewer\nEND-ISO-10303-21;\n" }; + const e2 = Object.entries(zip2Files).map(([name, text], i) => { const p = join(d, `v${i}`); writeFileSync(p, text); return { name, path: p, mtime: new Date() }; }); + const out2 = new PassThrough(); const c2: Buffer[] = []; out2.on("data", (c) => c2.push(c)); + await writeZip(out2, e2); + const zip2 = Buffer.concat(c2); + const sent2 = await fetch(`${t.base}/api/integrations/control-kit/workspaces/${team}/bom`, { method: "POST", headers: { authorization: `Bearer ${studentToken}`, "content-type": "application/json" }, + body: JSON.stringify({ ...JSON.parse(JSON.stringify({ idempotencyKey: "prints-zip-2" })), source: "control-kit", bundleVersion: 2, + assembly: { name: "Arm", documentId: "docZ", elementId: "asmZ", onshapeUrl: "https://cad.onshape.com/documents/docZ", assemblyUrl: "https://cad.onshape.com/documents/docZ/w/w1/e/asmZ", documentName: "Arm" }, + revision: { type: "m", id: "m2" }, generatedAt: "2026-10-28T11:00:00.000Z", lines: [], suppressedLines: [], missingMetadataLines: [], suppressedCount: 0, + totals: { uniqueLines: 1, totalInstances: 3, totalCostUsd: null, outOfStockLines: 0 }, cadParts: [cad("JHD", "Claw finger")], controlPointCadUrl: "https://tryctrlpoint.org/cad-parts", + files: [{ name: "Arm STEP.zip", mediaType: "application/zip", sizeBytes: 9, sha256: "ab".repeat(32), delivery: "reference", reason: "Uploaded after this bundle." }], filesNotIncluded: [] }) }); + const import2 = (await sent2.json()).importId; + const up2 = await fetch(`${t.base}/api/integrations/control-kit/workspaces/${team}/imports/${import2}/files?name=${encodeURIComponent("Arm STEP.zip")}`, { method: "POST", + headers: { authorization: `Bearer ${studentToken}`, "content-type": "application/zip", "x-content-sha256": createHash("sha256").update(zip2).digest("hex") }, body: zip2 }); + expect(up2.status).toBe(200); + await new Promise((res) => setTimeout(res, 2000)); + expect((await raw(`/api/prints/files/${hit.id}/download`, admin)).bytes.toString()).toContain("claw"); + expect((await lib()).files.filter((f: any) => f.source === "control-kit" && f.robotId === robotId)).toHaveLength(1); + }); +}); + +describe("disk reserve", () => { + it("refuses uploads that would leave the server short of disk", async () => { + const t2 = await startTestServer("cp-prints-full-", { PRINTS_DIR: mkdtempSync(join(tmpdir(), "cp-prints-full-store-")), PRINTS_MIN_FREE_MB: String(1024 * 1024 * 1024) }); + try { + const tm = await seedTeam(t2.db, "Full"); + const s = await t2.session(await seedMember(t2.db, tm, "A", "a@full.local", "admin")); + const form = new FormData(); + form.append("file", new Blob([stl()]), "x.stl"); + const res = await fetch(`${t2.base}/api/prints/files`, { method: "POST", body: form, headers: withSession(new Headers(), s) }); + expect(res.status).toBe(507); + expect((await res.json()).code).toBe("disk_full"); + } finally { await t2.stop(); } + }, 120_000); +}); diff --git a/server/controlKitLink.ts b/server/controlKitLink.ts index be1184783..5b75045d8 100644 --- a/server/controlKitLink.ts +++ b/server/controlKitLink.ts @@ -36,6 +36,11 @@ export interface ControlKitDeps { deleteStoredFile?: (url: string) => Promise; /** The fetch behind AI review's model calls (controlKitAi.runReview); tests swap it. */ reviewFetch?: typeof fetch; + /** + * A STEP zip stored for an import on a robot: the Prints library files its 3D-print STEPs under + * "Season / Robot / Control Kit" (server/prints.ts fileControlKitStepZip). Called after the answer, best effort. + */ + fileStepZip?: (input: { teamId: number; memberId: number; robotId: number; zip: Buffer; sentBy: string | null }) => Promise; } export const CONTROL_KIT_CLIENT_ID = "control-kit"; @@ -491,7 +496,13 @@ export function createControlKitLink(deps: ControlKitDeps) { ]); if (!swapped.changes) continue; if (replaced) await dropStored(replaced.url); - return res.json({ name, url, sizeBytes: buffer.length, sha256: want, duplicate: false }); + res.json({ name, url, sizeBytes: buffer.length, sha256: want, duplicate: false }); + // The STEP zip's prints go to the Prints library too, after the answer so a large zip never delays it. + if (mediaType === "application/zip" && row.robot_id && deps.fileStepZip) { + void deps.fileStepZip({ teamId, memberId: Number(req.ck.team.member_id), robotId: Number(row.robot_id), zip: buffer, sentBy: row.sent_by ?? null }) + .catch((e) => console.error("Filing Control Kit prints failed:", e?.message || e)); + } + return; } await dropStored(url); res.setHeader("Retry-After", "2"); diff --git a/server/prints.ts b/server/prints.ts new file mode 100644 index 000000000..41c3756b9 --- /dev/null +++ b/server/prints.ts @@ -0,0 +1,1112 @@ +// The Prints library (migration 155): a team's 3D print files, in folders +// (where a file lives) and in groups (smart groups from each file's details, +// plus collections the team makes). Shared rules are in src/types/prints.ts. +// +// Storage: bytes live on the VM's disk under PRINTS_DIR (default +// /.data/prints, outside every statically served directory), at a random +// key per file; names people type never reach a disk path. Limits, all +// configurable in the environment: +// PRINTS_MAX_FILE_MB one file (default 100) +// PRINTS_QUOTA_MB one workspace's total (default 2048) +// PRINTS_MIN_FREE_MB free disk an upload must leave (default 5120) +// The quota is checked inside the INSERT itself, so two uploads at once can't +// both slip past it. +// +// Permissions follow the workspace's roles: every member views and +// downloads; manage_prints uploads, moves, renames, deletes and edits +// details. Every route binds auth.teamId; another team's ids are 404. Every +// change is written to print_activity. +// +// Control Kit: fileControlKitPrint() files a print sent for a robot under +// "Season / Robot / Control Kit" (see the end of this file). +import type { Express } from "express"; +import multer from "multer"; +import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { seasonName, isSeason } from "../src/types/cadBom.js"; +import { DEFAULT_SUBSYSTEMS, cleanSubsystems } from "../src/types/teamModules.js"; +import { + canPreview, cleanMaterial, cleanName, cleanSubsystem, isPrintStatus, printKind, safeName, uniqueName, + type PrintFile, type PrintKind, type PrintStatus, +} from "../src/types/prints.js"; +import { readZip, writeZip, uniqueZipNames, ZIP_MAX_BYTES, ZIP_MAX_ENTRIES, type ZipReadEntry } from "./zipStream.js"; + +type Auth = { memberId: number; teamId: number | null }; +type Stmt = { sql: string; args: any[] }; +export interface PrintDeps { + dbGet: (sql: string, ...args: any[]) => Promise; + dbAll: (sql: string, ...args: any[]) => Promise; + dbRun: (sql: string, ...args: any[]) => Promise<{ lastInsertRowid: number; changes: number }>; + dbBatchResults: (stmts: { sql: string; args?: any[] }[]) => Promise<{ lastInsertRowid: number; changes: number; rows: any[] }[]>; + requireAuth: (req: any, res: any) => Promise; + hasPerm: (auth: Auth, perm: string) => Promise; + env: Record; +} + +export const MANAGE_PRINTS = "manage_prints"; +const MB = 1024 * 1024; +const THUMB_MAX = 512 * 1024; +const MAX_BULK = 1000; +const MAX_COLLECTIONS = 60; +const MAX_FOLDERS = 2000; +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export interface PrintLimits { maxFile: number; quota: number; minFree: number } +export function printLimits(env: Record): PrintLimits { + const num = (k: string, d: number, min: number) => { const n = Number(env[k]); return Number.isFinite(n) && n >= min && env[k] !== "" && env[k] != null ? n : d; }; + return { + maxFile: Math.round(num("PRINTS_MAX_FILE_MB", 100, 0.001) * MB), + quota: Math.round(num("PRINTS_QUOTA_MB", 2048, 0.001) * MB), + minFree: Math.round(num("PRINTS_MIN_FREE_MB", 5120, 0) * MB), + }; +} + +/** Why a print couldn't be stored. `status` is the HTTP answer for routes. */ +export class PrintError extends Error { + constructor(public code: "unsupported" | "too_large" | "quota" | "disk_full" | "bad_name" | "empty", message: string, public status = 400) { super(message); } +} + +/** Files on disk under random keys "/<32 hex>" (thumbnails add ".<8 hex>.thumb.png"). A key that doesn't look like one never becomes a path. */ +export class PrintStore { + readonly files: string; + readonly tmp: string; + constructor(readonly root: string) { + this.files = path.join(root, "files"); + this.tmp = path.join(root, "tmp"); + fs.mkdirSync(this.files, { recursive: true }); + fs.mkdirSync(this.tmp, { recursive: true }); + } + newKey(teamId: number) { return `${Math.trunc(teamId)}/${randomBytes(16).toString("hex")}`; } + pathOf(key: string): string { + if (!/^\d{1,12}\/[0-9a-f]{32}(\.[0-9a-f]{8}\.thumb\.png)?$/.test(key)) throw new Error("bad storage key"); + return path.join(this.files, ...key.split("/")); + } + async adopt(tmpPath: string, key: string) { + const dest = this.pathOf(key); + await fsp.mkdir(path.dirname(dest), { recursive: true }); + await fsp.rename(tmpPath, dest); + } + async write(key: string, bytes: Uint8Array) { + const dest = this.pathOf(key); + await fsp.mkdir(path.dirname(dest), { recursive: true }); + const part = `${dest}.${randomBytes(4).toString("hex")}.part`; + await fsp.writeFile(part, bytes); + await fsp.rename(part, dest); + } + async remove(key: string | null | undefined) { + if (!key) return; + try { await fsp.unlink(this.pathOf(key)); } catch (e: any) { if (e?.code !== "ENOENT") console.error("Removing a print file failed:", e?.message || e); } + } + async exists(key: string) { try { await fsp.access(this.pathOf(key)); return true; } catch { return false; } } + /** Bytes free for this process on the store's disk. */ + async free(): Promise { + const s = await fsp.statfs(this.root); + return Number(s.bavail) * Number(s.bsize); + } + /** Temp uploads left by a crash. */ + async sweepTmp(maxAgeMs = 3600_000) { + for (const f of await fsp.readdir(this.tmp).catch(() => [] as string[])) { + const p = path.join(this.tmp, f); + const st = await fsp.stat(p).catch(() => null); + if (st && Date.now() - st.mtimeMs > maxAgeMs) await fsp.unlink(p).catch(() => {}); + } + } +} + +const sha256File = (p: string) => new Promise((resolve, reject) => { + const h = createHash("sha256"); + fs.createReadStream(p).on("data", (c) => h.update(c)).on("error", reject).on("end", () => resolve(h.digest("hex"))); +}); +const isUnique = (e: any) => /UNIQUE constraint failed/i.test(String(e?.message || e)); +const intOrNull = (v: unknown, max: number): number | null | undefined => { + if (v === null || v === "") return null; + const n = Number(v); + return Number.isInteger(n) && n >= 0 && n <= max ? n : undefined; +}; +const ids = (raw: unknown): number[] => (Array.isArray(raw) ? [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n > 0))] : []); +/** Download tickets for chosen files: who asked, in which workspace, which files; five minutes. In memory (one server). */ +const zipTickets = new Map(); +const ZIP_TICKETS_PER_MEMBER = 3; +/** File ids held by every live ticket together (a few MB at most). */ +const ZIP_TICKET_IDS_MAX = 500_000; +const qs = (n: number) => Array(n).fill("?").join(","); +/** A Content-Disposition that keeps a UTF-8 name ("2025–26 DECODE.zip"). */ +function disposition(kind: "attachment" | "inline", name: string) { + const ascii = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; +} + +export function createPrints(deps: PrintDeps) { + const { dbGet, dbAll, dbRun } = deps; + const limits = printLimits(deps.env); + const store = new PrintStore(path.resolve(deps.env.PRINTS_DIR?.trim() || path.join(process.cwd(), ".data", "prints"))); + void store.sweepTmp(); + setInterval(() => void store.sweepTmp(), 3600_000).unref(); + + const upload = multer({ + storage: multer.diskStorage({ + destination: store.tmp, + filename: (_req, _file, cb) => cb(null, randomBytes(16).toString("hex")), + }), + limits: { fileSize: limits.maxFile, files: 2, fields: 8, fieldSize: 4096 }, + fileFilter: (_req, file, cb) => { + if (file.fieldname === "file" && printKind(file.originalname)) return cb(null, true); + if (file.fieldname === "thumbnail" && file.mimetype === "image/png") return cb(null, true); + cb(new PrintError("unsupported", file.fieldname === "file" ? "Prints takes STL, 3MF, STEP/STP, OBJ and G-code files" : "Thumbnails are PNG images")); + }, + }); + + const teamOf = (auth: Auth, res: any) => { + if (!auth.teamId) { res.status(400).json({ error: "Join a team first" }); return null; } + return auth.teamId; + }; + const memberName = async (id: number) => String((await dbGet("SELECT name FROM members WHERE id = ?", id))?.name || "A teammate"); + /** Signed in, on a team, and (when `edit`) allowed to change the library. */ + async function access(req: any, res: any, edit: boolean): Promise<{ auth: Auth; teamId: number } | null> { + const auth = await deps.requireAuth(req, res); + if (!auth) return null; + const teamId = teamOf(auth, res); + if (!teamId) return null; + // The page names the workspace it shows; a request sent after a workspace switch (a queued upload) stops here. + const sentFor = req.headers?.["x-cp-prints-team"]; + if (sentFor !== undefined && Number(sentFor) !== teamId) { res.status(409).json({ error: "You switched workspaces. Reopen Prints to keep going.", code: "workspace_changed" }); return null; } + if (edit && !(await deps.hasPerm(auth, MANAGE_PRINTS))) { res.status(403).json({ error: "Your role can view and download prints, not change them" }); return null; } + return { auth, teamId }; + } + const now = () => new Date().toISOString(); + /** An activity row. With `ifChanged`, it is written only when the statement just before it in the batch changed a row. */ + const logStmt = (teamId: number, by: { id: number | null; name: string }, action: string, o: { fileId?: number | null; folderId?: number | null; detail?: unknown; ifChanged?: boolean } = {}): Stmt => ({ + sql: `INSERT INTO print_activity (team_id, file_id, folder_id, member_id, member_name, action, detail, at) SELECT ?, ?, ?, ?, ?, ?, ?, ?${o.ifChanged ? " WHERE changes() > 0" : ""}`, + args: [teamId, o.fileId ?? null, o.folderId ?? null, by.id, by.name, action, JSON.stringify(o.detail ?? {}), now()], + }); + const usedBytes = async (teamId: number) => Number((await dbGet("SELECT COALESCE(SUM(size + thumb_size), 0) AS n FROM print_files WHERE team_id = ?", teamId))?.n) || 0; + async function teamFolder(teamId: number, raw: unknown): Promise { + if (raw === null || raw === undefined || raw === "" || raw === "root") return null; + const id = Number(raw); + if (!Number.isInteger(id) || id <= 0) return undefined; + return (await dbGet("SELECT id FROM print_folders WHERE id = ? AND team_id = ?", id, teamId)) ? id : undefined; + } + async function namesIn(teamId: number, folderId: number | null, exceptFile?: number) { + const rows = await dbAll("SELECT name FROM print_files WHERE team_id = ? AND COALESCE(folder_id, 0) = ? AND id <> ?", teamId, folderId ?? 0, exceptFile ?? 0); + return new Set(rows.map((r) => String(r.name).toLowerCase())); + } + /** SQL for `?` (a folder id) and every folder below it in team `?`; args: [id, teamId, teamId]. Usable inside any statement. */ + const SUBTREE = `WITH RECURSIVE sub(id) AS (SELECT id FROM print_folders WHERE id = ? AND team_id = ? + UNION SELECT f.id FROM print_folders f JOIN sub ON f.parent_id = sub.id WHERE f.team_id = ?) SELECT id FROM sub`; + /** True in SQL when folder `?` is the root (NULL) or a folder of team `?`; args: [folderId, folderId, teamId]. */ + const FOLDER_OK = "(? IS NULL OR EXISTS (SELECT 1 FROM print_folders d WHERE d.id = ? AND d.team_id = ?))"; + /** + * A statement that fails the whole write batch when `condition` (SQL) is false: json() of malformed text raises an + * error, so a check made inside the transaction can stop it. isAssert() recognises the failure. + */ + const assertStmt = (condition: string, args: any[]): Stmt => ({ sql: `SELECT CASE WHEN ${condition} THEN 1 ELSE json('prints-changed') END`, args }); + const isAssert = (e: any) => /malformed JSON/i.test(String(e?.message || e)); + /** `id` and every folder below it, in this team. */ + async function subtree(teamId: number, id: number): Promise { + const rows = await dbAll(SUBTREE, id, teamId, teamId); + return rows.map((r) => Number(r.id)); + } + async function folderTrailNames(teamId: number): Promise> { + const rows = await dbAll("SELECT id, parent_id, name FROM print_folders WHERE team_id = ?", teamId); + return new Map(rows.map((r) => [Number(r.id), { parent: r.parent_id == null ? null : Number(r.parent_id), name: String(r.name) }])); + } + /** "A/B/C" from the root to a folder (or from just below `from`). */ + function trail(map: Map, id: number | null, from: number | null = null): string[] { + const out: string[] = []; + const seen = new Set(); + let cur = id; + while (cur != null && cur !== from && !seen.has(cur)) { seen.add(cur); const f = map.get(cur); if (!f) break; out.unshift(f.name); cur = f.parent; } + return out; + } + + /** The library row as the explorer sees it. */ + function fileView(r: any, collections: Map): PrintFile { + const cadSection = r.cad_section && String(r.cad_section).toLowerCase() !== "other" ? String(r.cad_section) : null; + const cadQty = r.cad_id != null && Number.isFinite(Number(r.cad_quantity)) ? Number(r.cad_quantity) : null; + return { + id: Number(r.id), folderId: r.folder_id == null ? null : Number(r.folder_id), name: r.name, kind: r.kind as PrintKind, size: Number(r.size) || 0, + createdAt: r.created_at, updatedAt: r.updated_at, uploadedBy: r.uploaded_by_name || null, notes: r.notes || "", + robotId: r.robot_ok != null ? Number(r.robot_id) : null, + season: r.robot_ok != null ? Number(r.robot_season) : r.season != null ? Number(r.season) : null, + cadPart: r.cad_id != null ? { id: Number(r.cad_id), name: r.cad_name, section: r.cad_section ?? null, quantity: Number(r.cad_quantity) || 0, robotId: r.cad_robot_id == null ? null : Number(r.cad_robot_id) } : null, + subsystem: r.subsystem || cadSection, subsystemFrom: r.subsystem ? "manual" : cadSection ? "cad" : null, + material: r.material || null, status: isPrintStatus(r.status) ? r.status : "to_print", + copiesNeeded: r.copies_needed != null ? Number(r.copies_needed) : cadQty, + copiesNeededFrom: r.copies_needed != null ? "manual" : cadQty != null ? "cad" : null, + copiesPrinted: Number(r.copies_printed) || 0, collections: collections.get(Number(r.id)) ?? [], hasThumb: !!r.thumb_key, + source: r.source === "control-kit" ? "control-kit" : "upload", + }; + } + const FILE_SELECT = `SELECT f.*, r.id AS robot_ok, r.season AS robot_season, p.id AS cad_id, p.name AS cad_name, p.section AS cad_section, p.quantity AS cad_quantity, p.robot_id AS cad_robot_id + FROM print_files f + LEFT JOIN cad_robots r ON r.id = f.robot_id AND r.team_id = f.team_id + LEFT JOIN cad_parts p ON p.id = f.cad_part_id AND p.team_id = f.team_id`; + async function oneFile(teamId: number, id: number) { + const r = await dbGet(`${FILE_SELECT} WHERE f.id = ? AND f.team_id = ?`, id, teamId); + if (!r) return null; + const col = (await dbAll("SELECT collection_id FROM print_collection_files WHERE file_id = ? AND team_id = ?", id, teamId)).map((x) => Number(x.collection_id)); + return fileView(r, new Map([[Number(r.id), col]])); + } + + /** + * Insert a stored file's row. The quota is checked in the same statement, and a name taken meanwhile gets the next + * "(n)". Returns the row id and the name it got, or throws PrintError("quota"). + */ + async function insertFile(teamId: number, row: { folderId: number | null; name: string; kind: PrintKind; size: number; sha: string; key: string; by: { id: number | null; name: string }; source: "upload" | "control-kit"; robotId?: number | null; cadPartId?: number | null; ckPartKey?: string | null }) { + for (let attempt = 0; attempt < 5; attempt++) { + const name = uniqueName(row.name, await namesIn(teamId, row.folderId)); + const t = now(); + try { + const r = await dbRun( + `INSERT INTO print_files (team_id, folder_id, name, kind, size, sha256, storage_key, robot_id, cad_part_id, ck_part_key, source, uploaded_by, uploaded_by_name, created_at, updated_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE (SELECT COALESCE(SUM(size + thumb_size), 0) FROM print_files WHERE team_id = ?) + ? <= ? AND ${FOLDER_OK}`, + teamId, row.folderId, name, row.kind, row.size, row.sha, row.key, row.robotId ?? null, row.cadPartId ?? null, row.ckPartKey ?? null, row.source, row.by.id, row.by.name, t, t, + teamId, row.size, limits.quota, row.folderId, row.folderId, teamId); + if (!r.changes) { + if (row.folderId != null && !(await dbGet("SELECT 1 FROM print_folders WHERE id = ? AND team_id = ?", row.folderId, teamId))) throw new PrintError("bad_name", "That folder no longer exists", 404); + throw new PrintError("quota", quotaMessage(), 413); + } + return { id: Number(r.lastInsertRowid), name }; + } catch (e) { + if (!isUnique(e) || attempt === 4) throw e; + } + } + throw new Error("unreachable"); + } + const mbText = (n: number) => (n >= 1024 * MB ? `${+(n / (1024 * MB)).toFixed(1)} GB` : `${Math.round(n / MB)} MB`); + const quotaMessage = () => `Your team's prints are limited to ${mbText(limits.quota)}. Delete old files to make room.`; + async function checkDisk(incoming: number) { + const free = await store.free().catch(() => Infinity); + if (free - incoming < limits.minFree) throw new PrintError("disk_full", "The server is short of disk space right now. Ask the Control Point owner, or try again later.", 507); + } + async function saveThumb(teamId: number, fileId: number, key: string, bytes: Buffer, by: { id: number | null; name: string } | null) { + if (bytes.length > THUMB_MAX || !bytes.subarray(0, 8).equals(PNG_SIG)) throw new PrintError("unsupported", "Thumbnails are PNG images up to 512 KB"); + await checkDisk(bytes.length); + const thumbKey = `${key}.${randomBytes(4).toString("hex")}.thumb.png`; + const old = await dbGet("SELECT thumb_key FROM print_files WHERE id = ? AND team_id = ? AND storage_key = ?", fileId, teamId, key); + if (!old) return; + await store.write(thumbKey, bytes); + // Counted in the quota like the file itself (replacing any earlier thumbnail's size). + const r = await dbRun( + `UPDATE print_files SET thumb_key = ?, thumb_size = ? WHERE id = ? AND team_id = ? AND storage_key = ? AND thumb_key IS ? + AND (SELECT COALESCE(SUM(size + thumb_size), 0) FROM print_files WHERE team_id = ?) - thumb_size + ? <= ?`, + thumbKey, bytes.length, fileId, teamId, key, old.thumb_key ?? null, teamId, bytes.length, limits.quota); + if (!r.changes) { + // The image in use stays; only this attempt's file goes. + await store.remove(thumbKey); + const now2 = await dbGet("SELECT thumb_key FROM print_files WHERE id = ? AND team_id = ? AND storage_key = ?", fileId, teamId, key); + if (now2 && (now2.thumb_key ?? null) === (old.thumb_key ?? null)) throw new PrintError("quota", quotaMessage(), 413); + return; + } + if (old.thumb_key) await store.remove(old.thumb_key); + void by; + } + const sendError = (res: any, e: any, what: string) => { + if (e instanceof PrintError) return res.status(e.status).json({ error: e.message, code: e.code }); + console.error(`Prints ${what} error:`, e); + res.status(500).json({ error: "Internal server error" }); + }; + + function registerRoutes(app: Express) { + // Everything the explorer needs in one answer: folders, files with their details, collections, robots, usage. + app.get("/api/prints/library", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const { teamId } = a; + const [folders, files, collections, members, robots, team] = await Promise.all([ + dbAll("SELECT id, parent_id, name, created_at, updated_at FROM print_folders WHERE team_id = ? ORDER BY name COLLATE NOCASE", teamId), + dbAll(`${FILE_SELECT} WHERE f.team_id = ?`, teamId), + dbAll("SELECT c.id, c.name, (SELECT COUNT(*) FROM print_collection_files x WHERE x.collection_id = c.id) AS n FROM print_collections c WHERE c.team_id = ? ORDER BY c.name COLLATE NOCASE", teamId), + dbAll("SELECT collection_id, file_id FROM print_collection_files WHERE team_id = ?", teamId), + dbAll("SELECT id, name, season FROM cad_robots WHERE team_id = ? ORDER BY season DESC, is_main DESC, id", teamId), + dbGet("SELECT subsystems FROM teams WHERE id = ?", teamId), + ]); + const byFile = new Map(); + for (const m of members) byFile.set(Number(m.file_id), [...(byFile.get(Number(m.file_id)) ?? []), Number(m.collection_id)]); + let subsystems: string[] = [...DEFAULT_SUBSYSTEMS]; + try { if (team?.subsystems) subsystems = cleanSubsystems(JSON.parse(team.subsystems)); } catch { /* defaults */ } + res.setHeader("Cache-Control", "private, no-store"); + res.json({ + teamId, + canEdit: await deps.hasPerm(a.auth, MANAGE_PRINTS), + usage: { used: files.reduce((s, f) => s + (Number(f.size) || 0) + (Number(f.thumb_size) || 0), 0), quota: limits.quota, maxFile: limits.maxFile }, + folders: folders.map((f) => ({ id: Number(f.id), parentId: f.parent_id == null ? null : Number(f.parent_id), name: f.name, createdAt: f.created_at, updatedAt: f.updated_at })), + files: files.map((f) => fileView(f, byFile)), + collections: collections.map((c) => ({ id: Number(c.id), name: c.name, files: Number(c.n) || 0 })), + robots: robots.map((r) => ({ id: Number(r.id), name: r.name, season: Number(r.season), seasonName: seasonName(Number(r.season)) })), + subsystems, + }); + } catch (e) { sendError(res, e, "library"); } + }); + + // ---- Folders ---- + app.post("/api/prints/folders", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const name = cleanName(req.body?.name); + if (!name) return res.status(400).json({ error: "Folder names can't be empty or contain / \\ : * ? \" < > |" }); + const parentId = await teamFolder(a.teamId, req.body?.parentId); + if (parentId === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + const count = await dbGet("SELECT COUNT(*) AS n FROM print_folders WHERE team_id = ?", a.teamId); + if (Number(count?.n) >= MAX_FOLDERS) return res.status(400).json({ error: `A library holds at most ${MAX_FOLDERS} folders` }); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const t = now(); + try { + const r = await dbRun(`INSERT INTO print_folders (team_id, parent_id, name, created_by, created_at, updated_at) SELECT ?, ?, ?, ?, ?, ? WHERE ${FOLDER_OK}`, + a.teamId, parentId, name, by.id, t, t, parentId, parentId, a.teamId); + if (!r.changes) return res.status(404).json({ error: "That folder no longer exists" }); + const id = Number(r.lastInsertRowid); + const log = logStmt(a.teamId, by, "folder_create", { folderId: id, detail: { name, parentId } }); + await dbRun(log.sql, ...log.args); + res.json({ id, name }); + } catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a folder named "${name}" here` }); + throw e; + } + } catch (e) { sendError(res, e, "folder create"); } + }); + + // Rename and/or move a folder. It can't move into itself or a folder inside it. + app.patch("/api/prints/folders/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const folder = await dbGet("SELECT * FROM print_folders WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!folder) return res.status(404).json({ error: "Not found" }); + const body = req.body || {}; + let name = String(folder.name); + let parentId: number | null = folder.parent_id == null ? null : Number(folder.parent_id); + if (body.name !== undefined) { + const n = cleanName(body.name); + if (!n) return res.status(400).json({ error: "Folder names can't be empty or contain / \\ : * ? \" < > |" }); + name = n; + } + if (body.parentId !== undefined) { + const p = await teamFolder(a.teamId, body.parentId); + if (p === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + if (p !== null && (await subtree(a.teamId, Number(folder.id))).includes(p)) return res.status(400).json({ error: "A folder can't go inside itself" }); + parentId = p; + } + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const moved = parentId !== (folder.parent_id == null ? null : Number(folder.parent_id)); + const renamed = name !== folder.name; + if (!moved && !renamed) return res.json({ id: Number(folder.id), name }); + const stmts: Stmt[] = [ + // Checked again inside the update: a move into a folder deleted meanwhile, or into one that another move just + // put inside this folder, changes nothing. + { sql: `UPDATE print_folders SET name = ?, parent_id = ?, updated_at = ? WHERE id = ? AND team_id = ? AND ${FOLDER_OK} + AND (? IS NULL OR ? NOT IN (${SUBTREE}))`, + args: [name, parentId, now(), folder.id, a.teamId, parentId, parentId, a.teamId, parentId, parentId, folder.id, a.teamId, a.teamId] }, + ]; + if (renamed) stmts.push(logStmt(a.teamId, by, "folder_rename", { folderId: Number(folder.id), detail: { from: folder.name, to: name }, ifChanged: true })); + if (moved) stmts.push(logStmt(a.teamId, by, "folder_move", { folderId: Number(folder.id), detail: { from: folder.parent_id ?? null, to: parentId }, ifChanged: true })); + try { + const [r] = await deps.dbBatchResults(stmts); + if (!r.changes) { + const p = parentId == null ? null : await teamFolder(a.teamId, parentId); + if (p !== undefined && (await dbGet("SELECT 1 FROM print_folders WHERE id = ? AND team_id = ?", folder.id, a.teamId))) return res.status(400).json({ error: "A folder can't go inside itself" }); + return res.status(404).json({ error: "That folder no longer exists" }); + } + } catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a folder named "${name}" there` }); + throw e; + } + res.json({ id: Number(folder.id), name }); + } catch (e) { sendError(res, e, "folder update"); } + }); + + // Delete a folder and everything in it. With files inside, the caller must say how many it confirmed (?files=N); + // a different count (someone added or removed files meanwhile) asks again. + app.delete("/api/prints/folders/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const tree = await subtree(a.teamId, Number(req.params.id)); + if (!tree.length) return res.status(404).json({ error: "Not found" }); + const count = await dbGet(`SELECT COUNT(*) AS n FROM print_files WHERE team_id = ? AND folder_id IN (${qs(tree.length)})`, a.teamId, ...tree); + const files = Number(count?.n) || 0; + const confirmed = req.query.files === undefined ? null : Number(req.query.files); + if (files > 0 && confirmed !== files) { + return res.status(409).json({ error: `This folder holds ${files} file${files === 1 ? "" : "s"}. Confirm to delete them too.`, files, folders: tree.length - 1 }); + } + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const folder = await dbGet("SELECT name FROM print_folders WHERE id = ?", tree[0]); + // Every statement works on the subtree as it is inside this transaction, and the first one stops the batch when + // the folder now holds a different number of files than the person confirmed (an upload or move meanwhile). + const inTree = `IN (${SUBTREE})`; + const t3 = [tree[0], a.teamId, a.teamId]; + const stmts: Stmt[] = [ + assertStmt(`(SELECT COUNT(*) FROM print_files WHERE team_id = ? AND folder_id ${inTree}) = ?`, [a.teamId, ...t3, files]), + { sql: `DELETE FROM print_collection_files WHERE team_id = ? AND file_id IN (SELECT id FROM print_files WHERE team_id = ? AND folder_id ${inTree})`, args: [a.teamId, a.teamId, ...t3] }, + { sql: `DELETE FROM print_files WHERE team_id = ? AND folder_id ${inTree} RETURNING id, name, storage_key, thumb_key`, args: [a.teamId, ...t3] }, + { sql: `DELETE FROM print_folders WHERE team_id = ? AND id ${inTree}`, args: [a.teamId, ...t3] }, + logStmt(a.teamId, by, "folder_delete", { folderId: tree[0], detail: { name: folder?.name, files, folders: tree.length } }), + ]; + let out; + try { out = await deps.dbBatchResults(stmts); } + catch (e) { + if (!isAssert(e)) throw e; + const now2 = await subtree(a.teamId, tree[0]); + const n = now2.length ? Number((await dbGet(`SELECT COUNT(*) AS n FROM print_files WHERE team_id = ? AND folder_id IN (${qs(now2.length)})`, a.teamId, ...now2))?.n) || 0 : 0; + return res.status(409).json({ error: `This folder now holds ${n} file${n === 1 ? "" : "s"}. Confirm again to delete them too.`, files: n, folders: Math.max(0, now2.length - 1) }); + } + out = out.slice(1); + for (const r of out[1].rows) { await store.remove(r.storage_key); await store.remove(r.thumb_key); } + res.json({ success: true, files: out[1].rows.length }); + } catch (e) { sendError(res, e, "folder delete"); } + }); + + // ---- Files ---- + // Upload one file (field "file", optional PNG "thumbnail", fields "folderId" and "name"). + app.post("/api/prints/files", async (req, res) => { + const cleanup: string[] = []; + try { + const a = await access(req, res, true); if (!a) return; + // Refuse early, before the body is read, when its declared size can't fit. + const declared = Number(req.headers["content-length"]) || 0; + if (declared > limits.maxFile + THUMB_MAX + 64 * 1024) return res.status(413).json({ error: `Files can be up to ${mbText(limits.maxFile)}`, code: "too_large" }); + if (declared && (await usedBytes(a.teamId)) + declared - THUMB_MAX - 64 * 1024 > limits.quota) return res.status(413).json({ error: quotaMessage(), code: "quota" }); + await checkDisk(declared); + await new Promise((resolve, reject) => upload.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }])(req, res, (err: any) => { + for (const list of Object.values((req as any).files || {}) as any[]) for (const f of list) cleanup.push(f.path); + if (!err) return resolve(); + if (err?.code === "LIMIT_FILE_SIZE") return reject(new PrintError("too_large", `Files can be up to ${mbText(limits.maxFile)}`, 413)); + if (err instanceof PrintError) return reject(err); + reject(new PrintError("unsupported", err?.message || "Upload failed")); + })); + const file = (req as any).files?.file?.[0]; + if (!file) return res.status(400).json({ error: "Choose a file to upload" }); + const name = cleanName(req.body?.name ?? file.originalname); + if (!name) throw new PrintError("bad_name", "File names can't contain / \\ : * ? \" < > |"); + const kind = printKind(name); + if (!kind) throw new PrintError("unsupported", "Prints takes STL, 3MF, STEP/STP, OBJ and G-code files"); + if (!file.size) throw new PrintError("empty", "That file is empty"); + const folderId = await teamFolder(a.teamId, req.body?.folderId); + if (folderId === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + await checkDisk(0); + const sha = await sha256File(file.path); + const key = store.newKey(a.teamId); + await store.adopt(file.path, key); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + let row: { id: number; name: string }; + try { row = await insertFile(a.teamId, { folderId, name, kind, size: file.size, sha, key, by, source: "upload" }); } + catch (e) { await store.remove(key); throw e; } + const thumb = (req as any).files?.thumbnail?.[0]; + if (thumb && canPreview(kind)) { + try { await saveThumb(a.teamId, row.id, key, await fsp.readFile(thumb.path), by); } catch (e) { console.warn("Print thumbnail skipped:", (e as any)?.message || e); } + } + const log = logStmt(a.teamId, by, "upload", { fileId: row.id, folderId, detail: { name: row.name, size: file.size } }); + await dbRun(log.sql, ...log.args); + res.json({ id: row.id, name: row.name, renamed: row.name !== name }); + } catch (e) { sendError(res, e, "upload"); } + finally { for (const p of cleanup) await fsp.unlink(p).catch(() => {}); } + }); + + app.get("/api/prints/files/:id", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const f = await oneFile(a.teamId, Number(req.params.id)); + if (!f) return res.status(404).json({ error: "Not found" }); + res.json(f); + } catch (e) { sendError(res, e, "file"); } + }); + + // Details: name, folder, notes, robot/season, CAD part, subsystem, material, status, copies. + app.patch("/api/prints/files/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const row = await dbGet("SELECT * FROM print_files WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!row) return res.status(404).json({ error: "Not found" }); + const body = req.body || {}; + const sets: string[] = []; + const args: any[] = []; + const changes: Record = {}; + const set = (col: string, v: unknown, label = col) => { if (row[col] !== v) { sets.push(`${col} = ?`); args.push(v); changes[label] = v; } }; + let folderId: number | null = row.folder_id == null ? null : Number(row.folder_id); + if (body.folderId !== undefined) { + const f = await teamFolder(a.teamId, body.folderId); + if (f === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + folderId = f; + set("folder_id", f, "folderId"); + } + if (body.name !== undefined) { + const n = cleanName(body.name); + if (!n) return res.status(400).json({ error: "File names can't be empty or contain / \\ : * ? \" < > |" }); + if (printKind(n) !== row.kind) return res.status(400).json({ error: `Keep the file's .${String(row.name).split(".").pop()} ending` }); + set("name", n); + } else if (folderId !== (row.folder_id == null ? null : Number(row.folder_id))) { + // Moving into a folder with a file of the same name: this one becomes "name (2)". + set("name", uniqueName(row.name, await namesIn(a.teamId, folderId, Number(row.id)))); + } + if (body.notes !== undefined) set("notes", typeof body.notes === "string" ? body.notes.slice(0, 2000) : ""); + let robotId: number | null | undefined; + if (body.robotId !== undefined) { + if (body.robotId === null) robotId = null; + else { + const r = await dbGet("SELECT id FROM cad_robots WHERE id = ? AND team_id = ?", Number(body.robotId), a.teamId); + if (!r) return res.status(404).json({ error: "That robot no longer exists" }); + robotId = Number(r.id); + } + } + if (body.cadPartId !== undefined) { + if (body.cadPartId === null) set("cad_part_id", null, "cadPartId"); + else { + const p = await dbGet("SELECT id, robot_id FROM cad_parts WHERE id = ? AND team_id = ?", Number(body.cadPartId), a.teamId); + if (!p) return res.status(404).json({ error: "That CAD part no longer exists" }); + set("cad_part_id", Number(p.id), "cadPartId"); + // A part belongs to a robot: the file follows it unless the request names one. + if (robotId === undefined && p.robot_id != null) robotId = Number(p.robot_id); + } + } + if (robotId !== undefined) set("robot_id", robotId, "robotId"); + if (body.season !== undefined) { + if (body.season !== null && !isSeason(Number(body.season))) return res.status(400).json({ error: "Pick a season" }); + set("season", body.season === null ? null : Number(body.season)); + } + if (body.subsystem !== undefined) set("subsystem", cleanSubsystem(body.subsystem) || null); + if (body.material !== undefined) set("material", cleanMaterial(body.material) || null); + if (body.copiesNeeded !== undefined) { + const n = intOrNull(body.copiesNeeded, 999); + if (n === undefined) return res.status(400).json({ error: "Copies needed is a whole number up to 999" }); + set("copies_needed", n, "copiesNeeded"); + } + let status = row.status; + if (body.status !== undefined) { + if (!isPrintStatus(body.status)) return res.status(400).json({ error: "Unknown print status" }); + status = body.status; + } + if (body.copiesPrinted !== undefined) { + const n = intOrNull(body.copiesPrinted, 999); + if (n === undefined || n === null) return res.status(400).json({ error: "Copies printed is a whole number up to 999" }); + set("copies_printed", n, "copiesPrinted"); + // Reaching the copies needed marks it printed, unless the request says otherwise. + if (body.status === undefined && (status === "to_print" || status === "printing")) { + const need = body.copiesNeeded !== undefined ? intOrNull(body.copiesNeeded, 999) : row.copies_needed != null ? Number(row.copies_needed) + : row.cad_part_id ? Number((await dbGet("SELECT quantity FROM cad_parts WHERE id = ? AND team_id = ?", row.cad_part_id, a.teamId))?.quantity ?? NaN) : NaN; + if (need != null && Number.isFinite(need) && need > 0 && n >= need) status = "printed"; + } + } + set("status", status); + if (status === "printed" && row.status !== "printed" && body.copiesPrinted === undefined) { + const need = body.copiesNeeded !== undefined ? intOrNull(body.copiesNeeded, 999) : row.copies_needed != null ? Number(row.copies_needed) + : row.cad_part_id ? Number((await dbGet("SELECT quantity FROM cad_parts WHERE id = ? AND team_id = ?", row.cad_part_id, a.teamId))?.quantity ?? NaN) : NaN; + if (need != null && Number.isFinite(need) && need > Number(row.copies_printed)) set("copies_printed", need, "copiesPrinted"); + } + if (!sets.length) return res.json(await oneFile(a.teamId, Number(row.id))); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const stmts: Stmt[] = [{ sql: `UPDATE print_files SET ${sets.join(", ")}, updated_at = ? WHERE id = ? AND team_id = ? AND ${FOLDER_OK}`, args: [...args, now(), row.id, a.teamId, folderId, folderId, a.teamId] }]; + const action = changes.folderId !== undefined ? "move" : changes.name !== undefined ? "rename" : changes.status !== undefined && Object.keys(changes).length === 1 ? "status" : "edit"; + stmts.push(logStmt(a.teamId, by, action, { fileId: Number(row.id), folderId, detail: { ...changes, ...(changes.name !== undefined ? { from: row.name } : {}) }, ifChanged: true })); + try { + const [u] = await deps.dbBatchResults(stmts); + if (!u.changes) return res.status(404).json({ error: "That folder no longer exists" }); + } catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a file named "${changes.name ?? row.name}" there` }); + throw e; + } + res.json(await oneFile(a.teamId, Number(row.id))); + } catch (e) { sendError(res, e, "file update"); } + }); + + app.delete("/api/prints/files/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const removed = await removeFiles(a.teamId, [Number(req.params.id)], by); + if (!removed) return res.status(404).json({ error: "Not found" }); + res.json({ success: true }); + } catch (e) { sendError(res, e, "file delete"); } + }); + + async function removeFiles(teamId: number, list: number[], by: { id: number | null; name: string }) { + if (!list.length) return 0; + const inList = `IN (${qs(list.length)})`; + const out = await deps.dbBatchResults([ + { sql: `DELETE FROM print_collection_files WHERE team_id = ? AND file_id ${inList}`, args: [teamId, ...list] }, + { sql: `DELETE FROM print_files WHERE team_id = ? AND id ${inList} RETURNING id, name, folder_id, storage_key, thumb_key`, args: [teamId, ...list] }, + ]); + const rows = out[1].rows; + if (rows.length) await deps.dbBatchResults(rows.map((r) => logStmt(teamId, by, "delete", { fileId: Number(r.id), folderId: r.folder_id ?? null, detail: { name: r.name } }))); + for (const r of rows) { await store.remove(r.storage_key); await store.remove(r.thumb_key); } + return rows.length; + } + + // Several files at once: move, status, delete, add to / remove from a collection. + app.post("/api/prints/files/bulk", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const body = req.body || {}; + const list = ids(body.ids); + if (!list.length) return res.status(400).json({ error: "Pick some files" }); + if (list.length > MAX_BULK) return res.status(400).json({ error: `At most ${MAX_BULK} files at a time` }); + const rows = await dbAll(`SELECT id, name, folder_id, status FROM print_files WHERE team_id = ? AND id IN (${qs(list.length)})`, a.teamId, ...list); + if (!rows.length) return res.status(404).json({ error: "Those files no longer exist" }); + const mine = rows.map((r) => Number(r.id)); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + const t = now(); + const stmts: Stmt[] = []; + switch (body.action) { + case "move": { + const folderId = await teamFolder(a.teamId, body.folderId); + if (folderId === undefined) return res.status(404).json({ error: "That folder no longer exists" }); + // The destination's names stay taken; each moving file keeps its name, or takes the next "(n)". + const taken = await namesIn(a.teamId, folderId); + const moving = rows.filter((r) => (r.folder_id == null ? null : Number(r.folder_id)) !== folderId); + stmts.push(assertStmt(FOLDER_OK, [folderId, folderId, a.teamId])); + for (const r of moving) { + const name = uniqueName(r.name, taken); + taken.add(name.toLowerCase()); + stmts.push({ sql: "UPDATE print_files SET folder_id = ?, name = ?, updated_at = ? WHERE id = ? AND team_id = ?", args: [folderId, name, t, r.id, a.teamId] }); + stmts.push(logStmt(a.teamId, by, "move", { fileId: Number(r.id), folderId, detail: { from: r.folder_id ?? null, to: folderId, ...(name !== r.name ? { name, was: r.name } : {}) } })); + } + break; + } + case "status": { + if (!isPrintStatus(body.status)) return res.status(400).json({ error: "Unknown print status" }); + const status: PrintStatus = body.status; + for (const r of rows.filter((x) => x.status !== status)) { + stmts.push({ + // Marking printed counts every copy needed as printed (when the count is known). + sql: `UPDATE print_files SET status = ?, updated_at = ?, copies_printed = CASE WHEN ? = 'printed' + THEN MAX(copies_printed, COALESCE(copies_needed, (SELECT p.quantity FROM cad_parts p WHERE p.id = print_files.cad_part_id AND p.team_id = print_files.team_id), copies_printed)) + ELSE copies_printed END + WHERE id = ? AND team_id = ?`, + args: [status, t, status, r.id, a.teamId], + }); + stmts.push(logStmt(a.teamId, by, "status", { fileId: Number(r.id), folderId: r.folder_id ?? null, detail: { from: r.status, to: status } })); + } + break; + } + case "delete": { + const n = await removeFiles(a.teamId, mine, by); + return res.json({ success: true, files: n }); + } + case "collect": + case "uncollect": { + const c = await dbGet("SELECT id, name FROM print_collections WHERE id = ? AND team_id = ?", Number(body.collectionId), a.teamId); + if (!c) return res.status(404).json({ error: "That collection no longer exists" }); + const have = new Set((await dbAll(`SELECT file_id FROM print_collection_files WHERE collection_id = ? AND file_id IN (${qs(mine.length)})`, c.id, ...mine)).map((x) => Number(x.file_id))); + const change = body.action === "collect" ? mine.filter((id) => !have.has(id)) : mine.filter((id) => have.has(id)); + for (const id of change) { + stmts.push(body.action === "collect" + // Only while the file and the collection still exist. + ? { sql: `INSERT OR IGNORE INTO print_collection_files (collection_id, file_id, team_id, added_at) + SELECT ?, f.id, f.team_id, ? FROM print_files f WHERE f.id = ? AND f.team_id = ? AND EXISTS (SELECT 1 FROM print_collections c WHERE c.id = ? AND c.team_id = ?)`, + args: [c.id, t, id, a.teamId, c.id, a.teamId] } + : { sql: "DELETE FROM print_collection_files WHERE collection_id = ? AND file_id = ? AND team_id = ?", args: [c.id, id, a.teamId] }); + stmts.push(logStmt(a.teamId, by, body.action, { fileId: id, detail: { collection: c.name, collectionId: Number(c.id) } })); + } + break; + } + default: + return res.status(400).json({ error: "Unknown action" }); + } + const changed = stmts.filter((x) => x.sql.startsWith("INSERT INTO print_activity")).length; + if (changed) { + try { await deps.dbBatchResults(stmts); } + catch (e) { + if (isAssert(e)) return res.status(404).json({ error: "That folder no longer exists" }); + if (isUnique(e)) return res.status(409).json({ error: "A file with one of those names just appeared there. Try again." }); + throw e; + } + } + res.json({ success: true, files: changed }); + } catch (e) { sendError(res, e, "bulk"); } + }); + + app.get("/api/prints/files/:id/download", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const r = await dbGet("SELECT id, name, size, sha256, storage_key FROM print_files WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!r) return res.status(404).json({ error: "Not found" }); + const etag = `"p${r.id}-${String(r.sha256).slice(0, 16)}"`; + res.setHeader("ETag", etag); + res.setHeader("Cache-Control", "private, no-cache"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); + if (req.headers["if-none-match"] === etag) return res.status(304).end(); + if (!(await store.exists(r.storage_key))) return res.status(404).json({ error: "This file is missing from storage" }); + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Content-Disposition", disposition("attachment", String(r.name))); + res.setHeader("Content-Length", String(r.size)); + fs.createReadStream(store.pathOf(r.storage_key)).on("error", () => res.destroy()).pipe(res); + } catch (e) { sendError(res, e, "download"); } + }); + + app.get("/api/prints/files/:id/thumbnail", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const r = await dbGet("SELECT id, thumb_key FROM print_files WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!r?.thumb_key || !(await store.exists(r.thumb_key))) return res.status(404).end(); + const etag = `"t${r.id}-${String(r.thumb_key).slice(-48, -16)}"`; + res.setHeader("ETag", etag); + res.setHeader("Cache-Control", "private, no-cache"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (req.headers["if-none-match"] === etag) return res.status(304).end(); + res.setHeader("Content-Type", "image/png"); + fs.createReadStream(store.pathOf(r.thumb_key)).on("error", () => res.destroy()).pipe(res); + } catch (e) { sendError(res, e, "thumbnail"); } + }); + + // A thumbnail drawn in the browser for a file that has none yet (field "thumbnail", PNG). + app.put("/api/prints/files/:id/thumbnail", async (req, res) => { + const cleanup: string[] = []; + try { + const a = await access(req, res, true); if (!a) return; + const r = await dbGet("SELECT id, kind, storage_key FROM print_files WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!r) return res.status(404).json({ error: "Not found" }); + if (!canPreview(r.kind)) return res.status(400).json({ error: "Only STL, 3MF and OBJ files get thumbnails" }); + if ((Number(req.headers["content-length"]) || 0) > THUMB_MAX + 16 * 1024) return res.status(413).json({ error: "Thumbnails are up to 512 KB" }); + await new Promise((resolve, reject) => upload.single("thumbnail")(req, res, (err: any) => { + if ((req as any).file) cleanup.push((req as any).file.path); + err ? reject(err instanceof PrintError ? err : new PrintError("unsupported", err?.message || "Upload failed")) : resolve(); + })); + const f = (req as any).file; + if (!f) return res.status(400).json({ error: "No thumbnail" }); + await saveThumb(a.teamId, Number(r.id), r.storage_key, await fsp.readFile(f.path), null); + res.json({ success: true }); + } catch (e) { sendError(res, e, "thumbnail upload"); } + finally { for (const p of cleanup) await fsp.unlink(p).catch(() => {}); } + }); + + // A ZIP of a folder (?folder=|root, everything below it) or of chosen files (?files=1,2,3, kept in their + // folders). ?name= names the ZIP (a group's name). + // A download ticket for chosen files (a group's "Download all" can name thousands): the page then opens the GET. + app.post("/api/prints/zip", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const list = ids(req.body?.ids); + if (!list.length) return res.status(400).json({ error: "Pick some files" }); + if (list.length > ZIP_MAX_ENTRIES) return res.status(413).json({ error: `One download holds at most ${ZIP_MAX_ENTRIES.toLocaleString("en-US")} files. Pick fewer.` }); + // Only this workspace's files that exist are kept, so a ticket is never bigger than the library. + const mine = (await dbAll("SELECT id FROM print_files WHERE team_id = ? AND id IN (SELECT value FROM json_each(?))", a.teamId, JSON.stringify(list))).map((r) => Number(r.id)); + if (!mine.length) return res.status(404).json({ error: "Those files no longer exist" }); + for (const [k, v] of zipTickets) if (v.exp < Date.now()) zipTickets.delete(k); + // Each person keeps at most a few tickets (the oldest goes), and all of them together stay small. + const own = [...zipTickets].filter(([, v]) => v.memberId === a.auth.memberId).sort((x, y) => x[1].exp - y[1].exp); + for (const [k] of own.slice(0, Math.max(0, own.length - (ZIP_TICKETS_PER_MEMBER - 1)))) zipTickets.delete(k); + let held = 0; + for (const v of zipTickets.values()) held += v.ids.length; + if (held + mine.length > ZIP_TICKET_IDS_MAX) return res.status(429).json({ error: "Too many downloads are being prepared. Try again in a minute." }); + const ticket = randomBytes(18).toString("base64url"); + zipTickets.set(ticket, { teamId: a.teamId, memberId: a.auth.memberId, ids: mine, name: typeof req.body?.name === "string" ? req.body.name.slice(0, 120) : "", exp: Date.now() + 5 * 60_000 }); + res.json({ url: `/api/prints/zip?ticket=${ticket}` }); + } catch (e) { sendError(res, e, "zip ticket"); } + }); + + app.get("/api/prints/zip", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const map = await folderTrailNames(a.teamId); + let rows: any[] = []; + let base: number | null = null; + let zipName = "Prints"; + if (req.query.folder !== undefined) { + if (req.query.folder === "root") rows = await dbAll("SELECT id, name, folder_id, size, storage_key, updated_at FROM print_files WHERE team_id = ?", a.teamId); + else { + const tree = await subtree(a.teamId, Number(req.query.folder)); + if (!tree.length) return res.status(404).json({ error: "Not found" }); + base = tree[0]; + zipName = map.get(base)?.name ?? zipName; + rows = await dbAll(`SELECT id, name, folder_id, size, storage_key, updated_at FROM print_files WHERE team_id = ? AND folder_id IN (${qs(tree.length)})`, a.teamId, ...tree); + } + } else { + // Chosen files: a ticket from POST /api/prints/zip (any number), or ?files= for a short list. + let list: number[]; + let name: unknown = req.query.name; + if (typeof req.query.ticket === "string") { + const tk = zipTickets.get(req.query.ticket); + if (!tk || tk.exp < Date.now() || tk.teamId !== a.teamId || tk.memberId !== a.auth.memberId) return res.status(404).json({ error: "This download link has expired. Start the download again." }); + // One use. + zipTickets.delete(req.query.ticket); + list = tk.ids; + name = tk.name; + } else { + list = String(req.query.files || "").split(",").map(Number).filter((n) => Number.isInteger(n) && n > 0); + } + if (!list.length) return res.status(400).json({ error: "Pick some files" }); + if (list.length > ZIP_MAX_ENTRIES) return res.status(413).json({ error: `One download holds at most ${ZIP_MAX_ENTRIES.toLocaleString("en-US")} files. Pick fewer.` }); + rows = await dbAll("SELECT id, name, folder_id, size, storage_key, updated_at FROM print_files WHERE team_id = ? AND id IN (SELECT value FROM json_each(?))", a.teamId, JSON.stringify(list)); + if (typeof name === "string" && name.trim()) zipName = safeName(name, "Prints"); + } + if (!rows.length) return res.status(404).json({ error: "There are no files to download here" }); + if (rows.length > ZIP_MAX_ENTRIES) return res.status(413).json({ error: `One download holds at most ${ZIP_MAX_ENTRIES.toLocaleString("en-US")} files. Download smaller folders.` }); + const total = rows.reduce((s, r) => s + (Number(r.size) || 0), 0); + if (total > ZIP_MAX_BYTES) return res.status(413).json({ error: "That's too much for one download. Download smaller folders." }); + const present: any[] = []; + for (const r of rows) if (await store.exists(r.storage_key)) present.push(r); + const names = uniqueZipNames(present.map((r) => [...trail(map, r.folder_id == null ? null : Number(r.folder_id), base), r.name].join("/"))); + res.setHeader("Content-Type", "application/zip"); + res.setHeader("Content-Disposition", disposition("attachment", `${zipName}.zip`)); + res.setHeader("Cache-Control", "private, no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + try { + await writeZip(res, present.map((r, i) => ({ name: names[i], path: store.pathOf(r.storage_key), mtime: new Date(r.updated_at || Date.now()) }))); + res.end(); + } catch (e) { + if (!res.writableEnded) res.destroy(); + if ((e as any)?.message !== "closed") console.error("Prints ZIP failed:", (e as any)?.message || e); + } + } catch (e) { if (!res.headersSent) sendError(res, e, "zip"); else res.destroy(); } + }); + + // ---- Collections ---- + app.post("/api/prints/collections", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const name = typeof req.body?.name === "string" ? req.body.name.replace(/[\u0000-\u001f]/g, "").trim().slice(0, 40) : ""; + if (!name) return res.status(400).json({ error: "Name the collection" }); + const count = await dbGet("SELECT COUNT(*) AS n FROM print_collections WHERE team_id = ?", a.teamId); + if (Number(count?.n) >= MAX_COLLECTIONS) return res.status(400).json({ error: `A library holds at most ${MAX_COLLECTIONS} collections` }); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + try { + const r = await dbRun("INSERT INTO print_collections (team_id, name, created_by, created_at) VALUES (?, ?, ?, ?)", a.teamId, name, by.id, now()); + const log = logStmt(a.teamId, by, "collection_create", { detail: { name, collectionId: Number(r.lastInsertRowid) } }); + await dbRun(log.sql, ...log.args); + res.json({ id: Number(r.lastInsertRowid), name }); + } catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a collection named "${name}"` }); + throw e; + } + } catch (e) { sendError(res, e, "collection create"); } + }); + + app.patch("/api/prints/collections/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const c = await dbGet("SELECT id, name FROM print_collections WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!c) return res.status(404).json({ error: "Not found" }); + const name = typeof req.body?.name === "string" ? req.body.name.replace(/[\u0000-\u001f]/g, "").trim().slice(0, 40) : ""; + if (!name) return res.status(400).json({ error: "Name the collection" }); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + try { + await deps.dbBatchResults([ + { sql: "UPDATE print_collections SET name = ? WHERE id = ? AND team_id = ?", args: [name, c.id, a.teamId] }, + logStmt(a.teamId, by, "collection_rename", { detail: { from: c.name, to: name, collectionId: Number(c.id) } }), + ]); + } catch (e) { + if (isUnique(e)) return res.status(409).json({ error: `There's already a collection named "${name}"` }); + throw e; + } + res.json({ id: Number(c.id), name }); + } catch (e) { sendError(res, e, "collection rename"); } + }); + + // Deleting a collection only ungroups its files; every file stays where it lives. + app.delete("/api/prints/collections/:id", async (req, res) => { + try { + const a = await access(req, res, true); if (!a) return; + const c = await dbGet("SELECT id, name FROM print_collections WHERE id = ? AND team_id = ?", Number(req.params.id), a.teamId); + if (!c) return res.status(404).json({ error: "Not found" }); + const by = { id: a.auth.memberId, name: await memberName(a.auth.memberId) }; + await deps.dbBatchResults([ + { sql: "DELETE FROM print_collection_files WHERE collection_id = ? AND team_id = ?", args: [c.id, a.teamId] }, + { sql: "DELETE FROM print_collections WHERE id = ? AND team_id = ?", args: [c.id, a.teamId] }, + logStmt(a.teamId, by, "collection_delete", { detail: { name: c.name, collectionId: Number(c.id) } }), + ]); + res.json({ success: true }); + } catch (e) { sendError(res, e, "collection delete"); } + }); + + // Recent changes: the library's, or one file's (?file=). + app.get("/api/prints/activity", async (req, res) => { + try { + const a = await access(req, res, false); if (!a) return; + const file = Number(req.query.file); + const rows = Number.isInteger(file) && file > 0 + ? await dbAll("SELECT id, file_id, folder_id, member_name, action, detail, at FROM print_activity WHERE team_id = ? AND file_id = ? ORDER BY id DESC LIMIT 50", a.teamId, file) + : await dbAll("SELECT id, file_id, folder_id, member_name, action, detail, at FROM print_activity WHERE team_id = ? ORDER BY id DESC LIMIT 100", a.teamId); + res.json(rows.map((r) => { + let detail: any = {}; + try { detail = JSON.parse(r.detail || "{}"); } catch { /* none */ } + return { id: Number(r.id), fileId: r.file_id == null ? null : Number(r.file_id), folderId: r.folder_id == null ? null : Number(r.folder_id), by: r.member_name || "Someone", action: r.action, detail, at: r.at }; + })); + } catch (e) { sendError(res, e, "activity"); } + }); + } + + // ---- Filing from inside the server (Control Kit) ---- + + /** The folder at `segments` under the root, made as needed. */ + async function ensureFolders(teamId: number, segments: string[], by: { id: number | null; name: string }): Promise { + let parent: number | null = null; + for (const name of segments) { + const find = () => dbGet("SELECT id FROM print_folders WHERE team_id = ? AND COALESCE(parent_id, 0) = ? AND name = ? COLLATE NOCASE", teamId, parent ?? 0, name); + let hit = await find(); + if (!hit) { + const t = now(); + const r = await dbRun(`INSERT OR IGNORE INTO print_folders (team_id, parent_id, name, created_by, created_at, updated_at) SELECT ?, ?, ?, ?, ?, ? WHERE ${FOLDER_OK}`, + teamId, parent, name, by.id, t, t, parent, parent, teamId); + if (r.changes) { const l = logStmt(teamId, by, "folder_create", { folderId: Number(r.lastInsertRowid), detail: { name, parentId: parent } }); await dbRun(l.sql, ...l.args); } + hit = await find(); + if (!hit) throw new PrintError("bad_name", "A folder on that path was deleted meanwhile; send again", 409); + } + parent = Number(hit.id); + } + return parent!; + } + + /** + * File a print from inside the server: `path` is the folder ("A / B / C" or segments, made as needed; unusable + * characters in each part become "-"), `name` the file name with its extension. A file of the same name in that + * folder that Control Kit sent before is replaced in place: its status, notes and collections stay, and a printed + * one goes back to "To print" when the bytes changed. A same-named file a person uploaded is kept; the new one gets + * "name (2)". Throws PrintError (unsupported, too_large, quota, disk_full, empty, bad_name). + */ + async function filePrint(input: { + teamId: number; path: string | readonly string[]; name: string; bytes: Uint8Array; + by?: { memberId?: number | null; name?: string | null } | null; source?: "upload" | "control-kit"; + robotId?: number | null; cadPartId?: number | null; ckPartKey?: string | null; + }): Promise<{ fileId: number; folderId: number; name: string; replaced: boolean; unchanged: boolean }> { + const segments = (Array.isArray(input.path) ? [...input.path] : String(input.path).split("/")).map((s) => String(s).trim()).filter(Boolean).map((s) => safeName(s)); + if (!segments.length || segments.length > 12) throw new PrintError("bad_name", "A print needs a folder path"); + const name = safeName(input.name, ""); + if (!name) throw new PrintError("bad_name", "A print needs a file name"); + const kind = printKind(name); + if (!kind) throw new PrintError("unsupported", "Prints takes STL, 3MF, STEP/STP, OBJ and G-code files"); + const bytes = Buffer.from(input.bytes.buffer, input.bytes.byteOffset, input.bytes.byteLength); + if (!bytes.length) throw new PrintError("empty", "That file is empty"); + if (bytes.length > limits.maxFile) throw new PrintError("too_large", `Files can be up to ${mbText(limits.maxFile)}`, 413); + await checkDisk(bytes.length); + const source = input.source ?? "control-kit"; + const by = { id: input.by?.memberId ?? null, name: input.by?.name || (source === "control-kit" ? "Control Kit" : "Control Point") }; + const folderId = await ensureFolders(input.teamId, segments, by); + const sha = createHash("sha256").update(bytes).digest("hex"); + // Control Kit's earlier file for the same part (by its identity, else by name) is the one a new send replaces. + const existing = (source === "control-kit" && input.ckPartKey + ? await dbGet("SELECT * FROM print_files WHERE team_id = ? AND COALESCE(folder_id, 0) = ? AND source = 'control-kit' AND ck_part_key = ? ORDER BY id LIMIT 1", input.teamId, folderId, input.ckPartKey) + : null) ?? await dbGet("SELECT * FROM print_files WHERE team_id = ? AND COALESCE(folder_id, 0) = ? AND name = ? COLLATE NOCASE", input.teamId, folderId, name); + if (existing && existing.source === source && source === "control-kit" && (!input.ckPartKey || !existing.ck_part_key || existing.ck_part_key === input.ckPartKey)) { + if (existing.sha256 === sha) return { fileId: Number(existing.id), folderId, name: existing.name, replaced: false, unchanged: true }; + const key = store.newKey(input.teamId); + await store.write(key, bytes); + const r = await dbRun( + `UPDATE print_files SET size = ?, sha256 = ?, storage_key = ?, thumb_key = NULL, thumb_size = 0, updated_at = ?, + status = CASE WHEN status = 'printed' THEN 'to_print' ELSE status END, + robot_id = COALESCE(?, robot_id), cad_part_id = COALESCE(?, cad_part_id), ck_part_key = COALESCE(?, ck_part_key) + WHERE id = ? AND team_id = ? AND storage_key = ? + AND (SELECT COALESCE(SUM(size + thumb_size), 0) FROM print_files WHERE team_id = ?) - size - thumb_size + ? <= ?`, + bytes.length, sha, key, now(), input.robotId ?? null, input.cadPartId ?? null, input.ckPartKey ?? null, + existing.id, input.teamId, existing.storage_key, input.teamId, bytes.length, limits.quota); + if (!r.changes) { + await store.remove(key); + const still = await dbGet("SELECT id FROM print_files WHERE id = ?", existing.id); + if (still) throw new PrintError("quota", quotaMessage(), 413); + // Deleted meanwhile: file it as new. + return filePrint(input); + } + await store.remove(existing.storage_key); + await store.remove(existing.thumb_key); + const l = logStmt(input.teamId, by, "replace", { fileId: Number(existing.id), folderId, detail: { name: existing.name, size: bytes.length, wasPrinted: existing.status === "printed" } }); + await dbRun(l.sql, ...l.args); + return { fileId: Number(existing.id), folderId, name: existing.name, replaced: true, unchanged: false }; + } + const key = store.newKey(input.teamId); + await store.write(key, bytes); + let row: { id: number; name: string }; + try { + row = await insertFile(input.teamId, { folderId, name, kind, size: bytes.length, sha, key, by, source, robotId: input.robotId, cadPartId: input.cadPartId, ckPartKey: input.ckPartKey }); + } catch (e) { await store.remove(key); throw e; } + const l = logStmt(input.teamId, by, "upload", { fileId: row.id, folderId, detail: { name: row.name, size: bytes.length, source } }); + await dbRun(l.sql, ...l.args); + return { fileId: row.id, folderId, name: row.name, replaced: false, unchanged: false }; + } + + /** + * Control Kit's entry point: file a print for a robot under " / / Control Kit", linked to + * the robot and to the robot's CAD part with that Control Kit identity (cad_parts.ck_part_key) when there is one. + */ + async function fileControlKitPrint(input: { teamId: number; robotId: number; name: string; bytes: Uint8Array; ckPartKey?: string | null; sentBy?: string | null }) { + const robot = await dbGet("SELECT id, name, season FROM cad_robots WHERE id = ? AND team_id = ?", input.robotId, input.teamId); + if (!robot) throw new PrintError("bad_name", "That robot no longer exists", 404); + const part = input.ckPartKey + ? await dbGet("SELECT id FROM cad_parts WHERE team_id = ? AND robot_id = ? AND ck_part_key = ? ORDER BY id LIMIT 1", input.teamId, robot.id, input.ckPartKey) + : null; + return filePrint({ + teamId: input.teamId, path: controlKitPrintPath(Number(robot.season), String(robot.name)), name: input.name, bytes: input.bytes, + by: { name: input.sentBy ? `${input.sentBy} via Control Kit` : "Control Kit" }, source: "control-kit", + robotId: Number(robot.id), cadPartId: part ? Number(part.id) : null, ckPartKey: input.ckPartKey ?? null, + }); + } + + /** + * Control Kit's STEP zip for a send (uploaded after the send, contract v1.2 §6): every STEP in its "3D_print" folder is + * filed for the robot under "Season / Robot / Control Kit", named after its part (MANIFEST.txt) and linked to the + * robot's CAD part whose identity the file name carries ("__[__cfg].step"). Files of other + * processes (CNC, 2D cut, fabricate) are not prints and stay only in the zip. Never throws: it reports what it did. + */ + async function fileControlKitStepZip(input: { teamId: number; robotId: number; zip: Buffer; sentBy?: string | null }) { + const report = { filed: 0, replaced: 0, unchanged: 0, skipped: [] as { file: string; reason: string }[] }; + let entries: ZipReadEntry[]; + try { entries = readZip(input.zip, limits.maxFile); } catch (e: any) { report.skipped.push({ file: "(zip)", reason: e?.message || "unreadable" }); return report; } + const manifest = entries.find((e) => /(^|\/)MANIFEST\.txt$/.test(e.name)); + const partNames = new Map(); + if (manifest && manifest.size < 4 * MB) { + for (const line of (await manifest.read().catch(() => Buffer.alloc(0))).toString("utf8").split(/\r?\n/)) { + const cols = line.split(" | "); + if (cols.length >= 2 && /\.(step|stp)$/i.test(cols[0].trim())) partNames.set(cols[0].trim().toLowerCase(), cols[1].trim()); + } + } + const parts = await dbAll("SELECT ck_part_key FROM cad_parts WHERE team_id = ? AND robot_id = ? AND ck_part_key IS NOT NULL AND ck_removed_at IS NULL", input.teamId, input.robotId); + for (const e of entries.filter((x) => /(^|\/)3D_print\/[^/]+\.(step|stp)$/i.test(x.name))) { + const file = e.name.split("/").pop()!; + try { + const bytes = await e.read(); + const ckPartKey = partForStepFile(file, parts.map((p) => String(p.ck_part_key))); + const part = partNames.get(file.toLowerCase()); + const r = await fileControlKitPrint({ teamId: input.teamId, robotId: input.robotId, name: `${safeName(part ?? file.replace(/\.(step|stp)$/i, ""), "part")}.step`, bytes, ckPartKey, sentBy: input.sentBy }); + if (r.unchanged) report.unchanged++; else if (r.replaced) report.replaced++; else report.filed++; + } catch (err: any) { + report.skipped.push({ file, reason: err?.message || "failed" }); + // No room: the rest won't fit either. + if (err instanceof PrintError && (err.code === "quota" || err.code === "disk_full")) break; + } + } + return report; + } + + /** The bytes behind deleted rows (a deleted workspace). */ + async function removeStored(rows: { storage_key?: string | null; thumb_key?: string | null }[]) { + for (const r of rows) { await store.remove(r.storage_key); await store.remove(r.thumb_key); } + } + + return { registerRoutes, filePrint, fileControlKitPrint, fileControlKitStepZip, removeStored, limits, store }; +} + +/** + * The CAD part identity a Control Kit STEP file name points at, among a robot's identities + * ("documentId:elementId:partId:configuration"), or null when none or several fit. The name is Control Kit's + * stepFileName: "__[__cfg<5 base-36 of the configuration's hash>][_n].step". + */ +export function partForStepFile(file: string, identities: string[]): string | null { + const m = /__([A-Za-z0-9]{1,10})(?:__cfg([0-9a-z]{1,5}))?(?:_\d+)?\.(?:step|stp)$/i.exec(file); + if (!m) return null; + const cfgTag = (c: string) => { + if (!c || c === "default") return ""; + let h = 0; + for (const ch of c) h = (h * 31 + ch.charCodeAt(0)) >>> 0; + return h.toString(36).slice(0, 5); + }; + const hits = identities.filter((id) => { + const [, , partId = "", ...cfg] = id.split(":"); + return partId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 10) === m[1] && cfgTag(cfg.join(":")) === (m[2] ?? ""); + }); + return hits.length === 1 ? hits[0] : null; +} + +/** Where Control Kit's prints for a robot are filed: "2025–26 DECODE / Competition robot / Control Kit". */ +export function controlKitPrintPath(season: number, robotName: string): string[] { + return [safeName(seasonName(season)), safeName(robotName, "Robot"), "Control Kit"]; +} + +/** Statements that delete a workspace's library rows; the storage_key/thumb_key rows of the RETURNING one go to removeStored. */ +export const PRINTS_WORKSPACE_DELETE = { + files: "DELETE FROM print_files WHERE team_id = ? RETURNING storage_key, thumb_key", + others: [ + "DELETE FROM print_collection_files WHERE team_id = ?", + "DELETE FROM print_collections WHERE team_id = ?", + "DELETE FROM print_folders WHERE team_id = ?", + "DELETE FROM print_activity WHERE team_id = ?", + ], +}; diff --git a/server/zipStream.ts b/server/zipStream.ts new file mode 100644 index 000000000..a22c574b8 --- /dev/null +++ b/server/zipStream.ts @@ -0,0 +1,168 @@ +// A streaming ZIP writer for the Prints library's folder and group downloads. +// Each entry is deflated straight from disk to the response with a data +// descriptor (sizes and CRC after the data), so a 2 GB folder never sits in +// memory. Names are UTF-8 (flag bit 11). No ZIP64: callers keep the total +// under ZIP_MAX_BYTES. +import { createReadStream } from "node:fs"; +import { createDeflateRaw } from "node:zlib"; +import type { Writable } from "node:stream"; + +/** Classic ZIP offsets are 32-bit; stay well under 4 GiB (deflate can grow incompressible data slightly). */ +export const ZIP_MAX_BYTES = 3.5 * 1024 ** 3; +/** Classic ZIP counts entries in 16 bits. */ +export const ZIP_MAX_ENTRIES = 65_535; + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +export function crc32(buf: Uint8Array, crc = 0): number { + let c = (crc ^ 0xffffffff) >>> 0; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function dosTime(d: Date): { time: number; date: number } { + const y = Math.max(1980, d.getFullYear()); + return { + time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2), + date: ((y - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(), + }; +} + +export interface ZipEntry { + /** Path inside the archive, "/"-separated; every segment already a safe name. */ + name: string; + /** File on disk. */ + path: string; + mtime: Date; +} + +/** Write a ZIP of `entries` to `out`, honouring backpressure. Rejects if `out` closes early or a file can't be read. */ +export async function writeZip(out: Writable, entries: ZipEntry[]): Promise { + if (entries.length > ZIP_MAX_ENTRIES) throw new Error("too many entries for a classic ZIP"); + let offset = 0; + const write = (buf: Buffer) => new Promise((resolve, reject) => { + if (out.destroyed || (out as any).writableEnded) return reject(new Error("closed")); + offset += buf.length; + if (out.write(buf)) return resolve(); + const done = (err?: Error) => { out.off("drain", ok); out.off("close", closed); err ? reject(err) : resolve(); }; + const ok = () => done(); + const closed = () => done(new Error("closed")); + out.once("drain", ok); + out.once("close", closed); + }); + const central: Buffer[] = []; + for (const e of entries) { + const name = Buffer.from(e.name, "utf8"); + const { time, date } = dosTime(e.mtime); + const start = offset; + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0808, 6); // data descriptor + UTF-8 names + local.writeUInt16LE(8, 8); // deflate + local.writeUInt16LE(time, 10); + local.writeUInt16LE(date, 12); + local.writeUInt16LE(name.length, 26); + await write(Buffer.concat([local, name])); + let crc = 0, usize = 0, csize = 0; + const src = createReadStream(e.path); + const deflate = createDeflateRaw({ level: 6 }); + src.on("data", (c: Buffer | string) => { const b = typeof c === "string" ? Buffer.from(c) : c; crc = crc32(b, crc); usize += b.length; }); + src.on("error", (err) => deflate.destroy(err)); + src.pipe(deflate); + try { + for await (const chunk of deflate) { csize += (chunk as Buffer).length; await write(chunk as Buffer); } + } finally { src.destroy(); } + const desc = Buffer.alloc(16); + desc.writeUInt32LE(0x08074b50, 0); + desc.writeUInt32LE(crc, 4); + desc.writeUInt32LE(csize, 8); + desc.writeUInt32LE(usize, 12); + await write(desc); + const c = Buffer.alloc(46); + c.writeUInt32LE(0x02014b50, 0); + c.writeUInt16LE(20, 4); + c.writeUInt16LE(20, 6); + c.writeUInt16LE(0x0808, 8); + c.writeUInt16LE(8, 10); + c.writeUInt16LE(time, 12); + c.writeUInt16LE(date, 14); + c.writeUInt32LE(crc, 16); + c.writeUInt32LE(csize, 20); + c.writeUInt32LE(usize, 24); + c.writeUInt16LE(name.length, 28); + c.writeUInt32LE(start, 42); + central.push(c, name); + } + const cdStart = offset; + const cd = Buffer.concat(central); + await write(cd); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(cdStart, 16); + await write(end); +} + +/** Archive paths made unique ("a.stl", "a (2).stl"), case-insensitively, as every OS will unpack them. */ +export function uniqueZipNames(names: string[]): string[] { + const taken = new Set(); + return names.map((n) => { + let out = n; + const dot = n.lastIndexOf("."); + const slash = n.lastIndexOf("/"); + const [stem, ext] = dot > slash + 1 ? [n.slice(0, dot), n.slice(dot)] : [n, ""]; + for (let i = 2; taken.has(out.toLowerCase()); i++) out = `${stem} (${i})${ext}`; + taken.add(out.toLowerCase()); + return out; + }); +} + +export interface ZipReadEntry { name: string; size: number; read: () => Promise } +/** + * The entries of a classic ZIP (stored or deflated), from its central directory. `read` inflates one entry, refusing + * more than `maxEntryBytes` (a ZIP bomb stops there). Throws on ZIP64, encryption or a damaged directory. + */ +export function readZip(buf: Buffer, maxEntryBytes: number): ZipReadEntry[] { + const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + if (end < 0 || end + 22 > buf.length) throw new Error("not a ZIP file"); + const count = buf.readUInt16LE(end + 10); + let p = buf.readUInt32LE(end + 16); + if (count === 0xffff || p === 0xffffffff) throw new Error("ZIP64 is not supported"); + const out: ZipReadEntry[] = []; + for (let i = 0; i < count; i++) { + if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50) throw new Error("damaged ZIP directory"); + const flags = buf.readUInt16LE(p + 8), method = buf.readUInt16LE(p + 10); + const csize = buf.readUInt32LE(p + 20), usize = buf.readUInt32LE(p + 24); + const nlen = buf.readUInt16LE(p + 28), elen = buf.readUInt16LE(p + 30), clen = buf.readUInt16LE(p + 32); + const local = buf.readUInt32LE(p + 42); + const name = buf.subarray(p + 46, p + 46 + nlen).toString(flags & 0x800 ? "utf8" : "latin1"); + p += 46 + nlen + elen + clen; + if (flags & 1) throw new Error("encrypted ZIP entries are not supported"); + if (name.endsWith("/")) continue; + out.push({ + name, size: usize, + read: async () => { + if (usize > maxEntryBytes) throw new Error("entry too large"); + if (local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50) throw new Error("damaged ZIP entry"); + const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28); + const data = buf.subarray(start, start + csize); + if (method === 0) return Buffer.from(data); + if (method !== 8) throw new Error("unsupported ZIP compression"); + const { inflateRaw } = await import("node:zlib"); + return await new Promise((resolve, reject) => inflateRaw(data, { maxOutputLength: maxEntryBytes }, (e, r) => (e ? reject(e) : resolve(r)))); + }, + }); + } + return out; +} diff --git a/src/App.tsx b/src/App.tsx index dde98db5f..7ad47c8fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo, useRef, Suspense } from 'react'; import { isNavIdDisabled, parseDisabledModules } from './types/teamModules'; import { Routes, Route, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; -import { LayoutDashboard, Users, Crown, Pencil, CalendarCheck, CheckSquare, Wallet, Globe, Newspaper, Mail, X, ChevronRight, ChevronDown, Plus, AtSign, TrendingUp, MessageSquare, Lock, Calendar, UserCheck, Zap, Trash2, Bolt, Code2, Check, ShieldCheck, UserX, Copy, Sparkles, Save, Trophy, Layers, Box, FileBox, ClipboardCheck, ClipboardList, Package, ChevronUp, Phone, Video, ScanLine, Keyboard, BadgeCheck, Loader2, CheckCircle2, Clock3, ListTodo } from 'lucide-react'; +import { LayoutDashboard, Users, Crown, Pencil, CalendarCheck, CheckSquare, Wallet, Globe, Newspaper, Mail, X, ChevronRight, ChevronDown, Plus, AtSign, TrendingUp, MessageSquare, Lock, Calendar, UserCheck, Zap, Trash2, Bolt, Code2, Check, ShieldCheck, UserX, Copy, Sparkles, Save, Trophy, Layers, Box, Printer, FileBox, ClipboardCheck, ClipboardList, Package, ChevronUp, Phone, Video, ScanLine, Keyboard, BadgeCheck, Loader2, CheckCircle2, Clock3, ListTodo } from 'lucide-react'; import { ContextMenuProvider, useContextMenu } from './components/contextmenu/ContextMenuProvider'; import { motion } from 'motion/react'; // Heavy libraries stay out of the initial bundle: recharts (via TaskAnalytics), @@ -35,6 +35,7 @@ const OutreachPage = React.lazy(() => import('./modern/pages/outreach/OutreachPa const ChecklistsPage = React.lazy(() => import('./modern/pages/checklists/ChecklistsPage').then((m) => ({ default: m.ChecklistsPage }))); const ResourcesPage = React.lazy(() => import('./modern/pages/resources/ResourcesPage').then((m) => ({ default: m.ResourcesPage }))); const CadPage = React.lazy(() => import('./modern/pages/cad/CadPage').then((m) => ({ default: m.CadPage }))); +const PrintsPage = React.lazy(() => import('./modern/pages/prints/PrintsPage').then((m) => ({ default: m.PrintsPage }))); /** Lightweight placeholder while a heavy lazy chunk (charts, code editor) loads. */ function ChartLoadingFallback({ label = 'Loading…' }: { label?: string }) { @@ -309,6 +310,7 @@ const navItems = [ { id: 'inventory', path: 'inventory', labelKey: 'nav.inventory', icon: Zap, scope: 'inventory', group: 'Engineering' }, { id: 'checklists', path: 'checklists', labelKey: 'nav.checklists', icon: ClipboardList, group: 'Engineering' }, { id: 'cad', path: 'cad', labelKey: 'nav.cad', icon: Box, group: 'Engineering' }, + { id: 'prints', path: 'prints', labelKey: 'nav.prints', icon: Printer, group: 'Engineering' }, { id: 'code', path: 'code', labelKey: 'nav.code', icon: Code2, scope: 'code', group: 'Engineering' }, { id: 'outreach', path: 'outreach', labelKey: 'nav.outreach', icon: Globe, group: 'Outreach' }, { id: 'budget', path: 'budget', labelKey: 'nav.budget', icon: Wallet, scope: 'budget', group: 'Outreach' }, @@ -1952,7 +1954,7 @@ export default function App() { }; // Students get a focused personal workspace; admins get everything - const studentTabIds = ['dashboard', 'teams', 'stats', 'predict', 'attendance', 'tasks', 'calendar', 'notebook', 'budget', 'inventory', 'outreach', 'comm', 'chat', 'cad', 'cad-docs', 'cad-reviews', 'cad-snapshots', 'cad-parts', 'resources', 'checklists']; + const studentTabIds = ['dashboard', 'teams', 'stats', 'predict', 'attendance', 'tasks', 'calendar', 'notebook', 'budget', 'inventory', 'outreach', 'comm', 'chat', 'cad', 'cad-docs', 'cad-reviews', 'cad-snapshots', 'cad-parts', 'prints', 'resources', 'checklists']; // Modules the workspace switched off (Settings → Workspace → Modules). const disabledModules = useMemo( () => parseDisabledModules(teams.find((t: any) => t.id === currentUser?.team_id)?.disabled_modules), @@ -2386,6 +2388,7 @@ export default function App() { } /> } /> } /> + } /> } /> : } /> } /> diff --git a/src/components/PrintViewer.tsx b/src/components/PrintViewer.tsx new file mode 100644 index 000000000..e4f426bca --- /dev/null +++ b/src/components/PrintViewer.tsx @@ -0,0 +1,133 @@ +// The Prints library's 3D preview: STL, 3MF and OBJ in the browser. Loaded +// with React.lazy, so three.js only downloads when someone opens a preview. +// Drag to orbit, scroll or pinch to zoom; the arrow keys turn the model for +// keyboard users. +import { useEffect, useRef, useState } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { AlertTriangle } from 'lucide-react'; +import { apiFetch } from '../services/api'; +import { addLights, disposeObject, fitCamera, parseModel, type PreviewKind } from './printModel'; + +export default function PrintViewer({ url, kind, name, onLoaded }: { + url: string; + kind: PreviewKind; + name: string; + /** Called once with the file's bytes after it renders (the page draws a missing thumbnail from them). */ + onLoaded?: (buffer: ArrayBuffer) => void; +}) { + const mount = useRef(null); + const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); + const [error, setError] = useState(''); + const [info, setInfo] = useState(''); + const loaded = useRef(onLoaded); + loaded.current = onLoaded; + + useEffect(() => { + const el = mount.current; + if (!el) return; + let cancelled = false; + let raf = 0; + let renderer: THREE.WebGLRenderer | null = null; + let controls: OrbitControls | null = null; + let model: THREE.Object3D | null = null; + let ro: ResizeObserver | null = null; + let onKey: ((e: KeyboardEvent) => void) | null = null; + (async () => { + try { + const res = await apiFetch(url); + if (!res.ok) throw new Error(`Couldn't download the file (HTTP ${res.status}).`); + const buffer = await res.arrayBuffer(); + if (cancelled) return; + const parsed = parseModel(buffer, kind); + if (cancelled) { disposeObject(parsed.group); return; } + model = parsed.group; + const scene = new THREE.Scene(); + addLights(scene); + scene.add(model); + const maxDim = Math.max(parsed.size.x, parsed.size.z, 1); + const grid = new THREE.GridHelper(maxDim * 2.4, 24, 0x4a4a52, 0x2c2c33); + scene.add(grid); + const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 1000); + fitCamera(camera, parsed.size); + renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + el.appendChild(renderer.domElement); + renderer.domElement.setAttribute('aria-hidden', 'true'); + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.target.set(0, parsed.size.y * 0.45, 0); + controls.update(); + const resize = () => { + const w = el.clientWidth || 1, h = el.clientHeight || 1; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer?.setSize(w, h); + }; + resize(); + ro = new ResizeObserver(resize); + ro.observe(el); + onKey = (e: KeyboardEvent) => { + if (!controls || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return; + e.preventDefault(); + const step = Math.PI / 24; + const offset = camera.position.clone().sub(controls.target); + const sph = new THREE.Spherical().setFromVector3(offset); + if (e.key === 'ArrowLeft') sph.theta -= step; + if (e.key === 'ArrowRight') sph.theta += step; + if (e.key === 'ArrowUp') sph.phi = Math.max(0.1, sph.phi - step); + if (e.key === 'ArrowDown') sph.phi = Math.min(Math.PI - 0.1, sph.phi + step); + camera.position.copy(controls.target).add(new THREE.Vector3().setFromSpherical(sph)); + controls.update(); + }; + el.addEventListener('keydown', onKey); + const animate = () => { raf = requestAnimationFrame(animate); controls?.update(); renderer?.render(scene, camera); }; + animate(); + const s = parsed.size; + setInfo(`${parsed.triangles.toLocaleString()} triangles · about ${Math.round(s.x)} × ${Math.round(s.z)} × ${Math.round(s.y)} (file units)`); + setState('ready'); + loaded.current?.(buffer); + } catch (e: any) { + if (cancelled) return; + setError(e?.message || "Couldn't show this model."); + setState('error'); + } + })(); + return () => { + cancelled = true; + cancelAnimationFrame(raf); + ro?.disconnect(); + if (onKey) el.removeEventListener('keydown', onKey); + controls?.dispose(); + if (model) disposeObject(model); + if (renderer) { renderer.dispose(); renderer.domElement.remove(); } + }; + }, [url, kind]); + + return ( +
+
+ {state === 'loading' && ( +
+
+ Loading the model… +
+ )} + {state === 'error' && ( +
+ +

Couldn't show this model

+

{error}

+
+ )} +
+

{state === 'ready' ? `${info} · Drag to turn, scroll or pinch to zoom` : ''}

+
+ ); +} diff --git a/src/components/printModel.ts b/src/components/printModel.ts new file mode 100644 index 000000000..6f370efb4 --- /dev/null +++ b/src/components/printModel.ts @@ -0,0 +1,102 @@ +// 3D print models in the browser: parse STL, 3MF and OBJ with three.js's own +// loaders, and draw a small thumbnail for grid tiles. Only ever loaded with a +// dynamic import (the viewer is React.lazy, thumbnails import this on demand), +// so three.js stays out of the main bundle, as for CadModelViewer. +import * as THREE from 'three'; +import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; +import { ThreeMFLoader } from 'three/examples/jsm/loaders/3MFLoader.js'; +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; + +export type PreviewKind = 'stl' | '3mf' | 'obj'; + +const material = () => new THREE.MeshStandardMaterial({ color: 0xc9ccd2, metalness: 0.1, roughness: 0.55, side: THREE.DoubleSide }); + +/** The model as one group, centred on the origin, resting on y = 0, with its triangle count. */ +export function parseModel(buffer: ArrayBuffer, kind: PreviewKind): { group: THREE.Group; triangles: number; size: THREE.Vector3 } { + const group = new THREE.Group(); + if (kind === 'stl') { + const geo = new STLLoader().parse(buffer); + geo.computeVertexNormals(); + group.add(new THREE.Mesh(geo, material())); + } else if (kind === '3mf') { + group.add(new ThreeMFLoader().parse(buffer)); + } else { + group.add(new OBJLoader().parse(new TextDecoder().decode(buffer))); + } + let triangles = 0; + group.traverse((o: any) => { + if (!o.isMesh) return; + // One look for every format; 3MF colours are kept. + if (kind !== '3mf') o.material = material(); + const g = o.geometry as THREE.BufferGeometry; + if (!g.attributes.normal) g.computeVertexNormals(); + triangles += g.index ? g.index.count / 3 : (g.attributes.position?.count ?? 0) / 3; + }); + // Print files are Z-up; the viewer is Y-up. + if (kind !== 'obj') group.rotation.x = -Math.PI / 2; + group.updateMatrixWorld(true); + const box = new THREE.Box3().setFromObject(group); + if (box.isEmpty()) throw new Error('This file has no printable geometry.'); + const center = box.getCenter(new THREE.Vector3()); + const outer = new THREE.Group(); + outer.add(group); + group.position.set(-center.x, -box.min.y, -center.z); + return { group: outer, triangles: Math.round(triangles), size: box.getSize(new THREE.Vector3()) }; +} + +/** A camera looking at the model from the front-right, above, fitting it in view. */ +export function fitCamera(camera: THREE.PerspectiveCamera, size: THREE.Vector3) { + const maxDim = Math.max(size.x, size.y, size.z, 1e-3); + const dist = (maxDim * 0.9) / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)); + camera.position.set(dist * 0.7, size.y * 0.5 + dist * 0.55, dist * 0.85); + camera.near = dist / 1000; + camera.far = dist * 50; + camera.lookAt(0, size.y * 0.45, 0); + camera.updateProjectionMatrix(); +} + +export function addLights(scene: THREE.Scene) { + scene.add(new THREE.HemisphereLight(0xffffff, 0x2a2a30, 1.2)); + const key = new THREE.DirectionalLight(0xffffff, 1.6); + key.position.set(3, 5, 4); + scene.add(key); + const rim = new THREE.DirectionalLight(0xffc700, 0.35); + rim.position.set(-4, 2, -3); + scene.add(rim); +} + +export function disposeObject(o: THREE.Object3D) { + o.traverse((x: any) => { + x.geometry?.dispose?.(); + (Array.isArray(x.material) ? x.material : [x.material]).forEach((m: any) => m?.dispose?.()); + }); +} + +/** A 256 × 256 transparent PNG of the model, or null when the browser can't draw it (no WebGL, bad file). */ +export async function drawThumbnail(buffer: ArrayBuffer, kind: PreviewKind, px = 256): Promise { + let renderer: THREE.WebGLRenderer | null = null; + let model: THREE.Object3D | null = null; + try { + const canvas = document.createElement('canvas'); + canvas.width = px; + canvas.height = px; + renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true }); + renderer.setPixelRatio(1); + renderer.setSize(px, px, false); + const scene = new THREE.Scene(); + addLights(scene); + const parsed = parseModel(buffer, kind); + model = parsed.group; + scene.add(model); + const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 1000); + fitCamera(camera, parsed.size); + renderer.render(scene, camera); + return await new Promise((resolve) => canvas.toBlob((b) => resolve(b), 'image/png')); + } catch { + return null; + } finally { + if (model) disposeObject(model); + renderer?.dispose(); + renderer?.forceContextLoss(); + } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 47ee68283..457f593c8 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -26,6 +26,7 @@ const resources = { designReviews: 'Design Reviews', snapshots: 'Snapshots', partsList: 'Parts List', + prints: 'Prints', code: 'Code', outreach: 'Outreach', budget: 'Budget', diff --git a/src/modern/__tests__/prints.test.tsx b/src/modern/__tests__/prints.test.tsx new file mode 100644 index 000000000..2861c2eed --- /dev/null +++ b/src/modern/__tests__/prints.test.tsx @@ -0,0 +1,231 @@ +// The Prints explorer: folders and groups together, combined group filters +// with counts, search, selection actions (mark printed, move, download), the +// folder delete question with its file count, drag onto a collection, keyboard +// delete, and the read-only view for members without manage_prints. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within, configure } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +const api = vi.hoisted(() => ({ apiFetch: vi.fn() })); +const dlg = vi.hoisted(() => ({ confirmDialog: vi.fn(), notify: vi.fn() })); +vi.mock('../../services/api', async (orig) => ({ ...(await orig()), ...api })); +vi.mock('../../components/dialog', async (orig) => ({ ...(await orig()), ...dlg })); +vi.mock('../../components/PrintViewer', () => ({ default: ({ name }: any) =>
})); + +import { PrintsPage } from '../pages/prints/PrintsPage'; +import type { PrintFile, PrintLibrary } from '../../types/prints'; + +// The page and its lazy chunks take a few seconds to settle in jsdom on a busy machine. +configure({ asyncUtilTimeout: 8000 }); +globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as any; +const res = (body: any, ok = true, status = ok ? 200 : 400) => Promise.resolve({ ok, status, json: async () => body }); + +const file = (id: number, name: string, over: Partial = {}): PrintFile => ({ + id, folderId: null, name, kind: 'stl', size: 2048 * id, createdAt: '2026-10-20T10:00:00Z', updatedAt: `2026-10-2${id % 8}T10:00:00Z`, uploadedBy: 'Ada', notes: '', + robotId: null, season: null, cadPart: null, subsystem: null, subsystemFrom: null, material: null, status: 'to_print', copiesNeeded: null, copiesNeededFrom: null, + copiesPrinted: 0, collections: [], hasThumb: false, source: 'upload', ...over, +}); +let lib: PrintLibrary; +const seed = (): PrintLibrary => ({ + teamId: 1, + canEdit: true, + usage: { used: 5 * 1048576, quota: 2048 * 1048576, maxFile: 100 * 1048576 }, + folders: [ + { id: 1, parentId: null, name: '2025–26 DECODE', createdAt: '', updatedAt: '2026-10-20T10:00:00Z' }, + { id: 2, parentId: 1, name: 'Intake', createdAt: '', updatedAt: '2026-10-20T10:00:00Z' }, + ], + files: [ + file(1, 'Roller hub.stl', { folderId: 2, subsystem: 'Intake', subsystemFrom: 'cad', material: 'PETG', copiesNeeded: 4, copiesNeededFrom: 'cad', robotId: 1, season: 2025, collections: [1] }), + file(2, 'Roller spacer.stl', { folderId: 2, subsystem: 'Intake', material: 'TPU', status: 'printed', robotId: 1, season: 2025 }), + file(3, 'Lift spool.3mf', { kind: '3mf', folderId: 1, subsystem: 'Lift', material: 'PETG', status: 'failed', robotId: 1, season: 2025 }), + file(4, 'Camera mount.step', { kind: 'step' }), + file(5, 'Plate.gcode', { kind: 'gcode', material: 'PLA', status: 'printing' }), + ], + collections: [{ id: 1, name: 'Comp spares', files: 1 }, { id: 2, name: 'Worlds kit', files: 0 }], + robots: [{ id: 1, name: 'MR. HORS', season: 2025, seasonName: '2025–26 DECODE' }], + subsystems: ['Drivetrain', 'Intake', 'Lift'], +}); +const calls = () => api.apiFetch.mock.calls.filter(([, init]) => init?.method && init.method !== 'GET').map(([url, init]) => ({ url, method: init.method, body: init.body && typeof init.body === 'string' ? JSON.parse(init.body) : init.body })); + +beforeEach(() => { + lib = seed(); + api.apiFetch.mockReset(); + api.apiFetch.mockImplementation((url: string, init?: any) => { + if (url === '/api/prints/library') return res(lib); + if (url.startsWith('/api/prints/activity')) return res([]); + if (url === '/api/prints/zip') return res({ url: '/api/prints/zip?ticket=t1' }); + if (url.startsWith('/api/cad/robots/')) return res({ parts: [] }); + return res({ success: true, files: 1 }); + }); + dlg.confirmDialog.mockReset().mockResolvedValue(true); + dlg.notify.mockReset(); + try { localStorage.clear(); } catch { /* none */ } +}); +afterEach(cleanup); + +let where = ''; +function Where() { const l = useLocation(); where = l.pathname + l.search; return null; } +const setup = (url = '/prints') => render( + + } /> + , +); +const sidebar = () => screen.getAllByRole('navigation', { name: 'Folders and groups' })[0]; +const contents = () => screen.getByRole('list', { name: /^Contents of/ }); +/** A file's or folder's own button in the list (not its options button). */ +const item = (name: string) => within(contents()).getAllByRole('button').find((b) => !b.getAttribute('aria-label') && (b.textContent ?? '').startsWith(name))!; + +describe('Prints explorer', () => { + it('opens on the top level: folders, loose files, the path and the counts', async () => { + setup(); + expect(await screen.findByRole('heading', { name: /All prints · 2 files · 1 still to print/ })).toBeTruthy(); + const list = contents(); + expect(item('2025–26 DECODE')).toBeTruthy(); + expect(item('Camera mount.step')).toBeTruthy(); + expect(within(list).queryByRole('button', { name: /Roller hub/ })).toBeNull(); + fireEvent.click(item('2025–26 DECODE')); + expect(where).toBe('/prints?folder=1'); + expect(await screen.findByRole('heading', { name: /2025–26 DECODE · 1 file · 1 still to print/ })).toBeTruthy(); + const path = screen.getByRole('navigation', { name: 'Folder path' }); + expect(within(path).getByRole('button', { name: '2025–26 DECODE' }).getAttribute('aria-current')).toBe('page'); + }); + + it('combines groups from the sidebar: Intake + PETG + To print', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + const nav = sidebar(); + fireEvent.click(within(nav).getByRole('button', { name: /^Intake:/ })); + expect(await screen.findByRole('heading', { name: /Intake · 2 files · 1 still to print/ })).toBeTruthy(); + fireEvent.click(within(nav).getByRole('button', { name: /^PETG:/ })); + fireEvent.click(within(nav).getByRole('button', { name: /^To print:/ })); + expect(await screen.findByRole('heading', { name: /Intake \+ PETG \+ To print · 1 file · 1 still to print/ })).toBeTruthy(); + expect(where).toContain('sub=Intake'); + expect(where).toContain('mat=PETG'); + expect(where).toContain('status=to_print'); + // The file shows where it lives. + expect(within(contents()).getByRole('button', { name: /Roller hub\.stl.*2025–26 DECODE \/ Intake/ })).toBeTruthy(); + // A chip removes one group. + fireEvent.click(screen.getByRole('button', { name: 'Remove PETG' })); + expect(await screen.findByRole('heading', { name: /Intake \+ To print · 1 file/ })).toBeTruthy(); + }); + + it('searches the whole library', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search the library' }), { target: { value: 'roller' } }); + expect(await screen.findByRole('heading', { name: /Search “roller” · 2 files/ })).toBeTruthy(); + }); + + it('marks a selection printed, and downloads a group as one ZIP', async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + setup('/prints?sub=Intake'); + await screen.findByRole('heading', { name: /Intake · 2 files/ }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Roller hub.stl' })); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Roller spacer.stl' })); + const bar = screen.getByRole('toolbar', { name: 'Selected files' }); + expect(within(bar).getByText('2 selected')).toBeTruthy(); + fireEvent.click(within(bar).getByRole('button', { name: 'Printed' })); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/bulk', method: 'POST', body: { ids: [1, 2], action: 'status', status: 'printed' } })); + fireEvent.click(screen.getByRole('button', { name: 'Download all' })); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/zip', method: 'POST', body: { ids: [1, 2], name: 'Intake' } })); + await waitFor(() => expect(click).toHaveBeenCalled()); + const a = click.mock.instances[0] as unknown as HTMLAnchorElement; + expect(a.getAttribute('href')).toBe('/api/prints/zip?ticket=t1'); + click.mockRestore(); + }); + + it('moves a file with the Move to picker', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Camera mount.step' })); + fireEvent.click(within(screen.getByRole('toolbar', { name: 'Selected files' })).getByRole('button', { name: /Move to/ })); + const dialog = await screen.findByRole('dialog', { name: /Move/ }); + fireEvent.click(within(dialog).getByRole('radio', { name: /^Intake/ })); + fireEvent.click(within(dialog).getByRole('button', { name: /Move here/ })); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/bulk', method: 'POST', body: { ids: [4], action: 'move', folderId: 2 } })); + }); + + it('asks before deleting a folder that holds files, and says how many', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + fireEvent.pointerDown(within(contents()).getByRole('button', { name: '2025–26 DECODE options' }), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByRole('menuitem', { name: /Delete/ })); + await waitFor(() => expect(dlg.confirmDialog).toHaveBeenCalled()); + const ask = dlg.confirmDialog.mock.calls[0][0]; + expect(ask.message).toContain('3 files'); + expect(ask.message).toContain('1 folder inside it'); + expect(ask.confirmLabel).toBe('Delete folder and 3 files'); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/folders/1?files=3', method: 'DELETE', body: undefined })); + }); + + it('adds dragged files to a collection dropped on in the sidebar', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + const store: Record = {}; + const dataTransfer = { types: [] as string[], setData: (k: string, v: string) => { store[k] = v; dataTransfer.types = Object.keys(store); }, getData: (k: string) => store[k] ?? '', files: [] as File[], effectAllowed: '', dropEffect: '' }; + const row = item('Plate.gcode').closest('li')!; + fireEvent.dragStart(row, { dataTransfer }); + const target = within(sidebar()).getByRole('button', { name: /^Worlds kit:/ }).closest('li')!; + fireEvent.dragOver(target, { dataTransfer }); + fireEvent.drop(target, { dataTransfer }); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/bulk', method: 'POST', body: { ids: [5], action: 'collect', collectionId: 2 } })); + }); + + it('deletes the focused file with the Delete key, after asking', async () => { + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + const name = item('Camera mount.step'); + name.focus(); + fireEvent.keyDown(name, { key: 'Delete' }); + await waitFor(() => expect(dlg.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ title: 'Delete Camera mount.step?' }))); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/bulk', method: 'POST', body: { ids: [4], action: 'delete' } })); + }); + + it('opens details with the CAD-derived subsystem and copies', async () => { + setup('/prints?folder=2'); + await screen.findByRole('heading', { name: /Intake ·/ }); + fireEvent.click(item('Roller hub.stl')); + const sheet = await screen.findByRole('dialog', { name: 'Roller hub.stl' }); + expect(within(sheet).getByText(/The robot needs 4, from the CAD part's quantity/)).toBeTruthy(); + expect(within(sheet).getByText(/Subsystem Intake comes from the CAD part's section/)).toBeTruthy(); + expect(within(sheet).getByRole('button', { name: 'Comp spares' }).getAttribute('aria-pressed')).toBe('true'); + fireEvent.click(within(sheet).getByRole('radio', { name: 'Printing' })); + await waitFor(() => expect(calls()).toContainEqual({ url: '/api/prints/files/1', method: 'PATCH', body: { status: 'printing' } })); + }); + + it('counts quick clicks on the copies stepper and saves them once', async () => { + setup('/prints?folder=2'); + await screen.findByRole('heading', { name: /Intake ·/ }); + fireEvent.click(item('Roller hub.stl')); + const sheet = await screen.findByRole('dialog', { name: 'Roller hub.stl' }); + const more = within(sheet).getByRole('button', { name: 'One more printed' }); + fireEvent.click(more); fireEvent.click(more); fireEvent.click(more); + expect(within(sheet).getByText('3')).toBeTruthy(); + await waitFor(() => expect(calls().filter((c) => c.url === '/api/prints/files/1')).toEqual([{ url: '/api/prints/files/1', method: 'PATCH', body: { copiesPrinted: 3 } }])); + }); + + it('drops a waiting count when the file is marked printed', async () => { + setup('/prints?folder=2'); + await screen.findByRole('heading', { name: /Intake ·/ }); + fireEvent.click(item('Roller hub.stl')); + const sheet = await screen.findByRole('dialog', { name: 'Roller hub.stl' }); + fireEvent.click(within(sheet).getByRole('button', { name: 'One more printed' })); + fireEvent.click(within(sheet).getByRole('radio', { name: 'Printed' })); + await waitFor(() => expect(calls().some((c) => c.url === '/api/prints/files/1')).toBe(true)); + await new Promise((r) => setTimeout(r, 700)); + expect(calls().filter((c) => c.url === '/api/prints/files/1')).toEqual([{ url: '/api/prints/files/1', method: 'PATCH', body: { status: 'printed' } }]); + }); + + it('is read-only for members without manage_prints', async () => { + lib = { ...seed(), canEdit: false }; + api.apiFetch.mockImplementation((url: string) => (url === '/api/prints/library' ? res(lib) : res([]))); + setup(); + await screen.findByRole('heading', { name: /All prints/ }); + expect(screen.queryByRole('button', { name: /Upload/ })).toBeNull(); + expect(screen.queryByRole('button', { name: 'New collection' })).toBeNull(); + fireEvent.click(item('Camera mount.step')); + const sheet = await screen.findByRole('dialog', { name: 'Camera mount.step' }); + expect(within(sheet).queryByRole('button', { name: /Delete/ })).toBeNull(); + expect(within(sheet).getByRole('button', { name: /Download/ })).toBeTruthy(); + }); +}); diff --git a/src/modern/nav.ts b/src/modern/nav.ts index fcbc256f9..33eb96bc3 100644 --- a/src/modern/nav.ts +++ b/src/modern/nav.ts @@ -34,7 +34,7 @@ export interface ModernNavSection { const SECTION_OF: Record = { teams: 'team', attendance: 'team', calendar: 'team', tasks: 'team', notebook: 'team', stats: 'compete', predict: 'compete', - cad: 'build', code: 'build', inventory: 'build', + cad: 'build', prints: 'build', code: 'build', inventory: 'build', outreach: 'ops', comm: 'ops', budget: 'ops', resources: 'ops', checklists: 'build', owner: 'admin', }; @@ -63,7 +63,7 @@ export function buildModernNav(visible: NavItemLike[]): { primary: ModernNavItem (buckets[sec] ??= []).push(toItem(t)); } // Keep a stable, intentional order inside each section. - const ORDER = ['teams', 'roles', 'attendance', 'calendar', 'tasks', 'notebook', 'stats', 'predict', 'cad', 'code', 'inventory', 'checklists', 'outreach', 'comm', 'budget', 'resources', 'owner']; + const ORDER = ['teams', 'roles', 'attendance', 'calendar', 'tasks', 'notebook', 'stats', 'predict', 'cad', 'prints', 'code', 'inventory', 'checklists', 'outreach', 'comm', 'budget', 'resources', 'owner']; const sections = SECTION_ORDER .filter((id) => buckets[id]?.length) .map((id) => ({ diff --git a/src/modern/pages/prints/PrintDetails.tsx b/src/modern/pages/prints/PrintDetails.tsx new file mode 100644 index 000000000..1e4b1ee94 --- /dev/null +++ b/src/modern/pages/prints/PrintDetails.tsx @@ -0,0 +1,258 @@ +// One print file's details, in a side sheet: what it is, where it lives, who +// added it, and the details its smart groups come from (robot and season, CAD +// part, subsystem, material, status, copies), plus its collections, notes and +// history. People without manage_prints see the same details read-only. +import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Box, Download, ExternalLink, FolderInput, Minus, Pencil, Plus, Trash2 } from 'lucide-react'; +import { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Textarea } from '../../../components/ui-kit'; +import { cn } from '../../../components/cn'; +import { apiFetch } from '../../../services/api'; +import { fmtDate } from '../../../components/cad/useCad'; +import { currentSeason, seasonName } from '../../../types/cadBom'; +import { + KIND_LABELS, PRINT_MATERIALS, PRINT_STATUSES, STATUS_LABELS, canPreview, folderTrail, formatBytes, type PrintFile, type PrintLibrary, +} from '../../../types/prints'; +import { FileArt, STATUS_DOT, copiesText } from './printsUi'; +import { download, fileUrl, type Prints } from './usePrints'; + +const field = 'h-10 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:opacity-60'; +const ACTION_TEXT: Record = { + upload: 'added it', replace: 'sent a new version', rename: 'renamed it', move: 'moved it', edit: 'changed its details', status: 'changed its status', + collect: 'added it to a collection', uncollect: 'took it out of a collection', delete: 'deleted it', +}; + +function Row({ label, children, htmlFor }: { label: string; children: ReactNode; htmlFor?: string }) { + return ( +
+ {htmlFor ? : {label}} + {children} +
+ ); +} + +export function PrintDetails({ file, lib, prints, onClose, onPreview, onRename, onMove, onDelete, onError }: { + file: PrintFile; lib: PrintLibrary; prints: Prints; onClose: () => void; + onPreview: () => void; onRename: () => void; onMove: () => void; onDelete: () => void; onError: (message: string) => void; +}) { + const id = useId(); + const edit = lib.canEdit; + const [notes, setNotes] = useState(file.notes); + // Copies printed as clicked: shown at once and saved once the clicks stop (each click reading a stale count lost copies). + const [copies, setCopies] = useState(file.copiesPrinted); + const copiesTimer = useRef | null>(null); + const pendingCopies = useRef(null); + useEffect(() => { if (pendingCopies.current === null) setCopies(file.copiesPrinted); }, [file.id, file.copiesPrinted]); + const stepCopies = (delta: number) => { + const next = Math.max(0, Math.min(999, (pendingCopies.current ?? copies) + delta)); + pendingCopies.current = next; + setCopies(next); + if (copiesTimer.current) clearTimeout(copiesTimer.current); + copiesTimer.current = setTimeout(() => { + const value = pendingCopies.current; + copiesTimer.current = null; + if (value === null) return; + void save({ copiesPrinted: value }).finally(() => { if (pendingCopies.current === value) pendingCopies.current = null; }); + }, 500); + }; + // A count still waiting when the sheet closes is saved then. + useEffect(() => () => { + if (copiesTimer.current) { clearTimeout(copiesTimer.current); if (pendingCopies.current !== null) void prints.updateFile(file.id, { copiesPrinted: pendingCopies.current }).catch(() => {}); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [file.id]); + const [parts, setParts] = useState<{ id: number; name: string; section: string; quantity: number; group: string }[] | null>(null); + const [history, setHistory] = useState<{ id: number; by: string; action: string; detail: any; at: string }[] | null>(null); + useEffect(() => { setNotes(file.notes); }, [file.id, file.notes]); + // The linked robot's parts, for the CAD part picker (made parts first). + useEffect(() => { + setParts(null); + if (file.robotId == null) return; + let live = true; + apiFetch(`/api/cad/robots/${file.robotId}`).then((r) => (r.ok ? r.json() : null)).then((d) => { + if (!live || !d) return; + const list = (d.parts as any[]).filter((p) => !p.ck_removed_at).map((p) => ({ id: Number(p.id), name: String(p.name), section: String(p.section || ''), quantity: Number(p.quantity) || 0, group: String(p.effective?.group || '') })); + list.sort((a, b) => Number(b.group === 'make') - Number(a.group === 'make') || a.name.localeCompare(b.name)); + setParts(list); + }).catch(() => {}); + return () => { live = false; }; + }, [file.robotId]); + useEffect(() => { + let live = true; + setHistory(null); + prints.activity(file.id).then((h) => { if (live) setHistory(h); }).catch(() => { if (live) setHistory([]); }); + return () => { live = false; }; + // Reload the history when the file changes (updatedAt moves on every save). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [file.id, file.updatedAt]); + + const save = async (patch: Record) => { + // A count still waiting goes with this save, or is dropped when this marks the file printed (which fills the count). + if (copiesTimer.current) { + clearTimeout(copiesTimer.current); + copiesTimer.current = null; + if (pendingCopies.current !== null && patch.status !== 'printed' && patch.copiesPrinted === undefined) patch = { ...patch, copiesPrinted: pendingCopies.current }; + pendingCopies.current = null; + } + try { await prints.updateFile(file.id, patch); } catch (e: any) { onError(e?.message || "Couldn't save that."); } + }; + const where = folderTrail(lib.folders, file.folderId).map((f) => f.name); + const seasons = useMemo(() => { + const now = currentSeason(); + return [...new Set([now + 1, now, now - 1, now - 2, now - 3, ...lib.robots.map((r) => r.season), ...(file.season != null ? [file.season] : [])])].sort((a, b) => b - a); + }, [lib.robots, file.season]); + const subsystemOptions = useMemo(() => [...new Set([...lib.subsystems, ...lib.files.map((f) => f.subsystem).filter(Boolean) as string[]])], [lib]); + const copiesLabel = copiesText(file); + + return ( + { if (!o) onClose(); }}> + + + {file.name} + {KIND_LABELS[file.kind]} · {formatBytes(file.size)} · {where.length ? where.join(' / ') : 'Top level'} + + +
+ + {canPreview(file.kind) && ( + + )} +
+ +
+ + {edit && } + {edit && } + {edit && } +
+ + + {edit ? ( +
+ {PRINT_STATUSES.map((s) => ( + + ))} +
+ ) :

{STATUS_LABELS[file.status]}

} +
+ + +
+ {edit ? ( + <> + + {copies} + + printed of + { + const v = e.target.value.trim(); + const next = v === '' ? null : Number(v); + if (next !== (file.copiesNeededFrom === 'manual' ? file.copiesNeeded : null)) void save({ copiesNeeded: next }); + }} /> + needed + + ) : {copiesLabel ?? "Not counted"}} +
+ {file.copiesNeededFrom === 'cad' &&

The robot needs {file.copiesNeeded}, from the CAD part's quantity.

} +
+ + + + + {file.robotId == null && ( + + + + )} + {file.robotId != null && ( + + + {file.cadPart && ( + + Open the robot's BOM + + )} + + )} +
+ + { const v = e.target.value.trim(); if (v !== (file.subsystemFrom === 'manual' ? file.subsystem ?? '' : '')) void save({ subsystem: v }); }} /> + {subsystemOptions.map((s) => + + + { const v = e.target.value.trim(); if (v !== (file.material ?? '')) void save({ material: v }); }} /> + {PRINT_MATERIALS.map((m) => + +
+ {file.subsystemFrom === 'cad' &&

Subsystem {file.subsystem} comes from the CAD part's section. Type one to set it here.

} + + + {lib.collections.length === 0 ?

No collections yet. Make one in the sidebar.

: ( +
    + {lib.collections.map((c) => { + const on = file.collections.includes(c.id); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + +