import { IncomingMessage, ServerResponse } from "http"; import { existsSync, statSync } from "fs"; import { randomUUID } from "crypto"; import bcrypt from "bcryptjs"; import { lookup as mimeTypeLookup } from "mime-types"; import { prisma } from "./prisma"; import { getFilePath, getFileStream, saveFile, deleteFile, duplicateFile, } from "./storage"; import { logActivity } from "./activity"; import { trashFiles, trashFolderRecursive } from "./trash"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Virtual folder name for shared spaces — visible in Finder as a directory */ const SHARED_PREFIX = "Shared Spaces"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface DavUser { id: string; username: string; } interface PropItem { href: string; name: string; isDir: boolean; size?: bigint | number; mimeType?: string; modifiedAt: Date; createdAt: Date; } /** Parsed DAV path context */ type DavContext = | { kind: "personal-root" } | { kind: "shared-root" } | { kind: "personal"; folderParts: string[]; filename: string | null; isDir: boolean } | { kind: "shared-spaces-list" } | { kind: "shared-space-root"; spaceName: string } | { kind: "shared-content"; spaceName: string; folderParts: string[]; filename: string | null; isDir: boolean }; // In-memory lock store (reset on restart — sufficient for single-instance DAV) const locks = new Map(); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function escapeXml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function parseBasicAuth( req: IncomingMessage ): { username: string; password: string } | null { const auth = req.headers["authorization"]; if (!auth?.startsWith("Basic ")) return null; const decoded = Buffer.from(auth.slice(6), "base64").toString("utf-8"); const colonIdx = decoded.indexOf(":"); if (colonIdx === -1) return null; return { username: decoded.slice(0, colonIdx), password: decoded.slice(colonIdx + 1), }; } async function authenticate(req: IncomingMessage): Promise { const creds = parseBasicAuth(req); if (!creds) return null; const user = await prisma.user.findFirst({ where: { OR: [{ username: creds.username }, { email: creds.username }], }, select: { id: true, username: true, passwordHash: true }, }); if (!user) return null; const valid = await bcrypt.compare(creds.password, user.passwordHash); if (!valid) return null; return { id: user.id, username: user.username ?? creds.username }; } async function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (c: Buffer) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); req.on("error", reject); }); } /** Strip /dav prefix, percent-decode each segment */ function getDavPath(req: IncomingMessage): string { const raw = (req.url || "/").split("?")[0]; const stripped = raw.replace(/^\/dav/, "") || "/"; return stripped .split("/") .map((p) => { try { return decodeURIComponent(p); } catch { return p; } }) .join("/"); } /** Parse a decoded DAV path into a structured context */ function parseDavPath(davPath: string): DavContext { const isDir = davPath === "/" || davPath.endsWith("/"); const parts = davPath.split("/").filter(Boolean); // Root if (parts.length === 0) return { kind: "personal-root" }; // Shared Spaces virtual folder if (parts[0] === SHARED_PREFIX) { if (parts.length === 1) return { kind: "shared-spaces-list" }; const spaceName = parts[1]; if (parts.length === 2) return { kind: "shared-space-root", spaceName }; const rest = parts.slice(2); const sharedIsDir = isDir || false; if (sharedIsDir || rest.length === 0) { return { kind: "shared-content", spaceName, folderParts: rest, filename: null, isDir: true }; } return { kind: "shared-content", spaceName, folderParts: rest.slice(0, -1), filename: rest[rest.length - 1], isDir: false, }; } // Personal content if (isDir) { return { kind: "personal", folderParts: parts, filename: null, isDir: true }; } return { kind: "personal", folderParts: parts.slice(0, -1), filename: parts[parts.length - 1], isDir: false, }; } function notFoundError(): Error { const err = new Error("Not found"); (err as NodeJS.ErrnoException).code = "NOT_FOUND"; return err; } /** Resolve personal folder path → folderId (null = root). Throws NOT_FOUND. */ async function resolvePersonalFolder( userId: string, parts: string[] ): Promise { if (parts.length === 0) return null; let parentId: string | null = null; for (const part of parts) { const found: { id: string } | null = await prisma.folder.findFirst({ where: { userId, name: part, parentId }, }); if (!found) throw notFoundError(); parentId = found.id; } return parentId; } /** Find a shared space the user is a member of, by name. */ async function findSharedSpace(userId: string, spaceName: string) { const membership = await prisma.sharedSpaceMember.findFirst({ where: { userId, sharedSpace: { name: spaceName } }, include: { sharedSpace: true }, }); return membership?.sharedSpace ?? null; } /** Resolve folder path within a shared space → folderId (null = space root). Throws NOT_FOUND. */ async function resolveSharedFolder( spaceId: string, parts: string[] ): Promise { if (parts.length === 0) return null; let parentId: string | null = null; for (const part of parts) { const found: { id: string } | null = await prisma.folder.findFirst({ where: { sharedSpaceId: spaceId, name: part, parentId }, }); if (!found) throw notFoundError(); parentId = found.id; } return parentId; } /** Extract destination DAV path from the Destination header */ function extractDestPath(header: string): string { try { const url = new URL(header); return url.pathname.replace(/^\/dav/, "") || "/"; } catch { return header.replace(/^\/dav/, "") || "/"; } } function decodePath(p: string): string { return p.split("/").map((s) => { try { return decodeURIComponent(s); } catch { return s; } }).join("/"); } // --------------------------------------------------------------------------- // XML builders // --------------------------------------------------------------------------- function buildMultistatusXml(items: PropItem[]): string { const responses = items .map((item) => { const sizeNum = item.size !== undefined ? Number(item.size) : 0; const typeProps = item.isDir ? `` : `${sizeNum}${escapeXml(item.mimeType || "application/octet-stream")}`; return ` ${escapeXml(item.href)} ${escapeXml(item.name)} ${typeProps} ${item.modifiedAt.toUTCString()} ${item.createdAt.toISOString()} HTTP/1.1 200 OK `; }) .join("\n"); return `\n\n${responses}\n`; } function buildLockXml(token: string, davPath: string, owner: string): string { return ` 0 ${escapeXml(owner)} Second-3600 ${token} /dav${escapeXml(davPath)} `; } // --------------------------------------------------------------------------- // PROPFIND builders for each context // --------------------------------------------------------------------------- function sharedSpaceHref(spaceName: string, folderParts: string[] = [], filename?: string): string { const base = `/dav/${encodeURIComponent(SHARED_PREFIX)}/${encodeURIComponent(spaceName)}`; const folderPath = folderParts.map(encodeURIComponent).join("/"); if (filename) { return `${base}${folderPath ? "/" + folderPath : ""}/${encodeURIComponent(filename)}`; } return `${base}${folderPath ? "/" + folderPath : ""}/`; } function personalHref(folderParts: string[] = [], filename?: string): string { const folderPath = folderParts.map(encodeURIComponent).join("/"); if (filename) { return `/dav${folderPath ? "/" + folderPath : ""}/${encodeURIComponent(filename)}`; } return `/dav/${folderPath}/`; } async function buildPersonalRootItems(userId: string, depth: string): Promise { const now = new Date(); const items: PropItem[] = [ { href: "/dav/", name: "SPB Cloud", isDir: true, modifiedAt: now, createdAt: now }, ]; if (depth === "0") return items; // Virtual "Shared Spaces" folder items.push({ href: `/dav/${encodeURIComponent(SHARED_PREFIX)}/`, name: SHARED_PREFIX, isDir: true, modifiedAt: now, createdAt: now, }); const [folders, files] = await Promise.all([ prisma.folder.findMany({ where: { userId, parentId: null } }), prisma.file.findMany({ where: { userId, folderId: null } }), ]); for (const f of folders) { items.push({ href: `/dav/${encodeURIComponent(f.name)}/`, name: f.name, isDir: true, modifiedAt: f.createdAt, createdAt: f.createdAt, }); } for (const f of files) { items.push({ href: `/dav/${encodeURIComponent(f.name)}`, name: f.name, isDir: false, size: f.size, mimeType: f.mimeType, modifiedAt: f.updatedAt, createdAt: f.createdAt, }); } return items; } async function buildSharedSpacesListItems(userId: string, depth: string): Promise { const now = new Date(); const items: PropItem[] = [ { href: `/dav/${encodeURIComponent(SHARED_PREFIX)}/`, name: SHARED_PREFIX, isDir: true, modifiedAt: now, createdAt: now, }, ]; if (depth === "0") return items; const memberships = await prisma.sharedSpaceMember.findMany({ where: { userId }, include: { sharedSpace: true }, }); for (const m of memberships) { items.push({ href: `/dav/${encodeURIComponent(SHARED_PREFIX)}/${encodeURIComponent(m.sharedSpace.name)}/`, name: m.sharedSpace.name, isDir: true, modifiedAt: m.sharedSpace.updatedAt, createdAt: m.sharedSpace.createdAt, }); } return items; } async function buildSharedSpaceRootItems( userId: string, spaceName: string, depth: string ): Promise { const space = await findSharedSpace(userId, spaceName); if (!space) throw notFoundError(); const now = space.updatedAt; const items: PropItem[] = [ { href: `/dav/${encodeURIComponent(SHARED_PREFIX)}/${encodeURIComponent(spaceName)}/`, name: spaceName, isDir: true, modifiedAt: now, createdAt: space.createdAt, }, ]; if (depth === "0") return items; // Root-level folders of the space (parentId null + sharedSpaceId = spaceId) const rootFolders = await prisma.folder.findMany({ where: { sharedSpaceId: space.id, parentId: null }, }); for (const f of rootFolders) { items.push({ href: sharedSpaceHref(spaceName, [f.name]), name: f.name, isDir: true, modifiedAt: f.createdAt, createdAt: f.createdAt, }); } // Files directly in the space root (folderId null, but linked via space through folder — // the schema has no direct space-to-file link, so no root files possible) return items; } async function buildSharedFolderItems( userId: string, spaceName: string, folderParts: string[], depth: string ): Promise { const space = await findSharedSpace(userId, spaceName); if (!space) throw notFoundError(); const folderId = await resolveSharedFolder(space.id, folderParts); if (folderId === null && folderParts.length > 0) throw notFoundError(); const folderRecord = folderId ? await prisma.folder.findFirst({ where: { id: folderId } }) : null; const folderHref = sharedSpaceHref(spaceName, folderParts); const items: PropItem[] = [ { href: folderHref, name: folderRecord?.name ?? spaceName, isDir: true, modifiedAt: folderRecord?.createdAt ?? new Date(), createdAt: folderRecord?.createdAt ?? new Date(), }, ]; if (depth === "0") return items; const [subFolders, files] = await Promise.all([ prisma.folder.findMany({ where: { sharedSpaceId: space.id, parentId: folderId } }), prisma.file.findMany({ where: { folderId: folderId! } }), ]); for (const sf of subFolders) { items.push({ href: sharedSpaceHref(spaceName, [...folderParts, sf.name]), name: sf.name, isDir: true, modifiedAt: sf.createdAt, createdAt: sf.createdAt, }); } for (const f of files) { items.push({ href: sharedSpaceHref(spaceName, folderParts, f.name), name: f.name, isDir: false, size: f.size, mimeType: f.mimeType, modifiedAt: f.updatedAt, createdAt: f.createdAt, }); } return items; } // --------------------------------------------------------------------------- // Method handlers // --------------------------------------------------------------------------- async function handlePropfind( req: IncomingMessage, res: ServerResponse, user: DavUser, davPath: string ): Promise { const depth = (req.headers["depth"] || "1") as string; const ctx = parseDavPath(davPath); let items: PropItem[] = []; try { switch (ctx.kind) { case "personal-root": items = await buildPersonalRootItems(user.id, depth); break; case "shared-spaces-list": items = await buildSharedSpacesListItems(user.id, depth); break; case "shared-space-root": items = await buildSharedSpaceRootItems(user.id, ctx.spaceName, depth); break; case "shared-content": { if (ctx.filename && !ctx.isDir) { // Single file propfind inside a shared space const space = await findSharedSpace(user.id, ctx.spaceName); if (!space) { res.writeHead(404); res.end("Not Found"); return; } const folderId = await resolveSharedFolder(space.id, ctx.folderParts); if (folderId === null) { res.writeHead(404); res.end("Not Found"); return; } const file = await prisma.file.findFirst({ where: { folderId, name: ctx.filename }, }); if (!file) { res.writeHead(404); res.end("Not Found"); return; } items = [{ href: sharedSpaceHref(ctx.spaceName, ctx.folderParts, ctx.filename), name: file.name, isDir: false, size: file.size, mimeType: file.mimeType, modifiedAt: file.updatedAt, createdAt: file.createdAt, }]; } else { items = await buildSharedFolderItems(user.id, ctx.spaceName, ctx.folderParts, depth); } break; } case "personal": { if (ctx.filename && !ctx.isDir) { // Single file propfind const folderId = await resolvePersonalFolder(user.id, ctx.folderParts); const file = await prisma.file.findFirst({ where: { userId: user.id, folderId, name: ctx.filename }, }); if (!file) { res.writeHead(404); res.end("Not Found"); return; } items = [{ href: personalHref(ctx.folderParts, ctx.filename), name: file.name, isDir: false, size: file.size, mimeType: file.mimeType, modifiedAt: file.updatedAt, createdAt: file.createdAt, }]; } else { // Folder propfind const folderId = await resolvePersonalFolder(user.id, ctx.folderParts); const folder = folderId ? await prisma.folder.findFirst({ where: { id: folderId, userId: user.id } }) : null; const folderHref = ctx.folderParts.length === 0 ? "/dav/" : personalHref(ctx.folderParts); items = [{ href: folderHref, name: folder?.name ?? "Root", isDir: true, modifiedAt: folder?.createdAt ?? new Date(), createdAt: folder?.createdAt ?? new Date(), }]; if (depth !== "0") { const [subFolders, files] = await Promise.all([ prisma.folder.findMany({ where: { userId: user.id, parentId: folderId } }), prisma.file.findMany({ where: { userId: user.id, folderId } }), ]); for (const sf of subFolders) { items.push({ href: personalHref([...ctx.folderParts, sf.name]), name: sf.name, isDir: true, modifiedAt: sf.createdAt, createdAt: sf.createdAt, }); } for (const f of files) { items.push({ href: personalHref(ctx.folderParts, f.name), name: f.name, isDir: false, size: f.size, mimeType: f.mimeType, modifiedAt: f.updatedAt, createdAt: f.createdAt, }); } } } break; } } } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") { res.writeHead(404); res.end("Not Found"); return; } throw err; } res.writeHead(207, { "Content-Type": "application/xml; charset=utf-8" }); res.end(buildMultistatusXml(items)); } async function handleGet( req: IncomingMessage, res: ServerResponse, user: DavUser, davPath: string, headOnly: boolean ): Promise { const ctx = parseDavPath(davPath); // Virtual directories → simple response if ( ctx.kind === "personal-root" || ctx.kind === "shared-spaces-list" || ctx.kind === "shared-space-root" || (ctx.kind === "personal" && ctx.isDir) || (ctx.kind === "shared-content" && ctx.isDir) ) { res.writeHead(200, { "Content-Type": "text/html" }); res.end("Collection"); return; } try { let file; if (ctx.kind === "personal" && ctx.filename) { const folderId = await resolvePersonalFolder(user.id, ctx.folderParts); file = await prisma.file.findFirst({ where: { userId: user.id, folderId, name: ctx.filename }, }); } else if (ctx.kind === "shared-content" && ctx.filename) { const space = await findSharedSpace(user.id, ctx.spaceName); if (!space) { res.writeHead(404); res.end("Not Found"); return; } const folderId = await resolveSharedFolder(space.id, ctx.folderParts); if (folderId === null) { res.writeHead(404); res.end("Not Found"); return; } file = await prisma.file.findFirst({ where: { folderId, name: ctx.filename } }); } if (!file) { res.writeHead(404); res.end("Not Found"); return; } const filePath = getFilePath(file.storagePath); if (!existsSync(filePath)) { res.writeHead(404); res.end("Not Found"); return; } const stats = statSync(filePath); res.setHeader("Content-Type", file.mimeType || "application/octet-stream"); res.setHeader("Content-Length", stats.size); res.setHeader("Last-Modified", file.updatedAt.toUTCString()); res.setHeader("ETag", `"${file.id}"`); res.writeHead(200); if (headOnly) { res.end(); } else { getFileStream(file.storagePath).pipe(res); } } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") { res.writeHead(404); res.end("Not Found"); return; } throw err; } } async function handlePut( req: IncomingMessage, res: ServerResponse, user: DavUser, davPath: string ): Promise { const ctx = parseDavPath(davPath); const filename = ctx.kind === "personal" ? ctx.filename : ctx.kind === "shared-content" ? ctx.filename : null; if (!filename) { res.writeHead(400); res.end("Bad Request"); return; } // Discard macOS metadata noise if (filename.startsWith("._") || filename === ".DS_Store" || filename === ".localized") { await readBody(req); res.writeHead(204); res.end(); return; } const body = await readBody(req); const mimeType = (mimeTypeLookup(filename) as string | false) || "application/octet-stream"; try { if (ctx.kind === "personal") { const folderId = await resolvePersonalFolder(user.id, ctx.folderParts); const existing = await prisma.file.findFirst({ where: { userId: user.id, folderId, name: filename }, }); if (existing) { const { storagePath, size } = await saveFile(body, filename); const old = existing.storagePath; await prisma.file.update({ where: { id: existing.id }, data: { storagePath, size: BigInt(size), updatedAt: new Date() }, }); await deleteFile(old); await logActivity("UPLOAD", existing.id, `WebDAV update: ${filename}`); res.writeHead(204); } else { const { storagePath, size } = await saveFile(body, filename); const f = await prisma.file.create({ data: { name: filename, storagePath, size: BigInt(size), mimeType, folderId, userId: user.id }, }); await logActivity("UPLOAD", f.id, `WebDAV upload: ${filename}`); res.writeHead(201); } res.end(); return; } if (ctx.kind === "shared-content") { const space = await findSharedSpace(user.id, ctx.spaceName); if (!space) { res.writeHead(403); res.end("Forbidden: not a member"); return; } if (ctx.folderParts.length === 0) { // Can't put files directly at space root (no sharedSpaceId on File) res.writeHead(409); res.end("Conflict: create a folder first, then upload files into it"); return; } const folderId = await resolveSharedFolder(space.id, ctx.folderParts); if (folderId === null) { res.writeHead(404); res.end("Parent folder not found"); return; } const existing = await prisma.file.findFirst({ where: { folderId, name: filename } }); if (existing) { const { storagePath, size } = await saveFile(body, filename); const old = existing.storagePath; await prisma.file.update({ where: { id: existing.id }, data: { storagePath, size: BigInt(size), updatedAt: new Date() }, }); await deleteFile(old); await logActivity("UPLOAD", existing.id, `WebDAV shared update: ${filename}`); res.writeHead(204); } else { const { storagePath, size } = await saveFile(body, filename); const f = await prisma.file.create({ data: { name: filename, storagePath, size: BigInt(size), mimeType, folderId, userId: user.id }, }); await logActivity("UPLOAD", f.id, `WebDAV shared upload: ${filename} in ${ctx.spaceName}`); res.writeHead(201); } res.end(); return; } res.writeHead(400); res.end("Bad Request"); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") { res.writeHead(409); res.end("Conflict: parent folder not found"); return; } throw err; } } async function handleDelete( req: IncomingMessage, res: ServerResponse, user: DavUser, davPath: string ): Promise { const ctx = parseDavPath(davPath); // Protect virtual roots if ( ctx.kind === "personal-root" || ctx.kind === "shared-spaces-list" || ctx.kind === "shared-space-root" ) { res.writeHead(403); res.end("Forbidden"); return; } try { if (ctx.kind === "personal") { if (!ctx.filename || ctx.isDir) { // Delete folder const folderId = await resolvePersonalFolder(user.id, ctx.folderParts); if (!folderId) { res.writeHead(403); res.end("Cannot delete root"); return; } await trashFolderRecursive(folderId); await logActivity("DELETE_FOLDER", undefined, `WebDAV delete folder: ${ctx.folderParts.join("/")}`); } else { const folderId = await resolvePersonalFolder(user.id, ctx.folderParts); const file = await prisma.file.findFirst({ where: { userId: user.id, folderId, name: ctx.filename }, }); if (!file) { res.writeHead(404); res.end("Not Found"); return; } await trashFiles([file.id]); } } if (ctx.kind === "shared-content") { const space = await findSharedSpace(user.id, ctx.spaceName); if (!space) { res.writeHead(403); res.end("Forbidden"); return; } if (!ctx.filename || ctx.isDir) { const folderId = await resolveSharedFolder(space.id, ctx.folderParts); if (!folderId) { res.writeHead(403); res.end("Cannot delete space root"); return; } await trashFolderRecursive(folderId); await logActivity("DELETE_FOLDER", undefined, `WebDAV delete shared folder: ${ctx.folderParts.join("/")} in ${ctx.spaceName}`); } else { const folderId = await resolveSharedFolder(space.id, ctx.folderParts); if (folderId === null) { res.writeHead(404); res.end("Not Found"); return; } const file = await prisma.file.findFirst({ where: { folderId, name: ctx.filename } }); if (!file) { res.writeHead(404); res.end("Not Found"); return; } await trashFiles([file.id]); } } res.writeHead(204); res.end(); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") { res.writeHead(404); res.end("Not Found"); return; } throw err; } } async function handleMkcol( req: IncomingMessage, res: ServerResponse, user: DavUser, davPath: string ): Promise { // Use raw parts directly — parseDavPath has path ambiguity for MKCOL // since MKCOL paths never have a trailing slash yet refer to a directory. const rawParts = davPath.split("/").filter(Boolean); if (rawParts.length === 0) { res.writeHead(405); res.end("Cannot create root"); return; } // Protect virtual roots if (rawParts.length === 1 && rawParts[0] === SHARED_PREFIX) { res.writeHead(403); res.end("Forbidden"); return; } try { if (rawParts[0] === SHARED_PREFIX) { // Shared space MKCOL: /Shared Spaces/{spaceName}/{...folderPath} if (rawParts.length < 3) { res.writeHead(403); res.end("Forbidden: cannot create shared spaces via WebDAV"); return; } const spaceName = rawParts[1]; const space = await findSharedSpace(user.id, spaceName); if (!space) { res.writeHead(403); res.end("Forbidden: not a member"); return; } const pathInSpace = rawParts.slice(2); // e.g. ["Documents"] or ["Documents", "SubFolder"] const folderName = pathInSpace[pathInSpace.length - 1]; const parentParts = pathInSpace.slice(0, -1); const parentId = await resolveSharedFolder(space.id, parentParts); const existing = await prisma.folder.findFirst({ where: { sharedSpaceId: space.id, parentId, name: folderName }, }); if (existing) { res.writeHead(405); res.end("Already exists"); return; } await prisma.folder.create({ data: { name: folderName, parentId, userId: user.id, sharedSpaceId: space.id }, }); await logActivity("CREATE_FOLDER", undefined, `WebDAV shared mkdir: ${folderName} in ${spaceName}`); res.writeHead(201); res.end(); return; } // Personal MKCOL: /{...folderPath} const folderName = rawParts[rawParts.length - 1]; const parentParts = rawParts.slice(0, -1); const parentId = await resolvePersonalFolder(user.id, parentParts); const existing = await prisma.folder.findFirst({ where: { userId: user.id, parentId, name: folderName }, }); if (existing) { res.writeHead(405); res.end("Already exists"); return; } await prisma.folder.create({ data: { name: folderName, parentId, userId: user.id } }); await logActivity("CREATE_FOLDER", undefined, `WebDAV mkdir: ${folderName}`); res.writeHead(201); res.end(); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") { res.writeHead(409); res.end("Conflict: parent not found"); return; } throw err; } } async function handleMove( req: IncomingMessage, res: ServerResponse, user: DavUser, davPath: string ): Promise { const destination = req.headers["destination"] as string; if (!destination) { res.writeHead(400); res.end("Bad Request"); return; } const destPath = decodePath(extractDestPath(destination)); const src = parseDavPath(davPath); const dst = parseDavPath(destPath); // Only support moves within the same zone (personal→personal or shared→shared same space) if (src.kind === "personal-root" || src.kind === "shared-spaces-list" || src.kind === "shared-space-root") { res.writeHead(403); res.end("Forbidden"); return; } try { if (src.kind === "personal" && dst.kind === "personal") { if (src.filename && !src.isDir) { const srcFolderId = await resolvePersonalFolder(user.id, src.folderParts); const file = await prisma.file.findFirst({ where: { userId: user.id, folderId: srcFolderId, name: src.filename }, }); if (!file) { res.writeHead(404); res.end("Not Found"); return; } const destFolderId = await resolvePersonalFolder(user.id, dst.folderParts); await prisma.file.update({ where: { id: file.id }, data: { name: dst.filename || src.filename, folderId: destFolderId }, }); await logActivity("MOVE", file.id, `WebDAV move: ${src.filename} → ${dst.filename}`); } else { const folderId = await resolvePersonalFolder(user.id, src.folderParts); if (!folderId) { res.writeHead(403); res.end("Cannot move root"); return; } const newName = dst.kind === "personal" && dst.folderParts.length > 0 ? dst.folderParts[dst.folderParts.length - 1] : src.folderParts[src.folderParts.length - 1]; const newParentParts = dst.kind === "personal" ? dst.folderParts.slice(0, -1) : []; const newParentId = newParentParts.length > 0 ? await resolvePersonalFolder(user.id, newParentParts) : null; await prisma.folder.update({ where: { id: folderId }, data: { name: newName, parentId: newParentId } }); await logActivity("MOVE", undefined, `WebDAV move folder: ${src.folderParts.join("/")} → ${newName}`); } res.writeHead(204); res.end(); return; } if ( src.kind === "shared-content" && dst.kind === "shared-content" && src.spaceName === dst.spaceName ) { const space = await findSharedSpace(user.id, src.spaceName); if (!space) { res.writeHead(403); res.end("Forbidden"); return; } if (src.filename && !src.isDir) { const srcFolderId = await resolveSharedFolder(space.id, src.folderParts); if (srcFolderId === null) { res.writeHead(404); res.end("Not Found"); return; } const file = await prisma.file.findFirst({ where: { folderId: srcFolderId, name: src.filename } }); if (!file) { res.writeHead(404); res.end("Not Found"); return; } const destFolderId = await resolveSharedFolder(space.id, dst.folderParts); if (destFolderId === null) { res.writeHead(404); res.end("Not Found"); return; } await prisma.file.update({ where: { id: file.id }, data: { name: dst.filename || src.filename, folderId: destFolderId }, }); await logActivity("MOVE", file.id, `WebDAV shared move: ${src.filename} → ${dst.filename}`); } else { const folderId = await resolveSharedFolder(space.id, src.folderParts); if (!folderId) { res.writeHead(403); res.end("Cannot move space root"); return; } const newName = dst.folderParts[dst.folderParts.length - 1] ?? src.folderParts[src.folderParts.length - 1]; const newParentParts = dst.folderParts.slice(0, -1); const newParentId = newParentParts.length > 0 ? await resolveSharedFolder(space.id, newParentParts) : null; await prisma.folder.update({ where: { id: folderId }, data: { name: newName, parentId: newParentId } }); await logActivity("MOVE", undefined, `WebDAV shared move folder in ${src.spaceName}`); } res.writeHead(204); res.end(); return; } // Cross-zone moves not supported res.writeHead(403); res.end("Forbidden: cannot move across zones (personal ↔ shared)"); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") { res.writeHead(404); res.end("Not Found"); return; } throw err; } } async function handleCopy( req: IncomingMessage, res: ServerResponse, user: DavUser, davPath: string ): Promise { const destination = req.headers["destination"] as string; if (!destination) { res.writeHead(400); res.end("Bad Request"); return; } const destPath = decodePath(extractDestPath(destination)); const src = parseDavPath(davPath); const dst = parseDavPath(destPath); const srcFilename = src.kind === "personal" || src.kind === "shared-content" ? src.filename : null; if (!srcFilename) { res.writeHead(400); res.end("Cannot copy a directory"); return; } try { let srcFile; if (src.kind === "personal") { const folderId = await resolvePersonalFolder(user.id, src.folderParts); srcFile = await prisma.file.findFirst({ where: { userId: user.id, folderId, name: srcFilename } }); } else if (src.kind === "shared-content") { const space = await findSharedSpace(user.id, src.spaceName); if (!space) { res.writeHead(403); res.end("Forbidden"); return; } const folderId = await resolveSharedFolder(space.id, src.folderParts); if (folderId === null) { res.writeHead(404); res.end("Not Found"); return; } srcFile = await prisma.file.findFirst({ where: { folderId, name: srcFilename } }); } if (!srcFile) { res.writeHead(404); res.end("Not Found"); return; } const destFilename = dst.kind === "personal" || dst.kind === "shared-content" ? dst.filename || srcFilename : srcFilename; const { storagePath, size } = await duplicateFile(srcFile.storagePath, destFilename); let destFolderId: string | null = null; if (dst.kind === "personal") { destFolderId = await resolvePersonalFolder(user.id, dst.folderParts); await prisma.file.create({ data: { name: destFilename, storagePath, size: BigInt(size), mimeType: srcFile.mimeType, folderId: destFolderId, userId: user.id }, }); } else if (dst.kind === "shared-content") { const space = await findSharedSpace(user.id, dst.spaceName); if (!space) { res.writeHead(403); res.end("Forbidden"); return; } destFolderId = await resolveSharedFolder(space.id, dst.folderParts); if (destFolderId === null) { res.writeHead(404); res.end("Destination folder not found"); return; } await prisma.file.create({ data: { name: destFilename, storagePath, size: BigInt(size), mimeType: srcFile.mimeType, folderId: destFolderId, userId: user.id }, }); } res.writeHead(201); res.end(); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") { res.writeHead(404); res.end("Not Found"); return; } throw err; } } async function handleLock( req: IncomingMessage, res: ServerResponse, user: DavUser, davPath: string ): Promise { const token = `opaquelocktoken:${randomUUID()}`; locks.set(davPath, token); res.setHeader("Lock-Token", `<${token}>`); res.writeHead(200, { "Content-Type": "application/xml; charset=utf-8" }); res.end(buildLockXml(token, davPath, user.username)); } // --------------------------------------------------------------------------- // Main dispatcher // --------------------------------------------------------------------------- export async function handleWebDAV( req: IncomingMessage, res: ServerResponse ): Promise { const user = await authenticate(req); if (!user) { res.setHeader("WWW-Authenticate", 'Basic realm="SPB Cloud"'); res.writeHead(401, { "Content-Type": "text/plain" }); res.end("Unauthorized"); return; } const method = req.method?.toUpperCase() || "GET"; const davPath = getDavPath(req); res.setHeader("DAV", "1, 2"); res.setHeader("MS-Author-Via", "DAV"); res.setHeader( "Allow", "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH, LOCK, UNLOCK" ); try { switch (method) { case "OPTIONS": res.writeHead(200); res.end(); break; case "PROPFIND": await handlePropfind(req, res, user, davPath); break; case "PROPPATCH": res.writeHead(207, { "Content-Type": "application/xml; charset=utf-8" }); res.end( `${escapeXml("/dav" + davPath)}HTTP/1.1 200 OK` ); break; case "GET": await handleGet(req, res, user, davPath, false); break; case "HEAD": await handleGet(req, res, user, davPath, true); break; case "PUT": await handlePut(req, res, user, davPath); break; case "DELETE": await handleDelete(req, res, user, davPath); break; case "MKCOL": await handleMkcol(req, res, user, davPath); break; case "MOVE": await handleMove(req, res, user, davPath); break; case "COPY": await handleCopy(req, res, user, davPath); break; case "LOCK": await handleLock(req, res, user, davPath); break; case "UNLOCK": res.writeHead(204); res.end(); break; default: res.writeHead(405); res.end("Method Not Allowed"); } } catch (error) { console.error("[WebDAV]", method, davPath, error); if (!res.headersSent) { res.writeHead(500); res.end("Internal Server Error"); } } }