|
| 1 | +/** |
| 2 | + * Minimal WebDAV (class 1 + stubbed locks) so the Drive can be mounted as a network drive |
| 3 | + * (Finder, Windows Explorer, rsync/davfs, Cyberduck…). Normal (server-encrypted) folders only — |
| 4 | + * Zero-Knowledge vaults are decrypted in the browser and can't be served here. |
| 5 | + * |
| 6 | + * Auth is HTTP Basic where the PASSWORD is a personal API token (`ocl_…`); the username is |
| 7 | + * ignored. Read methods need the token's `read` scope, writes need `write`. Folder-restricted |
| 8 | + * tokens are refused — WebDAV exposes the whole account tree. |
| 9 | + * |
| 10 | + * This module is mounted OUTSIDE the CORS scope: @fastify/cors uses strictPreflight and would |
| 11 | + * answer every WebDAV OPTIONS with 400/204, hiding the DAV capability headers clients rely on. |
| 12 | + */ |
| 13 | +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; |
| 14 | +import type { Readable } from 'node:stream'; |
| 15 | +import type { FileObject, Folder } from '@prisma/client'; |
| 16 | +import { prisma } from '../db.js'; |
| 17 | +import { authenticateToken } from '../services/apiToken.js'; |
| 18 | +import { decryptServerFile } from '../services/download.js'; |
| 19 | +import { storeUserFile, QuotaExhaustedError } from '../services/upload.js'; |
| 20 | +import { FileTooLargeError, InfectedFileError } from '../services/ingest.js'; |
| 21 | +import { trashFile, trashFolder } from '../services/trash.js'; |
| 22 | + |
| 23 | +const PREFIX = '/dav'; |
| 24 | +const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PROPFIND']); |
| 25 | + |
| 26 | +export function escapeXml(s: string): string { |
| 27 | + return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); |
| 28 | +} |
| 29 | + |
| 30 | +/** Absolute href for a path, with a trailing slash for collections. */ |
| 31 | +export function hrefFor(segments: string[], isCollection: boolean): string { |
| 32 | + const encoded = segments.map((s) => encodeURIComponent(s)).join('/'); |
| 33 | + const base = encoded ? `${PREFIX}/${encoded}` : `${PREFIX}`; |
| 34 | + return isCollection ? `${base}/` : base; |
| 35 | +} |
| 36 | + |
| 37 | +/** Split a WebDAV request path (the part after /dav) into decoded, non-empty segments. */ |
| 38 | +export function pathSegments(star: string | undefined): string[] { |
| 39 | + return (star ?? '') |
| 40 | + .split('/') |
| 41 | + .filter(Boolean) |
| 42 | + .map((s) => { |
| 43 | + try { |
| 44 | + return decodeURIComponent(s); |
| 45 | + } catch { |
| 46 | + return s; |
| 47 | + } |
| 48 | + }); |
| 49 | +} |
| 50 | + |
| 51 | +type Resolved = |
| 52 | + | { type: 'root' } |
| 53 | + | { type: 'folder'; folder: Folder; parentId: string | null } |
| 54 | + | { type: 'file'; file: FileObject; parentId: string | null } |
| 55 | + | { type: 'missing'; parentId: string | null; name: string } |
| 56 | + | { type: 'invalid' }; |
| 57 | + |
| 58 | +/** Walk a path's segments, matching folders by name; the last may be a file. */ |
| 59 | +async function resolvePath(ownerId: string, segments: string[]): Promise<Resolved> { |
| 60 | + if (segments.length === 0) return { type: 'root' }; |
| 61 | + let parentId: string | null = null; |
| 62 | + for (let i = 0; i < segments.length - 1; i += 1) { |
| 63 | + const folder: Folder | null = await prisma.folder.findFirst({ |
| 64 | + where: { ownerId, parentId, name: segments[i], isZeroKnowledge: false }, |
| 65 | + }); |
| 66 | + if (!folder) return { type: 'invalid' }; |
| 67 | + parentId = folder.id; |
| 68 | + } |
| 69 | + const last = segments[segments.length - 1]!; |
| 70 | + const folder = await prisma.folder.findFirst({ where: { ownerId, parentId, name: last, isZeroKnowledge: false } }); |
| 71 | + if (folder) return { type: 'folder', folder, parentId }; |
| 72 | + const file = await prisma.fileObject.findFirst({ |
| 73 | + where: { ownerId, folderId: parentId, name: last, encMode: 'SERVER', deletedAt: null }, |
| 74 | + }); |
| 75 | + if (file) return { type: 'file', file, parentId }; |
| 76 | + return { type: 'missing', parentId, name: last }; |
| 77 | +} |
| 78 | + |
| 79 | +function propXml(segments: string[], isCollection: boolean, name: string, size: number, mtime: Date, mime: string): string { |
| 80 | + const props = isCollection |
| 81 | + ? `<D:resourcetype><D:collection/></D:resourcetype>` |
| 82 | + : `<D:resourcetype/><D:getcontentlength>${size}</D:getcontentlength><D:getcontenttype>${escapeXml(mime)}</D:getcontenttype>`; |
| 83 | + return ( |
| 84 | + `<D:response><D:href>${escapeXml(hrefFor(segments, isCollection))}</D:href>` + |
| 85 | + `<D:propstat><D:prop>` + |
| 86 | + `<D:displayname>${escapeXml(name)}</D:displayname>` + |
| 87 | + `<D:getlastmodified>${mtime.toUTCString()}</D:getlastmodified>` + |
| 88 | + props + |
| 89 | + `</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>` |
| 90 | + ); |
| 91 | +} |
| 92 | + |
| 93 | +export const webdavRoutes: FastifyPluginAsync = async (app) => { |
| 94 | + // Pass request bodies through as raw streams (PUT) and accept any content type (PROPFIND XML). |
| 95 | + app.addContentTypeParser('*', (_req, payload, done) => done(null, payload)); |
| 96 | + |
| 97 | + const challenge = (reply: FastifyReply) => |
| 98 | + reply.code(401).header('WWW-Authenticate', 'Basic realm="OpenCoperLock WebDAV"').send(); |
| 99 | + |
| 100 | + async function auth(req: FastifyRequest, reply: FastifyReply): Promise<{ ownerId: string } | null> { |
| 101 | + const header = req.headers.authorization; |
| 102 | + if (!header || !header.startsWith('Basic ')) { |
| 103 | + challenge(reply); |
| 104 | + return null; |
| 105 | + } |
| 106 | + const decoded = Buffer.from(header.slice('Basic '.length), 'base64').toString('utf8'); |
| 107 | + const idx = decoded.indexOf(':'); |
| 108 | + const pass = idx >= 0 ? decoded.slice(idx + 1) : ''; |
| 109 | + const user = idx >= 0 ? decoded.slice(0, idx) : decoded; |
| 110 | + const token = pass || user; // clients put the token in the password field |
| 111 | + const scope = READ_METHODS.has(req.method) ? 'read' : 'write'; |
| 112 | + const result = await authenticateToken(`Bearer ${token}`, scope); |
| 113 | + if (!result.ok) { |
| 114 | + challenge(reply); |
| 115 | + return null; |
| 116 | + } |
| 117 | + if (result.token.folderId) { |
| 118 | + reply.code(403).send('Use an unrestricted API token for WebDAV'); |
| 119 | + return null; |
| 120 | + } |
| 121 | + return { ownerId: result.owner.id }; |
| 122 | + } |
| 123 | + |
| 124 | + // A Destination header is an absolute URL or path; reduce it to /dav segments. |
| 125 | + function destinationSegments(dest: string | undefined): string[] | null { |
| 126 | + if (!dest) return null; |
| 127 | + let path = dest; |
| 128 | + try { |
| 129 | + path = new URL(dest).pathname; |
| 130 | + } catch { |
| 131 | + /* already a path */ |
| 132 | + } |
| 133 | + const i = path.indexOf(PREFIX); |
| 134 | + if (i < 0) return null; |
| 135 | + return pathSegments(path.slice(i + PREFIX.length)); |
| 136 | + } |
| 137 | + |
| 138 | + async function handle(req: FastifyRequest, reply: FastifyReply) { |
| 139 | + const principal = await auth(req, reply); |
| 140 | + if (!principal) return; |
| 141 | + const ownerId = principal.ownerId; |
| 142 | + const segments = pathSegments((req.params as Record<string, string>)['*']); |
| 143 | + const method = req.method; |
| 144 | + |
| 145 | + if (method === 'OPTIONS') { |
| 146 | + return reply |
| 147 | + .header('DAV', '1, 2') |
| 148 | + .header('MS-Author-Via', 'DAV') |
| 149 | + .header('Allow', 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, MOVE, COPY, LOCK, UNLOCK') |
| 150 | + .code(204) |
| 151 | + .send(); |
| 152 | + } |
| 153 | + |
| 154 | + const node = await resolvePath(ownerId, segments); |
| 155 | + |
| 156 | + if (method === 'PROPFIND') { |
| 157 | + if (node.type === 'invalid' || node.type === 'missing') return reply.code(404).send(); |
| 158 | + const depth = String(req.headers.depth ?? '1'); |
| 159 | + const parts: string[] = []; |
| 160 | + if (node.type === 'root') { |
| 161 | + parts.push(propXml([], true, 'OpenCoperLock', 0, new Date(), '')); |
| 162 | + } else if (node.type === 'folder') { |
| 163 | + parts.push(propXml(segments, true, node.folder.name, 0, node.folder.createdAt, '')); |
| 164 | + } else { |
| 165 | + parts.push(propXml(segments, false, node.file.name, Number(node.file.sizeBytes), node.file.createdAt, node.file.mimeType)); |
| 166 | + } |
| 167 | + if (depth !== '0' && node.type !== 'file') { |
| 168 | + const parentId = node.type === 'root' ? null : node.folder.id; |
| 169 | + const [folders, files] = await Promise.all([ |
| 170 | + prisma.folder.findMany({ where: { ownerId, parentId, isZeroKnowledge: false }, orderBy: { name: 'asc' } }), |
| 171 | + prisma.fileObject.findMany({ |
| 172 | + where: { ownerId, folderId: parentId, encMode: 'SERVER', deletedAt: null }, |
| 173 | + orderBy: { name: 'asc' }, |
| 174 | + }), |
| 175 | + ]); |
| 176 | + for (const f of folders) parts.push(propXml([...segments, f.name], true, f.name, 0, f.createdAt, '')); |
| 177 | + for (const f of files) parts.push(propXml([...segments, f.name], false, f.name, Number(f.sizeBytes), f.createdAt, f.mimeType)); |
| 178 | + } |
| 179 | + return reply |
| 180 | + .header('Content-Type', 'application/xml; charset=utf-8') |
| 181 | + .code(207) |
| 182 | + .send(`<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">${parts.join('')}</D:multistatus>`); |
| 183 | + } |
| 184 | + |
| 185 | + if (method === 'GET' || method === 'HEAD') { |
| 186 | + if (node.type !== 'file') return reply.code(node.type === 'folder' || node.type === 'root' ? 405 : 404).send(); |
| 187 | + reply |
| 188 | + .header('Content-Type', node.file.mimeType) |
| 189 | + .header('Content-Length', Number(node.file.sizeBytes)) |
| 190 | + .header('Last-Modified', node.file.createdAt.toUTCString()) |
| 191 | + .header('Accept-Ranges', 'none'); |
| 192 | + if (method === 'HEAD') return reply.send(); |
| 193 | + return reply.send(decryptServerFile(app.ctx, node.file)); |
| 194 | + } |
| 195 | + |
| 196 | + if (method === 'PUT') { |
| 197 | + if (node.type === 'folder' || node.type === 'root') return reply.code(405).send(); |
| 198 | + if (node.type === 'invalid') return reply.code(409).send(); // a parent folder is missing |
| 199 | + const parentId = node.type === 'file' ? node.file.folderId : node.parentId; |
| 200 | + const name = node.type === 'file' ? node.file.name : node.name; |
| 201 | + const mimetype = String(req.headers['content-type'] ?? 'application/octet-stream').split(';')[0]!.trim() || 'application/octet-stream'; |
| 202 | + try { |
| 203 | + await storeUserFile(app.ctx, { ownerId, folderId: parentId, stream: req.body as Readable, filename: name, mimetype }); |
| 204 | + } catch (err) { |
| 205 | + if (err instanceof QuotaExhaustedError || err instanceof FileTooLargeError) return reply.code(507).send(); // Insufficient Storage |
| 206 | + if (err instanceof InfectedFileError) return reply.code(403).send('File rejected by antivirus'); |
| 207 | + throw err; |
| 208 | + } |
| 209 | + return reply.code(node.type === 'file' ? 204 : 201).send(); |
| 210 | + } |
| 211 | + |
| 212 | + if (method === 'MKCOL') { |
| 213 | + if (node.type !== 'missing') return reply.code(node.type === 'invalid' ? 409 : 405).send(); |
| 214 | + await prisma.folder.create({ data: { ownerId, name: node.name, parentId: node.parentId, isZeroKnowledge: false } }); |
| 215 | + return reply.code(201).send(); |
| 216 | + } |
| 217 | + |
| 218 | + if (method === 'DELETE') { |
| 219 | + if (node.type === 'file') await trashFile(ownerId, node.file.id); |
| 220 | + else if (node.type === 'folder') await trashFolder(ownerId, node.folder.id); |
| 221 | + else return reply.code(404).send(); |
| 222 | + return reply.code(204).send(); |
| 223 | + } |
| 224 | + |
| 225 | + if (method === 'MOVE') { |
| 226 | + if (node.type !== 'file' && node.type !== 'folder') return reply.code(404).send(); |
| 227 | + const destSegs = destinationSegments(req.headers.destination as string | undefined); |
| 228 | + if (!destSegs || destSegs.length === 0) return reply.code(400).send(); |
| 229 | + const destParent = await resolvePath(ownerId, destSegs.slice(0, -1)); |
| 230 | + const destParentId = |
| 231 | + destParent.type === 'root' ? null : destParent.type === 'folder' ? destParent.folder.id : undefined; |
| 232 | + if (destParentId === undefined) return reply.code(409).send(); |
| 233 | + const destName = destSegs[destSegs.length - 1]!; |
| 234 | + const existing = await resolvePath(ownerId, destSegs); |
| 235 | + if (existing.type === 'file' || existing.type === 'folder') { |
| 236 | + if (String(req.headers.overwrite ?? 'T').toUpperCase() === 'F') return reply.code(412).send(); |
| 237 | + if (existing.type === 'file') await trashFile(ownerId, existing.file.id); |
| 238 | + else await trashFolder(ownerId, existing.folder.id); |
| 239 | + } |
| 240 | + if (node.type === 'file') { |
| 241 | + await prisma.fileObject.update({ where: { id: node.file.id }, data: { folderId: destParentId, name: destName } }); |
| 242 | + } else { |
| 243 | + await prisma.folder.update({ where: { id: node.folder.id }, data: { parentId: destParentId, name: destName } }); |
| 244 | + } |
| 245 | + return reply.code(existing.type === 'file' || existing.type === 'folder' ? 204 : 201).send(); |
| 246 | + } |
| 247 | + |
| 248 | + // Stubbed locking so Finder / Office clients are happy (we are single-writer in practice). |
| 249 | + if (method === 'LOCK') { |
| 250 | + const token = `opaquelocktoken:${crypto.randomUUID()}`; |
| 251 | + return reply |
| 252 | + .header('Content-Type', 'application/xml; charset=utf-8') |
| 253 | + .header('Lock-Token', `<${token}>`) |
| 254 | + .code(200) |
| 255 | + .send( |
| 256 | + `<?xml version="1.0" encoding="utf-8"?>\n<D:prop xmlns:D="DAV:"><D:lockdiscovery><D:activelock>` + |
| 257 | + `<D:locktype><D:write/></D:locktype><D:lockscope><D:exclusive/></D:lockscope>` + |
| 258 | + `<D:depth>infinity</D:depth><D:timeout>Second-3600</D:timeout>` + |
| 259 | + `<D:locktoken><D:href>${token}</D:href></D:locktoken></D:activelock></D:lockdiscovery></D:prop>`, |
| 260 | + ); |
| 261 | + } |
| 262 | + if (method === 'UNLOCK') return reply.code(204).send(); |
| 263 | + if (method === 'PROPPATCH') { |
| 264 | + // Pretend property writes (e.g. mtime) succeed so clients don't abort the transfer. |
| 265 | + return reply |
| 266 | + .header('Content-Type', 'application/xml; charset=utf-8') |
| 267 | + .code(207) |
| 268 | + .send( |
| 269 | + `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:"><D:response>` + |
| 270 | + `<D:href>${escapeXml(hrefFor(segments, node.type === 'folder' || node.type === 'root'))}</D:href>` + |
| 271 | + `<D:propstat><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response></D:multistatus>`, |
| 272 | + ); |
| 273 | + } |
| 274 | + if (method === 'COPY') return reply.code(501).send('COPY is not supported'); |
| 275 | + |
| 276 | + return reply.code(405).send(); |
| 277 | + } |
| 278 | + |
| 279 | + const methods = [ |
| 280 | + 'OPTIONS', |
| 281 | + 'GET', |
| 282 | + 'HEAD', |
| 283 | + 'PUT', |
| 284 | + 'DELETE', |
| 285 | + 'PROPFIND', |
| 286 | + 'PROPPATCH', |
| 287 | + 'MKCOL', |
| 288 | + 'MOVE', |
| 289 | + 'COPY', |
| 290 | + 'LOCK', |
| 291 | + 'UNLOCK', |
| 292 | + ]; |
| 293 | + for (const method of methods) { |
| 294 | + app.route({ method: method as never, url: '/', handler: handle }); |
| 295 | + app.route({ method: method as never, url: '/*', handler: handle }); |
| 296 | + } |
| 297 | +}; |
0 commit comments