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
This commit is contained in:
Jasper Van
2026-05-11 19:32:29 -04:00
committed by GitHub
parent 2053b84c1a
commit efb92397bf
7 changed files with 960 additions and 3 deletions
+3
View File
@@ -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<Env>()
@@ -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.
+6 -2
View File
@@ -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,
},
}),
],
+387
View File
@@ -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<ReturnType<typeof createTestApp>>
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<string, string[]>) {
// 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)
}
})
})
+359
View File
@@ -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<Env>
type DavAuth = { orgId: string; userId?: string }
async function requireWebDavApiKey(c: DavContext): Promise<DavAuth | Response> {
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<void> {
if (!parent) return
const target = await resolveWebDavPath(db, orgId, `/dav/${workspaceSlug}/${parent}`)
ensureFolder(target)
}
const app = new Hono<Env>().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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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
+133
View File
@@ -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<WebDavWorkspace | null> {
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<Matter[]> {
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<WebDavTarget> {
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<WebDavTarget> {
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<Matter | null> {
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
}
+71
View File
@@ -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 `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n${entries.map(response).join('\n')}\n</D:multistatus>`
}
function response(entry: DavEntry): string {
return ` <D:response>
<D:href>${escapeXml(entry.href)}</D:href>
<D:propstat>
<D:prop>
<D:resourcetype>${entry.collection ? '<D:collection/>' : ''}</D:resourcetype>
<D:getcontentlength>${entry.contentLength}</D:getcontentlength>
<D:getcontenttype>${escapeXml(entry.contentType)}</D:getcontenttype>
<D:getlastmodified>${entry.updatedAt.toUTCString()}</D:getlastmodified>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>`
}
function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;')
}
+1 -1
View File
@@ -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"