- PHP 55.2%
- Vue 35.6%
- Python 6.1%
- Shell 1%
- JavaScript 0.8%
- Other 1.2%
| app | ||
| bootstrap | ||
| config | ||
| database | ||
| deploy | ||
| driver-runner | ||
| lang | ||
| public | ||
| render-worker | ||
| resources | ||
| routes | ||
| slicer-worker | ||
| storage | ||
| tests | ||
| .editorconfig | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| .npmrc | ||
| artisan | ||
| composer.json | ||
| composer.lock | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| phpunit.xml | ||
| README.de.md | ||
| README.md | ||
| vite.config.js | ||
3D Vault
3D Vault is a self-hosted, open-source web application (GPL-3.0-or-later, see LICENSE) that turns a grown, unsorted 3D-printing file folder into a searchable, visual catalog and covers the entire path from file to finished print — automatic ingestion, previews, organization, duplicate detection, a virtual print bed, PrusaSlicer integration, printer connectivity, and an optionally public print queue for shared printers (e.g. in a hackspace).
What Is 3D Vault For?
Anyone who has collected 3D models for years — downloaded Thingiverse/Printables finds, their
own designs, OpenSCAD scripts, Blender scenes, mixed in with assembly instructions and photos —
almost inevitably ends up with a chaotic folder full of subfolders, duplicate files, version
names like -NEW2, and formats that no ordinary file browser can preview sensibly. 3D Vault
solves exactly this problem: a background scanner catalogs the entire inventory automatically,
generates thumbnails and interactive 3D views, detects duplicates and revisions, and turns it
into a searchable gallery with tags, projects, and full-text search — without touching the
original files unless you explicitly want it to.
Beyond that, 3D Vault is not just a catalog — it covers the entire printing workflow: models can be placed directly from within the app onto a virtual multi-part print bed, given infill/support settings, sliced to G-code via PrusaSlicer, and sent to a connected printer — including live status polling and automatic print-log reconciliation. An optional print queue, usable anonymously as well, additionally makes the app suitable for shared infrastructure such as a hackspace: multiple users, multiple printers, no account requirement for submitting a print request.
What 3D Vault Does
Cataloging & Organizing: recursive scan of the source folder, metadata (path, size, modification date, content hash), automatic detection of Thingiverse/Printables download structures (including license/source extraction) and of revision/backup patterns in filenames. Plus tags, projects/collections, a two-stage trash (soft delete + permanent delete), and move/copy within the inventory.
Visualizing: automatically generated thumbnails and an interactive 3D viewer (Three.js) for every supported file; image files are displayed directly instead of rendered; formats that can't be displayed show a format badge instead of a preview image but don't disappear from the catalog.
Finding Duplicates: exact duplicates via content hash, "probably the same model" via geometry fingerprint — robust against different exporters, repeated downloads, or slightly different scaling.
Searching: fuzzy full-text search via Meilisearch, format/project/tag/status filters, path/folder search.
Converting: selected files in several non-STL formats can be converted to a print-ready STL
with a click on the detail page, to make them usable in the slicer (see
Supported File Formats below). This never runs automatically in the
background, but is always a deliberate, individual action per file — otherwise every
.blend file in the entire inventory would get converted along with the next scan, which for a
historically grown archive (in which far from every .blend file is even a print model) would
be neither desired nor cheap.
Slicing: virtual multi-part print bed (up to 12 parts), free positioning via drag & drop, rotation and resizing per axis, infill/support configuration, real mesh-vs-mesh collision checking, PrusaSlicer integration with embedded G-code preview thumbnails.
Printing: printer profiles with isolated driver plugins (currently Creality WebSocket and OctoPrint, see Driver Plugins), G-code upload and print start directly from the app, live progress polling, automatic print-log reconciliation.
Sharing: an optional, publicly accessible print queue per printer for hackspace/multi-user scenarios, with its own security measures for anonymous uploads.
Securing: a complete login layer with optional 2FA (TOTP), roles (admin/user via an
is_admin group flag), all deleting actions restricted to admins without exception.
Localizing: the entire interface is available in English (default) and German, negotiated
automatically per request from the browser's Accept-Language header — no manual language
switcher, no account setting, nothing to configure.
Table of Contents
- What Is 3D Vault For?
- What 3D Vault Does
- Self-Hosting: Setup
- Environment
- Volumes and File Paths
- Networks and Access
- Feature Overview
- Supported File Formats
- Authentication and Admin Backend
- Scan Pipeline
- Search and Duplicates
- Trash and File Operations
- Printers and Slicer Profiles
- Printer Providers
- Driver Plugins
- Writing Your Own Driver Plugins
- Export and Import
- Slicer UI
- Print Bed and Placement
- Print Queue
- Print Status and Print Logs
- Operations
- Security
- Known Functional Limits
- Architecture
- Deployment
- Data Model
- Render Pipeline
- Rotation and Axis Mapping
- Slicer Worker
- Routes
- Tests
- Important Files
- Development
- License
Self-Hosting: Setup
3D Vault runs entirely on Docker Compose. This section walks you through everything once, from an empty server to your first successful login — everything after that (setting up printers, browsing files, slicing, …) builds on a running instance and is explained in the following sections.
Prerequisites
- A Linux host with Docker and the Docker Compose plugin (
docker compose versionshould work — not the older standalonedocker-compose). - An existing folder with your 3D-printing file inventory (STL, OBJ, Blender files, …) — may be
empty but must exist and be writable by
PUID:PGID(see Source Mount). 3D Vault does not create this folder itself — if it's missing or the permissions don't match,docker compose uprefuses to start, orappfails with a clear error message instead of a silentPermission deniedon first file access. - Optionally your own Traefik reverse proxy with TLS, if your instance should be publicly reachable under a real domain name. Without Traefik, 3D Vault works just as well over a directly published port (see "Step 2" below) — useful for a first test or a pure LAN setup.
Step 1: Clone and Configure the Repository
git clone <repo-url> 3dvault
cd 3dvault/deploy
cp .env.example .env
Now open deploy/.env in an editor (the file has an explanatory comment for every variable)
and fill in at least the following required fields:
APP_KEY(generate withopenssl rand -base64 32, enter it with thebase64:prefix)APP_URLVAULT_SOURCE_PATH(the already existing path to your 3D-printing folder from above)VAULT_DB_*MEILISEARCH_KEY
A complete description of all variables — including the optional ones — can be found in the next section, Environment.
Step 2: Choose an Operating Mode and Start the Containers
3D Vault supports two equivalent operating modes, each activated via an additional
Docker Compose file layered on top of the shared base (docker-compose.yml):
- Standalone — no reverse proxy of your own needed. The
appcontainer publishes a port directly on the host (defaulthttp://localhost:8080, configurable viaVAULT_HTTP_PORT). The right entry point if you're unsure, for a first test, or for a pure LAN setup. - Traefik — for running behind an already existing Traefik reverse proxy with its own
domain name and TLS. Additionally requires
VAULT_DOMAIN/TRAEFIK_*indeploy/.env.
# Standalone — app reachable directly via a host port (default http://localhost:8080):
docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d --build
# With Traefik — additionally set VAULT_DOMAIN/TRAEFIK_* in deploy/.env:
docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d --build
The first start takes noticeably longer than later restarts — Composer and npm install/build run once (see Deployment for details on what happens in the background). Migrations and caches run automatically along with it, you don't need to trigger anything manually.
docker compose ps shows you once all containers are healthy. If your
VAULT_SOURCE_PATH already contains files, the app container automatically triggers an
initial catalog scan on its very first start (files:scan-if-new, see Scan Pipeline)
— this runs in the background via the queue, so for larger inventories it can take a few seconds
to minutes after "healthy". Without this automatic scan, the dashboard/gallery would otherwise
stay empty for up to an hour (the watcher only reacts to new changes, not to the already
existing inventory; the hourly fallback scan only runs at the next full hour). This does not
happen again on any subsequent, ordinary restart — only a genuinely empty files table triggers
it.
Step 3: Create an Admin Account and Log In
docker compose exec app php artisan app:ensure-admin-user --email=<your-address>
The command prints the email address and the randomly generated password directly and unmissably to the console (a highlighted yellow block) — this is the guaranteed way to get the credentials, regardless of whether or how SMTP is configured. The password is never stored in plaintext anywhere and is shown exactly once, right here — copy/note it down on the spot.
The command additionally attempts to send the same credentials by mail — but that only runs
as an optional second channel you don't need to rely on. Without real SMTP configuration
(MAIL_MAILER defaults to log), that second channel doesn't arrive by mail but ends up in
its own log file instead:
docker compose exec app cat storage/logs/mail.log
(not storage/logs/laravel.log — the general log deliberately runs at
LOG_LEVEL=warning in production, but mail sending always writes at debug level;
config/mail.php/config/logging.php therefore route the log mailer into its own,
always-active mail channel. AccountCreated does implement ShouldQueue — but
app:ensure-admin-user deliberately sends it synchronously via notifyNow() inside the
app container itself instead of putting it on the queue, so that the cat command
right after is guaranteed to find something instead of racing against a queue job that
hasn't been processed yet. Only the web-based path via /admin/users queues the mail
normally, so as not to block the HTTP request.)
Log in on APP_URL with the email address and the password shown on the console — the
first login immediately forces a password change. After that, the instance is ready to use.
MAIL_FROM_ADDRESS has a working default (noreply@example.com) and doesn't need to be
touched for this minimal scenario — but don't set it to an empty string: Symfony
Mailer always requires a From header, even with the log mailer, otherwise mail sending
(including this first credentials mail) fails with a LogicException and the job ends up in
failed_jobs after 3 attempts, without the password ever becoming visible anywhere.
What Now?
Your instance is running and you're logged in. A sensible next stop is the Feature Overview — it shows compactly everything 3D Vault can do, with links to the respective detail sections. If you want to print right away, continue in the Printers and Slicer Profiles section.
Environment
deploy/.env.example is the complete, up-to-date template for
deploy/.env (not tracked in Git) — all variables that docker-compose.yml and the two
overlay files expect via ${...}, with comments right in the file. This README table
is only a compact overview now; deploy/.env.example remains the primary source of truth.
Separately, there's a second .env.example at the repo root — it's exclusively for local
development without Docker (php artisan serve, local tests with SQLite) and has nothing to
do with running under Docker. entrypoint.sh doesn't touch it; every value that actually
matters for operation comes as a container environment variable directly from deploy/.env
via docker-compose.yml — including APP_LOCALE/APP_FALLBACK_LOCALE (en), the base locale
used outside a request context (queue workers, Artisan commands). For Docker operation you only
ever edit deploy/.env; you can completely ignore the root .env.example.
Important variables (required marked with *):
| Variable | Purpose |
|---|---|
APP_KEY* |
Laravel app key |
APP_URL* |
Full URL under which the app is reachable |
VAULT_SOURCE_PATH* |
Host path to the 3D-printing file inventory (source mount) |
PUID, PGID |
UID/GID for Composer/npm/Artisan inside the containers (default 1000:1000). Determines the owner of all files created by the container in the bind mount (vendor/, node_modules/, public/build/, storage/, ...). Determine your own values with id -u/id -g on the host — otherwise, mismatched values can mean you can only edit/delete these files with sudo |
VAULT_DB_NAME, VAULT_DB_USER, VAULT_DB_PASSWORD, VAULT_DB_ROOT_PASSWORD |
MariaDB |
MEILISEARCH_KEY* |
Meilisearch master/API key |
VAULT_DOMAIN |
Only with docker-compose.traefik.yml: hostname for the Traefik Host() rule |
TRAEFIK_NETWORK, TRAEFIK_CERTRESOLVER, TRAEFIK_ENTRYPOINT |
Only with docker-compose.traefik.yml: name of the external proxy network, ACME cert resolver, HTTPS entrypoint — adapt to your own Traefik configuration |
VAULT_HTTP_PORT |
Only with docker-compose.standalone.yml: host port without Traefik (default 8080) |
SESSION_SECURE_COOKIE |
true only with real TLS in front, otherwise false. Also controls (AppServiceProvider) whether the app enforces https URLs — leave at false in standalone operation without Traefik, otherwise redirects (e.g. guest → /login) point to https:// even though no TLS is being terminated, and the browser gets a TLS error instead of the login page. Also controls (bootstrap/app.php) whether trustProxies() is active at all — only true (Traefik operation) lets Laravel trust forwarded headers, see Security |
RENDER_WORKER_URL |
URL of the render worker, normally http://render:8000 |
SLICER_WORKER_URL |
URL of the slicer worker, normally http://slicer:8000 |
DRIVER_RUNNER_URL |
URL of the isolated driver plugin runner, normally http://drivers:8000 — see Driver Plugins |
SOURCE_PATH |
Source mount path in the app container, always /data/source |
APP_LOCALE, APP_FALLBACK_LOCALE |
Fixed to en — not configurable via .env, set directly in docker-compose.yml. Only the base locale outside a request (queue workers, Artisan commands); the UI itself negotiates English/German per request from the browser's Accept-Language header, see Localizing |
HASH_DRIVER |
Password hashing algorithm, argon2id |
SESSION_LIFETIME |
Session/remember-me validity in minutes, default 43200 (30 days) |
SESSION_ENCRYPT |
true — session payload in Redis is stored encrypted |
MAIL_MAILER, MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, MAIL_FROM_ADDRESS |
SMTP access, used for account creation emails (default MAIL_MAILER=log — without real configuration the generated password only ends up in the log). MAIL_FROM_ADDRESS has a working default (noreply@example.com) — do not set it empty, Symfony Mailer always requires a From header, even with the log mailer, otherwise every mail send fails |
Volumes and File Paths
Source Mount
The 3D-printing inventory is mounted into the containers as:
/data/source
Host path: configurable via VAULT_SOURCE_PATH in deploy/.env (required variable, no
default) — the folder with your own 3D-printing file inventory, must already exist.
Must be writable by PUID:PGID (delete/move/copy, print-queue
uploads all go through the app container, which internally runs as www-data with this
UID/GID). Easiest way: chown -R <PUID>:<PGID> <VAULT_SOURCE_PATH>. If the folder comes from a
different source with its own owner (NAS mount, another user's sync client, etc.), group
write permission + setgid works as an alternative (chgrp -R <PGID> <path> && chmod -R g+rwX <path> && chmod g+s <path-subfolder>) — important: if PGID (or the default) changes later, this
permission has to be re-aligned, otherwise all writing actions fail with
UnableToCreateDirectory/"Permission denied" even though the container itself is healthy.
Mount modes:
app:rwqueue:roscheduler:rowatcher:rorender:roslicer:ro
This is intentional. Writing file operations against the source inventory only ever go through authenticated (or, for the print queue, deliberately allowed anonymous) web requests in the app container. Workers only read.
Print-queue uploads land under their own prefix print-queue/{printer-key}/{uuid}/
inside the same source mount — see Print Queue.
Persistent Data
| Path | Content |
|---|---|
deploy/db |
MariaDB data |
deploy/redis |
Redis dump |
deploy/meilisearch |
Meilisearch data |
deploy/logs |
additional Apache access log |
storage/app/public/renders |
Blender thumbnails, topdown thumbnails, GLB files, JSON metadata |
storage/app/public/gcode-thumbnails |
bed thumbnails from the slicer worker |
storage/app/private/gcodes |
generated G-code files |
Backups
Three things are enough for a complete backup:
deploy/.env(without this file, the rest is no longer decryptable/usable — it containsAPP_KEY, which among other thingsPrinter::driver_configis encrypted with in the database)deploy/db(the complete MariaDB data directory copy: catalog, tags, projects, printers, slicer profiles, driver plugins, users)- Your source folder (
VAULT_SOURCE_PATH) — that's your actual 3D files. 3D Vault only writes there when you do it via the app (delete/move/copy/print queue); a separate, independent backup of this folder remains your responsibility regardless — 3D Vault does not replace a file backup.
Not strictly necessary to back up, because it can be recreated from the two sources above at any time:
deploy/redis(only cache/queue/sessions)deploy/meilisearch(search index, rebuildable viaphp artisan scout:import "App\Models\File")storage/app/public/renders,storage/app/public/gcode-thumbnails,storage/app/private/gcodes(thumbnails/GLBs/G-code — all reproducible from the original files or a re-run of slicing, but losing them loses convenience/history, not data loss in the strict sense)
A simple approach: briefly stop the app container (prevents write access during
copying), back up deploy/.env and deploy/db, then start it again. For uninterrupted
backups, a regular mariadb-dump against the running db container is more suitable instead.
Networks and Access
Docker networks:
internal: internal bridge network for app, DB, Redis, workers, Meilisearch — always present.proxy: external Traefik network — only exists ifdocker-compose.traefik.ymlis included (network name configurable viaTRAEFIK_NETWORK, defaultproxy).
Only the app container is additionally attached to the proxy network. The render and slicer workers are not published via Traefik.
Traefik labels (in docker-compose.traefik.yml, values configurable via TRAEFIK_CERTRESOLVER/
TRAEFIK_ENTRYPOINT/TRAEFIK_NETWORK):
- Main router
3dvault: host ruleHost(${VAULT_DOMAIN}), entry point${TRAEFIK_ENTRYPOINT:-websecure}, TLS cert resolver${TRAEFIK_CERTRESOLVER:-letsencrypt}, middleware chain3dvault-secheaders. - Additional router
3dvault-login: the same host rule plusPathPrefixon/login,/two-factor-challenge,/forgot-password,/reset-password; middleware chain3dvault-secheaders,3dvault-loginlimit. 3dvault-secheaders: HSTS (stsSeconds=31536000,stsIncludeSubdomains,forceSTSHeader),contentTypeNosniff,customFrameOptionsValue=SAMEORIGIN,referrerPolicy=strict-origin-when-cross-origin.3dvault-loginlimit:ratelimitwithaverage=1,burst=5,period=1s— in addition to Fortify's application-side rate limiting (see below).3dvault-queuelimit:ratelimitwithaverage=2,burst=10,period=1son the print-queue router (PathPrefix /druckwarteschlange) — see Print Queue.
By default, the app is publicly reachable, secured primarily via its own login. Anyone who
additionally wants an IP allowlist at the Traefik level can add and reference an
ipallowlist middleware at any time in their own copy of
docker-compose.traefik.yml.
Feature Overview
Compact reference of all features once your instance is running — the following sections provide details on each point.
Specifically, the app supports:
- Scanning the source mount
/data/source - File metadata: path, name, folder, extension, size, mtime, hash
- README/LICENSE/platform detection for download folders
- Revision/backup detection, e.g.
.blend1,-NEW,-V2 - Thumbnails and GLB previews via Blender
- Topdown thumbnails for bed previews
- Duplicate detection via content hash and geometry hash
- Targeted STL conversion of individual files (
.obj/.ply/.blend/.blend1/.scad), never automatic during scanning - Gallery with search, format, project, tag, and status filters
- Tags and projects
- Trash with restore and permanent deletion
- Print logs, manual and automatic
- Slicer UI with a virtual print bed, usable for all
config('slicer.supported_extensions')formats, not just.stl - Multi-part beds with up to 12 parts
- Infill/support options
- Printer profiles for Ender 5 Max and Ender 5 S1
- G-code download
- G-code upload and print start
- Live printer status and progress polling
- Login with optional 2FA (TOTP), no self-signup
- Admin backend with user, group, printer, slicer-profile, and singleton-settings management
- Deleting actions (all variants) are restricted to admin users
- Print queue per printer (STL upload with note/quality/infill wish), optionally usable publicly without login (admin toggle)
Supported File Formats
3D Vault distinguishes between three levels of format support — not every cataloged format can automatically be visualized, let alone printed:
| Format | Cataloged | Preview (thumbnail + 3D viewer) | Convertible to STL | Usable in the slicer/plater |
|---|---|---|---|---|
.stl |
✅ | ✅ | – (already STL) | ✅ always, regardless of render status |
.obj |
✅ | ✅ | ✅ | after targeted conversion |
.ply |
✅ | ✅ | ✅ | after targeted conversion |
.blend |
✅ | ✅ | ✅ | after targeted conversion |
.blend1 (Blender's auto-backup) |
✅ | ✅ | ✅ | after targeted conversion |
.scad (OpenSCAD source code) |
✅ | ✅ (via OpenSCAD→STL) | ✅ | after targeted conversion |
Image files (png/jpg/jpeg/gif/webp/bmp/tif/tiff/svg) |
✅ | ✅ (original file, no rendering needed) | ❌ | ❌ |
.3mf |
✅ | ❌ | ❌ | ❌ |
.step/.stp |
✅ | ❌ | ❌ | ❌ |
everything else (.txt, .pdf, .docx, .zip, Fritzing files, …) |
✅ | ❌ (format badge) | ❌ | ❌ |
Everything gets cataloged, regardless of format — even non-3D files such as assembly plans, license texts, or photos show up in the inventory, just without a preview. Details on the render and conversion pipeline: see Render Pipeline and Targeted STL Conversion; for usage in the slicer, see Slicer UI.
Deliberate format limits: .3mf would need an additional Blender add-on (not installed),
.step/.stp are not meshes but parametric B-rep CAD data and would need a real
CAD kernel (OpenCASCADE/FreeCAD) — neither is currently implemented, see
Known Functional Limits. Independently of this, the public
Print Queue exclusively accepts .stl uploads
(a security boundary for anonymous submissions, not a technical format limit).
Authentication and Admin Backend
Login
- Backend: Laravel Fortify headless (
laravel/fortify), with custom Inertia/Vue views instead of Fortify's default views. - Activated Fortify features (
config/fortify.php):resetPasswords(),updatePasswords(),twoFactorAuthentication()(withconfirmandconfirmPassword).registration()andemailVerification()are deliberately disabled — there is no self-signup, only the admin creates users. - Password hashing: Argon2id (
config/hashing.php, 64 MiB / 4 iterations / 2 threads), switchable viaHASH_DRIVER. - Password rule (
app/Providers/AppServiceProvider.php): at least 9 characters, upper- and lowercase letters plus numbers required, no special-character requirement. Additionally,uncompromised()checks outside the test environment against the Have I Been Pwned leak database (k-anonymity, only a SHA1 prefix leaves the server). - Rate limiting is doubly secured: Fortify limits login attempts per email+IP combination and additionally per IP across all email addresses; Traefik additionally limits
/loginand the other auth routes at the network level (3dvault-loginlimit, see above). - Session:
SESSION_LIFETIME=43200(30 days). The remember-me cookie ("stay logged in", enabled by default in the login form) actually has its own, independent Laravel default of 400 days — this is explicitly coupled to the same 30-day value inapp/Providers/AppServiceProvider.phpviasetRememberDuration(), so login validity is genuinely capped at 30 days everywhere. - 2FA (TOTP) is optional for every user, set up under
/profile: QR code AND a textual secret key (for password managers without a scanner) plus recovery codes. - New users are created exclusively by the admin and receive a randomly generated password (
Str::password(20, symbols: false)— deliberately without special characters, since Markdown mail templates would otherwise interpret*/_as formatting and strip them from the password) by email (App\Notifications\AccountCreated,ShouldQueue). On first login,must_change_passwordforces a password change before the rest of the app becomes usable.
Roles/Groups
Table groups (id, name, is_admin, timestamps). Every user belongs to exactly one group via users.group_id. There is no fine-grained role/permission system — only the group's is_admin flag decides admin rights. The groups "Administrators" and "Users" are pre-seeded (migration seed_default_groups); additional groups can be created in the admin backend.
Because of this feature, users additionally has: group_id, must_change_password, is_active, disabled_at (deactivation instead of deletion), as well as Fortify's two_factor_secret, two_factor_recovery_codes, two_factor_confirmed_at.
Admin Backend
Reachable at /admin (routes/admin.php), protected by the middleware chain auth, password.change (enforces a required password change), and admin (checks $user->group->is_admin, otherwise 403).
| Route | Purpose |
|---|---|
GET /admin/users |
user list |
GET /admin/users/create, POST /admin/users |
create a user (random password + mail) |
GET /admin/users/{user}/edit, PUT /admin/users/{user} |
edit a user (additionally password.confirm) |
PUT /admin/users/{user}/deactivate, PUT /admin/users/{user}/reactivate |
deactivate/reactivate instead of delete (additionally password.confirm) |
GET /admin/groups, GET /admin/groups/create, POST /admin/groups |
group list/create (creation additionally password.confirm) |
GET /admin/groups/{group}/edit, PUT /admin/groups/{group}, DELETE /admin/groups/{group} |
edit/delete group (additionally password.confirm) |
GET /admin/printers, GET /admin/printers/create, POST /admin/printers |
printer list/create (creation additionally password.confirm) |
GET /admin/printers/{printer}/edit, PUT /admin/printers/{printer}, DELETE /admin/printers/{printer} |
edit/delete printer (additionally password.confirm) |
GET /admin/slicer-profiles, GET /admin/slicer-profiles/create, POST /admin/slicer-profiles |
profile list/create (creation additionally password.confirm) |
GET /admin/slicer-profiles/{slicerProfile}/edit, PUT /admin/slicer-profiles/{slicerProfile}, DELETE /admin/slicer-profiles/{slicerProfile} |
edit/delete profile (additionally password.confirm) |
GET /admin/settings, PUT /admin/settings |
edit singleton settings (print_queue_enabled, print_queue_public_access, additionally password.confirm) |
For details on printer/profile management, see Printers and Slicer Profiles; for the print-queue setting, see Print Queue.
Sensitive admin actions additionally require a fresh password confirmation (Fortify's password.confirm middleware, timeout in config/fortify.php). The frontend proactively checks the confirmation status via GET /user/confirmed-password-status (composable resources/js/composables/useConfirmsPassword.js + resources/js/Components/ConfirmPasswordModal.vue) and automatically lets the actual action continue after confirmation, instead of silently losing it on a 423.
app/Support/LastAdminGuard.php prevents the last active admin from being deactivated or demoted, or the last admin group from losing its is_admin flag or being deleted while it still has active members.
The initial admin user isn't created automatically on every deploy, but once, manually:
docker compose exec app php artisan app:ensure-admin-user --email=<address>
The command is idempotent (firstOrCreate), assigns the user to the Administrators group, and sends the same AccountCreated mail as for normally created users.
Delete Permissions
Without exception, all deleting/trash-related endpoints of the app are restricted to admin users (middleware admin at the route level in routes/web.php; additionally the corresponding buttons are completely hidden in the frontend for non-admins, not just disabled):
DELETE /slicer/{slicedFile},DELETE /slicerDELETE /trash/{fileId},POST /trash/emptyPOST /files/bulk/deleteDELETE /files/{file},DELETE /files/{file}/tags/{tag}DELETE /tags/{tag}DELETE /projects/{project}DELETE /print-logs/{printLog}
POST /trash/{fileId}/restore (restore) is deliberately excluded from this — not a delete operation. Viewing, downloading, and all non-deleting edit actions remain fully usable for normal users.
Scan Pipeline
The source inventory is cataloged via files:scan.
cd deploy
docker compose exec app php artisan files:scan
Options:
php artisan files:scan --dry-run
php artisan files:scan --no-render
The scan:
- reads all files from
Storage::disk('source') - ignores dotfiles and
.trash - detects extension, size, mtime, folder
- computes a content hash for new/changed files
- detects revisions and backup variants
- detects source/license from folder metadata
- marks vanished files via
missing_since - queues render jobs for new/changed non-image files
There's a Redis lock files-scan-command against parallel scans. RunScan additionally uses WithoutOverlapping.
The watcher container watches /data/source via inotifywait and triggers files:scan after a 30-second debounce. The scheduler additionally runs an hourly fallback scan.
In addition, entrypoint.sh calls files:scan-if-new every time the app container starts: if the
files table is still empty (a fresh self-hosting first run), this automatically queues a RunScan
job without you having to trigger files:scan manually. On any already populated
instance, the command does nothing.
Search and Duplicates
The gallery uses Meilisearch via Laravel Scout.
Indexed fields:
idfilenamefilename_wordsfolder_pathextensionsource_platformtags
Files with missing_since or deleted_at are not indexed.
Duplicate types:
- exact duplicates: same
content_hash - near duplicates: same
mesh_metadata.geometry_hash, but different file content
The geometry hash is built by Blender from sorted world-space mesh vertex coordinates rounded to 0.01 mm. That's significantly more robust than just bounding box plus triangle count.
Trash and File Operations
File operations live in:
app/Services/FileOperations.php
Deletion happens in two stages:
trash()moves the file within the source mount to.trash/<original path>and setsdeleted_at.forceDelete()permanently deletes it from.trash/and removes the DB record.
Restore moves it back from .trash/ to its original path. If another file has since taken that spot, the app aborts and requires manual resolution.
Move/copy always build target paths from a known filename plus target folder. Free-form paths from user input are never used directly.
Printers and Slicer Profiles
Printers and slicer profiles are database-backed, manageable under /admin/printers and
/admin/slicer-profiles, without a redeploy.
Printers (printers, App\Models\Printer)
key(stable, referenced bysliced_files.printer_key),label,bed_x/bed_y/bed_zdriver_typeis currently always'plugin'— every printer has a driver plugin (driver_plugin_id, FK todriver_plugins) that runs isolated in thedriverscontainer, see Driver Plugins.driver_config(JSON,encrypted:arraycast): the fields required by the respective plugin viaconfig_schema(e.g.upload_url/ws_port/gcode_diroroctoprint_base_url/octoprint_api_keyfor the two migrated drivers). Encrypted withAPP_KEYbecause real credentials sit in the DB here rather than just in.env. IfAPP_KEYis ever rotated without migrating this column along with it,driver_configbecomes permanently undecryptable.SoftDeletes. Deletion is blocked whileSlicerProfilerows still point to the printer (PrinterController::destroy(), checks$printer->slicerProfiles()->exists()— therestrictOnDelete()FK onslicer_profiles.printer_idalone isn't enough, because a soft delete isn't a realDELETE, so the FK doesn't fire).
Slicer Profiles (slicer_profiles, App\Models\SlicerProfile)
The admin form distinguishes curated fields (ones that actually vary between profiles AND
take effect during slicing: layer height, temperatures, perimeters/top-bottom layers, retraction,
filament/nozzle, start/end G-code) from a generic extra_settings key-value list
(JSON column) for everything else (speeds, accelerations, fan, machine_max_*, …) — this still
covers any arbitrary PrusaSlicer option without needing a form field per setting.
fill_density/fill_pattern and support_material_style/support_material_pattern are deliberately
not curated fields: SlicerController::store()/slicer-worker always override them anyway
via CLI flag based on the job selection in the slicer UI, so a profile value for them would never take effect.
SlicerProfile::toIni() generates the complete PrusaSlicer INI text from this (curated fields
as known keys, bed_shape/max_print_height from the printer relation, then all
extra_settings pairs appended). start_gcode/end_gcode sit in the DB as real
multi-line text (for the <textarea>); toIni() converts them, when generating, into PrusaSlicer's
single-line notation with a literal \n (see "Slicer Worker" below).
Handoff to the Slicer Worker
The SliceFile job sends profile_ini (the full generated INI text) instead of a
file reference in the request body to /slice. The worker itself writes it to a temp file
inside the tempfile.TemporaryDirectory() it's using anyway and loads it via --load — no
shared volume for profiles needed.
G-code History Stays Independent of Later Changes
sliced_files additionally freezes printer_label, printer_bed (JSON), and filament
at slicing time (analogous to profile_name/plate_items). Reason: mapSlicedFile()/
show()/sendToPrinter() would otherwise read these values live from the now-editable
printer/profile DB — a renamed or deleted printer would then corrupt old G-code
history. With the snapshot, it stays correct regardless of whether the original printer
or profile was later changed or deleted.
Bundled Default Profiles
Created once for the two bundled printers on the very first migrate --force
(freely editable afterward under /admin/slicer-profiles):
| Key | Printer | Layer Height | Firmware Flavor | Bed |
|---|---|---|---|---|
ender5max-008 |
Ender 5 Max | 0.08 mm | Klipper | 400 x 400 |
ender5max-012 |
Ender 5 Max | 0.12 mm | Klipper | 400 x 400 |
ender5max-020 |
Ender 5 Max | 0.20 mm | Klipper | 400 x 400 |
ender5s1-008 |
Ender 5 S1 | 0.08 mm | Marlin 2 | 220 x 220 |
ender5s1-012 |
Ender 5 S1 | 0.12 mm | Marlin 2 | 220 x 220 |
ender5s1-020 |
Ender 5 S1 | 0.20 mm | Marlin 2 | 220 x 220 |
Shared basis:
- FFF
- 0.4 mm nozzle
- 1.75 mm filament
- PLA
machine_limits_usage = time_estimate_only
Ender 5 Max:
bed_shape = 0x0,400x0,400x400,0x400gcode_flavor = klipper- Bed 45 °C
- Start G-code with a purge line at X just below 0 and Y 120..245
- End G-code presents at X395/Y395
Ender 5 S1 (not yet calibrated/verified on a real test print — starting values derived from the Max profiles):
bed_shape = 0x0,220x0,220x220,0x220gcode_flavor = marlin2- Bed 55 °C
- Start G-code with
G29 - End G-code presents at X215/Y215
The profile display in the UI comes directly from the SlicerProfile records (key, name, layer height,
filament).
Printer Providers
The printer connection is encapsulated behind an interface:
app/Services/PrinterDrivers/PrinterDriver.php
Interface methods:
fromConfig(array $printer)upload(string $localPath, string $filename)start(string $filename)status()outcomeFor(string $filename)
The factory:
app/Services/PrinterDrivers/PrinterDriverFactory.php
reads Printer::driverPlugin and builds a PluginPrinterDriver instance from it (an HTTP client to
the drivers container, see Driver Plugins).
Ender 5 Max and Ender 5 S1
Both run as driver plugins (driver_plugins.key: creality-websocket and octoprint
respectively, manageable under /admin/driver-plugins).
Creality WebSocket (Ender 5 Max):
- Upload via HTTP multipart to
driver_config.upload_url, filename as the last URL segment - Print start via proprietary Creality/DWIN WebSocket:
{"method":"set","params":{"opGcodeFile":"printprt:<gcode_dir>/<filename>"}} - Status via WebSocket request
ReqPrinterPara;printFileNamedecides whether a job is active,stateis only evaluated in the context of the active/last-known job - Python-side:
websockets.sync.client(synchronous API, matching the synchronoushandle()contract)
OctoPrint (Ender 5 S1):
- Upload via the OctoPrint REST API to
/api/files/local, auth viaX-Api-Key - Start via
POST /api/files/local/<filename>with{"command":"select","print":true} - Status via
/api/printer+/api/job; HTTP 409 (reachable but no printer connected) is still handled specially
Driver Plugins
A way to add a new printer driver — without a code change/deploy, but with the same
security level as fixed, compiled-in code, because the plugin code runs exclusively isolated
in the drivers container (driver-runner/), never in the app container itself. This lets a
self-hoster write a driver for a new protocol (Prusa Connect, Moonraker/Klipper, Bambu, …), export
it, and have other 3D Vault instances import it via /admin/driver-plugins — without writing
any PHP or triggering a deploy.
Data Model (driver_plugins, App\Models\DriverPlugin)
key,label,description,authorsource_code(TEXT): full Python source code of the plugin. Deliberately unencrypted — it never contains credentials (those stay inPrinter::driver_config,encrypted:array) and needs to remain exportable/importable between instances.config_schema(JSON, a list of{key, label, type, secret}): describes whichdriver_configfields a printer with this plugin needs (e.g.base_url/api_key) — drives the dynamic form under/admin/printers(field type, password input whensecret: true).SoftDeletes, delete lock analogous toPrinter/SlicerProfile: locked as long as aPrinterwithdriver_type === 'plugin'still points to the plugin (DriverPluginController::destroy();restrictOnDelete()doesn't fire on a soft delete).
The Plugin Contract
A plugin is a Python module with a top-level function:
def handle(op: str, config: dict, params: dict) -> dict:
...
op∈upload|start|status|outcome(identical toPrinterDriver's interface methods)config=Printer::driver_config, decryptedparamsdepending onop:upload:{"file_path": <absolute path to the G-code file in the driver-runner container>, "filename": <str>}start:{"filename": <str>}status:{}outcome:{"filename": <str>}
- The return mirrors
PrinterStatus/existing driver behavior 1:1, e.g.status:{"online": bool, "state": str, "file_name": str|None, "progress_percent": int|None, "elapsed_seconds": int|None, "remaining_seconds": int|None, "current_layer": int|None, "total_layers": int|None, "error": str|None}
A curated, pinned selection of Python libraries is permanently pre-installed in the
driver-runner image (instead of runtime pip install) and covers virtually every realistic
printer protocol — a full list with intended use plus a detailed practical guide
to writing your own plugin: Writing Your Own Driver Plugins.
A complete reference example: driver-runner/examples/example_http_driver.py.
App\Services\PrinterDrivers\PluginPrinterDriver
Implements PrinterDriver like the two built-in drivers, but doesn't execute any
third-party code itself — it's just a thin HTTP client to the drivers container (POST /run). Since this
driver needs more than just driver_config (the plugin source code from the DriverPlugin relation),
it doesn't fit the Class::fromConfig(array) convention of the built-in drivers — it deliberately
throws a LogicException there and is instead built directly with the Printer model via
PrinterDriverFactory::make() (PLUGIN_DRIVER_TYPE = 'plugin' branch, before the DRIVER_CLASSES lookup).
During upload(), the driver translates the app-container-absolute G-code path
(Storage::disk('local')->path(...)) into the plain filename (basename($localPath), without
a gcodes/ prefix) — the drivers container doesn't see the app path, only its own :ro mount
of the same storage/app/private/gcodes folder, and GCODES_ROOT already points directly at
this folder (not its parent) — with an extra gcodes/ prefix,
driver-runner would look in the non-existent /data/gcodes/gcodes/.
Isolation Boundary of driver-runner
- Its own container, with no access to the database, session, or
APP_KEY— plugin code gets onlyconfig(the decrypteddriver_configof the respective printer) and the G-code file path, never more. - No persistent plugin storage in this container:
source_codecomes fresh from the Laravel DB with every/runcall and is executed in a throwawaytempfile.TemporaryDirectory()(ontmpfs), then immediately discarded. - Every call runs in a fresh
python -Isubprocess (isolated mode: noPYTHON*env variables, no user site directory) with resource limits (RLIMIT_CPU/RLIMIT_AS/RLIMIT_NPROC/RLIMIT_FSIZE) and a timeout, plus Compose-level hardening:cap_drop: [ALL],security_opt: no-new-privileges, aread_onlyroot filesystem (only/tmpastmpfs),pids_limit,mem_limit, non-root user in the image (unlike the render/slicer workers, where this is still pending — deliberately not deferred to "medium-term" here, because this container demonstrably runs the least trustworthy code). - No kernel sandbox (no nsjail/gVisor) — disproportionately fragile to assume for a single-tenant self-hosting app on arbitrary Docker hosts. The actual protective effect comes from "its own container without DB/session/secret access", not from a hardened interpreter — a plugin can still open arbitrary network connections within its own container (technically necessary to talk to a printer at all) and max out its resource limits, but cannot reach the Laravel app, the database, or other containers.
- Diagnostic text from a failed plugin call (stdout/stderr) is both truncated and
scrubbed of all
configvalues (_redact_config_values()indriver-runner/main.py) before being returned to Laravel — a careless plugin that, say, prints the API key in an error message shouldn't leak the secret all the way to the admin UI/Laravel logs. /validate(an AST syntax check that never executes the code) is a pure convenience check for the admin UI ("check code" button in create/edit) —store()/update()don't depend on thedriverscontainer being reachable.
Writing Your Own Driver Plugins
A practical guide for anyone who wants to write a new driver under /admin/driver-plugins (or
understand/adapt an imported driver) — e.g. for Prusa Connect, Moonraker/Klipper,
Bambu Lab (LAN or cloud), or any other protocol a printer/printer cloud speaks.
The goal of this section: everything needed for that, in one place, precise enough to get started right away.
Workflow in the Admin Area
/admin/driver-plugins→ "New Plugin".- Fill in
key/label/description/author(pure metadata). - Define the credential fields (
config_schema): one line per field that a printer with this driver will need later (e.g.ip,access_code,serial_number). Typestring/url/number, check "secret" for password input fields (purely UI-side —driver_configis already fully encrypted regardless of the checkbox). - Write the Python source code — see the contract below. "Check code" does a pure AST syntax check (no execution, see Isolation Boundary), useful as quick feedback, but doesn't replace a real test against a running printer.
- Save. Immediately selectable as a driver type under
/admin/printers— no deploy, no container restart. - Test: create a printer with the plugin, enter real credentials, fetch the
live status on
/slicer(usesstatus()) or send a G-code to the printer (usesupload()+start()).
The Contract in Detail
A plugin is a Python module with exactly one required top-level function:
def handle(op: str, config: dict, params: dict) -> dict:
...
driver-runner calls it once per action (never more, no state between calls — see
Isolation Boundary). op is one of four fixed values,
with exactly defined params in and a dict return out:
op |
When called | params |
Expected return |
|---|---|---|---|
upload |
G-code is transferred to the printer (SlicerController::sendToPrinter()) |
{"file_path": <absolute path to the G-code file>, "filename": <str>} |
{} on success |
start |
Print is started, right after upload |
{"filename": <str>} |
{} on success |
status |
Fetch live status — runs synchronously when loading/polling /slicer, must respond quickly |
{} |
See below |
outcome |
Periodic reconciliation of running prints (prints:check, every 5 min.) |
{"filename": <str>} |
{"outcome": str | None} |
config is the same for every op: Printer::driver_config, already decrypted, with exactly
the keys defined in the plugin's config_schema (e.g. {"ip": "192.168.1.50", "access_code": "12345678"}).
Return value of status (if a field is missing, it's treated as null/default when mapped
to PrinterStatus — not every field is available for every protocol, e.g. OctoPrint doesn't
provide layer numbers by default):
{
"online": bool, # False -> all other fields are ignored
"state": str, # idle|printing|paused|complete|failed|aborted|error|unknown
"file_name": str | None,
"progress_percent": int | None, # 0-100
"elapsed_seconds": int | None,
"remaining_seconds": int | None,
"current_layer": int | None,
"total_layers": int | None,
"error": str | None, # only meaningful when online=False, shown in the admin UI
}
Return value of outcome: {"outcome": "printing" | "paused" | "complete" | "failed" | "aborted" | None}
— None means "no reliable statement possible" (printer unreachable, or another job is
now active in the meantime). A caller that gets None leaves the previous status unchanged
instead of guessing it — never just return "aborted" etc. simply because it's unclear what's
currently happening.
Error cases: for upload/start, simply raise an exception (raise RuntimeError(...),
response.raise_for_status(), …) — driver-runner catches it, truncates and scrubs the diagnostic
(see below), and reports it back as a failure. For status/outcome, never raise, but
return {"online": False, "state": "offline", "error": "..."} or {"outcome": None} respectively —
status() runs synchronously during page load; an exception there would crash the entire
/slicer page.
Available Python Libraries
Permanently pre-installed in the driver-runner image (driver-runner/requirements.txt), curated and
pinned — no pip install possible or intended at runtime (see
Isolation Boundary; this is a deliberate security boundary,
not an oversight). Also the complete Python standard library (among others socket, ssl,
ftplib, struct, hashlib/hmac, base64, xml.etree.ElementTree, subprocess — the latter
is barely worth it though, see the limits below).
| Library | What for |
|---|---|
requests |
HTTP/REST (most printer and cloud APIs) |
httpx |
Modern HTTP/1.1+2 alternative to requests, if preferred |
websockets |
WebSocket protocols (e.g. the Creality/DWIN driver uses websockets.sync.client) |
paho-mqtt |
MQTT — local (Klipper/Moonraker extensions) or cloud-hosted (e.g. Bambu Lab), incl. TLS |
pyserial |
Serial/USB connection (many Marlin printers directly via USB) — see the device-access note below |
paramiko |
SSH/SFTP (e.g. a Moonraker host managed via SSH instead of REST) |
protobuf |
Binary protocols that use Protocol Buffers instead of JSON |
msgpack |
Compact binary serialization, as used by some firmwares instead of JSON for status reports |
pyjwt |
JWT tokens, as issued by many manufacturer cloud APIs for authentication |
cryptography |
Cryptography beyond hashlib/hmac (request signatures, certificates, custom encryption) — TLS itself is already handled by requests/websockets/paho-mqtt/ftplib |
zeroconf |
mDNS/Bonjour — e.g. addressing a printer with a changing LAN IP via its hostname instead of a fixed IP |
bleak |
Bluetooth Low Energy (some consumer/resin printers) — async-only, see the note below |
bleak is asynchronous: handle() itself is a synchronous function. A BLE
operation inside it can still be integrated by wrapping it in asyncio.run(...):
import asyncio
from bleak import BleakClient
async def _read_status(address):
async with BleakClient(address) as client:
return await client.read_gatt_char("...")
def handle(op, config, params):
if op == "status":
raw = asyncio.run(_read_status(config["ble_address"]))
...
Limits no library can fix:
- Serial/USB (
pyserial) and Bluetooth (bleak) need extra device access that thedriverscontainer doesn't have by default (no/dev/ttyUSB0mount, no host Bluetooth access) — merely having thepippackage present isn't enough. Anyone who needs this adds e.g.devices: ["/dev/ttyUSB0"]for thedriversservice in their owndeploy/docker-compose.override.yml. Deliberately not on by default, because device passthrough increases the container's attack surface and most instances never need it. - mDNS (
zeroconf) over Docker bridge networks is unreliable — multicast traffic (mDNS runs over224.0.0.251:5353) isn't always forwarded cleanly by Docker's default bridge driver. For reliable discovery, considernetwork_mode: hostfor thedriversservice in an override file of your own (with the same trade-offs as device passthrough). subprocess/external programs inside a plugin: technically possible (part of the Python standard library), butread_only: true(see Isolation Boundary of driver-runner) leaves little room for anything that wants to write files itself, and the resource limits apply to the entire process tree. For anything one of the libraries above covers, they're always the better choice.- No installing at runtime. If a library for a new protocol is really missing, that's a
driver-runner/requirements.txtchange (code change, image rebuild) — no plugin can get around this on its own, that's by design.
Runtime Limits
- Timeouts (env-configurable, defaults):
status/outcome15s,start30s,upload300s — a plugin that takes longer gets aborted ({"success": false, "error": "Plugin timeout (<op>)"}). Especially relevant forstatus: this runs synchronously while loading/slicer. - Resource limits per call: CPU time, memory, process/thread count, maximum file size — all
configurable via env variables of the
driverscontainer (PLUGIN_CPU_SECONDS,PLUGIN_MEMORY_MB,PLUGIN_NPROC,PLUGIN_FSIZE_MB), seedeploy/docker-compose.yml. - No state between calls (every
handle()call starts in a fresh interpreter, see Isolation Boundary) — connection setup (WebSocket handshake, MQTT connect, HTTP login) has to happen fresh on every call, no caching an open connection/session token between twostatus()polls. Not an issue for most protocols (short connect-request-response-disconnect cycle, see thecreality_websocketplugin as a template) — potentially relevant for response time with cloud APIs that have an expensive login flow, not for correctness.
Best Practices
- Never build
configvalues into exceptions/prints if it can be avoided —driver- runnerdoes scrub knownconfigvalues from error texts before they're returned (see Isolation Boundary), but that's a safety net, not an invitation. An error message likef"HTTP {response.status_code}"is just as helpful as one with the API key in it. - Set short timeouts on every individual network call (
requests.get(..., timeout=5)etc.) — the outerdriver-runnertimeouts only kick in as a last resort; a hanging socket without its own timeout blocks needlessly long until then. status()/outcome()never raise (see contract above) — fall back cleanly tooffline/Noneon every error.- Follow the return shape exactly (keys, types) —
PluginPrinterDrivermaps the response directly ontoPrinterStatus; missing/mis-named keys silently becomenull, no error message. config_schemakeys are also Python dict keys — choose sensible, stable names (access_codeinstead offield1), they show up 1:1 in the form AND in export/import.- When passing
files={"file": (...)}torequests, always explicitly include a third tuple entry (content type) —files={"file": (filename, fh, "application/octet-stream")}, not just(filename, fh). Without the third entry,requestssends noContent-Typeheader at all for this part of the multipart body (unlike, say,curl -F, which sets one automatically). Most servers (including OctoPrint) tolerate this without complaint, but some minimal/embedded printer HTTP servers don't — their multipart parser then resets the connection mid-request instead of sending a response (ConnectionResetError/"Connection reset by peer"). Reproduced and fixed live against real hardware (2026-09-19,creality-websocketplugin) — the effect only surfaced on the very first real-world test against actual hardware, because the migration from the previous PHP driver had previously only been verified against simulated endpoints.
Debugging and Testing
- "Check code" in the admin form catches syntax errors and a missing
handle()before anything is even saved. - A failure during real use (printer status/upload) shows the (truncated, scrubbed) diagnostic text directly in the admin UI — usually already enough to spot typos/wrong field names.
- For deeper debugging directly against the running container, without going through the app:
(In the embedded Python code, Python'sdocker compose exec app curl -s http://drivers:8000/run \ -H 'Content-Type: application/json' \ -d '{"op":"status","source_code":"def handle(op,config,params):\n return {\"online\": True, \"state\": \"idle\"}\n","config":{},"params":{}}'True/Nonecount, not JSON'strue/null— thesource_codevalue itself is a string, not nested JSON.) (Source code directly in the request, no detour via the DB — a fast iteration loop.) docker compose logs driversonly shows Uvicorn access logs, no plugin-internal diagnostics (those only come back via the/runresponse) — for debugging, the admin UI or thecurlcall above is usually enough.
Second Example: MQTT Skeleton
Shows the basic structure for an MQTT-based protocol (e.g. Klipper/Moonraker extensions or as a starting point for Bambu Lab LAN mode) — not a complete driver, just the structure:
import json
import threading
import paho.mqtt.client as mqtt
def _fetch_status(config, timeout=5.0):
result = {}
done = threading.Event()
def on_message(client, userdata, msg):
result.update(json.loads(msg.payload))
done.set()
client = mqtt.Client()
client.on_message = on_message
try:
# status() must NEVER raise per the contract (see "The Contract in
# Detail" above) - a DNS/connection error here is the normal case
# for a printer that's turned off/unreachable, not a bug.
client.connect(config["ip"], int(config.get("port", 8883)))
except OSError:
return None
client.subscribe("printer/report")
client.loop_start()
done.wait(timeout=timeout)
client.loop_stop()
client.disconnect()
return result or None
def handle(op, config, params):
if op == "status":
data = _fetch_status(config)
if data is None:
return {"online": False, "state": "offline", "error": "No response from the printer."}
return {"online": True, "state": data.get("state", "unknown"), "file_name": None,
"progress_percent": None, "elapsed_seconds": None, "remaining_seconds": None,
"current_layer": None, "total_layers": None, "error": None}
raise ValueError(f"Unknown operation: {op}")
Complete, runnable reference (HTTP-based): driver-runner/examples/example_http_driver.py.
Two real, complete examples (WebSocket and REST) sit as driver plugins directly in the
database: creality-websocket and octoprint — viewable and exportable via the admin UI.
Export and Import
Slicer profiles and driver plugins can each be exported individually and re-imported via an
uploaded file — handy for sharing a configuration with another 3D Vault
instance, or transferring between a test instance and a production instance. Both use
the same file format (App\Services\VaultExport): a JSON envelope with a fixed marker
(3dvault_export: true), type (slicer_profile | driver_plugin), a schema version, and a
SHA-256 checksum over the payload.
The filename is never the identifying feature — it can be renamed freely; the importer
only checks the envelope content and rejects anything else (missing marker, wrong
type, wrong checksum). The checksum is deliberately a plain SHA-256, not a signature encrypted
with APP_KEY — exports are meant to remain exchangeable between different 3D Vault
instances, that's the whole point. This is format detection against mistakes/broken files, not
tamper protection against an admin who deliberately edits the file by hand — the admin area
is only reachable by admins anyway.
An import never creates anything automatically in either case: the file only pre-fills the
respective "create new" form; only an explicit click on "Save" applies the values and, e.g.,
actually makes an imported driver plugin runnable via PrinterDriverFactory.
Exporting and Importing Slicer Profiles
Under /admin/slicer-profiles: a download link per row for export, an upload form for
import. An export references its printer via the stable Printer::key, not the local
ID — if the import doesn't find a local printer with this key on the target instance, the
printer field in the pre-filled form stays empty and the admin selects manually.
Exporting and Importing Driver Plugins
Under /admin/driver-plugins, mechanically identical to slicer profile export/import. An export
never contains credentials — those live exclusively in Printer::driver_config
(encrypted:array), never in the plugin itself. A driver plugin can therefore be shared
safely, even if it was written for a specific printer.
Slicer UI
The slicer page lives in:
resources/js/Pages/Slicer/Index.vue
resources/js/Components/PlateViewer.vue
resources/js/Components/PlatePreview.vue
resources/js/Components/PlatePreviewSvg.vue
The page shows:
- sliceable files (inventory)
- virtual print bed
- printer selection
- profile selection
- infill and support options
- list of generated G-code versions
- live printer status
- G-code download, print start, and delete functions
All formats from config('slicer.supported_extensions') are selectable
(stl, obj, ply, blend, blend1, scad, see App\Models\File::scopeSliceable()):
- native
.stlfiles always, regardless of render status — the slicer worker parses them directly. - every other format only after the user has specifically converted that one file via the "Convert to STL" button on its detail page (
stl_status=done, see Targeted STL Conversion). This never happens automatically during a scan or routine re-render — otherwise every.blend/.obj/.ply/.scadfile in the entire inventory would get converted along, which is explicitly not wanted.
The slicer worker itself stays unchanged and simple: in both
cases it just gets handed a finished .stl — for .stl originals directly from
SOURCE_ROOT, for every other format the derived stl_path from
RENDERS_ROOT (see Slicer Worker). Exception: the
public Print Queue for anonymous hackspace uploads
deliberately stays .stl-only — files there come
unauthenticated from strangers, and .blend (potentially auto-executing
embedded Python scripts) and .scad (full OpenSCAD/CGAL pipeline, DoS
potential) remain too risky for this upload path. The
format extension only applies to files already cataloged in the internal
slicer flow.
Edit Fields per Selected Part
- Position (X/Y): no input fields — positioning happens exclusively via dragging on the 3D print bed.
- Rotation X/Y/Z: free numeric fields (degrees).
- Size X/Y/Z (mm): shows/sets the actual edge length in mm along the respective
bed axis (not the part's native STL axis — after a 90° rotation, "Size Z" shows the
actual vertical edge, not the originally native Z axis).
itemBedSizeMm()/setItemBedSizeMm()inIndex.vuerotate the native half-extents for this (fromfile.mesh_metadata.bbox×scale_x/y/z) with the same rotation matrix asitemFootprintPolygon()into bed space and sum, per bed axis, the native extents weighted by|matrix entry|(the standard formula for the AABB of a rotated box). At rotations in 90° steps, this reduces exactly to a 1:1 renaming of the axis. When editing, the native axis with the largest weight is adjusted for the edited bed axis, the contribution of the other two native axes stays fixed — at rotation angles off 90° multiples, several native axes can contribute similarly strongly, in which case only the most dominant one is still adjusted. Internally still stored as a scale factor (scale_x/scale_y/scale_z, see "Data Model"), only the UI shows/accepts mm.
Print Bed and Placement
The active print bed comes from the selected printer profile. In the frontend, the user does select a printer, but the profile is authoritative on submit. Each profile is mapped to a printer via the filename.
Configured printers:
| Key | Label | Bed |
|---|---|---|
ender5max |
Ender 5 Max | 400 x 400 x 400 mm |
ender5s1 |
Ender 5 S1 | 220 x 220 x 280 mm |
Coordinate System in the Frontend
The user coordinates are classic bed coordinates:
x: 0 to bed widthy: 0 to bed depth- Origin: front/left in the SVG/topdown context
In PlateViewer.vue, this becomes a Three.js world position:
group.position.x = item.x - props.bed.x / 2;
group.position.z = props.bed.y / 2 - item.y;
So the 3D scene is centered around the bed's middle.
Auto-Arrange
Index.vue uses a simple shelf bin-packing approach:
- largest footprints first
- row layout
- target width from the square root of the total area
- the block is centered on the bed's middle
BED_MARGIN = 6- positions are clamped to the bed boundaries
The footprint (itemFootprint()) is the axis-aligned box around itemFootprintPolygon() (see below).
Collision Warning
Collision checking uses real mesh-vs-mesh intersection tests (three-mesh-bvh), not a bounding-
box/hull approximation — this correctly detects collisions after tilting (rotation_x/rotation_y)
too, and doesn't falsely flag interlockable, concave parts (e.g. two "C"-shaped parts) as
colliding.
For each part, when loaded, a merged, position-only geometry is built from all meshes under modelRoot (GLB or placeholder box)
in modelRoot's local coordinate system
(buildCollisionGeometry()) and given a bounds tree once
(geometry.computeBoundsTree()). On every repositioning/rotation (recomputeCollisions(),
called at the end of syncItems() as well as after every load), the exact transformation matrix
between the two modelRoot frames is computed for every pair of parts via
boundsTree.intersectsGeometry(otherGeometry, matrix), and a real geometric intersection test is
performed. PlateViewer.vue colors the affected parts itself and reports the colliding IDs to
Index.vue via a colliding event (purely for displaying the warning line, not fed back as a prop —
otherwise there's a risk of a reactive loop). itemFootprintPolygon()/itemFootprint() remain in
Index.vue, but are now only used by autoArrange() for the (still axis-aligned) shelf packing.
The warning doesn't prevent submission. It only shows that parts overlap in the frontend.
Print Queue
A print queue per printer under /druckwarteschlange. Anyone submitting an STL file
provides a note (textarea, e.g. material/color) as well as a freely entered quality and
infill wish — both are purely recommendations to the operator, they never automatically
flow into an actual slicing job.
Activation and Public Access (Both Configurable)
Two independent switches under /admin/settings (Setting singleton row, App\Models\Setting):
print_queue_enabled(defaulttrue): turns the queue off completely whenfalse— every route then returns404, regardless of login. Existingprint_requestsrows remain in the DB and are immediately back once reactivated.print_queue_public_access(defaultfalse): only takes effect if the queue is enabled. By default, reachable only for logged-in users like the rest of the app; when enabled, it also lets guests without an account submit and see the board (hackspace use case, shared printers).
Both checks run in App\Http\Middleware\EnsureQueueAccessAllowed (alias queue.access,
replaces auth on all routes in routes/queue.php, in this order: first
print_queue_enabled, then print_queue_public_access). Changing status/deleting always stays
tied to a real login, independent of this.
routes/queue.php, like routes/admin.php, is registered via bootstrap/app.php's then:
callback, not part of the large auth group in routes/web.php.
print_queue_enabled is additionally shared globally with every Inertia page
(HandleInertiaRequests::share(), prop printQueueEnabled) — this lets
AppLayout.vue hide the "Print Queue" nav link without every controller that uses
AppLayout having to pass this value separately.
Upload Security Measures
The print queue is the only place in the app that accepts file uploads (otherwise
only read access to the existing data inventory). PrintQueueController::store() is built
correspondingly carefully:
.stlonly — regardless of login mode. No.blend(can contain embedded, potentially automatically running Python scripts) and no.scad(full OpenSCAD/CGAL pipeline) for files that, in public mode, can come from unknown people.- Content sanity check before writing (not a full parser): ASCII STL
(
solidprefix +facetoccurrence) or binary STL (triangle count from byte offset 80 checked against file size, the identical formula toslicer-worker/main.py'sload_stl()). - Its own size limit (50 MB) below the global
php.inilimit (upload_max_filesize=64M). - The target path is generated server-side (
print-queue/{printer-key}/{uuid}/{sanitizedName}.stl) — the original filename never ends up raw in the path, the same pattern asFileOperations::buildTargetPath(). - Rate limit per IP at the app level (
RateLimiter::for('print-queue-upload', ...),AppServiceProvider::boot()) and additionally at the Traefik level (3dvault-queuelimit,deploy/docker-compose.yml, analogous to the existing3dvault-loginlimitfor/login) — an abuse attempt is thereby already caught before PHP-FPM/Laravel. - A honeypot field (
website) in the form — invisible to humans, a bot fills it in and gets rejected without comment.
Catalog Integration
Submitted files become a completely normal File record, just like a NAS scan find
(a reduced variant of ScanFiles::runScan(): content_hash, extension, size, render_status='pending',
then RenderFile::dispatch()) — they therefore show up in the gallery, duplicate detection, search like every
other cataloged file, just under their own print-queue/ prefix.
From Request to Actual Print
A logged-in user (no admin requirement) can "Open in Slicer" an open request —
deep link /slicer?file_id=…&fulfills_request=…, extending the existing placeFile pattern in
SlicerController::index()/Slicer/Index.vue with a second, accompanying parameter. The request's
infill wish is parsed server-side from the free text (the first number 0–100)
and pre-filled as suggestedInfillDensity — the quality wish (layer height), on the other hand, deliberately
isn't, because there's no guarantee that a matching profile even exists (unlike
the infill percentage, which is a pure form field regardless of the chosen profile). As soon as
this becomes a real slicing job (SlicerController::store()), the app automatically
sets sliced_file_id and status='in_druck' on the PrintRequest. If the associated SlicedFile
is later deleted (destroy()/destroyAll()), the request jumps back to status='offen',
instead of staying stuck on "being printed" without a job.
Status values: offen (open) → in_druck (printing, automatic) → erledigt/storniert (done/canceled, manual, any
logged-in user; deleting the request itself remains admin-only, consistent with the app's
other delete conventions). Deleting only removes the PrintRequest row, not the underlying catalog file — that
can be managed like any other file via normal file deletion.
Print Status and Print Logs
The slicing status only means:
queued: job waitingslicing: worker runningdone: G-code was generatedfailed: slicing failed
It doesn't say whether it has already been printed.
The actual print state in the UI is derived from the most recent PrintLog per SlicedFile:
| PrintLog Status | UI State |
|---|---|
| no log | prepared |
druckt (printing) |
printing |
gedruckt (printed) |
printed |
fehlgeschlagen (failed) |
print_failed |
verschenkt (given away) |
given_away |
The scheduler calls every 5 minutes:
php artisan prints:check
The command queries the appropriate printer for active logs and updates the status if the outcome can be determined reliably.
Operations
Checking Status
cd deploy
docker compose ps
docker compose logs --tail=100 app queue render slicer
Rebuilding
cd deploy
docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d --build
(Without Traefik, use -f docker-compose.yml -f docker-compose.standalone.yml accordingly.)
Restarting the App Container
docker compose restart app queue scheduler watcher
In production use, check beforehand whether a slicing or render job is currently running:
docker compose logs --tail=50 queue
Useful Artisan Commands
docker compose exec app php artisan files:scan
docker compose exec app php artisan files:scan --dry-run
docker compose exec app php artisan files:scan --no-render
docker compose exec app php artisan files:render
docker compose exec app php artisan files:render --all
docker compose exec app php artisan files:render --limit=50
docker compose exec app php artisan prints:check
docker compose exec app php artisan queue:failed
docker compose exec app php artisan route:list
docker compose exec app php artisan about
docker compose exec app php artisan app:ensure-admin-user --email=<address>
Queue worker:
docker compose logs -f queue
Worker health:
docker compose exec slicer python3 -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read())"
docker compose exec render python3 -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read())"
Changing a Slicer Profile / Creating a New Profile
Under /admin/slicer-profiles (admin login required): edit a profile or "New Profile" —
curated fields plus the generic extra_settings line list for everything else. Takes effect
immediately for the next slicing job, no container restart needed (see
Printers and Slicer Profiles).
Adding a New Printer
Every printer has exactly one driver plugin (see Driver Plugins).
- If a matching plugin already exists (e.g.
creality-websocket/octoprint, or a self-written/imported one): under/admin/printers→ "New Printer" → select the plugin, fill in the fields it defines — no deploy needed. - If no matching plugin exists yet: write or import a new driver plugin under
/admin/driver-plugins(also no deploy needed). - Create matching slicer profiles for the new printer under
/admin/slicer-profiles.
Security
Positive points:
- Complete login layer (Fortify, Argon2id, optional 2FA, see Authentication and Admin Backend) — the app is publicly reachable, but not usable without authentication.
- All deleting actions are restricted to admin users (middleware + hidden UI, see Delete Permissions).
- Double rate limiting against brute force: application-side (Fortify, email+IP and IP-only) and additionally at the Traefik level (
3dvault-loginlimit). - Security headers (HSTS,
X-Content-Type-Options,X-Frame-Options,Referrer-Policy) set additively via Traefik. - Sensitive admin actions require a fresh password confirmation (
password.confirm). - Render, slicer, and driver-runner workers only sit on the internal Docker network.
- Driver plugin code (admin-only) runs exclusively isolated in the
driverscontainer with no DB/session/APP_KEYaccess, with resource/time limits and additional Compose hardening (cap_drop,no-new-privileges,read_onlyroot FS,pids_limit,mem_limit, non-root) — see Driver Plugins. - The source mount is read-only for workers.
- Only the app container has write access to the source mount.
- Composer/npm/Artisan and the long-running processes
queue:work/schedule:work/the watcher run aswww-data(UID/GID adaptable to the host viaPUID/PGID), not as root. - Apache denies access to anything above
public/. - Laravel enforces HTTPS URL generation in production.
trustProxies()(bootstrap/app.php) is tied toSESSION_SECURE_COOKIE(the same signal asURL::forceScheme()), not unconditionallyat: '*'— in standalone operation (docker-compose.standalone.yml, app container directly exposed via host port), any direct client could otherwise freely fakeX-Forwarded-Forand thereby make the IP-based rate limiters (Fortify login brute-force protection, print-queue upload limiter) ineffective. The app only trusts forwarded headers whenSESSION_SECURE_COOKIE=trueis set (Traefik operation, the only path reachable purely through the proxy) — in standalone operation,$request->ip()returns the real TCP peer address.- Paths in the render and slicer workers are validated against
SOURCE_ROOT. Printer::driver_config(among other things the OctoPrint API key) is stored encrypted in the DB withencrypted:array— a DB dump/backup therefore doesn't contain the credential in plain text..envanddeploy/.envare not tracked in Git.Storage::disk('local')hasserve => false— the app doesn't actively use the generatedstorage/{path}routes anywhere, so they're completely disabled rather than merely secured.- Print-queue uploads (see Print Queue):
.stlonly with a content sanity check, server-generated storage path, its own size limit, app and Traefik rate limit, honeypot field — changing status/deleting always stays tied to a real login, independent of the public toggle.
Deliberate security trade-offs (by explicit request, not accidentally left open):
SESSION_LIFETIME=43200(30 days), remember-me active by default. A stolen session or remember cookie stays valid for that long — 2FA only protects at login itself, not afterward.- There's an unused Traefik middleware definition (
3dvault-lanonly) for an IP allowlist that is currently not referenced by any router — ready to use if needed. - The password rule doesn't require special characters (min. 9 characters, upper/lowercase, numbers, leak check).
Setting::print_queue_public_accesscan deliberately make the print queue (submitting + board) fully reachable without authentication — the default isfalse(login-only), the activation is an explicit per-instance admin decision, not an oversight.
Open items / hardening potential:
- The render and slicer containers parse foreign/complex 3D files. In the medium term, they should get
cap_drop,security_opt: no-new-privileges,pids_limit, CPU/memory limits, and possibly a read-only root FS. This also applies to the publicly reachableappcontainer. - Deployment is bind-mount-based and not immutable.
- Migrations run automatically at app start.
- Server-side validation of the print bed currently only checks the center-point coordinate, not the full transformed footprint.
- There's no fine-grained role/permission system, only a binary
is_adminflag per group — deliberately sufficient for the current use case (admin/user), but not a basis for more differentiated access rights without a rework. Printer::driver_configis encrypted withAPP_KEY, but there's no documented key-rotation process in this repo. IfAPP_KEYis rotated without migrating this column along with it,driver_configbecomes permanently undecryptable — before any rotation, make sure to work out how encrypted columns migrate along with it.- The content check for print-queue uploads (
PrintQueueController::assertLooksLikeStl()) is deliberately a best-effort sanity check, not a full parser and not a virus scan — it filters out obvious nonsense, but is no hard guarantee against every kind of manipulated file.
Known Functional Limits
Print Bed Boundaries
SlicerController::store() validates:
xbetween0andbed.xybetween0andbed.y
The slicer worker likewise only validates the center position.
It's currently not checked whether the XY footprint, transformed by rotation, scaling, and Z-lift, lies entirely within the print bed. A large part can therefore have its center within the bed but still extend past the edge.
Recommended fix:
- after
transform_stl()in the slicer worker, compute the real XY bounding box of the transformed triangles - check against
0 <= x <= bed.xand0 <= y <= bed.y - abort with 422 on a violation
Collisions
The frontend collision check (in PlateViewer.vue, see "Collision Warning" above for the development stages and architecture):
- tests each part's real mesh geometry against the others (
three-mesh-bvh), not just a bounding box, hull, or rectangle — concave cutouts (e.g. two interlockable "C" parts) are therefore correctly not flagged as a collision, as long as the triangles don't actually touch - accounts for
rotation_x,rotation_y, ANDrotation_zas well asscaleimplicitly, because the transformed real geometry is tested directly - does not prevent submission
This is useful as a UI hint, but not as a binding slicing safeguard.
Supported Slicer File Formats
The plater can do more than just .stl — every format from
config('slicer.supported_extensions') becomes sliceable once the user has specifically
converted it to STL (see Targeted STL Conversion).
The slicer worker itself still only parses .stl:
SUPPORTED_EXTENSIONS = {".stl"}
in slicer-worker/main.py — Laravel always delivers it a finished STL, whether native
or derived by the render worker, see Slicer Worker.
Real limits that remain:
.3mfand.step/.stpcan neither be rendered nor converted (see Supported File Formats further up).- Conversion is a manual step per file, not an automatic bulk import — deliberately decided this way so that the entire inventory doesn't run through the Blender export on every scan.
- The public print queue still exclusively accepts
.stluploads (a security boundary for anonymous submissions, see Print Queue).
Rotation
The rotation mapping between GLB/Three.js and STL/PrusaSlicer is deliberately complex and extensively commented in the code. Only make changes to it with real test models and by comparing the preview against the transformed STL/G-code.
Dashboard Query Does Not Work Under SQLite
Under SQLite (the test suite always runs against SQLite via phpunit.xml, see
Tests), DashboardController::index() returns a 500 — the "top projects" query
uses having('files_count', '>', 0) on a column alias, which SQLite's stricter HAVING rules
reject (HAVING clause on a non-aggregate query). Not affected under MariaDB (production
operation). Currently only affects the test suite — no test calls GET //GET /gallery.
Architecture
The following sections describe 3D Vault's technical structure in detail — helpful if you want to contribute, debug your own driver plugin beyond this guide, or understand the application more deeply. For pure self-hosting operation, you don't need anything past this point.
The application is a Laravel 13 project with Inertia/Vue/Three.js on the frontend and several isolated worker containers for rendering, slicing, and printer drivers:
Browser
|
| HTTPS
v
Traefik proxy network
|
v
3dvault-app (Laravel, Apache, PHP)
|\
| \-- MariaDB (db)
| \-- Redis (queue/cache/session)
| \-- Meilisearch
| \-- render-worker (FastAPI + Blender + OpenSCAD)
| \-- slicer-worker (FastAPI + PrusaSlicer)
| \-- driver-runner (FastAPI, isolated - executes driver plugin code)
|
+-- /data/source -> ${VAULT_SOURCE_PATH}
+-- storage/app/public/renders
+-- storage/app/private/gcodes
Docker Services
| Service | Container | Task |
|---|---|---|
app |
3dvault-app |
web app, Laravel, Apache, migrations, caches, frontend build (Node/npm/Vite) |
queue |
deploy-queue-1 |
Laravel queue worker for scan, render, and slice jobs |
scheduler |
3dvault-scheduler |
php artisan schedule:work |
watcher |
3dvault-watcher |
inotify watcher on /data/source, triggers files:scan |
db |
3dvault-db |
MariaDB 11.4 |
redis |
3dvault-redis |
Redis 7 for queue, cache, sessions |
meilisearch |
3dvault-meilisearch |
Meilisearch v1.11 |
render |
3dvault-render |
FastAPI worker for Blender/OpenSCAD |
slicer |
3dvault-slicer |
FastAPI worker for PrusaSlicer |
drivers |
3dvault-drivers |
FastAPI worker, executes driver plugin code in isolation (see Driver Plugins) |
Deployment
The stack is controlled from the deploy directory, via three compose files (see
Self-Hosting above for the full flow):
docker-compose.yml (base, always included) plus exactly one of the two overlay files
docker-compose.standalone.yml (host port, no Traefik) or docker-compose.traefik.yml
(Traefik labels + external proxy network):
cd deploy
docker compose -f docker-compose.yml -f docker-compose.traefik.yml ps
docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d --build
docker compose -f docker-compose.yml -f docker-compose.traefik.yml logs -f app queue render slicer
Important: the code is mounted into the containers via bind mount:
volumes:
- ..:/var/www/html
This makes the deployment deliberately flexible, but not immutable. Changes to the project directory take effect directly in the containers. When the app container starts, deploy/docker/entrypoint.sh handles:
composer install --no-dev --optimize-autoloader, if needednpm ci && npm run build, ifpublic/build/manifest.jsonis missing orpackage-lock.jsonis newer — a required step, without which every page fails with "Vite manifest not found".public/build/is deliberately not in the repo (generated output,.gitignore) and gets covered by the bind mount, so apublic/buildbaked into the image wouldn't help anyway — hence Node/npm indeploy/Dockerfileand the build here in the entrypoint instead of as a Docker build stage.- creating storage/cache directories
- setting permissions for
storage,bootstrap/cache,/var/log/3dvault php artisan storage:linkphp artisan migrate --forcephp artisan config:cachephp artisan route:cachephp artisan view:cache
Queue, scheduler, and watcher use the same image but don't run the heavy setup steps. They wait on the healthcheck of the app container.
The first start therefore takes noticeably longer than later restarts (Composer and
npm install/build both run once, including vite build) — that's normal, not a hang.
Data Model
files
Central table for all cataloged files in the source mount.
Important fields:
path: relative path within/data/sourcefilenamefolder_pathextensionsizesource_mtimecontent_hash: SHA-256 over the file contentis_backup_variantrevision_grouprevision_numbersource_platformsource_item_idsource_licensethumbnail_pathtopdown_thumbnail_pathglb_pathstl_status:pending|converting|done|failed— status of the targeted STL conversion triggered by the user per file (see Targeted STL Conversion), independent ofrender_statusstl_error: error text whenstl_status=failedstl_path: only set after successful targeted conversion — makes the file selectable in the slicer/plater (File::scopeSliceable())mesh_metadatarender_statusrender_errorproject_idlast_seen_atmissing_sincedeleted_at
After a successful render, mesh_metadata typically contains:
{
"bbox": {
"x_mm": 100.0,
"y_mm": 80.0,
"z_mm": 25.0
},
"triangle_count": 12345,
"geometry_hash": "sha256..."
}
sliced_files
Stores a generated G-code version.
Important fields:
file_id: primary file of the bedprofile_keyprofile_namefilament: frozen fromSlicerProfile::filament_typeat slicing timeprinter_keyprinter_label,printer_bed(JSON{x,y,z}): frozen fromPrinterat slicing time (see Printers and Slicer Profiles — reason: printers/profiles are editable/deletable; without a snapshot this would corrupt old history)infill_densityinfill_patternsupport_modesupport_styleplate_itemsstatus:queued,slicing,done,failedgcode_pathgcode_filenamegcode_thumbnail_pathgcode_sizegcode_hashestimated_print_timefilament_used_mmfilament_used_gerrorsliced_atuploaded_atstarted_at
plate_items freezes the print bed at slicing time:
[
{
"file_id": 123,
"path": "folder/model.stl",
"filename": "model.stl",
"x": 200.0,
"y": 200.0,
"rotation_x": 0,
"rotation_y": 0,
"rotation_z": 90,
"scale_x": 1.0,
"scale_y": 1.0,
"scale_z": 1.0
}
]
groups
Fields: id, name, is_admin, timestamps. users.group_id points to it. Pre-seeded: "Administrators" (is_admin=true), "Users" (is_admin=false). Details in Authentication and Admin Backend.
printers
Fields: id, key (unique), label, bed_x/bed_y/bed_z, driver_type, driver_plugin_id
(FK, restrictOnDelete(), nullable, only set when driver_type === 'plugin'), driver_config
(JSON, encrypted:array cast), timestamps, deleted_at. Details in
Printers and Slicer Profiles.
driver_plugins
Fields: id, key (unique), label, description, author, source_code (TEXT, unencrypted),
config_schema (JSON), timestamps, deleted_at. Details in Driver Plugins.
slicer_profiles
Fields: id, key (unique), printer_id (FK, restrictOnDelete()), name, curated
profile settings (layer_height, first_layer_height, nozzle_diameter, filament_diameter,
filament_type, gcode_flavor, four temperature fields, perimeters, top_solid_layers,
bottom_solid_layers, retract_length, retract_speed, start_gcode, end_gcode),
extra_settings (JSON, key→value), timestamps, deleted_at. Details in
Printers and Slicer Profiles.
print_logs
Stores manual and automatically generated print history.
Automatic entries are created on print start via SlicerController::sendToPrinter() with status druckt (printing). The scheduler command prints:check later updates them to gedruckt (printed) or fehlgeschlagen (failed), if the printer status allows it.
settings
Singleton table (exactly one row). Fields: id, print_queue_public_access
(boolean, default false), print_queue_enabled (boolean, default true), timestamps. Accessed
exclusively via Setting::current(). Details in Print Queue.
print_requests
Fields: id, file_id (FK files, cascadeOnDelete()), printer_id (FK
printers, restrictOnDelete()), user_id (FK users, nullable, nullOnDelete() — null for
anonymous submission), submitted_by_name, notes, quality_wish, infill_wish, status
(offen|in_druck|erledigt|storniert), sliced_file_id (FK sliced_files, nullable,
nullOnDelete()), timestamps. Details in Print Queue.
Render Pipeline
Render jobs run via App\Jobs\RenderFile and the FastAPI worker in render-worker.
Supported render formats:
.stl.obj.ply.blend.blend1.scad
.scad is first compiled to STL with OpenSCAD. Blender then renders:
- 3/4 thumbnail:
storage/app/public/renders/{id}.png - topdown thumbnail:
storage/app/public/renders/{id}-top.png - GLB for Three.js:
storage/app/public/renders/{id}.glb - JSON metadata:
storage/app/public/renders/{id}.json
The render worker uses Blender 5.2.0 from the official Blender download, not the Debian package. Reason: older Debian Blender versions can fail on Blender 5 files.
OpenSCAD comes from apt and is only used for .scad.
Targeted STL Conversion
In addition to the thumbnail/GLB, the render worker can export a
canonical, triangulated STL from the same geometry
(storage/app/public/renders/{id}.stl, File::stl_path) — making an otherwise
non-.stl format sliceable in the slicer/plater (see Slicer UI),
without the slicer worker itself needing to parse anything beyond .stl.
Deliberately NEVER runs automatically during a routine scan/re-render — only
when Laravel explicitly passes include_stl: true to the /render call
(RenderRequest.include_stl, default false). Without this separation,
every .blend/.blend1/.obj/.ply/.scad file in the inventory would get
converted automatically at the next scan — for a historically grown
archive (many .blend files aren't print models at all, but e.g.
pure Blender scenes), that's neither desired nor cheap (every conversion is an
additional, potentially failing Blender export step).
Conversion is triggered exclusively, per file, via the
"Convert to STL" button on the file detail page
(FilesController::convertToStl(), POST /files/{file}/convert-to-stl,
App\Jobs\RenderFile::dispatch($file->id, forceStl: true)), available to
any logged-in user (no admin restriction, analogous to rerender).
Prerequisite: render_status=done (thumbnail/GLB must already work,
otherwise the STL conversion would just repeat the same failure,
see File::isStlConvertible()).
Its own status per file, separate from render_status/render_error:
stl_status:pending(never requested) →converting(job running) →done(stl_pathset) orfailed(stl_error).- On a failed repeated export (e.g. because the source file changed),
stl_pathdeliberately stays at the last working state instead of being nulled — the file therefore doesn't retroactively disappear from the slicer. - Sticky refresh: if an already converted file changes content later
(a new scan detects a changed
content_hash,files:scantriggersRenderFile::dispatch()withoutforceStl), the STL still gets automatically updated as well (RenderFile::handle()additionally checksstl_status === 'done') — without conversion needing to be manually re-triggered. For a file that was never converted, on the other hand,stl_statusstayspendingforever, regardless of how often it's re-scanned/re-rendered. - An error during STL export doesn't take down the already successful thumbnail/GLB
result of the same render run — both steps run in the same
Blender process, but the STL export sits in
blender_render.pyin its owntry/except.
Native .stl files don't need this conversion — the slicer
always uses the original file directly there, regardless of render status.
Rotation and Axis Mapping
The preview doesn't load the STL directly, but the GLB generated by Blender.
In PlateViewer.vue, the model is normalized:
object.position.sub(box.getCenter(new THREE.Vector3()));
Rotation and scaling sit on modelRoot:
modelRoot.rotation.set(
degToRad(item.rotation_x),
degToRad(item.rotation_z),
degToRad(-item.rotation_y),
);
modelRoot.scale.set(item.scale_x, item.scale_z, item.scale_y);
Scaling is per axis (scale_x/scale_y/scale_z, see "Data Model" and "Edit Fields per Selected Part" above) and always acts along the part's own, native STL axes, regardless of its current rotation — in Three.js's TRS composition, scaling sits before rotation, exactly as in the slicer worker (transform_stl() scales first, rotates after).
After every sync, the part is lifted via applyFlushHeight() so that its lowest point sits on the bed.
World axes:
| UI field | Three.js axis | Sign |
|---|---|---|
rotation_x |
X | + |
rotation_y |
Z | - |
rotation_z |
Y | + |
Slicer Worker
The slicer worker lives in:
slicer-worker/main.py
It's a FastAPI service with:
GET /healthPOST /slice
Environment variables:
| Variable | Default | Purpose |
|---|---|---|
SOURCE_ROOT |
/data/source |
source mount (native .stl originals) |
RENDERS_ROOT |
/data/renders |
read-only mount of storage/app/public/renders — canonical STLs derived by the render worker for all sliceable non-.stl formats (see Render Pipeline) |
OUTPUT_ROOT |
/data/output |
G-code output |
THUMBNAIL_OUTPUT_ROOT |
/data/thumbnails |
G-code thumbnail output |
PRUSASLICER_BIN |
prusa-slicer |
binary |
SLICE_TIMEOUT_SECONDS |
900 |
PrusaSlicer timeout |
The worker itself still only reads .stl:
SUPPORTED_EXTENSIONS = {".stl"}
The worker doesn't need to be able to parse other formats
(obj/ply/blend/blend1/scad) for this — Laravel already sends a finished
.stl per plate item (for non-.stl originals, the stl_path generated by the
render worker), plus a root field indicating whether path should be resolved relative to
SOURCE_ROOT or RENDERS_ROOT (resolve_source(path, root)).
Input to /slice
Laravel sends:
idprofile_keyprofile_ini: the full PrusaSlicer INI text, generated bySlicerProfile::toIni(). The worker writes it itself to a temp file and loads it via--load, see Printers and Slicer Profilesrequested_filenamebeditemsoptions
Example:
{
"id": 62,
"profile_key": "ender5max-008",
"profile_ini": "printer_technology = FFF\nbed_shape = ...\n...",
"requested_filename": "3dbenchy-0p08mm-ender-5-max-15pct-gyroid.gcode",
"bed": { "x": 400, "y": 400, "z": 400 },
"items": [
{
"path": "3DBenchy.stl",
"root": "source",
"x": 200,
"y": 200,
"rotation_x": 0,
"rotation_y": 0,
"rotation_z": 0,
"scale_x": 1,
"scale_y": 1,
"scale_z": 1
}
],
"options": {
"infill_density": 15,
"infill_pattern": "gyroid",
"support_mode": "none",
"support_style": "organic"
}
}
STL Transformation
The worker:
- resolves the relative path securely against
SOURCE_ROOTorRENDERS_ROOT(depending onitem.root) - loads binary or ASCII STL
- computes the bounding box
- centers the geometry on the bounding box's midpoint
- scales per axis (
scale_x/scale_y/scale_z) - rotates analogously to the Three.js preview
- lifts the model to Z=0
- shifts it to
item.xanditem.y - writes a temporary ASCII STL per part
The axis conversion in the worker:
tx, ty, tz = x, z, -y
The rotations are then applied in the order matching Three.js's Euler composition XYZ. The result is mapped back onto slicer axes:
return tx, tz, ty
PrusaSlicer Invocation
The worker calls PrusaSlicer roughly like this:
prusa-slicer \
--export-gcode \
--load /profiles/<profile>.ini \
--output /data/output/<id>-<filename>.gcode \
--dont-arrange \
--merge \
--fill-density <n>% \
--fill-pattern <grid|gyroid> \
[support options] \
item-1.stl item-2.stl ...
Important:
--dont-arrangeis crucial so PrusaSlicer doesn't rearrange the already computed XY coordinates.--mergeensures the passed temporary STL files are sliced as a shared plate.- Transformations don't happen via PrusaSlicer CLI transform flags, but beforehand via custom STL geometry output.
G-code Metadata
After successful slicing, the worker reads from the G-code:
- estimated print time
- filament usage in mm
- filament usage in g
It also computes a SHA-256 over the G-code.
G-code Thumbnails
The worker renders a simple plate preview from the transformed temporary STLs:
- 96x96 PNG for the printer/G-code thumbnail
- 300x300 PNG for the Vault UI
The PNGs are embedded as base64 comment blocks in the G-code:
; THUMBNAIL_BLOCK_START
; png begin 96*96 ...
; ...
; png end
; THUMBNAIL_BLOCK_END
The thumbnail meta lines in the G-code are also adjusted:
; thumbnails = 96x96/PNG,300x300/PNG
; thumbnails_format = PNG
Routes
The app uses classic Laravel web routes with Inertia views.
Main Pages
| Route | Controller | Purpose |
|---|---|---|
GET / |
DashboardController@index |
dashboard with statistics |
GET /gallery |
GalleryController@index |
gallery and search |
GET /duplicates |
DuplicatesController@index |
duplicates view |
GET /slicer |
SlicerController@index |
slicer UI |
GET /trash |
TrashController@index |
trash |
GET /tags |
TagController@index |
tags |
GET /projects |
ProjectController@index |
projects |
Slicer
| Route | Purpose |
|---|---|
GET /slicer |
show the slicer UI |
POST /slicer |
create a slicing job |
GET /slicer/{slicedFile} |
G-code detail page |
GET /slicer/{slicedFile}/download |
download the G-code |
POST /slicer/{slicedFile}/print |
upload G-code and start the print |
DELETE /slicer/{slicedFile} |
delete a single G-code version from the vault (admin only) |
DELETE /slicer |
delete all completed G-code versions from the vault (admin only) |
Files
| Route | Purpose |
|---|---|
GET /files/{file} |
file detail |
GET /files/{file}/original |
serve the image file directly |
POST /files/{file}/rerender |
re-queue a render job |
POST /files/{file}/move |
move a file within the source mount |
POST /files/{file}/copy |
copy a file |
DELETE /files/{file} |
move a file to .trash/ and soft-delete it (admin only) |
POST /files/{file}/tags |
attach a tag |
DELETE /files/{file}/tags/{tag} |
remove a tag (admin only) |
POST /files/{file}/project |
assign a project |
POST /files/{file}/print-logs |
create a manual print log |
Bulk, Scan, Logs
| Route | Purpose |
|---|---|
POST /scan |
queue a scan job |
GET /scan/status |
scan status for the frontend |
POST /files/bulk/delete |
move several files to the trash (admin only) |
POST /files/bulk/move |
move several files |
POST /files/bulk/copy |
copy several files |
POST /files/bulk/project |
set a project |
POST /files/bulk/tags |
set a tag |
PATCH /print-logs/{printLog} |
correct an automatic print log |
DELETE /print-logs/{printLog} |
delete a print log (admin only) |
DELETE /trash/{fileId} |
permanently delete a file from the trash (admin only) |
POST /trash/empty |
empty the trash completely (admin only) |
DELETE /tags/{tag} |
delete a tag (admin only) |
DELETE /projects/{project} |
delete a project (admin only) |
For the full list of all admin-restricted delete endpoints, see Delete Permissions above.
Tests
Feature tests for authentication:
tests/Feature/Auth/LoginTest.phptests/Feature/Auth/LoginRateLimitTest.phptests/Feature/Auth/TwoFactorChallengeTest.phptests/Feature/GuestCannotAccessProtectedRoutesTest.php(architecture test: checks all registered routes against an explicit allowlist instead of maintaining individual route tests)tests/Feature/MustChangePasswordTest.phptests/Feature/Admin/AdminMiddlewareTest.phptests/Feature/Admin/LastAdminProtectionTest.phptests/Feature/Admin/UserCreationTest.phptests/Feature/Admin/PrinterCrudTest.php(create, edit, delete lock when profiles reference it, successful delete without references)tests/Feature/Admin/SlicerProfileCrudTest.php(create includingextra_settings/toIni()roundtrip, edit, delete)tests/Feature/PrintQueueTest.php(guest redirect when not public, 404 for everyone whenprint_queue_enabled=false, submitting as guest/logged-in,.blendupload gets rejected, rate limit kicks in, infill wish is pre-filled when opened in the slicer, taking it into the slicer linkssliced_file_id/setsstatus='in_druck')tests/Feature/Admin/SettingsTest.php(admin can toggleprint_queue_enabled/print_queue_public_access, non-admin gets 403)tests/Feature/ExampleTest.php(Laravel default, adapted to the new login requirement)tests/Unit/ExampleTest.php(Laravel default, unchanged)
Important: phpunit.xml sets force="true" on the relevant <env> entries — otherwise Docker
container environment variables would override PHPUnit's <env> values, and php artisan test
would connect to the real production DB instead of an isolated test DB.
For the original (non-auth) part of the app, there are still no dedicated tests for:
- slicer request validation
- STL transformation
- rotation/axis mapping
- print bed boundaries
- collision logic
PrinterDriverbehavior- render worker responses
Sensible next tests:
- Unit test for
SlicerProfile::toIni()(newline roundtrip,bed_shapederivation). - Feature test for
POST /slicer. - Python test for
rotate_vertex()andtransform_stl(). - Regression test with a small asymmetric STL for X/Y/Z rotation.
- Test for full XY bed boundaries after transformation.
Important Files
| Path | Purpose |
|---|---|
deploy/docker-compose.yml |
production Docker stack |
deploy/Dockerfile |
PHP/Apache app image |
deploy/docker/entrypoint.sh |
app start, Composer, migrations, caches |
deploy/docker/watch-source.sh |
inotify watcher for the source mount |
deploy/docker/apache-vhost.conf |
Apache vhost and public-only hardening |
config/slicer.php |
only worker_url/supported_extensions now (printers/profiles live in the DB) |
app/Models/Printer.php |
printer model, encrypted:array driver_config, bed() helper |
app/Models/SlicerProfile.php |
slicer profile model, toIni() generator |
app/Http/Controllers/Admin/PrinterController.php |
admin backend: printer management |
app/Http/Controllers/Admin/SlicerProfileController.php |
admin backend: profile management |
config/filesystems.php |
local/public/source disks |
routes/web.php |
web routes |
app/Http/Controllers/SlicerController.php |
slicer UI, job creation, download, print start |
app/Jobs/SliceFile.php |
Laravel queue job to the slicer worker |
app/Jobs/RenderFile.php |
Laravel queue job to the render worker, including targeted STL conversion (forceStl) |
app/Http/Controllers/FilesController.php |
file detail, re-render, targeted STL conversion (convertToStl()) |
app/Models/File.php |
scopeSliceable()/isStlConvertible() — determine slicer selectability and convertibility |
slicer-worker/main.py |
FastAPI slicer, STL transformation, PrusaSlicer CLI |
render-worker/main.py |
FastAPI render wrapper, including optional STL export (include_stl) |
render-worker/blender_render.py |
Blender script for render, GLB, metadata, and optional STL export |
resources/js/Pages/Slicer/Index.vue |
slicer UI |
resources/js/Components/PlateViewer.vue |
Three.js print bed |
resources/js/Components/PlatePreviewSvg.vue |
topdown plate preview |
app/Services/PrinterDrivers/* |
printer abstraction and implementations (including PluginPrinterDriver) |
app/Services/VaultExport.php |
shared export/import file format (marker + checksum) for slicer profiles and driver plugins |
app/Models/DriverPlugin.php |
driver plugin model |
app/Http/Controllers/Admin/DriverPluginController.php |
admin backend: plugin management, export/import, /validate proxy |
driver-runner/main.py |
isolated FastAPI service, executes plugin code per call in a throwaway subprocess |
driver-runner/_entry.py |
fixed execution harness for a single plugin call |
driver-runner/examples/example_http_driver.py |
reference example for the plugin contract |
app/Services/FileOperations.php |
safe file operations within the source mount |
app/Console/Commands/ScanFiles.php |
source scan |
app/Console/Commands/CheckPrints.php |
print-status reconciliation |
app/Providers/FortifyServiceProvider.php |
Fortify configuration: views, rate limiters, authenticateUsing |
config/fortify.php |
activated Fortify features, password_timeout |
app/Http/Middleware/EnsureUserIsAdmin.php |
middleware alias admin |
app/Http/Middleware/EnsurePasswordIsChanged.php |
middleware alias password.change |
app/Models/Group.php |
group model (is_admin) |
app/Support/LastAdminGuard.php |
protection against removing/demoting the last admin |
app/Http/Controllers/Admin/UserController.php |
admin backend: user management |
app/Http/Controllers/Admin/GroupController.php |
admin backend: group management |
app/Http/Controllers/Auth/ForcedPasswordChangeController.php |
mandatory password change on first login |
app/Notifications/AccountCreated.php |
mail with credentials for newly created users |
app/Console/Commands/EnsureAdminUser.php |
app:ensure-admin-user, idempotent initial creation of an admin |
resources/js/composables/useConfirmsPassword.js |
proactive password confirmation before password.confirm actions |
resources/js/Components/ConfirmPasswordModal.vue |
modal for password confirmation |
resources/js/Layouts/AdminLayout.vue |
layout for /admin/* |
routes/queue.php |
print-queue routes, registered independently (not part of routes/web.php's auth group) |
app/Http/Middleware/EnsureQueueAccessAllowed.php |
middleware alias queue.access, replaces auth depending on Setting::print_queue_public_access |
app/Models/Setting.php |
singleton settings model, Setting::current() |
app/Models/PrintRequest.php |
print-queue request model |
app/Http/Controllers/PrintQueueController.php |
board, submission (upload + validation), status/delete |
app/Http/Controllers/Admin/SettingsController.php |
admin backend: singleton settings |
resources/js/Layouts/PrintQueueLayout.vue |
its own lean layout, works even without a logged-in user |
resources/js/Pages/PrintQueue/Index.vue |
queue board |
resources/js/Pages/PrintQueue/Create.vue |
submission form |
Development
Local standard commands from composer.json:
composer install
npm install --ignore-scripts
npm run build
php artisan migrate
Dev mode:
composer run dev
Dev mode starts:
- Laravel dev server
- queue listener
- Pail logs
- Vite
In production, however, Docker Compose is authoritative (see Self-Hosting).
License
GPL-3.0-or-later. Full license text in LICENSE.