Files
zpan/server/usecases/redirect.ts
T

306 lines
10 KiB
TypeScript

// The redirect resource usecase. Owns every business decision behind the
// /r/:token short-link download routes — the two sub-resources served there:
// `ds_` direct shares and `ih` image-hosting links. Each resolves a token,
// runs its access/expiry/limit gates, meters the download (egress quota + cloud
// report, with refund-on-presign-failure rollback), and presigns the object.
//
// The http handler only dispatches on the token prefix, extracts request-bound
// inputs (cloud base URL, referer header, request origin), and renders the
// route-specific Responses from the discriminated outcomes below.
import { isImageHostingToken } from '../domain/image-hosting'
import {
type AppError,
expired as expiredError,
forbidden,
type ImageHostingRepo,
insufficientCredits,
notFound,
type QuotaRepo,
quotaExceeded,
type S3Gateway,
type ShareRepo,
type StorageRepo,
storageNotFound,
} from './ports'
import { PRESIGN_TTL_SECS } from './share'
import {
type CloudTrafficMeteringDeps,
confirmDownloadTraffic,
meterDownloadTraffic,
reportDownloadEgress,
reverseDownloadTraffic,
} from './store/traffic-metering'
import { createTrafficEventId, type TransferAuditTarget } from './transfer-activity'
// The metering usecases need the cloud-report ports plus quota; the redirect
// flows additionally read shares / image-hosting / storages and presign via s3.
export type RedirectDeps = CloudTrafficMeteringDeps & {
quota: QuotaRepo
s3: S3Gateway
storages: StorageRepo
share: ShareRepo
imageHosting: ImageHostingRepo
}
export async function resolveRedirectDownloadAuditTarget(
deps: Pick<RedirectDeps, 'share' | 'imageHosting'>,
token: string,
): Promise<TransferAuditTarget | null> {
if (token.startsWith('ds_')) {
const resolved = await deps.share.resolveByToken(token)
if (resolved.status !== 'ok' || resolved.share.kind !== 'direct') return null
return {
orgId: resolved.share.orgId,
targetType: 'share',
targetId: resolved.share.id,
targetName: resolved.matter.name,
bytes: resolved.matter.size ?? 0,
source: 'direct_share',
metadata: {
shareId: resolved.share.id,
matterId: resolved.matter.id,
storageId: resolved.matter.storageId,
},
}
}
if (isImageHostingToken(token)) {
const resolved = await deps.imageHosting.resolveActiveByToken(token)
if (!resolved) return null
return {
orgId: resolved.image.orgId,
targetType: 'image',
targetId: resolved.image.id,
targetName: resolved.image.path,
bytes: resolved.image.size,
source: 'image_hosting',
metadata: { imageId: resolved.image.id, storageId: resolved.image.storageId },
}
}
return null
}
// ─── Direct share (ds_) ──────────────────────────────────────────────────────
export type DirectShareOutcome =
| {
ok: true
url: string
receipt: {
orgId: string
shareId: string
matterId: string
matterName: string
storageId: string
bytes: number
trafficEventId: string
}
}
| { ok: false; error: AppError }
// Resolve a ds_ token to a presigned download URL, running the share gates,
// atomically reserving a download, metering traffic, and presigning. On a
// presign failure the traffic and the reserved download are both rolled back
// before the error propagates (→ 500 at the http layer).
export async function resolveDirectShareDownload(
deps: RedirectDeps,
params: { token: string; cloudBaseUrl: string; now?: Date },
): Promise<DirectShareOutcome> {
const now = params.now ?? new Date()
const resolved = await deps.share.resolveByToken(params.token)
if (resolved.status !== 'ok') {
if (resolved.status === 'matter_trashed') return { ok: false, error: expiredError('File no longer available') }
return { ok: false, error: notFound('Share not found or revoked') }
}
const { share, matter } = resolved
if (share.kind !== 'direct') return { ok: false, error: notFound('Share not found or revoked') }
if (share.expiresAt && share.expiresAt < now) return { ok: false, error: expiredError('Share has expired') }
if (!(await deps.share.hasDownloadsAvailable(share.id)))
return { ok: false, error: expiredError('Download limit exceeded') }
const storage = await deps.storages.get(matter.storageId)
if (!storage) return { ok: false, error: storageNotFound() }
const { ok } = await deps.share.incrementDownloadsAtomic(share.id)
if (!ok) return { ok: false, error: expiredError('Download limit exceeded') }
const bytes = matter.size ?? 0
const trafficEventId = createTrafficEventId()
const metered = await meterDownloadTraffic(deps, {
cloudBaseUrl: params.cloudBaseUrl,
orgId: share.orgId,
bytes,
storage,
source: 'direct_share',
sourceId: share.id,
eventId: trafficEventId,
onRejected: () => deps.share.decrementDownloads(share.id),
})
if (!metered.ok) {
return {
ok: false,
error:
metered.reason === 'quota_exceeded'
? quotaExceeded('Traffic quota exceeded')
: insufficientCredits('Insufficient credits', { metadata: { resource: 'storage_egress' } }),
}
}
let url: string
try {
url = await deps.s3.presignDownload(storage, matter.object, matter.name, PRESIGN_TTL_SECS)
} catch (e) {
try {
await reverseDownloadTraffic(deps, { orgId: share.orgId, bytes, eventId: trafficEventId })
} finally {
await deps.share.decrementDownloads(share.id)
}
throw e
}
try {
await confirmDownloadTraffic(deps, { eventId: trafficEventId })
} catch (error) {
try {
await reverseDownloadTraffic(deps, { orgId: share.orgId, bytes, eventId: trafficEventId })
} finally {
await deps.share.decrementDownloads(share.id)
}
throw error
}
return {
ok: true,
url,
receipt: {
orgId: share.orgId,
shareId: share.id,
matterId: matter.id,
matterName: matter.name,
storageId: storage.id,
bytes,
trafficEventId,
},
}
}
// ─── Image hosting (ih) ──────────────────────────────────────────────────────
export type ImageHostingOutcome =
| {
ok: true
url: string
receipt: {
orgId: string
imageId: string
imagePath: string
storageId: string
bytes: number
trafficEventId: string
}
}
| { ok: false; error: AppError }
// Resolve an ih token to a presigned inline URL. Order matters and mirrors the
// historical flow: enforce the referer allowlist, consume traffic quota, presign
// (refunding the quota on failure → 500), THEN report egress to Cloud (refunding
// + 402 on a credit block, so the presigned URL is discarded) and only then bump
// the access count. The access count is therefore never incremented on any of
// the rejection paths.
export async function resolveImageHostingDownload(
deps: RedirectDeps,
params: { token: string; cloudBaseUrl: string; refererHeader: string | null; requestOrigin: string },
): Promise<ImageHostingOutcome> {
const resolved = await deps.imageHosting.resolveActiveByToken(params.token)
if (!resolved) return { ok: false, error: notFound() }
const { image, refererAllowlist } = resolved
// Allow same-origin requests (e.g. Web UI viewing its own images).
const isSameOrigin = params.refererHeader ? new URL(params.refererHeader).origin === params.requestOrigin : false
if (!isSameOrigin && !checkReferer(refererAllowlist, params.refererHeader)) {
return { ok: false, error: forbidden('forbidden referer') }
}
const storage = await deps.storages.get(image.storageId)
if (!storage) return { ok: false, error: storageNotFound() }
const trafficEventId = createTrafficEventId()
const trafficAllowed = await deps.quota.consumeTrafficIfQuotaAllows(image.orgId, image.size)
if (!trafficAllowed) {
return { ok: false, error: quotaExceeded('Traffic quota exceeded') }
}
let url: string
try {
url = await deps.s3.presignInline(storage, image.storageKey, image.mime, PRESIGN_TTL_SECS)
} catch (e) {
await deps.quota.refundTraffic(image.orgId, image.size)
throw e
}
const reported = await reportDownloadEgress(deps, {
cloudBaseUrl: params.cloudBaseUrl,
orgId: image.orgId,
bytes: image.size,
storage,
source: 'image_hosting',
sourceId: image.id,
eventId: trafficEventId,
})
// reportDownloadEgress never consumes quota, so it cannot return quota_exceeded.
if (!reported.ok) {
return {
ok: false,
error: insufficientCredits('Insufficient credits', { metadata: { resource: 'storage_egress' } }),
}
}
try {
await confirmDownloadTraffic(deps, { eventId: trafficEventId })
} catch (error) {
await reverseDownloadTraffic(deps, { orgId: image.orgId, bytes: image.size, eventId: trafficEventId })
throw error
}
try {
await deps.imageHosting.incrementAccessCount(image.id)
} catch (error) {
console.error('[redirect] incrementAccessCount failed:', error)
}
return {
ok: true,
url,
receipt: {
orgId: image.orgId,
imageId: image.id,
imagePath: image.path,
storageId: storage.id,
bytes: image.size,
trafficEventId,
},
}
}
// ─── Referer allowlist (pure) ────────────────────────────────────────────────
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
}
}