mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
fix(traffic): meter webdav downloads
This commit is contained in:
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user