From 78f4aa83e01a742b6d2df8cb0b69c9a18089d016 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Tue, 14 Apr 2026 17:19:10 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20full=20codebase=20audit=20=E2=80=94?= =?UTF-8?q?=20extract=20modules,=20fix=20type=20safety,=20clean=20dead=20c?= =?UTF-8?q?ode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract API helpers from UploadZone into lib/upload-api.ts (FormData builder, checkFolderDiffs, uploadDrive, uploadGit) - Extract upload orchestration into hooks/useUploadOrchestrator.ts (UploadZone: 489 β†’ 162 lines) - Extract file diff classification into lib/diff-files.ts (from git route) - Extract shared SVG icons into components/ui/icons.tsx (7 icons, 0 duplication) - Extract shared modal wrapper into components/ui/Modal.tsx + ModalActions - Extract DriveStatusLine sub-component from FolderCard - Fix checkFolderDiffs silently swallowing auth/network errors (now throws) - Fix type safety: remove as never casts, add isHttpError type guard, use discriminated union for validateFolder - Fix nextcloud: cache getConfig, add max bound to findNextVersion, optimize mkdirRecursive (skip PROPFIND) - Fix drive route: remove req.clone(), extend parseMultiUpload to return extra fields - Fix commit message: model shown as unchanged with ↔️ on updates (not falsely marked as modified) - Clean dead code: unused folderExists import, FileStatus/DriveStatus exports, ParsedFile.textureName, getConfig basePath - Add security headers in next.config.ts (HSTS, X-Content-Type-Options, X-Frame-Options, etc.) - Update README with new project structure --- README.md | 21 +- app/api/upload/check/route.ts | 2 +- app/api/upload/drive/route.ts | 22 +- app/api/upload/git/route.ts | 50 +-- components/UploadZone.tsx | 377 ++------------------ components/ui/Modal.tsx | 65 ++++ components/ui/icons.tsx | 72 ++++ components/upload/DriveErrorModal.tsx | 82 ++--- components/upload/DriveStatusLine.tsx | 44 +++ components/upload/FolderCard.tsx | 64 +--- components/upload/FolderDropzone.tsx | 21 +- components/upload/NoChangesModal.tsx | 66 ++-- components/upload/OverwriteConfirmModal.tsx | 128 +++---- components/upload/WarningBanner.tsx | 10 +- hooks/useUploadOrchestrator.ts | 247 +++++++++++++ lib/client-types.ts | 4 +- lib/constants.ts | 4 +- lib/diff-files.ts | 83 +++++ lib/format-bytes.ts | 2 +- lib/github.ts | 7 +- lib/nextcloud.ts | 29 +- lib/parse-upload.ts | 33 +- lib/types.ts | 1 - lib/upload-api.ts | 182 ++++++++++ lib/validate-folder.ts | 41 +-- next.config.ts | 21 ++ 26 files changed, 957 insertions(+), 721 deletions(-) create mode 100644 components/ui/Modal.tsx create mode 100644 components/ui/icons.tsx create mode 100644 components/upload/DriveStatusLine.tsx create mode 100644 hooks/useUploadOrchestrator.ts create mode 100644 lib/diff-files.ts create mode 100644 lib/upload-api.ts diff --git a/README.md b/README.md index ea31454..fdf23ff 100644 --- a/README.md +++ b/README.md @@ -144,11 +144,13 @@ update: upload-gltf add a new model -> farm/my-model ``` update: upload-gltf update -> general/coffeetest +πŸ“¦ Model + ↔️ model.gltf (inchange) 🎨 Textures πŸ”„ metalness.jpg ``` -Symbols: `βœ…` new β€” `πŸ”„` modified β€” `❌` missing or deleted +Symbols: `βœ…` new β€” `πŸ”„` modified β€” `↔️` unchanged (model always re-pushed) β€” `❌` missing or deleted 8. Orphan files (present on remote but not in the new upload) are deleted in the same commit 9. If Blender is unavailable, the original model is pushed as-is (graceful fallback) @@ -178,34 +180,41 @@ app/ β”œβ”€β”€ layout.tsx # Root layout (next/font/google) └── page.tsx # Home page components/ +β”œβ”€β”€ ui/ +β”‚ β”œβ”€β”€ icons.tsx # Shared SVG icon components +β”‚ └── Modal.tsx # Shared modal wrapper + ModalActions β”œβ”€β”€ upload/ β”‚ β”œβ”€β”€ SecretInput.tsx # Access key input β”‚ β”œβ”€β”€ DestinationPicker.tsx # Destination selector β”‚ β”œβ”€β”€ FolderDropzone.tsx # Folder drag & drop / picker β”‚ β”œβ”€β”€ FolderCard.tsx # Folder status card (Drive + Git) +β”‚ β”œβ”€β”€ DriveStatusLine.tsx # Drive/Git status sub-line β”‚ β”œβ”€β”€ WarningBanner.tsx # Missing texture warnings β”‚ β”œβ”€β”€ OverwriteConfirmModal.tsx # Diff confirmation dialog β”‚ β”œβ”€β”€ NoChangesModal.tsx # "No changes detected" dialog β”‚ β”œβ”€β”€ DriveErrorModal.tsx # "Drive failed, continue?" dialog β”‚ └── ActionButtons.tsx # Upload / Cancel / Reset buttons -β”œβ”€β”€ UploadZone.tsx # Main orchestrator (Drive β†’ Git flow) +β”œβ”€β”€ UploadZone.tsx # Main upload page (rendering only) β”œβ”€β”€ ModelViewer.tsx # Lazy wrapper for 3D viewer └── SceneViewer.tsx # Three.js Canvas hooks/ -β”œβ”€β”€ useSecret.ts # Secret key state management -└── useFolderEntries.ts # Folder entries state management +β”œβ”€β”€ useSecret.ts # Secret key state management +β”œβ”€β”€ useFolderEntries.ts # Folder entries state management +└── useUploadOrchestrator.ts # Upload pipeline orchestration (Drive β†’ Git) lib/ β”œβ”€β”€ constants.ts # Shared constants, destinations, extensions β”œβ”€β”€ types.ts # Server types (ParsedFile, FileDiff, etc.) β”œβ”€β”€ client-types.ts # Client types (FolderEntry, DriveStatus, etc.) +β”œβ”€β”€ upload-api.ts # Client-side API helpers (check, uploadDrive, uploadGit) +β”œβ”€β”€ diff-files.ts # File diff classification (new/changed/unchanged/deleted) β”œβ”€β”€ sanitize.ts # Filename sanitization β”œβ”€β”€ auth.ts # Upload secret validation (timing-safe) β”œβ”€β”€ github.ts # Octokit helpers (getRemoteFolder, pushAllToGitHub) -β”œβ”€β”€ nextcloud.ts # Nextcloud WebDAV client (native fetch) +β”œβ”€β”€ nextcloud.ts # Nextcloud WebDAV client (native fetch, cached config) β”œβ”€β”€ blender.ts # Blender Draco compression β”œβ”€β”€ commit-message.ts # Commit message builder β”œβ”€β”€ parse-upload.ts # FormData parser + validation -β”œβ”€β”€ validate-folder.ts # Client-side folder validation +β”œβ”€β”€ validate-folder.ts # Client-side folder validation (discriminated union) └── format-bytes.ts # Byte formatting utility scripts/ └── compress.py # Blender Draco compression script diff --git a/app/api/upload/check/route.ts b/app/api/upload/check/route.ts index 2981911..eb6125e 100644 --- a/app/api/upload/check/route.ts +++ b/app/api/upload/check/route.ts @@ -23,7 +23,7 @@ export async function GET(req: NextRequest) { return NextResponse.json({ success: false, error: 'Parametres manquants' }, { status: 400 }) } - if (!VALID_DESTINATIONS.has(destination as never)) { + if (!VALID_DESTINATIONS.has(destination)) { return NextResponse.json({ success: false, error: 'Destination invalide' }, { status: 400 }) } diff --git a/app/api/upload/drive/route.ts b/app/api/upload/drive/route.ts index c71316f..954e080 100644 --- a/app/api/upload/drive/route.ts +++ b/app/api/upload/drive/route.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server' import { validateUploadSecret } from '@/lib/auth' import { parseMultiUpload } from '@/lib/parse-upload' import { - folderExists, mkdirRecursive, moveFolder, uploadFile, @@ -22,11 +21,11 @@ export const dynamic = 'force-dynamic' // - action: "new" | "replace" // // Versioning logic: -// VF/{folderName} ← latest version -// V1/{folderName} ← first archive, V2/ second, etc. +// 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/ +// 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) { @@ -42,11 +41,7 @@ export async function POST(req: NextRequest) { ) } - // --- Parse files --- - // Clone the request before parseMultiUpload consumes the body stream, - // so we can read the `action` field separately. - const cloned = req.clone() - + // --- Parse files (includes extra fields like "action") --- let folderName: string let parsedFiles: Awaited>['files'] let action: string @@ -55,10 +50,7 @@ export async function POST(req: NextRequest) { const parsed = await parseMultiUpload(req) folderName = parsed.folderName parsedFiles = parsed.files - - // Read action from the cloned request (parseMultiUpload doesn't expose it) - const formData = await cloned.formData() - action = (formData.get('action') as string | null)?.trim() || 'new' + action = parsed.extra.action?.trim() || 'new' } catch (err) { const message = err instanceof Error ? err.message : 'Erreur inconnue' return NextResponse.json({ success: false, error: message }, { status: 400 }) @@ -75,7 +67,7 @@ export async function POST(req: NextRequest) { // 2. Ensure Vx/ exists await mkdirRecursive(`${basePath}/${nextVersion}`) - // 3. Move VF/{folderName} β†’ Vx/{folderName} + // 3. Move VF/{folderName} -> Vx/{folderName} await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`) // 4. Re-create VF/{folderName} diff --git a/app/api/upload/git/route.ts b/app/api/upload/git/route.ts index 956d08d..ef914aa 100644 --- a/app/api/upload/git/route.ts +++ b/app/api/upload/git/route.ts @@ -7,8 +7,8 @@ import { parseMultiUpload } from '@/lib/parse-upload' import { compressWithBlender } from '@/lib/blender' import { getRemoteFolder, pushAllToGitHub } from '@/lib/github' import { buildCommitMessage } from '@/lib/commit-message' -import { TMP_DIR, MODEL_EXTENSIONS } from '@/lib/constants' -import type { FileChange } from '@/lib/types' +import { classifyFileChanges } from '@/lib/diff-files' +import { TMP_DIR } from '@/lib/constants' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -85,8 +85,6 @@ export async function POST(req: NextRequest) { } // --- Detect existing files and classify changes --- - // Models: always re-push (compression changes size, can't compare with LFS remote) - // Textures: compare by size (not compressed, reliable) const folderPath = `public/models/${destination}/${folderName}` let remoteFileMap: Map @@ -99,48 +97,8 @@ export async function POST(req: NextRequest) { const isReplace = remoteFileMap.size > 0 - const fileChanges = new Map() - const changedFilesToPush: { path: string; contentBase64: string }[] = [] - - for (const f of filesToPush) { - const filename = f.path.split('/').pop() ?? '' - const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase() - 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, - // because we can't know if the model really changed after Blender compression. - 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()) - - if (remoteSize === undefined) { - fileChanges.set(filename.toLowerCase(), 'new') - changedFilesToPush.push(f) - } else if (remoteSize !== localSize) { - fileChanges.set(filename.toLowerCase(), 'changed') - changedFilesToPush.push(f) - } else { - fileChanges.set(filename.toLowerCase(), 'unchanged') - } - } - } - - // 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[] = [] - for (const [name] of remoteFileMap) { - if (!newFileNames.has(name)) { - deletedFileNames.push(name) - deletePaths.push(`${folderPath}/${name}`) - } - } + 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) { diff --git a/components/UploadZone.tsx b/components/UploadZone.tsx index 7b3e409..0773143 100644 --- a/components/UploadZone.tsx +++ b/components/UploadZone.tsx @@ -1,11 +1,9 @@ 'use client' -import { useState, useRef, useCallback } from 'react' -import type { Destination } from '@/lib/constants' import type { FolderEntry } from '@/lib/client-types' -import type { FileDiff } from '@/lib/types' import { useSecret } from '@/hooks/useSecret' import { useFolderEntries } from '@/hooks/useFolderEntries' +import { useUploadOrchestrator } from '@/hooks/useUploadOrchestrator' import SecretInput from './upload/SecretInput' import DestinationPicker from './upload/DestinationPicker' import FolderDropzone from './upload/FolderDropzone' @@ -15,162 +13,6 @@ import OverwriteConfirmModal from './upload/OverwriteConfirmModal' import NoChangesModal from './upload/NoChangesModal' import DriveErrorModal from './upload/DriveErrorModal' -// --------------------------------------------------------------------------- -// API helpers -// --------------------------------------------------------------------------- -interface CheckResult { - exists: boolean - diffs: FileDiff[] -} - -async function checkFolderDiffs( - folder: FolderEntry, - destination: string, - secret: string, - signal?: AbortSignal, -): Promise { - try { - const params = new URLSearchParams({ folderName: folder.folderName, destination }) - const res = await fetch(`/api/upload/check?${params}`, { - headers: { 'x-upload-secret': secret.trim() }, - signal, - }) - const data = await res.json() - if (!data.success || !data.exists) { - return { exists: false, diffs: [] } - } - - const remoteFiles: { name: string; size: number }[] = data.files || [] - const remoteMap = new Map(remoteFiles.map((f) => [f.name.toLowerCase(), f.size])) - - const diffs: FileDiff[] = [] - const localNames = new Set() - - // Model: skip size comparison (compression changes the size). - const modelKey = folder.modelFile.name.toLowerCase() - localNames.add(modelKey) - if (!remoteMap.has(modelKey)) { - diffs.push({ name: folder.modelFile.name, status: 'new' }) - } - - // Textures: compare by size - for (const tex of folder.textures) { - const key = tex.name.toLowerCase() - localNames.add(key) - const remoteSize = remoteMap.get(key) - if (remoteSize === undefined) { - diffs.push({ name: tex.name, status: 'new' }) - } else if (remoteSize !== tex.file.size) { - diffs.push({ name: tex.name, status: 'changed' }) - } - } - - // Deleted - for (const [name] of remoteMap) { - if (!localNames.has(name)) { - diffs.push({ name, status: 'deleted' }) - } - } - - return { exists: true, diffs } - } catch { - return { exists: false, diffs: [] } - } -} - -/** Upload original files to Nextcloud Drive (no Blender compression). */ -async function uploadDrive( - folder: FolderEntry, - secret: string, - destination: string, - action: 'new' | 'replace', - signal?: AbortSignal, -): Promise<{ success: boolean; error?: string }> { - const formData = new FormData() - formData.append('folderName', folder.folderName) - formData.append('destination', destination) - formData.append('action', action) - - formData.append('files', folder.modelFile) - formData.append('fileTypes', 'model') - formData.append('textureNames', '') - - for (const tex of folder.textures) { - formData.append('files', tex.file) - formData.append('fileTypes', 'texture') - formData.append('textureNames', tex.name) - } - - try { - const res = await fetch('/api/upload/drive', { - method: 'POST', - headers: { 'x-upload-secret': secret.trim() }, - body: formData, - signal, - }) - const data = await res.json() - if (!data.success) return { success: false, error: data.error } - return { success: true } - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { - return { success: false, error: 'Upload annule' } - } - return { success: false, error: 'Erreur reseau (Drive)' } - } -} - -/** Upload files to GitHub (with Blender compression). */ -async function uploadGit( - folder: FolderEntry, - secret: string, - destination: string, - onProgress: (pct: number) => void, - signal?: AbortSignal, -): Promise<{ success: boolean; filename?: string; error?: string }> { - const formData = new FormData() - formData.append('folderName', folder.folderName) - formData.append('destination', destination) - - formData.append('files', folder.modelFile) - formData.append('fileTypes', 'model') - formData.append('textureNames', '') - - for (const tex of folder.textures) { - formData.append('files', tex.file) - formData.append('fileTypes', 'texture') - formData.append('textureNames', tex.name) - } - - onProgress(10) - - try { - const res = await fetch('/api/upload/git', { - method: 'POST', - headers: { 'x-upload-secret': secret.trim() }, - body: formData, - signal, - }) - - onProgress(80) - const data = await res.json() - - if (!data.success) { - return { success: false, error: data.error } - } - - onProgress(100) - return { success: true, filename: folder.folderName } - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { - return { success: false, error: 'Upload annule' } - } - return { success: false, error: 'Erreur reseau' } - } -} - -// --------------------------------------------------------------------------- -// UploadZone β€” orchestrator -// --------------------------------------------------------------------------- export default function UploadZone() { const { secret, @@ -192,27 +34,30 @@ export default function UploadZone() { hasErrors, } = useFolderEntries() - const [isUploading, setIsUploading] = useState(false) - const [globalError, setGlobalError] = useState(null) - const [destination, setDestination] = useState(null) - const [overwriteConfirm, setOverwriteConfirm] = useState<{ - folderName: string - diffs: FileDiff[] - } | null>(null) - const [noChangesFolder, setNoChangesFolder] = useState(null) - - // Drive error modal state - const [driveError, setDriveError] = useState<{ - error: string - folderIndex: number - } | null>(null) - - const abortRef = useRef(null) - - // Tracks the check result so we know if it's "new" or "replace" for the Drive - const checkResultRef = useRef({ exists: false, diffs: [] }) - - // -- Handlers -- + const { + isUploading, + globalError, + setGlobalError, + destination, + setDestination, + overwriteConfirm, + setOverwriteConfirm, + noChangesFolder, + setNoChangesFolder, + driveError, + handleUpload, + handleDriveContinue, + handleDriveCancel, + handleCancel, + handleReset, + proceedUpload, + } = useUploadOrchestrator({ + secret, + setSecretError, + entries, + updateEntry, + resetEntries, + }) const handleFolderSelected = (entry: FolderEntry) => { setGlobalError(null) @@ -226,180 +71,8 @@ export default function UploadZone() { } } - const handleUpload = async () => { - if (!secret.trim()) { - setSecretError("La cle d'acces est requise") - return - } - if (!destination) { - setGlobalError('Veuillez choisir une destination') - return - } - if (entries.length === 0) return - - setSecretError(null) - setGlobalError(null) - - const folder = entries[0] - const check = await checkFolderDiffs(folder, destination!, secret, abortRef.current?.signal) - checkResultRef.current = check - - if (check.exists) { - if (check.diffs.length === 0) { - setNoChangesFolder(folder.folderName) - return - } - setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs }) - return - } - - await proceedUpload() - } - - /** - * Main upload flow: Drive first, then Git. - * If Drive fails, show DriveErrorModal so user can decide. - */ - const proceedUpload = async () => { - setOverwriteConfirm(null) - setIsUploading(true) - setGlobalError(null) - - const controller = new AbortController() - abortRef.current = controller - - for (let i = 0; i < entries.length; i++) { - if (entries[i].status === 'success') continue - if (controller.signal.aborted) break - - const folderEntry = entries[i] - const driveAction = checkResultRef.current.exists ? 'replace' : 'new' - - // ---- Step 1: Drive upload ---- - updateEntry(i, { - status: 'uploading', - progress: 1, - error: undefined, - driveStatus: 'uploading', - driveError: undefined, - }) - - const driveResult = await uploadDrive( - folderEntry, - secret, - destination!, - driveAction as 'new' | 'replace', - controller.signal, - ) - - if (!driveResult.success) { - // Drive failed β€” pause and ask user - updateEntry(i, { driveStatus: 'error', driveError: driveResult.error }) - setDriveError({ error: driveResult.error || 'Erreur inconnue', folderIndex: i }) - // Stop here β€” the DriveErrorModal callbacks will resume or cancel - return - } - - updateEntry(i, { driveStatus: 'success', progress: 50 }) - - // ---- Step 2: Git upload ---- - await pushGit(i, controller.signal) - } - - abortRef.current = null - setIsUploading(false) - } - - /** Push a single folder to Git. Called after Drive succeeds or user skips Drive. */ - const pushGit = async (index: number, signal?: AbortSignal) => { - const folderEntry = entries[index] - - const gitResult = await uploadGit( - folderEntry, - secret, - destination!, - (pct) => updateEntry(index, { progress: 50 + Math.round(pct / 2) }), - signal, - ) - - updateEntry(index, { - status: gitResult.success ? 'success' : 'error', - progress: gitResult.success ? 100 : 0, - error: gitResult.success ? undefined : gitResult.error, - filename: gitResult.filename, - }) - } - - /** User chose "Continue without Drive" in DriveErrorModal. */ - const handleDriveContinue = useCallback(async () => { - if (!driveError) return - const idx = driveError.folderIndex - setDriveError(null) - - updateEntry(idx, { driveStatus: 'skipped' }) - - // Continue with Git - const signal = abortRef.current?.signal - await pushGit(idx, signal) - - // Continue with remaining entries - for (let i = idx + 1; i < entries.length; i++) { - if (entries[i].status === 'success') continue - if (abortRef.current?.signal.aborted) break - - // For subsequent entries after a Drive skip, also skip Drive - updateEntry(i, { - status: 'uploading', - progress: 0, - error: undefined, - driveStatus: 'skipped', - }) - - await pushGit(i, abortRef.current?.signal) - } - - abortRef.current = null - setIsUploading(false) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [driveError, entries, secret, destination]) - - /** User chose "Cancel" in DriveErrorModal. */ - const handleDriveCancel = useCallback(() => { - if (!driveError) return - const idx = driveError.folderIndex - setDriveError(null) - - updateEntry(idx, { - status: 'error', - progress: 0, - error: 'Upload annule (Drive echoue)', - driveStatus: 'error', - }) - - abortRef.current?.abort() - abortRef.current = null - setIsUploading(false) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [driveError]) - - const handleCancel = () => { - abortRef.current?.abort() - abortRef.current = null - setIsUploading(false) - } - - const handleReset = () => { - resetEntries() - setGlobalError(null) - setIsUploading(false) - setDriveError(null) - checkResultRef.current = { exists: false, diffs: [] } - } - const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error') - // -- Render -- - return (
+
+ {children} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Shared modal footer with two buttons +// --------------------------------------------------------------------------- + +interface ModalActionsProps { + cancelLabel: string + confirmLabel: string + onCancel: () => void + onConfirm: () => void + /** Tailwind classes for the confirm button (default: white bg) */ + confirmClassName?: string +} + +export function ModalActions({ + cancelLabel, + confirmLabel, + onCancel, + onConfirm, + confirmClassName = 'bg-white text-[#000000] hover:bg-gray-200', +}: ModalActionsProps) { + return ( +
+ + +
+ ) +} diff --git a/components/ui/icons.tsx b/components/ui/icons.tsx new file mode 100644 index 0000000..a271bc2 --- /dev/null +++ b/components/ui/icons.tsx @@ -0,0 +1,72 @@ +// --------------------------------------------------------------------------- +// Shared SVG icon components +// --------------------------------------------------------------------------- + +interface IconProps { + className?: string +} + +export function SpinnerIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + ) +} + +export function CheckIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + ) +} + +export function XIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + ) +} + +export function ChevronIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + ) +} + +export function WarningIcon({ className = 'w-5 h-5' }: IconProps) { + return ( + + + + ) +} + +export function FolderIcon({ className = 'w-6 h-6' }: IconProps) { + return ( + + + + ) +} + +export function InfoIcon({ className = 'w-5 h-5' }: IconProps) { + return ( + + + + ) +} diff --git a/components/upload/DriveErrorModal.tsx b/components/upload/DriveErrorModal.tsx index b7ff9a8..c183149 100644 --- a/components/upload/DriveErrorModal.tsx +++ b/components/upload/DriveErrorModal.tsx @@ -1,3 +1,6 @@ +import Modal, { ModalActions } from '@/components/ui/Modal' +import { WarningIcon } from '@/components/ui/icons' + interface DriveErrorModalProps { error: string onCancel: () => void @@ -10,60 +13,35 @@ export default function DriveErrorModal({ onContinue, }: DriveErrorModalProps) { return ( -
-
-
-
- - - -
-
-

