import * as Sentry from "@sentry/nextjs"; import { createClient, type WebDAVClient, type FileStat } from "webdav"; import { Readable as NodeReadable } from "node:stream"; import { env } from "@/lib/env"; import { normalizePath } from "@/lib/paths"; import type { WebDavEntry } from "@/types/files"; /** * Nextcloud WebDAV wrapper with Sentry spans. * Uses credentials from env and talks directly to the Nextcloud WebDAV endpoint. */ let _client: WebDAVClient | null = null; function getClient(): WebDAVClient { if (_client) return _client; _client = createClient(env.NEXTCLOUD_BASE_URL, { username: env.NEXTCLOUD_USERNAME, password: env.NEXTCLOUD_APP_PASSWORD, }); return _client; } /** * Type guards and converters for upload data to match webdav client types. */ function isNodeReadable(val: unknown): val is NodeReadable { return typeof val === "object" && val !== null && "pipe" in val && typeof (val as NodeReadable).pipe === "function"; } async function toWebdavData( file: unknown, ): Promise { if (typeof file === "string") return file; if (typeof Buffer !== "undefined" && Buffer.isBuffer(file)) { return file as Buffer; } if (file instanceof Uint8Array) { return Buffer.from(file as Uint8Array); } if (file instanceof ArrayBuffer) { return Buffer.from(new Uint8Array(file as ArrayBuffer)); } // Blob (from web File API) if (typeof Blob !== "undefined" && file instanceof Blob) { const ab = await (file as Blob).arrayBuffer(); return Buffer.from(new Uint8Array(ab)); } // Node stream if (isNodeReadable(file)) { return file as NodeReadable; } // Web ReadableStream (convert to Buffer) if (typeof ReadableStream !== "undefined" && file instanceof ReadableStream) { const reader = (file as ReadableStream).getReader(); const chunks: Uint8Array[] = []; while (true) { const { done, value } = await reader.read(); if (done) break; if (value) chunks.push(value); } const total = chunks.reduce((sum, c) => sum + c.byteLength, 0); const buf = Buffer.alloc(total); let offset = 0; for (const c of chunks) { buf.set(c, offset); offset += c.byteLength; } return buf; } throw new Error("Unsupported upload data type for WebDAV"); } function mapStatToEntry(stat: FileStat): WebDavEntry { const mime = (stat as unknown as { mime?: unknown }).mime; return { href: normalizePath(stat.filename), name: stat.basename, isDirectory: stat.type === "directory", lastmod: typeof stat.lastmod === "string" ? stat.lastmod : undefined, size: typeof stat.size === "number" ? stat.size : undefined, etag: typeof stat.etag === "string" ? stat.etag : undefined, contentType: typeof mime === "string" ? mime : undefined, props: undefined, }; } export class NextcloudClient { private rootPath: string; constructor(rootPath: string = env.NEXTCLOUD_ROOT_PATH) { this.rootPath = normalizePath(rootPath); } /** * Resolve incoming path to an absolute normalized DAV path. * If given path is absolute, use it as-is; otherwise join with NEXTCLOUD_ROOT_PATH. */ private resolve(path: string | undefined): string { if (!path) return this.rootPath; const p = normalizePath(path); return p; } async listDirectory(path?: string): Promise { return Sentry.startSpan( { op: "http.client", name: "WebDAV PROPFIND list" }, async (span) => { const target = this.resolve(path); span.setAttribute("path", target); const client = getClient(); const stats = (await client.getDirectoryContents(target)) as FileStat[]; return stats.map(mapStatToEntry); }, ); } async createFolder(path: string): Promise<{ ok: boolean; etag?: string }> { return Sentry.startSpan( { op: "http.client", name: "WebDAV MKCOL createFolder" }, async (span) => { const target = this.resolve(path); span.setAttribute("path", target); const client = getClient(); await client.createDirectory(target); // ETag not typically returned by createDirectory in webdav client return { ok: true }; }, ); } async uploadFile( destPath: string, file: unknown, contentType?: string, ): Promise<{ ok: boolean; etag?: string; size?: number }> { return Sentry.startSpan( { op: "http.client", name: "WebDAV PUT uploadFile" }, async (span) => { const target = this.resolve(destPath); span.setAttribute("path", target); if (contentType) span.setAttribute("contentType", contentType); const client = getClient(); const data = await toWebdavData(file); await client.putFileContents(target, data, { overwrite: true }); return { ok: true }; }, ); } downloadStream(path: string): Promise { return Sentry.startSpan( { op: "http.client", name: "WebDAV GET downloadStream" }, async (span) => { const target = this.resolve(path); span.setAttribute("path", target); const client = getClient(); const stream = client.createReadStream(target) as unknown as NodeJS.ReadableStream; return stream; }, ); } async stat(path: string): Promise { return Sentry.startSpan( { op: "http.client", name: "WebDAV PROPFIND stat" }, async (span) => { const target = this.resolve(path); span.setAttribute("path", target); const client = getClient(); try { const s = (await client.stat(target)) as FileStat; return mapStatToEntry(s); } catch (err) { // 404 -> not found Sentry.captureException(err); return null; } }, ); } } export const nextcloud = new NextcloudClient();