/** * Minimal WebDAV client for the Nextcloud backup target (issue #103), shared * by the api (connection test, remote set listing) and the backup sidecar * (upload, prune, download). Plain `fetch` with basic auth — no heavy DAV * dependency; the subset used here (PROPFIND/MKCOL/PUT/GET/DELETE) is stable * across Nextcloud versions and generic WebDAV servers. * * Like `token-crypto`, this module is a separate package entry (not part of * the barrel) because it is server-only. */ export interface WebDavTarget { /** * The Nextcloud base URL (e.g. `https://cloud.example.com`) — the DAV * path `remote.php/dav/files/` is derived. A URL that already * contains `remote.php` or `/dav/` is used as the DAV base verbatim, so * generic WebDAV servers work too. */ baseUrl: string; username: string; password: string; /** Target folder under the DAV base, may contain `/` for nesting. */ folder: string; } export interface WebDavEntry { /** Decoded file or collection name (last path segment). */ name: string; isCollection: boolean; sizeBytes: number | null; lastModified: string | null; } export type WebDavResult = { ok: true; value: T } | { ok: false; error: string }; type FetchLike = (url: string, init?: RequestInit) => Promise; /** Sane bound for a wrong host that answers slowly — uploads set their own. */ const REQUEST_TIMEOUT_MS = 20_000; function trimSlashes(value: string): string { return value.replace(/^\/+|\/+$/g, ''); } /** Folder segments, each URI-encoded; rejects `.`/`..` traversal segments. */ export function folderSegments(folder: string): string[] { const segments = trimSlashes(folder.trim()) .split('/') .filter((segment) => segment.length > 0); if (segments.some((segment) => segment === '.' || segment === '..')) { throw new Error('folder must not contain "." or ".." segments'); } return segments; } /** The DAV base URL (without the folder), normalized without trailing slash. */ export function webdavBaseUrl(target: Pick): string { const base = target.baseUrl.replace(/\/+$/, ''); if (/remote\.php|\/dav\//i.test(base)) return base; return `${base}/remote.php/dav/files/${encodeURIComponent(target.username)}`; } /** Absolute URL of the target folder (no trailing slash). */ export function webdavFolderUrl(target: WebDavTarget): string { const segments = folderSegments(target.folder).map(encodeURIComponent); return [webdavBaseUrl(target), ...segments].join('/'); } /** Absolute URL of a file inside the target folder. */ export function webdavFileUrl(target: WebDavTarget, name: string): string { if (name.includes('/')) throw new Error('file name must not contain "/"'); return `${webdavFolderUrl(target)}/${encodeURIComponent(name)}`; } export function basicAuthHeader(target: Pick): string { const credentials = `${target.username}:${target.password}`; return `Basic ${Buffer.from(credentials, 'utf8').toString('base64')}`; } function decodeXmlEntities(value: string): string { return value .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code))) .replace(/&/g, '&'); } /** First text content of `` inside `block`, prefix-agnostic. */ function xmlText(block: string, tag: string): string | null { const match = new RegExp(`<(?:[A-Za-z0-9-]+:)?${tag}[^>]*>([^<]*)][\s\S]*?<\/(?:[A-Za-z0-9-]+:)?response>/gi) ?? []; const normalizedRequest = trimSlashes(decodeURIComponent(requestPath)); for (const block of responseBlocks) { const href = xmlText(block, 'href'); if (!href) continue; const path = trimSlashes(decodeURIComponent(href)); if (path === normalizedRequest) continue; const name = path.split('/').pop() ?? ''; if (!name) continue; const lengthText = xmlText(block, 'getcontentlength'); const sizeBytes = lengthText !== null && /^\d+$/.test(lengthText) ? Number(lengthText) : null; entries.push({ name, isCollection: /<(?:[A-Za-z0-9-]+:)?collection\b/i.test(block), sizeBytes, lastModified: xmlText(block, 'getlastmodified'), }); } return entries; } async function davRequest( fetchLike: FetchLike, target: WebDavTarget, url: string, init: RequestInit & { timeoutMs?: number }, ): Promise { const { timeoutMs, ...rest } = init; return fetchLike(url, { ...rest, headers: { Authorization: basicAuthHeader(target), ...(rest.headers ?? {}), }, signal: AbortSignal.timeout(timeoutMs ?? REQUEST_TIMEOUT_MS), }); } function describeFailure(action: string, response: Response): string { const auth = response.status === 401 || response.status === 403; return auth ? `${action}: authentication failed (HTTP ${response.status}) — check username and app password` : `${action}: HTTP ${response.status} ${response.statusText}`.trim(); } function describeError(action: string, error: unknown): string { const message = error instanceof Error ? error.message : String(error); return `${action}: ${message}`; } /** * Verifies the target is usable: credentials accepted and the folder exists, * creating missing folder segments via MKCOL on the way (like the setup * wizard's SMTP test, nothing else is touched). */ export async function webdavCheck( target: WebDavTarget, fetchLike: FetchLike = fetch, ): Promise> { let url = webdavBaseUrl(target); try { const probe = await davRequest(fetchLike, target, url, { method: 'PROPFIND', headers: { Depth: '0' }, }); if (!probe.ok) return { ok: false, error: describeFailure('connect', probe) }; for (const segment of folderSegments(target.folder)) { url = `${url}/${encodeURIComponent(segment)}`; const exists = await davRequest(fetchLike, target, url, { method: 'PROPFIND', headers: { Depth: '0' }, }); if (exists.ok) continue; if (exists.status !== 404) return { ok: false, error: describeFailure('check folder', exists) }; const created = await davRequest(fetchLike, target, url, { method: 'MKCOL' }); if (!created.ok) return { ok: false, error: describeFailure('create folder', created) }; } return { ok: true, value: undefined }; } catch (error) { return { ok: false, error: describeError('connect', error) }; } } /** Lists the target folder (depth 1), excluding sub-collections' contents. */ export async function webdavList( target: WebDavTarget, fetchLike: FetchLike = fetch, ): Promise> { const url = webdavFolderUrl(target); try { const response = await davRequest(fetchLike, target, url, { method: 'PROPFIND', headers: { Depth: '1' }, }); if (!response.ok) return { ok: false, error: describeFailure('list folder', response) }; const requestPath = new URL(url).pathname; return { ok: true, value: parsePropfind(await response.text(), requestPath) }; } catch (error) { return { ok: false, error: describeError('list folder', error) }; } } /** * Uploads a file into the target folder. The body is a Buffer or a byte * stream (pass `contentLength` for streams so the server can reject early * on quota). No practical timeout — bundles can be large and slow links are * fine; the caller's run wraps the whole upload. */ export async function webdavPut( target: WebDavTarget, name: string, body: Buffer | ReadableStream, options: { contentLength?: number; timeoutMs?: number } = {}, fetchLike: FetchLike = fetch, ): Promise> { try { const response = await davRequest(fetchLike, target, webdavFileUrl(target, name), { method: 'PUT', body: body as RequestInit['body'], headers: options.contentLength ? { 'Content-Length': String(options.contentLength) } : {}, timeoutMs: options.timeoutMs ?? 6 * 60 * 60 * 1000, // Node's fetch requires half-duplex for streamed request bodies. ...(body instanceof Buffer ? {} : { duplex: 'half' as const }), } as RequestInit & { timeoutMs?: number }); if (!response.ok) return { ok: false, error: describeFailure(`upload ${name}`, response) }; return { ok: true, value: undefined }; } catch (error) { return { ok: false, error: describeError(`upload ${name}`, error) }; } } /** Fetches a file; the caller streams `response.body` to disk. */ export async function webdavGet( target: WebDavTarget, name: string, fetchLike: FetchLike = fetch, ): Promise> { try { const response = await davRequest(fetchLike, target, webdavFileUrl(target, name), { method: 'GET', timeoutMs: 6 * 60 * 60 * 1000, }); if (!response.ok) return { ok: false, error: describeFailure(`download ${name}`, response) }; return { ok: true, value: response }; } catch (error) { return { ok: false, error: describeError(`download ${name}`, error) }; } } export async function webdavDelete( target: WebDavTarget, name: string, fetchLike: FetchLike = fetch, ): Promise> { try { const response = await davRequest(fetchLike, target, webdavFileUrl(target, name), { method: 'DELETE', }); // 404 counts as deleted — prune must be idempotent across retries. if (!response.ok && response.status !== 404) { return { ok: false, error: describeFailure(`delete ${name}`, response) }; } return { ok: true, value: undefined }; } catch (error) { return { ok: false, error: describeError(`delete ${name}`, error) }; } }