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 全绿;二进制端到端冒烟通过
257 lines
9.9 KiB
TypeScript
257 lines
9.9 KiB
TypeScript
/**
|
||
* 开发期 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)')
|
||
}
|