mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
fix: vscode webdav (#3610)
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
// This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus
|
||||
// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { hash as bcryptHash } from 'bcrypt';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { RouteDescriptor } from '../../core/http/types.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
@@ -14,7 +15,7 @@ import type { WebDAVController } from './WebDAVController.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let controller: WebDAVController;
|
||||
let dispatchMiddleware: Function;
|
||||
let routes: RouteDescriptor[];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
@@ -22,12 +23,60 @@ beforeAll(async () => {
|
||||
|
||||
const router = new PuterRouter();
|
||||
controller.registerRoutes(router);
|
||||
|
||||
// WebDAVController registers a single `use()` middleware on the `dav`
|
||||
// subdomain. Grab it to call directly in tests.
|
||||
dispatchMiddleware = router.routes[0]!.handler;
|
||||
routes = router.routes;
|
||||
});
|
||||
|
||||
/** Run one handler; true when it called `next` instead of answering. */
|
||||
const runHandler = async (
|
||||
handler: RequestHandler,
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<boolean> => {
|
||||
let advanced = false;
|
||||
await handler(req, res, (() => {
|
||||
advanced = true;
|
||||
}) as never);
|
||||
return advanced;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stand in for express's router: walk the controller's collected routes in
|
||||
* order and run the first one that matches the request's method, plus any
|
||||
* `middleware` it declares.
|
||||
*
|
||||
* The options the real server materializes into gates — subdomain, rate limit,
|
||||
* concurrency — are deliberately not applied here; those belong to the
|
||||
* materializer's own tests, and reproducing them would make every case below
|
||||
* share one DAV budget. `server.test.ts` covers the wiring on a real port.
|
||||
*/
|
||||
const dispatchMiddleware = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: () => void,
|
||||
): Promise<void> => {
|
||||
const method = req.method.toLowerCase();
|
||||
for (const route of routes) {
|
||||
if (
|
||||
route.method !== 'use' &&
|
||||
route.method !== 'all' &&
|
||||
route.method !== method
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let advanced = true;
|
||||
for (const handler of [
|
||||
...(route.options.middleware ?? []),
|
||||
route.handler,
|
||||
]) {
|
||||
advanced = await runHandler(handler, req, res);
|
||||
if (!advanced) break;
|
||||
}
|
||||
// A chain that stopped short of `next` answered the request.
|
||||
if (!advanced) return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
@@ -152,10 +201,45 @@ const makeUser = async () => {
|
||||
|
||||
describe('WebDAVController', () => {
|
||||
describe('route registration', () => {
|
||||
it('registers a single catch-all use() route', () => {
|
||||
it('registers one catch-all per DAV verb, all on the dav subdomain', () => {
|
||||
const router = new PuterRouter();
|
||||
controller.registerRoutes(router);
|
||||
expect(router.routes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
for (const verb of [
|
||||
'options',
|
||||
'head',
|
||||
'get',
|
||||
'propfind',
|
||||
'proppatch',
|
||||
'mkcol',
|
||||
'put',
|
||||
'delete',
|
||||
'copy',
|
||||
'move',
|
||||
'lock',
|
||||
'unlock',
|
||||
]) {
|
||||
const route = router.routes.find((r) => r.method === verb);
|
||||
expect(route, verb).toBeDefined();
|
||||
expect(route!.path).toBe('/{*splat}');
|
||||
expect(route!.options.subdomain).toBe('dav');
|
||||
// The gates the materializer installs ahead of the handler.
|
||||
expect(route!.options.rateLimit).toBeDefined();
|
||||
expect(route!.options.concurrent).toBeDefined();
|
||||
}
|
||||
|
||||
// `all` catches the verbs we don't implement, so it has to come
|
||||
// after every route that does.
|
||||
const allIndex = router.routes.findIndex((r) => r.method === 'all');
|
||||
expect(allIndex).toBe(router.routes.length - 1);
|
||||
});
|
||||
|
||||
it('registers HEAD ahead of GET so express does not fold the two', () => {
|
||||
const router = new PuterRouter();
|
||||
controller.registerRoutes(router);
|
||||
const head = router.routes.findIndex((r) => r.method === 'head');
|
||||
const get = router.routes.findIndex((r) => r.method === 'get');
|
||||
expect(head).toBeLessThan(get);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,6 +325,48 @@ describe('WebDAVController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CORS preflight', () => {
|
||||
it('answers a browser preflight before the auth gate', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'OPTIONS',
|
||||
path: '/someone/.vscode/settings.json',
|
||||
headers: {
|
||||
origin: 'https://code.puter.com',
|
||||
'access-control-request-method': 'PROPFIND',
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
// A non-2xx here is what made every browser DAV client give up
|
||||
// before it could send a token.
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(captured.headers['dav']).toContain('1');
|
||||
expect(captured.headers['allow']).toContain('PROPFIND');
|
||||
expect(captured.headers['access-control-max-age']).toBe('86400');
|
||||
expect(captured.headers['www-authenticate']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still authenticates an OPTIONS that is not a preflight', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'OPTIONS',
|
||||
// An Origin alone isn't a preflight — only the
|
||||
// `Access-Control-Request-Method` probe is.
|
||||
headers: { origin: 'https://code.puter.com' },
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(401);
|
||||
// macOS opens a mount on this reply, so it keeps the DAV header.
|
||||
expect(captured.headers['dav']).toContain('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending-verification gate', () => {
|
||||
// WebDAV must enforce the same gate every other authenticated route
|
||||
// gets from requireVerifiedAccount — it dispatches off a single use()
|
||||
@@ -1766,14 +1892,11 @@ describe('WebDAVController verbs', () => {
|
||||
});
|
||||
|
||||
describe('shared-path masking', () => {
|
||||
// The DAV controller authenticates in-controller, after the request
|
||||
// context snapshotted an empty `req.actor` — so it has to place the
|
||||
// actor into the context itself or outbound masking silently turns
|
||||
// off and PROPFIND lists the owner's real paths.
|
||||
it('lists a shared folder under its masked path, not the owner’s real one', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
|
||||
/** A `Secrets/plan.txt` folder in `owner`'s Documents, shared with `to`. */
|
||||
const shareFolder = async (
|
||||
owner: Awaited<ReturnType<typeof makeUser>>,
|
||||
to: Awaited<ReturnType<typeof makeUser>>,
|
||||
) => {
|
||||
const dirUuid = uuidv4();
|
||||
const dirPath = `/${owner.username}/Documents/Secrets`;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@@ -1782,44 +1905,353 @@ describe('WebDAVController verbs', () => {
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 1, ?, ?, ?)',
|
||||
[dirUuid, 'Secrets', dirPath, owner.userId, now, parent.id, parent.uuid],
|
||||
[
|
||||
dirUuid,
|
||||
'Secrets',
|
||||
dirPath,
|
||||
owner.userId,
|
||||
now,
|
||||
parent.id,
|
||||
parent.uuid,
|
||||
],
|
||||
);
|
||||
const dir = (await server.stores.fsEntry.getEntryByPath(dirPath))!;
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 0, ?, ?, ?)',
|
||||
[uuidv4(), 'plan.txt', `${dirPath}/plan.txt`, owner.userId, now, dir.id, dir.uuid],
|
||||
[
|
||||
uuidv4(),
|
||||
'plan.txt',
|
||||
`${dirPath}/plan.txt`,
|
||||
owner.userId,
|
||||
now,
|
||||
dir.id,
|
||||
dir.uuid,
|
||||
],
|
||||
);
|
||||
|
||||
await runWithContext({ actor: owner.actor as never }, () =>
|
||||
server.services.share.share(owner.actor as never, {
|
||||
uid: dirUuid,
|
||||
recipient: { username: recipient.username! },
|
||||
recipient: { username: to.username! },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
return { dirUuid, dirPath };
|
||||
};
|
||||
|
||||
const masked = `/${owner.username}/${dirUuid}/Secrets`;
|
||||
/**
|
||||
* PROPFIND under the ALS wrap a real request gets, pre-auth actor
|
||||
* empty.
|
||||
*/
|
||||
const propfind = async (path: string, actor: unknown, depth = '1') => {
|
||||
const { res, captured } = makeRes();
|
||||
// The same ALS wrap every real request gets from the
|
||||
// request-context middleware, with the pre-auth (empty) actor.
|
||||
await runWithContext({ actor: undefined }, () =>
|
||||
dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
path: masked,
|
||||
headers: { depth: '1' },
|
||||
actor: recipient.actor,
|
||||
path,
|
||||
headers: { depth },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
),
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
// The DAV controller authenticates in-controller, after the request
|
||||
// context snapshotted an empty `req.actor` — so it has to place the
|
||||
// actor into the context itself or outbound masking silently turns
|
||||
// off and PROPFIND lists the owner's real paths.
|
||||
it('lists a shared folder under its masked path, not the owner’s real one', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const { dirUuid } = await shareFolder(owner, recipient);
|
||||
|
||||
const masked = `/${owner.username}/${dirUuid}/Secrets`;
|
||||
const captured = await propfind(masked, recipient.actor);
|
||||
|
||||
expect(captured.statusCode).toBe(207);
|
||||
const xml = String(captured.body);
|
||||
expect(xml).toContain(`${masked}/plan.txt`);
|
||||
expect(xml).not.toContain('/Documents/Secrets');
|
||||
});
|
||||
|
||||
it('lists each sharer as a collection under the root', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
await shareFolder(owner, recipient);
|
||||
|
||||
const captured = await propfind('/', recipient.actor);
|
||||
expect(captured.statusCode).toBe(207);
|
||||
const xml = String(captured.body);
|
||||
// The recipient's own home, plus the owner who shared with them.
|
||||
expect(xml).toContain(`<D:href>/${recipient.username}/</D:href>`);
|
||||
expect(xml).toContain(`<D:href>/${owner.username}/</D:href>`);
|
||||
});
|
||||
|
||||
it('omits the sharers at depth 0', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
await shareFolder(owner, recipient);
|
||||
|
||||
const captured = await propfind('/', recipient.actor, '0');
|
||||
expect(captured.statusCode).toBe(207);
|
||||
expect(String(captured.body)).not.toContain(`/${owner.username}/`);
|
||||
});
|
||||
|
||||
it('answers a sharer’s collection with the shares they issued', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const { dirUuid } = await shareFolder(owner, recipient);
|
||||
|
||||
const captured = await propfind(
|
||||
`/${owner.username}`,
|
||||
recipient.actor,
|
||||
);
|
||||
expect(captured.statusCode).toBe(207);
|
||||
const xml = String(captured.body);
|
||||
expect(xml).toContain(`<D:href>/${owner.username}/</D:href>`);
|
||||
// The `<uuid>` level, which the share root hangs off. It borrows
|
||||
// the shared item's name so a client has something to show.
|
||||
expect(xml).toContain(
|
||||
`<D:href>/${owner.username}/${dirUuid}/</D:href>`,
|
||||
);
|
||||
expect(xml).toContain('<D:displayname>Secrets</D:displayname>');
|
||||
// Nothing else of the owner's shows through — not their home
|
||||
// directory's other children, not the folder the share sits in.
|
||||
expect(xml).not.toContain('Documents');
|
||||
});
|
||||
|
||||
it('keeps a sharer’s collection a 403 for a caller with no share', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
await shareFolder(owner, await makeUser());
|
||||
|
||||
const captured = await propfind(
|
||||
`/${owner.username}`,
|
||||
stranger.actor,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// The virtual collections invent two levels of path that no fsentry backs.
|
||||
// Everything here checks that they only ever widen to what was actually
|
||||
// shared — the rest of the owner's tree has to stay unreachable and
|
||||
// unnamed.
|
||||
describe('shared-space containment', () => {
|
||||
/**
|
||||
* `owner` shares `Shared/` (holding `in.txt`) with `to`, and keeps
|
||||
* `Private/` (holding `out.txt`) to themselves.
|
||||
*/
|
||||
const shareOneOfTwo = async (
|
||||
owner: Awaited<ReturnType<typeof makeUser>>,
|
||||
to: Awaited<ReturnType<typeof makeUser>>,
|
||||
) => {
|
||||
const mk = async (
|
||||
method: string,
|
||||
path: string,
|
||||
extra: Record<string, string> = {},
|
||||
) => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor: undefined }, () =>
|
||||
dispatchMiddleware(
|
||||
makeReq({
|
||||
method,
|
||||
path,
|
||||
actor: owner.actor,
|
||||
headers: extra,
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
),
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
await mk('MKCOL', `/${owner.username}/Documents/Shared`);
|
||||
await mk('MKCOL', `/${owner.username}/Documents/Private`);
|
||||
|
||||
const shared = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Documents/Shared`,
|
||||
))!;
|
||||
const private_ = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Documents/Private`,
|
||||
))!;
|
||||
|
||||
await runWithContext({ actor: owner.actor as never }, () =>
|
||||
server.services.share.share(owner.actor as never, {
|
||||
uid: shared.uuid,
|
||||
recipient: { username: to.username! },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
return { shared, private: private_ };
|
||||
};
|
||||
|
||||
const propfind = async (path: string, actor: unknown, depth = '1') => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor: undefined }, () =>
|
||||
dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
path,
|
||||
headers: { depth },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
),
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
it('lists only the items that were shared, not the owner’s siblings', async () => {
|
||||
const owner = await makeUser();
|
||||
const holder = await makeUser();
|
||||
const { shared, private: hidden } = await shareOneOfTwo(
|
||||
owner,
|
||||
holder,
|
||||
);
|
||||
|
||||
const captured = await propfind(`/${owner.username}`, holder.actor);
|
||||
expect(captured.statusCode).toBe(207);
|
||||
const xml = String(captured.body);
|
||||
expect(xml).toContain(shared.uuid);
|
||||
expect(xml).not.toContain(hidden.uuid);
|
||||
expect(xml).not.toContain('Private');
|
||||
});
|
||||
|
||||
it('does not recurse past the uuid level at Depth: infinity', async () => {
|
||||
const owner = await makeUser();
|
||||
const holder = await makeUser();
|
||||
const { shared } = await shareOneOfTwo(owner, holder);
|
||||
|
||||
const captured = await propfind(
|
||||
`/${owner.username}`,
|
||||
holder.actor,
|
||||
'infinity',
|
||||
);
|
||||
expect(captured.statusCode).toBe(207);
|
||||
const xml = String(captured.body);
|
||||
// The share's own href lives one segment deeper; asking for the
|
||||
// world must not walk into it.
|
||||
expect(xml).toContain(`/${shared.uuid}/`);
|
||||
expect(xml).not.toContain(`/${shared.uuid}/Shared`);
|
||||
});
|
||||
|
||||
it('refuses every way out of a share', async () => {
|
||||
const owner = await makeUser();
|
||||
const holder = await makeUser();
|
||||
const { shared, private: hidden } = await shareOneOfTwo(
|
||||
owner,
|
||||
holder,
|
||||
);
|
||||
const root = `/${owner.username}/${shared.uuid}/Shared`;
|
||||
|
||||
for (const escape of [
|
||||
// Up and sideways out of the shared subtree.
|
||||
`${root}/../Private`,
|
||||
`${root}/../../Documents`,
|
||||
// Percent-encoded, because the path is decoded before it is
|
||||
// resolved — the guard has to sit after that, not before.
|
||||
`${root}/%2e%2e/Private`,
|
||||
// The owner's real path, named directly rather than walked to.
|
||||
`/${owner.username}/Documents/Private`,
|
||||
`/${owner.username}/Documents`,
|
||||
// A mask pointing at an entry the holder was never given —
|
||||
// both a bad tail under a uuid they do hold, and the uuid of
|
||||
// one they don't.
|
||||
`/${owner.username}/${shared.uuid}/Private`,
|
||||
`/${owner.username}/${hidden.uuid}`,
|
||||
`/${owner.username}/${hidden.uuid}/Private`,
|
||||
]) {
|
||||
const captured = await propfind(escape, holder.actor);
|
||||
expect(captured.statusCode, escape).toBeGreaterThanOrEqual(400);
|
||||
expect(captured.statusCode, escape).toBeLessThan(500);
|
||||
expect(String(captured.body), escape).not.toContain('Private');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not leak one holder’s shares to another', async () => {
|
||||
const owner = await makeUser();
|
||||
const holder = await makeUser();
|
||||
const outsider = await makeUser();
|
||||
await shareOneOfTwo(owner, holder);
|
||||
|
||||
const captured = await propfind('/', outsider.actor);
|
||||
expect(captured.statusCode).toBe(207);
|
||||
expect(String(captured.body)).not.toContain(owner.username);
|
||||
});
|
||||
|
||||
it('drops the sharer once the share is revoked', async () => {
|
||||
const owner = await makeUser();
|
||||
const holder = await makeUser();
|
||||
const { shared } = await shareOneOfTwo(owner, holder);
|
||||
|
||||
expect(String((await propfind('/', holder.actor)).body)).toContain(
|
||||
`/${owner.username}/`,
|
||||
);
|
||||
|
||||
await runWithContext({ actor: owner.actor as never }, () =>
|
||||
server.services.share.unshare(owner.actor as never, {
|
||||
uid: shared.uuid,
|
||||
recipient: { username: holder.username! },
|
||||
}),
|
||||
);
|
||||
|
||||
const root = await propfind('/', holder.actor);
|
||||
expect(String(root.body)).not.toContain(`/${owner.username}/`);
|
||||
// And the level it used to stand in for is a plain 403 again.
|
||||
const owned = await propfind(`/${owner.username}`, holder.actor);
|
||||
expect(owned.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses writes and reads aimed at the invented levels', async () => {
|
||||
const owner = await makeUser();
|
||||
const holder = await makeUser();
|
||||
const { shared } = await shareOneOfTwo(owner, holder);
|
||||
|
||||
const attempt = async (method: string, path: string) => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor: undefined }, () =>
|
||||
dispatchMiddleware(
|
||||
makeReq({
|
||||
method,
|
||||
path,
|
||||
actor: holder.actor,
|
||||
headers: { 'content-length': '3' },
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
),
|
||||
);
|
||||
return captured.statusCode;
|
||||
};
|
||||
|
||||
// A collection, so GET is a 400 either way — what matters is that
|
||||
// neither the owner level nor the uuid level accepts a write.
|
||||
expect(await attempt('GET', `/${owner.username}`)).not.toBe(200);
|
||||
expect(
|
||||
await attempt('MKCOL', `/${owner.username}/intruder`),
|
||||
).toBeGreaterThanOrEqual(400);
|
||||
expect(
|
||||
await attempt(
|
||||
'MKCOL',
|
||||
`/${owner.username}/${shared.uuid}/intruder`,
|
||||
),
|
||||
).toBeGreaterThanOrEqual(400);
|
||||
expect(
|
||||
await attempt('DELETE', `/${owner.username}/${shared.uuid}`),
|
||||
).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// Nothing landed in the owner's tree.
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/intruder`,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,23 @@ import {
|
||||
assertNotSuspended,
|
||||
assertVerifiedAccount,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import {
|
||||
All,
|
||||
Controller,
|
||||
Copy,
|
||||
Delete,
|
||||
Get,
|
||||
Head,
|
||||
Lock,
|
||||
Mkcol,
|
||||
Move,
|
||||
Options,
|
||||
Propfind,
|
||||
Proppatch,
|
||||
Put,
|
||||
Unlock,
|
||||
} from '../../core/http/decorators.js';
|
||||
import type { RouteOptions } from '../../core/http/types.js';
|
||||
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
import {
|
||||
@@ -49,12 +65,8 @@ import {
|
||||
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';
|
||||
import type { ResolvedShare } from '../../services/share/ShareService.js';
|
||||
|
||||
const DAV_HEADERS = {
|
||||
DAV: '1, 2, ordered-collections',
|
||||
@@ -68,11 +80,41 @@ const ALLOW_METHODS =
|
||||
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.
|
||||
* Every DAV verb mounts on the same catch-all: on a DAV host the path _is_ the
|
||||
* resource, so there is nothing else to route on. `{*splat}` rather than plain
|
||||
* `*splat` because only the braced form also matches `/`, which is the first
|
||||
* collection a client PROPFINDs.
|
||||
*/
|
||||
const CREDIT_GATED_DAV_METHODS = new Set(['GET', 'HEAD', 'PUT', 'COPY']);
|
||||
const DAV_ROUTE = '/{*splat}';
|
||||
|
||||
/**
|
||||
* Everything the route table declares. The subdomain keeps DAV off every other
|
||||
* host, and both limits key on the network fingerprint because nothing has
|
||||
* authenticated yet when they run — which is the point of putting them here
|
||||
* rather than inside the handler. A DAV client sends credentials on every
|
||||
* request anyway, so there is no unauthenticated browsing phase that a per-user
|
||||
* key would protect.
|
||||
*/
|
||||
const DAV_ROUTE_OPTIONS: RouteOptions = {
|
||||
subdomain: 'dav',
|
||||
rateLimit: DAV_LIMIT,
|
||||
concurrent: DAV_CONCURRENT,
|
||||
};
|
||||
|
||||
/** How long a browser may cache a DAV preflight. */
|
||||
const PREFLIGHT_MAX_AGE = '86400';
|
||||
|
||||
/** How many shared items the virtual share collections list. */
|
||||
const SHARE_LISTING_CAP = 200;
|
||||
|
||||
/** What `#context` resolved for one request, handed to every verb handler. */
|
||||
interface DavContext {
|
||||
actor: Actor;
|
||||
/** The real path being addressed, with `~` and any share mask resolved. */
|
||||
davPath: string;
|
||||
/** Lock token from `If:` / `Lock-Token:`, when the client sent one. */
|
||||
lockToken: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebDAV controller — full RFC 4918 surface on the `dav.*` subdomain.
|
||||
@@ -85,99 +127,162 @@ const CREDIT_GATED_DAV_METHODS = new Set(['GET', 'HEAD', 'PUT', 'COPY']);
|
||||
* `-token` username for token-based auth). Falls back to the global authProbe's
|
||||
* `req.actor` if a session cookie is present.
|
||||
*/
|
||||
@Controller()
|
||||
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.
|
||||
},
|
||||
);
|
||||
// -- Routes ------------------------------------------------------
|
||||
//
|
||||
// One route per verb, every one of them the same catch-all. Declaration
|
||||
// order is registration order: HEAD before GET, because express falls a
|
||||
// HEAD request back onto a GET route when it meets one first, and `@All`
|
||||
// last, because it matches any method.
|
||||
//
|
||||
// `DAV_ROUTE_OPTIONS` is the whole declarative half of the chain. The
|
||||
// credential work happens in `#serve` instead, which is what keeps the
|
||||
// limits ahead of it: HTTP Basic means a bcrypt compare per request, and a
|
||||
// caller already over budget shouldn't get one.
|
||||
|
||||
@Options(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
options(req: Request, res: Response): Promise<void> {
|
||||
// Before the auth gate, and only for a real preflight — see
|
||||
// `answerPreflight`.
|
||||
if (answerPreflight(req, res)) return Promise.resolve();
|
||||
return this.#serve(req, res, () => this.#options(res));
|
||||
}
|
||||
|
||||
// GET/HEAD/PUT/COPY move file content or duplicate it in the object store,
|
||||
// so they carry the same budget gate the FS routes declare with
|
||||
// `requireCredits`: DAV serves the same files over a metered host, and
|
||||
// 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. HEAD is metered with GET because a client asking for headers is a
|
||||
// client about to fetch the body.
|
||||
//
|
||||
// `requireCredits` itself can't be used for this: it reads `req.actor`, and
|
||||
// on the DAV host nothing has authenticated by the time route options run.
|
||||
|
||||
@Head(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
head(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#get(req, res, ctx, true), {
|
||||
credits: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
get(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#get(req, res, ctx, false), {
|
||||
credits: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Propfind(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
propfind(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#propfind(req, res, ctx));
|
||||
}
|
||||
|
||||
@Proppatch(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
proppatch(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#proppatch(res, ctx));
|
||||
}
|
||||
|
||||
@Mkcol(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
mkcol(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#mkcol(req, res, ctx));
|
||||
}
|
||||
|
||||
@Put(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
put(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#put(req, res, ctx), {
|
||||
credits: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
delete(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#delete(res, ctx));
|
||||
}
|
||||
|
||||
@Copy(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
copy(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#copy(req, res, ctx), {
|
||||
credits: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Move(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
move(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#move(req, res, ctx));
|
||||
}
|
||||
|
||||
@Lock(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
lock(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#lock(req, res, ctx));
|
||||
}
|
||||
|
||||
@Unlock(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
unlock(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, (ctx) => this.#unlock(req, res, ctx));
|
||||
}
|
||||
|
||||
/** POST, TRACE, an extension verb we don't implement. */
|
||||
@All(DAV_ROUTE, DAV_ROUTE_OPTIONS)
|
||||
unsupported(req: Request, res: Response): Promise<void> {
|
||||
return this.#serve(req, res, () => {
|
||||
res.status(405)
|
||||
.set('Allow', ALLOW_METHODS)
|
||||
.send('Method Not Allowed');
|
||||
});
|
||||
}
|
||||
|
||||
// -- Request plumbing ---------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Authenticate, gate, resolve the target path, and run `handler` — or
|
||||
* answer the request instead when any of that fails. A thrown `HttpError`
|
||||
* becomes the plain-text reply a DAV client reads; nothing calls `next`,
|
||||
* because a request that gets here is ours to answer.
|
||||
*/
|
||||
async #admit(req: Request, res: Response): Promise<boolean> {
|
||||
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;
|
||||
async #serve(
|
||||
req: Request,
|
||||
res: Response,
|
||||
handler: (ctx: DavContext) => void | Promise<void>,
|
||||
opts: { credits?: boolean } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ctx = await this.#context(req, res, opts);
|
||||
if (!ctx) return; // 401 already sent
|
||||
await handler(ctx);
|
||||
} catch (err) {
|
||||
sendDavError(res, err);
|
||||
}
|
||||
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<void> {
|
||||
// Authenticate
|
||||
/** The actor, path and lock token for one request; null once 401'd. */
|
||||
async #context(
|
||||
req: Request,
|
||||
res: Response,
|
||||
opts: { credits?: boolean },
|
||||
): Promise<DavContext | null> {
|
||||
const actor = await this.#resolveActor(req, res);
|
||||
if (!actor) return; // 401 already sent
|
||||
if (!actor) return null;
|
||||
|
||||
// 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.
|
||||
// The same suspension + pending-verification gates every other
|
||||
// authenticated route gets from `requireAuthGate` /
|
||||
// `requireVerifiedAccount`. DAV authenticates itself rather than
|
||||
// through the auth probe, so that middleware is never in 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.
|
||||
assertNotSuspended(actor.user);
|
||||
assertVerifiedAccount(actor.user);
|
||||
|
||||
// DAV authenticates here rather than in the auth probe, so the actor
|
||||
// was absent when the request context snapshotted `req.actor`. Set it
|
||||
// now: shared-path masking (and anything else downstream that asks the
|
||||
// context who is acting) is blind without it.
|
||||
// The actor was absent when the auth probe ran, so anything that reads
|
||||
// it off the request or the context — shared-path masking, egress
|
||||
// metering, which bills the `dav` host — is blind until it's published
|
||||
// here.
|
||||
req.actor = actor;
|
||||
if (Context.current()) Context.set('actor', actor);
|
||||
|
||||
// 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())) {
|
||||
if (opts.credits) {
|
||||
await assertActorHasCredits(
|
||||
this.services.metering,
|
||||
actor,
|
||||
@@ -193,47 +298,15 @@ export class WebDAVController extends PuterController {
|
||||
actor,
|
||||
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');
|
||||
}
|
||||
return {
|
||||
actor,
|
||||
davPath,
|
||||
lockToken: extractLockToken(
|
||||
(req.headers['if'] as string | undefined) ??
|
||||
(req.headers['lock-token'] as string | undefined),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// -- Auth ---------------------------------------------------------
|
||||
@@ -362,8 +435,7 @@ export class WebDAVController extends PuterController {
|
||||
async #get(
|
||||
req: Request,
|
||||
res: Response,
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
{ actor, davPath }: DavContext,
|
||||
headOnly: boolean,
|
||||
): Promise<void> {
|
||||
const entry = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
@@ -411,47 +483,47 @@ export class WebDAVController extends PuterController {
|
||||
async #propfind(
|
||||
req: Request,
|
||||
res: Response,
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
{ actor, davPath }: DavContext,
|
||||
): Promise<void> {
|
||||
const depth = req.headers.depth ?? '1';
|
||||
|
||||
const entry =
|
||||
davPath === '/'
|
||||
? null // root always exists
|
||||
: await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
if (davPath !== '/' && !entry) {
|
||||
// `/<owner>/<uuid>` — the parent of a share root — is not a real
|
||||
// path: the uuid stands in for the owner's folder, which the
|
||||
// recipient cannot see. A client walking up from a share (or down
|
||||
// toward one, as the Windows redirector does segment by segment)
|
||||
// lands here, so answer with a virtual collection whose only
|
||||
// member is the share root, rather than a dead end.
|
||||
const virtual = await this.#shareRootParentPropfind(
|
||||
actor,
|
||||
davPath,
|
||||
depth,
|
||||
);
|
||||
if (virtual) {
|
||||
res.status(207)
|
||||
.set({ 'Content-Type': 'application/xml; charset=utf-8' })
|
||||
.send(wrapMultistatus(virtual.join('\n')));
|
||||
return;
|
||||
}
|
||||
if (davPath === '/') {
|
||||
await this.#assertRead(actor, davPath);
|
||||
this.#sendMultistatus(res, await this.#rootPropfind(actor, depth));
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
|
||||
// The two levels a share mask invents — `/<owner>` and
|
||||
// `/<owner>/<uuid>` — have no entry behind them, so they answer as
|
||||
// virtual collections. Tried before the ACL check because `/<owner>`
|
||||
// _is_ a real path (the owner's home directory) that the recipient
|
||||
// can't read: a 403 there would hide every share they do have.
|
||||
const virtual =
|
||||
(await this.#shareOwnerPropfind(actor, davPath, entry, depth)) ??
|
||||
(entry
|
||||
? null
|
||||
: await this.#shareRootParentPropfind(actor, davPath, depth));
|
||||
if (virtual) {
|
||||
this.#sendMultistatus(res, virtual);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' });
|
||||
}
|
||||
|
||||
await this.#assertRead(actor, davPath);
|
||||
|
||||
const isDir = davPath === '/' || !!entry?.isDir;
|
||||
// `davPath` is the resolved real path; the href has to be the masked
|
||||
// one, like every child below it, or the self-entry names the owner's
|
||||
// real folder.
|
||||
const responses = [
|
||||
propfindEntry(entry ? maskEntryPath(entry) : davPath, entry, isDir),
|
||||
propfindEntry(maskEntryPath(entry), entry, entry.isDir),
|
||||
];
|
||||
|
||||
if (depth !== '0' && isDir && entry) {
|
||||
if (depth !== '0' && entry.isDir) {
|
||||
const children = await this.services.fs.listDirectory(
|
||||
entry.uuid,
|
||||
{},
|
||||
@@ -461,22 +533,122 @@ export class WebDAVController extends PuterController {
|
||||
propfindEntry(maskEntryPath(child), child, child.isDir),
|
||||
);
|
||||
}
|
||||
} else if (depth !== '0' && davPath === '/') {
|
||||
// Root: list top-level user directories
|
||||
const rootEntry = await this.stores.fsEntry.getEntryByPath(
|
||||
`/${actor.user!.username}`,
|
||||
}
|
||||
|
||||
this.#sendMultistatus(res, responses);
|
||||
}
|
||||
|
||||
/**
|
||||
* The root collection: the caller's own home directory, plus one collection
|
||||
* per person who has shared something with them.
|
||||
*
|
||||
* A share is addressed `/<owner>/<uuid>/<name>`, where the uuid stands in
|
||||
* for the folder the owner keeps it in. Only the `<owner>` segment is a
|
||||
* child of the root, so without this level nothing shared is reachable from
|
||||
* a mount at all — which is why a Finder mount showed only the caller's own
|
||||
* files.
|
||||
*/
|
||||
async #rootPropfind(actor: Actor, depth: string | string[]) {
|
||||
const responses = [propfindEntry('/', null, true)];
|
||||
if (depth === '0') return responses;
|
||||
|
||||
const home = await this.stores.fsEntry.getEntryByPath(
|
||||
`/${actor.user!.username}`,
|
||||
);
|
||||
if (home) {
|
||||
responses.push(
|
||||
propfindEntry(maskEntryPath(home), home, home.isDir),
|
||||
);
|
||||
if (rootEntry) {
|
||||
}
|
||||
|
||||
const owners = new Set<string>();
|
||||
for (const share of await this.#sharedWithMe(actor)) {
|
||||
const owner = share.owner?.username;
|
||||
// Self-owned entries never appear in the share index, but the home
|
||||
// directory above already covers them if one ever did.
|
||||
if (owner && owner !== actor.user!.username) owners.add(owner);
|
||||
}
|
||||
for (const owner of owners) {
|
||||
responses.push(propfindEntry(`/${owner}`, { name: owner }, true));
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* PROPFIND responses for the virtual collection at `/<owner>`, listing one
|
||||
* child per item that owner shared with the actor, or null when `davPath`
|
||||
* isn't a bare username, is the actor's own, is readable for real, or the
|
||||
* actor holds nothing of theirs — the caller then continues down the normal
|
||||
* path and gets the 403 or 404 it would otherwise have given.
|
||||
*
|
||||
* Children are the `<uuid>` level rather than the shared item itself: the
|
||||
* item's own href sits one segment deeper, and a collection may only report
|
||||
* its direct members. Nothing here reaches past that level, so `Depth:
|
||||
* infinity` on a sharer's collection still enumerates only what they
|
||||
* shared, and everything below it goes through the ordinary resolve + ACL
|
||||
* path.
|
||||
*/
|
||||
async #shareOwnerPropfind(
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
entry: FSEntry | null,
|
||||
depth: string | string[],
|
||||
): Promise<string[] | null> {
|
||||
const segments = davPath.split('/').filter(Boolean);
|
||||
if (segments.length !== 1) return null;
|
||||
const owner = segments[0]!;
|
||||
if (owner === actor.user?.username) return null;
|
||||
// Every provisioned user's home directory is a real entry and a
|
||||
// sharer is necessarily provisioned, so no directory here means no
|
||||
// share to stand in for. Checked before the share lookup so an
|
||||
// unknown name costs a 404 rather than a listing.
|
||||
if (!entry?.isDir) return null;
|
||||
// An owner who shared their whole home directory has a real listing
|
||||
// here, and it beats the virtual one.
|
||||
if (await this.#canRead(actor, davPath)) return null;
|
||||
|
||||
const shares = (await this.#sharedWithMe(actor)).filter(
|
||||
(share) => share.owner?.username === owner,
|
||||
);
|
||||
if (shares.length === 0) return null;
|
||||
|
||||
const responses = [propfindEntry(davPath, { name: owner }, true)];
|
||||
if (depth !== '0') {
|
||||
for (const share of shares) {
|
||||
responses.push(
|
||||
propfindEntry(
|
||||
maskEntryPath(rootEntry),
|
||||
rootEntry,
|
||||
rootEntry.isDir,
|
||||
`/${owner}/${share.entryUid}`,
|
||||
{
|
||||
name: share.name,
|
||||
uuid: share.entryUid,
|
||||
modified: share.modified,
|
||||
},
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* What has been shared with the actor, for the virtual collections above.
|
||||
* One page: WebDAV has no way to ask for the next one, so a caller holding
|
||||
* more shares than {@link SHARE_LISTING_CAP} sees the oldest that many.
|
||||
*
|
||||
* Listing also teaches the request's path masker every root it returns, so
|
||||
* an entry reached through one keeps that masked prefix on the way back out
|
||||
* instead of being masked against itself.
|
||||
*/
|
||||
async #sharedWithMe(actor: Actor): Promise<ResolvedShare[]> {
|
||||
const page = await this.services.share.listSharedWithMe(actor, {
|
||||
limit: SHARE_LISTING_CAP,
|
||||
});
|
||||
return page.items;
|
||||
}
|
||||
|
||||
#sendMultistatus(res: Response, responses: string[]): void {
|
||||
res.status(207)
|
||||
.set({ 'Content-Type': 'application/xml; charset=utf-8' })
|
||||
.send(wrapMultistatus(responses.join('\n')));
|
||||
@@ -486,16 +658,10 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
async #proppatch(
|
||||
res: Response,
|
||||
davPath: string,
|
||||
redis: unknown,
|
||||
lockToken: string | null,
|
||||
{ davPath, lockToken }: DavContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!(await hasWritePermission(
|
||||
redis as import('ioredis').Cluster,
|
||||
davPath,
|
||||
lockToken,
|
||||
))
|
||||
!(await hasWritePermission(this.clients.redis, davPath, lockToken))
|
||||
) {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
@@ -511,10 +677,7 @@ export class WebDAVController extends PuterController {
|
||||
async #mkcol(
|
||||
req: Request,
|
||||
res: Response,
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
redis: unknown,
|
||||
lockToken: string | null,
|
||||
{ actor, davPath, lockToken }: DavContext,
|
||||
): Promise<void> {
|
||||
if (davPath === '/')
|
||||
throw new HttpError(403, 'Cannot create at root', {
|
||||
@@ -529,11 +692,7 @@ export class WebDAVController extends PuterController {
|
||||
});
|
||||
}
|
||||
if (
|
||||
!(await hasWritePermission(
|
||||
redis as import('ioredis').Cluster,
|
||||
davPath,
|
||||
lockToken,
|
||||
))
|
||||
!(await hasWritePermission(this.clients.redis, davPath, lockToken))
|
||||
) {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
@@ -561,10 +720,7 @@ export class WebDAVController extends PuterController {
|
||||
async #put(
|
||||
req: Request,
|
||||
res: Response,
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
redis: unknown,
|
||||
lockToken: string | null,
|
||||
{ actor, davPath, lockToken }: DavContext,
|
||||
): Promise<void> {
|
||||
const name = pathPosix.basename(davPath);
|
||||
if (MACOS_JUNK_REGEX.test(name)) {
|
||||
@@ -572,11 +728,7 @@ export class WebDAVController extends PuterController {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(await hasWritePermission(
|
||||
redis as import('ioredis').Cluster,
|
||||
davPath,
|
||||
lockToken,
|
||||
))
|
||||
!(await hasWritePermission(this.clients.redis, davPath, lockToken))
|
||||
) {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
@@ -634,17 +786,10 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
async #delete(
|
||||
res: Response,
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
redis: unknown,
|
||||
lockToken: string | null,
|
||||
{ actor, davPath, lockToken }: DavContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!(await hasWritePermission(
|
||||
redis as import('ioredis').Cluster,
|
||||
davPath,
|
||||
lockToken,
|
||||
))
|
||||
!(await hasWritePermission(this.clients.redis, davPath, lockToken))
|
||||
) {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
@@ -665,18 +810,11 @@ export class WebDAVController extends PuterController {
|
||||
async #copy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
redis: unknown,
|
||||
lockToken: string | null,
|
||||
{ actor, davPath, lockToken }: DavContext,
|
||||
): Promise<void> {
|
||||
const destPath = await this.#parseDestination(req);
|
||||
if (
|
||||
!(await hasWritePermission(
|
||||
redis as import('ioredis').Cluster,
|
||||
destPath,
|
||||
lockToken,
|
||||
))
|
||||
!(await hasWritePermission(this.clients.redis, destPath, lockToken))
|
||||
) {
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
}
|
||||
@@ -723,13 +861,10 @@ export class WebDAVController extends PuterController {
|
||||
async #move(
|
||||
req: Request,
|
||||
res: Response,
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
redis: unknown,
|
||||
lockToken: string | null,
|
||||
{ actor, davPath, lockToken }: DavContext,
|
||||
): Promise<void> {
|
||||
const destPath = await this.#parseDestination(req);
|
||||
const r = redis as import('ioredis').Cluster;
|
||||
const r = this.clients.redis;
|
||||
if (!(await hasWritePermission(r, davPath, lockToken)))
|
||||
throw new HttpError(423, 'Locked', { legacyCode: 'conflict' });
|
||||
if (!(await hasWritePermission(r, destPath, lockToken)))
|
||||
@@ -779,12 +914,9 @@ export class WebDAVController extends PuterController {
|
||||
async #lock(
|
||||
req: Request,
|
||||
res: Response,
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
redis: unknown,
|
||||
headerToken: string | null,
|
||||
{ actor, davPath, lockToken: headerToken }: DavContext,
|
||||
): Promise<void> {
|
||||
const r = redis as import('ioredis').Cluster;
|
||||
const r = this.clients.redis;
|
||||
|
||||
// ACL must succeed before any lock state is touched — otherwise
|
||||
// an authenticated user could lock paths they don't own (e.g. `/`)
|
||||
@@ -846,10 +978,9 @@ export class WebDAVController extends PuterController {
|
||||
async #unlock(
|
||||
req: Request,
|
||||
res: Response,
|
||||
davPath: string,
|
||||
redis: unknown,
|
||||
{ davPath }: DavContext,
|
||||
): Promise<void> {
|
||||
const r = redis as import('ioredis').Cluster;
|
||||
const r = this.clients.redis;
|
||||
const tokenHeader = req.headers['lock-token'] as string | undefined;
|
||||
const token = extractLockToken(tokenHeader);
|
||||
if (!token)
|
||||
@@ -906,7 +1037,10 @@ export class WebDAVController extends PuterController {
|
||||
if (!allowed) return null;
|
||||
|
||||
const maskedRoot = `/${parsed.ownerUsername}/${root.uuid}/${root.name}`;
|
||||
const responses = [propfindEntry(davPath, null, true)];
|
||||
// The uuid segment has no name of its own, so it borrows the shared
|
||||
// item's — a client that reads `displayname` shows something
|
||||
// recognizable rather than a bare uuid.
|
||||
const responses = [propfindEntry(davPath, { name: root.name }, true)];
|
||||
if (depth !== '0') {
|
||||
responses.push(propfindEntry(maskedRoot, root, root.isDir));
|
||||
}
|
||||
@@ -915,13 +1049,19 @@ export class WebDAVController extends PuterController {
|
||||
|
||||
// -- ACL helpers -------------------------------------------------
|
||||
|
||||
async #canRead(actor: Actor, path: string): Promise<boolean> {
|
||||
return await this.services.acl.check(
|
||||
actor,
|
||||
{
|
||||
path,
|
||||
resolveAncestors: () => this.services.fs.getAncestorChain(path),
|
||||
},
|
||||
'read',
|
||||
);
|
||||
}
|
||||
|
||||
async #assertRead(actor: Actor, path: string): Promise<void> {
|
||||
const descriptor = {
|
||||
path,
|
||||
resolveAncestors: () => this.services.fs.getAncestorChain(path),
|
||||
};
|
||||
const ok = await this.services.acl.check(actor, descriptor, 'read');
|
||||
if (!ok)
|
||||
if (!(await this.#canRead(actor, path)))
|
||||
throw new HttpError(403, 'Permission denied', {
|
||||
legacyCode: 'permission_denied',
|
||||
});
|
||||
@@ -989,6 +1129,53 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Middleware helpers -----------------------------------------------
|
||||
|
||||
/**
|
||||
* Answer a CORS preflight before anything can reject it. True when this request
|
||||
* was one and has been answered.
|
||||
*
|
||||
* A browser sends a credential-less `OPTIONS` ahead of any cross-origin
|
||||
* PROPFIND/GET/PUT and treats a non-2xx reply as a failed preflight, never
|
||||
* sending the real request. The authenticated OPTIONS path answers 401, which
|
||||
* blocked every browser DAV client — the VS Code app's mount included — before
|
||||
* it could present a token.
|
||||
*
|
||||
* Only an actual preflight is answered here: it's the one OPTIONS that carries
|
||||
* `Access-Control-Request-Method`. A native client's plain OPTIONS still goes
|
||||
* through auth, so macOS keeps getting the 401-with-`DAV:` reply it opens a
|
||||
* mount with.
|
||||
*/
|
||||
function answerPreflight(req: Request, res: Response): boolean {
|
||||
if (!req.headers.origin || !req.headers['access-control-request-method']) {
|
||||
return false;
|
||||
}
|
||||
// `Allow-Origin`/`-Methods`/`-Headers` were set on the way in by the
|
||||
// server's CORS middleware. `Max-Age` is what belongs here specifically:
|
||||
// without it a browser re-preflights every URL every few seconds, doubling
|
||||
// the request count of a mount and its share of the DAV rate limit.
|
||||
res.status(200)
|
||||
.set({
|
||||
...DAV_HEADERS,
|
||||
Allow: ALLOW_METHODS,
|
||||
'Access-Control-Max-Age': PREFLIGHT_MAX_AGE,
|
||||
'Content-Length': '0',
|
||||
})
|
||||
.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** DAV clients read a status and a plain-text reason, not our JSON error shape. */
|
||||
function sendDavError(res: Response, err: unknown): void {
|
||||
if (res.headersSent) return;
|
||||
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');
|
||||
}
|
||||
|
||||
// -- XML helpers ------------------------------------------------------
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
@@ -1028,9 +1215,18 @@ function entryEtag(uid: string, ts: number | string): string {
|
||||
return `"${uid}-${toEpochSeconds(ts)}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a `<D:response>` needs to describe one resource. An {@link FSEntry}
|
||||
* satisfies it; the virtual collections a share mask invents pass whatever they
|
||||
* know, which is sometimes only a name.
|
||||
*/
|
||||
type PropfindTarget = Partial<
|
||||
Pick<FSEntry, 'name' | 'uuid' | 'modified' | 'created' | 'size'>
|
||||
>;
|
||||
|
||||
function propfindEntry(
|
||||
href: string,
|
||||
entry: FSEntry | null,
|
||||
entry: PropfindTarget | null,
|
||||
isDir: boolean,
|
||||
): string {
|
||||
const encodedHref =
|
||||
@@ -1056,7 +1252,7 @@ function propfindEntry(
|
||||
|
||||
if (!isDir && entry) {
|
||||
props += `\n <D:getcontentlength>${entry.size ?? 0}</D:getcontentlength>`;
|
||||
const mime = mimeFromExt(pathPosix.extname(entry.name));
|
||||
const mime = mimeFromExt(pathPosix.extname(name));
|
||||
props += `\n <D:getcontenttype>${escapeXml(mime)}</D:getcontenttype>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Controller, Get, Propfind } from './decorators.ts';
|
||||
import { PuterRouter } from './PuterRouter.ts';
|
||||
|
||||
interface Registrable {
|
||||
registerRoutes: (router: PuterRouter) => void;
|
||||
}
|
||||
|
||||
const collect = (controller: Registrable) => {
|
||||
const router = new PuterRouter();
|
||||
controller.registerRoutes(router);
|
||||
return router.routes;
|
||||
};
|
||||
|
||||
@Controller('/demo')
|
||||
class DemoController {
|
||||
@Get('/thing')
|
||||
thing() {}
|
||||
|
||||
@Propfind('/{*splat}', { subdomain: 'dav' })
|
||||
browse() {}
|
||||
}
|
||||
|
||||
describe('route decorators', () => {
|
||||
it('collects each decorated method once, in declaration order', () => {
|
||||
const routes = collect(new DemoController() as unknown as Registrable);
|
||||
expect(routes.map((r) => r.method)).toEqual(['get', 'propfind']);
|
||||
expect(routes[1]!.options).toEqual({ subdomain: 'dav' });
|
||||
});
|
||||
|
||||
it('does not re-collect when a second instance is constructed', () => {
|
||||
// Method initializers run per instance but collect onto the shared
|
||||
// prototype, so without a guard a process that builds two servers
|
||||
// registers every route on the second one twice over.
|
||||
const first = collect(new DemoController() as unknown as Registrable);
|
||||
const second = collect(new DemoController() as unknown as Registrable);
|
||||
const third = collect(new DemoController() as unknown as Registrable);
|
||||
expect(second).toHaveLength(first.length);
|
||||
expect(third).toHaveLength(first.length);
|
||||
expect(third.map((r) => r.method)).toEqual(['get', 'propfind']);
|
||||
});
|
||||
|
||||
it('binds handlers to the instance that registered them', () => {
|
||||
@Controller()
|
||||
class Bound {
|
||||
marker = 'mine';
|
||||
seen: string | undefined;
|
||||
|
||||
@Get('/x')
|
||||
handle() {
|
||||
this.seen = this.marker;
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new Bound();
|
||||
const [route] = collect(instance as unknown as Registrable);
|
||||
(route!.handler as unknown as () => void)();
|
||||
expect(instance.seen).toBe('mine');
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,21 @@ const createMethodDecorator = (method: Exclude<RouteMethod, 'use'>) => {
|
||||
const proto = Object.getPrototypeOf(
|
||||
this as object,
|
||||
) as DecoratedPrototype;
|
||||
getOrInitRoutes(proto).push({
|
||||
const routes = getOrInitRoutes(proto);
|
||||
// Initializers run once per *instance* but collect onto the
|
||||
// shared prototype, so a process that builds a second server
|
||||
// (every multi-server test file) would otherwise register
|
||||
// every route twice. The decorated method and the path literal
|
||||
// are the same references each time, so identity recognizes a
|
||||
// repeat without comparing option objects.
|
||||
const already = routes.some(
|
||||
(r) =>
|
||||
r.handler === (target as unknown as RequestHandler) &&
|
||||
r.method === method &&
|
||||
r.path === path,
|
||||
);
|
||||
if (already) return;
|
||||
routes.push({
|
||||
method,
|
||||
path,
|
||||
options,
|
||||
|
||||
@@ -205,6 +205,73 @@ describe('PuterServer host header validation', () => {
|
||||
expect(res.headers['dav']).toContain('2');
|
||||
});
|
||||
|
||||
// The DAV controller declares one route per verb, so these check what only
|
||||
// a real server can: that express materializes the WebDAV verbs, that the
|
||||
// catch-all matches the root collection as well as deep paths, and that it
|
||||
// stays on the `dav` subdomain.
|
||||
it('routes every WebDAV verb on the dav subdomain, root included', async () => {
|
||||
const dav = { host: `dav.puter.localhost:${port}` };
|
||||
for (const [method, path] of [
|
||||
['PROPFIND', '/'],
|
||||
['PROPFIND', '/some-user/Documents'],
|
||||
['PROPPATCH', '/some-user/a.txt'],
|
||||
['MKCOL', '/some-user/new-folder'],
|
||||
['LOCK', '/some-user/a.txt'],
|
||||
// Not a verb the controller implements; the catch-all that answers
|
||||
// 405 has to authenticate first, like every other route.
|
||||
['SEARCH', '/some-user'],
|
||||
] as const) {
|
||||
const res = await request(path, dav, method);
|
||||
// Unauthenticated, so the reply is the auth challenge — what
|
||||
// matters is that it came from the DAV controller and not from the
|
||||
// 404 handler.
|
||||
expect(res.status, `${method} ${path}`).toBe(401);
|
||||
expect(res.headers['www-authenticate']).toContain('Basic');
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves WebDAV verbs on other hosts alone', async () => {
|
||||
// The DAV catch-all matches any path, so the subdomain gate is the only
|
||||
// thing keeping it off the main domain.
|
||||
const res = await request(
|
||||
'/',
|
||||
{ host: `puter.localhost:${port}` },
|
||||
'PROPFIND',
|
||||
);
|
||||
expect(res.status).not.toBe(401);
|
||||
expect(res.status).not.toBe(405);
|
||||
expect(res.headers['www-authenticate']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers a browser CORS preflight on the dav subdomain', async () => {
|
||||
// Browsers send this credential-less probe before any cross-origin DAV
|
||||
// verb and abandon the request unless it comes back 2xx.
|
||||
const res = await request(
|
||||
'/some-user/.vscode/settings.json',
|
||||
{
|
||||
host: `dav.puter.localhost:${port}`,
|
||||
origin: 'https://code.puter.localhost',
|
||||
'access-control-request-method': 'PROPFIND',
|
||||
'access-control-request-headers': 'authorization,depth',
|
||||
},
|
||||
'OPTIONS',
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['dav']).toContain('1');
|
||||
expect(res.headers['access-control-max-age']).toBe('86400');
|
||||
expect(String(res.headers['access-control-allow-headers'])).toContain(
|
||||
'Depth',
|
||||
);
|
||||
expect(res.headers['access-control-allow-origin']).toBe(
|
||||
'https://code.puter.localhost',
|
||||
);
|
||||
// A browser client reads ETags and lock tokens off the reply, so they
|
||||
// have to be exposed to it.
|
||||
expect(String(res.headers['access-control-expose-headers'])).toContain(
|
||||
'ETag',
|
||||
);
|
||||
});
|
||||
|
||||
it('still short-circuits OPTIONS preflight off the dav subdomain', async () => {
|
||||
const res = await request(
|
||||
'/some-path',
|
||||
|
||||
@@ -681,10 +681,31 @@ export class PuterServer {
|
||||
'Overwrite',
|
||||
'If',
|
||||
'Lock-Token',
|
||||
'Timeout',
|
||||
'X-Expected-Entity-Length',
|
||||
'DAV',
|
||||
'stripe-signature',
|
||||
].join(', ');
|
||||
|
||||
// What a browser DAV client is allowed to read back off a response.
|
||||
// Everything below is a header the WebDAV controller sets and a client
|
||||
// acts on: without this list `fetch` hands the page a response whose
|
||||
// ETag, lock token and content range are all invisible, so it can't
|
||||
// cache, lock, or resume anything.
|
||||
const davExposedHeaders = [
|
||||
'DAV',
|
||||
'MS-Author-Via',
|
||||
'Allow',
|
||||
'ETag',
|
||||
'Last-Modified',
|
||||
'Content-Length',
|
||||
'Content-Range',
|
||||
'Accept-Ranges',
|
||||
'Location',
|
||||
'Lock-Token',
|
||||
'WWW-Authenticate',
|
||||
].join(', ');
|
||||
|
||||
this.#app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
const subdomain = activeSubdomain(req);
|
||||
@@ -700,6 +721,10 @@ export class PuterServer {
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
} else if (subdomain === 'dav') {
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'false');
|
||||
res.setHeader(
|
||||
'Access-Control-Expose-Headers',
|
||||
davExposedHeaders,
|
||||
);
|
||||
}
|
||||
|
||||
res.setHeader('Access-Control-Allow-Methods', allowedMethods);
|
||||
|
||||
Reference in New Issue
Block a user