mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
* refactor(api)!: DELETE endpoints return 204 No Content (#443) Resolves item #4 of #443 — DELETE return-shape inconsistency (8 different conventions). Standardize every DELETE on 204 No Content with an empty body, dropping the ack/result bodies: `{id,deleted}`, `{providerId,deleted}`, `{key,deleted}`, `{id,revoked}`, `{ok:true}`, the download-task tombstone, license `{deleted,cloud_unbind_error}`, and the entitlement-revoke / abort-upload-session objects. Kept (the issue's flagged special case): object-delete and empty-trash still carry a purge count — the only delete responses with information a caller can't otherwise derive. Object delete is trimmed from `{id,deleted,purged}` to just `{ purged: number | false }`; empty-trash keeps `{ purged: number }`. Backend: 15 DELETE routes → `204: { description }` + `c.body(null, 204)`; removed the now-dead `deleteDownloaderResponseSchema`. Frontend: added a `discard()` helper (the 204 counterpart to `unwrap()`); the unwrap-based delete wrappers now resolve `void`. cancelUpload/deleteObject now return `{ purged }`. The already-void wrappers (deleteShare, deleteAvatar, …) were untouched — they never read the body. OpenAPI document + Go client regenerated (go build clean). BREAKING CHANGE: all DELETE endpoints now respond 204 with no body. License unbind no longer returns `cloud_unbind_error`, so a partial cloud-unbind failure is no longer surfaced in the response body. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(licensing): surface cloud-unbind failure as 502, don't swallow it as 204 The DELETE→204 sweep turned license unbind into an unconditional 204, which hid a real partial failure: when the best-effort cloud unbind throws, the local binding is cleared but the cloud side is left dangling. Reporting 204 (success) in that case swallows the error. `unbindLicense` now returns a Result — `{ ok: true }` only when the cloud unbind also succeeds, and a 502 AppError (reason `CLOUD_UNBIND_FAILED`, the cloud error in `details.metadata`) when it fails. The local binding is still cleared either way; the handler returns 204 on ok and throws the error otherwise. DELETE success is still an empty 204 — this only restores fail-fast on the one endpoint whose failure was a soft body field, never a thrown error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): reconcile users.feature with #446 better-auth migration `pnpm lint:spec` (a CI gate) was red on 11 orphaned `spec/users.feature` scenarios — leftover from #446, which moved admin user management off our `/api/users/*` routes onto better-auth's admin plugin and deleted the old endpoints/tests but not their spec scenarios. Pre-existing on main; surfaced here as the only failing CI check. Reconcile the spec with reality: - Re-link the behaviors that survived (now via better-auth) to the tests that already cover them: list / admin-only(403) / disable(ban) / delete(remove) / patch-missing(act-on-missing→404) → admin-users-ba.integration.test.ts; quota-personal-org → the per-user quota test in users.integration.test.ts. - Drop scenarios for behavior that no longer exists: batch-toggle (now a client-side fan-out, no endpoint), invalid-status (ban/unban are explicit), multi-field filter (better-auth search is single-field, untested), the unauthenticated 401 guard (better-auth owns it), and the inline quota-entitlements-in-list (quota is now a per-user sub-resource). lint:spec: 413 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
5.3 KiB
TypeScript
173 lines
5.3 KiB
TypeScript
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
|
import { pageSchema } from '@shared/schemas'
|
|
import { requireAdmin } from '../../middleware/auth'
|
|
import type { Env } from '../../middleware/platform'
|
|
import { runtimeInfo } from '../../usecases/site/instance-info'
|
|
import {
|
|
deleteSystemOption,
|
|
getChangelog,
|
|
getSystemOption,
|
|
listSystemOptions,
|
|
resolveInstanceInfo,
|
|
setSystemOption,
|
|
} from '../../usecases/site/system'
|
|
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
|
|
|
const instanceInfoSchema = z
|
|
.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
url: z.string(),
|
|
version: z.string(),
|
|
commit: z.string().nullable().optional(),
|
|
runtime: z.string().nullable().optional(),
|
|
platform: z.string().nullable().optional(),
|
|
server: z
|
|
.object({
|
|
os: z
|
|
.object({
|
|
platform: z.string().nullable().optional(),
|
|
arch: z.string().nullable().optional(),
|
|
release: z.string().nullable().optional(),
|
|
})
|
|
.nullable()
|
|
.optional(),
|
|
})
|
|
.nullable()
|
|
.optional(),
|
|
node: z.object({ version: z.string().nullable().optional() }).nullable().optional(),
|
|
})
|
|
.openapi('InstanceInfo')
|
|
|
|
const changelogSchema = z
|
|
.object({
|
|
currentVersion: z.string(),
|
|
latestVersion: z.string().nullable(),
|
|
updateAvailable: z.boolean(),
|
|
markdown: z.string(),
|
|
})
|
|
.openapi('Changelog')
|
|
|
|
const systemOptionSchema = z.object({ key: z.string(), value: z.string(), public: z.boolean() }).openapi('SystemOption')
|
|
|
|
const systemOptionListSchema = pageSchema(systemOptionSchema, 'SystemOptionList')
|
|
|
|
const setOptionSchema = z.object({ value: z.string(), public: z.boolean().optional() })
|
|
|
|
const instanceRoute = createRoute({
|
|
operationId: 'getInstanceInfo',
|
|
summary: 'Get instance info',
|
|
tags: ['System'],
|
|
method: 'get',
|
|
path: '/instance',
|
|
middleware: [requireAdmin] as const,
|
|
responses: { 200: jsonContent(instanceInfoSchema, 'Instance info') },
|
|
})
|
|
|
|
const changelogRoute = createRoute({
|
|
operationId: 'getChangelog',
|
|
summary: 'Get changelog',
|
|
tags: ['System'],
|
|
method: 'get',
|
|
path: '/changelog',
|
|
middleware: [requireAdmin] as const,
|
|
request: { query: z.object({ refresh: z.string().optional() }) },
|
|
responses: { 200: jsonContent(changelogSchema, 'Changelog') },
|
|
})
|
|
|
|
const listOptionsRoute = createRoute({
|
|
operationId: 'listSystemOptions',
|
|
summary: 'List system options',
|
|
tags: ['System'],
|
|
method: 'get',
|
|
path: '/options',
|
|
responses: { 200: jsonContent(systemOptionListSchema, 'System options') },
|
|
})
|
|
|
|
const getOptionRoute = createRoute({
|
|
operationId: 'getSystemOption',
|
|
summary: 'Get a system option',
|
|
tags: ['System'],
|
|
method: 'get',
|
|
path: '/options/{key}',
|
|
request: { params: z.object({ key: z.string() }) },
|
|
responses: {
|
|
200: jsonContent(systemOptionSchema, 'System option'),
|
|
403: errorResponse('Forbidden'),
|
|
404: errorResponse('Option not found'),
|
|
},
|
|
})
|
|
|
|
const setOptionRoute = createRoute({
|
|
operationId: 'setSystemOption',
|
|
summary: 'Set a system option',
|
|
tags: ['System'],
|
|
method: 'put',
|
|
path: '/options/{key}',
|
|
middleware: [requireAdmin] as const,
|
|
request: { params: z.object({ key: z.string() }), ...jsonBody(setOptionSchema) },
|
|
responses: {
|
|
200: jsonContent(systemOptionSchema, 'Updated option'),
|
|
201: jsonContent(systemOptionSchema, 'Created option'),
|
|
400: errorResponse('Invalid option'),
|
|
402: errorResponse('Feature not available'),
|
|
},
|
|
})
|
|
|
|
const deleteOptionRoute = createRoute({
|
|
operationId: 'deleteSystemOption',
|
|
summary: 'Delete a system option',
|
|
tags: ['System'],
|
|
method: 'delete',
|
|
path: '/options/{key}',
|
|
middleware: [requireAdmin] as const,
|
|
request: { params: z.object({ key: z.string() }) },
|
|
responses: { 204: { description: 'Deleted option' } },
|
|
})
|
|
|
|
const system = new OpenAPIHono<Env>()
|
|
.openapi(instanceRoute, async (c) => {
|
|
const info = await resolveInstanceInfo(c.get('deps'), {
|
|
requestUrl: c.req.url,
|
|
runtime: runtimeInfo(c.get('platform')),
|
|
})
|
|
return c.json(info, 200)
|
|
})
|
|
.openapi(changelogRoute, async (c) =>
|
|
c.json(await getChangelog(c.get('deps'), { now: Date.now(), force: c.req.valid('query').refresh === 'true' }), 200),
|
|
)
|
|
.openapi(listOptionsRoute, async (c) => {
|
|
const { items } = await listSystemOptions(c.get('deps'), { isAdmin: c.get('userRole') === 'admin' })
|
|
return c.json({ items, total: items.length, page: 1, pageSize: items.length }, 200)
|
|
})
|
|
.openapi(getOptionRoute, async (c) => {
|
|
const result = await getSystemOption(c.get('deps'), {
|
|
key: c.req.valid('param').key,
|
|
isAdmin: c.get('userRole') === 'admin',
|
|
})
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.option, 200)
|
|
})
|
|
.openapi(setOptionRoute, async (c) => {
|
|
const body = c.req.valid('json')
|
|
const result = await setSystemOption(c.get('deps'), {
|
|
userId: c.get('userId')!,
|
|
orgId: c.get('orgId')!,
|
|
key: c.req.valid('param').key,
|
|
value: body.value,
|
|
public: body.public,
|
|
})
|
|
if (!result.ok) throw result.error
|
|
return result.created ? c.json(result.option, 201) : c.json(result.option, 200)
|
|
})
|
|
.openapi(deleteOptionRoute, async (c) => {
|
|
await deleteSystemOption(c.get('deps'), {
|
|
userId: c.get('userId')!,
|
|
orgId: c.get('orgId')!,
|
|
key: c.req.valid('param').key,
|
|
})
|
|
return c.body(null, 204)
|
|
})
|
|
|
|
export default system
|