From efb92397bfa1582e8570ddfb6a4ae2a72dff032e Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Mon, 11 May 2026 19:32:29 -0400 Subject: [PATCH] feat: add WebDAV API-key protocol routes (#390) * feat: add WebDAV API key routes Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5 * fix: route WebDAV preview requests to worker Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5 --- server/app.ts | 3 + server/auth.ts | 8 +- server/routes/webdav.integration.test.ts | 387 +++++++++++++++++++++++ server/routes/webdav.ts | 359 +++++++++++++++++++++ server/services/webdav-path.ts | 133 ++++++++ server/services/webdav-xml.ts | 71 +++++ wrangler.toml | 2 +- 7 files changed, 960 insertions(+), 3 deletions(-) create mode 100644 server/routes/webdav.integration.test.ts create mode 100644 server/routes/webdav.ts create mode 100644 server/services/webdav-path.ts create mode 100644 server/services/webdav-xml.ts diff --git a/server/app.ts b/server/app.ts index ee791eda..e93b13db 100644 --- a/server/app.ts +++ b/server/app.ts @@ -32,6 +32,7 @@ import system from './routes/system' import { publicTeams, teams } from './routes/teams' import trash from './routes/trash' import users from './routes/users' +import webdav from './routes/webdav' export function createApp(platform: Platform, auth: Auth) { const app = new Hono() @@ -55,6 +56,8 @@ export function createApp(platform: Platform, auth: Auth) { return a.handler(c.req.raw) }) + app.route('/dav', webdav) + // Public routes — no auth required; mount before authMiddleware. // /api/shares/:token endpoints are covered by run_worker_first=["/api/*"] in wrangler.toml. // /r/* is listed separately in run_worker_first. diff --git a/server/auth.ts b/server/auth.ts index 20e5ca8a..a857b89d 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -27,6 +27,9 @@ import { getEffectiveSignupMode } from './services/signup-mode-guard' import { acceptSiteInvitation, validateSiteInvitation } from './services/site-invitations' import { checkTeamLimit } from './services/team-count-guard' +export const IMAGE_HOSTING_API_KEY_PERMISSIONS = { 'image-hosting': ['upload'] } +export const WEBDAV_API_KEY_PERMISSIONS = { webdav: ['read', 'write'] } + // better-auth's default password hasher is pure-JS scrypt from @noble/hashes, // which blows past Cloudflare Workers' CPU budget and triggers error 1102. // We use node:crypto.scryptSync via server/lib/password.ts (native OpenSSL, @@ -269,9 +272,10 @@ export async function createAuth( timeWindow: 60_000, // 60 seconds maxRequests: 60, // 60 requests per window ≈ 1 req/s sustained }, - // Declare the image-hosting:upload permission so upload routes can require it + // Existing image-hosting keys rely on default upload permission. + // WebDAV keys must be created with explicit webdav:read/write permissions. permissions: { - defaultPermissions: { 'image-hosting': ['upload'] }, + defaultPermissions: IMAGE_HOSTING_API_KEY_PERMISSIONS, }, }), ], diff --git a/server/routes/webdav.integration.test.ts b/server/routes/webdav.integration.test.ts new file mode 100644 index 00000000..f8f19226 --- /dev/null +++ b/server/routes/webdav.integration.test.ts @@ -0,0 +1,387 @@ +import { sql } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { S3Service } from '../services/s3.js' +import { authedHeaders, createTestApp } from '../test/setup.js' + +type TestApp = Awaited> + +const storage = { + id: 'dav-storage', + title: 'DAV Storage', + mode: 'private', + bucket: 'dav-bucket', + endpoint: 'https://s3.example.com', + region: 'us-east-1', + accessKey: 'key', + secretKey: 'secret', +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(S3Service.prototype, 'presignDownload').mockResolvedValue('https://download.example.com/file.txt') + vi.spyOn(S3Service.prototype, 'putObject').mockResolvedValue(undefined) + vi.spyOn(S3Service.prototype, 'copyObject').mockResolvedValue(undefined) +}) + +async function seedStorage(db: TestApp['db']) { + const now = Date.now() + await db.run(sql` + INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) + VALUES (${storage.id}, ${storage.title}, ${storage.mode}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now}) + `) +} + +async function org(db: TestApp['db']) { + const rows = await db.all<{ id: string; slug: string }>(sql` + SELECT id, slug FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1 + `) + if (!rows[0]) throw new Error('No personal org found') + return rows[0] +} + +async function userId(db: TestApp['db']) { + const rows = await db.all<{ id: string }>(sql`SELECT id FROM user LIMIT 1`) + if (!rows[0]) throw new Error('No user found') + return rows[0].id +} + +async function apiKey(auth: TestApp['auth'], orgId: string, userId: string, permissions: Record) { + // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed + const result = (await (auth.api as any).createApiKey({ + body: { organizationId: orgId, userId, permissions }, + })) as { key: string } + return result.key +} + +async function file( + db: TestApp['db'], + orgId: string, + opts: { id: string; name: string; parent?: string; size?: number }, +) { + const now = Date.now() + await db.run(sql` + INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at) + VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', ${opts.size ?? 5}, 0, ${opts.parent ?? ''}, ${`objects/${opts.id}.txt`}, ${storage.id}, 'active', ${now}, ${now}) + `) +} + +async function folder(db: TestApp['db'], orgId: string, opts: { id: string; name: string; parent?: string }) { + const now = Date.now() + await db.run(sql` + INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at) + VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'folder', 0, 1, ${opts.parent ?? ''}, '', ${storage.id}, 'active', ${now}, ${now}) + `) +} + +describe('WebDAV API', () => { + it('rejects missing and insufficient API keys without accepting session cookies', async () => { + const { app, db, auth } = await createTestApp() + const headers = await authedHeaders(app) + const { id, slug } = await org(db) + const readKey = await apiKey(auth, id, await userId(db), { webdav: ['read'] }) + + expect((await app.request(`/dav/${slug}/`, { method: 'PROPFIND' })).status).toBe(401) + expect((await app.request(`/dav/${slug}/`, { method: 'PROPFIND', headers })).status).toBe(401) + expect( + ( + await app.request(`/dav/${slug}/new.txt`, { + method: 'PUT', + headers: { Authorization: `Bearer ${readKey}` }, + body: 'content', + }) + ).status, + ).toBe(401) + }) + + it('PROPFIND lists the mount root, workspace root, and folder children', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['read'] }) + await folder(db, workspace.id, { id: 'docs', name: 'Docs' }) + await file(db, workspace.id, { id: 'readme', name: 'readme.txt', parent: 'Docs' }) + + const root = await app.request('/dav/', { method: 'PROPFIND', headers: { Authorization: `Bearer ${key}` } }) + expect(root.status).toBe(207) + expect(await root.text()).toContain(`/dav/${workspace.slug}/`) + + const docs = await app.request(`/dav/${workspace.slug}/Docs`, { + method: 'PROPFIND', + headers: { Authorization: `Bearer ${key}` }, + }) + expect(docs.status).toBe(207) + const xml = await docs.text() + expect(xml).toContain(`/dav/${workspace.slug}/Docs/`) + expect(xml).toContain(`/dav/${workspace.slug}/Docs/readme.txt`) + }) + + it('GET redirects to storage and HEAD returns file headers', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['read'] }) + await file(db, workspace.id, { id: 'readme', name: 'readme.txt', size: 12 }) + + const head = await app.request(`/dav/${workspace.slug}/readme.txt`, { + method: 'HEAD', + headers: { Authorization: `Bearer ${key}` }, + }) + expect(head.status).toBe(200) + expect(head.headers.get('Content-Type')).toBe('text/plain') + expect(head.headers.get('Content-Length')).toBe('12') + + const get = await app.request(`/dav/${workspace.slug}/readme.txt`, { + method: 'GET', + headers: { Authorization: `Bearer ${key}` }, + }) + expect(get.status).toBe(302) + expect(get.headers.get('Location')).toBe('https://download.example.com/file.txt') + }) + + it('OPTIONS advertises DAV methods', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['read'] }) + + const res = await app.request('/dav/', { method: 'OPTIONS', headers: { Authorization: `Bearer ${key}` } }) + expect(res.status).toBe(204) + expect(res.headers.get('DAV')).toBe('1') + expect(res.headers.get('Allow')).toContain('PROPFIND') + }) + + it('rejects API keys when verification throws', async () => { + const { app, auth } = await createTestApp() + // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed + vi.spyOn(auth.api as any, 'verifyApiKey').mockRejectedValueOnce(new Error('verify failed')) + + const res = await app.request('/dav/', { method: 'PROPFIND', headers: { Authorization: 'Bearer bad-key' } }) + expect(res.status).toBe(401) + expect(await res.text()).toBe('Invalid API key') + }) + + it('PUT creates a file matter and writes through configured storage', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['write'] }) + + const res = await app.request(`/dav/${workspace.slug}/upload.txt`, { + method: 'PUT', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'text/plain' }, + body: 'hello dav', + }) + expect(res.status).toBe(201) + expect(S3Service.prototype.putObject).toHaveBeenCalledWith( + expect.objectContaining({ id: storage.id }), + expect.any(String), + expect.any(Uint8Array), + 'text/plain', + ) + + const rows = await db.all<{ name: string; size: number; status: string }>( + sql`SELECT name, size, status FROM matters WHERE org_id = ${workspace.id} AND name = 'upload.txt'`, + ) + expect(rows[0]).toEqual({ name: 'upload.txt', size: 9, status: 'active' }) + }) + + it('PUT updates an existing file matter and rejects collection writes', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['write'] }) + await file(db, workspace.id, { id: 'existing', name: 'existing', size: 20 }) + await folder(db, workspace.id, { id: 'docs', name: 'Docs' }) + + const update = await app.request(`/dav/${workspace.slug}/existing`, { + method: 'PUT', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/octet-stream' }, + body: 'short', + }) + expect(update.status).toBe(204) + const rows = await db.all<{ size: number; type: string }>(sql`SELECT size, type FROM matters WHERE id = 'existing'`) + expect(rows[0]).toEqual({ size: 5, type: 'application/octet-stream' }) + + const root = await app.request(`/dav/${workspace.slug}/`, { + method: 'PUT', + headers: { Authorization: `Bearer ${key}` }, + body: 'nope', + }) + expect(root.status).toBe(405) + + const folderWrite = await app.request(`/dav/${workspace.slug}/Docs`, { + method: 'PUT', + headers: { Authorization: `Bearer ${key}` }, + body: 'nope', + }) + expect(folderWrite.status).toBe(409) + }) + + it('PUT rolls back quota reservation when storage write fails', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['write'] }) + vi.mocked(S3Service.prototype.putObject).mockRejectedValueOnce(new Error('s3 failed')) + + const res = await app.request(`/dav/${workspace.slug}/will-fail.txt`, { + method: 'PUT', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'text/plain' }, + body: 'bytes', + }) + expect(res.status).toBe(500) + const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storage.id}`) + expect(rows[0]?.used).toBe(0) + }) + + it('MKCOL creates a folder matter', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['write'] }) + + const res = await app.request(`/dav/${workspace.slug}/Projects`, { + method: 'MKCOL', + headers: { Authorization: `Bearer ${key}` }, + }) + expect(res.status).toBe(201) + const rows = await db.all<{ dirtype: number }>( + sql`SELECT dirtype FROM matters WHERE org_id = ${workspace.id} AND name = 'Projects'`, + ) + expect(rows[0]?.dirtype).toBe(1) + }) + + it('MKCOL rejects existing targets and missing parent collections', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['write'] }) + await folder(db, workspace.id, { id: 'projects', name: 'Projects' }) + await file(db, workspace.id, { id: 'file-parent', name: 'file-parent.txt' }) + + const existing = await app.request(`/dav/${workspace.slug}/Projects`, { + method: 'MKCOL', + headers: { Authorization: `Bearer ${key}` }, + }) + expect(existing.status).toBe(405) + + const missingParent = await app.request(`/dav/${workspace.slug}/Missing/Child`, { + method: 'MKCOL', + headers: { Authorization: `Bearer ${key}` }, + }) + expect(missingParent.status).toBe(409) + + const fileParent = await app.request(`/dav/${workspace.slug}/file-parent.txt/Child`, { + method: 'MKCOL', + headers: { Authorization: `Bearer ${key}` }, + }) + expect(fileParent.status).toBe(405) + }) + + it('MOVE, COPY, and DELETE stay within org scope; DELETE trashes instead of purging', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['write'] }) + await file(db, workspace.id, { id: 'move-me', name: 'move-me.txt' }) + + const move = await app.request(`/dav/${workspace.slug}/move-me.txt`, { + method: 'MOVE', + headers: { Authorization: `Bearer ${key}`, Destination: `http://localhost/dav/${workspace.slug}/moved.txt` }, + }) + expect(move.status).toBe(201) + + const copy = await app.request(`/dav/${workspace.slug}/moved.txt`, { + method: 'COPY', + headers: { Authorization: `Bearer ${key}`, Destination: `http://localhost/dav/${workspace.slug}/copied.txt` }, + }) + expect(copy.status).toBe(201) + + const badMove = await app.request(`/dav/${workspace.slug}/moved.txt`, { + method: 'MOVE', + headers: { Authorization: `Bearer ${key}`, Destination: 'http://localhost/dav/other-workspace/nope.txt' }, + }) + expect(badMove.status).toBe(404) + + const del = await app.request(`/dav/${workspace.slug}/copied.txt`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${key}` }, + }) + expect(del.status).toBe(204) + const rows = await db.all<{ status: string }>( + sql`SELECT status FROM matters WHERE org_id = ${workspace.id} AND name = 'copied.txt'`, + ) + expect(rows[0]?.status).toBe('trashed') + }) + + it('MOVE and COPY reject invalid destinations', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['write'] }) + await file(db, workspace.id, { id: 'source', name: 'source.txt' }) + await file(db, workspace.id, { id: 'target', name: 'target.txt' }) + + const noDestination = await app.request(`/dav/${workspace.slug}/source.txt`, { + method: 'MOVE', + headers: { Authorization: `Bearer ${key}` }, + }) + expect(noDestination.status).toBe(400) + + const crossOrigin = await app.request(`/dav/${workspace.slug}/source.txt`, { + method: 'MOVE', + headers: { Authorization: `Bearer ${key}`, Destination: `https://example.com/dav/${workspace.slug}/moved.txt` }, + }) + expect(crossOrigin.status).toBe(400) + + const existing = await app.request(`/dav/${workspace.slug}/source.txt`, { + method: 'COPY', + headers: { Authorization: `Bearer ${key}`, Destination: `http://localhost/dav/${workspace.slug}/target.txt` }, + }) + expect(existing.status).toBe(412) + }) + + it('COPY rolls back quota reservation when storage copy fails', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['write'] }) + await file(db, workspace.id, { id: 'source', name: 'source.txt', size: 12 }) + vi.mocked(S3Service.prototype.copyObject).mockRejectedValueOnce(new Error('copy failed')) + + const res = await app.request(`/dav/${workspace.slug}/source.txt`, { + method: 'COPY', + headers: { Authorization: `Bearer ${key}`, Destination: `http://localhost/dav/${workspace.slug}/copy.txt` }, + }) + expect(res.status).toBe(500) + const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storage.id}`) + expect(rows[0]?.used).toBe(0) + }) + + it('rejects traversal, empty segments, and encoded path separators', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + const workspace = await org(db) + const key = await apiKey(auth, workspace.id, await userId(db), { webdav: ['read'] }) + + for (const path of [ + `/dav/${workspace.slug}/%252e%252e/x`, + `/dav/${workspace.slug}//x`, + `/dav/${workspace.slug}/a%2Fb`, + `/dav/${workspace.slug}/%E0%A4%A`, + ]) { + const res = await app.request(path, { method: 'PROPFIND', headers: { Authorization: `Bearer ${key}` } }) + expect(res.status, path).toBe(400) + } + }) +}) diff --git a/server/routes/webdav.ts b/server/routes/webdav.ts new file mode 100644 index 00000000..cfd5a8a1 --- /dev/null +++ b/server/routes/webdav.ts @@ -0,0 +1,359 @@ +import { and, eq } from 'drizzle-orm' +import type { Context } from 'hono' +import { Hono } from 'hono' +import { DirType, ObjectStatus } from '../../shared/constants' +import type { Storage as S3Storage } from '../../shared/types' +import { matters } from '../db/schema' +import type { Env } from '../middleware/platform' +import { + copyMatter, + createMatter, + decrementUsage, + incrementUsageIfAllowed, + trashMatter, + updateMatter, +} from '../services/matter' +import { NameConflictError } from '../services/matter-name-conflict' +import { buildObjectKey } from '../services/path-template' +import { S3Service } from '../services/s3' +import { getStorage, selectStorage } from '../services/storage' +import { + ensureFolder, + joinMatterPath, + listChildren, + resolveExistingWebDavPath, + resolveWebDavPath, + WebDavPathError, +} from '../services/webdav-path' +import { matterEntry, mountRootEntry, multistatus, workspaceEntry } from '../services/webdav-xml' + +const s3 = new S3Service() +const READ_METHODS = new Set(['OPTIONS', 'PROPFIND', 'GET', 'HEAD']) +const WRITE_METHODS = new Set(['PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY']) +const WEBDAV_RESOURCE = 'webdav' + +type DavContext = Context +type DavAuth = { orgId: string; userId?: string } + +async function requireWebDavApiKey(c: DavContext): Promise { + const method = c.req.method.toUpperCase() + const action = READ_METHODS.has(method) ? 'read' : WRITE_METHODS.has(method) ? 'write' : null + if (!action) return c.text('Method Not Allowed', 405) + + const authHeader = c.req.raw.headers.get('Authorization') + if (!authHeader?.startsWith('Bearer ')) return c.text('Unauthorized', 401) + + const key = authHeader.slice('Bearer '.length).trim() + if (!key) return c.text('Unauthorized', 401) + + try { + // biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed + const result = (await (c.get('auth').api as any).verifyApiKey({ + body: { key, permissions: { [WEBDAV_RESOURCE]: [action] } }, + })) as { + valid: boolean + key: { referenceId: string; userId?: string } | null + error: { message?: string } | null + } + if (!result?.valid || !result.key?.referenceId) return c.text(result?.error?.message ?? 'Unauthorized', 401) + return { orgId: result.key.referenceId, userId: result.key.userId } + } catch { + return c.text('Invalid API key', 401) + } +} + +function davPath(c: DavContext): string { + return new URL(c.req.url).pathname +} + +function davError(c: DavContext, error: unknown): Response { + if (error instanceof WebDavPathError) return new Response(error.message, { status: error.status }) + if (error instanceof NameConflictError) return c.text(error.message, 409) + throw error +} + +function fileExt(name: string): string { + const dot = name.lastIndexOf('.') + return dot >= 0 ? name.slice(dot) : '' +} + +function destinationPath(c: DavContext): string | Response { + const header = c.req.header('Destination') + if (!header) return c.text('Destination header required', 400) + const url = new URL(header, c.req.url) + if (url.origin !== new URL(c.req.url).origin) return c.text('Cross-origin DAV destination rejected', 400) + return url.pathname +} + +async function ensureParentCollection( + db: Env['Variables']['platform']['db'], + orgId: string, + workspaceSlug: string, + parent: string, +): Promise { + if (!parent) return + const target = await resolveWebDavPath(db, orgId, `/dav/${workspaceSlug}/${parent}`) + ensureFolder(target) +} + +const app = new Hono().on( + ['OPTIONS', 'PROPFIND', 'GET', 'HEAD', 'PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY'], + '/*', + async (c) => { + const auth = await requireWebDavApiKey(c) + if (auth instanceof Response) return auth + + switch (c.req.method.toUpperCase()) { + case 'OPTIONS': + return new Response(null, { + status: 204, + headers: { Allow: 'OPTIONS, PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY', DAV: '1' }, + }) + case 'PROPFIND': + return propfind(c, auth) + case 'GET': + case 'HEAD': + return readFile(c, auth) + case 'PUT': + return putFile(c, auth) + case 'MKCOL': + return makeCollection(c, auth) + case 'DELETE': + return deleteMatter(c, auth) + case 'MOVE': + return moveMatter(c, auth) + case 'COPY': + return copyMatterRoute(c, auth) + default: + return c.text('Method Not Allowed', 405) + } + }, +) + +async function propfind(c: DavContext, auth: DavAuth): Promise { + const db = c.get('platform').db + try { + const target = await resolveWebDavPath(db, auth.orgId, davPath(c)) + const depth = c.req.header('Depth') ?? '1' + const entries = [] + + if (target.mountRoot) { + entries.push(mountRootEntry()) + if (depth !== '0') entries.push(workspaceEntry(target.workspace)) + } else if (!target.matter) { + entries.push(workspaceEntry(target.workspace)) + if (depth !== '0') + entries.push(...(await listChildren(db, auth.orgId, '')).map((m) => matterEntry(target.workspace, m))) + } else { + entries.push(matterEntry(target.workspace, target.matter)) + if (depth !== '0' && target.matter.dirtype !== DirType.FILE) { + const parent = joinMatterPath(target.matter.parent, target.matter.name) + entries.push(...(await listChildren(db, auth.orgId, parent)).map((m) => matterEntry(target.workspace, m))) + } + } + + return c.body(multistatus(entries), 207, { 'Content-Type': 'application/xml; charset=utf-8' }) + } catch (e) { + return davError(c, e) + } +} + +async function readFile(c: DavContext, auth: DavAuth): Promise { + const db = c.get('platform').db + try { + const { matter } = await resolveExistingWebDavPath(db, auth.orgId, davPath(c)) + if (!matter) throw new WebDavPathError('Not found', 404) + if (matter.dirtype !== DirType.FILE) return c.text('Cannot read collection as file', 405) + const storage = (await getStorage(db, matter.storageId)) as unknown as S3Storage | null + if (!storage) return c.text('Storage not found', 404) + + if (c.req.method.toUpperCase() === 'HEAD') { + return new Response(null, { + headers: { 'Content-Type': matter.type, 'Content-Length': String(matter.size ?? 0), ETag: matter.id }, + }) + } + + const url = await s3.presignDownload(storage, matter.object, matter.name) + return c.redirect(url, 302) + } catch (e) { + return davError(c, e) + } +} + +async function putFile(c: DavContext, auth: DavAuth): Promise { + const db = c.get('platform').db + try { + const target = await resolveWebDavPath(db, auth.orgId, davPath(c)) + if (!target.name) return c.text('Cannot PUT a collection root', 405) + if (target.matter && target.matter.dirtype !== DirType.FILE) + return c.text('Cannot replace collection with file', 409) + await ensureParentCollection(db, auth.orgId, target.workspace.slug, target.parent) + + const bytes = new Uint8Array(await c.req.arrayBuffer()) + const storage = target.matter + ? ((await getStorage(db, target.matter.storageId)) as unknown as S3Storage | null) + : ((await selectStorage(db, 'private')) as unknown as S3Storage) + if (!storage) return c.text('Storage not found', 404) + const objectKey = target.matter?.object + ? target.matter.object + : buildObjectKey({ uid: auth.userId ?? 'webdav', orgId: auth.orgId, rawExt: fileExt(target.name) }) + const contentType = c.req.header('Content-Type') ?? 'application/octet-stream' + + const sizeDelta = target.matter ? bytes.byteLength - (target.matter.size ?? 0) : bytes.byteLength + if (sizeDelta > 0) { + const allowed = await incrementUsageIfAllowed(db, auth.orgId, storage.id, sizeDelta) + if (!allowed) return c.text('Quota exceeded', 422) + } + + try { + await s3.putObject(storage, objectKey, bytes, contentType) + } catch (e) { + if (sizeDelta > 0) await decrementUsage(db, auth.orgId, new Map([[storage.id, sizeDelta]]), sizeDelta) + throw e + } + + if (sizeDelta < 0) { + await decrementUsage(db, auth.orgId, new Map([[storage.id, Math.abs(sizeDelta)]]), Math.abs(sizeDelta)) + } + + if (target.matter) { + const now = new Date() + await db + .update(matters) + .set({ type: contentType, size: bytes.byteLength, updatedAt: now }) + .where(and(eq(matters.id, target.matter.id), eq(matters.orgId, auth.orgId))) + return new Response(null, { status: 204 }) + } + + await createMatter(db, { + orgId: auth.orgId, + userId: auth.userId, + name: target.name, + type: contentType, + size: bytes.byteLength, + dirtype: DirType.FILE, + parent: target.parent, + object: objectKey, + storageId: storage.id, + status: ObjectStatus.ACTIVE, + }) + return new Response(null, { status: 201 }) + } catch (e) { + return davError(c, e) + } +} + +async function makeCollection(c: DavContext, auth: DavAuth): Promise { + const db = c.get('platform').db + try { + const target = await resolveWebDavPath(db, auth.orgId, davPath(c)) + if (!target.name) return c.text('Cannot create collection root', 405) + if (target.matter) return c.text('Already exists', 405) + await ensureParentCollection(db, auth.orgId, target.workspace.slug, target.parent) + const storage = (await selectStorage(db, 'private')) as unknown as S3Storage + await createMatter(db, { + orgId: auth.orgId, + userId: auth.userId, + name: target.name, + type: 'folder', + size: 0, + dirtype: DirType.USER_FOLDER, + parent: target.parent, + object: '', + storageId: storage.id, + status: ObjectStatus.ACTIVE, + }) + return new Response(null, { status: 201 }) + } catch (e) { + return davError(c, e) + } +} + +async function deleteMatter(c: DavContext, auth: DavAuth): Promise { + const db = c.get('platform').db + try { + const { matter } = await resolveExistingWebDavPath(db, auth.orgId, davPath(c)) + if (!matter) throw new WebDavPathError('Not found', 404) + await trashMatter(db, auth.orgId, matter.id, auth.userId) + return new Response(null, { status: 204 }) + } catch (e) { + return davError(c, e) + } +} + +async function moveMatter(c: DavContext, auth: DavAuth): Promise { + const db = c.get('platform').db + try { + const source = await resolveExistingWebDavPath(db, auth.orgId, davPath(c)) + if (!source.matter) throw new WebDavPathError('Not found', 404) + const destination = destinationPath(c) + if (destination instanceof Response) return destination + const target = await resolveWebDavPath(db, auth.orgId, destination) + if (!target.name) return c.text('Cannot move to collection root', 405) + if (target.matter) return c.text('Already exists', 412) + await ensureParentCollection(db, auth.orgId, target.workspace.slug, target.parent) + await updateMatter(db, source.matter.id, auth.orgId, { name: target.name, parent: target.parent }, auth.userId) + return new Response(null, { status: 201 }) + } catch (e) { + return davError(c, e) + } +} + +async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise { + const db = c.get('platform').db + try { + const source = await resolveExistingWebDavPath(db, auth.orgId, davPath(c)) + if (!source.matter) throw new WebDavPathError('Not found', 404) + const destination = destinationPath(c) + if (destination instanceof Response) return destination + const target = await resolveWebDavPath(db, auth.orgId, destination) + if (!target.name) return c.text('Cannot copy to collection root', 405) + if (target.matter) return c.text('Already exists', 412) + await ensureParentCollection(db, auth.orgId, target.workspace.slug, target.parent) + + let newObject = '' + let reservedUsage: { storageId: string; bytes: number } | null = null + try { + if (source.matter.object) { + const storage = (await getStorage(db, source.matter.storageId)) as unknown as S3Storage | null + if (!storage) return c.text('Storage not found', 404) + const bytes = source.matter.size ?? 0 + if (bytes > 0) { + const allowed = await incrementUsageIfAllowed(db, auth.orgId, storage.id, bytes) + if (!allowed) return c.text('Quota exceeded', 422) + reservedUsage = { storageId: storage.id, bytes } + } + newObject = buildObjectKey({ uid: auth.userId ?? 'webdav', orgId: auth.orgId, rawExt: fileExt(target.name) }) + await s3.copyObject(storage, source.matter.object, storage, newObject) + } + + const copy = await copyMatter(db, { ...source.matter, name: target.name }, target.parent, newObject, { + onConflict: 'fail', + userId: auth.userId, + }) + c.header('Location', matterLocation(c.req.url, target.workspace.slug, joinMatterPath(copy.parent, copy.name))) + return c.body(null, 201) + } catch (e) { + if (reservedUsage) { + await decrementUsage( + db, + auth.orgId, + new Map([[reservedUsage.storageId, reservedUsage.bytes]]), + reservedUsage.bytes, + ) + } + throw e + } + } catch (e) { + return davError(c, e) + } +} + +function matterLocation(requestUrl: string, slug: string, path: string): string { + const url = new URL(requestUrl) + url.pathname = `/dav/${encodeURIComponent(slug)}/${path.split('/').map(encodeURIComponent).join('/')}` + url.search = '' + return url.toString() +} + +export default app diff --git a/server/services/webdav-path.ts b/server/services/webdav-path.ts new file mode 100644 index 00000000..58606294 --- /dev/null +++ b/server/services/webdav-path.ts @@ -0,0 +1,133 @@ +import { and, asc, desc, eq } from 'drizzle-orm' +import { DirType, ObjectStatus } from '../../shared/constants' +import { organization } from '../db/auth-schema' +import { matters } from '../db/schema' +import type { Database } from '../platform/interface' +import type { Matter } from './matter' + +export interface WebDavWorkspace { + id: string + name: string + slug: string + href: string +} + +export interface WebDavTarget { + workspace: WebDavWorkspace + mountRoot: boolean + parent: string + name: string + matter: Matter | null +} + +export class WebDavPathError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message) + } +} + +export function joinMatterPath(parent: string, name: string): string { + return parent ? `${parent}/${name}` : name +} + +export function matterHref(workspace: WebDavWorkspace, matter: Matter): string { + const path = joinMatterPath(matter.parent, matter.name) + return `/dav/${encodeURIComponent(workspace.slug)}/${path.split('/').map(encodeURIComponent).join('/')}` +} + +export function workspaceHref(workspace: WebDavWorkspace): string { + return `/dav/${encodeURIComponent(workspace.slug)}/` +} + +export async function getWorkspace(db: Database, orgId: string): Promise { + const rows = await db + .select({ id: organization.id, name: organization.name, slug: organization.slug }) + .from(organization) + .where(eq(organization.id, orgId)) + .limit(1) + const row = rows[0] + return row ? { ...row, href: `/dav/${encodeURIComponent(row.slug)}/` } : null +} + +export async function listChildren(db: Database, orgId: string, parent: string): Promise { + return db + .select() + .from(matters) + .where(and(eq(matters.orgId, orgId), eq(matters.parent, parent), eq(matters.status, ObjectStatus.ACTIVE))) + .orderBy(desc(matters.dirtype), asc(matters.name)) +} + +export async function resolveWebDavPath(db: Database, orgId: string, rawPath: string): Promise { + const workspace = await getWorkspace(db, orgId) + if (!workspace) throw new WebDavPathError('Workspace not found', 404) + + const parts = decodeDavPath(rawPath) + if (parts.length === 0) return { workspace, mountRoot: true, parent: '', name: '', matter: null } + if (parts[0] !== workspace.slug) throw new WebDavPathError('Workspace not found', 404) + if (parts.length === 1) return { workspace, mountRoot: false, parent: '', name: '', matter: null } + + const matterParts = parts.slice(1) + const name = matterParts.at(-1) ?? '' + const parent = matterParts.slice(0, -1).join('/') + const matter = await findMatterByPath(db, orgId, parent, name) + return { workspace, mountRoot: false, parent, name, matter } +} + +export async function resolveExistingWebDavPath(db: Database, orgId: string, rawPath: string): Promise { + const target = await resolveWebDavPath(db, orgId, rawPath) + if (!target.matter) throw new WebDavPathError('Not found', 404) + return target +} + +function decodeDavPath(rawPath: string): string[] { + if (!rawPath.startsWith('/')) throw new WebDavPathError('Invalid DAV path', 400) + if (rawPath.includes('//')) throw new WebDavPathError('Ambiguous DAV path', 400) + + const withoutMount = rawPath.replace(/^\/dav(?:\/|$)/, '/') + const trimmed = withoutMount.replace(/^\/+|\/+$/g, '') + if (!trimmed) return [] + + return trimmed.split('/').map(decodeSegment) +} + +function decodeSegment(segment: string): string { + if (!segment) throw new WebDavPathError('Ambiguous DAV path', 400) + if (/%2f|%5c/i.test(segment)) throw new WebDavPathError('Encoded path separators are not allowed', 400) + if (/%25(?:2e|2f|5c)/i.test(segment)) throw new WebDavPathError('Double-encoded path tricks are not allowed', 400) + + let decoded: string + try { + decoded = decodeURIComponent(segment) + } catch { + throw new WebDavPathError('Invalid path encoding', 400) + } + + if (!decoded || decoded === '.' || decoded === '..') throw new WebDavPathError('Invalid DAV path segment', 400) + if (decoded.includes('/') || decoded.includes('\\')) throw new WebDavPathError('Invalid DAV path segment', 400) + return decoded +} + +async function findMatterByPath(db: Database, orgId: string, parent: string, name: string): Promise { + const rows = await db + .select() + .from(matters) + .where( + and( + eq(matters.orgId, orgId), + eq(matters.parent, parent), + eq(matters.name, name), + eq(matters.status, ObjectStatus.ACTIVE), + ), + ) + .limit(1) + return rows[0] ?? null +} + +export function ensureFolder(target: WebDavTarget): Matter { + if (!target.matter) throw new WebDavPathError('Parent collection not found', 409) + if (target.matter.dirtype === DirType.FILE) throw new WebDavPathError('Not a collection', 405) + return target.matter +} diff --git a/server/services/webdav-xml.ts b/server/services/webdav-xml.ts new file mode 100644 index 00000000..32604cf4 --- /dev/null +++ b/server/services/webdav-xml.ts @@ -0,0 +1,71 @@ +import { DirType } from '../../shared/constants' +import type { Matter } from './matter' +import type { WebDavWorkspace } from './webdav-path' +import { matterHref, workspaceHref } from './webdav-path' + +interface DavEntry { + href: string + collection: boolean + contentType: string + contentLength: number + updatedAt: Date +} + +export function workspaceEntry(workspace: WebDavWorkspace): DavEntry { + return { + href: workspaceHref(workspace), + collection: true, + contentType: 'httpd/unix-directory', + contentLength: 0, + updatedAt: new Date(), + } +} + +export function mountRootEntry(): DavEntry { + return { + href: '/dav/', + collection: true, + contentType: 'httpd/unix-directory', + contentLength: 0, + updatedAt: new Date(), + } +} + +export function matterEntry(workspace: WebDavWorkspace, matter: Matter): DavEntry { + const collection = matter.dirtype !== DirType.FILE + return { + href: collection ? `${matterHref(workspace, matter)}/` : matterHref(workspace, matter), + collection, + contentType: collection ? 'httpd/unix-directory' : matter.type, + contentLength: matter.size ?? 0, + updatedAt: matter.updatedAt, + } +} + +export function multistatus(entries: DavEntry[]): string { + return `\n\n${entries.map(response).join('\n')}\n` +} + +function response(entry: DavEntry): string { + return ` + ${escapeXml(entry.href)} + + + ${entry.collection ? '' : ''} + ${entry.contentLength} + ${escapeXml(entry.contentType)} + ${entry.updatedAt.toUTCString()} + + HTTP/1.1 200 OK + + ` +} + +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} diff --git a/wrangler.toml b/wrangler.toml index ade4d55f..ca18ef31 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,7 +6,7 @@ compatibility_flags = ["nodejs_compat"] [assets] binding = "ASSETS" not_found_handling = "single-page-application" -run_worker_first = ["/api/*", "/r/*", "/s/*"] +run_worker_first = ["/api/*", "/dav/*", "/r/*", "/s/*"] [[d1_databases]] binding = "DB"