// --------------------------------------------------------------------------- // Nextcloud WebDAV client // Uses native fetch — no npm package needed. // --------------------------------------------------------------------------- const MAX_VERSIONS = 1000 // Lazy-cached config to avoid recomputing on every request let cachedConfig: { davBase: string; auth: string } | null = null function getConfig() { if (cachedConfig) return cachedConfig const url = process.env.NEXTCLOUD_URL const token = process.env.NEXTCLOUD_SHARE_TOKEN const password = process.env.NEXTCLOUD_SHARE_PASSWORD || '' if (!url || !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 auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64') cachedConfig = { davBase, auth } return cachedConfig } function davUrl(davBase: string, path: string): string { const clean = path.replace(/^\/+/, '').replace(/\/+$/, '') return `${davBase}/${clean}` } // --------------------------------------------------------------------------- // Low-level WebDAV helpers // --------------------------------------------------------------------------- async function davRequest( method: string, path: string, body?: Buffer | string | null, extraHeaders?: Record, ): Promise { const { davBase, auth } = getConfig() const url = davUrl(davBase, path) const res = await fetch(url, { method, headers: { Authorization: auth, ...extraHeaders, }, body: body == null ? undefined : typeof body === 'string' ? body : new Uint8Array(body), }) return res } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** Check if a folder exists on the Nextcloud instance. */ export async function folderExists(path: string): Promise { const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' }) if (res.status === 404) return false if (res.status >= 200 && res.status < 300) return true const text = await res.text().catch(() => '') throw new Error(`PROPFIND ${path} failed (${res.status}): ${text.slice(0, 200)}`) } /** * Create a folder and all parent segments if they don't exist. * Like `mkdir -p`. Attempts MKCOL directly and handles 405 (already exists). */ export async function mkdirRecursive(path: string): Promise { const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') let current = '' for (const seg of segments) { current += '/' + seg const res = await davRequest('MKCOL', current + '/') if (res.status !== 201 && res.status !== 405) { // 201 = created, 405 = already exists — both are fine const text = await res.text().catch(() => '') throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`) } } } /** Upload a file (overwrite if exists). */ export async function uploadFile(path: string, content: Buffer): Promise { const res = await davRequest('PUT', path, content, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(content.length), }) if (res.status < 200 || res.status >= 300) { const text = await res.text().catch(() => '') throw new Error(`PUT ${path} failed (${res.status}): ${text.slice(0, 200)}`) } } /** Move (rename) a folder or file. */ export async function moveFolder(from: string, to: string): Promise { const { davBase } = getConfig() const destination = davUrl(davBase, to) + '/' const res = await davRequest('MOVE', from + '/', null, { Destination: destination, Overwrite: 'F', }) if (res.status < 200 || res.status >= 300) { const text = await res.text().catch(() => '') throw new Error(`MOVE ${from} -> ${to} failed (${res.status}): ${text.slice(0, 200)}`) } } // --------------------------------------------------------------------------- // High-level: find next available version folder // --------------------------------------------------------------------------- /** * Find the next available Vx folder for archiving. * E.g. if V1/coffeetest exists but V2/coffeetest doesn't, returns "V2". */ export async function findNextVersion( basePath: string, folderName: string, ): Promise { 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})`) }