diff --git a/app/api/upload/check/route.ts b/app/api/upload/check/route.ts new file mode 100644 index 0000000..2981911 --- /dev/null +++ b/app/api/upload/check/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server' +import { validateUploadSecret } from '@/lib/auth' +import { sanitizeFilename } from '@/lib/sanitize' +import { VALID_DESTINATIONS } from '@/lib/constants' +import { getRemoteFolder } from '@/lib/github' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * GET /api/upload/check?destination=...&folderName=... + * Check if a folder already exists on the remote repo and return file SHAs. + */ +export async function GET(req: NextRequest) { + const authError = validateUploadSecret(req) + if (authError) return authError + + const { searchParams } = new URL(req.url) + const destination = searchParams.get('destination')?.trim() + const folderName = searchParams.get('folderName')?.trim() + + if (!destination || !folderName) { + return NextResponse.json({ success: false, error: 'Parametres manquants' }, { status: 400 }) + } + + if (!VALID_DESTINATIONS.has(destination as never)) { + 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 { exists, files } = await getRemoteFolder(folderPath) + + if (exists) { + return NextResponse.json({ + success: true, + exists: true, + path: folderPath, + files, + }) + } + + return NextResponse.json({ success: true, exists: false }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Erreur inconnue' + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/app/api/upload/drive/route.ts b/app/api/upload/drive/route.ts new file mode 100644 index 0000000..97676ba --- /dev/null +++ b/app/api/upload/drive/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +// --------------------------------------------------------------------------- +// TODO: POST /api/upload/drive +// +// Upload files and push them to Google Drive. +// This route will share the same auth (lib/auth.ts), parsing (lib/parse-upload.ts), +// and Blender compression (lib/blender.ts) as the git route. +// +// Implementation steps: +// 1. Add `googleapis` package: npm install googleapis +// 2. Add env vars: GOOGLE_DRIVE_FOLDER_ID, GOOGLE_SERVICE_ACCOUNT_KEY (JSON) +// 3. Authenticate with Google Drive API using service account +// 4. Upload files to the target folder (create subfolders as needed) +// 5. Return the Drive folder URL +// --------------------------------------------------------------------------- + +export async function POST() { + return NextResponse.json( + { success: false, error: 'Google Drive upload not implemented yet' }, + { status: 501 }, + ) +} diff --git a/app/api/upload/git/route.ts b/app/api/upload/git/route.ts new file mode 100644 index 0000000..221d2ff --- /dev/null +++ b/app/api/upload/git/route.ts @@ -0,0 +1,169 @@ +import { NextRequest, NextResponse } from 'next/server' +import { join } from 'path' +import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises' +import { existsSync } from 'fs' +import { validateUploadSecret } from '@/lib/auth' +import { parseMultiUpload } from '@/lib/parse-upload' +import { compressWithBlender } from '@/lib/blender' +import { computeGitBlobSha, getRemoteFolder, pushAllToGitHub } from '@/lib/github' +import { buildCommitMessage } from '@/lib/commit-message' +import { TMP_DIR } from '@/lib/constants' +import type { FileChange } from '@/lib/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * POST /api/upload/git + * Upload files, compress with Blender, and push to GitHub via Octokit. + */ +export async function POST(req: NextRequest) { + // --- Auth --- + const authError = validateUploadSecret(req) + if (authError) return authError + + // --- Parse all files --- + let folderName: string + let destination: string + let parsedFiles: Awaited>['files'] + + try { + const parsed = await parseMultiUpload(req) + folderName = parsed.folderName + destination = parsed.destination + parsedFiles = parsed.files + } 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`) + + try { + 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 + } + } finally { + // Always cleanup temp files + 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}` + let remoteFileMap: Map + + try { + const remote = await getRemoteFolder(folderPath) + remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.sha])) + } catch { + remoteFileMap = new Map() + } + + const isReplace = remoteFileMap.size > 0 + + // Classify each file: changed, new, or 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') + } + } + + // Files on remote not in the new upload → 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, 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(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 }, + ) + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts deleted file mode 100644 index 13a2475..0000000 --- a/app/api/upload/route.ts +++ /dev/null @@ -1,552 +0,0 @@ -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 } - ) - } -} diff --git a/components/UploadZone.tsx b/components/UploadZone.tsx index 480f371..98fe6f0 100644 --- a/components/UploadZone.tsx +++ b/components/UploadZone.tsx @@ -118,7 +118,7 @@ async function checkFolderDiffs( ): Promise { try { const params = new URLSearchParams({ folderName: folder.folderName, destination }) - const res = await fetch(`/api/upload?${params}`, { + const res = await fetch(`/api/upload/check?${params}`, { headers: { 'x-upload-secret': secret.trim() }, }) const data = await res.json() @@ -195,7 +195,7 @@ async function uploadFolder( onProgress(10) try { - const res = await fetch('/api/upload', { + const res = await fetch('/api/upload/git', { method: 'POST', headers: { 'x-upload-secret': secret.trim() }, body: formData, diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..1d883bd --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,39 @@ +import { timingSafeEqual } from 'crypto' +import { NextRequest, NextResponse } from 'next/server' + +/** + * Validate the upload secret from request headers. + * Returns null if valid, or a NextResponse error if invalid. + */ +export function validateUploadSecret(req: NextRequest): NextResponse | null { + 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) { + return NextResponse.json( + { success: false, error: "Cle d'authentification manquante" }, + { status: 401 }, + ) + } + + // Timing-safe comparison to prevent timing attacks + const a = Buffer.from(secret) + const b = Buffer.from(expectedSecret) + const isValid = a.length === b.length && timingSafeEqual(a, b) + + if (!isValid) { + return NextResponse.json( + { success: false, error: "Cle d'authentification invalide" }, + { status: 401 }, + ) + } + + return null +} diff --git a/lib/blender.ts b/lib/blender.ts new file mode 100644 index 0000000..6a035e6 --- /dev/null +++ b/lib/blender.ts @@ -0,0 +1,49 @@ +import { join } from 'path' +import { existsSync } from 'fs' +import { execFile } from 'child_process' +import { promisify } from 'util' + +const execFileAsync = promisify(execFile) + +/** + * Compress a GLTF/GLB model using Blender's Draco compression. + * Returns { success: true } on success, or { success: false, error } on failure. + * Callers should fall back to the original file on failure. + */ +export 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}` } + } +} diff --git a/lib/commit-message.ts b/lib/commit-message.ts new file mode 100644 index 0000000..2dca9a4 --- /dev/null +++ b/lib/commit-message.ts @@ -0,0 +1,72 @@ +import { REQUIRED_TEXTURES } from './constants' +import type { FileChange } from './types' + +/** + * Build a formatted commit message based on the upload context. + * + * Symbols: + * - ✅ = new file + * - 🔄 = modified file + * - ❌ = missing texture (new upload) or deleted file (update) + * - Unchanged files are omitted entirely + */ +export 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)' : ''}`) + } + + // Textures section — only show lines that have changes + const foundTextures = new Set( + textureNames.map((t) => t.toLowerCase().replace(/\.[^.]+$/, '')), + ) + + const textureLines: string[] = [] + + 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}`) + } + } else if (!isReplace) { + textureLines.push(` ❌ ${tex} (manquant)`) + } + } + + for (const name of deletedFileNames) { + textureLines.push(` ❌ ${name} (supprime)`) + } + + if (textureLines.length > 0) { + lines.push('🎨 Textures') + lines.push(...textureLines) + } + + return lines.join('\n') +} diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 0000000..5607b67 --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,29 @@ +// --------------------------------------------------------------------------- +// Shared constants — used by both client and server +// --------------------------------------------------------------------------- + +export const MODEL_EXTENSIONS = new Set(['.glb', '.gltf']) +export const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']) +export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS]) + +export const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] as const + +export const VALID_DESTINATIONS = new Set([ + 'farm', 'map', 'powergrid', 'workshop', 'general', 'environment', +] as const) + +export 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 + +export type Destination = typeof DESTINATIONS[number]['value'] + +export const TMP_DIR = '/tmp/assets' + +/** Maximum file size in bytes (100 MB) */ +export const MAX_FILE_SIZE = 100 * 1024 * 1024 diff --git a/lib/github.ts b/lib/github.ts new file mode 100644 index 0000000..39ecc36 --- /dev/null +++ b/lib/github.ts @@ -0,0 +1,156 @@ +import { Octokit } from '@octokit/rest' +import { createHash } from 'crypto' +import type { RemoteFile } from './types' + +// --------------------------------------------------------------------------- +// Octokit helpers +// --------------------------------------------------------------------------- + +export function getOctokit(): Octokit { + const token = process.env.GITHUB_TOKEN + if (!token) throw new Error('GITHUB_TOKEN non configure') + return new Octokit({ auth: token }) +} + +export 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 */ +export 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') +} + +// --------------------------------------------------------------------------- +// Read remote folder contents (with SHA per file) +// --------------------------------------------------------------------------- + +export async function getRemoteFolder( + folderPath: string, +): Promise<{ exists: boolean; files: RemoteFile[] }> { + const octokit = getOctokit() + const { owner, repo } = parseRepoUrl() + const branch = process.env.GIT_BRANCH ?? 'main' + + try { + const { data } = await octokit.repos.getContent({ + owner, + repo, + path: folderPath, + ref: branch, + }) + + if (Array.isArray(data)) { + return { + exists: true, + files: data.map((f) => ({ name: f.name, sha: f.sha })), + } + } + + return { exists: false, files: [] } + } catch (err: unknown) { + const status = (err as { status?: number })?.status + if (status === 404) { + return { exists: false, files: [] } + } + throw err + } +} + +// --------------------------------------------------------------------------- +// Push all files in a single commit (with optional deletions) +// --------------------------------------------------------------------------- + +export async function pushAllToGitHub( + 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. Build tree entries: new/changed files + deletions + 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 } +} diff --git a/lib/parse-upload.ts b/lib/parse-upload.ts new file mode 100644 index 0000000..a3d623c --- /dev/null +++ b/lib/parse-upload.ts @@ -0,0 +1,80 @@ +import { extname } from 'path' +import { NextRequest } from 'next/server' +import { sanitizeFilename } from './sanitize' +import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, VALID_DESTINATIONS, MAX_FILE_SIZE } from './constants' +import type { ParsedFile } from './types' + +/** + * Parse a multi-file FormData upload request. + * Validates destination, file extensions, file sizes, and returns parsed files. + */ +export 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 as never)) { + throw new Error(`Destination invalide: "${rawDestination}"`) + } + const destination = rawDestination + + const rawFiles = formData.getAll('files') + const fileTypes = formData.getAll('fileTypes') as string[] + const textureNames = formData.getAll('textureNames') as string[] + + // Runtime validation: ensure entries are actual File objects + const fileEntries: File[] = [] + for (const entry of rawFiles) { + if (!(entry instanceof File)) { + throw new Error('Donnees de fichier invalides') + } + fileEntries.push(entry) + } + + 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 + + // File size limit + if (file.size > MAX_FILE_SIZE) { + throw new Error( + `Fichier "${file.name}" trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`, + ) + } + + 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 } +} diff --git a/lib/sanitize.ts b/lib/sanitize.ts new file mode 100644 index 0000000..2f6d09a --- /dev/null +++ b/lib/sanitize.ts @@ -0,0 +1,12 @@ +import { basename } from 'path' + +/** + * Sanitize a filename: strip path components, replace special chars, + * collapse underscores, lowercase. + */ +export function sanitizeFilename(name: string): string { + return basename(name) + .replace(/[^a-zA-Z0-9._-]/g, '_') + .replace(/_{2,}/g, '_') + .toLowerCase() +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..6cba74a --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,37 @@ +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export interface ParsedFile { + filename: string + buffer: Buffer + isModel: boolean + textureName?: string +} + +export type FileChange = 'new' | 'changed' | 'unchanged' + +export interface FileDiff { + name: string + status: 'changed' | 'new' | 'deleted' +} + +export interface RemoteFile { + name: string + sha: string +} + +export type UploadResponse = + | { + success: true + folderName: string + filesCount: number + compressed: boolean + compressionError?: string + message: string + commitUrl?: string + } + | { + success: false + error: string + }