chore: prepare v1.0.0 release

This commit is contained in:
Tom Boullay
2026-04-27 23:43:16 +02:00
parent 31c05a35fc
commit dddecbb11c
18 changed files with 123 additions and 239 deletions
+6 -1
View File
@@ -1,5 +1,7 @@
# upload-GLTF # upload-GLTF
Version: `1.0.0`
A secure web interface for uploading `model.gltf` with its associated `.bin` file and textures with two outputs: A secure web interface for uploading `model.gltf` with its associated `.bin` file and textures with two outputs:
- **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions. - **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions.
@@ -146,6 +148,8 @@ All files are uploaded to `VF/` (not just diffs), because the move operation emp
- The upload flow prevents duplicate submissions on the client (`Envoyer`, overwrite confirmation, and "Git only" confirmation are locked while processing) - The upload flow prevents duplicate submissions on the client (`Envoyer`, overwrite confirmation, and "Git only" confirmation are locked while processing)
- The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes - The server applies a lightweight per-folder lock on Drive and Git routes to avoid duplicate commits and concurrent writes
- The folder is staged server-side so the browser sends the payload only once during the full upload flow - The folder is staged server-side so the browser sends the payload only once during the full upload flow
- Invalid `model.gltf` JSON or malformed `buffers` entries block the upload before remote writes
- Git LFS uploads are batched in groups of 100 objects to stay within the GitHub LFS Batch API limit
### Commit messages ### Commit messages
@@ -199,7 +203,7 @@ Uploaded models are pushed to `public/models/<folderName>/` in the target repo.
## Limitations ## Limitations
- Large uploads are faster than before because the folder is staged only once, but the Drive upload remains sequential. - Large uploads are faster than before because the folder is staged only once, but the Drive upload remains sequential.
- Git LFS uploads are still sequential. - Git LFS batch uploads are sequential by batch.
- Uploads expect a single `model.gltf` file plus optional flat support files (`.bin`, `.png`, `.jpg`, `.jpeg`, `.webp`). - Uploads expect a single `model.gltf` file plus optional flat support files (`.bin`, `.png`, `.jpg`, `.jpeg`, `.webp`).
## Project Structure ## Project Structure
@@ -240,6 +244,7 @@ lib/
├── types.ts # Server types (ParsedFile, FileDiff, staged asset metadata, etc.) ├── types.ts # Server types (ParsedFile, FileDiff, staged asset metadata, etc.)
├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.) ├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.)
├── upload-api.ts # Client-side API helpers (stage, check, uploadDrive, uploadGit) ├── upload-api.ts # Client-side API helpers (stage, check, uploadDrive, uploadGit)
├── guards.ts # Shared runtime guards and error message helpers
├── diff-files.ts # File diff classification (new/changed/unchanged/deleted) ├── diff-files.ts # File diff classification (new/changed/unchanged/deleted)
├── sanitize.ts # Filename sanitization ├── sanitize.ts # Filename sanitization
├── auth.ts # Upload secret validation (timing-safe) ├── auth.ts # Upload secret validation (timing-safe)
+3 -2
View File
@@ -5,6 +5,7 @@ import { classifyFileChanges } from '@/lib/diff-files'
import { getModelFolderPath } from '@/lib/model-paths' import { getModelFolderPath } from '@/lib/model-paths'
import { ensurePreparedStagingAssets } from '@/lib/upload-staging' import { ensurePreparedStagingAssets } from '@/lib/upload-staging'
import { parseStagingRequestBody } from '@/lib/upload-request' import { parseStagingRequestBody } from '@/lib/upload-request'
import { getErrorMessage } from '@/lib/guards'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -23,7 +24,7 @@ export async function POST(req: NextRequest) {
const body: unknown = await req.json() const body: unknown = await req.json()
stagingId = parseStagingRequestBody(body).stagingId stagingId = parseStagingRequestBody(body).stagingId
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = getErrorMessage(err)
return NextResponse.json({ success: false, error: message }, { status: 400 }) return NextResponse.json({ success: false, error: message }, { status: 400 })
} }
@@ -56,7 +57,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, exists: false }) return NextResponse.json({ success: true, exists: false })
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = getErrorMessage(err)
return NextResponse.json({ success: false, error: message }, { status: 500 }) return NextResponse.json({ success: false, error: message }, { status: 500 })
} }
} }
+3 -28
View File
@@ -9,33 +9,15 @@ import {
} from '@/lib/nextcloud' } from '@/lib/nextcloud'
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock' import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
import { parseDriveRequestBody } from '@/lib/upload-request' import { parseDriveRequestBody } from '@/lib/upload-request'
import { getErrorMessage } from '@/lib/guards'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// ---------------------------------------------------------------------------
// POST /api/upload/drive
//
// Upload **original** files to Nextcloud Drive.
//
// JSON body:
// - stagingId
// - action: "new" | "replace"
//
// Versioning logic:
// VF/{folderName} <- latest version
// V1/{folderName} <- first archive, V2/ second, etc.
//
// action="new" -> just mkdir + upload into VF/
// action="replace" -> archive VF -> Vx, then re-upload all files into VF/
// ---------------------------------------------------------------------------
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
// --- Auth ---
const authError = validateUploadSecret(req) const authError = validateUploadSecret(req)
if (authError) return authError if (authError) return authError
// --- Check Nextcloud config ---
if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_SHARE_TOKEN) { if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_SHARE_TOKEN) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)' }, { success: false, error: 'Nextcloud non configure sur le serveur (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)' },
@@ -43,7 +25,6 @@ export async function POST(req: NextRequest) {
) )
} }
// --- Parse staging request ---
let folderName: string let folderName: string
let parsedFiles: Awaited<ReturnType<typeof readStagedOriginalFiles>>['files'] let parsedFiles: Awaited<ReturnType<typeof readStagedOriginalFiles>>['files']
let action: 'new' | 'replace' let action: 'new' | 'replace'
@@ -57,7 +38,7 @@ export async function POST(req: NextRequest) {
folderName = staged.folderName folderName = staged.folderName
parsedFiles = staged.files parsedFiles = staged.files
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = getErrorMessage(err)
return NextResponse.json({ success: false, error: message }, { status: 400 }) return NextResponse.json({ success: false, error: message }, { status: 400 })
} }
@@ -73,23 +54,17 @@ export async function POST(req: NextRequest) {
try { try {
if (action === 'replace') { if (action === 'replace') {
// 1. Find the next available Vx
const nextVersion = await findNextVersion(basePath, folderName) const nextVersion = await findNextVersion(basePath, folderName)
// 2. Ensure Vx/ exists
await mkdirRecursive(`${basePath}/${nextVersion}`) await mkdirRecursive(`${basePath}/${nextVersion}`)
// 3. Move VF/{folderName} -> Vx/{folderName}
await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`) await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`)
// 4. Re-create VF/{folderName}
await mkdirRecursive(vfFolderPath) await mkdirRecursive(vfFolderPath)
} else { } else {
// action === 'new': just ensure VF/{folderName} exists
await mkdirRecursive(vfFolderPath) await mkdirRecursive(vfFolderPath)
} }
// --- Upload all original files ---
for (const pf of parsedFiles) { for (const pf of parsedFiles) {
const remotePath = `${vfFolderPath}/${pf.filename}` const remotePath = `${vfFolderPath}/${pf.filename}`
await uploadFile(remotePath, pf.buffer) await uploadFile(remotePath, pf.buffer)
@@ -102,7 +77,7 @@ export async function POST(req: NextRequest) {
message: `${parsedFiles.length} fichier(s) envoye(s) sur le Drive.`, message: `${parsedFiles.length} fichier(s) envoye(s) sur le Drive.`,
}) })
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur Nextcloud inconnue' const message = getErrorMessage(err, 'Erreur Nextcloud inconnue')
return NextResponse.json( return NextResponse.json(
{ success: false, error: `Drive echoue: ${message}` }, { success: false, error: `Drive echoue: ${message}` },
{ status: 500 }, { status: 500 },
+3 -8
View File
@@ -7,6 +7,7 @@ import { getModelFolderPath } from '@/lib/model-paths'
import { cleanupStagingUpload, ensurePreparedStagingAssets, readStagedManifest } from '@/lib/upload-staging' import { cleanupStagingUpload, ensurePreparedStagingAssets, readStagedManifest } from '@/lib/upload-staging'
import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock' import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock'
import { parseStagingRequestBody } from '@/lib/upload-request' import { parseStagingRequestBody } from '@/lib/upload-request'
import { getErrorMessage } from '@/lib/guards'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -16,7 +17,6 @@ export const dynamic = 'force-dynamic'
* Upload prepared files and push to GitHub via Octokit. * Upload prepared files and push to GitHub via Octokit.
*/ */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
// --- Auth ---
const authError = validateUploadSecret(req) const authError = validateUploadSecret(req)
if (authError) return authError if (authError) return authError
@@ -29,7 +29,7 @@ export async function POST(req: NextRequest) {
const manifest = await readStagedManifest(stagingId) const manifest = await readStagedManifest(stagingId)
folderName = manifest.folderName folderName = manifest.folderName
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = getErrorMessage(err)
return NextResponse.json({ success: false, error: message }, { status: 400 }) return NextResponse.json({ success: false, error: message }, { status: 400 })
} }
@@ -41,7 +41,6 @@ export async function POST(req: NextRequest) {
} }
try { try {
// --- Process files (preserve model + buffers, compress textures for Git) ---
const { const {
filesToPush, filesToPush,
modelFilename, modelFilename,
@@ -50,7 +49,6 @@ export async function POST(req: NextRequest) {
assetSummaries, assetSummaries,
} = await ensurePreparedStagingAssets(stagingId) } = await ensurePreparedStagingAssets(stagingId)
// --- Detect existing files and classify changes ---
const folderPath = getModelFolderPath(folderName) const folderPath = getModelFolderPath(folderName)
const remote = await getRemoteFolder(folderPath) const remote = await getRemoteFolder(folderPath)
const remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.size])) const remoteFileMap = new Map(remote.files.map((f) => [f.name.toLowerCase(), f.size]))
@@ -60,7 +58,6 @@ export async function POST(req: NextRequest) {
const { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } = const { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } =
classifyFileChanges(filesToPush, remoteFileMap, folderPath) classifyFileChanges(filesToPush, remoteFileMap, folderPath)
// If nothing changed, don't create an empty commit
if (changedFilesToPush.length === 0 && deletePaths.length === 0) { if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
await cleanupStagingUpload(stagingId).catch(() => {}) await cleanupStagingUpload(stagingId).catch(() => {})
return NextResponse.json({ return NextResponse.json({
@@ -73,7 +70,6 @@ export async function POST(req: NextRequest) {
}) })
} }
// --- Build commit message ---
const commitMessage = buildCommitMessage( const commitMessage = buildCommitMessage(
folderName, folderName,
modelFilename, modelFilename,
@@ -83,7 +79,6 @@ export async function POST(req: NextRequest) {
deletedFileNames, deletedFileNames,
) )
// --- Push all in one commit ---
try { try {
const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage) const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage)
await cleanupStagingUpload(stagingId).catch(() => {}) await cleanupStagingUpload(stagingId).catch(() => {})
@@ -98,7 +93,7 @@ export async function POST(req: NextRequest) {
commitUrl, commitUrl,
}) })
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue' const message = getErrorMessage(err, 'Erreur GitHub inconnue')
return NextResponse.json( return NextResponse.json(
{ success: false, error: `Push GitHub echoue: ${message}` }, { success: false, error: `Push GitHub echoue: ${message}` },
{ status: 500 }, { status: 500 },
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { validateUploadSecret } from '@/lib/auth' import { validateUploadSecret } from '@/lib/auth'
import { parseMultiUpload } from '@/lib/parse-upload' import { parseMultiUpload } from '@/lib/parse-upload'
import { createStagingUpload } from '@/lib/upload-staging' import { createStagingUpload } from '@/lib/upload-staging'
import { getErrorMessage } from '@/lib/guards'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -15,7 +16,7 @@ export async function POST(req: NextRequest) {
const staged = await createStagingUpload(parsed.folderName, parsed.files) const staged = await createStagingUpload(parsed.folderName, parsed.files)
return NextResponse.json({ success: true, ...staged }) return NextResponse.json({ success: true, ...staged })
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = getErrorMessage(err)
return NextResponse.json({ success: false, error: message }, { status: 400 }) return NextResponse.json({ success: false, error: message }, { status: 400 })
} }
} }
+6 -16
View File
@@ -1,10 +1,7 @@
'use client' 'use client'
// ---------------------------------------------------------------------------
// Upload orchestration hook — manages the Drive→Git upload pipeline
// ---------------------------------------------------------------------------
import { useState, useRef, useCallback } from 'react' import { useState, useRef, useCallback } from 'react'
import { getErrorMessage } from '@/lib/guards'
import type { FolderEntry } from '@/lib/client-types' import type { FolderEntry } from '@/lib/client-types'
import type { FileDiff } from '@/lib/types' import type { FileDiff } from '@/lib/types'
import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api' import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api'
@@ -67,13 +64,11 @@ export function useUploadOrchestrator({
const uploadActionRef = useRef(false) const uploadActionRef = useRef(false)
const stagingIdRef = useRef<string | null>(null) const stagingIdRef = useRef<string | null>(null)
// Refs for values used inside callbacks to avoid stale closures
const secretRef = useRef(secret) const secretRef = useRef(secret)
secretRef.current = secret secretRef.current = secret
const entriesRef = useRef(entries) const entriesRef = useRef(entries)
entriesRef.current = entries entriesRef.current = entries
// ---- Internal: push a single folder to Git ----
const pushGit = useCallback(async (index: number, signal?: AbortSignal) => { const pushGit = useCallback(async (index: number, signal?: AbortSignal) => {
const stagingId = stagingIdRef.current const stagingId = stagingIdRef.current
if (!stagingId) { if (!stagingId) {
@@ -95,7 +90,7 @@ export function useUploadOrchestrator({
endGitLog(gitResult.success ? 'done' : 'failed', { error: gitResult.error }) endGitLog(gitResult.success ? 'done' : 'failed', { error: gitResult.error })
} catch (err) { } catch (err) {
endGitLog(signal?.aborted ? 'cancelled' : 'failed', { endGitLog(signal?.aborted ? 'cancelled' : 'failed', {
error: err instanceof Error ? err.message : 'Erreur inconnue', error: getErrorMessage(err),
}) })
throw err throw err
} }
@@ -108,7 +103,6 @@ export function useUploadOrchestrator({
}) })
}, [updateEntry]) }, [updateEntry])
// ---- Main upload flow: Drive first, then Git ----
const proceedUpload = useCallback(async () => { const proceedUpload = useCallback(async () => {
if (uploadActionRef.current) return if (uploadActionRef.current) return
uploadActionRef.current = true uploadActionRef.current = true
@@ -135,7 +129,6 @@ export function useUploadOrchestrator({
return return
} }
// ---- Step 1: Drive upload ----
updateEntry(i, { updateEntry(i, {
status: 'uploading', status: 'uploading',
progress: 1, progress: 1,
@@ -161,7 +154,7 @@ export function useUploadOrchestrator({
endDriveLog(driveResult.success ? 'done' : 'failed', { error: driveResult.error }) endDriveLog(driveResult.success ? 'done' : 'failed', { error: driveResult.error })
} catch (err) { } catch (err) {
endDriveLog(controller.signal.aborted ? 'cancelled' : 'failed', { endDriveLog(controller.signal.aborted ? 'cancelled' : 'failed', {
error: err instanceof Error ? err.message : 'Erreur inconnue', error: getErrorMessage(err),
}) })
throw err throw err
} }
@@ -174,7 +167,6 @@ export function useUploadOrchestrator({
updateEntry(i, { driveStatus: 'success', progress: 50 }) updateEntry(i, { driveStatus: 'success', progress: 50 })
// ---- Step 2: Git upload ----
await pushGit(i, controller.signal) await pushGit(i, controller.signal)
} }
} finally { } finally {
@@ -184,8 +176,6 @@ export function useUploadOrchestrator({
} }
}, [updateEntry, pushGit]) }, [updateEntry, pushGit])
// ---- Handlers ----
const handleUpload = useCallback(async () => { const handleUpload = useCallback(async () => {
if (uploadActionRef.current || isChecking || isUploading) return if (uploadActionRef.current || isChecking || isUploading) return
@@ -217,7 +207,7 @@ export function useUploadOrchestrator({
endStageLog('done', { stagingId: staged.stagingId, filesCount: staged.filesCount }) endStageLog('done', { stagingId: staged.stagingId, filesCount: staged.filesCount })
} catch (err) { } catch (err) {
endStageLog(controller.signal.aborted ? 'cancelled' : 'failed', { endStageLog(controller.signal.aborted ? 'cancelled' : 'failed', {
error: err instanceof Error ? err.message : 'Erreur inconnue', error: getErrorMessage(err),
}) })
throw err throw err
} }
@@ -239,7 +229,7 @@ export function useUploadOrchestrator({
endCheckLog('done', { exists: check.exists, diffs: check.diffs.length }) endCheckLog('done', { exists: check.exists, diffs: check.diffs.length })
} catch (err) { } catch (err) {
endCheckLog(controller.signal.aborted ? 'cancelled' : 'failed', { endCheckLog(controller.signal.aborted ? 'cancelled' : 'failed', {
error: err instanceof Error ? err.message : 'Erreur inconnue', error: getErrorMessage(err),
}) })
throw err throw err
} }
@@ -261,7 +251,7 @@ export function useUploadOrchestrator({
return return
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = getErrorMessage(err)
setGlobalError(message) setGlobalError(message)
uploadActionRef.current = false uploadActionRef.current = false
setIsChecking(false) setIsChecking(false)
+1 -13
View File
@@ -1,18 +1,10 @@
// ---------------------------------------------------------------------------
// File diff classification — compares local files against a remote file map
// ---------------------------------------------------------------------------
import { MODEL_EXTENSIONS } from './constants' import { MODEL_EXTENSIONS } from './constants'
import type { FileChange, PushFile } from './types' import type { FileChange, PushFile } from './types'
export interface DiffResult { interface DiffResult {
/** Map of lowercase filename → change status (for commit message) */
fileChanges: Map<string, FileChange> fileChanges: Map<string, FileChange>
/** Files that actually need to be pushed (new or changed) */
changedFilesToPush: PushFile[] changedFilesToPush: PushFile[]
/** Filenames that were on remote but not in the new upload */
deletedFileNames: string[] deletedFileNames: string[]
/** Full paths for deletion on remote */
deletePaths: string[] deletePaths: string[]
} }
@@ -41,13 +33,10 @@ export function classifyFileChanges(
const isModel = MODEL_EXTENSIONS.has(ext) const isModel = MODEL_EXTENSIONS.has(ext)
if (isModel) { if (isModel) {
// Model: always re-push since compression makes size comparison unreliable.
// Mark as 'unchanged' for the commit message when the folder already exists.
const remoteSize = remoteFileMap.get(filename.toLowerCase()) const remoteSize = remoteFileMap.get(filename.toLowerCase())
fileChanges.set(filename.toLowerCase(), remoteSize === undefined ? 'new' : 'unchanged') fileChanges.set(filename.toLowerCase(), remoteSize === undefined ? 'new' : 'unchanged')
changedFilesToPush.push(f) changedFilesToPush.push(f)
} else { } else {
// Texture: compare by size
const localSize = Buffer.from(f.contentBase64, 'base64').length const localSize = Buffer.from(f.contentBase64, 'base64').length
const remoteSize = remoteFileMap.get(filename.toLowerCase()) const remoteSize = remoteFileMap.get(filename.toLowerCase())
@@ -63,7 +52,6 @@ export function classifyFileChanges(
} }
} }
// Files on remote not in the new upload → deleted (orphans)
const newFileNames = new Set(filesToPush.map((f) => (f.path.split('/').pop() ?? '').toLowerCase())) const newFileNames = new Set(filesToPush.map((f) => (f.path.split('/').pop() ?? '').toLowerCase()))
const deletedFileNames: string[] = [] const deletedFileNames: string[] = []
const deletePaths: string[] = [] const deletePaths: string[] = []
+59 -57
View File
@@ -1,14 +1,11 @@
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { Octokit } from '@octokit/rest' import { Octokit } from '@octokit/rest'
import { LFS_EXTENSIONS } from './constants' import { LFS_EXTENSIONS } from './constants'
import { isRecord } from './guards'
import type { PushFile, RemoteFile } from './types' import type { PushFile, RemoteFile } from './types'
const LFS_BATCH_SIZE = 100 const LFS_BATCH_SIZE = 100
// ---------------------------------------------------------------------------
// Octokit helpers
// ---------------------------------------------------------------------------
function isHttpError(err: unknown): err is { status: number } { function isHttpError(err: unknown): err is { status: number } {
return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record<string, unknown>).status === 'number' return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record<string, unknown>).status === 'number'
} }
@@ -33,22 +30,15 @@ function parseRepoUrl(): { owner: string; repo: string } {
return { owner: match[1], repo: match[2] } return { owner: match[1], repo: match[2] }
} }
// ---------------------------------------------------------------------------
// Git LFS helpers
// ---------------------------------------------------------------------------
/** Check if a file path should be tracked by LFS based on its extension. */
function isLfsFile(filePath: string): boolean { function isLfsFile(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase() const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
return LFS_EXTENSIONS.has(ext) return LFS_EXTENSIONS.has(ext)
} }
/** Build an LFS pointer file (text content stored in the Git blob). */
function buildLfsPointer(sha256: string, size: number): string { function buildLfsPointer(sha256: string, size: number): string {
return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n` return `version https://git-lfs.github.com/spec/v1\noid sha256:${sha256}\nsize ${size}\n`
} }
/** Parse an LFS pointer to extract the real file size. Returns null if not a pointer. */
function parseLfsPointer(content: string): { oid: string; size: number } | null { function parseLfsPointer(content: string): { oid: string; size: number } | null {
if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null
const sizeMatch = content.match(/^size (\d+)$/m) const sizeMatch = content.match(/^size (\d+)$/m)
@@ -81,14 +71,62 @@ interface LfsObject {
contentBase64: string contentBase64: string
} }
/** interface LfsBatchObject {
* Upload binary objects to the Git LFS server via the Batch API. oid: string
* size: number
* Flow: actions?: {
* 1. POST to the LFS batch endpoint with operation "upload" upload?: { href: string; header?: Record<string, string> }
* 2. For each object that has an "upload" action, PUT the binary content verify?: { href: string; header?: Record<string, string> }
* 3. If the server omits "actions", the object already exists — skip upload }
*/ error?: { code: number; message: string }
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string')
}
function parseLfsAction(value: unknown) {
if (!isRecord(value) || typeof value.href !== 'string') return undefined
return {
href: value.href,
header: isStringRecord(value.header) ? value.header : undefined,
}
}
function parseLfsBatchObject(value: unknown): LfsBatchObject | null {
if (!isRecord(value) || typeof value.oid !== 'string' || typeof value.size !== 'number') return null
const actions = isRecord(value.actions)
? {
upload: parseLfsAction(value.actions.upload),
verify: parseLfsAction(value.actions.verify),
}
: undefined
const error = isRecord(value.error) && typeof value.error.code === 'number' && typeof value.error.message === 'string'
? { code: value.error.code, message: value.error.message }
: undefined
return { oid: value.oid, size: value.size, actions, error }
}
function parseLfsBatchResponse(value: unknown): LfsBatchObject[] {
if (!isRecord(value) || !Array.isArray(value.objects)) {
throw new Error('LFS batch response invalide')
}
const objects: LfsBatchObject[] = []
for (const object of value.objects) {
const parsed = parseLfsBatchObject(object)
if (!parsed) {
throw new Error('LFS batch object invalide')
}
objects.push(parsed)
}
return objects
}
async function uploadToLfs( async function uploadToLfs(
owner: string, owner: string,
repo: string, repo: string,
@@ -118,7 +156,6 @@ async function uploadToLfsBatch(
const token = process.env.GITHUB_TOKEN! const token = process.env.GITHUB_TOKEN!
const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch` const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch`
// 1. Batch request — ask for upload URLs
const batchRes = await fetch(lfsUrl, { const batchRes = await fetch(lfsUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -142,27 +179,15 @@ async function uploadToLfsBatch(
throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`) throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`)
} }
const batchData = (await batchRes.json()) as { const batchObjects = parseLfsBatchResponse(await batchRes.json())
objects: Array<{
oid: string
size: number
actions?: {
upload?: { href: string; header?: Record<string, string> }
verify?: { href: string; header?: Record<string, string> }
}
error?: { code: number; message: string }
}>
}
// 2. Upload each object that has an upload action
const objectMap = new Map(objects.map((o) => [o.oid, o])) const objectMap = new Map(objects.map((o) => [o.oid, o]))
for (const obj of batchData.objects) { for (const obj of batchObjects) {
if (obj.error) { if (obj.error) {
throw new Error(`LFS error for ${obj.oid}: ${obj.error.message} (${obj.error.code})`) throw new Error(`LFS error for ${obj.oid}: ${obj.error.message} (${obj.error.code})`)
} }
// No actions = server already has this object, skip
if (!obj.actions?.upload) continue if (!obj.actions?.upload) continue
const local = objectMap.get(obj.oid) const local = objectMap.get(obj.oid)
@@ -187,7 +212,6 @@ async function uploadToLfsBatch(
throw new Error(`LFS upload failed for ${obj.oid} (${uploadRes.status}): ${text}`) throw new Error(`LFS upload failed for ${obj.oid} (${uploadRes.status}): ${text}`)
} }
// 3. Verify if required
if (obj.actions.verify) { if (obj.actions.verify) {
const verifyAction = obj.actions.verify const verifyAction = obj.actions.verify
const verifyHeaders: Record<string, string> = { const verifyHeaders: Record<string, string> = {
@@ -214,10 +238,6 @@ async function uploadToLfsBatch(
}) })
} }
// ---------------------------------------------------------------------------
// Read remote folder contents (with real file sizes for LFS files)
// ---------------------------------------------------------------------------
export async function getRemoteFolder( export async function getRemoteFolder(
folderPath: string, folderPath: string,
): Promise<{ exists: boolean; files: RemoteFile[] }> { ): Promise<{ exists: boolean; files: RemoteFile[] }> {
@@ -237,16 +257,12 @@ export async function getRemoteFolder(
return { exists: false, files: [] } return { exists: false, files: [] }
} }
// For LFS-tracked files, the "size" from getContent is the pointer size (~130 bytes),
// not the real file size. We need to fetch each LFS pointer to get the real size.
const files: RemoteFile[] = await Promise.all( const files: RemoteFile[] = await Promise.all(
data.map(async (f): Promise<RemoteFile> => { data.map(async (f): Promise<RemoteFile> => {
if (!isLfsFile(f.name) || f.size > 1024) { if (!isLfsFile(f.name) || f.size > 1024) {
// Not LFS or too large to be a pointer — use size as-is
return { name: f.name, size: f.size } return { name: f.name, size: f.size }
} }
// Fetch the blob content to check if it's an LFS pointer
try { try {
const { data: fileData } = await octokit.repos.getContent({ const { data: fileData } = await octokit.repos.getContent({
owner, owner,
@@ -279,10 +295,6 @@ export async function getRemoteFolder(
} }
} }
// ---------------------------------------------------------------------------
// Push all files in a single commit (with optional deletions + LFS support)
// ---------------------------------------------------------------------------
export async function pushAllToGitHub( export async function pushAllToGitHub(
files: PushFile[], files: PushFile[],
deletePaths: string[], deletePaths: string[],
@@ -292,7 +304,6 @@ export async function pushAllToGitHub(
const { owner, repo } = parseRepoUrl() const { owner, repo } = parseRepoUrl()
const branch = process.env.GIT_BRANCH ?? 'main' const branch = process.env.GIT_BRANCH ?? 'main'
// --- Separate LFS files from regular files ---
const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = [] const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = []
const regularFiles: PushFile[] = [] const regularFiles: PushFile[] = []
@@ -306,7 +317,6 @@ export async function pushAllToGitHub(
} }
} }
// --- Upload LFS objects to the LFS server ---
if (lfsFiles.length > 0) { if (lfsFiles.length > 0) {
await uploadToLfs( await uploadToLfs(
owner, owner,
@@ -315,7 +325,6 @@ export async function pushAllToGitHub(
) )
} }
// 1. Get latest commit on branch
const { data: ref } = await octokit.git.getRef({ const { data: ref } = await octokit.git.getRef({
owner, owner,
repo, repo,
@@ -323,21 +332,18 @@ export async function pushAllToGitHub(
}) })
const latestCommitSha = ref.object.sha const latestCommitSha = ref.object.sha
// 2. Get that commit's tree
const { data: commit } = await octokit.git.getCommit({ const { data: commit } = await octokit.git.getCommit({
owner, owner,
repo, repo,
commit_sha: latestCommitSha, commit_sha: latestCommitSha,
}) })
// 3. Create blobs — LFS files get pointer blobs, regular files get raw blobs
const allFiles = [...regularFiles, ...lfsFiles] const allFiles = [...regularFiles, ...lfsFiles]
const blobResults = await Promise.all( const blobResults = await Promise.all(
allFiles.map((f) => { allFiles.map((f) => {
const lfs = lfsFiles.find((lf) => lf.path === f.path) const lfs = lfsFiles.find((lf) => lf.path === f.path)
if (lfs) { if (lfs) {
// Create a blob with the LFS pointer text (NOT the binary content)
const pointer = buildLfsPointer(lfs.oid, lfs.size) const pointer = buildLfsPointer(lfs.oid, lfs.size)
return octokit.git.createBlob({ return octokit.git.createBlob({
owner, owner,
@@ -346,7 +352,6 @@ export async function pushAllToGitHub(
encoding: 'base64', encoding: 'base64',
}) })
} }
// Regular file — push content as-is
return octokit.git.createBlob({ return octokit.git.createBlob({
owner, owner,
repo, repo,
@@ -356,7 +361,6 @@ export async function pushAllToGitHub(
}), }),
) )
// 4. Build tree entries: new/changed files + deletions
const newFilePaths = new Set(files.map((f) => f.path)) const newFilePaths = new Set(files.map((f) => f.path))
const deleteEntries = deletePaths const deleteEntries = deletePaths
.filter((p) => !newFilePaths.has(p)) .filter((p) => !newFilePaths.has(p))
@@ -382,7 +386,6 @@ export async function pushAllToGitHub(
], ],
}) })
// 5. Create a single commit
const { data: newCommit } = await octokit.git.createCommit({ const { data: newCommit } = await octokit.git.createCommit({
owner, owner,
repo, repo,
@@ -391,7 +394,6 @@ export async function pushAllToGitHub(
parents: [latestCommitSha], parents: [latestCommitSha],
}) })
// 6. Update branch ref
await octokit.git.updateRef({ await octokit.git.updateRef({
owner, owner,
repo, repo,
+7
View File
@@ -0,0 +1,7 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function getErrorMessage(error: unknown, fallback = 'Erreur inconnue') {
return error instanceof Error ? error.message : fallback
}
+1 -32
View File
@@ -1,11 +1,5 @@
// ---------------------------------------------------------------------------
// Nextcloud WebDAV client
// Uses native fetch — no npm package needed.
// ---------------------------------------------------------------------------
const MAX_VERSIONS = 1000 const MAX_VERSIONS = 1000
// Lazy-cached config to avoid recomputing on every request
let cachedConfig: { davBase: string; auth: string } | null = null let cachedConfig: { davBase: string; auth: string } | null = null
function getConfig() { function getConfig() {
@@ -19,7 +13,6 @@ function getConfig() {
throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)') throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)')
} }
// Public share WebDAV: https://cloud.example.com/public.php/webdav/
const davBase = `${url.replace(/\/+$/, '')}/public.php/webdav` const davBase = `${url.replace(/\/+$/, '')}/public.php/webdav`
const auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64') const auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64')
@@ -32,10 +25,6 @@ function davUrl(davBase: string, path: string): string {
return `${davBase}/${clean}` return `${davBase}/${clean}`
} }
// ---------------------------------------------------------------------------
// Low-level WebDAV helpers
// ---------------------------------------------------------------------------
async function davRequest( async function davRequest(
method: string, method: string,
path: string, path: string,
@@ -57,12 +46,7 @@ async function davRequest(
return res return res
} }
// --------------------------------------------------------------------------- async function folderExists(path: string): Promise<boolean> {
// Public API
// ---------------------------------------------------------------------------
/** Check if a folder exists on the Nextcloud instance. */
export async function folderExists(path: string): Promise<boolean> {
const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' }) const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' })
if (res.status === 404) return false if (res.status === 404) return false
@@ -72,10 +56,6 @@ export async function folderExists(path: string): Promise<boolean> {
throw new Error(`PROPFIND ${path} failed (${res.status}): ${text.slice(0, 200)}`) throw new Error(`PROPFIND ${path} failed (${res.status}): ${text.slice(0, 200)}`)
} }
/**
* Create a folder and all parent segments if they don't exist.
* Like `mkdir -p`. Attempts MKCOL directly and handles 405 (already exists).
*/
export async function mkdirRecursive(path: string): Promise<void> { export async function mkdirRecursive(path: string): Promise<void> {
const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
let current = '' let current = ''
@@ -84,14 +64,12 @@ export async function mkdirRecursive(path: string): Promise<void> {
current += '/' + seg current += '/' + seg
const res = await davRequest('MKCOL', current + '/') const res = await davRequest('MKCOL', current + '/')
if (res.status !== 201 && res.status !== 405) { if (res.status !== 201 && res.status !== 405) {
// 201 = created, 405 = already exists — both are fine
const text = await res.text().catch(() => '') const text = await res.text().catch(() => '')
throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`) throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
} }
} }
} }
/** Upload a file (overwrite if exists). */
export async function uploadFile(path: string, content: Buffer): Promise<void> { export async function uploadFile(path: string, content: Buffer): Promise<void> {
const res = await davRequest('PUT', path, content, { const res = await davRequest('PUT', path, content, {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
@@ -104,7 +82,6 @@ export async function uploadFile(path: string, content: Buffer): Promise<void> {
} }
} }
/** Move (rename) a folder or file. */
export async function moveFolder(from: string, to: string): Promise<void> { export async function moveFolder(from: string, to: string): Promise<void> {
const { davBase } = getConfig() const { davBase } = getConfig()
const destination = davUrl(davBase, to) + '/' const destination = davUrl(davBase, to) + '/'
@@ -120,14 +97,6 @@ export async function moveFolder(from: string, to: string): Promise<void> {
} }
} }
// ---------------------------------------------------------------------------
// High-level: find next available version folder
// ---------------------------------------------------------------------------
/**
* Find the next available Vx folder for archiving.
* E.g. if V1/coffeetest exists but V2/coffeetest doesn't, returns "V2".
*/
export async function findNextVersion( export async function findNextVersion(
basePath: string, basePath: string,
folderName: string, folderName: string,
+2 -21
View File
@@ -4,19 +4,11 @@ import { sanitizeFilename } from './sanitize'
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE } from './constants' import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE } from './constants'
import type { ParsedFile } from './types' import type { ParsedFile } from './types'
export interface ParsedUpload { interface ParsedUpload {
folderName: string folderName: string
files: ParsedFile[] files: ParsedFile[]
/** Any extra string fields from the FormData (e.g. "action") */
extra: Record<string, string>
} }
/**
* Parse a multi-file FormData upload request.
* Validates file extensions, file sizes, and returns parsed files.
* Extra string fields (beyond folderName, files, fileTypes, textureNames)
* are returned in `extra`.
*/
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> { export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
const formData = await req.formData() const formData = await req.formData()
const folderValue = formData.get('folderName') const folderValue = formData.get('folderName')
@@ -27,16 +19,6 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
const fileTypes = formData.getAll('fileTypes').filter((value): value is string => typeof value === 'string') const fileTypes = formData.getAll('fileTypes').filter((value): value is string => typeof value === 'string')
const textureNames = formData.getAll('textureNames').filter((value): value is string => typeof value === 'string') const textureNames = formData.getAll('textureNames').filter((value): value is string => typeof value === 'string')
// Collect extra string fields
const knownKeys = new Set(['folderName', 'files', 'fileTypes', 'textureNames'])
const extra: Record<string, string> = {}
for (const [key, value] of formData.entries()) {
if (!knownKeys.has(key) && typeof value === 'string') {
extra[key] = value
}
}
// Runtime validation: ensure entries are actual File objects
const fileEntries: File[] = [] const fileEntries: File[] = []
for (const entry of rawFiles) { for (const entry of rawFiles) {
if (!(entry instanceof File)) { if (!(entry instanceof File)) {
@@ -56,7 +38,6 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
const file = fileEntries[i] const file = fileEntries[i]
if (!file || file.size === 0) continue if (!file || file.size === 0) continue
// File size limit
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
throw new Error( throw new Error(
`Fichier "${file.name}" trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`, `Fichier "${file.name}" trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`,
@@ -100,5 +81,5 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
throw new Error('Un seul fichier model.gltf est autorise') throw new Error('Un seul fichier model.gltf est autorise')
} }
return { folderName: safeFolderName, files: parsed, extra } return { folderName: safeFolderName, files: parsed }
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import { extname } from 'path' import { extname } from 'path'
import sharp from 'sharp' import sharp from 'sharp'
import { getErrorMessage } from './guards'
interface TextureCompressionResult { interface TextureCompressionResult {
buffer: Buffer buffer: Buffer
@@ -35,7 +36,7 @@ export async function compressTextureBuffer(
} }
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = getErrorMessage(err, String(err))
return { return {
buffer, buffer,
compressed: false, compressed: false,
+3 -42
View File
@@ -1,7 +1,4 @@
// --------------------------------------------------------------------------- import { isRecord } from './guards'
// Client-side API helpers for upload operations
// ---------------------------------------------------------------------------
import type { FolderEntry } from './client-types' import type { FolderEntry } from './client-types'
import type { FileDiff } from './types' import type { FileDiff } from './types'
@@ -10,16 +7,12 @@ export interface CheckResult {
diffs: FileDiff[] diffs: FileDiff[]
} }
export interface StageResult { interface StageResult {
stagingId: string stagingId: string
folderName: string folderName: string
filesCount: number filesCount: number
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function getApiError(data: unknown, fallback: string) { function getApiError(data: unknown, fallback: string) {
return isRecord(data) && typeof data.error === 'string' ? data.error : fallback return isRecord(data) && typeof data.error === 'string' ? data.error : fallback
} }
@@ -30,23 +23,10 @@ function isFileDiff(value: unknown): value is FileDiff {
&& (value.status === 'new' || value.status === 'changed' || value.status === 'deleted') && (value.status === 'new' || value.status === 'changed' || value.status === 'deleted')
} }
// --------------------------------------------------------------------------- function buildUploadFormData(folder: FolderEntry): FormData {
// Shared FormData builder
// ---------------------------------------------------------------------------
function buildUploadFormData(
folder: FolderEntry,
extra?: Record<string, string>,
): FormData {
const formData = new FormData() const formData = new FormData()
formData.append('folderName', folder.folderName) formData.append('folderName', folder.folderName)
if (extra) {
for (const [key, value] of Object.entries(extra)) {
formData.append(key, value)
}
}
formData.append('files', folder.modelFile) formData.append('files', folder.modelFile)
formData.append('fileTypes', 'model') formData.append('fileTypes', 'model')
formData.append('textureNames', '') formData.append('textureNames', '')
@@ -60,14 +40,6 @@ function buildUploadFormData(
return formData return formData
} }
// ---------------------------------------------------------------------------
// Check folder diffs against remote (GitHub)
// ---------------------------------------------------------------------------
/**
* Check whether a folder already exists on the remote repo and compute diffs.
* Throws on auth/network errors so callers can surface them to the user.
*/
export async function checkFolderDiffs( export async function checkFolderDiffs(
stagingId: string, stagingId: string,
secret: string, secret: string,
@@ -85,7 +57,6 @@ export async function checkFolderDiffs(
const data: unknown = await res.json() const data: unknown = await res.json()
// Surface auth/server errors to the caller
if (!res.ok) { if (!res.ok) {
throw new Error(getApiError(data, `Erreur serveur (${res.status})`)) throw new Error(getApiError(data, `Erreur serveur (${res.status})`))
} }
@@ -129,11 +100,6 @@ export async function stageUpload(
} }
} }
// ---------------------------------------------------------------------------
// Upload original files to Nextcloud Drive
// ---------------------------------------------------------------------------
/** Upload original files to Nextcloud Drive. */
export async function uploadDrive( export async function uploadDrive(
stagingId: string, stagingId: string,
secret: string, secret: string,
@@ -163,11 +129,6 @@ export async function uploadDrive(
} }
} }
// ---------------------------------------------------------------------------
// Upload files to GitHub
// ---------------------------------------------------------------------------
/** Upload files to GitHub. */
export async function uploadGit( export async function uploadGit(
stagingId: string, stagingId: string,
secret: string, secret: string,
+3 -5
View File
@@ -1,4 +1,6 @@
export type DriveAction = 'new' | 'replace' import { isRecord } from './guards'
type DriveAction = 'new' | 'replace'
interface StagingRequestBody { interface StagingRequestBody {
stagingId: string stagingId: string
@@ -8,10 +10,6 @@ interface DriveRequestBody extends StagingRequestBody {
action: DriveAction action: DriveAction
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function parseStagingRequestBody(value: unknown): StagingRequestBody { export function parseStagingRequestBody(value: unknown): StagingRequestBody {
if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') { if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') {
throw new Error('stagingId manquant') throw new Error('stagingId manquant')
+1 -1
View File
@@ -65,7 +65,7 @@ async function writeManifest(manifest: StagingManifest) {
await writeFile(getManifestPath(manifest.stagingId), JSON.stringify(manifest, null, 2), 'utf-8') await writeFile(getManifestPath(manifest.stagingId), JSON.stringify(manifest, null, 2), 'utf-8')
} }
export async function cleanupExpiredStagingUploads() { async function cleanupExpiredStagingUploads() {
if (!existsSync(STAGING_ROOT)) return if (!existsSync(STAGING_ROOT)) return
const entries = await readdir(STAGING_ROOT, { withFileTypes: true }) const entries = await readdir(STAGING_ROOT, { withFileTypes: true })
+18 -8
View File
@@ -1,9 +1,6 @@
// ---------------------------------------------------------------------------
// Client-side folder validation
// ---------------------------------------------------------------------------
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants' import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
import { formatAssetFamilies, getAssetFamily, getForbiddenAssetFamilyAlias } from '@/lib/asset-naming' import { formatAssetFamilies, getAssetFamily, getForbiddenAssetFamilyAlias } from '@/lib/asset-naming'
import { getErrorMessage, isRecord } from '@/lib/guards'
import type { TextureFile } from '@/lib/client-types' import type { TextureFile } from '@/lib/client-types'
const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS] const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS]
@@ -22,7 +19,9 @@ export type ValidationResult =
| { ok: false; errors: string[] } | { ok: false; errors: string[] }
function isGltfJson(value: unknown): value is GltfJson { function isGltfJson(value: unknown): value is GltfJson {
return typeof value === 'object' && value !== null if (!isRecord(value)) return false
if (value.buffers === undefined) return true
return Array.isArray(value.buffers) && value.buffers.every(isRecord)
} }
function getReferencedBufferNames(gltf: GltfJson) { function getReferencedBufferNames(gltf: GltfJson) {
@@ -83,10 +82,12 @@ async function getGltfWarnings(model: File, supportFiles: File[]) {
try { try {
parsed = JSON.parse(await model.text()) parsed = JSON.parse(await model.text())
} catch { } catch {
return warnings throw new Error('model.gltf contient un JSON invalide')
} }
if (!isGltfJson(parsed)) return warnings if (!isGltfJson(parsed)) {
throw new Error('model.gltf a une structure invalide')
}
const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase())) const supportFilenames = new Set(supportFiles.map((file) => file.name.toLowerCase()))
const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin')) const binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin'))
@@ -144,7 +145,16 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
return { ok: false, errors } return { ok: false, errors }
} }
const warnings = await getGltfWarnings(modelFiles[0], supportFiles) let warnings: string[] = []
try {
warnings = await getGltfWarnings(modelFiles[0], supportFiles)
} catch (err) {
errors.push(getErrorMessage(err, 'model.gltf invalide'))
}
if (errors.length > 0) {
return { ok: false, errors }
}
return { ok: true, model: modelFiles[0], textures, warnings } return { ok: true, model: modelFiles[0], textures, warnings }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "upload-gltf", "name": "upload-gltf",
"version": "0.1.5", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "upload-gltf", "name": "upload-gltf",
"version": "0.1.5", "version": "1.0.0",
"dependencies": { "dependencies": {
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@react-three/drei": "^10.7.0", "@react-three/drei": "^10.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "upload-gltf", "name": "upload-gltf",
"version": "0.1.5", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",