/** * WebDAV client that proxies through /api/webdav to avoid CORS issues. * The server-side proxy handles auth and forwards requests to Stalwart's /dav/file/ endpoint. */ import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; import { apiFetch, withBasePath } from '@/lib/browser-navigation'; export interface WebDAVResource { href: string; name: string; isDirectory: boolean; contentType: string; contentLength: number; lastModified: string; etag: string; } export class WebDAVClient { private proxyUrl = '/api/webdav'; /** * Send a WebDAV request through the proxy. */ private async request(method: string, path: string, options?: { headers?: Record; body?: string | ArrayBuffer | Blob; }): Promise { const headers: Record = { 'X-WebDAV-Method': method, 'X-WebDAV-Path': path, ...getActiveAccountSlotHeaders(), ...options?.headers, }; return apiFetch(this.proxyUrl, { method: 'POST', headers, body: options?.body, }); } /** * Check if WebDAV is available by sending a PROPFIND to the root. */ async checkSupport(): Promise { try { const response = await this.request('PROPFIND', '/', { headers: { 'Depth': '0', 'Content-Type': 'application/xml; charset=utf-8', }, body: ` `, }); return response.status === 207; } catch { return false; } } /** * List contents of a directory via PROPFIND with Depth: 1 */ async list(path: string = '/'): Promise { const response = await this.request('PROPFIND', path, { headers: { 'Depth': '1', 'Content-Type': 'application/xml; charset=utf-8', }, body: ` `, }); if (response.status !== 207) { throw new Error(`PROPFIND failed: ${response.status} ${response.statusText}`); } const text = await response.text(); const requestUri = response.headers.get('X-WebDAV-Request-URI') || ''; return this.parseMultistatus(text, requestUri); } /** * Create a new directory */ async createDirectory(path: string): Promise { const response = await this.request('MKCOL', path); if (response.status !== 201 && response.status !== 204) { throw new Error(`MKCOL failed: ${response.status} ${response.statusText}`); } } /** * Upload a file with optional progress tracking */ async uploadFile( path: string, file: File | Blob, contentType?: string, onProgress?: (loaded: number, total: number) => void, signal?: AbortSignal, ): Promise { if (onProgress) { // Use XMLHttpRequest for progress tracking return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', withBasePath(this.proxyUrl)); xhr.setRequestHeader('X-WebDAV-Method', 'PUT'); xhr.setRequestHeader('X-WebDAV-Path', path); const slotHeaders = getActiveAccountSlotHeaders(); if (slotHeaders['X-JMAP-Cookie-Slot']) { xhr.setRequestHeader('X-JMAP-Cookie-Slot', slotHeaders['X-JMAP-Cookie-Slot']); } xhr.setRequestHeader('Content-Type', contentType || (file instanceof File ? file.type : 'application/octet-stream')); if (signal) { signal.addEventListener('abort', () => { xhr.abort(); reject(new DOMException('Upload aborted', 'AbortError')); }); } xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); }; xhr.onload = () => { if (xhr.status === 200 || xhr.status === 201 || xhr.status === 204) { resolve(); } else { reject(new Error(`PUT failed: ${xhr.status} ${xhr.statusText}`)); } }; xhr.onerror = () => reject(new Error('Upload failed')); xhr.send(file); }); } const response = await this.request('PUT', path, { headers: { 'Content-Type': contentType || (file instanceof File ? file.type : 'application/octet-stream'), }, body: file, }); if (response.status !== 201 && response.status !== 204 && response.status !== 200) { throw new Error(`PUT failed: ${response.status} ${response.statusText}`); } } /** * Download a file */ async downloadFile(path: string): Promise<{ blob: Blob; contentType: string; filename: string }> { const response = await this.request('GET', path); if (!response.ok) { throw new Error(`GET failed: ${response.status} ${response.statusText}`); } const blob = await response.blob(); const contentType = response.headers.get('Content-Type') || 'application/octet-stream'; const filename = path.split('/').pop() || 'download'; return { blob, contentType, filename }; } /** * Delete a file or directory */ async delete(path: string): Promise { const response = await this.request('DELETE', path); if (response.status !== 204 && response.status !== 200) { throw new Error(`DELETE failed: ${response.status} ${response.statusText}`); } } /** * Move/rename a resource */ async move(fromPath: string, toPath: string): Promise { const response = await this.request('MOVE', fromPath, { headers: { 'X-WebDAV-Destination': toPath, 'Overwrite': 'F', }, }); if (response.status !== 201 && response.status !== 204) { throw new Error(`MOVE failed: ${response.status} ${response.statusText}`); } } /** * Copy a resource */ async copy(fromPath: string, toPath: string): Promise { const response = await this.request('COPY', fromPath, { headers: { 'X-WebDAV-Destination': toPath, 'Overwrite': 'F', }, }); if (response.status !== 201 && response.status !== 204) { throw new Error(`COPY failed: ${response.status} ${response.statusText}`); } } /** * Parse a WebDAV multistatus XML response into WebDAVResource[] */ private parseMultistatus(xml: string, requestUrl: string): WebDAVResource[] { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'application/xml'); const responses = doc.getElementsByTagNameNS('DAV:', 'response'); const resources: WebDAVResource[] = []; // Normalize the request URL for comparison (skip the "self" entry) const normalizedRequestUrl = requestUrl.replace(/\/+$/, ''); for (let i = 0; i < responses.length; i++) { const resp = responses[i]; const hrefEl = resp.getElementsByTagNameNS('DAV:', 'href')[0]; if (!hrefEl?.textContent) continue; const href = decodeURIComponent(hrefEl.textContent); // Skip the directory itself (the parent being listed) const normalizedHref = href.replace(/\/+$/, ''); if (this.isSameResource(normalizedHref, normalizedRequestUrl)) continue; const propstat = resp.getElementsByTagNameNS('DAV:', 'propstat')[0]; if (!propstat) continue; const statusEl = propstat.getElementsByTagNameNS('DAV:', 'status')[0]; if (statusEl?.textContent && !statusEl.textContent.includes('200')) continue; const prop = propstat.getElementsByTagNameNS('DAV:', 'prop')[0]; if (!prop) continue; const resourceType = prop.getElementsByTagNameNS('DAV:', 'resourcetype')[0]; const isDirectory = !!resourceType?.getElementsByTagNameNS('DAV:', 'collection')[0]; const displayName = prop.getElementsByTagNameNS('DAV:', 'displayname')[0]?.textContent || ''; const contentType = prop.getElementsByTagNameNS('DAV:', 'getcontenttype')[0]?.textContent || ''; const contentLengthStr = prop.getElementsByTagNameNS('DAV:', 'getcontentlength')[0]?.textContent || '0'; const lastModified = prop.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || ''; const etag = prop.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || ''; // Extract the name from the href path const segments = href.replace(/\/+$/, '').split('/'); const name = displayName || segments[segments.length - 1] || ''; resources.push({ href, name, isDirectory, contentType: isDirectory ? '' : contentType, contentLength: parseInt(contentLengthStr, 10) || 0, lastModified, etag, }); } // Sort: directories first, then alphabetically resources.sort((a, b) => { if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; return a.name.localeCompare(b.name); }); return resources; } private isSameResource(href1: string, href2: string): boolean { // Compare by path only (ignore origin differences) try { const path1 = new URL(href1, 'http://dummy').pathname.replace(/\/+$/, ''); const path2 = new URL(href2, 'http://dummy').pathname.replace(/\/+$/, ''); return path1 === path2; } catch { return href1 === href2; } } }