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 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}` } } } // --------------------------------------------------------------------------- // Parse uploaded file from FormData // --------------------------------------------------------------------------- async function parseUpload(req: NextRequest) { const formData = await req.formData() const file = formData.get('file') as File | null const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets' const fileType = formData.get('fileType') as string | null const textureName = formData.get('textureName') as string | null if (!file || file.size === 0) { throw new Error('Aucun fichier recu') } const originalSafe = sanitizeFilename(file.name) const ext = extname(originalSafe).toLowerCase() if (!ALL_ALLOWED_EXTENSIONS.has(ext)) { throw new Error(`Extension non autorisee: "${ext}"`) } const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') let filename: string if (fileType === 'texture' && textureName) { filename = sanitizeFilename(textureName) } else { filename = originalSafe } const isModel = MODEL_EXTENSIONS.has(ext) const buffer = Buffer.from(await file.arrayBuffer()) return { filename, buffer, safeFolderName, isModel } } // --------------------------------------------------------------------------- // Push a single file to GitHub via the API // --------------------------------------------------------------------------- async function pushFileToGitHub( filePath: string, contentBase64: string, folderName: 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 blob const { data: blob } = await octokit.git.createBlob({ owner, repo, content: contentBase64, encoding: 'base64', }) // 4. Create tree const { data: newTree } = await octokit.git.createTree({ owner, repo, base_tree: commit.tree.sha, tree: [ { path: filePath, mode: '100644', type: 'blob', sha: blob.sha, }, ], }) // 5. Create commit const timestamp = new Date().toISOString() const { data: newCommit } = await octokit.git.createCommit({ owner, repo, message: `Design Update: ${folderName} [${timestamp}]`, 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 upload --- let filename: string let buffer: Buffer let safeFolderName: string let isModel: boolean try { ;({ filename, buffer, safeFolderName, isModel } = await parseUpload(req)) } catch (err) { const message = err instanceof Error ? err.message : 'Erreur inconnue' return NextResponse.json({ success: false, error: message }, { status: 400 }) } // --- Write to /tmp --- const tmpFolder = join(TMP_DIR, safeFolderName) await mkdir(tmpFolder, { recursive: true }) const tmpFilePath = join(tmpFolder, filename) await writeFile(tmpFilePath, buffer) let contentToUpload: Buffer = buffer let compressed = false let compressionError: string | undefined // --- Compress model with Blender (if available) --- if (isModel) { const stem = filename.replace(/\.[^.]+$/, '') const compressedPath = join(tmpFolder, `${stem}_compressed.glb`) const result = await compressWithBlender(tmpFilePath, compressedPath) if (result.success && existsSync(compressedPath)) { contentToUpload = await readFile(compressedPath) compressed = true // Clean up compressed temp file await unlink(compressedPath).catch(() => {}) } else { // Blender not available or failed — push original compressionError = result.error } } // Clean up original temp file await unlink(tmpFilePath).catch(() => {}) // Clean up folder if empty await rm(tmpFolder, { recursive: true, force: true }).catch(() => {}) // --- Push to GitHub --- const gitPath = `public/assets/${safeFolderName}/${filename}` const contentBase64 = contentToUpload.toString('base64') try { const { commitUrl } = await pushFileToGitHub(gitPath, contentBase64, safeFolderName) return NextResponse.json({ success: true, filename, compressed, compressionError: compressionError || undefined, message: compressed ? `"${filename}" compresse avec Draco et pousse sur GitHub.` : `"${filename}" pousse sur GitHub${compressionError ? ' (sans compression: ' + compressionError + ')' : ''}.`, 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 } ) } }