- Erreur Drive -

-

- L'archivage sur le Drive a echoue. Les fichiers n'ont pas ete versionnes. -

-
+ +
+
+
- -
-

{error}

-
- -

- Voulez-vous quand meme envoyer les fichiers aux devs via GitHub ? -

- -
- - +
+

+ Erreur Drive +

+

+ L'archivage sur le Drive a echoue. Les fichiers n'ont pas ete versionnes. +

-
+ +
+

{error}

+
+ +

+ Voulez-vous quand meme envoyer les fichiers aux devs via GitHub ? +

+ + +
) } diff --git a/components/upload/DriveStatusLine.tsx b/components/upload/DriveStatusLine.tsx new file mode 100644 index 0000000..a379e85 --- /dev/null +++ b/components/upload/DriveStatusLine.tsx @@ -0,0 +1,44 @@ +// --------------------------------------------------------------------------- +// Drive/Git status sub-line for FolderCard +// --------------------------------------------------------------------------- + +import { SpinnerIcon, XIcon, InfoIcon } from '@/components/ui/icons' +import type { FolderEntry } from '@/lib/client-types' + +interface DriveStatusLineProps { + driveStatus: NonNullable + driveError?: string +} + +export default function DriveStatusLine({ driveStatus, driveError }: DriveStatusLineProps) { + if (driveStatus === 'pending') return null + + return ( +
+ {driveStatus === 'uploading' && ( + <> + + Upload Drive en cours... + + )} + {driveStatus === 'success' && ( + <> + + Upload Git en cours... + + )} + {driveStatus === 'error' && ( + <> + + Drive echoue{driveError ? ` : ${driveError}` : ''} + + )} + {driveStatus === 'skipped' && ( + <> + + Drive ignore + + )} +
+ ) +} diff --git a/components/upload/FolderCard.tsx b/components/upload/FolderCard.tsx index 2704b7f..d712e8f 100644 --- a/components/upload/FolderCard.tsx +++ b/components/upload/FolderCard.tsx @@ -1,6 +1,8 @@ import dynamic from 'next/dynamic' import type { FolderEntry } from '@/lib/client-types' import { formatBytes } from '@/lib/format-bytes' +import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon } from '@/components/ui/icons' +import DriveStatusLine from './DriveStatusLine' import WarningBanner from './WarningBanner' const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false }) @@ -20,22 +22,15 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
{entry.status === 'success' ? (
- - - +
) : entry.status === 'error' ? (
- - - +
) : entry.status === 'uploading' ? (
- - - - +
) : ( )}
@@ -73,43 +63,9 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F {/* Drive status sub-line (only during upload, not after success) */} {entry.status !== 'success' && entry.driveStatus && entry.driveStatus !== 'pending' && ( -
- {entry.driveStatus === 'uploading' && ( - <> - - - - - Upload Drive en cours... - - )} - {entry.driveStatus === 'success' && ( - <> - - - - - Upload Git en cours... - - )} - {entry.driveStatus === 'error' && ( - <> - - - - Drive echoue{entry.driveError ? ` : ${entry.driveError}` : ''} - - )} - {entry.driveStatus === 'skipped' && ( - <> - - - - Drive ignore - - )} -
+ )} + {entry.status === 'uploading' && (
- - - + )}
diff --git a/components/upload/FolderDropzone.tsx b/components/upload/FolderDropzone.tsx index d7642a0..18bf6a6 100644 --- a/components/upload/FolderDropzone.tsx +++ b/components/upload/FolderDropzone.tsx @@ -1,5 +1,6 @@ import type { FolderEntry } from '@/lib/client-types' import { validateFolder } from '@/lib/validate-folder' +import { FolderIcon } from '@/components/ui/icons' interface FolderDropzoneProps { isUploading: boolean @@ -20,19 +21,19 @@ export default function FolderDropzone({ const folderName = fileArray[0].webkitRelativePath?.split('/')[0] || 'folder' const validation = validateFolder(fileArray) - if (validation.errors.length > 0) { + if (!validation.ok) { onError(validation.errors.join(' | ')) return } const entry: FolderEntry = { folderName, - modelFile: validation.model!, + modelFile: validation.model, textures: validation.textures, status: 'pending', progress: 0, warnings: validation.warnings, - modelUrl: URL.createObjectURL(validation.model!), + modelUrl: URL.createObjectURL(validation.model), viewerOpen: true, } onFolderSelected(entry) @@ -59,19 +60,7 @@ export default function FolderDropzone({ >
- - - +

diff --git a/components/upload/NoChangesModal.tsx b/components/upload/NoChangesModal.tsx index 48ccccf..1a631af 100644 --- a/components/upload/NoChangesModal.tsx +++ b/components/upload/NoChangesModal.tsx @@ -1,3 +1,6 @@ +import Modal, { ModalActions } from '@/components/ui/Modal' +import { CheckIcon } from '@/components/ui/icons' + interface NoChangesModalProps { destination: string folderName: string @@ -12,52 +15,27 @@ export default function NoChangesModal({ onModify, }: NoChangesModalProps) { return ( -

-
-
-
- - - -
-
-

- Aucun changement detecte -

-

- Le dossier {destination}/{folderName} est identique au contenu distant. Rien a envoyer. -

-
+ +
+
+
- -
- - +
+

+ Aucun changement detecte +

+

+ Le dossier {destination}/{folderName} est identique au contenu distant. Rien a envoyer. +

-
+ + +
) } diff --git a/components/upload/OverwriteConfirmModal.tsx b/components/upload/OverwriteConfirmModal.tsx index 5c993ef..ee8c6db 100644 --- a/components/upload/OverwriteConfirmModal.tsx +++ b/components/upload/OverwriteConfirmModal.tsx @@ -1,4 +1,6 @@ import type { FileDiff } from '@/lib/types' +import Modal, { ModalActions } from '@/components/ui/Modal' +import { WarningIcon } from '@/components/ui/icons' interface OverwriteConfirmModalProps { destination: string @@ -16,83 +18,59 @@ export default function OverwriteConfirmModal({ onConfirm, }: OverwriteConfirmModalProps) { return ( -
-
-
-
- - - -
-
-

- Dossier deja existant -

-

- {destination}/{folderName} existe deja. - Les anciens fichiers seront archives sur le Drive, puis les nouveaux seront envoyes sur le Drive et Git. -

-
+ +
+
+
- - {diffs.length > 0 && ( -
-

Modifications detectees :

-
    - {diffs.map((d) => ( -
  • - {d.status === 'changed' && ( - <> - πŸ”„ - {d.name} - - )} - {d.status === 'new' && ( - <> - βœ… - {d.name} - - )} - {d.status === 'deleted' && ( - <> - ❌ - {d.name} - - )} -
  • - ))} -
-
- )} - -
- - +
+

+ Dossier deja existant +

+

+ {destination}/{folderName} existe deja. + Les anciens fichiers seront archives sur le Drive, puis les nouveaux seront envoyes sur le Drive et Git. +

-
+ + {diffs.length > 0 && ( +
+

Modifications detectees :

+
    + {diffs.map((d) => ( +
  • + {d.status === 'changed' && ( + <> + πŸ”„ + {d.name} + + )} + {d.status === 'new' && ( + <> + βœ… + {d.name} + + )} + {d.status === 'deleted' && ( + <> + ❌ + {d.name} + + )} +
  • + ))} +
+
+ )} + + +
) } diff --git a/components/upload/WarningBanner.tsx b/components/upload/WarningBanner.tsx index 22d06aa..45b8f5a 100644 --- a/components/upload/WarningBanner.tsx +++ b/components/upload/WarningBanner.tsx @@ -1,3 +1,5 @@ +import { WarningIcon } from '@/components/ui/icons' + interface WarningBannerProps { warnings: string[] } @@ -8,13 +10,7 @@ export default function WarningBanner({ warnings }: WarningBannerProps) { return (
- - - + Textures manquantes : {warnings.join(', ')}
diff --git a/hooks/useUploadOrchestrator.ts b/hooks/useUploadOrchestrator.ts new file mode 100644 index 0000000..e6a2d34 --- /dev/null +++ b/hooks/useUploadOrchestrator.ts @@ -0,0 +1,247 @@ +'use client' + +// --------------------------------------------------------------------------- +// Upload orchestration hook — manages the Drive→Git upload pipeline +// --------------------------------------------------------------------------- + +import { useState, useRef, useCallback } from 'react' +import type { Destination } from '@/lib/constants' +import type { FolderEntry } from '@/lib/client-types' +import type { FileDiff } from '@/lib/types' +import { checkFolderDiffs, uploadDrive, uploadGit } from '@/lib/upload-api' +import type { CheckResult } from '@/lib/upload-api' + +interface UseUploadOrchestratorParams { + secret: string + setSecretError: (err: string | null) => void + entries: FolderEntry[] + updateEntry: (index: number, patch: Partial) => void + resetEntries: () => void +} + +export function useUploadOrchestrator({ + secret, + setSecretError, + entries, + updateEntry, + resetEntries, +}: UseUploadOrchestratorParams) { + const [isUploading, setIsUploading] = useState(false) + const [globalError, setGlobalError] = useState(null) + const [destination, setDestination] = useState(null) + const [overwriteConfirm, setOverwriteConfirm] = useState<{ + folderName: string + diffs: FileDiff[] + } | null>(null) + const [noChangesFolder, setNoChangesFolder] = useState(null) + const [driveError, setDriveError] = useState<{ + error: string + folderIndex: number + } | null>(null) + + const abortRef = useRef(null) + const checkResultRef = useRef({ exists: false, diffs: [] }) + + // Refs for values used inside callbacks to avoid stale closures + const secretRef = useRef(secret) + secretRef.current = secret + const destinationRef = useRef(destination) + destinationRef.current = destination + const entriesRef = useRef(entries) + entriesRef.current = entries + + // ---- Internal: push a single folder to Git ---- + const pushGit = useCallback(async (index: number, signal?: AbortSignal) => { + const folderEntry = entriesRef.current[index] + const dest = destinationRef.current + + const gitResult = await uploadGit( + folderEntry, + secretRef.current, + dest!, + (pct) => updateEntry(index, { progress: 50 + Math.round(pct / 2) }), + signal, + ) + + updateEntry(index, { + status: gitResult.success ? 'success' : 'error', + progress: gitResult.success ? 100 : 0, + error: gitResult.success ? undefined : gitResult.error, + filename: gitResult.filename, + }) + }, [updateEntry]) + + // ---- Main upload flow: Drive first, then Git ---- + const proceedUpload = useCallback(async () => { + setOverwriteConfirm(null) + setIsUploading(true) + setGlobalError(null) + + const controller = new AbortController() + abortRef.current = controller + + const currentEntries = entriesRef.current + + for (let i = 0; i < currentEntries.length; i++) { + if (currentEntries[i].status === 'success') continue + if (controller.signal.aborted) break + + const folderEntry = currentEntries[i] + const driveAction = checkResultRef.current.exists ? 'replace' : 'new' + + // ---- Step 1: Drive upload ---- + updateEntry(i, { + status: 'uploading', + progress: 1, + error: undefined, + driveStatus: 'uploading', + driveError: undefined, + }) + + const driveResult = await uploadDrive( + folderEntry, + secretRef.current, + destinationRef.current!, + driveAction as 'new' | 'replace', + controller.signal, + ) + + if (!driveResult.success) { + updateEntry(i, { driveStatus: 'error', driveError: driveResult.error }) + setDriveError({ error: driveResult.error || 'Erreur inconnue', folderIndex: i }) + return + } + + updateEntry(i, { driveStatus: 'success', progress: 50 }) + + // ---- Step 2: Git upload ---- + await pushGit(i, controller.signal) + } + + abortRef.current = null + setIsUploading(false) + }, [updateEntry, pushGit]) + + // ---- Handlers ---- + + const handleUpload = useCallback(async () => { + if (!secretRef.current.trim()) { + setSecretError("La cle d'acces est requise") + return + } + if (!destinationRef.current) { + setGlobalError('Veuillez choisir une destination') + return + } + if (entriesRef.current.length === 0) return + + setSecretError(null) + setGlobalError(null) + + const folder = entriesRef.current[0] + + try { + const check = await checkFolderDiffs( + folder, + destinationRef.current, + secretRef.current, + abortRef.current?.signal, + ) + checkResultRef.current = check + + if (check.exists) { + if (check.diffs.length === 0) { + setNoChangesFolder(folder.folderName) + return + } + setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs }) + return + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Erreur inconnue' + setGlobalError(message) + return + } + + await proceedUpload() + }, [setSecretError, proceedUpload]) + + const handleDriveContinue = useCallback(async () => { + if (!driveError) return + const idx = driveError.folderIndex + setDriveError(null) + + updateEntry(idx, { driveStatus: 'skipped' }) + + const signal = abortRef.current?.signal + await pushGit(idx, signal) + + const currentEntries = entriesRef.current + for (let i = idx + 1; i < currentEntries.length; i++) { + if (currentEntries[i].status === 'success') continue + if (abortRef.current?.signal.aborted) break + + updateEntry(i, { + status: 'uploading', + progress: 0, + error: undefined, + driveStatus: 'skipped', + }) + + await pushGit(i, abortRef.current?.signal) + } + + abortRef.current = null + setIsUploading(false) + }, [driveError, updateEntry, pushGit]) + + const handleDriveCancel = useCallback(() => { + if (!driveError) return + const idx = driveError.folderIndex + setDriveError(null) + + updateEntry(idx, { + status: 'error', + progress: 0, + error: 'Upload annule (Drive echoue)', + driveStatus: 'error', + }) + + abortRef.current?.abort() + abortRef.current = null + setIsUploading(false) + }, [driveError, updateEntry]) + + const handleCancel = useCallback(() => { + abortRef.current?.abort() + abortRef.current = null + setIsUploading(false) + }, []) + + const handleReset = useCallback(() => { + resetEntries() + setGlobalError(null) + setIsUploading(false) + setDriveError(null) + checkResultRef.current = { exists: false, diffs: [] } + }, [resetEntries]) + + return { + isUploading, + globalError, + setGlobalError, + destination, + setDestination, + overwriteConfirm, + setOverwriteConfirm, + noChangesFolder, + setNoChangesFolder, + driveError, + handleUpload, + handleDriveContinue, + handleDriveCancel, + handleCancel, + handleReset, + proceedUpload, + } +} diff --git a/lib/client-types.ts b/lib/client-types.ts index b3e65eb..dc23af0 100644 --- a/lib/client-types.ts +++ b/lib/client-types.ts @@ -2,14 +2,14 @@ // Client-side types — used by components and hooks (no Node.js Buffer) // --------------------------------------------------------------------------- -export type FileStatus = 'pending' | 'uploading' | 'success' | 'error' +type FileStatus = 'pending' | 'uploading' | 'success' | 'error' export interface TextureFile { name: string file: File } -export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped' +type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped' export interface FolderEntry { folderName: string diff --git a/lib/constants.ts b/lib/constants.ts index 5607b67..3904f33 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -8,9 +8,9 @@ export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_E export const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] as const -export const VALID_DESTINATIONS = new Set([ +export const VALID_DESTINATIONS = new Set([ 'farm', 'map', 'powergrid', 'workshop', 'general', 'environment', -] as const) +]) export const DESTINATIONS = [ { value: 'farm', label: 'Farm' }, diff --git a/lib/diff-files.ts b/lib/diff-files.ts new file mode 100644 index 0000000..3af6f02 --- /dev/null +++ b/lib/diff-files.ts @@ -0,0 +1,83 @@ +// --------------------------------------------------------------------------- +// File diff classification — compares local files against a remote file map +// --------------------------------------------------------------------------- + +import { MODEL_EXTENSIONS } from './constants' +import type { FileChange } from './types' + +interface PushFile { + path: string + contentBase64: string +} + +export interface DiffResult { + /** Map of lowercase filename → change status (for commit message) */ + 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[] +} + +/** + * Classify each file as new, changed, or unchanged by comparing against + * the remote file map. + * + * Rules: + * - Models: always re-pushed (compression makes size comparison unreliable), + * but marked as 'unchanged' in the commit message when the folder already + * exists (we can't know if the model really changed after Blender). + * - Textures: compared by size (not compressed, reliable). + * - Orphan remote files: classified as deletions. + */ +export function classifyFileChanges( + filesToPush: PushFile[], + remoteFileMap: Map, + folderPath: string, +): DiffResult { + const fileChanges = new Map() + const changedFilesToPush: PushFile[] = [] + + for (const f of filesToPush) { + const filename = f.path.split('/').pop() ?? '' + const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase() + 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()) + + if (remoteSize === undefined) { + fileChanges.set(filename.toLowerCase(), 'new') + changedFilesToPush.push(f) + } else if (remoteSize !== localSize) { + fileChanges.set(filename.toLowerCase(), 'changed') + changedFilesToPush.push(f) + } else { + fileChanges.set(filename.toLowerCase(), 'unchanged') + } + } + } + + // 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[] = [] + for (const [name] of remoteFileMap) { + if (!newFileNames.has(name)) { + deletedFileNames.push(name) + deletePaths.push(`${folderPath}/${name}`) + } + } + + return { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } +} diff --git a/lib/format-bytes.ts b/lib/format-bytes.ts index 86cb07c..b14456b 100644 --- a/lib/format-bytes.ts +++ b/lib/format-bytes.ts @@ -3,7 +3,7 @@ // --------------------------------------------------------------------------- export function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B' + if (bytes <= 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) diff --git a/lib/github.ts b/lib/github.ts index b614f26..a5eb076 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -5,6 +5,10 @@ import type { RemoteFile } from './types' // 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' +} + function getOctokit(): Octokit { const token = process.env.GITHUB_TOKEN if (!token) throw new Error('GITHUB_TOKEN non configure') @@ -53,8 +57,7 @@ export async function getRemoteFolder( return { exists: false, files: [] } } catch (err: unknown) { - const status = (err as { status?: number })?.status - if (status === 404) { + if (isHttpError(err) && err.status === 404) { return { exists: false, files: [] } } throw err diff --git a/lib/nextcloud.ts b/lib/nextcloud.ts index 085aed3..10e0361 100644 --- a/lib/nextcloud.ts +++ b/lib/nextcloud.ts @@ -3,11 +3,17 @@ // 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() { + if (cachedConfig) return cachedConfig + const url = process.env.NEXTCLOUD_URL const token = process.env.NEXTCLOUD_SHARE_TOKEN const password = process.env.NEXTCLOUD_SHARE_PASSWORD || '' - const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models' if (!url || !token) { throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)') @@ -17,7 +23,8 @@ function getConfig() { const davBase = `${url.replace(/\/+$/, '')}/public.php/webdav` const auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64') - return { davBase, auth, basePath } + cachedConfig = { davBase, auth } + return cachedConfig } function davUrl(davBase: string, path: string): string { @@ -66,7 +73,7 @@ export async function folderExists(path: string): Promise { /** * Create a folder and all parent segments if they don't exist. - * Like `mkdir -p`. + * Like `mkdir -p`. Attempts MKCOL directly and handles 405 (already exists). */ export async function mkdirRecursive(path: string): Promise { const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') @@ -74,14 +81,11 @@ export async function mkdirRecursive(path: string): Promise { for (const seg of segments) { current += '/' + seg - const exists = await folderExists(current) - if (!exists) { - const res = await davRequest('MKCOL', current + '/') - if (res.status !== 201 && res.status !== 405) { - // 405 = already exists (race condition), that's fine - const text = await res.text().catch(() => '') - throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`) - } + 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)}`) } } } @@ -127,9 +131,10 @@ export async function findNextVersion( basePath: string, folderName: string, ): Promise { - for (let i = 1; ; i++) { + for (let i = 1; i <= MAX_VERSIONS; i++) { const versionPath = `${basePath}/V${i}/${folderName}` const exists = await folderExists(versionPath) if (!exists) return `V${i}` } + throw new Error(`Nombre maximum de versions atteint (V${MAX_VERSIONS})`) } diff --git a/lib/parse-upload.ts b/lib/parse-upload.ts index a3d623c..e4ca5e1 100644 --- a/lib/parse-upload.ts +++ b/lib/parse-upload.ts @@ -4,21 +4,27 @@ import { sanitizeFilename } from './sanitize' import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, VALID_DESTINATIONS, MAX_FILE_SIZE } from './constants' import type { ParsedFile } from './types' -/** - * Parse a multi-file FormData upload request. - * Validates destination, file extensions, file sizes, and returns parsed files. - */ -export async function parseMultiUpload(req: NextRequest): Promise<{ +export interface ParsedUpload { folderName: string destination: string files: ParsedFile[] -}> { + /** Any extra string fields from the FormData (e.g. "action") */ + extra: Record +} + +/** + * Parse a multi-file FormData upload request. + * Validates destination, file extensions, file sizes, and returns parsed files. + * Extra string fields (beyond folderName, destination, files, fileTypes, textureNames) + * are returned in `extra`. + */ +export async function parseMultiUpload(req: NextRequest): Promise { const formData = await req.formData() const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets' const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') const rawDestination = (formData.get('destination') as string | null)?.trim() || 'general' - if (!VALID_DESTINATIONS.has(rawDestination as never)) { + if (!VALID_DESTINATIONS.has(rawDestination)) { throw new Error(`Destination invalide: "${rawDestination}"`) } const destination = rawDestination @@ -27,6 +33,15 @@ export async function parseMultiUpload(req: NextRequest): Promise<{ const fileTypes = formData.getAll('fileTypes') as string[] const textureNames = formData.getAll('textureNames') as string[] + // Collect extra string fields + const knownKeys = new Set(['folderName', 'destination', '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) { @@ -73,8 +88,8 @@ export async function parseMultiUpload(req: NextRequest): Promise<{ const isModel = MODEL_EXTENSIONS.has(ext) const buffer = Buffer.from(await file.arrayBuffer()) - parsed.push({ filename, buffer, isModel, textureName: texName || undefined }) + parsed.push({ filename, buffer, isModel }) } - return { folderName: safeFolderName, destination, files: parsed } + return { folderName: safeFolderName, destination, files: parsed, extra } } diff --git a/lib/types.ts b/lib/types.ts index 68fb3fe..de6eeae 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -6,7 +6,6 @@ export interface ParsedFile { filename: string buffer: Buffer isModel: boolean - textureName?: string } export type FileChange = 'new' | 'changed' | 'unchanged' diff --git a/lib/upload-api.ts b/lib/upload-api.ts new file mode 100644 index 0000000..88245aa --- /dev/null +++ b/lib/upload-api.ts @@ -0,0 +1,182 @@ +// --------------------------------------------------------------------------- +// Client-side API helpers for upload operations +// --------------------------------------------------------------------------- + +import type { FolderEntry } from './client-types' +import type { FileDiff } from './types' + +export interface CheckResult { + exists: boolean + diffs: FileDiff[] +} + +// --------------------------------------------------------------------------- +// Shared FormData builder +// --------------------------------------------------------------------------- + +function buildUploadFormData( + folder: FolderEntry, + destination: string, + extra?: Record, +): FormData { + const formData = new FormData() + formData.append('folderName', folder.folderName) + formData.append('destination', destination) + + 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', '') + + for (const tex of folder.textures) { + formData.append('files', tex.file) + formData.append('fileTypes', 'texture') + formData.append('textureNames', tex.name) + } + + 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( + folder: FolderEntry, + destination: string, + secret: string, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ folderName: folder.folderName, destination }) + const res = await fetch(`/api/upload/check?${params}`, { + headers: { 'x-upload-secret': secret.trim() }, + signal, + }) + + const data = await res.json() + + // Surface auth/server errors to the caller + if (!res.ok) { + throw new Error(data.error || `Erreur serveur (${res.status})`) + } + + if (!data.success || !data.exists) { + return { exists: false, diffs: [] } + } + + const remoteFiles: { name: string; size: number }[] = data.files || [] + const remoteMap = new Map(remoteFiles.map((f) => [f.name.toLowerCase(), f.size])) + + const diffs: FileDiff[] = [] + const localNames = new Set() + + // Model: skip size comparison (compression changes the size). + const modelKey = folder.modelFile.name.toLowerCase() + localNames.add(modelKey) + if (!remoteMap.has(modelKey)) { + diffs.push({ name: folder.modelFile.name, status: 'new' }) + } + + // Textures: compare by size + for (const tex of folder.textures) { + const key = tex.name.toLowerCase() + localNames.add(key) + const remoteSize = remoteMap.get(key) + if (remoteSize === undefined) { + diffs.push({ name: tex.name, status: 'new' }) + } else if (remoteSize !== tex.file.size) { + diffs.push({ name: tex.name, status: 'changed' }) + } + } + + // Deleted + for (const [name] of remoteMap) { + if (!localNames.has(name)) { + diffs.push({ name, status: 'deleted' }) + } + } + + return { exists: true, diffs } +} + +// --------------------------------------------------------------------------- +// Upload original files to Nextcloud Drive +// --------------------------------------------------------------------------- + +/** Upload original files to Nextcloud Drive (no Blender compression). */ +export async function uploadDrive( + folder: FolderEntry, + secret: string, + destination: string, + action: 'new' | 'replace', + signal?: AbortSignal, +): Promise<{ success: boolean; error?: string }> { + const formData = buildUploadFormData(folder, destination, { action }) + + try { + const res = await fetch('/api/upload/drive', { + method: 'POST', + headers: { 'x-upload-secret': secret.trim() }, + body: formData, + signal, + }) + const data = await res.json() + if (!data.success) return { success: false, error: data.error } + return { success: true } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + return { success: false, error: 'Upload annule' } + } + return { success: false, error: 'Erreur reseau (Drive)' } + } +} + +// --------------------------------------------------------------------------- +// Upload files to GitHub (with Blender compression) +// --------------------------------------------------------------------------- + +/** Upload files to GitHub (with Blender compression). */ +export async function uploadGit( + folder: FolderEntry, + secret: string, + destination: string, + onProgress: (pct: number) => void, + signal?: AbortSignal, +): Promise<{ success: boolean; filename?: string; error?: string }> { + const formData = buildUploadFormData(folder, destination) + + onProgress(10) + + try { + const res = await fetch('/api/upload/git', { + method: 'POST', + headers: { 'x-upload-secret': secret.trim() }, + body: formData, + signal, + }) + + onProgress(80) + const data = await res.json() + + if (!data.success) { + return { success: false, error: data.error } + } + + onProgress(100) + return { success: true, filename: folder.folderName } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + return { success: false, error: 'Upload annule' } + } + return { success: false, error: 'Erreur reseau' } + } +} diff --git a/lib/validate-folder.ts b/lib/validate-folder.ts index 49145be..cae3e61 100644 --- a/lib/validate-folder.ts +++ b/lib/validate-folder.ts @@ -13,22 +13,15 @@ function getTextureType(filename: string): string | null { return null } -export function validateFolder(files: File[]): { - model?: File - textures: TextureFile[] - errors: string[] - warnings: string[] -} { - const result: { - model?: File - textures: TextureFile[] - errors: string[] - warnings: string[] - } = { - textures: [], - errors: [], - warnings: [], - } +/** Discriminated union: either valid (with model) or invalid (with errors). */ +export type ValidationResult = + | { ok: true; model: File; textures: TextureFile[]; warnings: string[] } + | { ok: false; errors: string[] } + +export function validateFolder(files: File[]): ValidationResult { + const textures: TextureFile[] = [] + const warnings: string[] = [] + const errors: string[] = [] const modelFiles = files.filter((f) => { const name = f.name.toLowerCase() @@ -36,9 +29,7 @@ export function validateFolder(files: File[]): { }) if (modelFiles.length === 0) { - result.errors.push('model.glb ou model.gltf manquant (obligatoire)') - } else { - result.model = modelFiles[0] + return { ok: false, errors: ['model.glb ou model.gltf manquant (obligatoire)'] } } const textureFiles = files.filter((f) => { @@ -47,17 +38,21 @@ export function validateFolder(files: File[]): { }) for (const tf of textureFiles) { - result.textures.push({ name: tf.name, file: tf }) + textures.push({ name: tf.name, file: tf }) } const foundTextures = new Set( - result.textures.map((t) => t.name.toLowerCase().replace(/\.[^.]+$/, '')), + textures.map((t) => t.name.toLowerCase().replace(/\.[^.]+$/, '')), ) for (const req of REQUIRED_TEXTURES) { if (!foundTextures.has(req)) { - result.warnings.push(`${req}.webp/png/jpg manquant`) + warnings.push(`${req}.webp/png/jpg manquant`) } } - return result + if (errors.length > 0) { + return { ok: false, errors } + } + + return { ok: true, model: modelFiles[0], textures, warnings } } diff --git a/next.config.ts b/next.config.ts index 4133a3f..9472ce8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,27 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { output: 'standalone', + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'X-DNS-Prefetch-Control', value: 'on' }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + ], + }, + ] + }, } export default nextConfig