chore: prepare v1.0.0 release
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user