/* * Copyright (C) 2024-present Puter Technologies Inc. * * This file is part of Puter. * * Puter is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import { compare as bcryptCompare } from 'bcrypt'; import type { Request, Response } from 'express'; import { posix as pathPosix } from 'node:path'; import { EventMap } from '../../clients/event/types.js'; import { makeActor, type Actor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; import { assertNotSuspended, assertVerifiedAccount, } from '../../core/http/middleware/gates.js'; import type { PuterRouter } from '../../core/http/PuterRouter.js'; import { verify as verifyOtp } from '../../services/auth/OTPUtil.js'; import { expandTildePath } from '../../services/fs/resolveNode.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; import { toLegacyEntry } from '../fs/legacyFsHelpers.js'; import { PuterController } from '../types.js'; import { createLock, deleteLock, extractLockToken, getFileLocks, getLockIfValid, hasWritePermission, refreshLock, } from './locks.js'; import { DAV_CONCURRENT, DAV_LIMIT } from '../fs/limits.js'; import { acquireConcurrent, checkRateLimit, computeNetworkFingerprint, } from '../../core/http/middleware/rateLimit.js'; import { assertActorHasCredits } from '../../services/metering/enforcement.js'; const DAV_HEADERS = { DAV: '1, 2, ordered-collections', 'MS-Author-Via': 'DAV', }; const ALLOW_METHODS = 'OPTIONS, GET, HEAD, POST, PUT, DELETE, COPY, MOVE, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, TRACE'; // macOS creates these files; reject them to keep the FS clean. const MACOS_JUNK_REGEX = /(?:^\.DS_Store$|^\._)/; /** * Verbs that move file content or duplicate it in the object store, and so are * refused to an account with nothing left of its budget. HEAD is here with GET * because a client asking for headers is a client about to fetch the body. */ const CREDIT_GATED_DAV_METHODS = new Set(['GET', 'HEAD', 'PUT', 'COPY']); /** * WebDAV controller — full RFC 4918 surface on the `dav.*` subdomain. * * All FS operations go through v2's FSService + S3ObjectStore. Locking uses * Redis (see `./locks.ts`). ACL is enforced via ACLService before every * mutation and read. * * Auth: HTTP Basic → parse credentials → verify via AuthService + bcrypt (or * `-token` username for token-based auth). Falls back to the global authProbe's * `req.actor` if a session cookie is present. */ export class WebDAVController extends PuterController { registerRoutes(router: PuterRouter): void { // Single catch-all on the `dav` subdomain. We dispatch by req.method // inside the handler because WebDAV uses non-standard HTTP verbs that // Express doesn't have first-class router methods for in all versions. // // The rate limit is applied inside the handler rather than through // `RouteOptions`. For a `use` mount the subdomain check lives in the // handler wrapper, not in the middleware chain — so a `rateLimit` // here would run for every request on every subdomain and count // non-DAV traffic against the DAV budget. router.use( { subdomain: 'dav' }, async (req: Request, res: Response, _next) => { try { if (!(await this.#admit(req, res))) return; await this.#dispatch(req, res); } catch (err) { if (err instanceof HttpError) { res.status(err.statusCode).send(err.message); return; } console.error('[webdav] unhandled error', err); res.status(500).send('Internal Server Error'); } // Don't call next — we always handle or error. }, ); } /** * Rate + concurrency gate for the whole DAV surface. Returns false when the * request was rejected (429 already sent). * * Runs before `#dispatch` authenticates, so it keys on the network * fingerprint rather than an actor. That is the coarser bucket, but a DAV * client sends credentials on every request anyway — there is no * unauthenticated browsing phase to protect a per-user key from. */ async #admit(req: Request, res: Response): Promise { const key = computeNetworkFingerprint(req); if ( !(await checkRateLimit( `${DAV_LIMIT.scope}:${key}`, DAV_LIMIT.limit, DAV_LIMIT.window, )) ) { res.status(429).send('Too many requests.'); return false; } const slot = await acquireConcurrent( `${DAV_CONCURRENT.scope}:${key}`, DAV_CONCURRENT.limit, ); if (!slot.ok) { res.status(429).send('Too many concurrent requests.'); return false; } // `finish` and `close` can both fire; release is once-only. res.once('finish', () => void slot.release()); res.once('close', () => void slot.release()); return true; } async #dispatch(req: Request, res: Response): Promise { // Authenticate const actor = await this.#resolveActor(req, res); if (!actor) return; // 401 already sent // Apply the same suspension + pending-verification gates every other // authenticated route gets from `requireAuthGate` / `requireVerifiedAccount`. // WebDAV dispatches every method off a single `router.use` with no route // options, so that middleware is never inserted into its chain — without // these calls a suspended account (or one still pending email / phone / // card verification) could read, write, and delete its entire filesystem // over the `dav` subdomain, bypassing the gates. Both throw a 403 // HttpError, surfaced by the catch in registerRoutes. assertNotSuspended(actor.user); assertVerifiedAccount(actor.user); // And the same budget gate the FS routes declare with // `requireCredits`, for the verbs that move content — DAV serves the // same files over a metered host, so leaving it out would make mounting // the drive the way around enforcement. The verbs that only describe or // remove things stay open, as they do over HTTP. if (CREDIT_GATED_DAV_METHODS.has(req.method.toUpperCase())) { await assertActorHasCredits( this.services.metering, actor, this.config, ); } // Expand `~`/`~/...` against the authenticated actor's username. // WebDAV doesn't standardize `~`, but some clients do — and the // pre-existing behaviour silently expanded it via the FS store. const davPath = expandTildePath( decodeURIComponent(req.path), actor.user.username, ); const redis = this.clients.redis; const lockToken = extractLockToken( (req.headers['if'] as string | undefined) ?? (req.headers['lock-token'] as string | undefined), ); switch (req.method.toUpperCase()) { case 'OPTIONS': return this.#options(res); case 'HEAD': case 'GET': return this.#get( req, res, actor, davPath, req.method === 'HEAD', ); case 'PROPFIND': return this.#propfind(req, res, actor, davPath); case 'PROPPATCH': return this.#proppatch(res, davPath, redis, lockToken); case 'MKCOL': return this.#mkcol(req, res, actor, davPath, redis, lockToken); case 'PUT': return this.#put(req, res, actor, davPath, redis, lockToken); case 'DELETE': return this.#delete(res, actor, davPath, redis, lockToken); case 'COPY': return this.#copy(req, res, actor, davPath, redis, lockToken); case 'MOVE': return this.#move(req, res, actor, davPath, redis, lockToken); case 'LOCK': return this.#lock(req, res, actor, davPath, redis, lockToken); case 'UNLOCK': return this.#unlock(req, res, davPath, redis); default: res.status(405) .set('Allow', ALLOW_METHODS) .send('Method Not Allowed'); } } // -- Auth --------------------------------------------------------- async #resolveActor(req: Request, res: Response): Promise { // If the global authProbe already resolved an actor, use it. if (req.actor?.user) return req.actor; // Parse HTTP Basic const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Basic ')) { res.status(401) .set({ 'WWW-Authenticate': 'Basic realm="WebDAV"', ...DAV_HEADERS, }) .send('Authentication required'); return null; } const decoded = Buffer.from(authHeader.slice(6), 'base64').toString( 'utf-8', ); const colonIdx = decoded.indexOf(':'); if (colonIdx < 0) { res.status(401) .set('WWW-Authenticate', 'Basic realm="WebDAV"') .send('Invalid credentials'); return null; } const username = decoded.slice(0, colonIdx); const password = decoded.slice(colonIdx + 1); // `-token` username: password IS the auth token if (username === '-token') { const actor = await this.services.auth.authenticateFromToken(password); if (!actor) { res.status(401) .set('WWW-Authenticate', 'Basic realm="WebDAV"') .send('Invalid token'); return null; } return actor; } // Regular username + password (with optional 6-digit OTP suffix) const user = await this.stores.user.getByUsername(username); if (!user || !user.password) { res.status(401) .set('WWW-Authenticate', 'Basic realm="WebDAV"') .send('Invalid credentials'); return null; } // If 2FA is enabled the password MUST be suffixed with the 6-digit // TOTP code — HTTP Basic has no channel for a second factor. const otpEnabled = Boolean(user.otp_enabled); let passwordOk = false; if (otpEnabled) { if (password.length <= 6) { res.status(401) .set('WWW-Authenticate', 'Basic realm="WebDAV"') .send('Invalid credentials'); return null; } const basePassword = password.slice(0, -6); const otpCode = password.slice(-6); const baseOk = await bcryptCompare(basePassword, user.password); const otpOk = baseOk && typeof user.otp_secret === 'string' && verifyOtp(user.username, user.otp_secret, otpCode); passwordOk = Boolean(otpOk); } else { passwordOk = await bcryptCompare(password, user.password); } if (!passwordOk) { res.status(401) .set('WWW-Authenticate', 'Basic realm="WebDAV"') .send('Invalid credentials'); return null; } // Build a session-less actor for the user return makeActor({ user: { id: user.id, uuid: user.uuid, username: user.username, email: user.email ?? null, suspended: user.suspended ?? false, email_confirmed: user.email_confirmed ?? false, requires_email_confirmation: user.requires_email_confirmation ?? false, // Carry the signup-time verification flags so the // assertVerifiedAccount() gate in #dispatch can see them on // the Basic-auth path too (the cookie / `-token` paths get // them from AuthService#actorUserFromRow). Omitting these is // what let phone/card-gated accounts through WebDAV. requires_phone_verification: user.requires_phone_verification ?? false, requires_card_verification: user.requires_card_verification ?? false, }, }); } // -- OPTIONS ------------------------------------------------------ #options(res: Response): void { res.status(200) .set({ Allow: ALLOW_METHODS, ...DAV_HEADERS, 'Accept-Ranges': 'bytes', 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache', }) .send(''); } // -- GET / HEAD -------------------------------------------------- async #get( req: Request, res: Response, actor: Actor, davPath: string, headOnly: boolean, ): Promise { const entry = await this.stores.fsEntry.getEntryByPath(davPath); if (!entry) throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' }); if (entry.isDir) throw new HttpError(400, 'Cannot GET a directory', { legacyCode: 'bad_request', }); await this.#assertRead(actor, davPath); const etag = `"${entry.uuid}-${Math.floor(entry.modified ?? entry.created ?? 0)}"`; const size = entry.size ?? 0; res.set({ 'Accept-Ranges': 'bytes', 'Content-Length': String(size), 'Last-Modified': new Date( entry.modified ?? entry.created ?? 0, ).toUTCString(), ETag: etag, }); if (headOnly) { res.status(200).end(); return; } const rangeHeader = req.headers.range; const result = await this.services.fs.readContent(entry, { range: rangeHeader, }); if (result.contentType) res.set('Content-Type', result.contentType); if (result.contentRange) { res.status(206).set({ 'Content-Range': result.contentRange, 'Content-Length': String(result.contentLength ?? 0), }); } result.body.pipe(res); } // -- PROPFIND ---------------------------------------------------- async #propfind( req: Request, res: Response, actor: Actor, davPath: string, ): Promise { const depth = req.headers.depth ?? '1'; const entry = davPath === '/' ? null // root always exists : await this.stores.fsEntry.getEntryByPath(davPath); if (davPath !== '/' && !entry) throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' }); await this.#assertRead(actor, davPath); const isDir = davPath === '/' || !!entry?.isDir; const responses = [propfindEntry(davPath, entry, isDir)]; if (depth !== '0' && isDir && entry) { const children = await this.services.fs.listDirectory( entry.uuid, {}, ); for (const child of children) { responses.push(propfindEntry(child.path, child, child.isDir)); } } else if (depth !== '0' && davPath === '/') { // Root: list top-level user directories const rootEntry = await this.stores.fsEntry.getEntryByPath( `/${actor.user!.username}`, ); if (rootEntry) { responses.push( propfindEntry(rootEntry.path, rootEntry, rootEntry.isDir), ); } } res.status(207) .set({ 'Content-Type': 'application/xml; charset=utf-8' }) .send(wrapMultistatus(responses.join('\n'))); } // -- PROPPATCH (stub — acknowledges but doesn't persist props) --- async #proppatch( res: Response, davPath: string, redis: unknown, lockToken: string | null, ): Promise { if ( !(await hasWritePermission( redis as import('ioredis').Cluster, davPath, lockToken, )) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } res.status(207) .set({ 'Content-Type': 'application/xml; charset=utf-8' }) .send( `\n${escapeXml(encodeURI(davPath))}HTTP/1.1 200 OK`, ); } // -- MKCOL ------------------------------------------------------- async #mkcol( req: Request, res: Response, actor: Actor, davPath: string, redis: unknown, lockToken: string | null, ): Promise { if (davPath === '/') throw new HttpError(403, 'Cannot create at root', { legacyCode: 'forbidden', }); if ( req.headers['content-length'] && Number(req.headers['content-length']) > 0 ) { throw new HttpError(415, 'MKCOL must not have a body', { legacyCode: 'bad_request', }); } if ( !(await hasWritePermission( redis as import('ioredis').Cluster, davPath, lockToken, )) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } const userId = actor.user!.id as number; const parentPath = pathPosix.dirname(davPath); await this.#assertWrite(actor, parentPath); const existing = await this.stores.fsEntry.getEntryByPath(davPath); if (existing) throw new HttpError(405, 'Already exists', { legacyCode: 'bad_request', }); const entry = await this.services.fs.mkdir(userId, { path: davPath, }); this.#emitGuiEvent('outer.gui.item.added', entry); res.status(201) .set({ 'Content-Length': '0', Location: `${davPath}/` }) .end(); } // -- PUT --------------------------------------------------------- async #put( req: Request, res: Response, actor: Actor, davPath: string, redis: unknown, lockToken: string | null, ): Promise { const name = pathPosix.basename(davPath); if (MACOS_JUNK_REGEX.test(name)) { res.status(422).send('Ignored macOS metadata file'); return; } if ( !(await hasWritePermission( redis as import('ioredis').Cluster, davPath, lockToken, )) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } const userId = actor.user!.id as number; const parentPath = pathPosix.dirname(davPath); await this.#assertWrite(actor, parentPath); const contentLength = Number( req.headers['content-length'] ?? req.headers['x-expected-entity-length'] ?? 0, ); if (!contentLength && contentLength !== 0) throw new HttpError(400, 'Missing Content-Length', { legacyCode: 'bad_request', }); // Check if overwrite const existing = await this.stores.fsEntry.getEntryByPath(davPath); // Expect: 100-continue if (req.headers.expect === '100-continue') { (req.socket as { write?: (s: string) => void }).write?.( 'HTTP/1.1 100 Continue\r\n\r\n', ); } const writeResult = await this.services.fs.write(userId, { fileMetadata: { path: davPath, size: contentLength, overwrite: true, createMissingParents: true, }, fileContent: req, }); this.#emitGuiEvent( existing ? 'outer.gui.item.updated' : 'outer.gui.item.added', writeResult.fsEntry, ); const fe = writeResult.fsEntry; const etag = `"${fe.uuid}-${Math.floor(fe.modified ?? fe.created ?? 0)}"`; res.status(existing ? 204 : 201) .set({ ETag: etag, 'Last-Modified': new Date( fe.modified ?? fe.created ?? 0, ).toUTCString(), }) .end(); } // -- DELETE ------------------------------------------------------- async #delete( res: Response, actor: Actor, davPath: string, redis: unknown, lockToken: string | null, ): Promise { if ( !(await hasWritePermission( redis as import('ioredis').Cluster, davPath, lockToken, )) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } const userId = actor.user!.id as number; await this.#assertWrite(actor, davPath); const entry = await this.stores.fsEntry.getEntryByPath(davPath); if (!entry) throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' }); await this.services.fs.remove(userId, { entry, recursive: true }); this.#emitGuiEvent('outer.gui.item.removed', entry); res.status(204).end(); } // -- COPY -------------------------------------------------------- async #copy( req: Request, res: Response, actor: Actor, davPath: string, redis: unknown, lockToken: string | null, ): Promise { const destPath = this.#parseDestination(req); if ( !(await hasWritePermission( redis as import('ioredis').Cluster, destPath, lockToken, )) ) { throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); } const userId = actor.user!.id as number; await this.#assertRead(actor, davPath); await this.#assertWrite(actor, pathPosix.dirname(destPath)); const source = await this.stores.fsEntry.getEntryByPath(davPath); if (!source) throw new HttpError(404, 'Source not found', { legacyCode: 'not_found', }); const overwrite = req.headers.overwrite !== 'F'; const destExists = await this.stores.fsEntry.getEntryByPath(destPath); if (destExists && !overwrite) throw new HttpError(412, 'Destination exists and Overwrite=F', { legacyCode: 'conflict', }); const destParent = await this.stores.fsEntry.getEntryByPath( pathPosix.dirname(destPath), ); if (!destParent?.isDir) throw new HttpError( 409, 'Destination parent missing or not a directory', { legacyCode: 'dest_is_not_a_directory' }, ); const copy = await this.services.fs.copy(userId, { source, destinationParent: destParent, newName: pathPosix.basename(destPath), overwrite, }); this.#emitGuiEvent('outer.gui.item.added', copy); res.status(destExists ? 204 : 201).end(); } // -- MOVE -------------------------------------------------------- async #move( req: Request, res: Response, actor: Actor, davPath: string, redis: unknown, lockToken: string | null, ): Promise { const destPath = this.#parseDestination(req); const r = redis as import('ioredis').Cluster; if (!(await hasWritePermission(r, davPath, lockToken))) throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); if (!(await hasWritePermission(r, destPath, lockToken))) throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); const userId = actor.user!.id as number; await this.#assertWrite(actor, davPath); await this.#assertWrite(actor, pathPosix.dirname(destPath)); const source = await this.stores.fsEntry.getEntryByPath(davPath); if (!source) throw new HttpError(404, 'Source not found', { legacyCode: 'not_found', }); const overwrite = req.headers.overwrite !== 'F'; const destExists = await this.stores.fsEntry.getEntryByPath(destPath); if (destExists && !overwrite) throw new HttpError(412, 'Destination exists and Overwrite=F', { legacyCode: 'conflict', }); const destParent = await this.stores.fsEntry.getEntryByPath( pathPosix.dirname(destPath), ); if (!destParent?.isDir) throw new HttpError( 409, 'Destination parent missing or not a directory', { legacyCode: 'dest_is_not_a_directory' }, ); const moved = await this.services.fs.move(userId, { source, destinationParent: destParent, newName: pathPosix.basename(destPath), overwrite, }); this.#emitGuiEvent('outer.gui.item.moved', moved, { old_path: davPath, }); res.status(destExists ? 204 : 201).end(); } // -- LOCK -------------------------------------------------------- async #lock( req: Request, res: Response, actor: Actor, davPath: string, redis: unknown, headerToken: string | null, ): Promise { const r = redis as import('ioredis').Cluster; // ACL must succeed before any lock state is touched — otherwise // an authenticated user could lock paths they don't own (e.g. `/`) // and block writes for everyone else. await this.#assertWrite(actor, davPath); // Refresh existing lock if (headerToken) { const existing = await getLockIfValid(r, headerToken); if (!existing) throw new HttpError(412, 'Lock token not found', { legacyCode: 'conflict', }); await refreshLock(r, headerToken); res.status(200) .set({ 'Content-Type': 'application/xml; charset=utf-8', ...DAV_HEADERS, }) .send( lockResponseXml(headerToken, davPath, existing.lockScope), ); return; } // Parse requested scope from XML body let lockScope: 'exclusive' | 'shared' = 'exclusive'; const body = req.body as Record | undefined; if (body?.lockinfo) { const info = body.lockinfo as Record; const scope = info.lockscope as Record | undefined; if (scope?.shared !== undefined) lockScope = 'shared'; } // Check for conflicts const existingLocks = await getFileLocks(r, davPath); for (const lock of existingLocks) { if (lockScope === 'exclusive' || lock.lockScope === 'exclusive') { throw new HttpError(423, 'Locked — conflicting lock exists', { legacyCode: 'conflict', }); } } const token = await createLock(r, davPath, lockScope); const status = 200; res.status(status) .set({ 'Content-Type': 'application/xml; charset=utf-8', 'Lock-Token': `<${token}>`, ...DAV_HEADERS, }) .send(lockResponseXml(token, davPath, lockScope)); } // -- UNLOCK ------------------------------------------------------ async #unlock( req: Request, res: Response, davPath: string, redis: unknown, ): Promise { const r = redis as import('ioredis').Cluster; const tokenHeader = req.headers['lock-token'] as string | undefined; const token = extractLockToken(tokenHeader); if (!token) throw new HttpError(400, 'Missing Lock-Token header', { legacyCode: 'token_missing', }); const lock = await getLockIfValid(r, token); if (!lock) { // Idempotent — if already expired, just 204. res.status(204).end(); return; } if (lock.path !== davPath) throw new HttpError(403, 'Lock token does not match this path', { legacyCode: 'forbidden', }); await deleteLock(r, token); res.status(204).end(); } // -- ACL helpers ------------------------------------------------- async #assertRead(actor: Actor, path: string): Promise { const descriptor = { path, resolveAncestors: () => this.services.fs.getAncestorChain(path), }; const ok = await this.services.acl.check(actor, descriptor, 'read'); if (!ok) throw new HttpError(403, 'Permission denied', { legacyCode: 'permission_denied', }); } async #assertWrite(actor: Actor, path: string): Promise { const descriptor = { path, resolveAncestors: () => this.services.fs.getAncestorChain(path), }; const ok = await this.services.acl.check(actor, descriptor, 'write'); if (!ok) throw new HttpError(403, 'Permission denied', { legacyCode: 'permission_denied', }); } // -- Event emission ---------------------------------------------- #emitGuiEvent( eventName: T, entry: FSEntry, extra?: Record, ): void { const meta = {}; void Promise.resolve() .then(async () => { const response = { ...(await toLegacyEntry(this.clients.event, entry)), ...extra, from_new_service: true, }; this.clients.event.emit( eventName, { user_id_list: [entry.userId], response, } as unknown as EventMap[T], meta, ); }) .catch(() => { // non-critical }); } // -- Misc helpers ------------------------------------------------ #parseDestination(req: Request): string { const dest = req.headers.destination as string | undefined; if (!dest) throw new HttpError(400, 'Missing Destination header', { legacyCode: 'bad_request', }); try { const url = new URL(dest, `http://${req.headers.host}`); return decodeURIComponent(url.pathname); } catch { return decodeURIComponent(dest); } } } // -- XML helpers ------------------------------------------------------ function escapeXml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function wrapMultistatus(inner: string): string { return `\n\n${inner}\n`; } function propfindEntry( href: string, entry: FSEntry | null, isDir: boolean, ): string { const encodedHref = encodeURI(href) + (isDir && !href.endsWith('/') ? '/' : ''); const modified = entry?.modified ?? entry?.created ?? '2025-01-01T00:00:00Z'; const created = entry?.created ?? '2025-01-01T00:00:00Z'; const name = entry?.name ?? (pathPosix.basename(href) || '/'); const uid = entry?.uuid ?? 'root'; const modTs = Math.floor(new Date(modified as string).getTime()); let props = ` ${escapeXml(String(name))} ${new Date(modified as string).toUTCString()} ${new Date(created as string).toISOString()} ${isDir ? '' : ''} "${uid}-${modTs}" 0`; if (!isDir && entry) { props += `\n ${entry.size ?? 0}`; const mime = mimeFromExt(pathPosix.extname(entry.name)); props += `\n ${escapeXml(mime)}`; } return ` ${escapeXml(encodedHref)} ${props} HTTP/1.1 200 OK `; } const MIME_MAP: Record = { '.html': 'text/html', '.htm': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.mjs': 'application/javascript', '.json': 'application/json', '.xml': 'application/xml', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon', '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.webm': 'video/webm', '.zip': 'application/zip', '.wasm': 'application/wasm', }; function mimeFromExt(ext: string): string { return MIME_MAP[ext.toLowerCase()] ?? 'application/octet-stream'; } function lockResponseXml( token: string, path: string, scope: 'exclusive' | 'shared', ): string { return ` 0 webdav-user Second-7200 ${escapeXml(token)} ${escapeXml(encodeURI(path))} `; }