diff --git a/server/routes/webdav.integration.test.ts b/server/routes/webdav.integration.test.ts index 82f223f4..14b74572 100644 --- a/server/routes/webdav.integration.test.ts +++ b/server/routes/webdav.integration.test.ts @@ -47,6 +47,29 @@ async function seedStorage(db: TestApp['db']) { `) } +async function seedTrafficPlan(db: TestApp['db'], orgId: string, bytes: number, used = 0) { + const now = Date.now() + await db.run(sql` + UPDATE org_quotas + SET traffic_used = ${used}, traffic_period = '2026-06' + WHERE org_id = ${orgId} + `) + await db.run(sql` + UPDATE org_quota_entitlements + SET status = 'revoked', updated_at = ${now} + WHERE org_id = ${orgId} + AND resource_type = 'traffic' + AND entitlement_type = 'plan' + AND status = 'active' + `) + await db.run(sql` + INSERT INTO org_quota_entitlements + (id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at) + VALUES + (${`traffic-plan-${orgId}-${now}`}, ${orgId}, 'traffic', 'plan', 'test', ${`traffic-plan:${orgId}:${now}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Traffic Plan"}', ${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 @@ -467,6 +490,113 @@ describe('WebDAV API', () => { expect(S3Service.prototype.getObjectBytes).not.toHaveBeenCalled() }) + it('GET consumes WebDAV traffic while HEAD does not', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const account = await userAccount(db) + const key = await apiKey(auth, account.id, { webdav: ['read'] }) + await file(db, workspace.id, { id: 'traffic-full', name: 'traffic.txt', size: 12 }) + await seedTrafficPlan(db, workspace.id, 1000, 25) + + const head = await app.request(`/dav/${workspace.slug}/traffic.txt`, { + method: 'HEAD', + headers: basicHeaders(account.email, key), + }) + expect(head.status).toBe(200) + const afterHead = await db.all<{ trafficUsed: number }>( + sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${workspace.id}`, + ) + expect(afterHead[0].trafficUsed).toBe(25) + + const get = await app.request(`/dav/${workspace.slug}/traffic.txt`, { + method: 'GET', + headers: basicHeaders(account.email, key), + }) + expect(get.status).toBe(200) + await expect(get.text()).resolves.toBe('hello webdav') + + const rows = await db.all<{ trafficUsed: number }>( + sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${workspace.id}`, + ) + expect(rows[0].trafficUsed).toBe(37) + }) + + it('GET consumes only served WebDAV range bytes and rejects over-quota reads before S3 access', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + const workspace = await org(db) + const account = await userAccount(db) + const key = await apiKey(auth, account.id, { webdav: ['read'] }) + await file(db, workspace.id, { id: 'traffic-range', name: 'range-traffic.txt', size: 12 }) + await seedTrafficPlan(db, workspace.id, 30, 25) + vi.mocked(S3Service.prototype.getObjectBody).mockResolvedValueOnce(streamBody('hello')) + + const partial = await app.request(`/dav/${workspace.slug}/range-traffic.txt`, { + method: 'GET', + headers: basicHeaders(account.email, key, { Range: 'bytes=0-4' }), + }) + expect(partial.status).toBe(206) + await expect(partial.text()).resolves.toBe('hello') + + const afterPartial = await db.all<{ trafficUsed: number }>( + sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${workspace.id}`, + ) + expect(afterPartial[0].trafficUsed).toBe(30) + + const over = await app.request(`/dav/${workspace.slug}/range-traffic.txt`, { + method: 'GET', + headers: basicHeaders(account.email, key, { Range: 'bytes=5-6' }), + }) + expect(over.status).toBe(422) + await expect(over.text()).resolves.toBe('Traffic quota exceeded') + expect(S3Service.prototype.getObjectBody).toHaveBeenCalledTimes(1) + + const invalid = await app.request(`/dav/${workspace.slug}/range-traffic.txt`, { + method: 'GET', + headers: basicHeaders(account.email, key, { Range: 'bytes=99-100' }), + }) + expect(invalid.status).toBe(416) + const afterInvalid = await db.all<{ trafficUsed: number }>( + sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${workspace.id}`, + ) + expect(afterInvalid[0].trafficUsed).toBe(30) + }) + + it('GET reports metered WebDAV traffic for cloud billing', async () => { + const { app, db, auth } = await createTestApp() + await authedHeaders(app) + await seedStorage(db) + await db.run(sql` + UPDATE storages + SET egress_credit_billing_enabled = 1, egress_credit_unit_bytes = 100, egress_credit_per_unit = 2 + WHERE id = ${storage.id} + `) + const workspace = await org(db) + const account = await userAccount(db) + const key = await apiKey(auth, account.id, { webdav: ['read'] }) + await file(db, workspace.id, { id: 'traffic-report', name: 'report.txt', size: 12 }) + await seedTrafficPlan(db, workspace.id, 1000, 0) + + const res = await app.request(`/dav/${workspace.slug}/report.txt`, { + method: 'GET', + headers: basicHeaders(account.email, key), + }) + expect(res.status).toBe(200) + await res.text() + + const reports = await db.all<{ source: string; sourceId: string; bytes: number; status: string }>(sql` + SELECT source, source_id AS sourceId, bytes, status + FROM cloud_traffic_reports + WHERE org_id = ${workspace.id} + `) + expect(reports).toMatchObject([ + { source: 'webdav_download', sourceId: 'traffic-report', bytes: 12, status: 'skipped_unbound' }, + ]) + }) + it('GET supports valid byte ranges and rejects invalid ranges', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) diff --git a/server/routes/webdav.ts b/server/routes/webdav.ts index e05bafc4..e1a1f45d 100644 --- a/server/routes/webdav.ts +++ b/server/routes/webdav.ts @@ -8,6 +8,7 @@ import { user } from '../db/auth-schema' import { matters } from '../db/schema' import type { Env } from '../middleware/platform' import { ApiKeyRateLimitError, verifyApiKeyForPermission } from '../services/api-keys' +import { consumeTrafficIfQuotaAllows, refundTraffic } from '../services/effective-quota' import { copyMatter, createMatter, @@ -58,6 +59,7 @@ import { workspaceEntry, xmlResponse, } from '../services/webdav-xml' +import { reportTrafficForDownload } from './traffic-metering-utils' const s3 = new S3Service() const READ_METHODS = new Set(['OPTIONS', 'PROPFIND', 'GET', 'HEAD']) @@ -323,6 +325,10 @@ function multipartRangeContentLength(boundary: string, contentType: string, rang 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`, @@ -756,8 +762,9 @@ async function proppatch(c: DavContext, auth: DavAuth): Promise { async function readFile(c: DavContext, auth: DavAuth): Promise { const db = c.get('platform').db try { - const { matter } = await resolveExistingWebDavPath(db, auth.userId, davPath(c)) + const { matter, workspace } = await resolveExistingWebDavPath(db, auth.userId, davPath(c)) if (!matter) throw new WebDavPathError('Not found', 404) + if (!workspace) throw new WebDavPathError('Workspace not found', 404) if (matter.dirtype !== DirType.FILE) return c.text('Cannot read collection as file', 405) const precondition = preconditionResponse(c, matter) if (precondition) return precondition @@ -782,12 +789,22 @@ async function readFile(c: DavContext, auth: DavAuth): Promise { : { action: 'ignore' } if (rangeRequest.action === 'none' || rangeRequest.action === 'ignore') { - const body = await s3.getObjectBody(storage, matter.object) - return new Response(fixedLengthResponseBody(body, size), { headers }) + const trafficError = await reserveWebDavTraffic(c, workspace.id, matter.id, storage, size) + if (trafficError) return trafficError + try { + const body = await s3.getObjectBody(storage, matter.object) + return new Response(fixedLengthResponseBody(body, size), { headers }) + } catch (e) { + await refundTraffic(db, workspace.id, size) + 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) + const trafficError = await reserveWebDavTraffic(c, workspace.id, matter.id, storage, trafficBytes) + if (trafficError) return trafficError if (rangeRequest.ranges.length > 1) { const boundary = `zpan-webdav-${matter.id}` const contentLength = multipartRangeContentLength(boundary, matter.type, rangeRequest.ranges, size) @@ -800,7 +817,13 @@ async function readFile(c: DavContext, auth: DavAuth): Promise { const [range] = rangeRequest.ranges const contentLength = range.end - range.start + 1 - const body = await s3.getObjectBody(storage, matter.object, `bytes=${range.start}-${range.end}`) + let body: BodyInit + try { + body = await s3.getObjectBody(storage, matter.object, `bytes=${range.start}-${range.end}`) + } catch (e) { + await refundTraffic(db, workspace.id, contentLength) + throw e + } headers.set('Content-Length', String(contentLength)) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${size}`) return new Response(fixedLengthResponseBody(body, contentLength), { status: 206, headers }) @@ -809,6 +832,26 @@ async function readFile(c: DavContext, auth: DavAuth): Promise { } } +async function reserveWebDavTraffic( + c: DavContext, + orgId: string, + matterId: string, + storage: S3Storage, + bytes: number, +): Promise { + if (bytes <= 0) return null + const db = c.get('platform').db + const trafficAllowed = await consumeTrafficIfQuotaAllows(db, orgId, bytes) + if (!trafficAllowed) return c.text('Traffic quota exceeded', 422) + return reportTrafficForDownload(c, { + orgId, + bytes, + storage, + source: 'webdav_download', + sourceId: matterId, + }) +} + async function putFile(c: DavContext, auth: DavAuth): Promise { const db = c.get('platform').db try { diff --git a/server/services/cloud-traffic-metering.ts b/server/services/cloud-traffic-metering.ts index 64604dfc..bed9778c 100644 --- a/server/services/cloud-traffic-metering.ts +++ b/server/services/cloud-traffic-metering.ts @@ -14,6 +14,7 @@ export type TrafficReportSource = | 'landing_share' | 'image_hosting' | 'custom_domain_image' + | 'webdav_download' export class CloudTrafficBlockedError extends Error { constructor() {