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,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 */
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user