26.9(安全审计修复版)

Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
2026-09-05 04:22:41 +08:00
commit 7f060dd0e4
173 changed files with 32455 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
/** 管理后台 API(对齐 t3 契约:login/verify/logout/file list/update/delete/batch-delete/config/audit */
import { request } from './http'
import { paths } from './paths'
import type {
AdminConfig,
AdminFileListResult,
AdminFileUpdatePayload,
AdminLoginResult,
AuditListResult,
} from './types'
import { pick } from '@/utils/format'
/** 管理员登录(密码错误 → 401 ApiError */
export function adminLogin(password: string): Promise<AdminLoginResult> {
return request<AdminLoginResult>(paths.adminLogin, { method: 'POST', json: { password } })
}
export function adminVerify(): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(paths.adminVerify)
}
export async function adminLogout(): Promise<void> {
await request<void>(paths.adminLogout, { method: 'POST' })
}
export interface AdminFileListQuery {
page: number
size: number
keyword?: string
}
/** 文件列表(宽松解析:兼容 data 字段或 list 字段、total 顶层) */
export async function adminFileList(q: AdminFileListQuery): Promise<AdminFileListResult> {
const raw = await request<Record<string, unknown>>(paths.adminFileList, {
query: { page: q.page, size: q.size, keyword: q.keyword || undefined },
})
const rows = (pick<unknown[]>(raw, ['data', 'list', 'items', 'files']) ?? []) as Record<string, unknown>[]
const total = Number(pick<number>(raw, ['total', 'count']) ?? rows.length)
return {
page: Number(pick<number>(raw, ['page']) ?? q.page),
size: Number(pick<number>(raw, ['size']) ?? q.size),
total,
data: rows.map((r) => ({
id: Number(pick<number>(r, ['id']) ?? 0),
code: String(pick<string>(r, ['code']) ?? ''),
name: String(pick<string>(r, ['name']) ?? `${pick<string>(r, ['prefix']) ?? ''}${pick<string>(r, ['suffix']) ?? ''}`),
suffix: String(pick<string>(r, ['suffix']) ?? ''),
size: Number(pick<number>(r, ['size']) ?? 0),
isText: Boolean(pick<boolean>(r, ['isText', 'is_text']) ?? false),
expiredAt: (pick<string>(r, ['expiredAt', 'expired_at', 'expires_at']) as string) ?? null,
expiredCount: (pick<number>(r, ['expiredCount', 'expired_count']) as number) ?? null,
usedCount: Number(pick<number>(r, ['usedCount', 'used_count']) ?? 0),
createdAt: (pick<string>(r, ['createdAt', 'created_at']) as string) ?? null,
isExpired: Boolean(pick<boolean>(r, ['isExpired', 'is_expired']) ?? false),
})),
}
}
/** 删除单个文件 */
export async function adminFileDelete(id: number): Promise<void> {
await request<void>(paths.adminFileDelete, { method: 'DELETE', json: { id } })
}
/** 批量删除(契约:POST /admin/file/batch-deletebody {ids:number[]} */
export async function adminFileBatchDelete(ids: number[]): Promise<void> {
await request<void>(paths.adminFileBatchDelete, { method: 'POST', json: { ids } })
}
/** 更新分享(code/过期时间/次数;go-api 定稿 PATCH /admin/file/update */
export async function adminFileUpdate(payload: AdminFileUpdatePayload): Promise<void> {
await request<void>(paths.adminFileUpdate, { method: 'PATCH', json: payload })
}
/** 读取管理端配置(KV 全量,含 site_name/logo_url/favicon_url */
export async function adminConfigGet(): Promise<AdminConfig> {
const raw = await request<unknown>(paths.adminConfigGet)
// 兼容:后端可能直接返回 KV 对象,或包一层 config/data
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const obj = raw as Record<string, unknown>
const inner = (pick<Record<string, unknown>>(obj, ['config', 'data', 'settings']) ?? obj) as Record<string, unknown>
return inner
}
return {}
}
/** 更新管理端配置(PATCH 部分字段 JSON;提交后全站生效,含 site_name/logo_url/favicon_url */
export async function adminConfigUpdate(patch: Record<string, unknown>): Promise<void> {
await request<void>(paths.adminConfigUpdate, { method: 'PATCH', json: patch })
}
/** v3 存储引擎热切换:成功返回当前引擎名;失败(503)服务端保持原引擎并抛 ApiError */
export async function adminStorageSwitch(engine: 'local' | 's3' | 'webdav'): Promise<string> {
const data = await request<{ ok: boolean; engine: string }>(paths.adminStorageSwitch, {
method: 'POST',
json: { engine },
})
return String(data?.engine ?? engine)
}
/** 修改管理员密码(成功后旧 token 全部失效,需重新登录) */
export async function adminPasswordUpdate(oldPassword: string, newPassword: string): Promise<void> {
await request<void>(paths.adminPasswordUpdate, {
method: 'PATCH',
json: { old_password: oldPassword, new_password: newPassword },
})
}
export interface AuditListQuery {
page: number
size: number
action?: string
result?: string
ip?: string
/** ISO 或 YYYY-MM-DD;由调用方格式化 */
startTime?: string
endTime?: string
}
/** 审计日志列表(宽松解析兼容 data/list 字段与 snake_case/camelCase 行字段) */
export async function adminAuditList(q: AuditListQuery): Promise<AuditListResult> {
const raw = await request<Record<string, unknown>>(paths.adminAuditList, {
query: {
page: q.page,
size: q.size,
action: q.action || undefined,
result: q.result || undefined,
ip: q.ip || undefined,
start_time: q.startTime || undefined,
end_time: q.endTime || undefined,
},
})
const rows = (pick<unknown[]>(raw, ['data', 'list', 'items', 'logs']) ?? []) as Record<string, unknown>[]
const total = Number(pick<number>(raw, ['total', 'count']) ?? rows.length)
return {
page: Number(pick<number>(raw, ['page']) ?? q.page),
size: Number(pick<number>(raw, ['size']) ?? q.size),
total,
data: rows.map((r, idx) => ({
id: Number(pick<number>(r, ['id']) ?? idx + 1),
action: String(pick<string>(r, ['action']) ?? ''),
result: String(pick<string>(r, ['result']) ?? ''),
fileCode: String(pick<string>(r, ['file_code', 'fileCode', 'code']) ?? ''),
fileName: String(pick<string>(r, ['file_name', 'fileName', 'name']) ?? ''),
sizeBytes: (pick<number>(r, ['size_bytes', 'sizeBytes', 'size']) as number) ?? null,
transferredBytes: (pick<number>(r, ['transferred_bytes', 'transferredBytes', 'bytes']) as number) ?? null,
ip: String(pick<string>(r, ['ip', 'client_ip', 'clientIp']) ?? ''),
userAgent: String(pick<string>(r, ['user_agent', 'userAgent']) ?? ''),
deviceOs: String(pick<string>(r, ['device_os', 'deviceOs', 'os']) ?? ''),
deviceBrowser: String(pick<string>(r, ['device_browser', 'deviceBrowser', 'browser']) ?? ''),
deviceType: String(pick<string>(r, ['device_type', 'deviceType']) ?? ''),
actor: String(pick<string>(r, ['actor']) ?? ''),
errorMsg: String(pick<string>(r, ['error_msg', 'errorMsg', 'error']) ?? ''),
durationMs: (pick<number>(r, ['duration_ms', 'durationMs', 'duration']) as number) ?? null,
createdAt: (pick<string>(r, ['created_at', 'createdAt', 'time']) as string) ?? null,
})),
}
}