diff --git a/app/api/upload/check/route.ts b/app/api/upload/check/route.ts index 85140de..868b282 100644 --- a/app/api/upload/check/route.ts +++ b/app/api/upload/check/route.ts @@ -4,8 +4,7 @@ import { getRemoteFolder } from '@/lib/github' import { classifyFileChanges } from '@/lib/diff-files' import { getModelFolderPath } from '@/lib/model-paths' import { ensurePreparedStagingAssets } from '@/lib/upload-staging' -import { parseStagingRequestBody } from '@/lib/upload-request' -import { getErrorMessage } from '@/lib/guards' +import { readStagingRequestBody, uploadErrorResponse } from '@/lib/upload-request' import type { FileDiff } from '@/lib/types' export const runtime = 'nodejs' @@ -22,15 +21,13 @@ export async function POST(req: NextRequest) { let stagingId: string try { - const body: unknown = await req.json() - stagingId = parseStagingRequestBody(body).stagingId + stagingId = (await readStagingRequestBody(req)).stagingId } catch (err) { - const message = getErrorMessage(err) - return NextResponse.json({ success: false, error: message }, { status: 400 }) + return uploadErrorResponse(err, 400) } try { - const { folderName, filesToPush } = await ensurePreparedStagingAssets(stagingId) + const { folderName, filesToPush, deliveryMode, compressionError } = await ensurePreparedStagingAssets(stagingId) const folderPath = getModelFolderPath(folderName) const { exists, files } = await getRemoteFolder(folderPath) @@ -53,12 +50,18 @@ export async function POST(req: NextRequest) { exists: true, path: folderPath, diffs, + deliveryMode, + compressionError, }) } - return NextResponse.json({ success: true, exists: false }) + return NextResponse.json({ + success: true, + exists: false, + deliveryMode, + compressionError, + }) } catch (err) { - const message = getErrorMessage(err) - return NextResponse.json({ success: false, error: message }, { status: 500 }) + return uploadErrorResponse(err, 500) } } diff --git a/app/api/upload/drive/route.ts b/app/api/upload/drive/route.ts index 61c232d..4da08d5 100644 --- a/app/api/upload/drive/route.ts +++ b/app/api/upload/drive/route.ts @@ -8,7 +8,12 @@ import { findNextVersion, } from '@/lib/nextcloud' import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock' -import { parseDriveRequestBody } from '@/lib/upload-request' +import { + readDriveRequestBody, + uploadErrorMessageResponse, + uploadErrorResponse, + uploadLockConflictResponse, +} from '@/lib/upload-request' import { getErrorMessage } from '@/lib/guards' import type { DriveAction } from '@/lib/types' @@ -20,9 +25,9 @@ export async function POST(req: NextRequest) { if (authError) return authError if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_SHARE_TOKEN) { - return NextResponse.json( - { success: false, error: 'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)' }, - { status: 500 }, + return uploadErrorMessageResponse( + 'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)', + 500, ) } @@ -31,23 +36,18 @@ export async function POST(req: NextRequest) { let action: DriveAction try { - const body: unknown = await req.json() - const parsedBody = parseDriveRequestBody(body) + const parsedBody = await readDriveRequestBody(req) action = parsedBody.action const stagingId = parsedBody.stagingId const staged = await readStagedOriginalFiles(stagingId) folderName = staged.folderName parsedFiles = staged.files } catch (err) { - const message = getErrorMessage(err) - return NextResponse.json({ success: false, error: message }, { status: 400 }) + return uploadErrorResponse(err, 400) } if (!acquireUploadLock(folderName)) { - return NextResponse.json( - { success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' }, - { status: 409 }, - ) + return uploadLockConflictResponse() } const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models' @@ -79,10 +79,7 @@ export async function POST(req: NextRequest) { }) } catch (err) { const message = getErrorMessage(err, 'Erreur Nextcloud inconnue') - return NextResponse.json( - { success: false, error: `Drive echoue: ${message}` }, - { status: 500 }, - ) + return uploadErrorMessageResponse(`Drive echoue: ${message}`, 500) } finally { releaseUploadLock(folderName) } diff --git a/app/api/upload/git/route.ts b/app/api/upload/git/route.ts index f663f36..bd0583a 100644 --- a/app/api/upload/git/route.ts +++ b/app/api/upload/git/route.ts @@ -6,12 +6,26 @@ import { classifyFileChanges } from '@/lib/diff-files' import { getModelFolderPath } from '@/lib/model-paths' import { cleanupStagingUpload, ensurePreparedStagingAssets, readStagedManifest } from '@/lib/upload-staging' import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock' -import { parseStagingRequestBody } from '@/lib/upload-request' +import { + readStagingRequestBody, + uploadErrorMessageResponse, + uploadErrorResponse, + uploadLockConflictResponse, +} from '@/lib/upload-request' import { getErrorMessage } from '@/lib/guards' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' +async function cleanupCompletedStagingUpload(stagingId: string) { + await cleanupStagingUpload(stagingId).catch((err) => { + console.warn('[WARN] Git upload -> staging cleanup failed', { + stagingId, + error: getErrorMessage(err), + }) + }) +} + /** * POST /api/upload/git * Upload prepared files and push to GitHub via Octokit. @@ -24,20 +38,15 @@ export async function POST(req: NextRequest) { let stagingId: string try { - const body: unknown = await req.json() - stagingId = parseStagingRequestBody(body).stagingId + stagingId = (await readStagingRequestBody(req)).stagingId const manifest = await readStagedManifest(stagingId) folderName = manifest.folderName } catch (err) { - const message = getErrorMessage(err) - return NextResponse.json({ success: false, error: message }, { status: 400 }) + return uploadErrorResponse(err, 400) } if (!acquireUploadLock(folderName)) { - return NextResponse.json( - { success: false, error: 'Un upload est deja en cours pour ce dossier. Patientez quelques secondes.' }, - { status: 409 }, - ) + return uploadLockConflictResponse() } try { @@ -45,6 +54,7 @@ export async function POST(req: NextRequest) { filesToPush, modelFilename, compressed, + deliveryMode, compressionError, assetSummaries, } = await ensurePreparedStagingAssets(stagingId) @@ -59,12 +69,13 @@ export async function POST(req: NextRequest) { classifyFileChanges(filesToPush, remoteFileMap, folderPath) if (changedFilesToPush.length === 0 && deletePaths.length === 0) { - await cleanupStagingUpload(stagingId).catch(() => {}) + await cleanupCompletedStagingUpload(stagingId) return NextResponse.json({ success: true, folderName, filesCount: 0, compressed, + deliveryMode, compressionError: compressionError || undefined, message: 'Aucun fichier modifie — rien a envoyer.', }) @@ -79,26 +90,22 @@ export async function POST(req: NextRequest) { deletedFileNames, ) - try { - const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage) - await cleanupStagingUpload(stagingId).catch(() => {}) + const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage) + await cleanupCompletedStagingUpload(stagingId) - 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 = getErrorMessage(err, 'Erreur GitHub inconnue') - return NextResponse.json( - { success: false, error: `Push GitHub echoue: ${message}` }, - { status: 500 }, - ) - } + return NextResponse.json({ + success: true, + folderName, + filesCount: changedFilesToPush.length, + compressed, + deliveryMode, + compressionError: compressionError || undefined, + message: `${changedFilesToPush.length} fichier(s) modifie(s) envoye(s) sur GitHub en un seul commit.`, + commitUrl, + }) + } catch (err) { + const message = getErrorMessage(err, 'Erreur GitHub inconnue') + return uploadErrorMessageResponse(`Upload GitHub echoue: ${message}`, 500) } finally { releaseUploadLock(folderName) } diff --git a/app/api/upload/stage/route.ts b/app/api/upload/stage/route.ts index 9e12b9e..68d8179 100644 --- a/app/api/upload/stage/route.ts +++ b/app/api/upload/stage/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { validateUploadSecret } from '@/lib/auth' import { parseMultiUpload } from '@/lib/parse-upload' import { createStagingUpload } from '@/lib/upload-staging' -import { getErrorMessage } from '@/lib/guards' +import { uploadErrorResponse } from '@/lib/upload-request' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -16,7 +16,6 @@ export async function POST(req: NextRequest) { const staged = await createStagingUpload(parsed.folderName, parsed.files, parsed.gitModelMode) return NextResponse.json({ success: true, ...staged }) } catch (err) { - const message = getErrorMessage(err) - return NextResponse.json({ success: false, error: message }, { status: 400 }) + return uploadErrorResponse(err, 400) } } diff --git a/components/ModelViewer.tsx b/components/ModelViewer.tsx index 50f599c..18d33dc 100644 --- a/components/ModelViewer.tsx +++ b/components/ModelViewer.tsx @@ -1,6 +1,7 @@ 'use client' -import { useEffect, useState } from 'react' +import { Component, useEffect, useState } from 'react' +import type { ComponentType, ReactNode } from 'react' import type { ModelStats } from './SceneViewer' interface ModelViewerProps { @@ -10,10 +11,51 @@ interface ModelViewerProps { size: string } +function getPreviewErrorMessage(error: unknown) { + return error instanceof Error ? error.message : 'Erreur preview inconnue' +} + +function PreviewFallback({ message }: { message?: string }) { + return ( +
+
+

