import { NextRequest, NextResponse } from 'next/server' import { Octokit } from '@octokit/rest' import { extname, basename, join } from 'path' import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises' import { existsSync } from 'fs' import { execFile } from 'child_process' import { promisify } from 'util' import { createHash } from 'crypto' const execFileAsync = promisify(execFile) export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const MODEL_EXTENSIONS = new Set(['.glb', '.gltf']) const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']) const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS]) 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') function sanitizeFilename(name: string): string { return basename(name) .replace(/[^a-zA-Z0-9._-]/g, '_') .replace(/_{2,}/g, '_') .toLowerCase() } function getOctokit(): Octokit { const token = process.env.GITHUB_TOKEN if (!token) throw new Error('GITHUB_TOKEN non configure') return new Octokit({ auth: token }) } function parseRepoUrl(): { owner: string; repo: string } { const url = process.env.GIT_REPO_URL if (!url) throw new Error('GIT_REPO_URL non configure') const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+)/) const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+)/) const shortMatch = url.match(/^([^/]+)\/([^/]+)$/) const match = httpsMatch || sshMatch || shortMatch if (!match) throw new Error(`Format GIT_REPO_URL invalide: "${url}"`) return { owner: match[1], repo: match[2] } } /** Compute the SHA that Git would assign to a blob with this content */ function computeGitBlobSha(content: Buffer): string { const header = `blob ${content.length}\0` const store = Buffer.concat([Buffer.from(header), content]) return createHash('sha1').update(store).digest('hex') } // --------------------------------------------------------------------------- // Blender Draco compression // --------------------------------------------------------------------------- async function compressWithBlender( inputPath: string, outputPath: string ): Promise<{ success: boolean; error?: string }> { const blenderPath = process.env.BLENDER_PATH || 'blender' const scriptPath = join(process.cwd(), 'scripts', 'compress.py') if (!existsSync(scriptPath)) { return { success: false, error: 'scripts/compress.py introuvable' } } try { await execFileAsync(blenderPath, [ '--background', '--python', scriptPath, '--', '-i', inputPath, '-o', outputPath, '--draco-level', '7', '--texture-size', '512', '-q', ], { timeout: 120_000 }) if (!existsSync(outputPath)) { return { success: false, error: 'Blender n\'a pas produit de fichier compresse' } } return { success: true } } catch (err) { const message = err instanceof Error ? err.message : String(err) return { success: false, error: `Compression Blender echouee: ${message}` } } } // --------------------------------------------------------------------------- // Build commit message with checklist // --------------------------------------------------------------------------- function buildCommitMessage( folderName: string, destination: string, modelFilename: string, textureNames: string[], compressed: boolean, isReplace: boolean, fileChanges: Map, deletedFileNames: string[], ): string { const title = isReplace ? `update: upload-gltf update -> ${destination}/${folderName}` : `update: upload-gltf add a new model -> ${destination}/${folderName}` const lines: string[] = [title, ''] // Model section — only show if changed or new const modelChange = fileChanges.get(modelFilename.toLowerCase()) if (modelChange === 'new') { lines.push('📦 Model') lines.push(` ✅ ${modelFilename}${compressed ? ' (compressed)' : ''}`) } else if (modelChange === 'changed') { lines.push('📦 Model') lines.push(` 🔄 ${modelFilename}${compressed ? ' (compressed)' : ''}`) } // unchanged → don't show model section at all // Textures section — only show lines that have changes const foundTextures = new Set( textureNames.map(t => t.toLowerCase().replace(/\.[^.]+$/, '')) ) const textureLines: string[] = [] // Changed or new textures for (const tex of REQUIRED_TEXTURES) { if (foundTextures.has(tex)) { const actual = textureNames.find(t => t.toLowerCase().replace(/\.[^.]+$/, '') === tex)! const change = fileChanges.get(actual.toLowerCase()) if (change === 'new') { textureLines.push(` ✅ ${actual}`) } else if (change === 'changed') { textureLines.push(` 🔄 ${actual}`) } // unchanged → skip } else if (!isReplace) { // Only show missing textures for new uploads textureLines.push(` ❌ ${tex} (manquant)`) } } // Deleted files (orphans removed from remote) for (const name of deletedFileNames) { textureLines.push(` ❌ ${name} (supprime)`) } if (textureLines.length > 0) { lines.push('🎨 Textures') lines.push(...textureLines) } return lines.join('\n') } // --------------------------------------------------------------------------- // Parse all files from a multi-file FormData // --------------------------------------------------------------------------- interface ParsedFile { filename: string buffer: Buffer isModel: boolean textureName?: string } async function parseMultiUpload(req: NextRequest): Promise<{ folderName: string destination: string files: ParsedFile[] }> { const formData = await req.formData() const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets' 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 fileTypes = formData.getAll('fileTypes') as string[] const textureNames = formData.getAll('textureNames') as string[] if (fileEntries.length === 0) { throw new Error('Aucun fichier recu') } const parsed: ParsedFile[] = [] for (let i = 0; i < fileEntries.length; i++) { const file = fileEntries[i] if (!file || file.size === 0) continue const fileType = fileTypes[i] || 'model' const texName = textureNames[i] || '' const originalSafe = sanitizeFilename(file.name) const ext = extname(originalSafe).toLowerCase() if (!ALL_ALLOWED_EXTENSIONS.has(ext)) { throw new Error(`Extension non autorisee: "${ext}"`) } let filename: string if (fileType === 'texture' && texName) { filename = sanitizeFilename(texName) } else { filename = originalSafe } const isModel = MODEL_EXTENSIONS.has(ext) const buffer = Buffer.from(await file.arrayBuffer()) parsed.push({ filename, buffer, isModel, textureName: texName || undefined }) } return { folderName: safeFolderName, destination, files: parsed } } // --------------------------------------------------------------------------- // Push all files in a single commit // --------------------------------------------------------------------------- async function pushAllToGitHub( folderName: string, files: { path: string; contentBase64: string }[], deletePaths: string[], commitMessage: string ): Promise<{ commitUrl: string }> { const octokit = getOctokit() const { owner, repo } = parseRepoUrl() const branch = process.env.GIT_BRANCH ?? 'main' // 1. Get latest commit on branch const { data: ref } = await octokit.git.getRef({ owner, repo, ref: `heads/${branch}`, }) const latestCommitSha = ref.object.sha // 2. Get that commit's tree const { data: commit } = await octokit.git.getCommit({ owner, repo, commit_sha: latestCommitSha, }) // 3. Create all blobs in parallel const blobResults = await Promise.all( files.map(f => octokit.git.createBlob({ owner, repo, content: f.contentBase64, encoding: 'base64', }) ) ) // 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({ owner, repo, base_tree: commit.tree.sha, tree: [ ...files.map((f, i) => ({ path: f.path, mode: '100644' as const, type: 'blob' as const, sha: blobResults[i].data.sha, })), ...deleteEntries, ], }) // 5. Create a single commit const { data: newCommit } = await octokit.git.createCommit({ owner, repo, message: commitMessage, tree: newTree.sha, parents: [latestCommitSha], }) // 6. Update branch ref await octokit.git.updateRef({ owner, repo, ref: `heads/${branch}`, sha: newCommit.sha, }) 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 => ({ name: f.name, sha: f.sha })) 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 // --------------------------------------------------------------------------- export async function POST(req: NextRequest) { // --- Auth --- const secret = req.headers.get('x-upload-secret') const expectedSecret = process.env.UPLOAD_SECRET_KEY if (!expectedSecret) { return NextResponse.json( { success: false, error: 'Configuration serveur incomplete (UPLOAD_SECRET_KEY manquant)' }, { status: 500 } ) } if (!secret || secret !== expectedSecret) { return NextResponse.json( { success: false, error: "Cle d'authentification invalide" }, { status: 401 } ) } // --- Parse all files --- let folderName: string let destination: string let parsedFiles: ParsedFile[] try { ;({ folderName, destination, files: parsedFiles } = await parseMultiUpload(req)) } catch (err) { const message = err instanceof Error ? err.message : 'Erreur inconnue' return NextResponse.json({ success: false, error: message }, { status: 400 }) } // --- Process files (compress model if possible) --- const filesToPush: { path: string; contentBase64: string }[] = [] let modelFilename = '' let compressed = false let compressionError: string | undefined const textureNames: string[] = [] for (const pf of parsedFiles) { let content = pf.buffer if (pf.isModel) { modelFilename = pf.filename // Write to /tmp for Blender compression const tmpFolder = join(TMP_DIR, folderName) await mkdir(tmpFolder, { recursive: true }) const tmpFilePath = join(tmpFolder, pf.filename) await writeFile(tmpFilePath, pf.buffer) const stem = pf.filename.replace(/\.[^.]+$/, '') const compressedPath = join(tmpFolder, `${stem}_compressed.glb`) const result = await compressWithBlender(tmpFilePath, compressedPath) if (result.success && existsSync(compressedPath)) { content = await readFile(compressedPath) compressed = true await unlink(compressedPath).catch(() => {}) } else { compressionError = result.error } await unlink(tmpFilePath).catch(() => {}) await rm(tmpFolder, { recursive: true, force: true }).catch(() => {}) } else { textureNames.push(pf.filename) } filesToPush.push({ path: `public/models/${destination}/${folderName}/${pf.filename}`, contentBase64: content.toString('base64'), }) } // --- Detect existing files and compare SHA to classify changes --- const folderPath = `public/models/${destination}/${folderName}` const remoteFileMap = new Map() // name -> sha 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)) { for (const f of data) { remoteFileMap.set(f.name.toLowerCase(), f.sha) } } } catch { // 404 = folder doesn't exist yet } const isReplace = remoteFileMap.size > 0 // Classify each file: changed, new, or unchanged type FileChange = 'new' | 'changed' | 'unchanged' const fileChanges = new Map() const changedFilesToPush: { path: string; contentBase64: string }[] = [] for (const f of filesToPush) { const filename = f.path.split('/').pop()! const localSha = computeGitBlobSha(Buffer.from(f.contentBase64, 'base64')) const remoteSha = remoteFileMap.get(filename.toLowerCase()) if (!remoteSha) { fileChanges.set(filename.toLowerCase(), 'new') changedFilesToPush.push(f) } else if (remoteSha !== localSha) { fileChanges.set(filename.toLowerCase(), 'changed') changedFilesToPush.push(f) } else { fileChanges.set(filename.toLowerCase(), 'unchanged') // skip — don't push unchanged files } } // Files on remote that are not in the new upload → will be deleted (orphans) const newFileNames = new Set(filesToPush.map(f => f.path.split('/').pop()!.toLowerCase())) const deletedFileNames: string[] = [] const deletePaths: string[] = [] for (const [name] of remoteFileMap) { if (!newFileNames.has(name)) { deletedFileNames.push(name) deletePaths.push(`${folderPath}/${name}`) } } // If nothing changed at all, don't create an empty commit if (changedFilesToPush.length === 0 && deletePaths.length === 0) { return NextResponse.json({ success: true, folderName, filesCount: 0, compressed, compressionError: compressionError || undefined, message: 'Aucun fichier modifie — rien a envoyer.', }) } // --- Build commit message --- const commitMessage = buildCommitMessage( folderName, destination, modelFilename, textureNames, compressed, isReplace, fileChanges, deletedFileNames, ) // --- Push all in one commit --- try { const { commitUrl } = await pushAllToGitHub(folderName, changedFilesToPush, deletePaths, commitMessage) return NextResponse.json({ success: true, folderName, filesCount: changedFilesToPush.length, compressed, compressionError: compressionError || undefined, message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur GitHub en un seul commit.`, commitUrl, }) } catch (err) { const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue' return NextResponse.json( { success: false, error: `Push GitHub echoue: ${message}` }, { status: 500 } ) } }