update: add gestion erreur si dossier est existant

This commit is contained in:
Tom Boullay
2026-04-14 13:26:49 +02:00
parent c795082ca4
commit 2b3d02e489
4 changed files with 287 additions and 27 deletions
+1
View File
@@ -2,3 +2,4 @@ UPLOAD_SECRET_KEY=your-secret-key-here
GITHUB_TOKEN=ghp_your-github-personal-access-token GITHUB_TOKEN=ghp_your-github-personal-access-token
GIT_BRANCH=main GIT_BRANCH=main
GIT_REPO_URL=https://github.com/your-org/your-repo.git GIT_REPO_URL=https://github.com/your-org/your-repo.git
BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
+40 -11
View File
@@ -28,17 +28,18 @@ UPLOAD_SECRET_KEY=your-secret-key-here
GITHUB_TOKEN=ghp_your-github-personal-access-token GITHUB_TOKEN=ghp_your-github-personal-access-token
GIT_BRANCH=main GIT_BRANCH=main
GIT_REPO_URL=https://github.com/your-org/your-repo.git GIT_REPO_URL=https://github.com/your-org/your-repo.git
BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
``` ```
| Variable | Description | Required | | Variable | Description | Required |
|----------|-------------|----------| |----------|-------------|----------|
| `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes | | `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes |
| `GITHUB_TOKEN` | GitHub Personal Access Token (scope `repo`) | Yes | | `GITHUB_TOKEN` | GitHub Personal Access Token (fine-grained, `Contents: Read and write`) | Yes |
| `GIT_BRANCH` | Target branch (default: main) | No | | `GIT_BRANCH` | Target branch (default: main) | No |
| `GIT_REPO_URL` | Target GitHub repository URL | Yes | | `GIT_REPO_URL` | Target GitHub repository URL | Yes |
| `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No | | `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No |
> To create a token: GitHub > Settings > Developer settings > Personal access tokens > Generate new token (classic) with the `repo` scope. > To create a token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write.
## Usage ## Usage
@@ -50,7 +51,7 @@ npm run dev
Access the app at `http://localhost:3000` Access the app at `http://localhost:3000`
> **Note:** Draco compression requires Blender installed locally. If Blender is not available, models are pushed to GitHub without compression. > **Note:** Draco compression requires Blender installed locally. On macOS, Blender is typically at `/Applications/Blender.app/Contents/MacOS/Blender`. Set `BLENDER_PATH` in `.env.local` accordingly. If Blender is not available, models are pushed to GitHub without compression.
### Production (Coolify / Docker) ### Production (Coolify / Docker)
@@ -68,25 +69,53 @@ The Dockerfile includes Blender headless for automatic Draco compression.
## How it works ## How it works
1. The user enters their access key 1. The user enters their access key
2. They select a folder containing: 2. They pick a **destination** (`farm`, `map`, `powergrid`, `workshop`, `general`, `environment`)
3. They select a folder containing:
- `model.glb` or `model.gltf` (required) - `model.glb` or `model.gltf` (required)
- Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, optional) - Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, optional)
3. The model is displayed in a 3D preview 4. The model is displayed in a 3D preview
4. On clicking "Upload & Push to GitHub", each file is sent to the `/api/upload` endpoint 5. On clicking "Envoyer sur GitHub":
5. For models: the file is written to `/tmp`, compressed with Blender Draco, then the compressed version is pushed to GitHub - The app checks if the folder already exists on the remote repo
6. For textures: pushed directly to GitHub without compression - If it exists, a confirmation dialog is shown listing the existing files that will be replaced
7. If Blender is unavailable, the original model is pushed as-is (graceful fallback) - On confirmation (or if the folder is new), all files are sent to the `/api/upload` endpoint
6. For models: the file is written to `/tmp`, compressed with Blender Draco, then the compressed version is pushed
7. For textures: pushed directly without compression
8. All files are pushed in a **single commit** with a formatted message:
```
update: upload-gltf add a new model -> farm/my-model
📦 Model
✅ model.gltf (compressed)
🎨 Textures
✅ color.jpg
❌ metalness (manquant)
```
9. If the folder already existed, orphan files (present in the old version but not in the new upload) are deleted in the same commit
10. If Blender is unavailable, the original model is pushed as-is (graceful fallback)
## Destinations
Uploaded models are pushed to `public/models/<destination>/<folderName>/` in the target repo:
| Destination | Path |
|-------------|------|
| Farm | `public/models/farm/` |
| Map | `public/models/map/` |
| Powergrid | `public/models/powergrid/` |
| Workshop | `public/models/workshop/` |
| General | `public/models/general/` |
| Environment | `public/models/environment/` |
## Project Structure ## Project Structure
``` ```
app/ app/
├── api/upload/route.ts # API: validation + Draco compression + GitHub push ├── api/upload/route.ts # API: GET (check existence) + POST (compress + push)
├── globals.css # Tailwind + Google Fonts ├── globals.css # Tailwind + Google Fonts
├── layout.tsx # Root layout ├── layout.tsx # Root layout
└── page.tsx # Home page └── page.tsx # Home page
components/ components/
├── UploadZone.tsx # UI: key input, folder picker, validation, upload ├── UploadZone.tsx # UI: key input, destination picker, folder picker, validation, overwrite confirmation, upload
├── ModelViewer.tsx # Lazy wrapper for the 3D viewer ├── ModelViewer.tsx # Lazy wrapper for the 3D viewer
└── SceneViewer.tsx # Three.js Canvas └── SceneViewer.tsx # Three.js Canvas
scripts/ scripts/
+120 -15
View File
@@ -15,6 +15,7 @@ const MODEL_EXTENSIONS = new Set(['.glb', '.gltf'])
const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']) const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS]) const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS])
const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace']
const VALID_DESTINATIONS = new Set(['farm', 'map', 'powergrid', 'workshop', 'general', 'environment'])
const TMP_DIR = join('/tmp', 'assets') const TMP_DIR = join('/tmp', 'assets')
@@ -89,11 +90,12 @@ async function compressWithBlender(
function buildCommitMessage( function buildCommitMessage(
folderName: string, folderName: string,
destination: string,
modelFilename: string, modelFilename: string,
textureNames: string[], textureNames: string[],
compressed: boolean, compressed: boolean,
): string { ): string {
const title = `update: from upload-gltf add a new model -> ${folderName}` const title = `update: upload-gltf add a new model -> ${destination}/${folderName}`
const foundTextures = new Set( const foundTextures = new Set(
textureNames.map(t => t.toLowerCase().replace(/\.[^.]+$/, '')) textureNames.map(t => t.toLowerCase().replace(/\.[^.]+$/, ''))
@@ -111,8 +113,7 @@ function buildCommitMessage(
title, title,
'', '',
'📦 Model', '📦 Model',
`${modelFilename}${compressed ? ' (Draco)' : ''}`, `${modelFilename}${compressed ? ' (compressed)' : ''}`,
'',
'🎨 Textures', '🎨 Textures',
...textureLines, ...textureLines,
] ]
@@ -133,12 +134,19 @@ interface ParsedFile {
async function parseMultiUpload(req: NextRequest): Promise<{ async function parseMultiUpload(req: NextRequest): Promise<{
folderName: string folderName: string
destination: string
files: ParsedFile[] files: ParsedFile[]
}> { }> {
const formData = await req.formData() const formData = await req.formData()
const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets' const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets'
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
const rawDestination = (formData.get('destination') as string | null)?.trim() || 'general'
if (!VALID_DESTINATIONS.has(rawDestination)) {
throw new Error(`Destination invalide: "${rawDestination}"`)
}
const destination = rawDestination
const fileEntries = formData.getAll('files') as File[] const fileEntries = formData.getAll('files') as File[]
const fileTypes = formData.getAll('fileTypes') as string[] const fileTypes = formData.getAll('fileTypes') as string[]
const textureNames = formData.getAll('textureNames') as string[] const textureNames = formData.getAll('textureNames') as string[]
@@ -176,7 +184,7 @@ async function parseMultiUpload(req: NextRequest): Promise<{
parsed.push({ filename, buffer, isModel, textureName: texName || undefined }) parsed.push({ filename, buffer, isModel, textureName: texName || undefined })
} }
return { folderName: safeFolderName, files: parsed } return { folderName: safeFolderName, destination, files: parsed }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -186,6 +194,7 @@ async function parseMultiUpload(req: NextRequest): Promise<{
async function pushAllToGitHub( async function pushAllToGitHub(
folderName: string, folderName: string,
files: { path: string; contentBase64: string }[], files: { path: string; contentBase64: string }[],
deletePaths: string[],
commitMessage: string commitMessage: string
): Promise<{ commitUrl: string }> { ): Promise<{ commitUrl: string }> {
const octokit = getOctokit() const octokit = getOctokit()
@@ -219,17 +228,30 @@ async function pushAllToGitHub(
) )
) )
// 4. Create a single tree with all files // 4. Create a single tree with all files (add new + delete orphans)
const newFilePaths = new Set(files.map(f => f.path))
const deleteEntries = deletePaths
.filter(p => !newFilePaths.has(p))
.map(p => ({
path: p,
mode: '100644' as const,
type: 'blob' as const,
sha: null,
}))
const { data: newTree } = await octokit.git.createTree({ const { data: newTree } = await octokit.git.createTree({
owner, owner,
repo, repo,
base_tree: commit.tree.sha, base_tree: commit.tree.sha,
tree: files.map((f, i) => ({ tree: [
path: f.path, ...files.map((f, i) => ({
mode: '100644' as const, path: f.path,
type: 'blob' as const, mode: '100644' as const,
sha: blobResults[i].data.sha, type: 'blob' as const,
})), sha: blobResults[i].data.sha,
})),
...deleteEntries,
],
}) })
// 5. Create a single commit // 5. Create a single commit
@@ -252,6 +274,65 @@ async function pushAllToGitHub(
return { commitUrl: newCommit.html_url } return { commitUrl: newCommit.html_url }
} }
// ---------------------------------------------------------------------------
// GET handler — check if folder already exists on remote
// ---------------------------------------------------------------------------
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const destination = searchParams.get('destination')?.trim()
const folderName = searchParams.get('folderName')?.trim()
const secret = req.headers.get('x-upload-secret')
const expectedSecret = process.env.UPLOAD_SECRET_KEY
if (!expectedSecret || !secret || secret !== expectedSecret) {
return NextResponse.json({ success: false, error: 'Non autorise' }, { status: 401 })
}
if (!destination || !folderName) {
return NextResponse.json({ success: false, error: 'Parametres manquants' }, { status: 400 })
}
if (!VALID_DESTINATIONS.has(destination)) {
return NextResponse.json({ success: false, error: 'Destination invalide' }, { status: 400 })
}
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
const folderPath = `public/models/${destination}/${safeFolderName}`
try {
const octokit = getOctokit()
const { owner, repo } = parseRepoUrl()
const branch = process.env.GIT_BRANCH ?? 'main'
const { data } = await octokit.repos.getContent({
owner,
repo,
path: folderPath,
ref: branch,
})
if (Array.isArray(data)) {
const existingFiles = data.map(f => f.name)
return NextResponse.json({
success: true,
exists: true,
path: folderPath,
files: existingFiles,
})
}
return NextResponse.json({ success: true, exists: false })
} catch (err: unknown) {
const status = (err as { status?: number })?.status
if (status === 404) {
return NextResponse.json({ success: true, exists: false })
}
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 500 })
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST handler // POST handler
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -277,10 +358,11 @@ export async function POST(req: NextRequest) {
// --- Parse all files --- // --- Parse all files ---
let folderName: string let folderName: string
let destination: string
let parsedFiles: ParsedFile[] let parsedFiles: ParsedFile[]
try { try {
;({ folderName, files: parsedFiles } = await parseMultiUpload(req)) ;({ folderName, destination, files: parsedFiles } = await parseMultiUpload(req))
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 }) return NextResponse.json({ success: false, error: message }, { status: 400 })
@@ -325,17 +407,40 @@ export async function POST(req: NextRequest) {
} }
filesToPush.push({ filesToPush.push({
path: `public/assets/${folderName}/${pf.filename}`, path: `public/models/${destination}/${folderName}/${pf.filename}`,
contentBase64: content.toString('base64'), contentBase64: content.toString('base64'),
}) })
} }
// --- Build commit message --- // --- Build commit message ---
const commitMessage = buildCommitMessage(folderName, modelFilename, textureNames, compressed) const commitMessage = buildCommitMessage(folderName, destination, modelFilename, textureNames, compressed)
// --- Detect existing files to clean up orphans ---
const folderPath = `public/models/${destination}/${folderName}`
let existingFilePaths: string[] = []
try {
const octokit = getOctokit()
const { owner, repo } = parseRepoUrl()
const branch = process.env.GIT_BRANCH ?? 'main'
const { data } = await octokit.repos.getContent({
owner,
repo,
path: folderPath,
ref: branch,
})
if (Array.isArray(data)) {
existingFilePaths = data.map(f => `${folderPath}/${f.name}`)
}
} catch {
// 404 = folder doesn't exist yet, no cleanup needed
}
// --- Push all in one commit --- // --- Push all in one commit ---
try { try {
const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, commitMessage) const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, existingFilePaths, commitMessage)
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
+125
View File
@@ -28,6 +28,15 @@ interface FolderEntry {
const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace']
const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'] const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp']
const DESTINATIONS = [
{ value: 'farm', label: 'Farm' },
{ value: 'map', label: 'Map' },
{ value: 'powergrid', label: 'Powergrid' },
{ value: 'workshop', label: 'Workshop' },
{ value: 'general', label: 'General' },
{ value: 'environment', label: 'Environment' },
] as const
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B' if (bytes === 0) return '0 B'
const k = 1024 const k = 1024
@@ -79,13 +88,35 @@ function validateFolder(files: File[]): { model?: File; textures: TextureFile[];
return result return result
} }
async function checkFolderExists(
folderName: string,
destination: string,
secret: string,
): Promise<{ exists: boolean; files: string[] }> {
try {
const params = new URLSearchParams({ folderName, destination })
const res = await fetch(`/api/upload?${params}`, {
headers: { 'x-upload-secret': secret.trim() },
})
const data = await res.json()
if (data.success && data.exists) {
return { exists: true, files: data.files || [] }
}
return { exists: false, files: [] }
} catch {
return { exists: false, files: [] }
}
}
async function uploadFolder( async function uploadFolder(
folder: FolderEntry, folder: FolderEntry,
secret: string, secret: string,
destination: string,
onProgress: (pct: number) => void onProgress: (pct: number) => void
): Promise<{ success: boolean; filename?: string; error?: string }> { ): Promise<{ success: boolean; filename?: string; error?: string }> {
const formData = new FormData() const formData = new FormData()
formData.append('folderName', folder.folderName) formData.append('folderName', folder.folderName)
formData.append('destination', destination)
// Model file // Model file
formData.append('files', folder.modelFile) formData.append('files', folder.modelFile)
@@ -130,7 +161,9 @@ export default function UploadZone() {
const [secretVisible, setSecretVisible] = useState(false) const [secretVisible, setSecretVisible] = useState(false)
const [globalError, setGlobalError] = useState<string | null>(null) const [globalError, setGlobalError] = useState<string | null>(null)
const [secretError, setSecretError] = useState<string | null>(null) const [secretError, setSecretError] = useState<string | null>(null)
const [destination, setDestination] = useState<typeof DESTINATIONS[number]['value']>(DESTINATIONS[0].value)
const [abortController, setAbortController] = useState<AbortController | null>(null) const [abortController, setAbortController] = useState<AbortController | null>(null)
const [overwriteConfirm, setOverwriteConfirm] = useState<{ folderName: string; files: string[] } | null>(null)
const isSecretEmpty = !secret.trim() const isSecretEmpty = !secret.trim()
@@ -146,6 +179,21 @@ export default function UploadZone() {
if (files.length === 0) return if (files.length === 0) return
setSecretError(null) setSecretError(null)
setGlobalError(null)
// Check if folder already exists on remote
const folder = files[0]
const check = await checkFolderExists(folder.folderName, destination, secret)
if (check.exists) {
setOverwriteConfirm({ folderName: folder.folderName, files: check.files })
return
}
await proceedUpload()
}
const proceedUpload = async () => {
setOverwriteConfirm(null)
setIsUploading(true) setIsUploading(true)
setGlobalError(null) setGlobalError(null)
@@ -157,6 +205,7 @@ export default function UploadZone() {
const result = await uploadFolder( const result = await uploadFolder(
files[i], files[i],
secret, secret,
destination,
(pct) => updateFile(i, { progress: pct }) (pct) => updateFile(i, { progress: pct })
) )
@@ -237,6 +286,28 @@ export default function UploadZone() {
)} )}
</div> </div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-300">Destination</label>
<div className="grid grid-cols-3 gap-2">
{DESTINATIONS.map((dest) => (
<button
key={dest.value}
type="button"
onClick={() => setDestination(dest.value)}
disabled={isUploading}
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all duration-150 border
disabled:opacity-50 disabled:cursor-not-allowed
${destination === dest.value
? 'bg-white text-[#000000] border-white'
: 'bg-black-800 text-gray-400 border-white/20 hover:border-white/40 hover:text-gray-200'
}`}
>
{dest.label}
</button>
))}
</div>
</div>
<input <input
type="file" type="file"
id="folder-input" id="folder-input"
@@ -448,6 +519,60 @@ export default function UploadZone() {
</button> </button>
)} )}
</div> </div>
{overwriteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-black-900 border border-white/20 rounded-2xl p-6 max-w-md w-full mx-4 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-yellow-900/30 flex items-center justify-center shrink-0">
<svg className="w-5 h-5 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-100">Dossier deja existant</h3>
<p className="text-xs text-gray-400 mt-0.5">
<span className="font-mono text-yellow-400">{destination}/{overwriteConfirm.folderName}</span> existe deja sur le repo.
</p>
</div>
</div>
{overwriteConfirm.files.length > 0 && (
<div className="bg-black-800 border border-white/10 rounded-xl p-3 max-h-32 overflow-y-auto">
<p className="text-xs text-gray-500 mb-1.5">Fichiers existants qui seront remplaces :</p>
<ul className="space-y-0.5">
{overwriteConfirm.files.map((f) => (
<li key={f} className="text-xs text-gray-400 font-mono">{f}</li>
))}
</ul>
</div>
)}
<p className="text-xs text-gray-400">
Les anciens fichiers seront supprimes et remplaces par les nouveaux. Cette action est irreversible.
</p>
<div className="flex gap-3">
<button
onClick={() => setOverwriteConfirm(null)}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150
hover:bg-black-600"
>
Annuler
</button>
<button
onClick={proceedUpload}
className="flex-1 bg-yellow-600 text-[#000000] font-medium text-sm
py-2.5 px-4 rounded-xl transition-colors duration-150
hover:bg-yellow-500"
>
Remplacer
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }