file-browser/src/lib/webdav.ts

184 lines
5.8 KiB
TypeScript

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<Buffer | NodeReadable | string> {
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<Uint8Array>).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<WebDavEntry[]> {
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<NodeJS.ReadableStream> {
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<WebDavEntry | null> {
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();