17 KiB
upload-GLTF
A secure web interface for uploading model.gltf with its associated .bin file and textures with two outputs:
- Nextcloud Drive — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions
- Git remote — Delivers Draco-compressed GLB assets by default, with an optional GLTF delivery mode for specific models
Built for La Fabrik Durable's internal use, but open-sourced for anyone looking for a similar solution. The app validates the upload locally, stages it server-side, then compares file diffs to avoid unnecessary uploads and commits. The Drive upload serves as the source of truth and version history, while the Git upload delivers the prepared assets to developers.
Stack
- Next.js 16.2.5 (App Router) + React 19 + TypeScript
- Three.js (React Three Fiber + Drei) for 3D preview
- Tailwind CSS for styling
- Octokit for pushing via a GitHub-compatible API (GitHub or Gitea)
- Nextcloud WebDAV for Drive archiving with automatic versioning
- Sharp for server-side texture compression
- npm lockfile + Coolify (Docker) for hosting
Usage
Development
npm ci
npm run dev
The app runs on http://localhost:3000 with hot reload. The upload API routes are available under http://localhost:3000/api/upload/*
Use npm for this repo. package-lock.json is the source of truth for local installs and Coolify builds; no pnpm/yarn lockfile should be committed here.
Dependency and security policy
The project pins next to 16.2.5 to include the May 2026 WebSocket SSRF fix (CVE-2026-44578 / GHSA-c4j6-fc7j-m34r). Do not loosen this back to ^16.2.4 or any range that can resolve below 16.2.5.
This repo also keeps install-time package scripts disabled by default through .npmrc and Docker:
npm ci --ignore-scripts --no-audit --no-fund
When a dependency update is needed, prefer a lockfile-only update in a clean environment with no .env, no Git token, and no cloud credentials:
mkdir -p /tmp/npm-clean-home /tmp/npm-clean-cache
env -i \
HOME=/tmp/npm-clean-home \
PATH="$PATH" \
npm_config_userconfig=/tmp/npm-clean-home/.npmrc \
npm_config_cache=/tmp/npm-clean-cache \
npm_config_ignore_scripts=true \
npm_config_audit=false \
npm_config_fund=false \
npm install <package>@<version> --package-lock-only --ignore-scripts --no-audit --no-fund --save-exact
The May 2026 TanStack incident affected malicious package versions published to npm, not the npm CLI itself. This repo does not depend on @tanstack/*, but other projects should be checked for @tanstack/setup, github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c, router_init.js, tanstack_runner.js, and the fake unscoped package tanstack@2.0.4 through 2.0.7.
How it works
- The user enters their access key
- They select a folder containing:
model.gltf(required)- Any associated binary buffer (
.bin, for examplemodel.bin) - Any associated textures (
.png/.jpg/.jpeg/.webp)
- The folder is validated locally.
.glbfiles are not accepted. - On clicking "Envoyer" or "Envoyer en GLTF":
- The app uploads the folder once to a temporary server-side staging area
- The app prepares the final Git payload from this staging area
- The app checks the remote Git repo for existing files and computes diffs
- If the folder doesn't exist, upload proceeds directly
- If the folder exists and files differ, a confirmation dialog shows only the actual changes
- If nothing changed, the upload is skipped entirely
Asset naming convention
Texture filenames can either use the internal convention or common GLTF export suffixes. The Git payload is normalized automatically and model.gltf texture URIs are rewritten to match the normalized filenames. Drive archiving keeps the original artist files.
Internal convention: use asset.png to apply a texture to the whole model, or asset_object.png to target a specific object.
Allowed families are defined in lib/asset-naming.ts: color, diffuse, roughness, normal, metalness, height, opacity, orm, ao.
Valid internal examples: color.png, diffuse_lampe.png, normal_cable1.png, opacity_lampe.png.
Accepted export examples: lampe_baseColor.png, lampe_base_color.png, lampe_normal_opengl.png, lampe_metallic.png, lampe_occlusionRoughnessMetallic.png, lampe_mixed_ao.png.
Git normalization examples: lampe_baseColor.png becomes color_lampe.png, lampe_metallic.png becomes metalness_lampe.png, lampe_occlusionRoughnessMetallic.png becomes orm_lampe.png, and lampe_mixed_ao.png becomes ao_lampe.png.
When several exported textures would normalize to the same filename, all colliding variants keep their original names to avoid data loss. For example, chap_normal.png and chap_normal_opengl.png are both kept as-is.
Invalid or unknown asset names still block the upload.
Upload flow: Drive first, then Git
- Drive upload (archiving) — Original files from the staging area are uploaded to the Nextcloud Drive with automatic versioning (see below). This serves as the artists' source of truth and version history. If the Drive upload fails, a modal asks the user whether to send to Git only or cancel entirely.
- Git upload (delivery to devs) — The prepared Git payload is reused from staging. By default, Blender exports a single
model.glbwith Draco compression. If Blender cannot process a specific model, the upload falls back to the separatemodel.gltf+.bin+ compressed textures workflow and shows a warning. For specific models, "Envoyer en GLTF" keeps that separate GLTF delivery mode from the start.
Drive versioning (Nextcloud WebDAV)
The Drive uses a VF (version finale) / Vx (archived versions) structure:
Models/
VF/ ← latest version
coffeetest/
model.gltf
model.bin
color.jpg
V1/ ← first archive
coffeetest/
V2/ ← second archive
coffeetest/
- New folder (doesn't exist in
VF/): files are uploaded directly toVF/{folderName}/ - Replace (folder exists in
VF/with diffs):VF/{folderName}is moved toVx/{folderName}(next available version), then all files are re-uploaded toVF/{folderName}/ - No changes: nothing happens on the Drive
All files are uploaded to VF/ (not just diffs), because the move operation empties the previous folder.
Upload safeguards
- The upload flow prevents duplicate submissions on the client (
Envoyer, overwrite confirmation, and "Git only" confirmation are locked while processing) - The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
- The folder is staged server-side so the browser sends the payload only once during the full upload flow
- Invalid
model.gltfJSON or malformedbuffersentries block the upload before remote writes - Git LFS uploads are batched in groups of 100 objects to stay within the LFS Batch API limit
Commit messages
All changes are pushed in a single commit with a grouped formatted message:
New folder:
update: upload-gltf add a new model -> my-model
📦 Model
✅ model.glb (compressed)
Update (only one texture changed):
update: upload-gltf update -> coffeetest
📦 Model
🔄 model.glb (compressed)
Commit sections:
📦 Model🎨 Textures (color)🪶 Textures (roughness)🧭 Textures (normal)🔩 Textures (metalness)🧩 Assets🗑 Deleted
Symbols: ✅ new — 🔄 modified — ↔️ unchanged (model always re-pushed) — ❌ deleted
- Orphan files (present on remote but not in the new upload) are deleted in the same commit
- Default Git delivery pushes
model.glbwhen Blender compression succeeds. If Blender fails, the app falls back to separatemodel.gltf,.bin, and compressed textures with a warning. "Envoyer en GLTF" always uses separate GLTF delivery.
Uploaded models are pushed to public/models/<folderName>/ in the target repo.
Limitations
- Large uploads are staged once, but the Drive upload remains sequential.
- Git LFS batch uploads are sequential by batch.
- Default GLB Draco delivery reduces Git LFS usage by replacing many support files with one compressed model file.
- Uploads expect a single
model.gltffile plus optional flat support files (.bin,.png,.jpg,.jpeg,.webp).
Project Structure
app/
├── api/upload/
│ ├── stage/route.ts # POST: upload folder once to temporary staging
│ ├── check/route.ts # POST: prepare staged Git assets and compare with remote files
│ ├── drive/route.ts # POST: upload staged originals to Nextcloud Drive (VF/Vx versioning)
│ └── git/route.ts # POST: push staged prepared assets to the Git remote
├── globals.css # Tailwind + CSS variable fonts
├── layout.tsx # Root layout (next/font/google)
└── page.tsx # Home page
components/
├── ui/
│ ├── icons.tsx # Shared SVG icon components
│ └── Modal.tsx # Shared modal wrapper + ModalActions
├── upload/
│ ├── SecretInput.tsx # Access key input
│ ├── FolderDropzone.tsx # Folder drag & drop / picker
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line
│ ├── WarningBanner.tsx # Missing texture warnings
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
│ ├── NoChangesModal.tsx # "No changes detected" dialog
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
│ └── ActionButtons.tsx # Upload / Cancel / Reset buttons
├── UploadZone.tsx # Main upload page (rendering only)
├── ModelViewer.tsx # 3D viewer shell, stats panel, and hierarchy panel
└── SceneViewer.tsx # Three.js Canvas, model stats, and scene hierarchy extraction
hooks/
├── useSecret.ts # Secret key state management
├── useFolderEntries.ts # Folder entries state management
└── useUploadOrchestrator.ts # Upload pipeline orchestration (Drive → Git)
lib/
├── constants.ts # Shared constants and extensions
├── types.ts # Server types (ParsedFile, FileDiff, staged asset metadata, etc.)
├── client-types.ts # Client types (FolderEntry, DriveStatus, viewer contracts, etc.)
├── upload-api.ts # Client-side API helpers (stage, check, uploadDrive, uploadGit)
├── guards.ts # Shared runtime guards and error message helpers
├── diff-files.ts # File diff classification (new/changed/unchanged/deleted)
├── sanitize.ts # Filename sanitization
├── auth.ts # Upload secret validation (timing-safe)
├── github.ts # Octokit helpers for GitHub-compatible remotes
├── nextcloud.ts # Nextcloud WebDAV client (native fetch, cached config)
├── upload-staging.ts # Temporary server-side staging and prepared asset reuse
├── upload-lock.ts # Lightweight in-memory per-folder upload lock
├── asset-classification.ts # Group assets by family for commit messages
├── asset-naming.ts # Allowed asset families and naming convention helpers
├── blender.ts # Blender Draco compression helper
├── commit-message.ts # Commit message builder
├── parse-upload.ts # FormData parser + validation
├── validate-folder.ts # Client-side folder validation (discriminated union)
└── format-bytes.ts # Byte formatting utility
scripts/
└── compress.py # Blender Draco compression script
Dockerfile # Multi-stage build: Node 20 slim + Blender + tini
docker-entrypoint.sh # Upload temp setup + Blender availability check
Installation
git clone https://github.com/La-Fabrik-Durable/upload-GLTF.git
cd upload-GLTF
npm ci
Configuration
Copy .env.example to .env.local and fill in the values:
UPLOAD_SECRET_KEY=your-secret-key-here
GIT_USERNAME=your-gitea-username
GIT_TOKEN=your-git-provider-token
GIT_BRANCH=main
GIT_REPO_URL=https://git.example.com/your-org/your-repo
# Nextcloud Drive (public share WebDAV)
NEXTCLOUD_URL=https://cloud.example.com
NEXTCLOUD_SHARE_TOKEN=your-public-share-token
NEXTCLOUD_SHARE_PASSWORD=
NEXTCLOUD_BASE_PATH=Models
| Variable | Description | Required |
|---|---|---|
UPLOAD_SECRET_KEY |
Secret key for upload authentication | Yes |
GIT_USERNAME |
Git username for Git LFS Basic auth on Gitea. Required for Gitea when LFS files are uploaded. | Gitea LFS |
GIT_TOKEN |
Git provider token with repository read/write access. GITHUB_TOKEN is still accepted for backward compatibility. |
Yes |
GIT_BRANCH |
Target branch (default: main) | No |
GIT_REPO_URL |
Target GitHub or Gitea repository URL (owner/repo, HTTPS, or SSH) |
Yes |
NEXTCLOUD_URL |
Nextcloud instance URL | Yes |
NEXTCLOUD_SHARE_TOKEN |
Public share token (the part after /s/ in the share link) |
Yes |
NEXTCLOUD_SHARE_PASSWORD |
Public share password (empty if none) | No |
NEXTCLOUD_BASE_PATH |
Root folder on the Drive (default: Models) |
No |
GitHub tokens need
Contents: Read and write. Gitea tokens need repository read/write access.
To create a Nextcloud public share token: Nextcloud > Files > select folder > Share > Create public share > set permissions (write access required) > copy the share link and extract the token
Production (Coolify / Docker)
Coolify must build this repository with the included Dockerfile. The dependency stage copies .npmrc and runs npm ci --ignore-scripts --no-audit --no-fund, so the deployed dependency tree comes from package-lock.json.
After a security patch:
- Push the commit to both remotes.
- In Coolify, trigger a rebuild with cache disabled when possible.
- Confirm the build logs show
npm ci --ignore-scripts --no-audit --no-fund. - Confirm the app starts and the upload flow still reaches staging, Drive, and Git.
- Rotate secrets in Coolify, then redeploy once more with the new values.
docker build -t upload-gltf .
docker run -p 3000:3000 \
-e UPLOAD_SECRET_KEY=your-key \
-e GIT_USERNAME=your-gitea-username \
-e GIT_TOKEN=token_xxx \
-e GIT_REPO_URL=https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik \
-e NEXTCLOUD_URL=https://cloud.example.com \
-e NEXTCLOUD_SHARE_TOKEN=your-share-token \
upload-gltf
The Docker image runs the Next.js app, Blender Draco compression, and server-side asset preparation in a single container. The docker-entrypoint.sh script creates the upload temp directory and reports Blender availability before launching the app.
Secret rotation
Rotate secrets after patching if the previous deployment exposed a vulnerable Next.js version, if a suspicious dependency install happened on a machine with credentials, or if you cannot prove the install host was clean.
Recommended order:
- Generate a new
GIT_TOKENlimited to the target model repository with read/write access. - Generate a new long random
UPLOAD_SECRET_KEY. - Regenerate the Nextcloud public share token or password when possible.
- Update the variables in Coolify.
- Redeploy the patched image.
- Revoke the old Git token and old Nextcloud share credentials after the new deployment is healthy.
Do not commit real .env files. .dockerignore excludes .env and .env.*, while keeping .env.example as documentation.
Publishing to remotes
This repo is mirrored to GitHub and Gitea:
git remote -v
git push origin main
git push gitea main
If your local git config has the pushall alias, it should be equivalent to:
git push origin main && git push gitea main
Supported Formats
| Type | Extensions |
|---|---|
| 3D Models | .gltf |
| Binary buffers | .bin |
| Textures | .png, .jpg, .jpeg, .webp |
Git delivery outputs .glb by default, or keeps the source .gltf structure when "Envoyer en GLTF" is selected.
License
See MIT.
Copyright 2026 La Fabrik Durable. All rights reserved.