debut refacto

This commit is contained in:
Tom Boullay
2026-04-14 14:18:40 +02:00
parent ab9685b6ee
commit e9ae6ffc41
13 changed files with 721 additions and 554 deletions
+39
View File
@@ -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
}
+49
View File
@@ -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}` }
}
}
+72
View File
@@ -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<string, FileChange>,
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')
}
+29
View File
@@ -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
+156
View File
@@ -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 }
}
+80
View File
@@ -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 }
}
+12
View File
@@ -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()
}
+37
View File
@@ -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
}