Files
zpan/server/http/objects.ts
T

572 lines
19 KiB
TypeScript

import { OpenAPIHono, z } from '@hono/zod-openapi'
import {
completeObjectUploadSchema,
copyObjectBodySchema,
createMatterSchema,
cursorPageQuerySchema,
cursorPageSchema,
objectUploadInstructionsSchema,
patchMatterSchema,
presignObjectUploadPartsResponseSchema,
presignObjectUploadPartsSchema,
transferMatterSchema,
} from '@shared/schemas'
import type { Context } from 'hono'
import { createMiddleware } from 'hono/factory'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { transferAuditActor } from '../middleware/audit-transfers'
import { requireTeamRole } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import {
abortUpload,
authorizeTaskUploadConfirm,
completeUpload,
copyObject,
createObject,
getObject,
hasEditorAccess,
listObjects,
type ObjectActor,
ObjectUploadSessionError,
presignUploadSessionParts,
transferObject,
trashObject,
updateObject,
} from '../usecases/object'
import { badRequest, forbidden, type Matter, type MatterListItem, unauthorized } from '../usecases/ports'
import { recordDownloadIssued } from '../usecases/transfer-activity'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
import { decodeOptionalPageToken, directoryCursorCodec, encodeNextPageToken, pageQueryFingerprint } from './page-token'
// The wire shape of a file/folder — exactly what the API serializes. Timestamps
// are strings here (the domain `Matter` carries them as `Date`); `toMatterDTO`
// below bridges the two, so this schema is provably what handlers return. Doc-only
// for the frontend (callers go through `unwrap<T>()`), but the single source the
// OpenAPI document and SDKs derive the `Matter` model from.
const matterSchema = z
.object({
id: z.string(),
orgId: z.string(),
alias: z.string(),
name: z.string(),
type: z.string(),
size: z.number().int().nullable(),
dirtype: z.number().int().nullable(),
parent: z.string(),
object: z.string(),
storageId: z.string(),
status: z.string(),
trashedAt: z.number().int().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.openapi('Matter')
type MatterDTO = z.infer<typeof matterSchema>
// The one place the domain `Matter` crosses to the wire: serialize `Date`
// timestamps to ISO strings, pass everything else through. Its return type is the
// schema's inferred type, so a drift between `Matter` and `matterSchema` is a
// compile error — not a silent lie in the document.
function toMatterDTO(m: Matter): MatterDTO {
return {
id: m.id,
orgId: m.orgId,
alias: m.alias,
name: m.name,
type: m.type,
size: m.size,
dirtype: m.dirtype,
parent: m.parent,
object: m.object,
storageId: m.storageId,
status: m.status,
trashedAt: m.trashedAt,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
}
const objectListItemSchema = matterSchema.extend({ hasChildren: z.boolean() }).openapi('ObjectListItem')
type ObjectListItemDTO = z.infer<typeof objectListItemSchema>
function toObjectListItemDTO(item: MatterListItem): ObjectListItemDTO {
return { ...toMatterDTO(item), hasChildren: item.hasChildren }
}
const objectPageSchema = cursorPageSchema(objectListItemSchema, 'ObjectPage')
// POST / returns the created object plus, for a file draft, the upload
// instructions: the server-decided part size and the presigned URLs to PUT each
// slice to (1 URL = single PutObject, N URLs = multipart).
const objectCreateResultSchema = matterSchema.extend({ upload: objectUploadInstructionsSchema.optional() })
// GET /{id} returns the object plus, when egress is metered/allowed, a presigned
// download URL.
const objectWithDownloadSchema = matterSchema.extend({ downloadUrl: z.string().optional() })
// List endpoint reads query params ad-hoc; declared here for docs + RPC typing.
// The non-pagination filters are optional so callers may send any subset; `page`
// comes from the shared integer-coerced pagination schema. The file manager loads a
// whole folder client-side (no UI paging, FILES_PAGE_SIZE=500), so this list
// overrides the shared pageSize cap of 100 with a higher ceiling — the rest of the
// API keeps the 100 default. Live objects only — the recycle bin is GET /trash/objects.
const listObjectsQuerySchema = cursorPageQuerySchema.extend({
parent: z.string().optional(),
path: z.string().optional(),
type: z.string().optional(),
search: z.string().optional(),
orgId: z.string().optional(),
})
const idParam = z.object({ id: z.string() })
const sessionParams = z.object({ id: z.string(), uploadSessionId: z.string() })
const abortUploadQuerySchema = z.object({
strictStorageCleanup: z.enum(['1', 'true']).optional(),
})
// The caller acting on objects: a download-task-upload token acts on behalf of
// the task creator; otherwise it is the authenticated user.
function objectActor(c: Context<Env>): ObjectActor {
const principal = c.get('principal')
if (principal?.kind === 'download-task-upload') {
return {
kind: 'download-task-upload',
downloaderId: principal.downloaderId,
taskId: principal.taskId,
targetFolder: principal.targetFolder,
createdByUserId: principal.createdByUserId,
}
}
return { kind: 'user', userId: c.get('userId') as string }
}
// The id recorded in matter/activity logs.
function actorId(c: Context<Env>): string {
const principal = c.get('principal')
if (principal?.kind === 'download-task-upload') return `downloader:${principal.downloaderId}`
return c.get('userId') ?? 'system'
}
const cloudBaseUrl = (c: Context<Env>) => c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
const requireObjectWriteAccess = createMiddleware<Env>(async (c, next) => {
const principal = c.get('principal')
if (principal?.kind === 'download-task-upload') {
await next()
return
}
if (!(await hasEditorAccess(c.get('deps'), { orgId: c.get('orgId'), userId: c.get('userId') }))) {
if (c.get('userId')) throw forbidden()
throw unauthorized()
}
await next()
})
const objectWriteAuth = {
access: 'anyOf',
policies: [{ access: 'session', minTeamRole: 'editor' }, { access: 'task-upload-token' }],
} as const
const listRoute = authRoute(
{ access: 'session', minTeamRole: 'viewer' },
{
operationId: 'listObjects',
summary: 'List objects',
tags: ['Objects'],
method: 'get',
path: '/',
middleware: [requireTeamRole('viewer')] as const,
request: { query: listObjectsQuerySchema },
responses: {
200: jsonContent(objectPageSchema, 'Objects'),
400: errorResponse('No active organization'),
403: errorResponse('Forbidden'),
},
},
)
const createObjectRoute = authRoute(objectWriteAuth, {
operationId: 'createObject',
summary: 'Create object',
tags: ['Objects'],
method: 'post',
path: '/',
middleware: [requireObjectWriteAccess] as const,
request: jsonBody(createMatterSchema),
responses: {
201: jsonContent(objectCreateResultSchema, 'Created object (folder, or file draft with upload instructions)'),
400: errorResponse('No active organization or file too large'),
403: errorResponse('Forbidden'),
409: errorResponse('Name conflict'),
503: errorResponse('No storage configured'),
},
})
const presignPartsRoute = authRoute(objectWriteAuth, {
operationId: 'presignObjectUploadParts',
summary: 'Re-presign upload parts',
tags: ['Objects'],
method: 'post',
path: '/{id}/uploads/{uploadSessionId}/parts',
middleware: [requireObjectWriteAccess] as const,
request: { params: sessionParams, ...jsonBody(presignObjectUploadPartsSchema) },
responses: {
200: jsonContent(presignObjectUploadPartsResponseSchema, 'Presigned multipart upload parts'),
400: errorResponse('Invalid upload session'),
403: errorResponse('Forbidden'),
404: errorResponse('Not found'),
502: errorResponse('Storage failure'),
},
})
const completionsRoute = authRoute(objectWriteAuth, {
operationId: 'completeObjectUpload',
summary: 'Complete upload',
tags: ['Objects'],
method: 'post',
path: '/{id}/uploads/{uploadSessionId}/completions',
middleware: [requireObjectWriteAccess] as const,
request: { params: sessionParams, ...jsonBody(completeObjectUploadSchema) },
responses: {
200: jsonContent(matterSchema, 'Finalized live object'),
400: errorResponse('Invalid upload session'),
403: errorResponse('Forbidden'),
404: errorResponse('Not found'),
422: errorResponse('Quota exceeded'),
502: errorResponse('Storage failure'),
},
})
const abortUploadRoute = authRoute(objectWriteAuth, {
operationId: 'abortObjectUpload',
summary: 'Abort upload',
tags: ['Objects'],
method: 'delete',
path: '/{id}/uploads/{uploadSessionId}',
middleware: [requireObjectWriteAccess] as const,
request: { params: sessionParams, query: abortUploadQuerySchema },
responses: {
204: { description: 'Aborted upload and discarded the draft' },
400: errorResponse('Invalid upload session'),
403: errorResponse('Forbidden'),
404: errorResponse('Not found'),
502: errorResponse('Storage cleanup failed'),
},
})
const getObjectRoute = authRoute(
{ access: 'session', minTeamRole: 'viewer' },
{
operationId: 'getObject',
summary: 'Get object',
tags: ['Objects'],
method: 'get',
path: '/{id}',
middleware: [requireTeamRole('viewer')] as const,
request: { params: idParam },
responses: {
200: jsonContent(objectWithDownloadSchema, 'Object'),
400: errorResponse('No active organization'),
402: errorResponse('Insufficient credits'),
404: errorResponse('Not found'),
422: errorResponse('Traffic quota exceeded'),
},
},
)
const patchObjectRoute = authRoute(objectWriteAuth, {
operationId: 'updateObject',
summary: 'Update object',
tags: ['Objects'],
method: 'patch',
path: '/{id}',
middleware: [requireObjectWriteAccess] as const,
request: { params: idParam, ...jsonBody(patchMatterSchema) },
responses: {
200: jsonContent(matterSchema, 'Updated object'),
400: errorResponse('Bad request'),
404: errorResponse('Not found'),
},
})
const deleteObjectRoute = authRoute(
{ access: 'session', minTeamRole: 'editor' },
{
operationId: 'deleteObject',
summary: 'Delete object',
tags: ['Objects'],
method: 'delete',
path: '/{id}',
middleware: [requireTeamRole('editor')] as const,
request: { params: idParam },
responses: {
// Soft delete: the object moves to trash (GET /trash/objects). Permanent
// removal is DELETE /trash/objects/{id}.
204: { description: 'Object moved to trash' },
400: errorResponse('No active organization'),
404: errorResponse('Not found'),
},
},
)
const copyObjectRoute = authRoute(
{ access: 'session', minTeamRole: 'editor' },
{
operationId: 'copyObject',
summary: 'Copy object',
tags: ['Objects'],
method: 'post',
path: '/{id}/copies',
middleware: [requireTeamRole('editor')] as const,
request: { params: idParam, ...jsonBody(copyObjectBodySchema) },
responses: {
201: jsonContent(matterSchema, 'Copied object'),
400: errorResponse('No active organization'),
404: errorResponse('Not found'),
},
},
)
const transferObjectRoute = authRoute(
{ access: 'session', minTeamRole: 'viewer' },
{
operationId: 'transferObject',
summary: 'Transfer object to another space',
tags: ['Objects'],
method: 'post',
path: '/{id}/transfers',
middleware: [requireTeamRole('viewer')] as const,
request: { params: idParam, ...jsonBody(transferMatterSchema) },
responses: {
201: jsonContent(
z
.object({
saved: z.array(matterSchema),
skipped: z.array(z.object({ name: z.string(), reason: z.string() })),
sourceDeleted: z.boolean(),
})
.openapi('TransferResult'),
'Transferred object',
),
400: errorResponse('Invalid transfer target'),
403: errorResponse('Forbidden'),
404: errorResponse('Not found'),
422: errorResponse('Quota exceeded'),
},
},
)
const app = new OpenAPIHono<Env>()
// Blanket auth gate for every object route. Applied as a statement, not chained,
// because `.use()` returns the base Hono type and would strip `.openapi()`.
app.use(async (c, next) => {
const principal = c.get('principal')
if (principal?.kind === 'user' || principal?.kind === 'download-task-upload') {
await next()
return
}
throw unauthorized()
})
const objects = app
.openapi(listRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const query = c.req.valid('query')
const fingerprint = await pageQueryFingerprint({
orgId: query.orgId ?? orgId,
parent: query.path ?? query.parent ?? '',
type: query.type ?? null,
search: query.search ?? null,
pageSize: query.pageSize,
})
const after = await decodeOptionalPageToken(c.get('platform'), query.pageToken, {
query: fingerprint,
codec: directoryCursorCodec,
})
const result = await listObjects(c.get('deps'), {
orgId,
userId: c.get('userId')!,
orgOverride: query.orgId,
filters: {
parent: query.path ?? query.parent ?? '',
typeFilter: query.type,
search: query.search,
pageSize: query.pageSize,
after,
},
})
if (!result.ok) throw result.error
return c.json(
{
items: result.result.items.map(toObjectListItemDTO),
nextPageToken: await encodeNextPageToken(c.get('platform'), result.result.nextBoundary, {
query: fingerprint,
codec: directoryCursorCodec,
}),
},
200,
)
})
.openapi(createObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const input = c.req.valid('json')
if (input.storageId && c.get('userRole') !== 'admin') throw forbidden('Forbidden')
const result = await createObject(c.get('deps'), { orgId, actor: objectActor(c), input })
if (!result.ok) throw result.error
if ('upload' in result) return c.json({ ...toMatterDTO(result.matter), upload: result.upload }, 201)
return c.json(toMatterDTO(result.matter), 201)
})
.openapi(presignPartsRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw new ObjectUploadSessionError('not_found')
const result = await presignUploadSessionParts(c.get('deps'), {
orgId,
objectId: c.req.valid('param').id,
sessionId: c.req.valid('param').uploadSessionId,
partNumbers: c.req.valid('json').partNumbers,
})
return c.json(result, 200)
})
// Finalize the upload (draft → live). The client has PUT every slice and read
// its ETag; the server HEADs (single PutObject) or CompleteMultipartUpload,
// then activates the draft. NameConflictError / StorageQuotaExceededError thrown
// by the activation propagate to onError (409 / 422).
.openapi(completionsRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw new ObjectUploadSessionError('not_found')
const objectId = c.req.valid('param').id
const principal = c.get('principal')
if (principal?.kind === 'download-task-upload') {
const authorized = await authorizeTaskUploadConfirm(c.get('deps'), {
orgId,
objectId,
taskId: principal.taskId,
downloaderId: principal.downloaderId,
targetFolder: principal.targetFolder,
})
if (!authorized.ok) throw authorized.error
}
const result = await completeUpload(c.get('deps'), {
orgId,
objectId,
sessionId: c.req.valid('param').uploadSessionId,
parts: c.req.valid('json').parts,
actorId: actorId(c),
})
if (!result.ok) {
if ('error' in result) throw result.error // quota exceeded
throw new ObjectUploadSessionError('not_found') // draft gone
}
return c.json(toMatterDTO(result.matter), 200)
})
.openapi(abortUploadRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw new ObjectUploadSessionError('not_found')
await abortUpload(c.get('deps'), {
orgId,
objectId: c.req.valid('param').id,
sessionId: c.req.valid('param').uploadSessionId,
actorId: actorId(c),
strictStorageCleanup: c.req.valid('query').strictStorageCleanup !== undefined,
})
return c.body(null, 204)
})
.openapi(getObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const result = await getObject(c.get('deps'), {
orgId,
objectId: c.req.valid('param').id,
cloudBaseUrl: cloudBaseUrl(c),
})
if (result.ok) {
if ('downloadUrl' in result) {
await recordDownloadIssued(
c.get('deps'),
transferAuditActor(c.get('principal')),
'object_download',
{
orgId,
targetType: 'file',
targetId: result.matter.id,
targetName: result.matter.name,
bytes: result.receipt.bytes,
source: 'object_download',
metadata: { matterId: result.matter.id, storageId: result.receipt.storageId },
},
result.receipt.trafficEventId,
)
return c.json({ ...toMatterDTO(result.matter), downloadUrl: result.downloadUrl }, 200)
}
return c.json(toMatterDTO(result.matter), 200)
}
throw result.error
})
.openapi(patchObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const result = await updateObject(c.get('deps'), {
orgId,
objectId: c.req.valid('param').id,
input: c.req.valid('json'),
})
if (!result.ok) throw result.error
return c.json(toMatterDTO(result.matter), 200)
})
// Soft delete: move a live object to trash. Permanent removal is
// DELETE /trash/objects/{id}; discarding a draft is DELETE /{id}/uploads/{sid}.
.openapi(deleteObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const result = await trashObject(c.get('deps'), {
orgId,
objectId: c.req.valid('param').id,
})
if (!result.ok) throw result.error
return c.body(null, 204)
})
.openapi(copyObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const body = c.req.valid('json')
const result = await copyObject(c.get('deps'), {
orgId,
userId: c.get('userId')!,
input: { copyFrom: c.req.valid('param').id, parent: body.parent, onConflict: body.onConflict },
})
if (!result.ok) throw result.error
return c.json(toMatterDTO(result.matter), 201)
})
.openapi(transferObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const result = await transferObject(c.get('deps'), {
orgId,
userId: c.get('userId')!,
objectId: c.req.valid('param').id,
input: c.req.valid('json'),
})
if (!result.ok) throw result.error
return c.json(
{
saved: result.result.saved.map(toMatterDTO),
skipped: result.result.skipped,
sourceDeleted: result.result.sourceDeleted,
},
201,
)
})
export default objects