Files
FileShare/web/src/mock/index.ts
T
SKYMirror 7f060dd0e4 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 全绿;二进制端到端冒烟通过
2026-09-05 04:22:41 +08:00

257 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 开发期 MockVITE_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)')
}