Preview 3D indisponible pour ce modele.

+

+ L'upload reste possible. {message ? `Detail technique : ${message}` : ''} +

+
+
+ ) +} + +class PreviewErrorBoundary extends Component< + { children: ReactNode }, + { message: string | null } +> { + state = { message: null } + + static getDerivedStateFromError(error: unknown) { + return { message: getPreviewErrorMessage(error) } + } + + componentDidCatch(error: unknown) { + console.error('[ERROR] Preview 3D indisponible', error) + } + + render() { + if (this.state.message) { + return + } + + return this.props.children + } +} + export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) { const canPreview = filename.toLowerCase().endsWith('.gltf') const [stats, setStats] = useState(null) - const [Scene, setScene] = useState(null) + const [Scene, setScene] = useState onStatsReady: (stats: ModelStats) => void @@ -23,13 +65,19 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie if (!canPreview) return let cancel = false + setSceneError(null) + setStats(null) - import('./SceneViewer').then((mod) => { - if (!cancel) setScene(() => mod.default) - }) + import('./SceneViewer') + .then((mod) => { + if (!cancel) setScene(() => mod.default) + }) + .catch((error: unknown) => { + if (!cancel) setSceneError(getPreviewErrorMessage(error)) + }) return () => { cancel = true } - }, [canPreview]) + }, [canPreview, url]) if (!canPreview) { return ( @@ -87,7 +135,13 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie )} - + {sceneError ? ( + + ) : ( + + + + )} ) } diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx index 5b99e20..158a358 100644 --- a/components/SceneViewer.tsx +++ b/components/SceneViewer.tsx @@ -93,7 +93,7 @@ function supportsAlphaMap(material: Material): material is AlphaMapMaterial { return 'alphaMap' in material } -function isAlphaImageSource(image: object | null | undefined): image is AlphaImageSource { +function isAlphaImageSource(image: unknown): image is AlphaImageSource { return image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap) @@ -103,7 +103,7 @@ function createAlphaMapTexture(texture: Texture) { const cachedTexture = alphaMapTextureCache.get(texture) if (cachedTexture) return cachedTexture - const image = texture.image as object | null | undefined + const image = texture.image if (!isAlphaImageSource(image)) { texture.flipY = false @@ -243,7 +243,7 @@ function Model({ loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls)) }) const opacityMapEntries = getOpacityMapEntries(assetUrls) - const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url)) as Texture[] + const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url)) useEffect(() => { onStatsReady(getModelStats(scene, assetUrls)) diff --git a/components/upload/DriveStatusLine.tsx b/components/upload/DriveStatusLine.tsx index 544b25b..908a84e 100644 --- a/components/upload/DriveStatusLine.tsx +++ b/components/upload/DriveStatusLine.tsx @@ -1,8 +1,8 @@ import { SpinnerIcon, XIcon, WarningIcon } from '@/components/ui/icons' -import type { FolderEntry } from '@/lib/client-types' +import type { DriveStatus } from '@/lib/client-types' interface DriveStatusLineProps { - driveStatus: NonNullable + driveStatus: DriveStatus driveError?: string } diff --git a/components/upload/FolderCard.tsx b/components/upload/FolderCard.tsx index e63ab0e..1452f4c 100644 --- a/components/upload/FolderCard.tsx +++ b/components/upload/FolderCard.tsx @@ -1,7 +1,7 @@ import dynamic from 'next/dynamic' import type { FolderEntry } from '@/lib/client-types' import { formatBytes } from '@/lib/format-bytes' -import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon } from '@/components/ui/icons' +import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon, WarningIcon } from '@/components/ui/icons' import DriveStatusLine from './DriveStatusLine' import WarningBanner from './WarningBanner' @@ -63,6 +63,13 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F )} + {entry.uploadWarning && ( +
+ + {entry.uploadWarning} +
+ )} + {entry.status === 'uploading' && (
@@ -62,7 +66,7 @@ export function useUploadOrchestrator({ } | null>(null) const abortRef = useRef(null) - const checkResultRef = useRef({ exists: false, diffs: [] }) + const checkResultRef = useRef({ exists: false, diffs: [] }) const uploadActionRef = useRef(false) const stagingIdRef = useRef(null) @@ -101,6 +105,7 @@ export function useUploadOrchestrator({ status: gitResult.success ? 'success' : 'error', progress: gitResult.success ? 100 : 0, error: gitResult.success ? undefined : gitResult.error, + uploadWarning: gitResult.success ? gitResult.warning : undefined, filename: gitResult.filename, }) }, [updateEntry]) @@ -135,6 +140,7 @@ export function useUploadOrchestrator({ status: 'uploading', progress: 1, error: undefined, + uploadWarning: undefined, driveStatus: 'uploading', driveError: undefined, }) @@ -221,7 +227,7 @@ export function useUploadOrchestrator({ folderName: folder.folderName, stagingId: staged.stagingId, }) - let check: CheckResult + let check: CheckUploadResult try { check = await checkFolderDiffs( @@ -238,6 +244,7 @@ export function useUploadOrchestrator({ } checkResultRef.current = check + updateEntry(0, { uploadWarning: check.warning }) if (check.exists) { if (check.diffs.length === 0) { @@ -289,6 +296,7 @@ export function useUploadOrchestrator({ status: 'uploading', progress: 0, error: undefined, + uploadWarning: undefined, driveStatus: 'skipped', }) diff --git a/lib/client-types.ts b/lib/client-types.ts index 73be2d6..eda1f5f 100644 --- a/lib/client-types.ts +++ b/lib/client-types.ts @@ -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 diff --git a/lib/github.ts b/lib/github.ts index dd4ab1f..cdda391 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -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 +} + interface LfsBatchObject { oid: string size: number actions?: { - upload?: { href: string; header?: Record } - verify?: { href: string; header?: Record } + upload?: LfsBatchAction + verify?: LfsBatchAction } error?: { code: number; message: string } } @@ -87,7 +108,7 @@ function isStringRecord(value: unknown): value is Record { 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, diff --git a/lib/parse-upload.ts b/lib/parse-upload.ts index b2e58fb..7addb26 100644 --- a/lib/parse-upload.ts +++ b/lib/parse-upload.ts @@ -19,8 +19,16 @@ function parseGitModelMode(value: FormDataEntryValue | null): GitModelMode { export async function parseMultiUpload(req: NextRequest): Promise { 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') diff --git a/lib/prepare-git-assets.ts b/lib/prepare-git-assets.ts index 6a96a1e..bb6c1e1 100644 --- a/lib/prepare-git-assets.ts +++ b/lib/prepare-git-assets.ts @@ -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() const normalizedGroups = new Map>() @@ -75,7 +100,7 @@ function rewriteGltfUris(value: JsonValue, filenameMap: Map): Js function prepareModelBuffer(buffer: Buffer, filenameMap: Map) { 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(() => {}) diff --git a/lib/types.ts b/lib/types.ts index 53fee88..7e4e8fa 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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 } diff --git a/lib/upload-api.ts b/lib/upload-api.ts index 1a5fd4e..e8c7260 100644 --- a/lib/upload-api.ts +++ b/lib/upload-api.ts @@ -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 { - const res = await fetch('/api/upload/check', { - method: 'POST', - headers: getUploadJsonHeaders(secret), - body: JSON.stringify({ stagingId }), - signal, - }) - - const data: unknown = await res.json() +): Promise { + 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 { 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 { 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') } } } diff --git a/lib/upload-request.ts b/lib/upload-request.ts index 34f0846..0093f8c 100644 --- a/lib/upload-request.ts +++ b/lib/upload-request.ts @@ -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 { + 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 { + const body: unknown = await req.json() + return parseDriveRequestBody(body) } diff --git a/lib/upload-staging.ts b/lib/upload-staging.ts index 6b76bc6..4627a4e 100644 --- a/lib/upload-staging.ts +++ b/lib/upload-staging.ts @@ -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 { 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 { @@ -179,6 +245,7 @@ export async function ensurePreparedStagingAssets(stagingId: string): Promise