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
+61
View File
@@ -0,0 +1,61 @@
/** 管理员会话 store:JWT 持久化 + 登录/登出/校验 */
import { defineStore } from 'pinia'
import { adminLogin, adminLogout, adminVerify } from '@/api/admin'
import { getToken, setToken } from '@/api/http'
const ADMIN_FLAG_KEY = 'fcb_admin_flag'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: getToken(),
checked: false,
}),
getters: {
isAuthed: (s) => Boolean(s.token),
},
actions: {
async login(password: string): Promise<void> {
const res = await adminLogin(password)
const token = res?.token
if (!token) throw new Error('登录响应缺少 token')
this.token = token
setToken(token)
try {
localStorage.setItem(ADMIN_FLAG_KEY, '1')
} catch {
/* ignore */
}
this.checked = true
},
/** 校验现有 token 是否仍有效(进入管理页时调用) */
async verify(): Promise<boolean> {
if (!this.token) return false
try {
await adminVerify()
this.checked = true
return true
} catch {
this.reset()
return false
}
},
async logout(): Promise<void> {
try {
if (this.token) await adminLogout()
} catch {
/* 后端登出失败不阻塞本地清理 */
}
this.reset()
},
reset(): void {
this.token = ''
this.checked = false
setToken('')
try {
localStorage.removeItem(ADMIN_FLAG_KEY)
} catch {
/* ignore */
}
},
},
})
+161
View File
@@ -0,0 +1,161 @@
/**
* 站点配置 store:公共配置 + Logo/favicon(需求④)+ 背景/页脚/通知(⑤⑥⑦)+ 策略范围(⑧)。
* 远程 URL 默认值已删除:Logo/favicon 使用本地打包资源兜底(config 自定义仍优先)。
*/
import { defineStore } from 'pinia'
import { request } from '@/api/http'
import { paths } from '@/api/paths'
import { pick } from '@/utils/format'
import logoLocal from '@/assets/brand/logo.svg'
import faviconLocal from '@/assets/brand/favicon.png'
/** 需求④:默认 Logo(导航栏)与 favicon(浏览器标签)——本地打包资源,无远程依赖 */
export const DEFAULT_LOGO_URL = logoLocal
export const DEFAULT_FAVICON_URL = faviconLocal
export const DEFAULT_SITE_NAME = '文件快传'
export interface ConfigState {
loaded: boolean
loading: boolean
siteName: string
siteDomain: string
description: string
explain: string
uploadSize: number
allowedFileTypes: string[]
expireStyle: string[]
enableChunk: boolean
openUpload: boolean
notifyEnabled: boolean
notifyTitle: string
notifyContent: string
logoUrl: string
faviconUrl: string
backgroundUrl: string
footerText: string
footerBeian: string
maxFileSize: number
maxSaveSeconds: number
maxSaveCount: number
uploadCount: number
uploadMinute: number
}
function toBool(v: unknown, fallback = true): boolean {
if (v === undefined || v === null) return fallback
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v !== 0
return String(v) !== '0' && String(v) !== 'false' && String(v) !== ''
}
function toList(v: unknown): string[] {
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean)
if (typeof v === 'string') {
return v
.split(',')
.map((x) => x.trim())
.filter(Boolean)
}
return []
}
function toNum(v: unknown, fallback: number): number {
const n = Number(v)
return Number.isFinite(n) ? n : fallback
}
export const useConfigStore = defineStore('config', {
state: (): ConfigState => ({
loaded: false,
loading: false,
siteName: DEFAULT_SITE_NAME,
siteDomain: '',
description: '',
explain: '',
uploadSize: 10 * 1024 * 1024,
allowedFileTypes: [],
expireStyle: ['day', 'hour', 'minute', 'forever', 'count'],
enableChunk: false,
openUpload: true,
notifyEnabled: false,
notifyTitle: '',
notifyContent: '',
logoUrl: '',
faviconUrl: '',
backgroundUrl: '',
footerText: '',
footerBeian: '',
maxFileSize: 0,
maxSaveSeconds: 0,
maxSaveCount: 0,
uploadCount: 0,
uploadMinute: 0,
}),
getters: {
/** 需求④:自定义优先,本地打包资源兜底 */
displayLogoUrl: (s) => (s.logoUrl?.trim() ? s.logoUrl : DEFAULT_LOGO_URL),
displayFaviconUrl: (s) => (s.faviconUrl?.trim() ? s.faviconUrl : DEFAULT_FAVICON_URL),
displayName: (s) => (s.siteName?.trim() ? s.siteName : DEFAULT_SITE_NAME),
/** v3.1:分享链接基础地址——配置了对外域名用之,否则当前访问地址 */
shareLinkBase: (s): string => (s.siteDomain?.trim() ? s.siteDomain.trim().replace(/\/$/, '') : location.origin),
/** ⑧ 存储策略:max_file_size>0 时优先,否则回落 uploadSize */
effectiveMaxFileSize(): number {
return this.maxFileSize > 0 ? this.maxFileSize : this.uploadSize
},
},
actions: {
/** 拉取公共配置;失败静默使用默认值(未部署后端时前端仍可浏览) */
async load(): Promise<void> {
this.loading = true
try {
const raw = await request<Record<string, unknown>>(paths.publicConfig, { timeout: 8000 })
const cfg = (pick<Record<string, unknown>>(raw, ['config']) ?? raw) as Record<string, unknown>
this.siteName = String(pick<string>(cfg, ['name', 'site_name', 'siteName']) ?? DEFAULT_SITE_NAME)
// v3.1:站点对外域名(空=当前访问地址)
this.siteDomain = String(pick<string>(cfg, ['site_domain', 'siteDomain']) ?? '').trim()
this.description = String(pick<string>(cfg, ['description']) ?? '')
this.explain = String(pick<string>(cfg, ['explain', 'page_explain']) ?? '')
this.uploadSize = toNum(pick<unknown>(cfg, ['uploadSize', 'upload_size']), 10 * 1024 * 1024)
this.allowedFileTypes = toList(pick<unknown>(cfg, ['allowedFileTypes', 'allowed_file_types']))
const styles = toList(pick<unknown>(cfg, ['expireStyle', 'expire_style']))
if (styles.length) this.expireStyle = styles
this.enableChunk = toBool(pick<unknown>(cfg, ['enableChunk', 'enable_chunk']), false)
this.openUpload = toBool(pick<unknown>(cfg, ['openUpload', 'open_upload']), true)
this.notifyTitle = String(pick<string>(cfg, ['notify_title', 'notifyTitle']) ?? '')
this.notifyContent = String(pick<string>(cfg, ['notify_content', 'notifyContent']) ?? '')
// 需求⑦:通知开关(1/0
this.notifyEnabled = toBool(pick<unknown>(cfg, ['notify_enabled', 'notifyEnabled']), false)
// 需求⑤:背景图
this.backgroundUrl = String(pick<string>(cfg, ['background_url', 'backgroundUrl']) ?? '').trim()
// 需求⑥:页脚
this.footerText = String(pick<string>(cfg, ['footer_text', 'footerText']) ?? '')
this.footerBeian = String(pick<string>(cfg, ['footer_beian', 'footerBeian']) ?? '')
// 需求⑧:保存策略与上传频率
this.maxFileSize = toNum(pick<unknown>(cfg, ['max_file_size', 'maxFileSize', 'maxFileSize']), 0)
this.maxSaveSeconds = toNum(pick<unknown>(cfg, ['max_save_seconds', 'maxSaveSeconds']), 0)
this.maxSaveCount = toNum(pick<unknown>(cfg, ['max_save_count', 'maxSaveCount']), 0)
this.uploadCount = toNum(pick<unknown>(cfg, ['uploadCount', 'upload_count']), 0)
this.uploadMinute = toNum(pick<unknown>(cfg, ['uploadMinute', 'upload_minute']), 0)
// 需求④:运行时优先读 config 的 logo_url/favicon_url,空值回退本地默认
this.logoUrl = String(pick<string>(cfg, ['logo_url', 'logoUrl']) ?? '').trim()
this.faviconUrl = String(pick<string>(cfg, ['favicon_url', 'faviconUrl']) ?? '').trim()
this.loaded = true
this.applyToDocument()
} catch {
// 保持默认值
} finally {
this.loading = false
}
},
/** 将站点名与 favicon 应用到 document(管理端保存配置后调用即可全站生效) */
applyToDocument(): void {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = this.displayFaviconUrl
},
},
})
+38
View File
@@ -0,0 +1,38 @@
/** 轻量 Toast 通知 */
import { defineStore } from 'pinia'
export type ToastType = 'success' | 'error' | 'info'
export interface ToastItem {
id: number
type: ToastType
text: string
}
let seq = 0
export const useToastStore = defineStore('toast', {
state: () => ({
items: [] as ToastItem[],
}),
actions: {
push(text: string, type: ToastType = 'info', duration = 3200): void {
const id = ++seq
this.items.push({ id, type, text })
if (this.items.length > 4) this.items.shift()
setTimeout(() => this.dismiss(id), duration)
},
success(text: string): void {
this.push(text, 'success')
},
error(text: string): void {
this.push(text, 'error', 4200)
},
info(text: string): void {
this.push(text, 'info')
},
dismiss(id: number): void {
this.items = this.items.filter((t) => t.id !== id)
},
},
})