From dddecbb11c23dc6ea68f398e234bf9f8bbadf1e9 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 27 Apr 2026 23:43:16 +0200 Subject: [PATCH] chore: prepare v1.0.0 release --- README.md | 7 +- app/api/upload/check/route.ts | 5 +- app/api/upload/drive/route.ts | 31 +-------- app/api/upload/git/route.ts | 11 +--- app/api/upload/stage/route.ts | 3 +- hooks/useUploadOrchestrator.ts | 22 ++----- lib/diff-files.ts | 14 +--- lib/github.ts | 116 +++++++++++++++++---------------- lib/guards.ts | 7 ++ lib/nextcloud.ts | 33 +--------- lib/parse-upload.ts | 23 +------ lib/texture-compression.ts | 3 +- lib/upload-api.ts | 45 +------------ lib/upload-request.ts | 8 +-- lib/upload-staging.ts | 2 +- lib/validate-folder.ts | 26 +++++--- package-lock.json | 4 +- package.json | 2 +- 18 files changed, 123 insertions(+), 239 deletions(-) create mode 100644 lib/guards.ts diff --git a/README.md b/README.md index 2d48196..dde7640 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # upload-GLTF +Version: `1.0.0` + 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. @@ -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 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 +- 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 @@ -199,7 +203,7 @@ Uploaded models are pushed to `public/models//` in the target repo. ## Limitations - 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`). ## Project Structure @@ -240,6 +244,7 @@ lib/ ├── types.ts # Server types (ParsedFile, FileDiff, staged asset metadata, etc.) ├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.) ├── 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) ├── sanitize.ts # Filename sanitization ├── auth.ts # Upload secret validation (timing-safe) diff --git a/app/api/upload/check/route.ts b/app/api/upload/check/route.ts index 80c628f..a6c343c 100644 --- a/app/api/upload/check/route.ts +++ b/app/api/upload/check/route.ts @@ -5,6 +5,7 @@ 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' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -23,7 +24,7 @@ export async function POST(req: NextRequest) { const body: unknown = await req.json() stagingId = parseStagingRequestBody(body).stagingId } catch (err) { - const message = err instanceof Error ? err.message : 'Erreur inconnue' + const message = getErrorMessage(err) 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 }) } catch (err) { - const message = err instanceof Error ? err.message : 'Erreur inconnue' + const message = getErrorMessage(err) return NextResponse.json({ success: false, error: message }, { status: 500 }) } } diff --git a/app/api/upload/drive/route.ts b/app/api/upload/drive/route.ts index 738fbd4..273fedb 100644 --- a/app/api/upload/drive/route.ts +++ b/app/api/upload/drive/route.ts @@ -9,33 +9,15 @@ import { } from '@/lib/nextcloud' import { acquireUploadLock, releaseUploadLock } from '@/lib/upload-lock' import { parseDriveRequestBody } from '@/lib/upload-request' +import { getErrorMessage } from '@/lib/guards' export const runtime = 'nodejs' 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) { - // --- Auth --- const authError = validateUploadSecret(req) if (authError) return authError - // --- Check Nextcloud config --- 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)' }, @@ -43,7 +25,6 @@ export async function POST(req: NextRequest) { ) } - // --- Parse staging request --- let folderName: string let parsedFiles: Awaited>['files'] let action: 'new' | 'replace' @@ -57,7 +38,7 @@ export async function POST(req: NextRequest) { folderName = staged.folderName parsedFiles = staged.files } catch (err) { - const message = err instanceof Error ? err.message : 'Erreur inconnue' + const message = getErrorMessage(err) return NextResponse.json({ success: false, error: message }, { status: 400 }) } @@ -73,23 +54,17 @@ export async function POST(req: NextRequest) { try { if (action === 'replace') { - // 1. Find the next available Vx const nextVersion = await findNextVersion(basePath, folderName) - // 2. Ensure Vx/ exists await mkdirRecursive(`${basePath}/${nextVersion}`) - // 3. Move VF/{folderName} -> Vx/{folderName} await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`) - // 4. Re-create VF/{folderName} await mkdirRecursive(vfFolderPath) } else { - // action === 'new': just ensure VF/{folderName} exists await mkdirRecursive(vfFolderPath) } - // --- Upload all original files --- for (const pf of parsedFiles) { const remotePath = `${vfFolderPath}/${pf.filename}` 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.`, }) } catch (err) { - const message = err instanceof Error ? err.message : 'Erreur Nextcloud inconnue' + const message = getErrorMessage(err, 'Erreur Nextcloud inconnue') return NextResponse.json( { success: false, error: `Drive echoue: ${message}` }, { status: 500 }, diff --git a/app/api/upload/git/route.ts b/app/api/upload/git/route.ts index ae51e7d..f663f36 100644 --- a/app/api/upload/git/route.ts +++ b/app/api/upload/git/route.ts @@ -7,6 +7,7 @@ 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 { getErrorMessage } from '@/lib/guards' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -16,7 +17,6 @@ export const dynamic = 'force-dynamic' * Upload prepared files and push to GitHub via Octokit. */ export async function POST(req: NextRequest) { - // --- Auth --- const authError = validateUploadSecret(req) if (authError) return authError @@ -29,7 +29,7 @@ export async function POST(req: NextRequest) { const manifest = await readStagedManifest(stagingId) folderName = manifest.folderName } catch (err) { - const message = err instanceof Error ? err.message : 'Erreur inconnue' + const message = getErrorMessage(err) return NextResponse.json({ success: false, error: message }, { status: 400 }) } @@ -41,7 +41,6 @@ export async function POST(req: NextRequest) { } try { - // --- Process files (preserve model + buffers, compress textures for Git) --- const { filesToPush, modelFilename, @@ -50,7 +49,6 @@ export async function POST(req: NextRequest) { assetSummaries, } = await ensurePreparedStagingAssets(stagingId) - // --- Detect existing files and classify changes --- const folderPath = getModelFolderPath(folderName) const remote = await getRemoteFolder(folderPath) 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 } = classifyFileChanges(filesToPush, remoteFileMap, folderPath) - // If nothing changed, don't create an empty commit if (changedFilesToPush.length === 0 && deletePaths.length === 0) { await cleanupStagingUpload(stagingId).catch(() => {}) return NextResponse.json({ @@ -73,7 +70,6 @@ export async function POST(req: NextRequest) { }) } - // --- Build commit message --- const commitMessage = buildCommitMessage( folderName, modelFilename, @@ -83,7 +79,6 @@ export async function POST(req: NextRequest) { deletedFileNames, ) - // --- Push all in one commit --- try { const { commitUrl } = await pushAllToGitHub(changedFilesToPush, deletePaths, commitMessage) await cleanupStagingUpload(stagingId).catch(() => {}) @@ -98,7 +93,7 @@ export async function POST(req: NextRequest) { commitUrl, }) } catch (err) { - const message = err instanceof Error ? err.message : 'Erreur GitHub inconnue' + const message = getErrorMessage(err, 'Erreur GitHub inconnue') return NextResponse.json( { success: false, error: `Push GitHub echoue: ${message}` }, { status: 500 }, diff --git a/app/api/upload/stage/route.ts b/app/api/upload/stage/route.ts index b3d3e36..44a4f56 100644 --- a/app/api/upload/stage/route.ts +++ b/app/api/upload/stage/route.ts @@ -2,6 +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' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ export async function POST(req: NextRequest) { const staged = await createStagingUpload(parsed.folderName, parsed.files) return NextResponse.json({ success: true, ...staged }) } catch (err) { - const message = err instanceof Error ? err.message : 'Erreur inconnue' + const message = getErrorMessage(err) return NextResponse.json({ success: false, error: message }, { status: 400 }) } } diff --git a/hooks/useUploadOrchestrator.ts b/hooks/useUploadOrchestrator.ts index 0e5ae77..709f957 100644 --- a/hooks/useUploadOrchestrator.ts +++ b/hooks/useUploadOrchestrator.ts @@ -1,10 +1,7 @@ 'use client' -// --------------------------------------------------------------------------- -// Upload orchestration hook — manages the Drive→Git upload pipeline -// --------------------------------------------------------------------------- - import { useState, useRef, useCallback } from 'react' +import { getErrorMessage } from '@/lib/guards' import type { FolderEntry } from '@/lib/client-types' import type { FileDiff } from '@/lib/types' import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api' @@ -67,13 +64,11 @@ export function useUploadOrchestrator({ const uploadActionRef = useRef(false) const stagingIdRef = useRef(null) - // Refs for values used inside callbacks to avoid stale closures const secretRef = useRef(secret) secretRef.current = secret const entriesRef = useRef(entries) entriesRef.current = entries - // ---- Internal: push a single folder to Git ---- const pushGit = useCallback(async (index: number, signal?: AbortSignal) => { const stagingId = stagingIdRef.current if (!stagingId) { @@ -95,7 +90,7 @@ export function useUploadOrchestrator({ endGitLog(gitResult.success ? 'done' : 'failed', { error: gitResult.error }) } catch (err) { endGitLog(signal?.aborted ? 'cancelled' : 'failed', { - error: err instanceof Error ? err.message : 'Erreur inconnue', + error: getErrorMessage(err), }) throw err } @@ -108,7 +103,6 @@ export function useUploadOrchestrator({ }) }, [updateEntry]) - // ---- Main upload flow: Drive first, then Git ---- const proceedUpload = useCallback(async () => { if (uploadActionRef.current) return uploadActionRef.current = true @@ -135,7 +129,6 @@ export function useUploadOrchestrator({ return } - // ---- Step 1: Drive upload ---- updateEntry(i, { status: 'uploading', progress: 1, @@ -161,7 +154,7 @@ export function useUploadOrchestrator({ endDriveLog(driveResult.success ? 'done' : 'failed', { error: driveResult.error }) } catch (err) { endDriveLog(controller.signal.aborted ? 'cancelled' : 'failed', { - error: err instanceof Error ? err.message : 'Erreur inconnue', + error: getErrorMessage(err), }) throw err } @@ -174,7 +167,6 @@ export function useUploadOrchestrator({ updateEntry(i, { driveStatus: 'success', progress: 50 }) - // ---- Step 2: Git upload ---- await pushGit(i, controller.signal) } } finally { @@ -184,8 +176,6 @@ export function useUploadOrchestrator({ } }, [updateEntry, pushGit]) - // ---- Handlers ---- - const handleUpload = useCallback(async () => { if (uploadActionRef.current || isChecking || isUploading) return @@ -217,7 +207,7 @@ export function useUploadOrchestrator({ endStageLog('done', { stagingId: staged.stagingId, filesCount: staged.filesCount }) } catch (err) { endStageLog(controller.signal.aborted ? 'cancelled' : 'failed', { - error: err instanceof Error ? err.message : 'Erreur inconnue', + error: getErrorMessage(err), }) throw err } @@ -239,7 +229,7 @@ export function useUploadOrchestrator({ endCheckLog('done', { exists: check.exists, diffs: check.diffs.length }) } catch (err) { endCheckLog(controller.signal.aborted ? 'cancelled' : 'failed', { - error: err instanceof Error ? err.message : 'Erreur inconnue', + error: getErrorMessage(err), }) throw err } @@ -261,7 +251,7 @@ export function useUploadOrchestrator({ return } } catch (err) { - const message = err instanceof Error ? err.message : 'Erreur inconnue' + const message = getErrorMessage(err) setGlobalError(message) uploadActionRef.current = false setIsChecking(false) diff --git a/lib/diff-files.ts b/lib/diff-files.ts index c254d54..8c7f437 100644 --- a/lib/diff-files.ts +++ b/lib/diff-files.ts @@ -1,18 +1,10 @@ -// --------------------------------------------------------------------------- -// File diff classification — compares local files against a remote file map -// --------------------------------------------------------------------------- - import { MODEL_EXTENSIONS } from './constants' import type { FileChange, PushFile } from './types' -export interface DiffResult { - /** Map of lowercase filename → change status (for commit message) */ +interface DiffResult { fileChanges: Map - /** Files that actually need to be pushed (new or changed) */ changedFilesToPush: PushFile[] - /** Filenames that were on remote but not in the new upload */ deletedFileNames: string[] - /** Full paths for deletion on remote */ deletePaths: string[] } @@ -41,13 +33,10 @@ export function classifyFileChanges( const isModel = MODEL_EXTENSIONS.has(ext) 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()) fileChanges.set(filename.toLowerCase(), remoteSize === undefined ? 'new' : 'unchanged') changedFilesToPush.push(f) } else { - // Texture: compare by size const localSize = Buffer.from(f.contentBase64, 'base64').length 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 deletedFileNames: string[] = [] const deletePaths: string[] = [] diff --git a/lib/github.ts b/lib/github.ts index d8a1fc8..01775b8 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -1,14 +1,11 @@ import { createHash } from 'crypto' import { Octokit } from '@octokit/rest' import { LFS_EXTENSIONS } from './constants' +import { isRecord } from './guards' import type { PushFile, RemoteFile } from './types' const LFS_BATCH_SIZE = 100 -// --------------------------------------------------------------------------- -// Octokit helpers -// --------------------------------------------------------------------------- - function isHttpError(err: unknown): err is { status: number } { return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record).status === 'number' } @@ -33,22 +30,15 @@ function parseRepoUrl(): { owner: string; repo: string } { 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 { const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase() return LFS_EXTENSIONS.has(ext) } -/** Build an LFS pointer file (text content stored in the Git blob). */ function buildLfsPointer(sha256: string, size: number): string { 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 { if (!content.startsWith('version https://git-lfs.github.com/spec/v1')) return null const sizeMatch = content.match(/^size (\d+)$/m) @@ -81,14 +71,62 @@ interface LfsObject { contentBase64: string } -/** - * Upload binary objects to the Git LFS server via the Batch API. - * - * Flow: - * 1. POST to the LFS batch endpoint with operation "upload" - * 2. For each object that has an "upload" action, PUT the binary content - * 3. If the server omits "actions", the object already exists — skip upload - */ +interface LfsBatchObject { + oid: string + size: number + actions?: { + upload?: { href: string; header?: Record } + verify?: { href: string; header?: Record } + } + error?: { code: number; message: string } +} + +function isStringRecord(value: unknown): value is Record { + 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( owner: string, repo: string, @@ -118,7 +156,6 @@ async function uploadToLfsBatch( const token = process.env.GITHUB_TOKEN! const lfsUrl = `https://github.com/${owner}/${repo}.git/info/lfs/objects/batch` - // 1. Batch request — ask for upload URLs const batchRes = await fetch(lfsUrl, { method: 'POST', headers: { @@ -142,27 +179,15 @@ async function uploadToLfsBatch( throw new Error(`LFS batch request failed (${batchRes.status}): ${text}`) } - const batchData = (await batchRes.json()) as { - objects: Array<{ - oid: string - size: number - actions?: { - upload?: { href: string; header?: Record } - verify?: { href: string; header?: Record } - } - error?: { code: number; message: string } - }> - } + const batchObjects = parseLfsBatchResponse(await batchRes.json()) - // 2. Upload each object that has an upload action const objectMap = new Map(objects.map((o) => [o.oid, o])) - for (const obj of batchData.objects) { + for (const obj of batchObjects) { if (obj.error) { 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 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}`) } - // 3. Verify if required if (obj.actions.verify) { const verifyAction = obj.actions.verify const verifyHeaders: Record = { @@ -214,10 +238,6 @@ async function uploadToLfsBatch( }) } -// --------------------------------------------------------------------------- -// Read remote folder contents (with real file sizes for LFS files) -// --------------------------------------------------------------------------- - export async function getRemoteFolder( folderPath: string, ): Promise<{ exists: boolean; files: RemoteFile[] }> { @@ -237,16 +257,12 @@ export async function getRemoteFolder( 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( data.map(async (f): Promise => { 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 } } - // Fetch the blob content to check if it's an LFS pointer try { const { data: fileData } = await octokit.repos.getContent({ 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( files: PushFile[], deletePaths: string[], @@ -292,7 +304,6 @@ export async function pushAllToGitHub( const { owner, repo } = parseRepoUrl() const branch = process.env.GIT_BRANCH ?? 'main' - // --- Separate LFS files from regular files --- const lfsFiles: { path: string; contentBase64: string; oid: string; size: number }[] = [] const regularFiles: PushFile[] = [] @@ -306,7 +317,6 @@ export async function pushAllToGitHub( } } - // --- Upload LFS objects to the LFS server --- if (lfsFiles.length > 0) { await uploadToLfs( owner, @@ -315,7 +325,6 @@ export async function pushAllToGitHub( ) } - // 1. Get latest commit on branch const { data: ref } = await octokit.git.getRef({ owner, repo, @@ -323,21 +332,18 @@ export async function pushAllToGitHub( }) const latestCommitSha = ref.object.sha - // 2. Get that commit's tree const { data: commit } = await octokit.git.getCommit({ owner, repo, commit_sha: latestCommitSha, }) - // 3. Create blobs — LFS files get pointer blobs, regular files get raw blobs const allFiles = [...regularFiles, ...lfsFiles] const blobResults = await Promise.all( allFiles.map((f) => { const lfs = lfsFiles.find((lf) => lf.path === f.path) if (lfs) { - // Create a blob with the LFS pointer text (NOT the binary content) const pointer = buildLfsPointer(lfs.oid, lfs.size) return octokit.git.createBlob({ owner, @@ -346,7 +352,6 @@ export async function pushAllToGitHub( encoding: 'base64', }) } - // Regular file — push content as-is return octokit.git.createBlob({ owner, 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 deleteEntries = deletePaths .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({ owner, repo, @@ -391,7 +394,6 @@ export async function pushAllToGitHub( parents: [latestCommitSha], }) - // 6. Update branch ref await octokit.git.updateRef({ owner, repo, diff --git a/lib/guards.ts b/lib/guards.ts new file mode 100644 index 0000000..2449f2c --- /dev/null +++ b/lib/guards.ts @@ -0,0 +1,7 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function getErrorMessage(error: unknown, fallback = 'Erreur inconnue') { + return error instanceof Error ? error.message : fallback +} diff --git a/lib/nextcloud.ts b/lib/nextcloud.ts index d5d6977..5929937 100644 --- a/lib/nextcloud.ts +++ b/lib/nextcloud.ts @@ -1,11 +1,5 @@ -// --------------------------------------------------------------------------- -// Nextcloud WebDAV client -// Uses native fetch — no npm package needed. -// --------------------------------------------------------------------------- - const MAX_VERSIONS = 1000 -// Lazy-cached config to avoid recomputing on every request let cachedConfig: { davBase: string; auth: string } | null = null function getConfig() { @@ -19,7 +13,6 @@ function getConfig() { 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 auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64') @@ -32,10 +25,6 @@ function davUrl(davBase: string, path: string): string { return `${davBase}/${clean}` } -// --------------------------------------------------------------------------- -// Low-level WebDAV helpers -// --------------------------------------------------------------------------- - async function davRequest( method: string, path: string, @@ -57,12 +46,7 @@ async function davRequest( return res } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** Check if a folder exists on the Nextcloud instance. */ -export async function folderExists(path: string): Promise { +async function folderExists(path: string): Promise { const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' }) if (res.status === 404) return false @@ -72,10 +56,6 @@ export async function folderExists(path: string): Promise { 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 { const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') let current = '' @@ -84,14 +64,12 @@ export async function mkdirRecursive(path: string): Promise { current += '/' + seg const res = await davRequest('MKCOL', current + '/') if (res.status !== 201 && res.status !== 405) { - // 201 = created, 405 = already exists — both are fine const text = await res.text().catch(() => '') 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 { const res = await davRequest('PUT', path, content, { 'Content-Type': 'application/octet-stream', @@ -104,7 +82,6 @@ export async function uploadFile(path: string, content: Buffer): Promise { } } -/** Move (rename) a folder or file. */ export async function moveFolder(from: string, to: string): Promise { const { davBase } = getConfig() const destination = davUrl(davBase, to) + '/' @@ -120,14 +97,6 @@ export async function moveFolder(from: string, to: string): Promise { } } -// --------------------------------------------------------------------------- -// 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( basePath: string, folderName: string, diff --git a/lib/parse-upload.ts b/lib/parse-upload.ts index 6c01170..dba1d9c 100644 --- a/lib/parse-upload.ts +++ b/lib/parse-upload.ts @@ -4,19 +4,11 @@ import { sanitizeFilename } from './sanitize' import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE } from './constants' import type { ParsedFile } from './types' -export interface ParsedUpload { +interface ParsedUpload { folderName: string files: ParsedFile[] - /** Any extra string fields from the FormData (e.g. "action") */ - extra: Record } -/** - * 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 { const formData = await req.formData() const folderValue = formData.get('folderName') @@ -27,16 +19,6 @@ export async function parseMultiUpload(req: NextRequest): Promise 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') - // Collect extra string fields - const knownKeys = new Set(['folderName', 'files', 'fileTypes', 'textureNames']) - const extra: Record = {} - 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[] = [] for (const entry of rawFiles) { if (!(entry instanceof File)) { @@ -56,7 +38,6 @@ export async function parseMultiUpload(req: NextRequest): Promise const file = fileEntries[i] if (!file || file.size === 0) continue - // File size limit if (file.size > MAX_FILE_SIZE) { throw new Error( `Fichier "${file.name}" trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum: ${MAX_FILE_SIZE / 1024 / 1024} MB.`, @@ -100,5 +81,5 @@ export async function parseMultiUpload(req: NextRequest): Promise throw new Error('Un seul fichier model.gltf est autorise') } - return { folderName: safeFolderName, files: parsed, extra } + return { folderName: safeFolderName, files: parsed } } diff --git a/lib/texture-compression.ts b/lib/texture-compression.ts index e13c92f..b944dc9 100644 --- a/lib/texture-compression.ts +++ b/lib/texture-compression.ts @@ -1,5 +1,6 @@ import { extname } from 'path' import sharp from 'sharp' +import { getErrorMessage } from './guards' interface TextureCompressionResult { buffer: Buffer @@ -35,7 +36,7 @@ export async function compressTextureBuffer( } } } catch (err) { - const message = err instanceof Error ? err.message : String(err) + const message = getErrorMessage(err, String(err)) return { buffer, compressed: false, diff --git a/lib/upload-api.ts b/lib/upload-api.ts index 049503a..12783ad 100644 --- a/lib/upload-api.ts +++ b/lib/upload-api.ts @@ -1,7 +1,4 @@ -// --------------------------------------------------------------------------- -// Client-side API helpers for upload operations -// --------------------------------------------------------------------------- - +import { isRecord } from './guards' import type { FolderEntry } from './client-types' import type { FileDiff } from './types' @@ -10,16 +7,12 @@ export interface CheckResult { diffs: FileDiff[] } -export interface StageResult { +interface StageResult { stagingId: string folderName: string filesCount: number } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - function getApiError(data: unknown, fallback: string) { 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') } -// --------------------------------------------------------------------------- -// Shared FormData builder -// --------------------------------------------------------------------------- - -function buildUploadFormData( - folder: FolderEntry, - extra?: Record, -): FormData { +function buildUploadFormData(folder: FolderEntry): FormData { const formData = new FormData() 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('fileTypes', 'model') formData.append('textureNames', '') @@ -60,14 +40,6 @@ function buildUploadFormData( 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( stagingId: string, secret: string, @@ -85,7 +57,6 @@ export async function checkFolderDiffs( const data: unknown = await res.json() - // Surface auth/server errors to the caller if (!res.ok) { 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( stagingId: string, secret: string, @@ -163,11 +129,6 @@ export async function uploadDrive( } } -// --------------------------------------------------------------------------- -// Upload files to GitHub -// --------------------------------------------------------------------------- - -/** Upload files to GitHub. */ export async function uploadGit( stagingId: string, secret: string, diff --git a/lib/upload-request.ts b/lib/upload-request.ts index c194b6f..ae9dd28 100644 --- a/lib/upload-request.ts +++ b/lib/upload-request.ts @@ -1,4 +1,6 @@ -export type DriveAction = 'new' | 'replace' +import { isRecord } from './guards' + +type DriveAction = 'new' | 'replace' interface StagingRequestBody { stagingId: string @@ -8,10 +10,6 @@ interface DriveRequestBody extends StagingRequestBody { action: DriveAction } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - export function parseStagingRequestBody(value: unknown): StagingRequestBody { if (!isRecord(value) || typeof value.stagingId !== 'string' || value.stagingId.trim() === '') { throw new Error('stagingId manquant') diff --git a/lib/upload-staging.ts b/lib/upload-staging.ts index 68c8634..f55d33c 100644 --- a/lib/upload-staging.ts +++ b/lib/upload-staging.ts @@ -65,7 +65,7 @@ async function writeManifest(manifest: StagingManifest) { await writeFile(getManifestPath(manifest.stagingId), JSON.stringify(manifest, null, 2), 'utf-8') } -export async function cleanupExpiredStagingUploads() { +async function cleanupExpiredStagingUploads() { if (!existsSync(STAGING_ROOT)) return const entries = await readdir(STAGING_ROOT, { withFileTypes: true }) diff --git a/lib/validate-folder.ts b/lib/validate-folder.ts index 45dd94f..e2495f3 100644 --- a/lib/validate-folder.ts +++ b/lib/validate-folder.ts @@ -1,9 +1,6 @@ -// --------------------------------------------------------------------------- -// Client-side folder validation -// --------------------------------------------------------------------------- - import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants' import { formatAssetFamilies, getAssetFamily, getForbiddenAssetFamilyAlias } from '@/lib/asset-naming' +import { getErrorMessage, isRecord } from '@/lib/guards' import type { TextureFile } from '@/lib/client-types' const SUPPORT_FILE_EXT_ARRAY = [...TEXTURE_EXTENSIONS, ...ASSET_EXTENSIONS] @@ -22,7 +19,9 @@ export type ValidationResult = | { ok: false; errors: string[] } 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) { @@ -83,10 +82,12 @@ async function getGltfWarnings(model: File, supportFiles: File[]) { try { parsed = JSON.parse(await model.text()) } 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 binFiles = supportFiles.filter((file) => file.name.toLowerCase().endsWith('.bin')) @@ -144,7 +145,16 @@ export async function validateFolder(files: File[]): Promise { 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 } } diff --git a/package-lock.json b/package-lock.json index f54f6f6..e6496c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "upload-gltf", - "version": "0.1.5", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "upload-gltf", - "version": "0.1.5", + "version": "1.0.0", "dependencies": { "@octokit/rest": "^22.0.1", "@react-three/drei": "^10.7.0", diff --git a/package.json b/package.json index d76a25f..9d5dd92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "upload-gltf", - "version": "0.1.5", + "version": "1.0.0", "private": true, "scripts": { "dev": "next dev",