fix(traffic): meter webdav downloads

This commit is contained in:
saltbo
2026-06-06 10:37:14 -04:00
parent 4f15956209
commit 76f9e01f89
3 changed files with 178 additions and 4 deletions
+130
View File
@@ -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)
+47 -4
View File
@@ -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<Response> {
async function readFile(c: DavContext, auth: DavAuth): Promise<Response> {
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<Response> {
: { 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<Response> {
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<Response> {
}
}
async function reserveWebDavTraffic(
c: DavContext,
orgId: string,
matterId: string,
storage: S3Storage,
bytes: number,
): Promise<Response | null> {
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<Response> {
const db = c.get('platform').db
try {
@@ -14,6 +14,7 @@ export type TrafficReportSource =
| 'landing_share'
| 'image_hosting'
| 'custom_domain_image'
| 'webdav_download'
export class CloudTrafficBlockedError extends Error {
constructor() {