mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
306 lines
10 KiB
TypeScript
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
|
|
}
|
|
}
|