Files
2026-05-17 16:49:24 +02:00

20 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 using KTX2 textures, then WebP/original fallbacks 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

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

  1. The user enters their access key
  2. They select a folder containing:
    • model.gltf (required)
    • Any associated binary buffer (.bin, for example model.bin)
    • Any associated textures (.png/.jpg/.jpeg/.webp)
  3. The folder is validated locally. .glb files are not accepted.
  4. 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

  1. 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.
  2. Git upload (delivery to devs) — The prepared Git payload is reused from staging. By default, Blender exports a single model.glb with Draco compression. If Blender cannot process a specific model, the upload falls back to the separate model.gltf + .bin + optimized textures workflow and shows a warning. In separate GLTF delivery, each texture tries one optimized output in order: KTX2 with KHR_texture_basisu, then WebP through Sharp, then the original texture if all optimization fails. 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 to VF/{folderName}/
  • Replace (folder exists in VF/ with diffs): VF/{folderName} is moved to Vx/{folderName} (next available version), then all files are re-uploaded to VF/{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.gltf JSON or malformed buffers entries block the upload before remote writes
  • The client shows texture diagnostics before upload: missing GLTF image files, unsupported texture formats, duplicate flat filenames, unused textures, broad opacity maps, and all-transparent material exports
  • 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

  1. Orphan files (present on remote but not in the new upload) are deleted in the same commit
  2. Default Git delivery pushes model.glb when Blender compression succeeds. If Blender fails, the app falls back to separate model.gltf, .bin, and one delivered texture per source: .ktx2 when possible, .webp when KTX2 fails, or the original file when both optimizations fail. "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.
  • KTX2 is currently applied to separate GLTF delivery only. Embedding KTX2 inside the default GLB would require a glTF-aware optimizer step after Blender, not just image preprocessing before Blender.
  • Uploads expect a single model.gltf file 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
│   ├── TextureDiagnosticsPanel.tsx # Texture loading/export diagnostics
│   ├── 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)
├── git/                   # Git provider layer selected by env
│   ├── config.ts          # GIT_PROVIDER/GIT_REPO_URL/GIT_TOKEN parsing
│   ├── content.ts         # Shared remote folder/file content helpers
│   ├── http.ts            # Shared Git API request helpers
│   ├── index.ts           # Public getRemoteFolder/pushAllToGit facade
│   ├── lfs.ts             # Shared Git LFS upload helpers
│   └── providers/
│       ├── gitea.ts       # Gitea Contents API implementation
│       └── github.ts      # GitHub Git Data API implementation
├── 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
├── texture-compression.ts # KTX2/WebP texture delivery helpers
├── 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 + KTX-Software + tini
docker-entrypoint.sh       # Upload temp setup + Blender/toktx 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_PROVIDER=gitea
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
# Optional texture optimization
TOKTX_PATH=toktx
TEXTURE_MAX_SIZE=1024
# 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_PROVIDER Git provider adapter to use: github or gitea. If omitted, it is inferred from GIT_REPO_URL (github.com → GitHub, anything else → Gitea). No
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
TOKTX_PATH Path to the toktx binary used for KTX2 texture generation (default: toktx). No
TEXTURE_MAX_SIZE Maximum texture dimension used for KTX2/WebP separate GLTF delivery (default: 1024). No
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.

Git provider selection

The upload routes call a small provider layer in lib/git/:

  • lib/git/config.ts reads GIT_PROVIDER, GIT_REPO_URL, GIT_TOKEN, GIT_USERNAME, and GIT_BRANCH
  • lib/git/providers/github.ts handles GitHub commits with the Git Data API
  • lib/git/providers/gitea.ts handles Gitea commits with the Contents API
  • lib/git/lfs.ts handles Git LFS upload/auth for both providers

For GitHub:

GIT_PROVIDER=github
GIT_TOKEN=ghp_xxx
GIT_REPO_URL=https://github.com/org/repo.git
GIT_BRANCH=main

For Gitea:

GIT_PROVIDER=gitea
GIT_USERNAME=your-gitea-username
GIT_TOKEN=token_xxx
GIT_REPO_URL=https://git.fabrik.mathieu-chavanel.fr/math-pixel/La-Fabrik
GIT_BRANCH=main

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:

  1. Push the commit to both remotes.
  2. In Coolify, trigger a rebuild with cache disabled when possible.
  3. Confirm the build logs show npm ci --ignore-scripts --no-audit --no-fund.
  4. Confirm the app starts and the upload flow still reaches staging, Drive, and Git.
  5. 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_PROVIDER=gitea \
  -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, KTX2 texture generation, and server-side asset preparation in a single container. The docker-entrypoint.sh script creates the upload temp directory and reports Blender/toktx 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:

  1. Generate a new GIT_TOKEN limited to the target model repository with read/write access.
  2. Generate a new long random UPLOAD_SECRET_KEY.
  3. Regenerate the Nextcloud public share token or password when possible.
  4. Update the variables in Coolify.
  5. Redeploy the patched image.
  6. 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
Input textures .png, .jpg, .jpeg, .webp
Generated Git textures .ktx2, .webp, or original source extension as fallback

Git delivery outputs .glb by default, or keeps the source .gltf structure when "Envoyer en GLTF" is selected. Separate GLTF delivery tries KTX2 first, then WebP, then the original texture. When KTX2 is selected, model.gltf references it through KHR_texture_basisu.

License

See MIT.

Copyright 2026 La Fabrik Durable. All rights reserved.