mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
4817afecdb
Agent-Profile: https://agent-kanban.dev/agents/b0abe6cd7aeba133 Co-authored-by: Noah Reed <noah-reed@mails.agent-kanban.dev>
1569 lines
56 KiB
TypeScript
1569 lines
56 KiB
TypeScript
import type { Context } from 'hono'
|
|
import { Hono } from 'hono'
|
|
import { ApiKeyTemplate, WEBDAV_RATE_LIMITER_BINDING } from '../../shared/api-key-templates'
|
|
import { DirType, ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
|
import { encodeDavPathSegment, joinMatterPath, workspaceHref } from '../domain/webdav'
|
|
import { WEBDAV_AUTH_CHALLENGE, type WebDavMountPath, webDavPublicUrl } from '../domain/webdav-public-url'
|
|
import {
|
|
DAV_NAMESPACE,
|
|
type DavEntry,
|
|
davEtag,
|
|
errorXml,
|
|
lockDiscoveryXml,
|
|
matterEntry,
|
|
mountRootEntry,
|
|
multistatus,
|
|
type PropfindRequest,
|
|
parseLockInfoXml,
|
|
parsePropfindXml,
|
|
parseProppatchXml,
|
|
proppatchMultistatus,
|
|
workspaceEntry,
|
|
xmlResponse,
|
|
} from '../domain/webdav-xml'
|
|
import { mapDomainError } from '../lib/http-errors'
|
|
import { isDownloadFailureStatus, transferAuditActor, transferFailureReason } from '../middleware/audit-transfers'
|
|
import type { Env } from '../middleware/platform'
|
|
import {
|
|
ApiKeyRateLimitError,
|
|
insufficientCredits,
|
|
type RecordAuditEventInput,
|
|
type StorageRecord,
|
|
WebDavPathError,
|
|
type WebDavTarget,
|
|
} from '../usecases/ports'
|
|
import {
|
|
recordAuditEvent,
|
|
recordDownloadFailure,
|
|
recordDownloadIssued,
|
|
recordUploadResult,
|
|
type TransferAuditTarget,
|
|
} from '../usecases/transfer-activity'
|
|
import {
|
|
activeLocks,
|
|
activeLocksForResources,
|
|
applyWebDavDeadProperties,
|
|
copyWebDavCollection,
|
|
copyWebDavFile,
|
|
createWebDavCollection,
|
|
createWebDavLock,
|
|
deleteWebDavMatter,
|
|
ensureParentCollection as ensureParentCollectionUsecase,
|
|
getWebDavObjectBody,
|
|
invalidateWebDavPaths,
|
|
listDeadPropertiesForResources,
|
|
listUserWebDavWorkspaces,
|
|
meterWebDavDownload,
|
|
moveWebDavMatter,
|
|
putWebDavFile,
|
|
recordWebDavDownloadIssued,
|
|
refreshWebDavLock,
|
|
refundWebDavTraffic,
|
|
removeWebDavLock,
|
|
resolveExistingWebDavPath,
|
|
resolveWebDavAuth,
|
|
resolveWebDavDownload,
|
|
resolveWebDavPath,
|
|
resolveWebDavPathWithChildren,
|
|
} from '../usecases/webdav'
|
|
|
|
const WEBDAV_METHOD_SCOPES: Record<
|
|
string,
|
|
Array<{ resource: 'objects'; action: 'read' | 'create' | 'update' | 'delete' }>
|
|
> = {
|
|
OPTIONS: [{ resource: 'objects', action: 'read' }],
|
|
PROPFIND: [{ resource: 'objects', action: 'read' }],
|
|
GET: [{ resource: 'objects', action: 'read' }],
|
|
HEAD: [{ resource: 'objects', action: 'read' }],
|
|
PUT: [
|
|
{ resource: 'objects', action: 'create' },
|
|
{ resource: 'objects', action: 'update' },
|
|
],
|
|
MKCOL: [{ resource: 'objects', action: 'create' }],
|
|
COPY: [
|
|
{ resource: 'objects', action: 'create' },
|
|
{ resource: 'objects', action: 'update' },
|
|
],
|
|
PROPPATCH: [{ resource: 'objects', action: 'update' }],
|
|
LOCK: [{ resource: 'objects', action: 'update' }],
|
|
UNLOCK: [{ resource: 'objects', action: 'update' }],
|
|
DELETE: [{ resource: 'objects', action: 'delete' }],
|
|
MOVE: [{ resource: 'objects', action: 'update' }],
|
|
}
|
|
|
|
type DavContext = Context<Env>
|
|
type DavAuth = {
|
|
userId: string
|
|
keyId: string
|
|
configId: string
|
|
permissions: Record<string, string[]> | null
|
|
}
|
|
|
|
interface NativeRateLimiter {
|
|
limit(options: { key: string }): Promise<{ success: boolean }>
|
|
}
|
|
|
|
const cloudBaseUrl = (c: DavContext): string => c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
|
|
|
|
async function requireWebDavApiKey(c: DavContext): Promise<DavAuth | Response> {
|
|
const method = c.req.method.toUpperCase()
|
|
const requiredScope = WEBDAV_METHOD_SCOPES[method]
|
|
if (!requiredScope) return c.text('Method Not Allowed', 405)
|
|
|
|
const credentials = parseBasicAuth(c.req.raw.headers.get('Authorization'))
|
|
if (!credentials) return unauthorized()
|
|
|
|
const nativeRateLimiter = c.get('platform').getBinding<NativeRateLimiter>(WEBDAV_RATE_LIMITER_BINDING)
|
|
const credentialKey = nativeRateLimiter ? await webDavCredentialKey(credentials) : null
|
|
if (nativeRateLimiter && credentialKey) {
|
|
const { success } = await nativeRateLimiter.limit({ key: credentialKey })
|
|
if (!success) {
|
|
return rateLimited(new ApiKeyRateLimitError('Rate limit exceeded.', 60_000))
|
|
}
|
|
}
|
|
|
|
const startedAt = performance.now()
|
|
const result = await resolveWebDavAuth(c.get('deps'), {
|
|
auth: c.get('auth'),
|
|
db: c.get('platform').db,
|
|
username: credentials.username,
|
|
password: credentials.password,
|
|
requiredScopes: requiredScope,
|
|
configId: ApiKeyTemplate.WEBDAV,
|
|
})
|
|
c.get('webDavTrace').push(`auth:${Math.round(performance.now() - startedAt)}`)
|
|
if (!result.ok) {
|
|
if (result.reason === 'rate_limited')
|
|
return rateLimited(new ApiKeyRateLimitError(result.message, result.retryAfterMs))
|
|
return unauthorized()
|
|
}
|
|
c.set('userId', result.userId)
|
|
c.set('principal', {
|
|
kind: 'api-key',
|
|
keyId: result.keyId,
|
|
configId: result.configId,
|
|
orgId: null,
|
|
userId: result.userId,
|
|
scope: { mode: 'user-workspaces' },
|
|
permissions: result.permissions,
|
|
authMethod: 'api-key',
|
|
})
|
|
return result
|
|
}
|
|
|
|
async function webDavCredentialKey(credentials: { username: string; password: string }): Promise<string> {
|
|
const bytes = new TextEncoder().encode(`${credentials.username}\0${credentials.password}`)
|
|
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
|
|
return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
}
|
|
|
|
function unauthorized(): Response {
|
|
return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': WEBDAV_AUTH_CHALLENGE } })
|
|
}
|
|
|
|
function rateLimited(error: ApiKeyRateLimitError): Response {
|
|
const headers = new Headers()
|
|
if (error.retryAfterMs !== undefined) headers.set('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)))
|
|
return new Response(error.message, { status: 429, headers })
|
|
}
|
|
|
|
function parseBasicAuth(header: string | null): { username: string; password: string } | null {
|
|
if (!header) return null
|
|
const match = /^Basic\s+(.+)$/i.exec(header)
|
|
if (!match) return null
|
|
|
|
let decoded: string
|
|
try {
|
|
const bytes = Uint8Array.from(atob(match[1]), (ch) => ch.charCodeAt(0))
|
|
decoded = new TextDecoder().decode(bytes)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
const separator = decoded.indexOf(':')
|
|
if (separator <= 0) return null
|
|
const username = decoded.slice(0, separator)
|
|
const password = decoded.slice(separator + 1)
|
|
if (!password) return null
|
|
return { username, password }
|
|
}
|
|
|
|
function davPath(c: DavContext): string {
|
|
return normalizeDavMountPath(new URL(c.req.url).pathname)
|
|
}
|
|
|
|
function publicMountPath(c: DavContext): WebDavMountPath {
|
|
return c.get('webDavMountPath')
|
|
}
|
|
|
|
function normalizeDavMountPath(pathname: string): string {
|
|
return pathname.replace(/^\/dav\/+/, '/dav/')
|
|
}
|
|
|
|
function davError(c: DavContext, error: unknown): Response {
|
|
const mapped = mapDomainError(error)
|
|
if (mapped) return c.text(mapped.message, mapped.status)
|
|
throw error
|
|
}
|
|
|
|
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.host !== new URL(c.req.url).host) return c.text('Cross-origin DAV destination rejected', 400)
|
|
return normalizeDavMountPath(url.pathname)
|
|
}
|
|
|
|
async function ensureParentCollection(
|
|
c: DavContext,
|
|
userId: string,
|
|
workspaceSlug: string,
|
|
parent: string,
|
|
): Promise<void> {
|
|
await ensureParentCollectionUsecase(c.get('deps'), { userId, workspaceSlug, parent })
|
|
}
|
|
|
|
function requireWorkspace(target: WebDavTarget) {
|
|
if (!target.workspace) throw new WebDavPathError('Workspace not found', 404)
|
|
return target.workspace
|
|
}
|
|
|
|
function matterEtag(matter: NonNullable<WebDavTarget['matter']>): string {
|
|
return davEtag(matter.id, matter.size ?? 0, matter.updatedAt)
|
|
}
|
|
|
|
function fileHeaders(matter: NonNullable<WebDavTarget['matter']>): Headers {
|
|
return new Headers({
|
|
'Content-Type': matter.type,
|
|
'Content-Length': String(matter.size ?? 0),
|
|
ETag: matterEtag(matter),
|
|
'Last-Modified': matter.updatedAt.toUTCString(),
|
|
'Accept-Ranges': 'bytes',
|
|
})
|
|
}
|
|
|
|
function validatorHeaders(matter: NonNullable<WebDavTarget['matter']>): Headers {
|
|
return new Headers({ ETag: matterEtag(matter), 'Last-Modified': matter.updatedAt.toUTCString() })
|
|
}
|
|
|
|
function isMountedWebDavRead(c: DavContext): boolean {
|
|
const method = c.req.method.toUpperCase()
|
|
return (method === 'GET' || method === 'HEAD') && Boolean(c.req.header('User-Agent')?.startsWith('WebDAVFS/'))
|
|
}
|
|
|
|
function etagMatches(header: string, etag: string): boolean {
|
|
return header
|
|
.split(',')
|
|
.map((value) => value.trim())
|
|
.some((value) => value === '*' || value === etag)
|
|
}
|
|
|
|
function preconditionResponse(c: DavContext, matter: NonNullable<WebDavTarget['matter']>): Response | null {
|
|
const etag = matterEtag(matter)
|
|
const method = c.req.method.toUpperCase()
|
|
const isWebDavFsRead = isMountedWebDavRead(c)
|
|
const ifMatch = c.req.header('If-Match')
|
|
if (ifMatch && !etagMatches(ifMatch, etag)) return new Response(null, { status: 412 })
|
|
|
|
const ifUnmodifiedSince = ifMatch ? null : parseHttpDate(c.req.header('If-Unmodified-Since'))
|
|
if (ifUnmodifiedSince && matter.updatedAt.getTime() > ifUnmodifiedSince.getTime()) {
|
|
return new Response(null, { status: 412 })
|
|
}
|
|
|
|
const ifNoneMatch = c.req.header('If-None-Match')
|
|
if (ifNoneMatch) {
|
|
if (!etagMatches(ifNoneMatch, etag)) return null
|
|
if (isWebDavFsRead) return null
|
|
if (method === 'GET' || method === 'HEAD') {
|
|
return new Response(null, { status: 304, headers: validatorHeaders(matter) })
|
|
}
|
|
return new Response(null, { status: 412 })
|
|
}
|
|
|
|
const ifModifiedSince =
|
|
method === 'GET' || method === 'HEAD' ? parseHttpDate(c.req.header('If-Modified-Since')) : null
|
|
if (ifModifiedSince && matter.updatedAt.getTime() <= ifModifiedSince.getTime()) {
|
|
if (isWebDavFsRead) return null
|
|
return new Response(null, { status: 304, headers: validatorHeaders(matter) })
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function parseHttpDate(header: string | undefined): Date | null {
|
|
if (!header) return null
|
|
const timestamp = Date.parse(header)
|
|
if (!Number.isFinite(timestamp)) return null
|
|
return new Date(timestamp)
|
|
}
|
|
|
|
function missingPreconditionResponse(c: DavContext): Response | null {
|
|
if (c.req.header('If-Match') || c.req.header('If-Unmodified-Since')) return new Response(null, { status: 412 })
|
|
return null
|
|
}
|
|
|
|
interface ByteRange {
|
|
start: number
|
|
end: number
|
|
}
|
|
|
|
type RangeRequest = { action: 'none' | 'ignore' } | { action: 'serve'; ranges: ByteRange[] } | { action: 'reject' }
|
|
|
|
function parseRangeRequest(header: string | undefined, size: number): RangeRequest {
|
|
if (!header) return { action: 'none' }
|
|
const separator = header.indexOf('=')
|
|
if (separator <= 0) return { action: 'ignore' }
|
|
|
|
const unit = header.slice(0, separator).trim().toLowerCase()
|
|
const specs = header
|
|
.slice(separator + 1)
|
|
.split(',')
|
|
.map((spec) => spec.trim())
|
|
if (unit !== 'bytes') return { action: 'ignore' }
|
|
if (size <= 0) return { action: 'reject' }
|
|
|
|
const ranges: ByteRange[] = []
|
|
for (const spec of specs) {
|
|
const range = parseByteRangeSpec(spec, size)
|
|
if (range === 'invalid') return { action: 'reject' }
|
|
if (range) ranges.push(range)
|
|
}
|
|
if (ranges.length === 0) return { action: 'reject' }
|
|
return { action: 'serve', ranges }
|
|
}
|
|
|
|
function parseByteRangeSpec(spec: string, size: number): ByteRange | null | 'invalid' {
|
|
const match = /^(\d*)-(\d*)$/.exec(spec)
|
|
if (!match) return 'invalid'
|
|
|
|
const [, rawStart, rawEnd] = match
|
|
if (!rawStart && !rawEnd) return 'invalid'
|
|
|
|
if (!rawStart) {
|
|
const suffix = Number(rawEnd)
|
|
if (!Number.isSafeInteger(suffix) || suffix <= 0) return 'invalid'
|
|
return { start: Math.max(size - suffix, 0), end: size - 1 }
|
|
}
|
|
|
|
const start = Number(rawStart)
|
|
const end = rawEnd ? Number(rawEnd) : size - 1
|
|
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end) return 'invalid'
|
|
if (start >= size) return null
|
|
return { start, end: Math.min(end, size - 1) }
|
|
}
|
|
|
|
function rangeNotSatisfiable(size: number): Response {
|
|
return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}` } })
|
|
}
|
|
|
|
function multipartRangeContentLength(boundary: string, contentType: string, ranges: ByteRange[], size: number): number {
|
|
let contentLength = finalMultipartBoundary(boundary).byteLength
|
|
for (const range of ranges) {
|
|
contentLength += multipartRangeHeader(boundary, contentType, range, size).byteLength
|
|
contentLength += range.end - range.start + 1
|
|
contentLength += 2
|
|
}
|
|
return contentLength
|
|
}
|
|
|
|
function rangeContentBytes(ranges: ByteRange[]): number {
|
|
return ranges.reduce((total, range) => total + range.end - range.start + 1, 0)
|
|
}
|
|
|
|
function multipartRangeHeader(boundary: string, contentType: string, range: ByteRange, size: number): Uint8Array {
|
|
return new TextEncoder().encode(
|
|
`--${boundary}\r\nContent-Type: ${contentType}\r\nContent-Range: bytes ${range.start}-${range.end}/${size}\r\n\r\n`,
|
|
)
|
|
}
|
|
|
|
function finalMultipartBoundary(boundary: string): Uint8Array {
|
|
return new TextEncoder().encode(`--${boundary}--\r\n`)
|
|
}
|
|
|
|
function multipartRangeBody(
|
|
c: DavContext,
|
|
storage: StorageRecord,
|
|
matter: NonNullable<WebDavTarget['matter']>,
|
|
boundary: string,
|
|
ranges: ByteRange[],
|
|
size: number,
|
|
): ReadableStream<Uint8Array> {
|
|
return new ReadableStream({
|
|
async start(controller) {
|
|
try {
|
|
for (const range of ranges) {
|
|
controller.enqueue(multipartRangeHeader(boundary, matter.type, range, size))
|
|
await enqueueObjectRange(c, controller, storage, matter.object, range)
|
|
controller.enqueue(new Uint8Array([13, 10]))
|
|
}
|
|
controller.enqueue(finalMultipartBoundary(boundary))
|
|
controller.close()
|
|
} catch (error) {
|
|
controller.error(error)
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
async function enqueueObjectRange(
|
|
c: DavContext,
|
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
storage: StorageRecord,
|
|
object: string,
|
|
range: ByteRange,
|
|
): Promise<void> {
|
|
const body = await getWebDavObjectBody(c.get('deps'), { storage, object, range: `bytes=${range.start}-${range.end}` })
|
|
if (!isReadableBodyStream(body)) throw new Error('Unsupported range body stream')
|
|
const reader = body.getReader()
|
|
try {
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
controller.enqueue(value)
|
|
}
|
|
} finally {
|
|
releaseStreamLock(reader)
|
|
}
|
|
}
|
|
|
|
function ifRangeMatches(header: string | undefined, matter: NonNullable<WebDavTarget['matter']>): boolean {
|
|
if (!header) return true
|
|
const value = header.trim()
|
|
if (value.startsWith('W/')) return false
|
|
if (value.startsWith('"')) return value === matterEtag(matter)
|
|
return value === matter.updatedAt.toUTCString()
|
|
}
|
|
|
|
function parseContentLength(header: string | undefined): number | null | Response {
|
|
if (!header) return null
|
|
const size = Number(header)
|
|
if (!Number.isSafeInteger(size) || size < 0) return new Response('Invalid Content-Length', { status: 400 })
|
|
return size
|
|
}
|
|
|
|
function fixedLengthResponseBody(body: BodyInit, contentLength: number): BodyInit {
|
|
const ctor = (
|
|
globalThis as typeof globalThis & {
|
|
FixedLengthStream?: new (
|
|
expectedLength: number,
|
|
) => {
|
|
readable: ReadableStream<Uint8Array>
|
|
writable: WritableStream<ArrayBuffer | ArrayBufferView>
|
|
}
|
|
}
|
|
).FixedLengthStream
|
|
if (!ctor || !isReadableBodyStream(body)) return body
|
|
|
|
const { readable, writable } = new ctor(contentLength)
|
|
void body.pipeTo(writable).catch((error) => {
|
|
if (isExpectedStreamCancellation(error)) return
|
|
console.error(JSON.stringify({ message: 'webdav.response_stream.error', error: errorMessage(error) }))
|
|
})
|
|
return readable
|
|
}
|
|
|
|
function isReadableBodyStream(body: BodyInit): body is ReadableStream<Uint8Array> {
|
|
return typeof (body as ReadableStream<Uint8Array>).getReader === 'function'
|
|
}
|
|
|
|
function releaseStreamLock(stream: { releaseLock: () => void }): void {
|
|
try {
|
|
stream.releaseLock()
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
|
|
function overwriteAllowed(c: DavContext): boolean {
|
|
return (c.req.header('Overwrite') ?? 'T').toUpperCase() !== 'F'
|
|
}
|
|
|
|
function resourcePath(target: WebDavTarget): string {
|
|
if (!target.workspace) return ''
|
|
return target.matter
|
|
? joinMatterPath(target.matter.parent, target.matter.name)
|
|
: joinMatterPath(target.parent, target.name)
|
|
}
|
|
|
|
function targetHref(c: DavContext, target: WebDavTarget): string {
|
|
const mountPath = publicMountPath(c)
|
|
if (target.mountRoot) return `${mountPath}/`
|
|
const workspace = requireWorkspace(target)
|
|
if (!target.matter) return workspaceHref(workspace, mountPath)
|
|
const path = joinMatterPath(target.matter.parent, target.matter.name)
|
|
const href = `${workspaceHref(workspace, mountPath)}${path.split('/').map(encodeDavPathSegment).join('/')}`
|
|
return target.matter.dirtype === DirType.FILE ? href : `${href}/`
|
|
}
|
|
|
|
function parseTimeout(header: string | undefined): number {
|
|
if (!header) return 3600
|
|
const second = header
|
|
.split(',')
|
|
.map((value) => value.trim())
|
|
.find((value) => /^Second-\d+$/i.test(value))
|
|
if (!second) return 3600
|
|
return Math.min(Number(second.slice('Second-'.length)), 604800)
|
|
}
|
|
|
|
function lockTokenHeader(c: DavContext): string | null {
|
|
const header = c.req.header('Lock-Token')
|
|
return header?.replace(/^<|>$/g, '') ?? null
|
|
}
|
|
|
|
function submittedLockTokens(c: DavContext): Set<string> {
|
|
const tokens = new Set<string>()
|
|
const direct = lockTokenHeader(c)
|
|
if (direct) tokens.add(direct)
|
|
const ifHeader = c.req.header('If')
|
|
if (!ifHeader) return tokens
|
|
for (const match of ifHeader.matchAll(/<([^>]+)>/g)) {
|
|
if (match[1].startsWith('opaquelocktoken:')) tokens.add(match[1])
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
function lockRefreshToken(c: DavContext): string | Response | null {
|
|
const ifHeader = c.req.header('If')
|
|
if (!ifHeader) return null
|
|
const tokens = [...ifHeader.matchAll(/<([^>]+)>/g)]
|
|
.map((match) => match[1])
|
|
.filter((token) => token.startsWith('opaquelocktoken:'))
|
|
if (tokens.length === 0) return null
|
|
if (tokens.length !== 1) return xmlResponse(errorXml('lock-token-submitted'), 400)
|
|
return tokens[0]
|
|
}
|
|
|
|
async function lockPrecondition(c: DavContext, target: WebDavTarget): Promise<Response | null> {
|
|
const locks = await targetActiveLocks(c, target)
|
|
if (locks.length === 0) return null
|
|
const tokens = submittedLockTokens(c)
|
|
if (locks.every((lock) => tokens.has(lock.token))) return null
|
|
return xmlResponse(errorXml('lock-token-submitted', 'A matching lock token is required.'), 423)
|
|
}
|
|
|
|
async function targetActiveLocks(c: DavContext, target: WebDavTarget) {
|
|
const workspace = requireWorkspace(target)
|
|
const path = resourcePath(target)
|
|
const key = `${workspace.id}\0${path}`
|
|
const cached = c.get('webDavLocksByResource').get(key)
|
|
if (cached) return cached
|
|
const locks = await activeLocks(c.get('deps'), { orgId: workspace.id, resourcePath: path })
|
|
c.get('webDavLocksByResource').set(key, locks)
|
|
return locks
|
|
}
|
|
|
|
async function ifHeaderPrecondition(c: DavContext, auth: DavAuth, target: WebDavTarget): Promise<Response | null> {
|
|
const header = c.req.header('If')
|
|
if (!header) return null
|
|
if (await evaluateIfHeader(c, auth, header, target)) return null
|
|
return xmlResponse(errorXml('condition-failed', 'If header conditions did not match.'), 412)
|
|
}
|
|
|
|
async function evaluateIfHeader(
|
|
c: DavContext,
|
|
auth: DavAuth,
|
|
header: string,
|
|
fallback: WebDavTarget,
|
|
): Promise<boolean> {
|
|
const clauses = [...header.matchAll(/(?:<([^>]+)>\s*)?(\([^)]*\))/g)]
|
|
if (clauses.length === 0) return false
|
|
for (const clause of clauses) {
|
|
const target = clause[1] ? await ifTaggedTarget(c, auth, clause[1]) : fallback
|
|
if (!target) continue
|
|
const etag = target.matter ? matterEtag(target.matter) : null
|
|
const locks = target.workspace ? await targetActiveLocks(c, target) : []
|
|
const lockTokens = new Set(locks.map((lock) => lock.token))
|
|
const list = clause[2]
|
|
const conditions = [...list.matchAll(/(Not\s+)?(?:\[([^\]]+)\]|<([^>]+)>)/gi)]
|
|
if (conditions.length === 0) continue
|
|
if (
|
|
conditions.every((condition) => {
|
|
const negated = Boolean(condition[1])
|
|
const value = condition[2] ?? condition[3]
|
|
const matched = value.startsWith('opaquelocktoken:') ? lockTokens.has(value) : etag === value
|
|
return negated ? !matched : matched
|
|
})
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function ifTaggedTarget(c: DavContext, auth: DavAuth, tag: string): Promise<WebDavTarget | null> {
|
|
if (tag.startsWith('opaquelocktoken:')) return null
|
|
try {
|
|
const url = new URL(tag, c.req.url)
|
|
if (url.host !== new URL(c.req.url).host) return null
|
|
return await resolveWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: normalizeDavMountPath(url.pathname) })
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function propfindNeedsDeadProperties(request: PropfindRequest): boolean {
|
|
return request.mode !== 'prop' || request.properties.some((property) => property.namespace !== DAV_NAMESPACE)
|
|
}
|
|
|
|
function propfindNeedsLocks(request: PropfindRequest): boolean {
|
|
if (request.mode === 'allprop') return true
|
|
if (request.mode === 'propname') return false
|
|
return request.properties.some(
|
|
(property) => property.namespace === DAV_NAMESPACE && property.name === 'lockdiscovery',
|
|
)
|
|
}
|
|
|
|
async function davEntries(c: DavContext, targets: WebDavTarget[], request: PropfindRequest): Promise<DavEntry[]> {
|
|
const entries: DavEntry[] = []
|
|
const mountPath = publicMountPath(c)
|
|
const byWorkspace = new Map<string, { workspace: NonNullable<WebDavTarget['workspace']>; targets: WebDavTarget[] }>()
|
|
const needsDeadProperties = propfindNeedsDeadProperties(request)
|
|
const needsLocks = propfindNeedsLocks(request)
|
|
|
|
for (const target of targets) {
|
|
if (target.mountRoot) {
|
|
entries.push(mountRootEntry(mountPath))
|
|
continue
|
|
}
|
|
const workspace = requireWorkspace(target)
|
|
const group = byWorkspace.get(workspace.id)
|
|
if (group) {
|
|
group.targets.push(target)
|
|
} else {
|
|
byWorkspace.set(workspace.id, { workspace, targets: [target] })
|
|
}
|
|
}
|
|
|
|
const workspaceEntries = await Promise.all(
|
|
[...byWorkspace.values()].map(async ({ workspace, targets: workspaceTargets }) => {
|
|
const paths = workspaceTargets.map(resourcePath)
|
|
const [deadPropertiesByPath, locksByPath] = await Promise.all([
|
|
needsDeadProperties
|
|
? listDeadPropertiesForResources(c.get('deps'), { orgId: workspace.id, resourcePaths: paths })
|
|
: Promise.resolve(new Map()),
|
|
needsLocks
|
|
? activeLocksForResources(c.get('deps'), { orgId: workspace.id, resourcePaths: paths })
|
|
: Promise.resolve(new Map()),
|
|
])
|
|
return workspaceTargets.map((target) => {
|
|
const path = resourcePath(target)
|
|
const deadProperties = deadPropertiesByPath.get(path) ?? []
|
|
const locks = locksByPath.get(path) ?? []
|
|
return target.matter
|
|
? matterEntry(workspace, target.matter, deadProperties, locks, mountPath)
|
|
: workspaceEntry(workspace, deadProperties, locks, mountPath)
|
|
})
|
|
}),
|
|
)
|
|
for (const resolved of workspaceEntries) entries.push(...resolved)
|
|
|
|
return entries
|
|
}
|
|
|
|
const app = new Hono<Env>()
|
|
|
|
app.use('*', async (c, next) => {
|
|
if (!c.get('webDavEnabled')) return c.notFound()
|
|
await next()
|
|
})
|
|
|
|
app.use('*', async (c, next) => {
|
|
const auth = await requireWebDavApiKey(c)
|
|
if (auth instanceof Response) return auth
|
|
await next()
|
|
})
|
|
|
|
app.use('*', async (c, next) => {
|
|
const userId = c.get('userId')
|
|
if (!userId) throw new Error('webdav_authenticated_user_missing')
|
|
const preparedAction = await prepareWebDavActionAudit(c, userId)
|
|
await next()
|
|
await invalidateWebDavReadCache(c, userId)
|
|
await waitUntilOrAwait(c, 'audit', processWebDavAudit(c, userId, preparedAction))
|
|
})
|
|
|
|
async function invalidateWebDavReadCache(c: DavContext, userId: string): Promise<void> {
|
|
if (c.res.status >= 400) return
|
|
const method = c.req.method.toUpperCase()
|
|
if (method !== 'PUT' && method !== 'DELETE' && method !== 'MKCOL' && method !== 'MOVE' && method !== 'COPY') return
|
|
const rawPaths = [davPath(c)]
|
|
if (method === 'MOVE' || method === 'COPY') {
|
|
const destination = validDestinationPath(c)
|
|
if (destination) rawPaths.push(destination)
|
|
}
|
|
await invalidateWebDavPaths(c.get('deps'), { userId, rawPaths })
|
|
}
|
|
|
|
async function processWebDavAudit(
|
|
c: DavContext,
|
|
userId: string,
|
|
preparedAction: WebDavActionAuditContext | null,
|
|
): Promise<void> {
|
|
const actor = transferAuditActor(c.get('principal'))
|
|
if (c.req.method === 'GET' && isDownloadFailureStatus(c.res.status)) {
|
|
const target = c.get('webDavDownloadAuditTarget')
|
|
if (target) {
|
|
await recordDownloadFailure(c.get('deps'), actor, target, transferFailureReason(c))
|
|
}
|
|
return
|
|
}
|
|
|
|
if (preparedAction && c.res.status < 400) {
|
|
const event = await resolveWebDavActionAudit(c, userId, preparedAction)
|
|
if (event) await recordAuditEvent(c.get('deps'), { ...actor, ...event })
|
|
}
|
|
|
|
if (c.req.method !== 'PUT') return
|
|
if (c.res.status === 201 || c.res.status === 204) {
|
|
const target = c.get('webDavUploadAuditTarget')
|
|
if (!target) throw new Error('transfer_audit_context_missing:webdav_upload')
|
|
await recordUploadResult(c.get('deps'), actor, target)
|
|
return
|
|
}
|
|
|
|
const preparedPut = putAuditTarget(c.get('webDavResolvedPutTarget'))
|
|
if (!preparedPut || !isUploadFailureStatus(c.res.status)) return
|
|
const bytes = exactRequestContentLength(c)
|
|
if (bytes === null) return
|
|
await recordUploadResult(c.get('deps'), actor, { ...preparedPut, bytes }, transferFailureReason(c))
|
|
}
|
|
|
|
type WebDavActionAuditContext = {
|
|
method: 'MKCOL' | 'DELETE' | 'MOVE' | 'COPY'
|
|
source: WebDavTarget
|
|
destination: string | null
|
|
}
|
|
|
|
async function prepareWebDavActionAudit(c: DavContext, userId: string): Promise<WebDavActionAuditContext | null> {
|
|
const method = c.req.method.toUpperCase()
|
|
if (method !== 'MKCOL' && method !== 'DELETE' && method !== 'MOVE' && method !== 'COPY') return null
|
|
try {
|
|
const source = await resolveWebDavPath(c.get('deps'), { userId, rawPath: davPath(c) })
|
|
const destination = method === 'MOVE' || method === 'COPY' ? validDestinationPath(c) : null
|
|
return { method, source, destination }
|
|
} catch (error) {
|
|
if (error instanceof WebDavPathError) return null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function resolveWebDavActionAudit(
|
|
c: DavContext,
|
|
userId: string,
|
|
prepared: WebDavActionAuditContext,
|
|
): Promise<RecordAuditEventInput | null> {
|
|
const source = prepared.source
|
|
const workspace = source.workspace
|
|
if (!workspace) return null
|
|
|
|
if (prepared.method === 'MKCOL') {
|
|
const created = await resolveExistingWebDavPath(c.get('deps'), { userId, rawPath: davPath(c) })
|
|
if (!created.matter) return null
|
|
return {
|
|
orgId: workspace.id,
|
|
action: 'create',
|
|
targetType: 'folder',
|
|
targetId: created.matter.id,
|
|
targetName: created.matter.name,
|
|
}
|
|
}
|
|
|
|
const matter = source.matter
|
|
if (!matter) return null
|
|
const targetType = matter.dirtype === DirType.FILE ? 'file' : 'folder'
|
|
if (prepared.method === 'DELETE') {
|
|
return {
|
|
orgId: workspace.id,
|
|
action: 'delete',
|
|
targetType,
|
|
targetId: matter.id,
|
|
targetName: matter.name,
|
|
}
|
|
}
|
|
|
|
if (!prepared.destination) return null
|
|
const destination = await resolveExistingWebDavPath(c.get('deps'), { userId, rawPath: prepared.destination })
|
|
if (!destination.matter) return null
|
|
return {
|
|
orgId: workspace.id,
|
|
action: prepared.method === 'MOVE' ? 'object_update' : 'object_copy',
|
|
targetType,
|
|
targetId: destination.matter.id,
|
|
targetName: destination.matter.name,
|
|
metadata: {
|
|
sourceId: matter.id,
|
|
sourceName: matter.name,
|
|
targetParent: destination.matter.parent,
|
|
},
|
|
}
|
|
}
|
|
|
|
function validDestinationPath(c: DavContext): string | null {
|
|
const header = c.req.header('Destination')
|
|
if (!header) return null
|
|
const url = new URL(header, c.req.url)
|
|
if (url.host !== new URL(c.req.url).host) return null
|
|
return normalizeDavMountPath(url.pathname)
|
|
}
|
|
|
|
app.on(
|
|
['OPTIONS', 'PROPFIND', 'PROPPATCH', 'GET', 'HEAD', 'PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'LOCK', 'UNLOCK'],
|
|
['/', '/*'],
|
|
async (c) => {
|
|
const principal = c.get('principal')
|
|
if (principal?.kind !== 'api-key' || !principal.userId) throw new Error('webdav_api_key_principal_missing')
|
|
return dispatchWebDavRequest(c, {
|
|
userId: principal.userId,
|
|
keyId: principal.keyId,
|
|
configId: principal.configId,
|
|
permissions: principal.permissions,
|
|
})
|
|
},
|
|
)
|
|
|
|
function putAuditTarget(target: WebDavTarget | null): TransferAuditTarget | null {
|
|
if (!target) return null
|
|
if (!target.workspace || !target.name) return null
|
|
return {
|
|
orgId: target.workspace.id,
|
|
targetType: 'file',
|
|
targetId: target.matter?.id,
|
|
targetName: target.matter?.name ?? target.name,
|
|
bytes: 0,
|
|
source: 'webdav_upload',
|
|
metadata: {
|
|
matterId: target.matter?.id,
|
|
storageId: target.matter?.storageId,
|
|
},
|
|
}
|
|
}
|
|
|
|
function requestedWebDavBytes(c: DavContext, matter: NonNullable<WebDavTarget['matter']>): number {
|
|
const size = matter.size ?? 0
|
|
if (!ifRangeMatches(c.req.header('If-Range'), matter)) return size
|
|
const range = parseRangeRequest(c.req.header('Range'), size)
|
|
return range.action === 'serve' ? rangeContentBytes(range.ranges) : size
|
|
}
|
|
|
|
function exactRequestContentLength(c: DavContext): number | null {
|
|
const raw = c.req.header('Content-Length')
|
|
if (!raw) return null
|
|
const value = Number(raw)
|
|
return Number.isSafeInteger(value) && value >= 0 ? value : null
|
|
}
|
|
|
|
function isUploadFailureStatus(status: number): boolean {
|
|
return status === 409 || status === 413 || status === 422 || status >= 500
|
|
}
|
|
|
|
async function dispatchWebDavRequest(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
switch (c.req.method.toUpperCase()) {
|
|
case 'OPTIONS':
|
|
return new Response(null, {
|
|
status: 204,
|
|
headers: {
|
|
Allow: 'OPTIONS, PROPFIND, PROPPATCH, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY, LOCK, UNLOCK',
|
|
DAV: '1, 2',
|
|
},
|
|
})
|
|
case 'PROPFIND':
|
|
return propfind(c, auth)
|
|
case 'PROPPATCH':
|
|
return proppatch(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)
|
|
case 'LOCK':
|
|
return lockMatter(c, auth)
|
|
case 'UNLOCK':
|
|
return unlockMatter(c, auth)
|
|
default:
|
|
return c.text('Method Not Allowed', 405)
|
|
}
|
|
}
|
|
|
|
async function propfind(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
const depth = c.req.header('Depth') ?? '1'
|
|
if (depth !== '0' && depth !== '1') {
|
|
return xmlResponse(errorXml('propfind-finite-depth', 'Depth infinity is not supported for PROPFIND.'), 403)
|
|
}
|
|
let startedAt = performance.now()
|
|
const resolved =
|
|
depth === '0'
|
|
? {
|
|
target: await resolveWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) }),
|
|
children: [],
|
|
}
|
|
: await resolveWebDavPathWithChildren(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
const { target, children } = resolved
|
|
c.get('webDavTrace').push(`resolve:${Math.round(performance.now() - startedAt)}`)
|
|
const request = parsePropfindXml(await c.req.text())
|
|
c.get('webDavTrace').push(
|
|
`props:${request.mode}/${propfindNeedsDeadProperties(request) ? 'dead' : '-'}/${propfindNeedsLocks(request) ? 'locks' : '-'}`,
|
|
)
|
|
const targets: WebDavTarget[] = []
|
|
|
|
startedAt = performance.now()
|
|
if (target.mountRoot) {
|
|
targets.push(target)
|
|
if (depth !== '0') {
|
|
for (const workspace of await listUserWebDavWorkspaces(c.get('deps'), auth.userId)) {
|
|
const workspaceTarget = { workspace, mountRoot: false, parent: '', name: '', matter: null }
|
|
targets.push(workspaceTarget)
|
|
}
|
|
}
|
|
} else if (!target.matter) {
|
|
if (target.name) throw new WebDavPathError('Not found', 404)
|
|
const workspace = requireWorkspace(target)
|
|
targets.push(target)
|
|
if (depth !== '0') {
|
|
for (const matter of children) {
|
|
targets.push({ workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter })
|
|
}
|
|
}
|
|
} else {
|
|
const workspace = requireWorkspace(target)
|
|
targets.push(target)
|
|
if (depth !== '0' && target.matter.dirtype !== DirType.FILE) {
|
|
for (const matter of children) {
|
|
targets.push({ workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter })
|
|
}
|
|
}
|
|
}
|
|
c.get('webDavTrace').push(`list:${Math.round(performance.now() - startedAt)}`)
|
|
|
|
startedAt = performance.now()
|
|
const entries = await davEntries(c, targets, request)
|
|
c.get('webDavTrace').push(`state:${Math.round(performance.now() - startedAt)}`)
|
|
return xmlResponse(multistatus(entries, request), 207)
|
|
} catch (e) {
|
|
if (e instanceof Error && (e.message.includes('XML') || e.message.includes('PROPFIND'))) {
|
|
return xmlResponse(errorXml('valid-xml', e.message), 400)
|
|
}
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
async function proppatch(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
const target = await resolveWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
if (target.name && !target.matter) throw new WebDavPathError('Not found', 404)
|
|
const workspace = requireWorkspace(target)
|
|
const locked = await lockPrecondition(c, target)
|
|
if (locked) return locked
|
|
const ifFailed = await ifHeaderPrecondition(c, auth, target)
|
|
if (ifFailed) return ifFailed
|
|
const operations = parseProppatchXml(await c.req.text())
|
|
await applyWebDavDeadProperties(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
resourcePath: resourcePath(target),
|
|
operations,
|
|
matterId: target.matter?.id ?? null,
|
|
})
|
|
const properties = operations.map((operation) => operation.property)
|
|
return xmlResponse(proppatchMultistatus(targetHref(c, target), properties), 207)
|
|
} catch (e) {
|
|
if (
|
|
e instanceof Error &&
|
|
(e.message.includes('XML') || e.message.includes('PROPPATCH') || e.message.includes('Protected'))
|
|
) {
|
|
return xmlResponse(errorXml('cannot-modify-protected-property', e.message), 403)
|
|
}
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
async function readFile(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
let startedAt = performance.now()
|
|
const resolved = await resolveWebDavDownload(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
c.get('webDavTrace').push(`download-resolve:${Math.round(performance.now() - startedAt)}`)
|
|
if (!resolved.ok) {
|
|
switch (resolved.reason) {
|
|
case 'not_found':
|
|
throw new WebDavPathError('Not found', 404)
|
|
case 'workspace_not_found':
|
|
throw new WebDavPathError('Workspace not found', 404)
|
|
case 'not_a_file':
|
|
return c.text('Cannot read collection as file', 405)
|
|
case 'storage_not_found':
|
|
return c.text('Storage not found', 404)
|
|
}
|
|
}
|
|
const { matter, workspace, storage } = resolved
|
|
c.set('webDavDownloadAuditTarget', {
|
|
orgId: workspace.id,
|
|
targetType: 'file',
|
|
targetId: matter.id,
|
|
targetName: matter.name,
|
|
bytes: requestedWebDavBytes(c, matter),
|
|
source: 'webdav_download',
|
|
metadata: { matterId: matter.id, storageId: storage.id },
|
|
})
|
|
const precondition = preconditionResponse(c, matter)
|
|
if (precondition) return precondition
|
|
|
|
const headers = fileHeaders(matter)
|
|
if (isMountedWebDavRead(c)) {
|
|
headers.delete('ETag')
|
|
headers.delete('Last-Modified')
|
|
headers.set('Cache-Control', 'no-store')
|
|
}
|
|
|
|
if (c.req.method.toUpperCase() === 'HEAD') {
|
|
return new Response(null, { headers })
|
|
}
|
|
|
|
const size = matter.size ?? 0
|
|
const rangeHeader = c.req.header('Range')
|
|
const rangeRequest: RangeRequest = ifRangeMatches(c.req.header('If-Range'), matter)
|
|
? parseRangeRequest(rangeHeader, size)
|
|
: { action: 'ignore' }
|
|
|
|
if (rangeRequest.action === 'none' || rangeRequest.action === 'ignore') {
|
|
startedAt = performance.now()
|
|
const reservation = await reserveWebDavTraffic(c, auth.userId, workspace.id, matter, storage, size)
|
|
c.get('webDavTrace').push(`traffic:${Math.round(performance.now() - startedAt)}`)
|
|
if (reservation.error) return reservation.error
|
|
try {
|
|
startedAt = performance.now()
|
|
const body = await getWebDavObjectBody(c.get('deps'), { storage, object: matter.object })
|
|
c.get('webDavTrace').push(`s3:${Math.round(performance.now() - startedAt)}`)
|
|
startedAt = performance.now()
|
|
await waitUntilOrAwait(
|
|
c,
|
|
'download_finish',
|
|
finishWebDavDownload(c, {
|
|
orgId: workspace.id,
|
|
userId: auth.userId,
|
|
matterId: matter.id,
|
|
matterName: matter.name,
|
|
storageId: storage.id,
|
|
bytes: size,
|
|
trafficEventId: reservation.trafficEventId,
|
|
}),
|
|
)
|
|
c.get('webDavTrace').push(`finish:${Math.round(performance.now() - startedAt)}`)
|
|
return new Response(fixedLengthResponseBody(body, size), { headers })
|
|
} catch (e) {
|
|
await refundWebDavTraffic(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
bytes: size,
|
|
trafficEventId: reservation.trafficEventId,
|
|
})
|
|
throw e
|
|
}
|
|
}
|
|
|
|
if (rangeRequest.action === 'reject') return rangeNotSatisfiable(size)
|
|
if (rangeRequest.action !== 'serve') throw new Error('Unexpected range request action')
|
|
const trafficBytes = rangeContentBytes(rangeRequest.ranges)
|
|
startedAt = performance.now()
|
|
const reservation = await reserveWebDavTraffic(c, auth.userId, workspace.id, matter, storage, trafficBytes)
|
|
c.get('webDavTrace').push(`traffic:${Math.round(performance.now() - startedAt)}`)
|
|
if (reservation.error) return reservation.error
|
|
if (rangeRequest.ranges.length > 1) {
|
|
const boundary = `zpan-webdav-${matter.id}`
|
|
const contentLength = multipartRangeContentLength(boundary, matter.type, rangeRequest.ranges, size)
|
|
const body = multipartRangeBody(c, storage, matter, boundary, rangeRequest.ranges, size)
|
|
headers.set('Content-Type', `multipart/byteranges; boundary=${boundary}`)
|
|
headers.set('Content-Length', String(contentLength))
|
|
headers.delete('Content-Range')
|
|
try {
|
|
startedAt = performance.now()
|
|
await waitUntilOrAwait(
|
|
c,
|
|
'download_finish',
|
|
finishWebDavDownload(c, {
|
|
orgId: workspace.id,
|
|
userId: auth.userId,
|
|
matterId: matter.id,
|
|
matterName: matter.name,
|
|
storageId: storage.id,
|
|
bytes: trafficBytes,
|
|
trafficEventId: reservation.trafficEventId,
|
|
}),
|
|
)
|
|
c.get('webDavTrace').push(`finish:${Math.round(performance.now() - startedAt)}`)
|
|
} catch (error) {
|
|
await refundWebDavTraffic(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
bytes: trafficBytes,
|
|
trafficEventId: reservation.trafficEventId,
|
|
})
|
|
throw error
|
|
}
|
|
return new Response(fixedLengthResponseBody(body, contentLength), { status: 206, headers })
|
|
}
|
|
|
|
const [range] = rangeRequest.ranges
|
|
const contentLength = range.end - range.start + 1
|
|
let body: BodyInit
|
|
try {
|
|
startedAt = performance.now()
|
|
body = await getWebDavObjectBody(c.get('deps'), {
|
|
storage,
|
|
object: matter.object,
|
|
range: `bytes=${range.start}-${range.end}`,
|
|
})
|
|
c.get('webDavTrace').push(`s3:${Math.round(performance.now() - startedAt)}`)
|
|
} catch (e) {
|
|
await refundWebDavTraffic(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
bytes: contentLength,
|
|
trafficEventId: reservation.trafficEventId,
|
|
})
|
|
throw e
|
|
}
|
|
headers.set('Content-Length', String(contentLength))
|
|
headers.set('Content-Range', `bytes ${range.start}-${range.end}/${size}`)
|
|
try {
|
|
startedAt = performance.now()
|
|
await waitUntilOrAwait(
|
|
c,
|
|
'download_finish',
|
|
finishWebDavDownload(c, {
|
|
orgId: workspace.id,
|
|
userId: auth.userId,
|
|
matterId: matter.id,
|
|
matterName: matter.name,
|
|
storageId: storage.id,
|
|
bytes: contentLength,
|
|
trafficEventId: reservation.trafficEventId,
|
|
}),
|
|
)
|
|
c.get('webDavTrace').push(`finish:${Math.round(performance.now() - startedAt)}`)
|
|
} catch (error) {
|
|
await refundWebDavTraffic(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
bytes: contentLength,
|
|
trafficEventId: reservation.trafficEventId,
|
|
})
|
|
throw error
|
|
}
|
|
return new Response(fixedLengthResponseBody(body, contentLength), { status: 206, headers })
|
|
} catch (e) {
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
async function finishWebDavDownload(
|
|
c: DavContext,
|
|
params: {
|
|
orgId: string
|
|
userId: string
|
|
matterId: string
|
|
matterName: string
|
|
storageId: string
|
|
bytes: number
|
|
trafficEventId: string
|
|
},
|
|
): Promise<void> {
|
|
await Promise.all([
|
|
recordWebDavDownloadIssued(c.get('deps'), params),
|
|
recordDownloadIssued(
|
|
c.get('deps'),
|
|
transferAuditActor(c.get('principal')),
|
|
'webdav_download',
|
|
{
|
|
orgId: params.orgId,
|
|
targetType: 'file',
|
|
targetId: params.matterId,
|
|
targetName: params.matterName,
|
|
bytes: params.bytes,
|
|
source: 'webdav_download',
|
|
metadata: {
|
|
matterId: params.matterId,
|
|
storageId: params.storageId,
|
|
},
|
|
},
|
|
params.trafficEventId,
|
|
),
|
|
])
|
|
}
|
|
|
|
async function waitUntilOrAwait(c: DavContext, operation: string, task: Promise<void>): Promise<void> {
|
|
const loggedTask = task.catch((error) => {
|
|
console.error(JSON.stringify({ message: 'webdav.background.error', operation, error: errorMessage(error) }))
|
|
throw error
|
|
})
|
|
try {
|
|
c.executionCtx.waitUntil(loggedTask)
|
|
} catch {
|
|
await loggedTask
|
|
}
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
function isExpectedStreamCancellation(error: unknown): boolean {
|
|
const message = errorMessage(error)
|
|
return message === 'Network connection lost.' || message.includes('destination stream aborted')
|
|
}
|
|
|
|
// Meters a WebDAV download (consume traffic quota → report egress) and renders
|
|
// the 422 / 402 responses. Returns null when metering succeeded (or the read is
|
|
// zero bytes) and the caller should proceed to stream the body.
|
|
async function reserveWebDavTraffic(
|
|
c: DavContext,
|
|
userId: string,
|
|
orgId: string,
|
|
matter: NonNullable<WebDavTarget['matter']>,
|
|
storage: StorageRecord,
|
|
bytes: number,
|
|
): Promise<{ error: Response | null; trafficEventId: string }> {
|
|
const trafficEventId = `traffic_${crypto.randomUUID()}`
|
|
const outcome = await meterWebDavDownload(c.get('deps'), {
|
|
cloudBaseUrl: cloudBaseUrl(c),
|
|
orgId,
|
|
userId,
|
|
matterId: matter.id,
|
|
matterName: matter.name,
|
|
storage,
|
|
bytes,
|
|
trafficEventId,
|
|
})
|
|
if (outcome.ok) return { error: null, trafficEventId }
|
|
if (outcome.reason === 'quota_exceeded') return { error: c.text('Traffic quota exceeded', 422), trafficEventId }
|
|
throw insufficientCredits('Insufficient credits', { metadata: { resource: 'storage_egress' } })
|
|
}
|
|
|
|
async function putFile(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
let startedAt = performance.now()
|
|
const target = await resolveWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
c.set('webDavResolvedPutTarget', target)
|
|
c.get('webDavTrace').push(`resolve:${Math.round(performance.now() - startedAt)}`)
|
|
const workspace = requireWorkspace(target)
|
|
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)
|
|
startedAt = performance.now()
|
|
const locked = await lockPrecondition(c, target)
|
|
if (locked) return locked
|
|
const ifFailed = await ifHeaderPrecondition(c, auth, target)
|
|
if (ifFailed) return ifFailed
|
|
c.get('webDavTrace').push(`preconditions:${Math.round(performance.now() - startedAt)}`)
|
|
const precondition = target.matter ? preconditionResponse(c, target.matter) : missingPreconditionResponse(c)
|
|
if (precondition) return precondition
|
|
await ensureParentCollection(c, auth.userId, workspace.slug, target.parent)
|
|
|
|
const contentLength = parseContentLength(c.req.header('Content-Length'))
|
|
if (contentLength instanceof Response) return contentLength
|
|
const body = contentLength === 0 ? new Uint8Array() : c.req.raw.body
|
|
if (!body) return c.text('Request body required', 400)
|
|
const contentType = c.req.header('Content-Type') ?? 'application/octet-stream'
|
|
|
|
try {
|
|
startedAt = performance.now()
|
|
const result = await putWebDavFile(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
userId: auth.userId,
|
|
target,
|
|
fileName: target.name,
|
|
parent: target.parent,
|
|
contentType,
|
|
contentLength,
|
|
body,
|
|
onTiming: (phase, durationMs) => c.get('webDavTrace').push(`${phase}:${Math.round(durationMs)}`),
|
|
})
|
|
c.get('webDavTrace').push(`upload:${Math.round(performance.now() - startedAt)}`)
|
|
if (!result.ok) return c.text('Storage not found', 404)
|
|
c.set('webDavUploadAuditTarget', {
|
|
orgId: workspace.id,
|
|
targetType: 'file',
|
|
targetId: result.matterId,
|
|
targetName: target.name,
|
|
bytes: result.bytes,
|
|
source: 'webdav_upload',
|
|
metadata: { matterId: result.matterId, storageId: result.storageId },
|
|
})
|
|
return new Response(null, { status: result.status })
|
|
} catch (e) {
|
|
const mapped = mapDomainError(e)
|
|
if (mapped) return c.text(mapped.message, mapped.status)
|
|
throw e
|
|
}
|
|
} catch (e) {
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
async function makeCollection(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
const target = await resolveWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
const workspace = requireWorkspace(target)
|
|
if (!target.name) return c.text('Cannot create collection root', 405)
|
|
if (target.matter) return c.text('Already exists', 405)
|
|
const body = await c.req.text()
|
|
if (body.length > 0) {
|
|
return xmlResponse(errorXml('unsupported-media-type', 'MKCOL request bodies are not supported.'), 415)
|
|
}
|
|
const locked = await lockPrecondition(c, target)
|
|
if (locked) return locked
|
|
const ifFailed = await ifHeaderPrecondition(c, auth, target)
|
|
if (ifFailed) return ifFailed
|
|
await ensureParentCollection(c, auth.userId, workspace.slug, target.parent)
|
|
await createWebDavCollection(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
userId: auth.userId,
|
|
name: target.name,
|
|
parent: target.parent,
|
|
})
|
|
return new Response(null, { status: 201 })
|
|
} catch (e) {
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
async function deleteMatter(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
const target = await resolveExistingWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
const workspace = requireWorkspace(target)
|
|
const matter = target.matter
|
|
if (!matter) throw new WebDavPathError('Not found', 404)
|
|
const locked = await lockPrecondition(c, target)
|
|
if (locked) return locked
|
|
const ifFailed = await ifHeaderPrecondition(c, auth, target)
|
|
if (ifFailed) return ifFailed
|
|
await deleteWebDavMatter(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
resourcePath: resourcePath(target),
|
|
matterId: matter.id,
|
|
userId: auth.userId,
|
|
})
|
|
return new Response(null, { status: 204 })
|
|
} catch (e) {
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
async function moveMatter(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
const source = await resolveExistingWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
const sourceWorkspace = requireWorkspace(source)
|
|
if (!source.matter) throw new WebDavPathError('Not found', 404)
|
|
const locked = await lockPrecondition(c, source)
|
|
if (locked) return locked
|
|
const ifFailed = await ifHeaderPrecondition(c, auth, source)
|
|
if (ifFailed) return ifFailed
|
|
const precondition = preconditionResponse(c, source.matter)
|
|
if (precondition) return precondition
|
|
const destination = destinationPath(c)
|
|
if (destination instanceof Response) return destination
|
|
const target = await resolveWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: destination })
|
|
const targetWorkspace = requireWorkspace(target)
|
|
if (sourceWorkspace.id !== targetWorkspace.id) return c.text('Cross-workspace MOVE is not supported', 403)
|
|
if (!target.name) return c.text('Cannot move to collection root', 405)
|
|
if (source.matter.dirtype !== DirType.FILE) {
|
|
const oldPath = joinMatterPath(source.matter.parent, source.matter.name)
|
|
const newPath = joinMatterPath(target.parent, target.name)
|
|
if (newPath === oldPath || newPath.startsWith(`${oldPath}/`)) {
|
|
return xmlResponse(errorXml('forbidden', 'Cannot move a collection into itself or its descendant.'), 403)
|
|
}
|
|
}
|
|
const targetLocked = await lockPrecondition(c, target)
|
|
if (targetLocked) return targetLocked
|
|
const replacingTarget = Boolean(target.matter)
|
|
if (target.matter) {
|
|
if (target.matter.id === source.matter.id) return new Response(null, { status: 204 })
|
|
if (!overwriteAllowed(c)) return c.text('Already exists', 412)
|
|
}
|
|
await ensureParentCollection(c, auth.userId, targetWorkspace.slug, target.parent)
|
|
await moveWebDavMatter(c.get('deps'), {
|
|
orgId: sourceWorkspace.id,
|
|
userId: auth.userId,
|
|
sourceMatterId: source.matter.id,
|
|
sourceResourcePath: resourcePath(source),
|
|
targetName: target.name,
|
|
targetParent: target.parent,
|
|
targetResourcePath: resourcePath(target),
|
|
replacedMatterId: target.matter?.id ?? null,
|
|
})
|
|
return new Response(null, { status: replacingTarget ? 204 : 201 })
|
|
} catch (e) {
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
const source = await resolveExistingWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
const sourceWorkspace = requireWorkspace(source)
|
|
if (!source.matter) throw new WebDavPathError('Not found', 404)
|
|
const sourceMatter = source.matter
|
|
const ifFailed = await ifHeaderPrecondition(c, auth, source)
|
|
if (ifFailed) return ifFailed
|
|
const precondition = preconditionResponse(c, sourceMatter)
|
|
if (precondition) return precondition
|
|
const destination = destinationPath(c)
|
|
if (destination instanceof Response) return destination
|
|
const target = await resolveWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: destination })
|
|
const targetWorkspace = requireWorkspace(target)
|
|
if (sourceWorkspace.id !== targetWorkspace.id) return c.text('Cross-workspace COPY is not supported', 403)
|
|
if (!target.name) return c.text('Cannot copy to collection root', 405)
|
|
const oldPath = joinMatterPath(sourceMatter.parent, sourceMatter.name)
|
|
const newPath = joinMatterPath(target.parent, target.name)
|
|
if (sourceMatter.dirtype !== DirType.FILE && (newPath === oldPath || newPath.startsWith(`${oldPath}/`))) {
|
|
return xmlResponse(errorXml('forbidden', 'Cannot copy a collection into itself or its descendant.'), 403)
|
|
}
|
|
const targetLocked = await lockPrecondition(c, target)
|
|
if (targetLocked) return targetLocked
|
|
if (target.matter && !overwriteAllowed(c)) return c.text('Already exists', 412)
|
|
const replacingTarget = Boolean(target.matter)
|
|
await ensureParentCollection(c, auth.userId, targetWorkspace.slug, target.parent)
|
|
|
|
if (sourceMatter.dirtype !== DirType.FILE) {
|
|
const depth = c.req.header('Depth') ?? 'infinity'
|
|
if (depth !== '0' && depth !== 'infinity') return xmlResponse(errorXml('bad-depth'), 400)
|
|
try {
|
|
const result = await copyWebDavCollection(c.get('deps'), {
|
|
orgId: sourceWorkspace.id,
|
|
userId: auth.userId,
|
|
sourceMatter,
|
|
sourceRoot: oldPath,
|
|
targetName: target.name,
|
|
targetParent: target.parent,
|
|
targetResourcePath: resourcePath(target),
|
|
targetMatter: target.matter,
|
|
replacingTarget,
|
|
depth,
|
|
})
|
|
if (!result.ok) return c.text('Storage not found', 404)
|
|
c.header('Location', matterLocation(c, targetWorkspace.pathSegment, result.location))
|
|
return c.body(null, result.status)
|
|
} catch (e) {
|
|
const mapped = mapDomainError(e)
|
|
if (mapped) return c.text(mapped.message, mapped.status)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await copyWebDavFile(c.get('deps'), {
|
|
orgId: sourceWorkspace.id,
|
|
userId: auth.userId,
|
|
sourceMatter,
|
|
sourceResourcePath: resourcePath(source),
|
|
targetName: target.name,
|
|
targetParent: target.parent,
|
|
targetResourcePath: resourcePath(target),
|
|
replacedMatterId: target.matter?.id ?? null,
|
|
replacingTarget,
|
|
})
|
|
if (!result.ok) return c.text('Storage not found', 404)
|
|
c.header('Location', matterLocation(c, targetWorkspace.pathSegment, result.location))
|
|
return c.body(null, result.status)
|
|
} catch (e) {
|
|
const mapped = mapDomainError(e)
|
|
if (mapped) return c.text(mapped.message, mapped.status)
|
|
throw e
|
|
}
|
|
} catch (e) {
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
async function lockMatter(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
let startedAt = performance.now()
|
|
const target = await resolveWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
c.get('webDavTrace').push(`resolve:${Math.round(performance.now() - startedAt)}`)
|
|
const workspace = requireWorkspace(target)
|
|
const body = await c.req.text()
|
|
const existingToken = lockRefreshToken(c)
|
|
if (existingToken instanceof Response) return existingToken
|
|
if (existingToken) {
|
|
if (body.length > 0) return xmlResponse(errorXml('lock-token-submitted'), 400)
|
|
const refreshed = await refreshWebDavLock(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
resourcePath: resourcePath(target),
|
|
token: existingToken,
|
|
timeoutSeconds: parseTimeout(c.req.header('Timeout')),
|
|
})
|
|
if (!refreshed) return xmlResponse(errorXml('lock-token-submitted'), 412)
|
|
return xmlResponse(lockDiscoveryXml(refreshed), 200)
|
|
}
|
|
|
|
const depth = c.req.header('Depth') ?? 'infinity'
|
|
if (depth !== '0' && depth !== 'infinity') return xmlResponse(errorXml('bad-depth'), 400)
|
|
const path = resourcePath(target)
|
|
let lockInfo: { owner: string }
|
|
try {
|
|
lockInfo = parseLockInfoXml(body)
|
|
} catch (e) {
|
|
return xmlResponse(errorXml('supported-lock', e instanceof Error ? e.message : 'Unsupported lock request.'), 422)
|
|
}
|
|
const isCreate = !target.matter && Boolean(target.name)
|
|
if (isCreate) {
|
|
await ensureParentCollection(c, auth.userId, workspace.slug, target.parent)
|
|
}
|
|
startedAt = performance.now()
|
|
const acquired = await createWebDavLock(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
userId: auth.userId,
|
|
resourcePath: path,
|
|
target,
|
|
owner: lockInfo.owner,
|
|
depth,
|
|
timeoutSeconds: parseTimeout(c.req.header('Timeout')),
|
|
})
|
|
c.get('webDavTrace').push(`lock:${Math.round(performance.now() - startedAt)}`)
|
|
if (!acquired) return xmlResponse(errorXml('no-conflicting-lock'), 423)
|
|
const { lock, created } = acquired
|
|
return xmlResponse(lockDiscoveryXml(lock), created ? 201 : 200, { 'Lock-Token': `<${lock.token}>` })
|
|
} catch (e) {
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
async function unlockMatter(c: DavContext, auth: DavAuth): Promise<Response> {
|
|
try {
|
|
let startedAt = performance.now()
|
|
const target = await resolveExistingWebDavPath(c.get('deps'), { userId: auth.userId, rawPath: davPath(c) })
|
|
c.get('webDavTrace').push(`resolve:${Math.round(performance.now() - startedAt)}`)
|
|
const workspace = requireWorkspace(target)
|
|
const token = lockTokenHeader(c)
|
|
if (!token) return xmlResponse(errorXml('lock-token-submitted'), 400)
|
|
startedAt = performance.now()
|
|
const removed = await removeWebDavLock(c.get('deps'), {
|
|
orgId: workspace.id,
|
|
resourcePath: resourcePath(target),
|
|
token,
|
|
})
|
|
c.get('webDavTrace').push(`unlock:${Math.round(performance.now() - startedAt)}`)
|
|
if (!removed) return xmlResponse(errorXml('lock-token-matches-request-uri'), 409)
|
|
return new Response(null, { status: 204 })
|
|
} catch (e) {
|
|
return davError(c, e)
|
|
}
|
|
}
|
|
|
|
function matterLocation(c: DavContext, workspaceSegment: string, path: string): string {
|
|
const requestUrl = new URL(c.req.url)
|
|
const mountPath = publicMountPath(c)
|
|
const publicUrl = mountPath === '' ? webDavPublicUrl(c.get('sitePublicOrigin'), c.get('webDavDomain')) : null
|
|
const url = publicUrl ?? requestUrl
|
|
url.pathname = `${mountPath}/${encodeDavPathSegment(workspaceSegment)}/${path
|
|
.split('/')
|
|
.map(encodeDavPathSegment)
|
|
.join('/')}`
|
|
url.search = ''
|
|
return url.toString()
|
|
}
|
|
|
|
export default app
|