Files
zpan/server/usecases/share.ts
T
saltbo 60f8f64481 refactor(analytics): enforce offline result boundaries
Build versioned UTC hourly rollups, validate analytics facts, and keep dashboard requests result-only with explicit coverage semantics.

Move derived metrics to the server and simplify the admin dashboard around one reconcilable UTC range without export.
2026-07-18 01:58:34 -04:00

784 lines
28 KiB
TypeScript

// The shares resource usecase. Owns every business decision behind the public
// (/api/shares before auth) and authed (/api/shares after auth) share routes:
// resolution gating, view/download dedup, the end-to-end download meter, share
// creation + recipient notification, revocation, and save-to-drive. The http
// handlers only read cookies/params, call these functions with `deps` whole,
// and serialize the discriminated outcomes into responses.
//
// Cookies stay in http: the usecase takes cookie-derived inputs (viewerId,
// whether the access cookie is 'ok', whether the view cookie was already 'seen')
// and returns cookie *decisions*; the handler runs getCookie/setCookie.
import { createHmac } from 'node:crypto'
import { DirType } from '@shared/constants'
import type { CreateShareRequest } from '@shared/schemas/share'
import { isAccessibleByUser } from '../domain/share'
import { verifyPassword as verifyPasswordHash } from '../lib/password'
import type { Platform } from '../platform/interface'
import { type SaveToDriveDeps, saveShareToDrive } from './object'
import {
type ActivityRepo,
AppError,
badRequest,
CreateShareError,
type EmailGateway,
expired as expiredError,
forbidden,
insufficientCredits,
type Matter,
type MatterRepo,
type NotificationRepo,
notFound,
type OrgRepo,
passwordRequired,
type QuotaRepo,
quotaExceeded,
type S3Gateway,
type ShareNotificationRecipient,
type ShareNotificationRepo,
type ShareNotificationShare,
type ShareRecipientRecord,
type ShareRecord,
type ShareRepo,
type StorageRepo,
storageNotFound,
} from './ports'
import type { CloudTrafficMeteringDeps } from './store/traffic-metering'
import { meterDownloadTraffic } from './store/traffic-metering'
import { createTrafficEventId, recordDownloadFailed, recordDownloadIssued } from './transfer-activity'
// The ports + sub-usecase deps this resource touches. `c.get('deps')` (the full
// Deps) structurally satisfies this, so the handler passes it whole. The
// intersected sub-usecase deps (save-to-drive, share-notification, the download
// meter) carry the ports those collaborators reach through.
export type ShareDeps = SaveToDriveDeps &
ShareNotificationDeps &
CloudTrafficMeteringDeps & {
share: ShareRepo
matter: MatterRepo
storages: StorageRepo
s3: S3Gateway
quota: QuotaRepo
activity: ActivityRepo
org: OrgRepo
}
// ─── GET /:token — view a share (creator vs viewer DTO) ──────────────────────
export type ViewShareParams = {
token: string
viewerId: string | null
// Cookie-derived: 'seen' if the view-dedup cookie is already set.
viewCookie: string | undefined
// Cookie-derived: 'ok' if the access cookie is set.
accessCookie: string | undefined
now?: Date
}
export type ShareViewerDto = {
token: string
kind: string
status: string
expiresAt: Date | null
downloadLimit: number | null
matter: { name: string; type: string; size: number | null; isFolder: boolean }
creatorName: string
requiresPassword: boolean
expired: boolean
exhausted: boolean
accessibleByUser: boolean
downloads: number
views: number
rootRef: string
}
export type ShareCreatorDto = ShareViewerDto & {
id: string
matterId: string
orgId: string
creatorId: string
createdAt: Date
recipients: ShareRecipientRecord[]
}
export type ViewShareOutcome =
| { ok: true; dto: ShareViewerDto | ShareCreatorDto; setViewCookie: boolean }
| { ok: false; error: AppError }
// Assemble the share view DTO from resolved share data. The creator (matched by
// viewerId) gets the richer ShareCreatorDto; everyone else gets the viewer DTO.
// Shared by viewShare and revokeShare so both expose an identically-shaped view.
async function composeShareView(
deps: ShareDeps,
resolved: { share: ShareRecord; matter: Matter; recipients: ShareRecipientRecord[] },
opts: { viewerId: string | null; accessCookie: string | undefined; now: Date },
): Promise<ShareViewerDto | ShareCreatorDto> {
const { share, matter, recipients } = resolved
const { viewerId, accessCookie, now } = opts
const isCreator = !!viewerId && viewerId === share.creatorId
const accessibleByUser = viewerId ? isAccessibleByUser(recipients, viewerId) : false
const requiresPassword = !isCreator && !!(share.passwordHash && !accessibleByUser && accessCookie !== 'ok')
const expired = !!(share.expiresAt && share.expiresAt < now)
const exhausted = !!(share.downloadLimit != null && share.downloads >= share.downloadLimit)
const isFolder = matter.dirtype !== DirType.FILE
const creatorName = (await deps.share.getCreatorName(share.creatorId)) ?? ''
const base: ShareViewerDto = {
token: share.token,
kind: share.kind,
status: share.status,
expiresAt: share.expiresAt,
downloadLimit: share.downloadLimit,
matter: { name: matter.name, type: matter.type, size: matter.size, isFolder },
creatorName,
requiresPassword,
expired,
exhausted,
accessibleByUser,
downloads: share.downloads,
views: share.views,
rootRef: encodeChildRef(share.token, matter.id),
}
if (isCreator) {
return {
...base,
id: share.id,
matterId: share.matterId,
orgId: share.orgId,
creatorId: share.creatorId,
createdAt: share.createdAt,
recipients,
}
}
return base
}
export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promise<ViewShareOutcome> {
const { token, viewerId, viewCookie, accessCookie, now = new Date() } = params
const resolved = await deps.share.resolveByToken(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, recipients } = resolved
const isCreator = !!viewerId && viewerId === share.creatorId
// Direct shares are not publicly viewable; only the creator sees metadata.
if (share.kind !== 'landing' && !isCreator) return { ok: false, error: notFound('Share not found or revoked') }
// View-dedup increment: non-creators whose view cookie isn't yet 'seen'. The
// handler sets the cookie when setViewCookie is true.
const setViewCookie = !isCreator && viewCookie !== 'seen'
if (setViewCookie) {
await deps.share.recordView(share.id, {
orgId: share.orgId,
userId: viewerId,
actorType: viewerId ? 'user' : 'anonymous',
action: 'share_view',
targetType: 'share',
targetId: share.id,
targetName: matter.name,
metadata: {
shareId: share.id,
matterId: matter.id,
creatorId: share.creatorId,
kind: share.kind,
requiresPassword: !!share.passwordHash,
matterType: matter.type,
bytes: matter.size ?? 0,
},
})
}
const dto = await composeShareView(deps, { share, matter, recipients }, { viewerId, accessCookie, now })
return { ok: true, setViewCookie, dto }
}
// ─── POST /:token/sessions — verify password → access-cookie decision ────────
export type VerifySharePasswordParams = { token: string; password: string; viewerId?: string | null; now?: Date }
export type VerifySharePasswordOutcome = { ok: true; setAccessCookieExpiry: Date } | { ok: false; error: AppError }
export async function verifySharePassword(
deps: ShareDeps,
params: VerifySharePasswordParams,
): Promise<VerifySharePasswordOutcome> {
const { token, password, viewerId = null, now = new Date() } = params
const resolved = await deps.share.resolveByToken(token)
if (resolved.status !== 'ok') return { ok: false, error: notFound('Share not found or revoked') }
const { share, matter } = resolved
if (share.kind !== 'landing') return { ok: false, error: notFound('Share not found or revoked') }
if (!share.passwordHash || !verifyPasswordHash(share.passwordHash, password))
return { ok: false, error: forbidden('Invalid password') }
// Cookie lives for up to a day, never past the share's own expiry.
const oneDayMs = 24 * 60 * 60 * 1000
const setAccessCookieExpiry = share.expiresAt
? new Date(Math.min(share.expiresAt.getTime(), now.getTime() + oneDayMs))
: new Date(now.getTime() + oneDayMs)
await deps.activity.record({
orgId: share.orgId,
userId: viewerId,
actorType: viewerId ? 'user' : 'anonymous',
action: 'share_password_passed',
targetType: 'share',
targetId: share.id,
targetName: matter.name,
metadata: {
shareId: share.id,
matterId: matter.id,
creatorId: share.creatorId,
kind: share.kind,
},
})
return { ok: true, setAccessCookieExpiry }
}
// ─── GET /:token/objects — folder listing ────────────────────────────────────
export type ListShareObjectsParams = {
token: string
viewerId: string | null
accessCookie: string | undefined
relativePath: string
page: number
pageSize: number
now?: Date
}
export type ShareObjectItem = { ref: string; name: string; type: string; size: number | null; isFolder: boolean }
export type ListShareObjectsResult = {
items: ShareObjectItem[]
total: number
page: number
pageSize: number
breadcrumb: Array<{ name: string; path: string }>
}
export type ListShareObjectsOutcome = { ok: true; result: ListShareObjectsResult } | { ok: false; error: AppError }
export async function listShareObjects(
deps: ShareDeps,
params: ListShareObjectsParams,
): Promise<ListShareObjectsOutcome> {
const { token, viewerId, accessCookie, relativePath, page, pageSize, now = new Date() } = params
const resolved = await deps.share.resolveByToken(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, recipients } = resolved
if (share.kind !== 'landing') return { ok: false, error: notFound('Share not found or revoked') }
if (matter.dirtype === DirType.FILE) return { ok: false, error: badRequest('Not a folder share') }
if (checkAccessGate(share.passwordHash, recipients, viewerId, accessCookie) === 'password_required')
return { ok: false, error: passwordRequired() }
if (share.expiresAt && share.expiresAt < now) return { ok: false, error: expiredError('Share has expired') }
if (relativePath.includes('..')) return { ok: false, error: badRequest('Invalid path') }
const root = folderRootPath(matter)
const queryParent = relativePath ? `${root}/${relativePath}` : root
const result = await deps.matter.list(matter.orgId, { parent: queryParent, page, pageSize })
return {
ok: true,
result: {
items: result.items.map((m) => ({
ref: encodeChildRef(token, m.id),
name: m.name,
type: m.type,
size: m.size,
isFolder: m.dirtype !== DirType.FILE,
})),
total: result.total,
page,
pageSize,
breadcrumb: buildBreadcrumb(matter.name, relativePath),
},
}
}
// ─── GET /:token/objects/:ref — download (orchestration + metering) ──────────
export type DownloadShareObjectParams = {
token: string
// The decoded matter id of the requested ref (null when the ref signature
// failed to verify); the handler decodes/validates via the pure ref helpers.
matterId: string | null
viewerId: string | null
accessCookie: string | undefined
cloudBaseUrl: string
}
export type DownloadShareObjectOutcome = { ok: true; url: string } | { ok: false; error: AppError }
export async function downloadShareObject(
deps: ShareDeps,
params: DownloadShareObjectParams,
): Promise<DownloadShareObjectOutcome> {
const { token, matterId, viewerId, accessCookie, cloudBaseUrl } = params
const resolved = await deps.share.resolveByToken(token)
if (resolved.status !== 'ok') {
if (resolved.status === 'matter_trashed') return { ok: false, error: expiredError('File no longer available') }
return { ok: false, error: notFound('File not found or not accessible') }
}
const { share, matter, recipients } = resolved
if (share.kind !== 'landing') return { ok: false, error: notFound('File not found or not accessible') }
if (matterId === null) return { ok: false, error: badRequest('Invalid reference') }
if (checkAccessGate(share.passwordHash, recipients, viewerId, accessCookie) === 'password_required')
return { ok: false, error: passwordRequired() }
if (share.expiresAt && share.expiresAt < new Date()) return { ok: false, error: expiredError('Share has expired') }
let targetMatter = matter
if (matterId !== matter.id) {
if (matter.dirtype === DirType.FILE) return { ok: false, error: notFound('File not found or not accessible') }
const child = await deps.share.findShareChildMatter(matter, matterId)
if (!child) return { ok: false, error: notFound('File not found or not accessible') }
targetMatter = child
} else if (matter.dirtype !== DirType.FILE) {
return { ok: false, error: badRequest('Cannot download a folder directly') }
}
if (!(await deps.share.hasDownloadsAvailable(share.id)))
return { ok: false, error: expiredError('Download limit exceeded') }
const storage = await deps.storages.get(targetMatter.storageId)
if (!storage) return { ok: false, error: storageNotFound() }
const { ok: incremented } = await deps.share.incrementDownloadsAtomic(share.id)
if (!incremented) return { ok: false, error: expiredError('Download limit exceeded') }
const bytes = targetMatter.size ?? 0
const trafficEventId = createTrafficEventId()
const metered = await meterDownloadTraffic(deps, {
cloudBaseUrl,
orgId: share.orgId,
bytes,
storage,
source: 'landing_share',
sourceId: share.id,
eventId: trafficEventId,
onRejected: () => deps.share.decrementDownloads(share.id),
})
if (!metered.ok) {
await recordDownloadFailed(deps.activity, {
orgId: share.orgId,
userId: viewerId,
actorType: viewerId ? 'user' : 'anonymous',
targetType: 'share',
targetId: share.id,
targetName: targetMatter.name,
source: 'landing_share',
bytes,
trafficEventId,
reason: metered.reason,
metadata: { shareId: share.id, matterId: targetMatter.id, storageId: targetMatter.storageId },
})
return {
ok: false,
error:
metered.reason === 'quota_exceeded'
? quotaExceeded('Traffic quota exceeded')
: insufficientCredits('Insufficient credits', { metadata: { resource: 'storage_egress' } }),
}
}
// Presign. On failure the metering already succeeded, so roll it back
// ourselves: refund the consumed traffic and the download count, then rethrow.
let url: string
try {
url = await deps.s3.presignDownload(storage, targetMatter.object, targetMatter.name, PRESIGN_TTL_SECS)
} catch (e) {
await deps.quota.refundTraffic(share.orgId, bytes)
await deps.share.decrementDownloads(share.id)
await recordDownloadFailed(deps.activity, {
orgId: share.orgId,
userId: viewerId,
actorType: viewerId ? 'user' : 'anonymous',
targetType: 'share',
targetId: share.id,
targetName: targetMatter.name,
source: 'landing_share',
bytes,
trafficEventId,
reason: 'presign_failed',
metadata: { shareId: share.id, matterId: targetMatter.id, storageId: targetMatter.storageId },
})
throw e
}
try {
await recordDownloadIssued(deps.activity, {
orgId: share.orgId,
userId: viewerId,
actorType: viewerId ? 'user' : 'anonymous',
action: 'share_download',
targetType: 'share',
targetId: share.id,
targetName: targetMatter.name,
source: 'landing_share',
bytes,
trafficEventId,
metadata: {
anonymous: !viewerId,
shareId: share.id,
matterId: targetMatter.id,
rootMatterId: matter.id,
creatorId: share.creatorId,
storageId: targetMatter.storageId,
kind: share.kind,
},
})
} catch (error) {
await deps.quota.refundTraffic(share.orgId, bytes)
await deps.share.decrementDownloads(share.id)
throw error
}
return { ok: true, url }
}
// ─── GET / — list shares (received / sent) ───────────────────────────────────
export type ListSharesParams = {
userId: string
box: 'received' | 'sent' | undefined
page: number
pageSize: number
status?: string
}
export async function listShares(deps: ShareDeps, params: ListSharesParams) {
const { userId, box, page, pageSize, status } = params
if (box === 'received') {
const email = await deps.share.getUserEmail(userId)
const result = await deps.share.listReceivedForApi(userId, email, { page, pageSize })
return { ...result, page, pageSize }
}
const result = await deps.share.listForApi(userId, { page, pageSize, status })
return { ...result, page, pageSize }
}
// ─── POST / — create a share (notify + activity; map create errors) ──────────
export type CreateShareParams = {
orgId: string
userId: string
// The validated create-share request body. `expiresAt` is an ISO string here
// (the wire shape); it is parsed to a Date before hitting the repo.
input: CreateShareRequest
}
export type CreatedShare = {
token: string
kind: string
expiresAt: Date | null
downloadLimit: number | null
}
export type CreateShareOutcome = { ok: true; share: CreatedShare } | { ok: false; error: AppError }
// The wire mapping for each CreateShareError code: a 404 (matter missing) or a
// 400 (invalid direct-share shape), each preserving its stable reason.
const CREATE_SHARE_ERRORS: Record<CreateShareError['code'], AppError> = {
MATTER_NOT_FOUND: new AppError(404, 'Matter not found', { reason: 'MATTER_NOT_FOUND' }),
DIRECT_NO_FOLDER: badRequest('Direct shares cannot be folders', 'DIRECT_NO_FOLDER'),
DIRECT_NO_PASSWORD: badRequest('Direct shares cannot have a password', 'DIRECT_NO_PASSWORD'),
DIRECT_NO_RECIPIENTS: badRequest('Direct shares cannot have recipients', 'DIRECT_NO_RECIPIENTS'),
}
export async function createShare(
deps: ShareDeps,
platform: Platform,
params: CreateShareParams,
): Promise<CreateShareOutcome> {
const { orgId, userId, input } = params
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : undefined
const [creatorNameRaw, matterName] = await Promise.all([
deps.share.getCreatorName(userId),
deps.share.getMatterName(input.matterId),
])
const creatorName = creatorNameRaw ?? 'Unknown'
let share: ShareRecord
try {
share = await deps.share.create({
matterId: input.matterId,
orgId,
creatorId: userId,
kind: input.kind,
password: input.password,
expiresAt,
downloadLimit: input.downloadLimit,
recipients: input.recipients,
})
} catch (err) {
if (err instanceof CreateShareError) return { ok: false, error: CREATE_SHARE_ERRORS[err.code] }
throw err
}
const resolvedMatterName = matterName ?? ''
const recipients = input.recipients ?? []
if (recipients.length > 0) {
dispatchShareCreated(
deps,
platform,
{ id: share.id, token: share.token, kind: share.kind as 'landing' | 'direct', expiresAt: share.expiresAt },
recipients,
creatorName,
resolvedMatterName,
).catch((err) => console.error('[shares] dispatchShareCreated failed:', err))
}
await deps.activity.record({
orgId,
userId,
action: 'share_create',
targetType: 'share',
targetId: share.id,
targetName: resolvedMatterName,
metadata: { kind: share.kind, hasPassword: !!input.password, hasExpiry: !!input.expiresAt },
})
return {
ok: true,
share: { token: share.token, kind: share.kind, expiresAt: share.expiresAt, downloadLimit: share.downloadLimit },
}
}
// ─── PUT /:token/status — revoke (ownership-scoped) ──────────────────────────
export type RevokeShareParams = { token: string; userId: string; orgId: string; now?: Date }
export type RevokeShareOutcome = { ok: true; dto: ShareViewerDto | ShareCreatorDto } | { ok: false; error: AppError }
export async function revokeShare(deps: ShareDeps, params: RevokeShareParams): Promise<RevokeShareOutcome> {
const { token, userId, orgId, now = new Date() } = params
// Resolve before revoking: once the status flips to 'revoked', resolveByToken
// no longer returns the record, so we capture the share here to build the
// creator view. Unknown or already-revoked tokens are "not found" to the
// revoker. A trashed matter still carries the records: the owner must be able
// to revoke a share whose target was soft-deleted (trashing does not cascade
// to shares), so this path stays revocable.
const resolved = await deps.share.resolveByToken(token)
if (resolved.status === 'not_found' || resolved.status === 'revoked') return { ok: false, error: notFound() }
if (resolved.share.creatorId !== userId) return { ok: false, error: forbidden() }
// Race-safe: revokeByToken scopes the UPDATE to (token, creatorId). An
// ownership change between the resolve above and this call returns false —
// translate to not_found at the boundary.
const revoked = await deps.share.revokeByToken(token, userId)
if (!revoked) return { ok: false, error: notFound() }
await deps.activity.record({
orgId,
userId,
action: 'share_revoke',
targetType: 'share',
targetName: token,
})
// Return the creator view reflecting the post-revoke state.
const dto = await composeShareView(
deps,
{ share: { ...resolved.share, status: 'revoked' }, matter: resolved.matter, recipients: resolved.recipients },
{ viewerId: userId, accessCookie: undefined, now },
)
return { ok: true, dto }
}
// ─── POST /:token/objects — save-to-drive (gates + copy) ─────────────────────
export type SaveShareParams = {
token: string
currentUserId: string
targetOrgId: string
targetParent: string
accessCookie: string | undefined
}
export type SaveShareOutcome =
| { ok: true; result: { saved: Matter[]; skipped: Array<{ name: string; reason: string }> } }
| { ok: false; error: AppError }
export async function saveShare(deps: ShareDeps, params: SaveShareParams): Promise<SaveShareOutcome> {
const { token, currentUserId, targetOrgId, targetParent, accessCookie } = params
const resolution = await deps.share.resolveByToken(token)
if (resolution.status === 'matter_trashed') return { ok: false, error: expiredError('Share target has been deleted') }
if (resolution.status !== 'ok') return { ok: false, error: notFound('Share not found') }
const { share, matter, recipients } = resolution
if (share.kind === 'direct')
return {
ok: false,
error: badRequest(
'Direct link shares cannot be saved. Ask the sender for a landing share.',
'DIRECT_SAVE_FORBIDDEN',
),
}
if (checkAccessGate(share.passwordHash, recipients, currentUserId, accessCookie) === 'password_required')
return { ok: false, error: passwordRequired('Authentication required for password-protected share') }
if (!(await deps.org.canWriteToOrg(currentUserId, targetOrgId))) return { ok: false, error: forbidden() }
const totalBytes = await deps.share.computeSourceBytes(matter)
if (!(await deps.share.hasQuotaForBytes(targetOrgId, totalBytes))) return { ok: false, error: quotaExceeded() }
const result = await saveShareToDrive(deps, { share, matter, currentUserId, targetOrgId, targetParent })
return { ok: true, result }
}
// ── share notifications ──────────────────────────────────────────────────────
export type ShareNotificationDeps = {
notifications: NotificationRepo
email: EmailGateway
shareNotifications: ShareNotificationRepo
}
async function sendShareEmail(
deps: ShareNotificationDeps,
platform: Platform,
opts: { to: string; creatorName: string; matterName: string; url: string; expiresAt: Date | null },
): Promise<void> {
const expiryLine = opts.expiresAt ? `<p>This share expires on ${opts.expiresAt.toISOString().split('T')[0]}.</p>` : ''
await deps.email.send(platform, {
to: opts.to,
subject: `${opts.creatorName} shared "${opts.matterName}" with you`,
html: `
<h2>${opts.creatorName} shared a file with you</h2>
<p><strong>${opts.matterName}</strong> is now available.</p>
${expiryLine}
<p><a href="${opts.url}">Open share</a></p>
`,
})
}
export async function dispatchShareCreated(
deps: ShareNotificationDeps,
platform: Platform,
share: ShareNotificationShare,
recipients: ShareNotificationRecipient[],
creatorName: string,
matterName: string,
): Promise<void> {
const shareUrl = share.kind === 'landing' ? `/s/${share.token}` : `/r/${share.token}`
const emailEnabled = await deps.email.isConfigured(platform)
for (const r of recipients) {
if (r.recipientUserId) {
await deps.notifications.create({
userId: r.recipientUserId,
type: 'share_received',
title: `${creatorName} shared "${matterName}" with you`,
body: 'Click to open the share',
refType: 'share',
refId: share.id,
metadata: JSON.stringify({ token: share.token, kind: share.kind, creatorName, matterName }),
})
}
const email =
r.recipientEmail ?? (r.recipientUserId ? await deps.shareNotifications.getUserEmail(r.recipientUserId) : null)
if (email && emailEnabled) {
try {
await sendShareEmail(deps, platform, {
to: email,
creatorName,
matterName,
url: shareUrl,
expiresAt: share.expiresAt,
})
} catch (err) {
console.error(`[share-notification] email to ${email} failed:`, err)
}
}
}
}
// ── share refs ───────────────────────────────────────────────────────────────
// Pure share-token helpers shared by the share + redirect usecases and the http
// layer: child-ref signing/verification, folder path math, breadcrumb building,
// and the password/recipient access gate. Framework-free (node:crypto only), so
// usecases may import it; http/share-utils re-exports it for the handlers.
export const PRESIGN_TTL_SECS = 5 * 60
export function encodeChildRef(shareToken: string, matterId: string): string {
const sig = createHmac('sha256', shareToken).update(matterId).digest('hex').slice(0, 16)
return Buffer.from(`${matterId}.${sig}`).toString('base64url')
}
export function decodeChildRef(shareToken: string, childRef: string): string | null {
try {
const raw = Buffer.from(childRef, 'base64url').toString('utf-8')
const dotIdx = raw.lastIndexOf('.')
if (dotIdx < 0) return null
const matterId = raw.slice(0, dotIdx)
const sig = raw.slice(dotIdx + 1)
const expectedSig = createHmac('sha256', shareToken).update(matterId).digest('hex').slice(0, 16)
return sig === expectedSig ? matterId : null
} catch {
return null
}
}
export function folderRootPath(matter: { parent: string; name: string }): string {
return matter.parent ? `${matter.parent}/${matter.name}` : matter.name
}
export function buildBreadcrumb(rootName: string, relativePath: string): Array<{ name: string; path: string }> {
const crumbs: Array<{ name: string; path: string }> = [{ name: rootName, path: '' }]
if (!relativePath) return crumbs
let accumulated = ''
for (const part of relativePath.split('/')) {
accumulated = accumulated ? `${accumulated}/${part}` : part
crumbs.push({ name: part, path: accumulated })
}
return crumbs
}
export function checkAccessGate(
passwordHash: string | null,
recipients: ShareRecipientRecord[],
userId: string | null,
cookieValue: string | undefined,
): 'ok' | 'password_required' {
if (!passwordHash) return 'ok'
if (userId && isAccessibleByUser(recipients, userId)) return 'ok'
if (cookieValue === 'ok') return 'ok'
return 'password_required'
}