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 全绿;二进制端到端冒烟通过
328 lines
12 KiB
Go
328 lines
12 KiB
Go
package api
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"filecodebox/internal/response"
|
||
"filecodebox/internal/settings"
|
||
)
|
||
|
||
// fileSizeUnits 文件大小单位(对齐参考 FILE_SIZE_UNITS)。
|
||
var fileSizeUnits = map[string]int64{"KB": 1024, "MB": 1024 * 1024, "GB": 1024 * 1024 * 1024}
|
||
|
||
// saveTimeUnits 保存时间单位(秒)。
|
||
var saveTimeUnits = map[string]int64{"second": 1, "minute": 60, "hour": 3600, "day": 86400}
|
||
|
||
// expireStyleOptions 可用过期方式(用于 setup 表单校验)。
|
||
var expireStyleOptions = []string{"day", "hour", "minute", "forever", "count"}
|
||
|
||
// setupFormValue 取表单/JSON 字符串值。
|
||
func setupFormValue(data map[string]any, key, def string) string {
|
||
v, ok := data[key]
|
||
if !ok || v == nil {
|
||
return def
|
||
}
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
return strings.TrimSpace(strconv.FormatFloat(toAnyFloat(v), 'f', -1, 64))
|
||
}
|
||
|
||
func toAnyFloat(v any) float64 {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return n
|
||
case int:
|
||
return float64(n)
|
||
case int64:
|
||
return float64(n)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// registerSetup 注册初始化向导(未初始化时唯一可用入口,白名单 /setup)。
|
||
func registerSetup(r *gin.Engine, d *Deps) {
|
||
r.GET("/setup", func(c *gin.Context) {
|
||
if d.Mgr.IsInitialized() {
|
||
c.Redirect(http.StatusSeeOther, "/")
|
||
return
|
||
}
|
||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(buildSetupPage("")))
|
||
})
|
||
r.POST("/setup", func(c *gin.Context) {
|
||
if d.Mgr.IsInitialized() {
|
||
c.Redirect(http.StatusSeeOther, "/")
|
||
return
|
||
}
|
||
d.setupSubmit(c)
|
||
})
|
||
}
|
||
|
||
// setupSubmit 处理初始化提交(对齐参考 setup_submit + parse_setup_options)。
|
||
func (d *Deps) setupSubmit(c *gin.Context) {
|
||
// 兼容 JSON 与表单
|
||
data := map[string]any{}
|
||
if strings.Contains(c.GetHeader("Content-Type"), "application/json") {
|
||
if err := c.ShouldBindJSON(&data); err != nil {
|
||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage("请求体格式错误")))
|
||
return
|
||
}
|
||
} else if err := c.Request.ParseForm(); err != nil {
|
||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage("请求体格式错误")))
|
||
return
|
||
} else {
|
||
// 多值字段(如多个 expireStyle 复选框)保留完整列表,单值取首项
|
||
for k, v := range c.Request.PostForm {
|
||
switch {
|
||
case len(v) == 1:
|
||
data[k] = v[0]
|
||
case len(v) > 1:
|
||
data[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
adminPassword := setupFormValue(data, "admin_password", "")
|
||
confirmPassword := setupFormValue(data, "confirm_password", "")
|
||
siteName := setupFormValue(data, "site_name", "")
|
||
|
||
if adminPassword == "" || len(adminPassword) < 8 {
|
||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage("管理员密码至少 8 位")))
|
||
return
|
||
}
|
||
if adminPassword != confirmPassword {
|
||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage("两次输入的管理员密码不一致")))
|
||
return
|
||
}
|
||
patch, errMsg := parseSetupOptions(data)
|
||
if errMsg != "" {
|
||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(buildSetupPage(errMsg)))
|
||
return
|
||
}
|
||
patch["site_name"] = firstNonEmpty(siteName, "文件快传")
|
||
patch["admin_token"] = settings.HashPassword(adminPassword)
|
||
patch["jwt_secret"] = settings.GenerateJWTSecret()
|
||
|
||
ctx := c.Request.Context()
|
||
if err := d.Mgr.UpdateKV(ctx, patch); err != nil {
|
||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte(buildSetupPage("初始化失败: "+err.Error())))
|
||
return
|
||
}
|
||
if err := d.Mgr.Reload(ctx); err != nil {
|
||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte(buildSetupPage("配置重载失败: "+err.Error())))
|
||
return
|
||
}
|
||
d.syncRateRules()
|
||
// JSON 请求返回 JSON;表单返回成功页
|
||
if strings.Contains(c.GetHeader("Accept"), "application/json") ||
|
||
strings.Contains(c.GetHeader("Content-Type"), "application/json") {
|
||
response.OK(c, gin.H{"ok": true, "admin": "/#/admin"})
|
||
return
|
||
}
|
||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(buildSetupSuccessPage()))
|
||
}
|
||
|
||
// parseSetupOptions 解析并校验初始化选项(对齐参考 parse_setup_options)。
|
||
func parseSetupOptions(data map[string]any) (map[string]any, string) {
|
||
out := map[string]any{}
|
||
|
||
// 文件大小限制
|
||
unit := strings.ToUpper(setupFormValue(data, "upload_size_unit", "MB"))
|
||
if _, ok := fileSizeUnits[unit]; !ok {
|
||
return nil, "文件大小单位不正确"
|
||
}
|
||
sizeVal, err := strconv.Atoi(setupFormValue(data, "upload_size_value", "10"))
|
||
if err != nil || sizeVal < 1 {
|
||
return nil, "文件大小限制必须是正整数"
|
||
}
|
||
out["uploadSize"] = int64(sizeVal) * fileSizeUnits[unit]
|
||
|
||
// 最长保存时间
|
||
saveUnit := strings.ToLower(setupFormValue(data, "save_time_unit", "day"))
|
||
if _, ok := saveTimeUnits[saveUnit]; !ok {
|
||
return nil, "最长保存时间单位不正确"
|
||
}
|
||
saveVal, err := strconv.Atoi(setupFormValue(data, "save_time_value", "0"))
|
||
if err != nil || saveVal < 0 {
|
||
return nil, "最长保存时间必须是非负整数"
|
||
}
|
||
out["max_save_seconds"] = int64(saveVal) * saveTimeUnits[saveUnit]
|
||
|
||
// 过期方式白名单
|
||
var styles []string
|
||
if raw, ok := data["expireStyle"]; ok {
|
||
switch v := raw.(type) {
|
||
case []any:
|
||
for _, item := range v {
|
||
if s, ok := item.(string); ok {
|
||
styles = append(styles, s)
|
||
}
|
||
}
|
||
case []string:
|
||
styles = v
|
||
case string:
|
||
for _, s := range strings.Split(v, ",") {
|
||
styles = append(styles, strings.TrimSpace(s))
|
||
}
|
||
}
|
||
}
|
||
valid := map[string]bool{}
|
||
var finalStyles []string
|
||
for _, s := range styles {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || valid[s] {
|
||
continue
|
||
}
|
||
for _, opt := range expireStyleOptions {
|
||
if opt == s {
|
||
valid[s] = true
|
||
finalStyles = append(finalStyles, s)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if len(finalStyles) == 0 {
|
||
return nil, "至少需要选择一种过期方式"
|
||
}
|
||
out["expireStyle"] = finalStyles
|
||
|
||
// 取件码类型
|
||
codeType := setupFormValue(data, "code_generate_type", "secret")
|
||
if codeType != "number" && codeType != "secret" {
|
||
return nil, "提取码类型不正确"
|
||
}
|
||
out["code_generate_type"] = codeType
|
||
|
||
// 频率限制
|
||
for _, item := range []struct{ key, def string }{
|
||
{"errorCount", "10"}, {"errorMinute", "1"},
|
||
{"loginCount", "5"}, {"loginMinute", "15"},
|
||
{"uploadCount", "10"}, {"uploadMinute", "1"},
|
||
} {
|
||
n, err := strconv.Atoi(setupFormValue(data, item.key, item.def))
|
||
if err != nil || n < 1 {
|
||
return nil, item.key + " 必须是正整数"
|
||
}
|
||
out[item.key] = n
|
||
}
|
||
|
||
// 布尔开关
|
||
out["openUpload"] = boolToInt(parseSetupBool(data, "openUpload", true))
|
||
out["enableChunk"] = boolToInt(parseSetupBool(data, "enableChunk", false))
|
||
|
||
// 允许文件类型
|
||
allowed := setupFormValue(data, "allowed_file_types", "*")
|
||
var types []string
|
||
for _, item := range strings.Split(allowed, ",") {
|
||
if item = strings.TrimSpace(item); item != "" {
|
||
types = append(types, item)
|
||
}
|
||
}
|
||
if len(types) == 0 {
|
||
types = []string{"*"}
|
||
}
|
||
out["allowed_file_types"] = types
|
||
return out, ""
|
||
}
|
||
|
||
// parseSetupBool 解析表单布尔(缺省 default;"1"/"true"/"on"/"yes" 为真)。
|
||
func parseSetupBool(data map[string]any, key string, def bool) bool {
|
||
v, ok := data[key]
|
||
if !ok {
|
||
return def
|
||
}
|
||
switch s := v.(type) {
|
||
case string:
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "1", "true", "on", "yes":
|
||
return true
|
||
case "0", "false", "off", "no", "":
|
||
return false
|
||
}
|
||
case float64:
|
||
return s != 0
|
||
case bool:
|
||
return s
|
||
}
|
||
return def
|
||
}
|
||
|
||
// buildSetupPage 初始化向导页面(简洁中文表单)。
|
||
func buildSetupPage(errMsg string) string {
|
||
errBlock := ""
|
||
if errMsg != "" {
|
||
errBlock = `<div style="margin-bottom:12px;padding:10px 12px;border-radius:10px;background:#fef2f2;color:#b91c1c;font-size:13px">` + htmlEscape(errMsg) + `</div>`
|
||
}
|
||
return `<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>初始化 文件快传</title>
|
||
<style>
|
||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:16px;font-family:-apple-system,"Segoe UI",sans-serif;background:#f5f5f7;color:#18181b}
|
||
main{width:min(100%,640px);padding:24px;border-radius:16px;background:#fff;box-shadow:0 18px 50px rgba(23,32,51,.08)}
|
||
h1{margin:0 0 6px;font-size:20px} p{margin:0 0 16px;color:#71717a;font-size:13px}
|
||
label{display:block;margin:10px 0 4px;font-size:12px;color:#3f3f46;font-weight:600}
|
||
input{width:100%;height:36px;border:1px solid #e4e4e7;border-radius:8px;padding:0 10px;box-sizing:border-box;font:inherit}
|
||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}
|
||
button{width:100%;height:40px;margin-top:16px;border:0;border-radius:10px;background:#18181b;color:#fff;font:inherit;font-weight:700;cursor:pointer}
|
||
</style>
|
||
</head>
|
||
<body><main>
|
||
<h1>初始化 文件快传</h1>
|
||
<p>首次配置管理员密码、上传限制和取件策略,后续可在后台调整。</p>
|
||
` + errBlock + `
|
||
<form method="post" action="/setup" autocomplete="off">
|
||
<label>站点名称</label>
|
||
<input name="site_name" maxlength="80" placeholder="文件快传">
|
||
<div class="grid">
|
||
<div><label>管理员密码</label><input name="admin_password" type="password" minlength="8" required></div>
|
||
<div><label>确认管理员密码</label><input name="confirm_password" type="password" minlength="8" required></div>
|
||
<div><label>单文件大小限制</label><input name="upload_size_value" type="number" min="1" value="10" required></div>
|
||
<div><label>大小单位</label><input name="upload_size_unit" value="MB" required></div>
|
||
<div><label>上传频率(次/分钟)</label><input name="uploadCount" type="number" min="1" value="10" required></div>
|
||
<div><label>上传检测窗口(分钟)</label><input name="uploadMinute" type="number" min="1" value="1" required></div>
|
||
<div><label>取件错误频率(次/分钟)</label><input name="errorCount" type="number" min="1" value="10" required></div>
|
||
<div><label>取件错误窗口(分钟)</label><input name="errorMinute" type="number" min="1" value="1" required></div>
|
||
<div><label>登录失败频率(次/分钟)</label><input name="loginCount" type="number" min="1" value="5" required></div>
|
||
<div><label>登录失败窗口(分钟)</label><input name="loginMinute" type="number" min="1" value="15" required></div>
|
||
<div><label>最长保存时间</label><input name="save_time_value" type="number" min="0" value="0" required></div>
|
||
<div><label>保存时间单位</label><input name="save_time_unit" value="day" required></div>
|
||
</div>
|
||
<label>允许文件类型(逗号分隔,* 不限制)</label>
|
||
<input name="allowed_file_types" value="*">
|
||
<label>提取码类型(number=数字 / secret=随机字符)</label>
|
||
<input name="code_generate_type" value="secret">
|
||
<label><input type="checkbox" name="openUpload" value="1" checked style="width:auto"> 允许游客上传</label>
|
||
<label><input type="checkbox" name="enableChunk" value="1" style="width:auto"> 启用切片上传</label>
|
||
<input type="hidden" name="expireStyle" value="day">
|
||
<input type="hidden" name="expireStyle" value="hour">
|
||
<input type="hidden" name="expireStyle" value="minute">
|
||
<input type="hidden" name="expireStyle" value="forever">
|
||
<input type="hidden" name="expireStyle" value="count">
|
||
<button type="submit">完成初始化</button>
|
||
</form>
|
||
</main></body></html>`
|
||
}
|
||
|
||
// buildSetupSuccessPage 初始化完成页。
|
||
func buildSetupSuccessPage() string {
|
||
return `<!doctype html>
|
||
<html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="refresh" content="2;url=/#/admin"><title>初始化完成</title></head>
|
||
<body style="display:grid;place-items:center;min-height:100vh;font-family:-apple-system,sans-serif;background:#f6f8fb;color:#172033">
|
||
<main style="text-align:center;padding:32px;background:#fff;border-radius:12px;box-shadow:0 18px 50px rgba(23,32,51,.08)">
|
||
<h1>初始化完成</h1><p>管理员密码已设置,请使用刚才的密码登录后台。</p><a href="/#/admin">进入后台</a>
|
||
</main></body></html>`
|
||
}
|
||
|
||
// htmlEscape HTML 转义(错误信息拼接用)。
|
||
func htmlEscape(s string) string {
|
||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, "'")
|
||
return r.Replace(s)
|
||
}
|