Files
zpan/server/routes/redirect.ts
T
Jasper Van ec5d08b233 Report traffic egress to Cloud metering (#384)
* feat(api): report traffic egress to cloud metering

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* test(api): cover cloud traffic redirect reporting

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* test(api): cover cloud traffic failure branches

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* test(api): cover final traffic metering branches

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98

* test(api): cover cloud metering rollback paths

Agent-Profile: https://agent-kanban.dev/agents/a318237412dd8b98
2026-05-08 20:31:48 -04:00

146 lines
5.2 KiB
TypeScript

import type { Context } from 'hono'
import { Hono } from 'hono'
import type { Storage as S3Storage } from '../../shared/types'
import type { Env } from '../middleware/platform'
import type { Database } from '../platform/interface'
import { consumeTrafficIfQuotaAllows, refundTraffic } from '../services/effective-quota'
import { incrementAccessCount, resolveActiveImageByToken } from '../services/image-hosting'
import {
decrementDownloads,
hasDownloadsAvailable,
incrementDownloadsAtomic,
resolveShareByToken,
} from '../services/share'
import { getStorage } from '../services/storage'
import { PRESIGN_TTL_SECS, s3 } from './share-utils'
import { reportTrafficForDownload } from './traffic-metering-utils'
// Strip optional file extension from token (e.g. "ih_aB3xK9.png" → "ih_aB3xK9")
function stripExtension(token: string): string {
const dot = token.lastIndexOf('.')
return dot > 0 ? token.slice(0, dot) : token
}
function checkReferer(refererAllowlist: string[], refererHeader: string | null): boolean {
if (refererAllowlist.length === 0) return true
// Allow empty referer — direct access from tools, address bar, or privacy
// extensions should not be blocked. The allowlist targets hotlinking from
// unauthorized *websites*, which always send a Referer header.
if (!refererHeader) return true
try {
const origin = new URL(refererHeader).origin
return refererAllowlist.includes(origin)
} catch {
return false
}
}
async function handleDirectShare(c: Context<Env>, db: Database, token: string): Promise<Response> {
const resolved = await resolveShareByToken(db, token)
if (resolved.status !== 'ok') {
if (resolved.status === 'matter_trashed') return c.json({ error: 'File no longer available' }, 410)
return c.json({ error: 'Share not found or revoked' }, 404)
}
const { share, matter } = resolved
if (share.kind !== 'direct') return c.json({ error: 'Share not found or revoked' }, 404)
if (share.expiresAt && share.expiresAt < new Date()) return c.json({ error: 'Share has expired' }, 410)
if (!(await hasDownloadsAvailable(db, share.id))) return c.json({ error: 'Download limit exceeded' }, 410)
const storage = (await getStorage(db, matter.storageId)) as unknown as S3Storage
if (!storage) return c.json({ error: 'Storage not found' }, 404)
const { ok } = await incrementDownloadsAtomic(db, share.id)
if (!ok) return c.json({ error: 'Download limit exceeded' }, 410)
const trafficAllowed = await consumeTrafficIfQuotaAllows(db, share.orgId, matter.size ?? 0)
if (!trafficAllowed) {
await decrementDownloads(db, share.id)
return c.json({ error: 'Traffic quota exceeded' }, 422)
}
let url: string
try {
url = await s3.presignDownload(storage, matter.object, matter.name, PRESIGN_TTL_SECS)
} catch (e) {
await refundTraffic(db, share.orgId, matter.size ?? 0)
await decrementDownloads(db, share.id)
throw e
}
const trafficReportError = await reportTrafficForDownload(c, {
orgId: share.orgId,
bytes: matter.size ?? 0,
source: 'direct_share',
sourceId: share.id,
onRejected: () => decrementDownloads(db, share.id),
})
if (trafficReportError) return trafficReportError
const res = c.redirect(url, 302)
res.headers.set('Cache-Control', 'no-store')
return res
}
async function handleImageHosting(c: Context<Env>, db: Database, token: string): Promise<Response> {
const resolved = await resolveActiveImageByToken(db, token)
if (!resolved) return c.json({ error: 'Not found' }, 404)
const { image, refererAllowlist } = resolved
const refererHeader = c.req.header('Referer') ?? null
// Allow same-origin requests (e.g. Web UI viewing its own images)
const requestOrigin = new URL(c.req.url).origin
const isSameOrigin = refererHeader ? new URL(refererHeader).origin === requestOrigin : false
if (!isSameOrigin && !checkReferer(refererAllowlist, refererHeader)) {
return c.json({ error: 'forbidden referer' }, 403)
}
const storage = (await getStorage(db, image.storageId)) as unknown as S3Storage
if (!storage) return c.json({ error: 'Storage not found' }, 404)
const trafficAllowed = await consumeTrafficIfQuotaAllows(db, image.orgId, image.size)
if (!trafficAllowed) return c.json({ error: 'Traffic quota exceeded' }, 422)
let url: string
try {
url = await s3.presignInline(storage, image.storageKey, image.mime, PRESIGN_TTL_SECS)
} catch (e) {
await refundTraffic(db, image.orgId, image.size)
throw e
}
const trafficReportError = await reportTrafficForDownload(c, {
orgId: image.orgId,
bytes: image.size,
source: 'image_hosting',
sourceId: image.id,
})
if (trafficReportError) return trafficReportError
try {
await incrementAccessCount(db, image.id)
} catch (error) {
console.error('[redirect] incrementAccessCount failed:', error)
}
const res = c.redirect(url, 302)
res.headers.set('Cache-Control', 'no-store')
return res
}
const app = new Hono<Env>().get('/:token', async (c) => {
const raw = c.req.param('token')
const token = stripExtension(raw)
const db = c.get('platform').db
if (token.startsWith('ds_')) return handleDirectShare(c, db, token)
if (token.startsWith('ih_')) return handleImageHosting(c, db, token)
return c.json({ error: 'Not found' }, 404)
})
export default app