mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
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:
@@ -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
@@ -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,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
+1
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user