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:
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 开发期 Mock(VITE_MOCK=1 时启用):在 service worker 之前仅用于本地调试,
|
||||
* 通过拦截 fetch/XHR 实现无后端推进前端。
|
||||
*/
|
||||
import type { MockRoute } from './types'
|
||||
|
||||
const ok = (data: unknown, msg = '') => ({ code: 200, msg, data })
|
||||
const err = (code: number, msg: string) => ({ code, msg, data: null })
|
||||
|
||||
let seq = 1
|
||||
|
||||
function textToCode(text: string): string {
|
||||
let h = 0
|
||||
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0
|
||||
return (h % 100000).toString().padStart(5, '0')
|
||||
}
|
||||
|
||||
const shares: { code: string; kind: 'text' | 'file'; name: string; size: number; text?: string }[] = []
|
||||
|
||||
const routes: MockRoute[] = [
|
||||
{ method: 'GET', pattern: /\/api\/v1\/health$/, reply: () => ok({ status: 'ok', version: 'mock', storage: 'local', cache: 'memory' }) },
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /\/api\/v1\/config$/,
|
||||
reply: () =>
|
||||
ok({
|
||||
config: {
|
||||
name: '文件快传 (Mock)',
|
||||
site_domain: '',
|
||||
description: '开发 Mock 模式 · 开箱即用的文件快传系统',
|
||||
explain: '',
|
||||
uploadSize: 10 * 1024 * 1024,
|
||||
allowedFileTypes: [],
|
||||
expireStyle: ['day', 'hour', 'minute', 'forever', 'count'],
|
||||
enableChunk: true,
|
||||
openUpload: true,
|
||||
notify_enabled: 1,
|
||||
notify_title: '系统通知',
|
||||
notify_content: '欢迎使用文件快传(Mock)。',
|
||||
logo_url: '',
|
||||
favicon_url: '',
|
||||
background_url: '',
|
||||
footer_text: '',
|
||||
footer_beian: '',
|
||||
max_file_size: 0,
|
||||
max_save_seconds: 0,
|
||||
max_save_count: 0,
|
||||
uploadCount: 10,
|
||||
uploadMinute: 1,
|
||||
},
|
||||
meta: { version: 'mock', features: { chunkUpload: true, guestUpload: true } },
|
||||
}),
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /\/share\/text$/,
|
||||
reply: (_m, body) => {
|
||||
const text = String((body as Record<string, string>)?.text ?? '')
|
||||
const code = textToCode(text + seq++)
|
||||
shares.push({ code, kind: 'text', name: 'Text.txt', size: new Blob([text]).size, text })
|
||||
return ok({ code })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /\/share\/file$/,
|
||||
reply: (_m, body) => {
|
||||
const fd = body as FormData
|
||||
const file = fd?.get?.('file')
|
||||
if (!(file instanceof File)) return err(400, '缺少文件')
|
||||
const code = textToCode(file.name + seq++)
|
||||
shares.push({ code, kind: 'file', name: file.name, size: file.size })
|
||||
return ok({ code, name: file.name })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /\/share\/metadata$/,
|
||||
reply: (_m, _body, q) => {
|
||||
const s = shares.find((x) => x.code === q?.get('code'))
|
||||
if (!s) return err(404, '文件不存在')
|
||||
return ok({
|
||||
code: s.code,
|
||||
name: s.name,
|
||||
size: s.size,
|
||||
type: s.kind,
|
||||
is_text: s.kind === 'text',
|
||||
created_at: new Date().toISOString(),
|
||||
expired_at: null,
|
||||
expired_count: -1,
|
||||
used_count: 0,
|
||||
remaining_downloads: null,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /\/share\/select$/,
|
||||
reply: (_m, _body, q) => {
|
||||
const s = shares.find((x) => x.code === q?.get('code'))
|
||||
if (!s) return { __rawError: 404 }
|
||||
if (s.kind === 'text') return { __rawText: s.text ?? '' }
|
||||
return { __rawBlob: new Blob([`mock-content-of-${s.name}`], { type: 'application/octet-stream' }), __rawName: s.name }
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /\/chunk\/upload\/init$/,
|
||||
reply: (_m, body) => {
|
||||
const b = body as Record<string, unknown>
|
||||
const total = Math.ceil(Number(b?.file_size ?? 0) / Number(b?.chunk_size ?? 1024))
|
||||
return ok({ existed: false, upload_id: `mock-${seq++}`, chunk_size: b?.chunk_size, total_chunks: total, uploaded_chunks: [] })
|
||||
},
|
||||
},
|
||||
{ method: 'POST', pattern: /\/chunk\/upload\/[^/]+\/\d+$/, reply: () => ok({ chunk_hash: 'mockhash' }) },
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /\/chunk\/upload\/status\/[^/]+$/,
|
||||
reply: (m) => {
|
||||
const id = String(m?.[0]?.split('/').pop() ?? 'mock')
|
||||
return ok({ upload_id: id, chunk_size: 1024, total_chunks: 1, uploaded_chunks: [0] })
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
pattern: /\/chunk\/upload\/complete\/[^/]+$/,
|
||||
reply: () => {
|
||||
const code = textToCode('chunk' + seq++)
|
||||
shares.push({ code, kind: 'file', name: 'chunked.bin', size: 1024 })
|
||||
return ok({ code, name: 'chunked.bin' })
|
||||
},
|
||||
},
|
||||
{ method: 'DELETE', pattern: /\/chunk\/upload\/[^/]+$/, reply: () => ok({ message: '已取消' }) },
|
||||
{ method: 'POST', pattern: /\/admin\/login$/, reply: (_m, body) => ((body as { password?: string })?.password === 'admin123' ? ok({ id: 'admin', username: 'admin', token: 'mock-token', token_type: 'Bearer', expires_in: 86400 }) : err(401, '密码错误')) },
|
||||
{ method: 'GET', pattern: /\/admin\/verify$/, reply: (_m, _b, _q) => (getToken() === 'mock-token' ? ok({ is_admin: true }) : err(401, '未登录')) },
|
||||
{ method: 'POST', pattern: /\/admin\/logout$/, reply: () => ok({ ok: true }) },
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /\/admin\/file\/list$/,
|
||||
reply: () =>
|
||||
ok({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: shares.length,
|
||||
data: shares.map((s, i) => ({
|
||||
id: i + 1,
|
||||
code: s.code,
|
||||
name: s.name,
|
||||
suffix: s.name.includes('.') ? s.name.split('.').pop() : '',
|
||||
size: s.size,
|
||||
isText: s.kind === 'text',
|
||||
expiredAt: null,
|
||||
expiredCount: -1,
|
||||
usedCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
isExpired: false,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
{ method: 'DELETE', pattern: /\/admin\/file\/delete$/, reply: () => ok(null) },
|
||||
{ method: 'POST', pattern: /\/admin\/file\/batch-delete$/, reply: () => ok(null) },
|
||||
{ method: 'PATCH', pattern: /\/admin\/file\/update$/, reply: () => ok('更新成功') },
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /\/admin\/config\/get$/,
|
||||
reply: () =>
|
||||
ok({
|
||||
site_name: '文件快传 (Mock)',
|
||||
logo_url: '',
|
||||
favicon_url: '',
|
||||
background_url: '',
|
||||
footer_text: '',
|
||||
footer_beian: '',
|
||||
notify_enabled: 1,
|
||||
notify_title: '系统通知',
|
||||
notify_content: '欢迎使用文件快传(Mock)。',
|
||||
max_save_seconds: 0,
|
||||
max_save_count: 0,
|
||||
max_file_size: 0,
|
||||
allowed_file_types: ['*'],
|
||||
uploadCount: 10,
|
||||
uploadMinute: 1,
|
||||
openUpload: 1,
|
||||
}),
|
||||
},
|
||||
{ method: 'PATCH', pattern: /\/admin\/config\/update$/, reply: () => ok(null) },
|
||||
{
|
||||
method: 'GET',
|
||||
pattern: /\/admin\/audit\/list$/,
|
||||
reply: () =>
|
||||
ok({
|
||||
page: 1,
|
||||
size: 20,
|
||||
total: 2,
|
||||
data: [
|
||||
{ id: 1, action: 'upload', result: 'success', file_code: shares[0]?.code ?? '10001', file_name: 'demo.txt', size_bytes: 1024, transferred_bytes: 1024, ip: '127.0.0.1', user_agent: 'Mozilla/5.0 (Mock)', device_os: 'macOS', device_browser: 'Chrome', device_type: 'desktop', actor: 'guest', error_msg: '', duration_ms: 42, created_at: new Date().toISOString() },
|
||||
{ id: 2, action: 'download', result: 'success', file_code: shares[0]?.code ?? '10001', file_name: 'demo.txt', size_bytes: 1024, transferred_bytes: 1024, ip: '192.168.1.2', user_agent: 'Mozilla/5.0 (iPhone)', device_os: 'iOS', device_browser: 'Safari', device_type: 'mobile', actor: 'guest', error_msg: '', duration_ms: 88, created_at: new Date().toISOString() },
|
||||
],
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
function getToken(): string {
|
||||
try {
|
||||
return localStorage.getItem('fcb_admin_token') ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 安装 fetch 拦截(dev-only,不覆盖 XHR —— mock 下上传走 XHR 也会失败,可接受) */
|
||||
export function installMock(): void {
|
||||
if (import.meta.env.VITE_MOCK !== '1') return
|
||||
const origFetch = globalThis.fetch.bind(globalThis)
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
const method = (init?.method ?? 'GET').toUpperCase()
|
||||
const qIndex = url.indexOf('?')
|
||||
const path = qIndex >= 0 ? url.slice(0, qIndex) : url
|
||||
const query = qIndex >= 0 ? new URL(url, location.origin).searchParams : undefined
|
||||
|
||||
for (const r of routes) {
|
||||
if (r.method !== method) continue
|
||||
const m = r.pattern.exec(path)
|
||||
if (!m) continue
|
||||
let body: unknown = undefined
|
||||
if (init?.body) {
|
||||
if (typeof init.body === 'string') {
|
||||
try {
|
||||
body = JSON.parse(init.body)
|
||||
} catch {
|
||||
body = Object.fromEntries(new URLSearchParams(init.body))
|
||||
}
|
||||
} else if (init.body instanceof FormData) {
|
||||
body = init.body
|
||||
}
|
||||
}
|
||||
const res = r.reply(m, body, query)
|
||||
await new Promise((res) => setTimeout(res, 120)) // 模拟网络延迟
|
||||
if (res && typeof res === 'object' && '__rawText' in (res as Record<string, unknown>)) {
|
||||
return new Response((res as { __rawText: string }).__rawText, { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
||||
}
|
||||
if (res && typeof res === 'object' && '__rawBlob' in (res as Record<string, unknown>)) {
|
||||
const rb = res as { __rawBlob: Blob; __rawName: string }
|
||||
return new Response(rb.__rawBlob, { status: 200, headers: { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(rb.__rawName)}` } })
|
||||
}
|
||||
if (res && typeof res === 'object' && '__rawError' in (res as Record<string, unknown>)) {
|
||||
const e = err((res as { __rawError: number }).__rawError, '文件不存在')
|
||||
return new Response(JSON.stringify(e), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
return new Response(JSON.stringify(res), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
return origFetch(input, init)
|
||||
}
|
||||
console.info('[mock] 文件快传 API mock enabled (VITE_MOCK=1)')
|
||||
}
|
||||
Reference in New Issue
Block a user