8.5 KiB
PrintStash API contract
Node.js + Express + Prisma (Postgres) backend, ported from MakersVault's FastAPI/SQLModel/SQLite backend, with one structural change: a Print is a set of one or more Plates (multi-part prints) instead of a single file. Everything else behaves the same as MakersVault.
Base path: no prefix, ever — routes are mounted directly (/health, /prints, /login, ...).
In production the backend also serves the built frontend as static files from the same origin
(see backend/src/app.ts's express.static(FRONTEND_DIST)), so the UI calls these routes with
plain relative paths and no reverse proxy is involved.
Auth: identical scheme to MakersVault — single-user JWT (HS256). AUTH_USERNAME/AUTH_PASSWORD/
AUTH_SECRET/AUTH_TOKEN_TTL env vars. AUTH_ENABLED = Boolean(username && password). Every
route (except /health) runs a requireAuth middleware that accepts either an Authorization: Bearer <token> header or a ?token= query param, and is a no-op when auth is disabled.
Types
type PreparedPrint = {
printer?: string | null;
material?: string | null;
nozzle_mm?: number | null;
layer_height_mm?: number | null;
estimated_seconds?: number | null;
format?: string | null; // "gcode" | "bgcode" | "gcode_3mf"
removable: boolean;
};
type Plate = {
id: string;
print_id: string;
position: number; // dense, 0-based, ordered
filename: string;
mime: string;
size: number;
url: string; // /print/{printId}/plate/{plateId}/file/{filename}
thumb_url?: string | null; // /plate/{plateId}/thumb.jpg?v=... if a thumb exists
};
type PrintFile = { // supporting doc or prepared-print attachment (print-level, not per-plate)
id: string;
filename: string;
mime: string;
size: number;
url: string; // /print/{printId}/files/{fileId}
};
type Print = {
id: string;
name: string;
title?: string | null;
notes?: string | null;
creator?: string | null;
collection?: string | null;
tags: string[];
folder_id?: string | null;
storage_path?: string | null; // parent directory shared by all plates
plates: Plate[]; // ordered by position, length >= 1
thumb_url?: string | null; // denormalized = plates[0].thumb_url, for card grids
supporting_file_count: number;
prepared_print?: PreparedPrint | null;
slicer_url?: string | null; // prepared file if present, else plates[0].url
slicer_filename?: string | null;
};
type Folder = { id: string; name: string; tags: string[]; parent_id?: string | null };
Routes
Mechanical renames from MakersVault (identical request/response shape, asset → print):
GET /health->{ ok, auth_required }GET/POST /settings/mount-importGET/POST /settings/storage(sample path now returns aplate_paths: string[]array of 2 example rendered paths instead of a singlesample_pathstring, to show that sibling plates share the{model}directory)POST /login,POST /refreshGET/POST /print/:id/files,GET/DELETE /print/:id/files/:fileId(supporting files, unchanged semantics)GET/DELETE /print/:id/prepared-printPOST /print/:id/tags,POST /print/:id/meta,DELETE /print/:id,POST /print/:id/folderGET/POST /folders,PATCH /folder/:id,DELETE /folder/:id,GET /folder/:id/downloadPOST /import,POST /import/inspect,POST /import/zip/entries,POST /import/zip— URL and zip-extraction imports always create a single-plate print per item (the separate/multiplate choice below only applies to the direct upload picker/drag-drop).
Shape changes / new routes (plate-aware):
POST /upload— multipart, multiplefilesfields accepted. Additional fieldmode: "separate" | "multiplate"(required only when more than one file is sent; ignored for a single file). Always responds{ prints: Print[] }.mode=separate(or single file): each file becomes its own single-platePrint.mode=multiplate: all files becomePlates (in submitted order) of one newPrint.
POST /print/:id/plates— multipart, add one or more plates to an existing print (used by "drop a file onto an existing print card" and the print detail view's "Add plate" action). Appends atposition = max(position)+1. Responds{ print: Print }.DELETE /print/:id/plates/:plateId— remove a plate; 409 if it is the only remaining plate (delete the whole print instead). Renumbers remaining plates densely. Responds{ print: Print }.POST /print/:id/plates/reorder— body{ plate_ids: string[] }(full ordered list), rewritespositiontransactionally. Responds{ print: Print }.POST /print/:id/plate/:plateId/rename— body{ filename: string }. Responds{ print: Print }.GET /print/:id/plate/:plateId/file/:filename— streams the plate's file (replacesGET /file/{asset_id}/{name}).GET /print/:id/thumb.jpg— the print card thumbnail (= plate[0]'s thumbnail).GET /plate/:plateId/thumb.jpg— a specific plate's thumbnail (for the plate switcher UI).POST /plate/:plateId/thumbnail-generated— client-rendered PNG/JPEG/WebP snapshot for one plate (replacesPOST /asset/{id}/thumbnail-generated), same 8MB cap.GET /prints(wasGET /assets) — same filters (q,tags,folder_id) and pagination (limit,offset,X-Has-More/X-Next-Offsetheaders); sort key becomesname, plates[0].filename, id.GET /tags(wasGET /tags) — same filters, implemented viaunnest(tags)over the filteredPrintset.POST /download/zip— body{ print_ids?: string[], tag?: string, folder_id?: string, filename?: string }(was{ asset_ids, tag, folder_id, filename }).POST /import/zipresponse —{ prints: Print[], failed: string[] }(was{ assets, failed }).POST /download/zip,GET /folder/:id/download— zip arcname becomes{folder_or_unassigned}/{print.name}/{plate.filename}for every plate, and{folder_or_unassigned}/{print.name}/supporting/{file.filename}for supporting files. The extra{print.name}directory level (new vs. MakersVault) is required so sibling plates from different prints, or same-named plates across prints, can't collide in the zip.
Retired: POST /asset/{id}/rename (renaming "the file" doesn't map onto a print with no single
filename) — replaced by POST /print/:id/plate/:plateId/rename for plate filenames, while
Print.name (the folder-unique display name) renames via the existing POST /print/:id/meta.
Storage path templating
Same template contract as MakersVault (validate_storage_template, default
{folder}/{model}/{filename}, tokens folder,collection,tags,creator,model,name,filename,id,
{filename} required exactly once in the final path segment). Rendered once per Plate:
{model}/{name}=Print.name(shared by every plate of a print — this is what makes all plates land in the same{folder}/{model}/directory under the default template).{filename}= the individualPlate.filename.{folder},{collection},{tags},{creator}= pulled from the parentPrint.{id}= the owningPrint.id(not a plate id).- New optional token
{plate}= 1-basedPlate.position + 1, for templates that want an explicit per-plate disambiguator. Not needed by the default template.
Filename collisions between two plates of the same print (e.g. two uploads both named
body.stl) are resolved by an availablePlateFilename(printId, dirTokens, desiredFilename)
helper that auto-suffixes (body.stl -> body-2.stl), mirroring the existing per-folder
unique_model_name/available_model_name dedup pattern. PrintFiles (supporting/prepared) are
never template-rendered; they always live at STORAGE/bundles/{printId}/{fileId}/{filename}.
Behavioral notes carried over unchanged
- Folder-scoped
Print.nameuniqueness (case-insensitive), including at the root — enforced vianameNormalized+ the composite unique index plus a partial unique index forfolderId IS NULL(seeschema.prismamigration comment). - Prepared-print detection/metadata sniffing (
prepared_print.pylogic) stays print-level: a sliced job output covers the whole build plate across all plates, so it is not tied to a singlePlate. - Mount-import volume scanning, zip-import wizard, MakerWorld/Printables/Thingiverse link import,
and settings (mount-import, storage template) all behave the same; each imported/extracted item
is a single-plate
Print(no multiplate prompt in these flows). - SSRF guard on outbound import URLs, filename sanitization, size limits — unchanged.