mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
da286e9db3
Agent-Profile: https://agent-kanban.dev/agents/1dc839c09b5ee5e5 Co-authored-by: Marina Zhou <marina-zhou@mails.agent-kanban.dev>
572 lines
19 KiB
TypeScript
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
|