Skip to content

Commit 1ed24de

Browse files
Add a WebDAV endpoint to mount the Drive as a network drive
Mount normal (server-encrypted) spaces in Finder / Windows Explorer / rsync / Cyberduck. - routes/webdav.ts: class-1 WebDAV + stubbed locks — OPTIONS, PROPFIND (depth 0/1), GET, HEAD, PUT, MKCOL, DELETE, MOVE, LOCK/UNLOCK, PROPPATCH (COPY → 501). PUT reuses storeUserFile, so quota/AV/versioning/webhooks all apply. Vault folders are excluded. - Auth is HTTP Basic where the PASSWORD is an API token; folder-restricted tokens are refused. - server.ts: register the custom WebDAV verbs via addHttpMethod and mount /dav OUTSIDE the CORS scope — @fastify/cors uses strictPreflight and would answer WebDAV's OPTIONS with 400/204, hiding the DAV headers. Browser routes are now wrapped so CORS applies only to them. - Unit tests for the path/href/XML helpers + routing (custom verb reaches the handler, /dav is outside CORS). Account ▸ WebDAV card + docs/API.md section. FR/EN parity (453). NOTE: needs a real-client mount test (Finder/Windows/rclone) — can't be exercised in CI.
1 parent 0a8165e commit 1ed24de

6 files changed

Lines changed: 436 additions & 22 deletions

File tree

‎apps/api/src/routes/webdav.ts‎

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[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+
};

‎apps/api/src/server.ts‎

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { twoFactorRoutes } from './routes/twofa.js';
2121
import { accountRoutes } from './routes/account.js';
2222
import { adminRoutes } from './routes/admin.js';
2323
import { apiV1Routes } from './routes/api-v1.js';
24+
import { webdavRoutes } from './routes/webdav.js';
2425

2526
export async function buildServer(ctx: AppContext): Promise<FastifyInstance> {
2627
const app = Fastify({
@@ -45,7 +46,6 @@ export async function buildServer(ctx: AppContext): Promise<FastifyInstance> {
4546
app.decorate('ctx', ctx);
4647

4748
await app.register(helmet, { contentSecurityPolicy: false });
48-
await app.register(cors, { origin: ctx.env.APP_URL, credentials: true });
4949
await app.register(cookie, { secret: ctx.env.SESSION_SECRET });
5050
await app.register(rateLimit, {
5151
max: ctx.env.RATE_LIMIT_ENABLED ? 300 : 1_000_000,
@@ -62,28 +62,42 @@ export async function buildServer(ctx: AppContext): Promise<FastifyInstance> {
6262

6363
await app.register(authPlugin);
6464

65-
// Liveness: the process is up. Readiness: dependencies (DB + storage) are usable.
66-
app.get('/health', async () => ({ status: 'ok' }));
67-
app.get('/ready', async (_req, reply) => {
68-
const report = await getHealth(ctx);
69-
return reply.code(report.ready ? 200 : 503).send(report);
70-
});
71-
// Authenticated status for the UI banner (warnings about degraded components).
72-
app.get('/status', { preHandler: app.requireAuth }, async () => getHealth(ctx));
65+
// Teach Fastify the WebDAV verbs before the routes that use them are registered.
66+
for (const m of ['PROPFIND', 'PROPPATCH', 'MKCOL', 'MOVE', 'COPY', 'LOCK', 'UNLOCK']) {
67+
app.addHttpMethod(m, { hasBody: ['PROPFIND', 'PROPPATCH', 'MKCOL', 'LOCK'].includes(m) });
68+
}
69+
70+
// WebDAV is mounted OUTSIDE the CORS scope below: @fastify/cors uses strictPreflight and would
71+
// answer every WebDAV OPTIONS with 400/204, hiding the DAV capability headers clients need.
72+
await app.register(webdavRoutes, { prefix: '/dav' });
73+
74+
// Everything browser-facing is wrapped so CORS applies only here (not to WebDAV).
75+
await app.register(async (web) => {
76+
await web.register(cors, { origin: ctx.env.APP_URL, credentials: true });
7377

74-
await app.register(authRoutes, { prefix: '/auth' });
75-
await app.register(folderRoutes, { prefix: '/folders' });
76-
await app.register(fileRoutes, { prefix: '/files' });
77-
await app.register(trashRoutes, { prefix: '/trash' });
78-
await app.register(zkRoutes, { prefix: '/zk' });
79-
await app.register(quickRoutes, { prefix: '/quick' });
80-
await app.register(remoteRoutes, { prefix: '/remote' });
81-
await app.register(shareRoutes, { prefix: '/shares' });
82-
await app.register(sharePublicRoutes, { prefix: '/s' });
83-
await app.register(twoFactorRoutes, { prefix: '/2fa' });
84-
await app.register(accountRoutes, { prefix: '/account' });
85-
await app.register(adminRoutes, { prefix: '/admin' });
86-
await app.register(apiV1Routes, { prefix: '/api/v1' });
78+
// Liveness: the process is up. Readiness: dependencies (DB + storage) are usable.
79+
web.get('/health', async () => ({ status: 'ok' }));
80+
web.get('/ready', async (_req, reply) => {
81+
const report = await getHealth(ctx);
82+
return reply.code(report.ready ? 200 : 503).send(report);
83+
});
84+
// Authenticated status for the UI banner (warnings about degraded components).
85+
web.get('/status', { preHandler: web.requireAuth }, async () => getHealth(ctx));
86+
87+
await web.register(authRoutes, { prefix: '/auth' });
88+
await web.register(folderRoutes, { prefix: '/folders' });
89+
await web.register(fileRoutes, { prefix: '/files' });
90+
await web.register(trashRoutes, { prefix: '/trash' });
91+
await web.register(zkRoutes, { prefix: '/zk' });
92+
await web.register(quickRoutes, { prefix: '/quick' });
93+
await web.register(remoteRoutes, { prefix: '/remote' });
94+
await web.register(shareRoutes, { prefix: '/shares' });
95+
await web.register(sharePublicRoutes, { prefix: '/s' });
96+
await web.register(twoFactorRoutes, { prefix: '/2fa' });
97+
await web.register(accountRoutes, { prefix: '/account' });
98+
await web.register(adminRoutes, { prefix: '/admin' });
99+
await web.register(apiV1Routes, { prefix: '/api/v1' });
100+
});
87101

88102
app.setErrorHandler((err: { statusCode?: number; message?: string }, req, reply) => {
89103
req.log.error({ err }, 'request failed');

0 commit comments

Comments
 (0)