SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
1 h agolast push
JavaScript 56.7% TypeScript 42.6%
38.6 KB · 1,179 lines typescript
Raw Blame History
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, "&amp;")62    .replace(/</g, "&lt;")63    .replace(/>/g, "&gt;")64    .replace(/"/g, "&quot;");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