mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
feat: attribute files and downloads to actors (#559)
* feat: attribute files and downloads to actors * chore: refresh preview after staging migration * fix: use a dash for missing actor identity * fix: complete actor attribution and file creator UI * fix: refine file creator identity UI
This commit is contained in:
@@ -143,6 +143,7 @@ describe('background jobs API', () => {
|
||||
const created = (await res.json()) as { id: string; status: string }
|
||||
expect(created.status).toBe('queued')
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].createdBy).toMatchObject({ type: 'user', issuer: null })
|
||||
await expect(createBackgroundJobRepo(db).get(orgId, created.id)).resolves.toMatchObject({ status: 'queued' })
|
||||
|
||||
await createArchiveJobsGateway(platform).runMessage(messages[0])
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
listBackgroundJobsQuerySchema,
|
||||
opaqueIdSchema,
|
||||
} from '../../shared/schemas'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { authzActorIdentity, type Env } from '../middleware/platform'
|
||||
import {
|
||||
cancelBackgroundJob,
|
||||
createBackgroundJob,
|
||||
@@ -203,7 +203,12 @@ const backgroundJobs = app
|
||||
const orgId = requireOrg(c)
|
||||
const userId = c.get('userId')
|
||||
if (!userId) throw new BackgroundJobError('not_found')
|
||||
return c.json(await createBackgroundJob(c.get('deps'), { orgId, userId, request: c.req.valid('json') }), 201)
|
||||
const createdBy = authzActorIdentity(c.get('authzContext'))
|
||||
if (!createdBy) throw new Error('authenticated_actor_missing')
|
||||
return c.json(
|
||||
await createBackgroundJob(c.get('deps'), { orgId, userId, createdBy, request: c.req.valid('json') }),
|
||||
201,
|
||||
)
|
||||
})
|
||||
.openapi(getJobRoute, async (c) =>
|
||||
c.json(await getBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id), 200),
|
||||
@@ -211,8 +216,10 @@ const backgroundJobs = app
|
||||
.openapi(cancelJobRoute, async (c) =>
|
||||
c.json(await cancelBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id), 200),
|
||||
)
|
||||
.openapi(retryJobRoute, async (c) =>
|
||||
c.json(await retryBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id), 201),
|
||||
)
|
||||
.openapi(retryJobRoute, async (c) => {
|
||||
const createdBy = authzActorIdentity(c.get('authzContext'))
|
||||
if (!createdBy) throw new Error('authenticated_actor_missing')
|
||||
return c.json(await retryBackgroundJob(c.get('deps'), requireOrg(c), c.req.valid('param').id, createdBy), 201)
|
||||
})
|
||||
|
||||
export default backgroundJobs
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('Download tasks API integration', () => {
|
||||
expect(new Set(ids)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('uses the API key owner UID in the object storage key', async () => {
|
||||
it('uses the API key owner UID in the object storage key [spec: download-tasks/actor-attribution]', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
const admin = await adminHeaders(app)
|
||||
@@ -359,9 +359,18 @@ describe('Download tasks API integration', () => {
|
||||
expect(createTaskRes.status).toBe(201)
|
||||
const task = (await createTaskRes.json()) as DownloadTask
|
||||
expect(task.createdBy).toBe(identity.userId)
|
||||
expect(task.requestedBy).toMatchObject({ type: 'api_key', name: 'API key · storage-key-owner', resolved: true })
|
||||
|
||||
const downloader = await registerDownloaderThroughDeviceLogin(app, 'api-key-storage-downloader', admin)
|
||||
const assigned = await claimTaskForDownloader(app, downloader.token, task.id)
|
||||
const detailRes = await app.request(`/api/downloads/tasks/${task.id}`, { headers: userHeaders })
|
||||
expect(detailRes.status).toBe(200)
|
||||
const detail = (await detailRes.json()) as DownloadTask
|
||||
expect(detail.status.assignment?.executor).toMatchObject({
|
||||
type: 'device',
|
||||
name: 'Device · api-key-storage-downloader',
|
||||
resolved: true,
|
||||
})
|
||||
const uploadToken = assigned.status.assignment?.uploadToken
|
||||
expect(uploadToken).toBeTruthy()
|
||||
|
||||
|
||||
@@ -283,7 +283,16 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
if (!orgId) throw unauthorized()
|
||||
// API keys carry their owning user's UID in userId, so downloader uploads
|
||||
// build the same org/uid object path as browser-created tasks.
|
||||
return c.json(await createDownloadTask(c.get('deps'), orgId, c.get('userId') as string, c.req.valid('json')), 201)
|
||||
const actor = c.get('authzContext').actor
|
||||
if (!actor) throw unauthorized()
|
||||
return c.json(
|
||||
await createDownloadTask(c.get('deps'), orgId, c.get('userId') as string, c.req.valid('json'), {
|
||||
type: actor.type,
|
||||
ref: actor.ref,
|
||||
issuer: 'issuer' in actor ? actor.issuer : null,
|
||||
}),
|
||||
201,
|
||||
)
|
||||
})
|
||||
.openapi(getRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
|
||||
@@ -2689,6 +2689,7 @@ async function createUserApiKey(
|
||||
const result = (await (auth.api as any).createApiKey({
|
||||
body: {
|
||||
configId: opts.orgId ? 'ihost' : 'webdav',
|
||||
name: 'test-api-key',
|
||||
userId,
|
||||
...(opts.orgId ? { organizationId: opts.orgId } : {}),
|
||||
permissions: opts.permissions,
|
||||
@@ -2803,10 +2804,11 @@ describe('Objects API — error branches', () => {
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
name: '藏.mp3',
|
||||
parent: targetFolder,
|
||||
createdBy: { type: 'device', resolved: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a folder with a workspace API key that has objects:create', async () => {
|
||||
it('creates a folder with a workspace API key that has objects:create [spec: objects/creator-attribution]', async () => {
|
||||
const { app, db, auth } = await createTestApp()
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -2820,7 +2822,24 @@ describe('Objects API — error branches', () => {
|
||||
body: JSON.stringify({ name: 'api-key-folder', type: 'folder', dirtype: 1, parent: '' }),
|
||||
})
|
||||
expect(res.status).toBe(201)
|
||||
await expect(res.json()).resolves.toMatchObject({ name: 'api-key-folder', orgId })
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
name: 'api-key-folder',
|
||||
orgId,
|
||||
createdBy: { type: 'api_key', name: 'API key · test-api-key', resolved: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null instead of attributing a legacy object to the workspace owner [spec: objects/legacy-creator]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertFile(db, orgId, { id: 'legacy-creator-file', name: 'legacy.txt' })
|
||||
|
||||
const res = await app.request('/api/objects/legacy-creator-file', { headers })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toMatchObject({ id: 'legacy-creator-file', createdBy: null })
|
||||
})
|
||||
|
||||
it('returns 403 when an object API key is missing the route scope', async () => {
|
||||
|
||||
+34
-14
@@ -1,6 +1,7 @@
|
||||
import { OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { AuthorizationScope } from '@shared/authorization'
|
||||
import {
|
||||
actorAttributionSchema,
|
||||
completeObjectUploadSchema,
|
||||
copyObjectBodySchema,
|
||||
createMatterSchema,
|
||||
@@ -29,11 +30,12 @@ import {
|
||||
type ObjectActor,
|
||||
ObjectUploadSessionError,
|
||||
presignUploadSessionParts,
|
||||
resolveMatterCreators,
|
||||
transferObject,
|
||||
trashObject,
|
||||
updateObject,
|
||||
} from '../usecases/object'
|
||||
import { badRequest, forbidden, type Matter, type MatterListItem, quotaExceeded } from '../usecases/ports'
|
||||
import { badRequest, forbidden, type Matter, type MatterListItem, quotaExceeded, unauthorized } from '../usecases/ports'
|
||||
import { describeCapacityRequirement } from '../usecases/store/store'
|
||||
import { recordDownloadIssued } from '../usecases/transfer-activity'
|
||||
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
@@ -60,6 +62,7 @@ const matterSchema = z
|
||||
storageId: opaqueIdSchema,
|
||||
status: z.string(),
|
||||
trashedAt: z.number().int().nullable(),
|
||||
createdBy: actorAttributionSchema.nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
@@ -71,7 +74,7 @@ type MatterDTO = z.infer<typeof matterSchema>
|
||||
// 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 {
|
||||
function toMatterDTO(m: Matter, createdBy: MatterDTO['createdBy']): MatterDTO {
|
||||
return {
|
||||
id: m.id,
|
||||
orgId: m.orgId,
|
||||
@@ -85,6 +88,7 @@ function toMatterDTO(m: Matter): MatterDTO {
|
||||
storageId: m.storageId,
|
||||
status: m.status,
|
||||
trashedAt: m.trashedAt,
|
||||
createdBy,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
}
|
||||
@@ -101,8 +105,13 @@ const objectListItemSchema = matterSchema
|
||||
.openapi('ObjectListItem')
|
||||
type ObjectListItemDTO = z.infer<typeof objectListItemSchema>
|
||||
|
||||
function toObjectListItemDTO(item: MatterListItem): ObjectListItemDTO {
|
||||
return { ...toMatterDTO(item), hasChildren: item.hasChildren }
|
||||
function toObjectListItemDTO(item: MatterListItem, createdBy: MatterDTO['createdBy']): ObjectListItemDTO {
|
||||
return { ...toMatterDTO(item, createdBy), hasChildren: item.hasChildren }
|
||||
}
|
||||
|
||||
async function matterDTO(deps: Env['Variables']['deps'], matter: Matter): Promise<MatterDTO> {
|
||||
const creators = await resolveMatterCreators(deps, [matter])
|
||||
return toMatterDTO(matter, creators.get(matter.id) ?? null)
|
||||
}
|
||||
|
||||
const objectPageSchema = cursorPageSchema(objectListItemSchema, 'ObjectPage')
|
||||
@@ -177,9 +186,16 @@ function objectActor(c: Context<Env>): ObjectActor {
|
||||
taskId: principal.taskId,
|
||||
targetFolder: principal.targetFolder,
|
||||
createdByUserId: principal.createdByUserId,
|
||||
identity: { type: 'device', ref: principal.downloaderId, issuer: null },
|
||||
}
|
||||
}
|
||||
return { kind: 'user', userId: c.get('userId') as string }
|
||||
const actor = c.get('authzContext').actor
|
||||
if (!actor) throw unauthorized()
|
||||
return {
|
||||
kind: 'user',
|
||||
userId: c.get('userId') as string,
|
||||
identity: { type: actor.type, ref: actor.ref, issuer: 'issuer' in actor ? actor.issuer : null },
|
||||
}
|
||||
}
|
||||
|
||||
// The id recorded in matter/activity logs.
|
||||
@@ -456,9 +472,10 @@ const objects = app
|
||||
},
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
const creators = await resolveMatterCreators(c.get('deps'), result.result.items)
|
||||
return c.json(
|
||||
{
|
||||
items: result.result.items.map(toObjectListItemDTO),
|
||||
items: result.result.items.map((item) => toObjectListItemDTO(item, creators.get(item.id) ?? null)),
|
||||
nextPageToken: await encodeNextPageToken(c.get('platform'), result.result.nextBoundary, {
|
||||
query: fingerprint,
|
||||
codec: directoryCursorCodec,
|
||||
@@ -503,8 +520,9 @@ const objects = app
|
||||
402,
|
||||
)
|
||||
}
|
||||
if ('upload' in result) return c.json({ ...toMatterDTO(result.matter), upload: result.upload }, 201)
|
||||
return c.json(toMatterDTO(result.matter), 201)
|
||||
const matter = await matterDTO(c.get('deps'), result.matter)
|
||||
if ('upload' in result) return c.json({ ...matter, upload: result.upload }, 201)
|
||||
return c.json(matter, 201)
|
||||
})
|
||||
.openapi(presignPartsRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
@@ -542,7 +560,7 @@ const objects = app
|
||||
if ('error' in result) throw result.error // quota exceeded
|
||||
throw new ObjectUploadSessionError('not_found') // draft gone
|
||||
}
|
||||
return c.json(toMatterDTO(result.matter), 200)
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
|
||||
})
|
||||
.openapi(abortUploadRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
@@ -585,9 +603,9 @@ const objects = app
|
||||
},
|
||||
result.receipt.trafficEventId,
|
||||
)
|
||||
return c.json({ ...toMatterDTO(result.matter), downloadUrl: result.downloadUrl }, 200)
|
||||
return c.json({ ...(await matterDTO(c.get('deps'), result.matter)), downloadUrl: result.downloadUrl }, 200)
|
||||
}
|
||||
return c.json(toMatterDTO(result.matter), 200)
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
|
||||
}
|
||||
throw result.error
|
||||
})
|
||||
@@ -600,7 +618,7 @@ const objects = app
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(toMatterDTO(result.matter), 200)
|
||||
return c.json(await matterDTO(c.get('deps'), 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}.
|
||||
@@ -622,10 +640,11 @@ const objects = app
|
||||
const result = await copyObject(c.get('deps'), {
|
||||
orgId,
|
||||
userId: c.get('userId')!,
|
||||
actor: objectActor(c),
|
||||
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)
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 201)
|
||||
})
|
||||
.openapi(transferObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
@@ -635,13 +654,14 @@ const objects = app
|
||||
const result = await transferObject(c.get('deps'), {
|
||||
orgId,
|
||||
userId: c.get('userId')!,
|
||||
actor: objectActor(c),
|
||||
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),
|
||||
saved: await Promise.all(result.result.saved.map((matter) => matterDTO(c.get('deps'), matter))),
|
||||
skipped: result.result.skipped,
|
||||
sourceDeleted: result.result.sourceDeleted,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
shareRecipientViewSchema,
|
||||
} from '../../shared/schemas/share'
|
||||
import { transferAuditActor } from '../middleware/audit-transfers'
|
||||
import { boundWorkspaceOrgId, type Env } from '../middleware/platform'
|
||||
import { authzActorIdentity, boundWorkspaceOrgId, type Env } from '../middleware/platform'
|
||||
import type { Matter, ShareListItem } from '../usecases/ports'
|
||||
import { notFound } from '../usecases/ports'
|
||||
import {
|
||||
@@ -577,6 +577,8 @@ export const authedShares = authedApp
|
||||
.openapi(saveShareRoute, async (c) => {
|
||||
const token = c.req.valid('param').token
|
||||
const { targetOrgId, targetParent } = c.req.valid('json')
|
||||
const createdBy = authzActorIdentity(c.get('authzContext'))
|
||||
if (!createdBy) throw new Error('authenticated_actor_missing')
|
||||
const out = await saveShare(c.get('deps'), {
|
||||
token,
|
||||
currentUserId: c.get('userId')!,
|
||||
@@ -584,6 +586,7 @@ export const authedShares = authedApp
|
||||
boundTargetOrgId: boundWorkspaceOrgId(c.get('authzContext')),
|
||||
targetParent,
|
||||
accessCookie: getCookie(c, cookieName(token)),
|
||||
createdBy,
|
||||
})
|
||||
if (out.ok) return c.json({ saved: out.result.saved.map(toSavedMatterDTO), skipped: out.result.skipped }, 201)
|
||||
throw out.error
|
||||
|
||||
@@ -99,6 +99,10 @@ type DavAuth = {
|
||||
permissions: Record<string, string[]> | null
|
||||
}
|
||||
|
||||
function webDavActor(auth: DavAuth) {
|
||||
return { type: 'api_key' as const, ref: auth.keyId, issuer: null }
|
||||
}
|
||||
|
||||
interface NativeRateLimiter {
|
||||
limit(options: { key: string }): Promise<{ success: boolean }>
|
||||
}
|
||||
@@ -1278,6 +1282,7 @@ async function putFile(c: DavContext, auth: DavAuth): Promise<Response> {
|
||||
contentType,
|
||||
contentLength,
|
||||
body,
|
||||
createdBy: webDavActor(auth),
|
||||
onTiming: (phase, durationMs) => c.get('webDavTrace').push(`${phase}:${Math.round(durationMs)}`),
|
||||
})
|
||||
c.get('webDavTrace').push(`upload:${Math.round(performance.now() - startedAt)}`)
|
||||
@@ -1322,6 +1327,7 @@ async function makeCollection(c: DavContext, auth: DavAuth): Promise<Response> {
|
||||
userId: auth.userId,
|
||||
name: target.name,
|
||||
parent: target.parent,
|
||||
createdBy: webDavActor(auth),
|
||||
})
|
||||
return new Response(null, { status: 201 })
|
||||
} catch (e) {
|
||||
@@ -1441,6 +1447,7 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
|
||||
targetMatter: target.matter,
|
||||
replacingTarget,
|
||||
depth,
|
||||
createdBy: webDavActor(auth),
|
||||
})
|
||||
if (!result.ok) return c.text('Storage not found', 404)
|
||||
c.header('Location', matterLocation(c, targetWorkspace.pathSegment, result.location))
|
||||
@@ -1463,6 +1470,7 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise<Response>
|
||||
targetResourcePath: resourcePath(target),
|
||||
replacedMatterId: target.matter?.id ?? null,
|
||||
replacingTarget,
|
||||
createdBy: webDavActor(auth),
|
||||
})
|
||||
if (!result.ok) return c.text('Storage not found', 404)
|
||||
c.header('Location', matterLocation(c, targetWorkspace.pathSegment, result.location))
|
||||
@@ -1520,6 +1528,7 @@ async function lockMatter(c: DavContext, auth: DavAuth): Promise<Response> {
|
||||
owner: lockInfo.owner,
|
||||
depth,
|
||||
timeoutSeconds: parseTimeout(c.req.header('Timeout')),
|
||||
createdBy: webDavActor(auth),
|
||||
})
|
||||
c.get('webDavTrace').push(`lock:${Math.round(performance.now() - startedAt)}`)
|
||||
if (!acquired) return xmlResponse(errorXml('no-conflicting-lock'), 423)
|
||||
|
||||
Reference in New Issue
Block a user