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' 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 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] } } // --------------------------------------------------------------------------- // 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, modelFilename: string, textureNames: string[], compressed: boolean, ): string { const title = `update: from upload-gltf add a new model -> ${folderName}` const foundTextures = new Set( textureNames.map(t => t.toLowerCase().replace(/\.[^.]+$/, '')) ) const textureLines = REQUIRED_TEXTURES.map(tex => { if (foundTextures.has(tex)) { const actual = textureNames.find(t => t.toLowerCase().replace(/\.[^.]+$/, '') === tex) return ` ✅ ${actual}` } return ` ❌ ${tex} (manquant)` }) const lines = [ title, '', '📦 Model', ` ✅ ${modelFilename}${compressed ? ' (Draco)' : ''}`, '', '🎨 Textures', ...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 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 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, files: parsed } } // --------------------------------------------------------------------------- // Push all files in a single commit // --------------------------------------------------------------------------- async function pushAllToGitHub( folderName: string, files: { path: string; contentBase64: 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 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, })), }) // 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 } } // --------------------------------------------------------------------------- // 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 parsedFiles: ParsedFile[] try { ;({ folderName, 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/assets/${folderName}/${pf.filename}`, contentBase64: content.toString('base64'), }) } // --- Build commit message --- const commitMessage = buildCommitMessage(folderName, modelFilename, textureNames, compressed) // --- Push all in one commit --- try { const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, commitMessage) return NextResponse.json({ success: true, folderName, filesCount: filesToPush.length, compressed, compressionError: compressionError || undefined, message: `${filesToPush.length} fichier(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 } ) } }