JavaScript 56.7%
TypeScript 42.6%
1import { IncomingMessage, ServerResponse } from "http";2import { existsSync, statSync } from "fs";3import { randomUUID } from "crypto";4import bcrypt from "bcryptjs";5import { lookup as mimeTypeLookup } from "mime-types";6import { prisma } from "./prisma";7import {8 getFilePath,9 getFileStream,10 saveFile,11 deleteFile,12 duplicateFile,13} from "./storage";14import { logActivity } from "./activity";15import { trashFiles, trashFolderRecursive } from "./trash";1617// ---------------------------------------------------------------------------18// Constants19// ---------------------------------------------------------------------------2021/** Virtual folder name for shared spaces — visible in Finder as a directory */22const SHARED_PREFIX = "Shared Spaces";2324// ---------------------------------------------------------------------------25// Types26// ---------------------------------------------------------------------------2728interface DavUser {29 id: string;30 username: string;31}3233interface PropItem {34 href: string;35 name: string;36 isDir: boolean;37 size?: bigint | number;38 mimeType?: string;39 modifiedAt: Date;40 createdAt: Date;41}4243/** Parsed DAV path context */44type DavContext =45 | { kind: "personal-root" }46 | { kind: "shared-root" }47 | { kind: "personal"; folderParts: string[]; filename: string | null; isDir: boolean }48 | { kind: "shared-spaces-list" }49 | { kind: "shared-space-root"; spaceName: string }50 | { kind: "shared-content"; spaceName: string; folderParts: string[]; filename: string | null; isDir: boolean };5152// In-memory lock store (reset on restart — sufficient for single-instance DAV)53const locks = new Map<string, string>();5455// ---------------------------------------------------------------------------56// Helpers57// ---------------------------------------------------------------------------5859function escapeXml(s: string): string {60 return s61 .replace(/&/g, "&")62 .replace(/</g, "<")63 .replace(/>/g, ">")64 .replace(/"/g, """);65}6667function parseBasicAuth(68 req: IncomingMessage69): { username: string; password: string } | null {70 const auth = req.headers["authorization"];71 if (!auth?.startsWith("Basic ")) return null;72 const decoded = Buffer.from(auth.slice(6), "base64").toString("utf-8");73 const colonIdx = decoded.indexOf(":");74 if (colonIdx === -1) return null;75 return {76 username: decoded.slice(0, colonIdx),77 password: decoded.slice(colonIdx + 1),78 };79}8081async function authenticate(req: IncomingMessage): Promise<DavUser | null> {82 const creds = parseBasicAuth(req);83 if (!creds) return null;8485 const user = await prisma.user.findFirst({86 where: {87 OR: [{ username: creds.username }, { email: creds.username }],88 },89 select: { id: true, username: true, passwordHash: true },90 });9192 if (!user) return null;93 const valid = await bcrypt.compare(creds.password, user.passwordHash);94 if (!valid) return null;9596 return { id: user.id, username: user.username ?? creds.username };97}9899async function readBody(req: IncomingMessage): Promise<Buffer> {100 return new Promise((resolve, reject) => {101 const chunks: Buffer[] = [];102 req.on("data", (c: Buffer) => chunks.push(c));103 req.on("end", () => resolve(Buffer.concat(chunks)));104 req.on("error", reject);105 });106}107108/** Strip /dav prefix, percent-decode each segment */109function getDavPath(req: IncomingMessage): string {110 const raw = (req.url || "/").split("?")[0];111 const stripped = raw.replace(/^\/dav/, "") || "/";112 return stripped113 .split("/")114 .map((p) => {115 try { return decodeURIComponent(p); } catch { return p; }116 })117 .join("/");118}119120/** Parse a decoded DAV path into a structured context */121function parseDavPath(davPath: string): DavContext {122 const isDir = davPath === "/" || davPath.endsWith("/");123 const parts = davPath.split("/").filter(Boolean);124125 // Root126 if (parts.length === 0) return { kind: "personal-root" };127128 // Shared Spaces virtual folder129 if (parts[0] === SHARED_PREFIX) {130 if (parts.length === 1) return { kind: "shared-spaces-list" };131 const spaceName = parts[1];132 if (parts.length === 2) return { kind: "shared-space-root", spaceName };133134 const rest = parts.slice(2);135 const sharedIsDir = isDir || false;136 if (sharedIsDir || rest.length === 0) {137 return { kind: "shared-content", spaceName, folderParts: rest, filename: null, isDir: true };138 }139 return {140 kind: "shared-content",141 spaceName,142 folderParts: rest.slice(0, -1),143 filename: rest[rest.length - 1],144 isDir: false,145 };146 }147148 // Personal content149 if (isDir) {150 return { kind: "personal", folderParts: parts, filename: null, isDir: true };151 }152 return {153 kind: "personal",154 folderParts: parts.slice(0, -1),155 filename: parts[parts.length - 1],156 isDir: false,157 };158}159160function notFoundError(): Error {161 const err = new Error("Not found");162 (err as NodeJS.ErrnoException).code = "NOT_FOUND";163 return err;164}165166/** Resolve personal folder path → folderId (null = root). Throws NOT_FOUND. */167async function resolvePersonalFolder(168 userId: string,169 parts: string[]170): Promise<string | null> {171 if (parts.length === 0) return null;172 let parentId: string | null = null;173 for (const part of parts) {174 const found: { id: string } | null = await prisma.folder.findFirst({175 where: { userId, name: part, parentId },176 });177 if (!found) throw notFoundError();178 parentId = found.id;179 }180 return parentId;181}182183/** Find a shared space the user is a member of, by name. */184async function findSharedSpace(userId: string, spaceName: string) {185 const membership = await prisma.sharedSpaceMember.findFirst({186 where: { userId, sharedSpace: { name: spaceName } },187 include: { sharedSpace: true },188 });189 return membership?.sharedSpace ?? null;190}191192/** Resolve folder path within a shared space → folderId (null = space root). Throws NOT_FOUND. */193async function resolveSharedFolder(194 spaceId: string,195 parts: string[]196): Promise<string | null> {197 if (parts.length === 0) return null;198 let parentId: string | null = null;199 for (const part of parts) {200 const found: { id: string } | null = await prisma.folder.findFirst({201 where: { sharedSpaceId: spaceId, name: part, parentId },202 });203 if (!found) throw notFoundError();204 parentId = found.id;205 }206 return parentId;207}208209/** Extract destination DAV path from the Destination header */210function extractDestPath(header: string): string {211 try {212 const url = new URL(header);213 return url.pathname.replace(/^\/dav/, "") || "/";214 } catch {215 return header.replace(/^\/dav/, "") || "/";216 }217}218219function decodePath(p: string): string {220 return p.split("/").map((s) => { try { return decodeURIComponent(s); } catch { return s; } }).join("/");221}222223// ---------------------------------------------------------------------------224// XML builders225// ---------------------------------------------------------------------------226227function buildMultistatusXml(items: PropItem[]): string {228 const responses = items229 .map((item) => {230 const sizeNum = item.size !== undefined ? Number(item.size) : 0;231 const typeProps = item.isDir232 ? `<D:resourcetype><D:collection/></D:resourcetype>`233 : `<D:resourcetype/><D:getcontentlength>${sizeNum}</D:getcontentlength><D:getcontenttype>${escapeXml(item.mimeType || "application/octet-stream")}</D:getcontenttype>`;234 return ` <D:response>235 <D:href>${escapeXml(item.href)}</D:href>236 <D:propstat>237 <D:prop>238 <D:displayname>${escapeXml(item.name)}</D:displayname>239 ${typeProps}240 <D:getlastmodified>${item.modifiedAt.toUTCString()}</D:getlastmodified>241 <D:creationdate>${item.createdAt.toISOString()}</D:creationdate>242 </D:prop>243 <D:status>HTTP/1.1 200 OK</D:status>244 </D:propstat>245 </D:response>`;246 })247 .join("\n");248 return `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n${responses}\n</D:multistatus>`;249}250251function buildLockXml(token: string, davPath: string, owner: string): string {252 return `<?xml version="1.0" encoding="utf-8"?>253<D:prop xmlns:D="DAV:">254 <D:lockdiscovery>255 <D:activelock>256 <D:locktype><D:write/></D:locktype>257 <D:lockscope><D:exclusive/></D:lockscope>258 <D:depth>0</D:depth>259 <D:owner>${escapeXml(owner)}</D:owner>260 <D:timeout>Second-3600</D:timeout>261 <D:locktoken><D:href>${token}</D:href></D:locktoken>262 <D:lockroot><D:href>/dav${escapeXml(davPath)}</D:href></D:lockroot>263 </D:activelock>264 </D:lockdiscovery>265</D:prop>`;266}267268// ---------------------------------------------------------------------------269// PROPFIND builders for each context270// ---------------------------------------------------------------------------271272function sharedSpaceHref(spaceName: string, folderParts: string[] = [], filename?: string): string {273 const base = `/dav/${encodeURIComponent(SHARED_PREFIX)}/${encodeURIComponent(spaceName)}`;274 const folderPath = folderParts.map(encodeURIComponent).join("/");275 if (filename) {276 return `${base}${folderPath ? "/" + folderPath : ""}/${encodeURIComponent(filename)}`;277 }278 return `${base}${folderPath ? "/" + folderPath : ""}/`;279}280281function personalHref(folderParts: string[] = [], filename?: string): string {282 const folderPath = folderParts.map(encodeURIComponent).join("/");283 if (filename) {284 return `/dav${folderPath ? "/" + folderPath : ""}/${encodeURIComponent(filename)}`;285 }286 return `/dav/${folderPath}/`;287}288289async function buildPersonalRootItems(userId: string, depth: string): Promise<PropItem[]> {290 const now = new Date();291 const items: PropItem[] = [292 { href: "/dav/", name: "SPB Cloud", isDir: true, modifiedAt: now, createdAt: now },293 ];294295 if (depth === "0") return items;296297 // Virtual "Shared Spaces" folder298 items.push({299 href: `/dav/${encodeURIComponent(SHARED_PREFIX)}/`,300 name: SHARED_PREFIX,301 isDir: true,302 modifiedAt: now,303 createdAt: now,304 });305306 const [folders, files] = await Promise.all([307 prisma.folder.findMany({ where: { userId, parentId: null } }),308 prisma.file.findMany({ where: { userId, folderId: null } }),309 ]);310311 for (const f of folders) {312 items.push({313 href: `/dav/${encodeURIComponent(f.name)}/`,314 name: f.name,315 isDir: true,316 modifiedAt: f.createdAt,317 createdAt: f.createdAt,318 });319 }320 for (const f of files) {321 items.push({322 href: `/dav/${encodeURIComponent(f.name)}`,323 name: f.name,324 isDir: false,325 size: f.size,326 mimeType: f.mimeType,327 modifiedAt: f.updatedAt,328 createdAt: f.createdAt,329 });330 }331332 return items;333}334335async function buildSharedSpacesListItems(userId: string, depth: string): Promise<PropItem[]> {336 const now = new Date();337 const items: PropItem[] = [338 {339 href: `/dav/${encodeURIComponent(SHARED_PREFIX)}/`,340 name: SHARED_PREFIX,341 isDir: true,342 modifiedAt: now,343 createdAt: now,344 },345 ];346347 if (depth === "0") return items;348349 const memberships = await prisma.sharedSpaceMember.findMany({350 where: { userId },351 include: { sharedSpace: true },352 });353354 for (const m of memberships) {355 items.push({356 href: `/dav/${encodeURIComponent(SHARED_PREFIX)}/${encodeURIComponent(m.sharedSpace.name)}/`,357 name: m.sharedSpace.name,358 isDir: true,359 modifiedAt: m.sharedSpace.updatedAt,360 createdAt: m.sharedSpace.createdAt,361 });362 }363364 return items;365}366367async function buildSharedSpaceRootItems(368 userId: string,369 spaceName: string,370 depth: string371): Promise<PropItem[]> {372 const space = await findSharedSpace(userId, spaceName);373 if (!space) throw notFoundError();374375 const now = space.updatedAt;376 const items: PropItem[] = [377 {378 href: `/dav/${encodeURIComponent(SHARED_PREFIX)}/${encodeURIComponent(spaceName)}/`,379 name: spaceName,380 isDir: true,381 modifiedAt: now,382 createdAt: space.createdAt,383 },384 ];385386 if (depth === "0") return items;387388 // Root-level folders of the space (parentId null + sharedSpaceId = spaceId)389 const rootFolders = await prisma.folder.findMany({390 where: { sharedSpaceId: space.id, parentId: null },391 });392393 for (const f of rootFolders) {394 items.push({395 href: sharedSpaceHref(spaceName, [f.name]),396 name: f.name,397 isDir: true,398 modifiedAt: f.createdAt,399 createdAt: f.createdAt,400 });401 }402403 // Files directly in the space root (folderId null, but linked via space through folder —404 // the schema has no direct space-to-file link, so no root files possible)405406 return items;407}408409async function buildSharedFolderItems(410 userId: string,411 spaceName: string,412 folderParts: string[],413 depth: string414): Promise<PropItem[]> {415 const space = await findSharedSpace(userId, spaceName);416 if (!space) throw notFoundError();417418 const folderId = await resolveSharedFolder(space.id, folderParts);419 if (folderId === null && folderParts.length > 0) throw notFoundError();420421 const folderRecord = folderId422 ? await prisma.folder.findFirst({ where: { id: folderId } })423 : null;424425 const folderHref = sharedSpaceHref(spaceName, folderParts);426 const items: PropItem[] = [427 {428 href: folderHref,429 name: folderRecord?.name ?? spaceName,430 isDir: true,431 modifiedAt: folderRecord?.createdAt ?? new Date(),432 createdAt: folderRecord?.createdAt ?? new Date(),433 },434 ];435436 if (depth === "0") return items;437438 const [subFolders, files] = await Promise.all([439 prisma.folder.findMany({ where: { sharedSpaceId: space.id, parentId: folderId } }),440 prisma.file.findMany({ where: { folderId: folderId! } }),441 ]);442443 for (const sf of subFolders) {444 items.push({445 href: sharedSpaceHref(spaceName, [...folderParts, sf.name]),446 name: sf.name,447 isDir: true,448 modifiedAt: sf.createdAt,449 createdAt: sf.createdAt,450 });451 }452 for (const f of files) {453 items.push({454 href: sharedSpaceHref(spaceName, folderParts, f.name),455 name: f.name,456 isDir: false,457 size: f.size,458 mimeType: f.mimeType,459 modifiedAt: f.updatedAt,460 createdAt: f.createdAt,461 });462 }463464 return items;465}466467// ---------------------------------------------------------------------------468// Method handlers469// ---------------------------------------------------------------------------470471async function handlePropfind(472 req: IncomingMessage,473 res: ServerResponse,474 user: DavUser,475 davPath: string476): Promise<void> {477 const depth = (req.headers["depth"] || "1") as string;478 const ctx = parseDavPath(davPath);479 let items: PropItem[] = [];480481 try {482 switch (ctx.kind) {483 case "personal-root":484 items = await buildPersonalRootItems(user.id, depth);485 break;486487 case "shared-spaces-list":488 items = await buildSharedSpacesListItems(user.id, depth);489 break;490491 case "shared-space-root":492 items = await buildSharedSpaceRootItems(user.id, ctx.spaceName, depth);493 break;494495 case "shared-content": {496 if (ctx.filename && !ctx.isDir) {497 // Single file propfind inside a shared space498 const space = await findSharedSpace(user.id, ctx.spaceName);499 if (!space) { res.writeHead(404); res.end("Not Found"); return; }500 const folderId = await resolveSharedFolder(space.id, ctx.folderParts);501 if (folderId === null) { res.writeHead(404); res.end("Not Found"); return; }502 const file = await prisma.file.findFirst({503 where: { folderId, name: ctx.filename },504 });505 if (!file) { res.writeHead(404); res.end("Not Found"); return; }506 items = [{507 href: sharedSpaceHref(ctx.spaceName, ctx.folderParts, ctx.filename),508 name: file.name,509 isDir: false,510 size: file.size,511 mimeType: file.mimeType,512 modifiedAt: file.updatedAt,513 createdAt: file.createdAt,514 }];515 } else {516 items = await buildSharedFolderItems(user.id, ctx.spaceName, ctx.folderParts, depth);517 }518 break;519 }520521 case "personal": {522 if (ctx.filename && !ctx.isDir) {523 // Single file propfind524 const folderId = await resolvePersonalFolder(user.id, ctx.folderParts);525 const file = await prisma.file.findFirst({526 where: { userId: user.id, folderId, name: ctx.filename },527 });528 if (!file) { res.writeHead(404); res.end("Not Found"); return; }529 items = [{530 href: personalHref(ctx.folderParts, ctx.filename),531 name: file.name,532 isDir: false,533 size: file.size,534 mimeType: file.mimeType,535 modifiedAt: file.updatedAt,536 createdAt: file.createdAt,537 }];538 } else {539 // Folder propfind540 const folderId = await resolvePersonalFolder(user.id, ctx.folderParts);541 const folder = folderId542 ? await prisma.folder.findFirst({ where: { id: folderId, userId: user.id } })543 : null;544545 const folderHref = ctx.folderParts.length === 0546 ? "/dav/"547 : personalHref(ctx.folderParts);548549 items = [{550 href: folderHref,551 name: folder?.name ?? "Root",552 isDir: true,553 modifiedAt: folder?.createdAt ?? new Date(),554 createdAt: folder?.createdAt ?? new Date(),555 }];556557 if (depth !== "0") {558 const [subFolders, files] = await Promise.all([559 prisma.folder.findMany({ where: { userId: user.id, parentId: folderId } }),560 prisma.file.findMany({ where: { userId: user.id, folderId } }),561 ]);562 for (const sf of subFolders) {563 items.push({564 href: personalHref([...ctx.folderParts, sf.name]),565 name: sf.name,566 isDir: true,567 modifiedAt: sf.createdAt,568 createdAt: sf.createdAt,569 });570 }571 for (const f of files) {572 items.push({573 href: personalHref(ctx.folderParts, f.name),574 name: f.name,575 isDir: false,576 size: f.size,577 mimeType: f.mimeType,578 modifiedAt: f.updatedAt,579 createdAt: f.createdAt,580 });581 }582 }583 }584 break;585 }586 }587 } catch (err: unknown) {588 if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") {589 res.writeHead(404);590 res.end("Not Found");591 return;592 }593 throw err;594 }595596 res.writeHead(207, { "Content-Type": "application/xml; charset=utf-8" });597 res.end(buildMultistatusXml(items));598}599600async function handleGet(601 req: IncomingMessage,602 res: ServerResponse,603 user: DavUser,604 davPath: string,605 headOnly: boolean606): Promise<void> {607 const ctx = parseDavPath(davPath);608609 // Virtual directories → simple response610 if (611 ctx.kind === "personal-root" ||612 ctx.kind === "shared-spaces-list" ||613 ctx.kind === "shared-space-root" ||614 (ctx.kind === "personal" && ctx.isDir) ||615 (ctx.kind === "shared-content" && ctx.isDir)616 ) {617 res.writeHead(200, { "Content-Type": "text/html" });618 res.end("<html><body>Collection</body></html>");619 return;620 }621622 try {623 let file;624 if (ctx.kind === "personal" && ctx.filename) {625 const folderId = await resolvePersonalFolder(user.id, ctx.folderParts);626 file = await prisma.file.findFirst({627 where: { userId: user.id, folderId, name: ctx.filename },628 });629 } else if (ctx.kind === "shared-content" && ctx.filename) {630 const space = await findSharedSpace(user.id, ctx.spaceName);631 if (!space) { res.writeHead(404); res.end("Not Found"); return; }632 const folderId = await resolveSharedFolder(space.id, ctx.folderParts);633 if (folderId === null) { res.writeHead(404); res.end("Not Found"); return; }634 file = await prisma.file.findFirst({ where: { folderId, name: ctx.filename } });635 }636637 if (!file) { res.writeHead(404); res.end("Not Found"); return; }638639 const filePath = getFilePath(file.storagePath);640 if (!existsSync(filePath)) { res.writeHead(404); res.end("Not Found"); return; }641642 const stats = statSync(filePath);643 res.setHeader("Content-Type", file.mimeType || "application/octet-stream");644 res.setHeader("Content-Length", stats.size);645 res.setHeader("Last-Modified", file.updatedAt.toUTCString());646 res.setHeader("ETag", `"${file.id}"`);647 res.writeHead(200);648649 if (headOnly) {650 res.end();651 } else {652 getFileStream(file.storagePath).pipe(res);653 }654 } catch (err: unknown) {655 if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") {656 res.writeHead(404);657 res.end("Not Found");658 return;659 }660 throw err;661 }662}663664async function handlePut(665 req: IncomingMessage,666 res: ServerResponse,667 user: DavUser,668 davPath: string669): Promise<void> {670 const ctx = parseDavPath(davPath);671672 const filename =673 ctx.kind === "personal"674 ? ctx.filename675 : ctx.kind === "shared-content"676 ? ctx.filename677 : null;678679 if (!filename) {680 res.writeHead(400);681 res.end("Bad Request");682 return;683 }684685 // Discard macOS metadata noise686 if (filename.startsWith("._") || filename === ".DS_Store" || filename === ".localized") {687 await readBody(req);688 res.writeHead(204);689 res.end();690 return;691 }692693 const body = await readBody(req);694 const mimeType =695 (mimeTypeLookup(filename) as string | false) || "application/octet-stream";696697 try {698 if (ctx.kind === "personal") {699 const folderId = await resolvePersonalFolder(user.id, ctx.folderParts);700 const existing = await prisma.file.findFirst({701 where: { userId: user.id, folderId, name: filename },702 });703 if (existing) {704 const { storagePath, size } = await saveFile(body, filename);705 const old = existing.storagePath;706 await prisma.file.update({707 where: { id: existing.id },708 data: { storagePath, size: BigInt(size), updatedAt: new Date() },709 });710 await deleteFile(old);711 await logActivity("UPLOAD", existing.id, `WebDAV update: ${filename}`);712 res.writeHead(204);713 } else {714 const { storagePath, size } = await saveFile(body, filename);715 const f = await prisma.file.create({716 data: { name: filename, storagePath, size: BigInt(size), mimeType, folderId, userId: user.id },717 });718 await logActivity("UPLOAD", f.id, `WebDAV upload: ${filename}`);719 res.writeHead(201);720 }721 res.end();722 return;723 }724725 if (ctx.kind === "shared-content") {726 const space = await findSharedSpace(user.id, ctx.spaceName);727 if (!space) { res.writeHead(403); res.end("Forbidden: not a member"); return; }728729 if (ctx.folderParts.length === 0) {730 // Can't put files directly at space root (no sharedSpaceId on File)731 res.writeHead(409);732 res.end("Conflict: create a folder first, then upload files into it");733 return;734 }735736 const folderId = await resolveSharedFolder(space.id, ctx.folderParts);737 if (folderId === null) { res.writeHead(404); res.end("Parent folder not found"); return; }738739 const existing = await prisma.file.findFirst({ where: { folderId, name: filename } });740 if (existing) {741 const { storagePath, size } = await saveFile(body, filename);742 const old = existing.storagePath;743 await prisma.file.update({744 where: { id: existing.id },745 data: { storagePath, size: BigInt(size), updatedAt: new Date() },746 });747 await deleteFile(old);748 await logActivity("UPLOAD", existing.id, `WebDAV shared update: ${filename}`);749 res.writeHead(204);750 } else {751 const { storagePath, size } = await saveFile(body, filename);752 const f = await prisma.file.create({753 data: { name: filename, storagePath, size: BigInt(size), mimeType, folderId, userId: user.id },754 });755 await logActivity("UPLOAD", f.id, `WebDAV shared upload: ${filename} in ${ctx.spaceName}`);756 res.writeHead(201);757 }758 res.end();759 return;760 }761762 res.writeHead(400);763 res.end("Bad Request");764 } catch (err: unknown) {765 if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") {766 res.writeHead(409);767 res.end("Conflict: parent folder not found");768 return;769 }770 throw err;771 }772}773774async function handleDelete(775 req: IncomingMessage,776 res: ServerResponse,777 user: DavUser,778 davPath: string779): Promise<void> {780 const ctx = parseDavPath(davPath);781782 // Protect virtual roots783 if (784 ctx.kind === "personal-root" ||785 ctx.kind === "shared-spaces-list" ||786 ctx.kind === "shared-space-root"787 ) {788 res.writeHead(403);789 res.end("Forbidden");790 return;791 }792793 try {794 if (ctx.kind === "personal") {795 if (!ctx.filename || ctx.isDir) {796 // Delete folder797 const folderId = await resolvePersonalFolder(user.id, ctx.folderParts);798 if (!folderId) { res.writeHead(403); res.end("Cannot delete root"); return; }799 await trashFolderRecursive(folderId);800 await logActivity("DELETE_FOLDER", undefined, `WebDAV delete folder: ${ctx.folderParts.join("/")}`);801 } else {802 const folderId = await resolvePersonalFolder(user.id, ctx.folderParts);803 const file = await prisma.file.findFirst({804 where: { userId: user.id, folderId, name: ctx.filename },805 });806 if (!file) { res.writeHead(404); res.end("Not Found"); return; }807 await trashFiles([file.id]);808 }809 }810811 if (ctx.kind === "shared-content") {812 const space = await findSharedSpace(user.id, ctx.spaceName);813 if (!space) { res.writeHead(403); res.end("Forbidden"); return; }814815 if (!ctx.filename || ctx.isDir) {816 const folderId = await resolveSharedFolder(space.id, ctx.folderParts);817 if (!folderId) { res.writeHead(403); res.end("Cannot delete space root"); return; }818 await trashFolderRecursive(folderId);819 await logActivity("DELETE_FOLDER", undefined, `WebDAV delete shared folder: ${ctx.folderParts.join("/")} in ${ctx.spaceName}`);820 } else {821 const folderId = await resolveSharedFolder(space.id, ctx.folderParts);822 if (folderId === null) { res.writeHead(404); res.end("Not Found"); return; }823 const file = await prisma.file.findFirst({ where: { folderId, name: ctx.filename } });824 if (!file) { res.writeHead(404); res.end("Not Found"); return; }825 await trashFiles([file.id]);826 }827 }828829 res.writeHead(204);830 res.end();831 } catch (err: unknown) {832 if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") {833 res.writeHead(404);834 res.end("Not Found");835 return;836 }837 throw err;838 }839}840841async function handleMkcol(842 req: IncomingMessage,843 res: ServerResponse,844 user: DavUser,845 davPath: string846): Promise<void> {847 // Use raw parts directly — parseDavPath has path ambiguity for MKCOL848 // since MKCOL paths never have a trailing slash yet refer to a directory.849 const rawParts = davPath.split("/").filter(Boolean);850851 if (rawParts.length === 0) {852 res.writeHead(405);853 res.end("Cannot create root");854 return;855 }856857 // Protect virtual roots858 if (rawParts.length === 1 && rawParts[0] === SHARED_PREFIX) {859 res.writeHead(403);860 res.end("Forbidden");861 return;862 }863864 try {865 if (rawParts[0] === SHARED_PREFIX) {866 // Shared space MKCOL: /Shared Spaces/{spaceName}/{...folderPath}867 if (rawParts.length < 3) {868 res.writeHead(403);869 res.end("Forbidden: cannot create shared spaces via WebDAV");870 return;871 }872 const spaceName = rawParts[1];873 const space = await findSharedSpace(user.id, spaceName);874 if (!space) { res.writeHead(403); res.end("Forbidden: not a member"); return; }875876 const pathInSpace = rawParts.slice(2); // e.g. ["Documents"] or ["Documents", "SubFolder"]877 const folderName = pathInSpace[pathInSpace.length - 1];878 const parentParts = pathInSpace.slice(0, -1);879880 const parentId = await resolveSharedFolder(space.id, parentParts);881 const existing = await prisma.folder.findFirst({882 where: { sharedSpaceId: space.id, parentId, name: folderName },883 });884 if (existing) { res.writeHead(405); res.end("Already exists"); return; }885886 await prisma.folder.create({887 data: { name: folderName, parentId, userId: user.id, sharedSpaceId: space.id },888 });889 await logActivity("CREATE_FOLDER", undefined, `WebDAV shared mkdir: ${folderName} in ${spaceName}`);890 res.writeHead(201);891 res.end();892 return;893 }894895 // Personal MKCOL: /{...folderPath}896 const folderName = rawParts[rawParts.length - 1];897 const parentParts = rawParts.slice(0, -1);898 const parentId = await resolvePersonalFolder(user.id, parentParts);899900 const existing = await prisma.folder.findFirst({901 where: { userId: user.id, parentId, name: folderName },902 });903 if (existing) { res.writeHead(405); res.end("Already exists"); return; }904905 await prisma.folder.create({ data: { name: folderName, parentId, userId: user.id } });906 await logActivity("CREATE_FOLDER", undefined, `WebDAV mkdir: ${folderName}`);907 res.writeHead(201);908 res.end();909 } catch (err: unknown) {910 if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") {911 res.writeHead(409);912 res.end("Conflict: parent not found");913 return;914 }915 throw err;916 }917}918919async function handleMove(920 req: IncomingMessage,921 res: ServerResponse,922 user: DavUser,923 davPath: string924): Promise<void> {925 const destination = req.headers["destination"] as string;926 if (!destination) { res.writeHead(400); res.end("Bad Request"); return; }927928 const destPath = decodePath(extractDestPath(destination));929 const src = parseDavPath(davPath);930 const dst = parseDavPath(destPath);931932 // Only support moves within the same zone (personal→personal or shared→shared same space)933 if (src.kind === "personal-root" || src.kind === "shared-spaces-list" || src.kind === "shared-space-root") {934 res.writeHead(403);935 res.end("Forbidden");936 return;937 }938939 try {940 if (src.kind === "personal" && dst.kind === "personal") {941 if (src.filename && !src.isDir) {942 const srcFolderId = await resolvePersonalFolder(user.id, src.folderParts);943 const file = await prisma.file.findFirst({944 where: { userId: user.id, folderId: srcFolderId, name: src.filename },945 });946 if (!file) { res.writeHead(404); res.end("Not Found"); return; }947 const destFolderId = await resolvePersonalFolder(user.id, dst.folderParts);948 await prisma.file.update({949 where: { id: file.id },950 data: { name: dst.filename || src.filename, folderId: destFolderId },951 });952 await logActivity("MOVE", file.id, `WebDAV move: ${src.filename} → ${dst.filename}`);953 } else {954 const folderId = await resolvePersonalFolder(user.id, src.folderParts);955 if (!folderId) { res.writeHead(403); res.end("Cannot move root"); return; }956 const newName =957 dst.kind === "personal" && dst.folderParts.length > 0958 ? dst.folderParts[dst.folderParts.length - 1]959 : src.folderParts[src.folderParts.length - 1];960 const newParentParts = dst.kind === "personal" ? dst.folderParts.slice(0, -1) : [];961 const newParentId =962 newParentParts.length > 0 ? await resolvePersonalFolder(user.id, newParentParts) : null;963 await prisma.folder.update({ where: { id: folderId }, data: { name: newName, parentId: newParentId } });964 await logActivity("MOVE", undefined, `WebDAV move folder: ${src.folderParts.join("/")} → ${newName}`);965 }966 res.writeHead(204);967 res.end();968 return;969 }970971 if (972 src.kind === "shared-content" &&973 dst.kind === "shared-content" &&974 src.spaceName === dst.spaceName975 ) {976 const space = await findSharedSpace(user.id, src.spaceName);977 if (!space) { res.writeHead(403); res.end("Forbidden"); return; }978979 if (src.filename && !src.isDir) {980 const srcFolderId = await resolveSharedFolder(space.id, src.folderParts);981 if (srcFolderId === null) { res.writeHead(404); res.end("Not Found"); return; }982 const file = await prisma.file.findFirst({ where: { folderId: srcFolderId, name: src.filename } });983 if (!file) { res.writeHead(404); res.end("Not Found"); return; }984 const destFolderId = await resolveSharedFolder(space.id, dst.folderParts);985 if (destFolderId === null) { res.writeHead(404); res.end("Not Found"); return; }986 await prisma.file.update({987 where: { id: file.id },988 data: { name: dst.filename || src.filename, folderId: destFolderId },989 });990 await logActivity("MOVE", file.id, `WebDAV shared move: ${src.filename} → ${dst.filename}`);991 } else {992 const folderId = await resolveSharedFolder(space.id, src.folderParts);993 if (!folderId) { res.writeHead(403); res.end("Cannot move space root"); return; }994 const newName = dst.folderParts[dst.folderParts.length - 1] ?? src.folderParts[src.folderParts.length - 1];995 const newParentParts = dst.folderParts.slice(0, -1);996 const newParentId =997 newParentParts.length > 0 ? await resolveSharedFolder(space.id, newParentParts) : null;998 await prisma.folder.update({ where: { id: folderId }, data: { name: newName, parentId: newParentId } });999 await logActivity("MOVE", undefined, `WebDAV shared move folder in ${src.spaceName}`);1000 }1001 res.writeHead(204);1002 res.end();1003 return;1004 }10051006 // Cross-zone moves not supported1007 res.writeHead(403);1008 res.end("Forbidden: cannot move across zones (personal ↔ shared)");1009 } catch (err: unknown) {1010 if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") {1011 res.writeHead(404);1012 res.end("Not Found");1013 return;1014 }1015 throw err;1016 }1017}10181019async function handleCopy(1020 req: IncomingMessage,1021 res: ServerResponse,1022 user: DavUser,1023 davPath: string1024): Promise<void> {1025 const destination = req.headers["destination"] as string;1026 if (!destination) { res.writeHead(400); res.end("Bad Request"); return; }10271028 const destPath = decodePath(extractDestPath(destination));1029 const src = parseDavPath(davPath);1030 const dst = parseDavPath(destPath);10311032 const srcFilename = src.kind === "personal" || src.kind === "shared-content" ? src.filename : null;1033 if (!srcFilename) { res.writeHead(400); res.end("Cannot copy a directory"); return; }10341035 try {1036 let srcFile;1037 if (src.kind === "personal") {1038 const folderId = await resolvePersonalFolder(user.id, src.folderParts);1039 srcFile = await prisma.file.findFirst({ where: { userId: user.id, folderId, name: srcFilename } });1040 } else if (src.kind === "shared-content") {1041 const space = await findSharedSpace(user.id, src.spaceName);1042 if (!space) { res.writeHead(403); res.end("Forbidden"); return; }1043 const folderId = await resolveSharedFolder(space.id, src.folderParts);1044 if (folderId === null) { res.writeHead(404); res.end("Not Found"); return; }1045 srcFile = await prisma.file.findFirst({ where: { folderId, name: srcFilename } });1046 }10471048 if (!srcFile) { res.writeHead(404); res.end("Not Found"); return; }10491050 const destFilename =1051 dst.kind === "personal" || dst.kind === "shared-content"1052 ? dst.filename || srcFilename1053 : srcFilename;10541055 const { storagePath, size } = await duplicateFile(srcFile.storagePath, destFilename);10561057 let destFolderId: string | null = null;1058 if (dst.kind === "personal") {1059 destFolderId = await resolvePersonalFolder(user.id, dst.folderParts);1060 await prisma.file.create({1061 data: { name: destFilename, storagePath, size: BigInt(size), mimeType: srcFile.mimeType, folderId: destFolderId, userId: user.id },1062 });1063 } else if (dst.kind === "shared-content") {1064 const space = await findSharedSpace(user.id, dst.spaceName);1065 if (!space) { res.writeHead(403); res.end("Forbidden"); return; }1066 destFolderId = await resolveSharedFolder(space.id, dst.folderParts);1067 if (destFolderId === null) { res.writeHead(404); res.end("Destination folder not found"); return; }1068 await prisma.file.create({1069 data: { name: destFilename, storagePath, size: BigInt(size), mimeType: srcFile.mimeType, folderId: destFolderId, userId: user.id },1070 });1071 }10721073 res.writeHead(201);1074 res.end();1075 } catch (err: unknown) {1076 if ((err as NodeJS.ErrnoException).code === "NOT_FOUND") {1077 res.writeHead(404);1078 res.end("Not Found");1079 return;1080 }1081 throw err;1082 }1083}10841085async function handleLock(1086 req: IncomingMessage,1087 res: ServerResponse,1088 user: DavUser,1089 davPath: string1090): Promise<void> {1091 const token = `opaquelocktoken:${randomUUID()}`;1092 locks.set(davPath, token);1093 res.setHeader("Lock-Token", `<${token}>`);1094 res.writeHead(200, { "Content-Type": "application/xml; charset=utf-8" });1095 res.end(buildLockXml(token, davPath, user.username));1096}10971098// ---------------------------------------------------------------------------1099// Main dispatcher1100// ---------------------------------------------------------------------------11011102export async function handleWebDAV(1103 req: IncomingMessage,1104 res: ServerResponse1105): Promise<void> {1106 const user = await authenticate(req);1107 if (!user) {1108 res.setHeader("WWW-Authenticate", 'Basic realm="SPB Cloud"');1109 res.writeHead(401, { "Content-Type": "text/plain" });1110 res.end("Unauthorized");1111 return;1112 }11131114 const method = req.method?.toUpperCase() || "GET";1115 const davPath = getDavPath(req);11161117 res.setHeader("DAV", "1, 2");1118 res.setHeader("MS-Author-Via", "DAV");1119 res.setHeader(1120 "Allow",1121 "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH, LOCK, UNLOCK"1122 );11231124 try {1125 switch (method) {1126 case "OPTIONS":1127 res.writeHead(200);1128 res.end();1129 break;1130 case "PROPFIND":1131 await handlePropfind(req, res, user, davPath);1132 break;1133 case "PROPPATCH":1134 res.writeHead(207, { "Content-Type": "application/xml; charset=utf-8" });1135 res.end(1136 `<?xml version="1.0"?><D:multistatus xmlns:D="DAV:"><D:response><D:href>${escapeXml("/dav" + davPath)}</D:href><D:propstat><D:prop/><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response></D:multistatus>`1137 );1138 break;1139 case "GET":1140 await handleGet(req, res, user, davPath, false);1141 break;1142 case "HEAD":1143 await handleGet(req, res, user, davPath, true);1144 break;1145 case "PUT":1146 await handlePut(req, res, user, davPath);1147 break;1148 case "DELETE":1149 await handleDelete(req, res, user, davPath);1150 break;1151 case "MKCOL":1152 await handleMkcol(req, res, user, davPath);1153 break;1154 case "MOVE":1155 await handleMove(req, res, user, davPath);1156 break;1157 case "COPY":1158 await handleCopy(req, res, user, davPath);1159 break;1160 case "LOCK":1161 await handleLock(req, res, user, davPath);1162 break;1163 case "UNLOCK":1164 res.writeHead(204);1165 res.end();1166 break;1167 default:1168 res.writeHead(405);1169 res.end("Method Not Allowed");1170 }1171 } catch (error) {1172 console.error("[WebDAV]", method, davPath, error);1173 if (!res.headersSent) {1174 res.writeHead(500);1175 res.end("Internal Server Error");1176 }1177 }1178}1179