diff --git a/server/adapters/repos/webdav-path.ts b/server/adapters/repos/webdav-path.ts index f5c2c920..bc01cbaa 100644 --- a/server/adapters/repos/webdav-path.ts +++ b/server/adapters/repos/webdav-path.ts @@ -1,5 +1,5 @@ import { ObjectStatus } from '@shared/constants' -import { and, asc, desc, eq, isNull } from 'drizzle-orm' +import { and, asc, desc, eq, getTableColumns, isNull } from 'drizzle-orm' import { member, organization } from '../../db/auth-schema' import { matters } from '../../db/schema' import type { Database } from '../../platform/interface' @@ -12,30 +12,32 @@ import { } from '../../usecases/ports' type WorkspaceRow = Pick +type WorkspaceMatterRow = { + workspace: WorkspaceRow + matter: Matter | null +} export function createWebDavPathRepo(db: Database): WebDavPathRepo { - async function getUserWorkspace(userId: string, pathSegment: string): Promise { - const workspaces = toWebDavWorkspaces(await userWorkspaceRows(db, userId)) - return ( - workspaces.find( - (workspace) => - workspace.slug === pathSegment || workspace.id === pathSegment || workspace.pathSegment === pathSegment, - ) ?? null - ) - } - async function resolveWebDavPath(userId: string, rawPath: string): Promise { const parts = decodeDavPath(rawPath) if (parts.length === 0) return { workspace: null, mountRoot: true, parent: '', name: '', matter: null } - const workspace = await getUserWorkspace(userId, parts[0]) - if (!workspace) 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, workspace.id, parent, name) + const rows = + parts.length === 1 + ? (await userWorkspaceRows(db, userId)).map((workspace) => ({ workspace, matter: null })) + : await userWorkspaceMatterRows(db, userId, parent, name) + const workspaces = toWebDavWorkspaces(rows.map((row) => row.workspace)) + const workspace = + workspaces.find( + (candidate) => candidate.slug === parts[0] || candidate.id === parts[0] || candidate.pathSegment === parts[0], + ) ?? null + if (!workspace) throw new WebDavPathError('Workspace not found', 404) + if (parts.length === 1) return { workspace, mountRoot: false, parent: '', name: '', matter: null } + + const matter = rows.find((row) => row.workspace.id === workspace.id)?.matter ?? null return { workspace, mountRoot: false, parent, name, matter } } @@ -79,6 +81,34 @@ async function userWorkspaceRows(db: Database, userId: string): Promise { + return db + .select({ + workspace: { id: organization.id, name: organization.name, slug: organization.slug }, + matter: getTableColumns(matters), + }) + .from(member) + .innerJoin(organization, eq(organization.id, member.organizationId)) + .leftJoin( + matters, + and( + eq(matters.orgId, organization.id), + eq(matters.parent, parent), + eq(matters.name, name), + eq(matters.status, ObjectStatus.ACTIVE), + isNull(matters.trashedAt), + isNull(matters.purgedAt), + ), + ) + .where(eq(member.userId, userId)) + .orderBy(asc(organization.name), asc(organization.slug)) +} + function toWebDavWorkspaces(rows: WorkspaceRow[]): WebDavWorkspace[] { const preferredSegments = new Map() for (const row of rows) { @@ -134,21 +164,3 @@ function decodeSegment(segment: string): string { function isSafeDavPathSegment(segment: string): boolean { return Boolean(segment) && segment !== '.' && segment !== '..' && !segment.includes('/') && !segment.includes('\\') } - -async function findMatterByPath(db: Database, orgId: string, parent: string, name: string): Promise { - const rows = await db - .select() - .from(matters) - .where( - and( - eq(matters.orgId, orgId), - eq(matters.parent, parent), - eq(matters.name, name), - eq(matters.status, ObjectStatus.ACTIVE), - isNull(matters.trashedAt), - isNull(matters.purgedAt), - ), - ) - .limit(1) - return rows[0] ?? null -} diff --git a/server/http/webdav.integration.test.ts b/server/http/webdav.integration.test.ts index da0eee55..8a49e25c 100644 --- a/server/http/webdav.integration.test.ts +++ b/server/http/webdav.integration.test.ts @@ -799,6 +799,25 @@ describe('WebDAV API', () => { ]) }) + it('does not resolve a missing GET target again for failure auditing', async () => { + const { app, db, auth, deps } = await createTestApp() + await authedHeaders(app) + const workspace = await org(db) + const account = await userAccount(db) + const key = await apiKey(auth, account.id, { webdav: ['read'] }) + const resolve = vi.spyOn(deps.webdavPath, 'resolveExistingWebDavPath') + + const res = await app.request(`/dav/${workspace.slug}/._missing.txt`, { + method: 'GET', + headers: basicHeaders(account.email, key, { + 'User-Agent': 'WebDAVFS/3.0.0 (03008000) Darwin/24.6.0 (arm64)', + }), + }) + + expect(res.status).toBe(404) + expect(resolve).toHaveBeenCalledTimes(1) + }) + it('GET supports valid byte ranges and rejects invalid ranges [spec: webdav/get-range]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) diff --git a/server/http/webdav.ts b/server/http/webdav.ts index ccfd0cd3..103f948c 100644 --- a/server/http/webdav.ts +++ b/server/http/webdav.ts @@ -664,7 +664,7 @@ async function processWebDavAudit( ): Promise { const actor = transferAuditActor(c.get('principal')) if (c.req.method === 'GET' && isDownloadFailureStatus(c.res.status)) { - const target = await webDavDownloadAuditTarget(c, userId) + const target = c.get('webDavDownloadAuditTarget') if (target) { await recordDownloadFailure(c.get('deps'), actor, target, transferFailureReason(c)) } @@ -821,20 +821,6 @@ async function webDavUploadedTarget(c: DavContext, userId: string): Promise { - const resolved = await resolveWebDavDownload(c.get('deps'), { userId, rawPath: davPath(c) }) - if (!resolved.ok) return null - return { - orgId: resolved.workspace.id, - targetType: 'file', - targetId: resolved.matter.id, - targetName: resolved.matter.name, - bytes: requestedWebDavBytes(c, resolved.matter), - source: 'webdav_download', - metadata: { matterId: resolved.matter.id, storageId: resolved.storage.id }, - } -} - function requestedWebDavBytes(c: DavContext, matter: NonNullable): number { const size = matter.size ?? 0 if (!ifRangeMatches(c.req.header('If-Range'), matter)) return size @@ -991,6 +977,15 @@ async function readFile(c: DavContext, auth: DavAuth): Promise { } } 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 diff --git a/server/middleware/platform.ts b/server/middleware/platform.ts index bce836db..942affc8 100644 --- a/server/middleware/platform.ts +++ b/server/middleware/platform.ts @@ -3,6 +3,7 @@ import type { Auth } from '../auth' import type { WebDavMountPath } from '../domain/webdav-public-url' import type { Platform } from '../platform/interface' import type { Deps } from '../usecases/deps' +import type { TransferAuditTarget } from '../usecases/transfer-activity' export type Env = { Variables: { @@ -18,6 +19,7 @@ export type Env = { webDavDomain: string webDavMountPath: WebDavMountPath webDavTrace: string[] + webDavDownloadAuditTarget: TransferAuditTarget | null // Structured detail for the access log on a failed request. Set by `jsonError` // (via `app.onError`); read by the accessLog middleware so every 4xx/5xx carries // its reason + full message, not just unhandled crashes. @@ -70,5 +72,6 @@ export const platformMiddleware = (platform: Platform, auth: Auth) => c.set('webDavDomain', '') c.set('webDavMountPath', '/dav') c.set('webDavTrace', []) + c.set('webDavDownloadAuditTarget', null) await next() })