fix: harden upload resilience and contracts

This commit is contained in:
Tom Boullay
2026-05-12 23:49:30 +02:00
parent 101af23418
commit 606df93b69
19 changed files with 479 additions and 159 deletions
+3 -2
View File
@@ -1,11 +1,11 @@
type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
export type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
export interface TextureFile {
name: string
file: File
}
type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
export interface FolderEntry {
folderName: string
@@ -14,6 +14,7 @@ export interface FolderEntry {
status: FileStatus
progress: number
error?: string
uploadWarning?: string
filename?: string
modelUrl?: string
assetUrls: Record<string, string>
+31 -10
View File
@@ -19,17 +19,33 @@ function getOctokit(): Octokit {
}
function parseRepoUrl(): { owner: string; repo: string } {
const url = process.env.GIT_REPO_URL
const url = process.env.GIT_REPO_URL?.trim()
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 cleanRepoName = (repo: string) => repo.replace(/\/+$/, '').replace(/\.git$/, '')
const shortMatch = url.match(/^([^/\s:]+)\/([^/\s]+)$/)
if (shortMatch) {
return { owner: shortMatch[1], repo: cleanRepoName(shortMatch[2]) }
}
const match = httpsMatch || sshMatch || shortMatch
if (!match) throw new Error(`Format GIT_REPO_URL invalide: "${url}"`)
const sshMatch = url.match(/github\.com:([^/\s]+)\/(.+)$/)
if (sshMatch) {
return { owner: sshMatch[1], repo: cleanRepoName(sshMatch[2]) }
}
return { owner: match[1], repo: match[2] }
if (URL.canParse(url)) {
const parsed = new URL(url)
const pathParts = parsed.pathname
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean)
if (parsed.hostname === 'github.com' && pathParts.length >= 2) {
return { owner: pathParts[0], repo: cleanRepoName(pathParts[1]) }
}
}
throw new Error(`Format GIT_REPO_URL invalide: "${url}"`)
}
function isLfsFile(filePath: string): boolean {
@@ -73,12 +89,17 @@ interface LfsObject {
contentBase64: string
}
interface LfsBatchAction {
href: string
header?: Record<string, string>
}
interface LfsBatchObject {
oid: string
size: number
actions?: {
upload?: { href: string; header?: Record<string, string> }
verify?: { href: string; header?: Record<string, string> }
upload?: LfsBatchAction
verify?: LfsBatchAction
}
error?: { code: number; message: string }
}
@@ -87,7 +108,7 @@ function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
}
function parseLfsAction(value: unknown) {
function parseLfsAction(value: unknown): LfsBatchAction | undefined {
if (!isRecord(value) || typeof value.href !== 'string') return undefined
return {
href: value.href,
+9 -1
View File
@@ -19,8 +19,16 @@ function parseGitModelMode(value: FormDataEntryValue | null): GitModelMode {
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
const formData = await req.formData()
const folderValue = formData.get('folderName')
const folderName = typeof folderValue === 'string' ? folderValue.trim() || 'assets' : 'assets'
if (typeof folderValue !== 'string' || folderValue.trim() === '') {
throw new Error('Nom de dossier manquant')
}
const folderName = folderValue.trim()
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
if (!safeFolderName) {
throw new Error('Nom de dossier invalide')
}
const gitModelMode = parseGitModelMode(formData.get('gitModelMode'))
const rawFiles = formData.getAll('files')
+61 -16
View File
@@ -7,6 +7,7 @@ import { compressTextureBuffer } from '@/lib/texture-compression'
import { classifyAssetCategory } from '@/lib/asset-classification'
import { normalizeTextureFilename } from '@/lib/asset-naming'
import { TEXTURE_EXTENSIONS, TMP_DIR } from '@/lib/constants'
import { getErrorMessage, isRecord } from '@/lib/guards'
import { getModelAssetPath } from '@/lib/model-paths'
import type { GitModelMode, ParsedFile, PreparedAssetSummary, PreparedGitAssetsResult, PushFile } from '@/lib/types'
@@ -18,6 +19,30 @@ interface PrepareGitAssetsParams {
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return true
}
if (Array.isArray(value)) {
return value.every(isJsonValue)
}
return isRecord(value) && Object.values(value).every(isJsonValue)
}
function parseJsonValue(content: string) {
const parsed: unknown = JSON.parse(content)
if (!isJsonValue(parsed)) {
throw new Error('model.gltf contient un JSON invalide')
}
return parsed
}
function getTextureFilenameMap(parsedFiles: ParsedFile[]) {
const filenameMap = new Map<string, string>()
const normalizedGroups = new Map<string, Array<{ original: string; normalized: string }>>()
@@ -75,7 +100,7 @@ function rewriteGltfUris(value: JsonValue, filenameMap: Map<string, string>): Js
function prepareModelBuffer(buffer: Buffer, filenameMap: Map<string, string>) {
if (filenameMap.size === 0) return buffer
const parsed = JSON.parse(buffer.toString('utf-8')) as JsonValue
const parsed = parseJsonValue(buffer.toString('utf-8'))
return Buffer.from(JSON.stringify(rewriteGltfUris(parsed, filenameMap), null, 2), 'utf-8')
}
@@ -130,7 +155,14 @@ async function prepareSeparateFiles(
})
}
return { filesToPush, modelFilename, assetSummaries, compressed, compressionError }
return {
filesToPush,
modelFilename,
assetSummaries,
compressed,
compressionError,
deliveryMode: 'keep-gltf' as const,
}
}
async function prepareDracoGlb(
@@ -160,22 +192,35 @@ async function prepareDracoGlb(
await writeFile(join(tmpFolder, filename), content)
}
const result = await compressWithBlender(inputModelPath, outputModelPath)
if (!result.success || !existsSync(outputModelPath)) {
throw new Error(result.error || 'Compression Blender echouee')
}
try {
const result = await compressWithBlender(inputModelPath, outputModelPath)
if (!result.success || !existsSync(outputModelPath)) {
throw new Error(result.error || 'Compression Blender echouee')
}
const content = await readFile(outputModelPath)
const modelFilename = 'model.glb'
const content = await readFile(outputModelPath)
const modelFilename = 'model.glb'
return {
filesToPush: [{
path: getModelAssetPath(folderName, modelFilename),
contentBase64: content.toString('base64'),
}],
modelFilename,
assetSummaries: [{ filename: modelFilename, kind: 'model', compressed: true }],
compressed: true,
return {
filesToPush: [{
path: getModelAssetPath(folderName, modelFilename),
contentBase64: content.toString('base64'),
}],
modelFilename,
assetSummaries: [{ filename: modelFilename, kind: 'model', compressed: true }],
compressed: true,
deliveryMode: 'draco-glb',
}
} catch (err) {
const fallback = await prepareSeparateFiles(folderName, parsedFiles, textureFilenameMap)
const message = getErrorMessage(err, 'Compression Blender echouee')
return {
...fallback,
compressionError: fallback.compressionError
? `${message}. ${fallback.compressionError}`
: message,
}
}
} finally {
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
+22 -1
View File
@@ -15,11 +15,13 @@ export type DriveAction = 'new' | 'replace'
export type FileChange = 'new' | 'changed' | 'unchanged'
export type FileDiffStatus = 'changed' | 'new' | 'deleted'
export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'height' | 'opacity' | 'orm' | 'ao' | 'assets'
export interface FileDiff {
name: string
status: 'changed' | 'new' | 'deleted'
status: FileDiffStatus
}
export interface RemoteFile {
@@ -40,11 +42,30 @@ export interface StagingUploadResult {
filesCount: number
}
export interface CheckUploadResult {
exists: boolean
diffs: FileDiff[]
warning?: string
}
export interface DriveUploadResult {
success: boolean
error?: string
}
export interface GitUploadResult {
success: boolean
filename?: string
warning?: string
error?: string
}
export interface PreparedGitAssetsResult {
filesToPush: PushFile[]
modelFilename: string
assetSummaries: PreparedAssetSummary[]
compressed: boolean
deliveryMode: GitModelMode
compressionError?: string
}
+93 -45
View File
@@ -1,16 +1,46 @@
import { isRecord } from './guards'
import { getErrorMessage, isRecord } from './guards'
import type { FolderEntry } from './client-types'
import type { DriveAction, FileDiff, GitModelMode, StagingUploadResult } from './types'
import type {
CheckUploadResult,
DriveAction,
DriveUploadResult,
FileDiff,
GitModelMode,
GitUploadResult,
StagingUploadResult,
} from './types'
export interface CheckResult {
exists: boolean
diffs: FileDiff[]
interface CompressionWarningPayload {
compressionError?: unknown
}
interface SuccessfulUploadData extends CompressionWarningPayload {
success: true
exists?: unknown
diffs?: unknown
stagingId?: unknown
folderName?: unknown
filesCount?: unknown
}
type UploadJsonBody =
| { stagingId: string }
| { stagingId: string; action: DriveAction }
function getApiError(data: unknown, fallback: string) {
return isRecord(data) && typeof data.error === 'string' ? data.error : fallback
}
function getClientRequestError(err: unknown, label: string) {
return `${label}: ${getErrorMessage(err)}`
}
function getCompressionWarning(data: CompressionWarningPayload) {
if (typeof data.compressionError !== 'string') return undefined
return `Compression GLB impossible. Le modele a ete prepare en GLTF separe. Detail : ${data.compressionError}`
}
function getUploadJsonHeaders(secret: string) {
return {
'Content-Type': 'application/json',
@@ -18,10 +48,35 @@ function getUploadJsonHeaders(secret: string) {
}
}
async function postUploadJson(
endpoint: string,
secret: string,
body: UploadJsonBody,
signal?: AbortSignal,
) {
const res = await fetch(endpoint, {
method: 'POST',
headers: getUploadJsonHeaders(secret),
body: JSON.stringify(body),
signal,
})
const data: unknown = await res.json()
return { res, data }
}
function isSuccessfulUploadData(data: unknown): data is SuccessfulUploadData {
return isRecord(data) && data.success === true
}
function isAbortError(err: unknown) {
return err instanceof DOMException && err.name === 'AbortError'
}
function getNetworkUploadError(err: unknown, label: string) {
return isAbortError(err) ? 'Upload annule' : getClientRequestError(err, label)
}
function isFileDiff(value: unknown): value is FileDiff {
return isRecord(value)
&& typeof value.name === 'string'
@@ -50,27 +105,34 @@ export async function checkFolderDiffs(
stagingId: string,
secret: string,
signal?: AbortSignal,
): Promise<CheckResult> {
const res = await fetch('/api/upload/check', {
method: 'POST',
headers: getUploadJsonHeaders(secret),
body: JSON.stringify({ stagingId }),
signal,
})
const data: unknown = await res.json()
): Promise<CheckUploadResult> {
const { res, data } = await postUploadJson('/api/upload/check', secret, { stagingId }, signal)
if (!res.ok) {
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
}
if (!isRecord(data) || data.success !== true || data.exists !== true) {
return { exists: false, diffs: [] }
if (!isSuccessfulUploadData(data)) {
throw new Error('Reponse serveur invalide')
}
const warning = getCompressionWarning(data)
if (data.exists !== true) {
return {
exists: false,
diffs: [],
warning,
}
}
const diffs = Array.isArray(data.diffs) ? data.diffs.filter(isFileDiff) : []
return { exists: true, diffs }
return {
exists: true,
diffs,
warning,
}
}
export async function stageUpload(
@@ -89,7 +151,7 @@ export async function stageUpload(
const data: unknown = await res.json()
if (!res.ok || !isRecord(data) || data.success !== true) {
if (!res.ok || !isSuccessfulUploadData(data)) {
throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
}
@@ -109,24 +171,15 @@ export async function uploadDrive(
secret: string,
action: DriveAction,
signal?: AbortSignal,
): Promise<{ success: boolean; error?: string }> {
): Promise<DriveUploadResult> {
try {
const res = await fetch('/api/upload/drive', {
method: 'POST',
headers: getUploadJsonHeaders(secret),
body: JSON.stringify({ stagingId, action }),
signal,
})
const data: unknown = await res.json()
if (!res.ok || !isRecord(data) || data.success !== true) {
const { res, data } = await postUploadJson('/api/upload/drive', secret, { stagingId, action }, signal)
if (!res.ok || !isSuccessfulUploadData(data)) {
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
}
return { success: true }
} catch (err) {
if (isAbortError(err)) {
return { success: false, error: 'Upload annule' }
}
return { success: false, error: 'Erreur reseau (Drive)' }
return { success: false, error: getNetworkUploadError(err, 'Erreur Drive') }
}
}
@@ -135,30 +188,25 @@ export async function uploadGit(
secret: string,
onProgress: (pct: number) => void,
signal?: AbortSignal,
): Promise<{ success: boolean; filename?: string; error?: string }> {
): Promise<GitUploadResult> {
onProgress(10)
try {
const res = await fetch('/api/upload/git', {
method: 'POST',
headers: getUploadJsonHeaders(secret),
body: JSON.stringify({ stagingId }),
signal,
})
const { res, data } = await postUploadJson('/api/upload/git', secret, { stagingId }, signal)
onProgress(80)
const data: unknown = await res.json()
if (!res.ok || !isRecord(data) || data.success !== true) {
if (!res.ok || !isSuccessfulUploadData(data)) {
return { success: false, error: getApiError(data, `Erreur serveur (${res.status})`) }
}
onProgress(100)
return { success: true, filename: typeof data.folderName === 'string' ? data.folderName : undefined }
} catch (err) {
if (isAbortError(err)) {
return { success: false, error: 'Upload annule' }
return {
success: true,
filename: typeof data.folderName === 'string' ? data.folderName : undefined,
warning: getCompressionWarning(data),
}
return { success: false, error: 'Erreur reseau' }
} catch (err) {
return { success: false, error: getNetworkUploadError(err, 'Erreur GitHub') }
}
}
+32 -3
View File
@@ -1,4 +1,5 @@
import { isRecord } from './guards'
import { NextResponse } from 'next/server'
import { getErrorMessage, isRecord } from './guards'
import type { DriveAction } from './types'
interface StagingRequestBody {
@@ -9,6 +10,21 @@ interface DriveRequestBody extends StagingRequestBody {
action: DriveAction
}
const UPLOAD_LOCK_ERROR = 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.'
export function uploadErrorResponse(error: unknown, status: number, fallback?: string) {
const message = getErrorMessage(error, fallback)
return NextResponse.json({ success: false, error: message }, { status })
}
export function uploadErrorMessageResponse(message: string, status: number) {
return NextResponse.json({ success: false, error: message }, { status })
}
export function uploadLockConflictResponse() {
return uploadErrorMessageResponse(UPLOAD_LOCK_ERROR, 409)
}
export function parseStagingRequestBody(value: unknown): StagingRequestBody {
if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') {
throw new Error('stagingId manquant')
@@ -17,9 +33,22 @@ export function parseStagingRequestBody(value: unknown): StagingRequestBody {
return { stagingId: value.stagingId }
}
export async function readStagingRequestBody(req: Request): Promise<StagingRequestBody> {
const body: unknown = await req.json()
return parseStagingRequestBody(body)
}
export function parseDriveRequestBody(value: unknown): DriveRequestBody {
const { stagingId } = parseStagingRequestBody(value)
const action = isRecord(value) && value.action === 'replace' ? 'replace' : 'new'
return { stagingId, action }
if (!isRecord(value) || (value.action !== 'new' && value.action !== 'replace')) {
throw new Error('Action Drive invalide')
}
return { stagingId, action: value.action }
}
export async function readDriveRequestBody(req: Request): Promise<DriveRequestBody> {
const body: unknown = await req.json()
return parseDriveRequestBody(body)
}
+69 -1
View File
@@ -3,9 +3,11 @@ import { dirname, join } from 'path'
import { mkdir, readdir, readFile, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { TMP_DIR } from '@/lib/constants'
import { isRecord } from '@/lib/guards'
import { getModelAssetPath } from '@/lib/model-paths'
import { prepareGitAssets } from '@/lib/prepare-git-assets'
import type {
AssetCategory,
GitModelMode,
ParsedFile,
PreparedAssetSummary,
@@ -26,6 +28,7 @@ interface StagedOriginalFile {
interface StagedPreparedData {
modelFilename: string
compressed: boolean
deliveryMode?: GitModelMode
compressionError?: string
assetSummaries: PreparedAssetSummary[]
}
@@ -55,6 +58,69 @@ function getManifestPath(stagingId: string) {
return join(getStageDir(stagingId), 'manifest.json')
}
function isGitModelMode(value: unknown): value is GitModelMode {
return value === 'draco-glb' || value === 'keep-gltf'
}
function isAssetCategory(value: unknown): value is AssetCategory {
return value === 'color'
|| value === 'diffuse'
|| value === 'roughness'
|| value === 'normal'
|| value === 'metalness'
|| value === 'height'
|| value === 'opacity'
|| value === 'orm'
|| value === 'ao'
|| value === 'assets'
}
function isPreparedAssetSummary(value: unknown): value is PreparedAssetSummary {
return isRecord(value)
&& typeof value.filename === 'string'
&& (value.kind === 'model' || value.kind === 'texture' || value.kind === 'asset')
&& (value.category === undefined || isAssetCategory(value.category))
&& typeof value.compressed === 'boolean'
}
function isStagedOriginalFile(value: unknown): value is StagedOriginalFile {
return isRecord(value)
&& typeof value.filename === 'string'
&& typeof value.size === 'number'
&& typeof value.isModel === 'boolean'
}
function isStagedPreparedData(value: unknown): value is StagedPreparedData {
return isRecord(value)
&& typeof value.modelFilename === 'string'
&& typeof value.compressed === 'boolean'
&& (value.deliveryMode === undefined || isGitModelMode(value.deliveryMode))
&& (value.compressionError === undefined || typeof value.compressionError === 'string')
&& Array.isArray(value.assetSummaries)
&& value.assetSummaries.every(isPreparedAssetSummary)
}
function isStagingManifest(value: unknown): value is StagingManifest {
return isRecord(value)
&& typeof value.stagingId === 'string'
&& typeof value.folderName === 'string'
&& isGitModelMode(value.gitModelMode)
&& typeof value.createdAt === 'number'
&& Array.isArray(value.originals)
&& value.originals.every(isStagedOriginalFile)
&& (value.prepared === undefined || isStagedPreparedData(value.prepared))
}
function parseStagingManifest(content: string) {
const parsed: unknown = JSON.parse(content)
if (!isStagingManifest(parsed)) {
throw new Error('Manifest de staging invalide')
}
return parsed
}
async function ensureParentDir(filePath: string) {
await mkdir(dirname(filePath), { recursive: true })
}
@@ -126,7 +192,7 @@ export async function createStagingUpload(
export async function readStagedManifest(stagingId: string): Promise<StagingManifest> {
const manifestPath = getManifestPath(stagingId)
const content = await readFile(manifestPath, 'utf-8')
return JSON.parse(content) as StagingManifest
return parseStagingManifest(content)
}
async function readOriginalParsedFiles(stagingId: string, manifest: StagingManifest): Promise<ParsedFile[]> {
@@ -179,6 +245,7 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
manifest.prepared = {
modelFilename: prepared.modelFilename,
compressed: prepared.compressed,
deliveryMode: prepared.deliveryMode,
compressionError: prepared.compressionError,
assetSummaries: prepared.assetSummaries,
}
@@ -192,6 +259,7 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise<Pr
modelFilename: manifest.prepared.modelFilename,
assetSummaries: manifest.prepared.assetSummaries,
compressed: manifest.prepared.compressed,
deliveryMode: manifest.prepared.deliveryMode ?? manifest.gitModelMode,
compressionError: manifest.prepared.compressionError,
}
}
+6 -3
View File
@@ -6,22 +6,25 @@ import type { TextureFile } from '@/lib/client-types'
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
interface GltfBufferReference {
uri?: unknown
uri?: string
}
interface GltfJson {
buffers?: GltfBufferReference[]
}
/** Discriminated union: either valid (with model) or invalid (with errors). */
type ValidationResult =
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
| { ok: false; errors: string[] }
function isGltfBufferReference(value: unknown): value is GltfBufferReference {
return isRecord(value) && (value.uri === undefined || typeof value.uri === 'string')
}
function isGltfJson(value: unknown): value is GltfJson {
if (!isRecord(value)) return false
if (value.buffers === undefined) return true
return Array.isArray(value.buffers) && value.buffers.every(isRecord)
return Array.isArray(value.buffers) && value.buffers.every(isGltfBufferReference)
}
function getReferencedBufferNames(gltf: GltfJson) {