184 lines
5.8 KiB
TypeScript
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();
|