Files
FileShare/server/internal/api/setup.go
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

328 lines
12 KiB
Go
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.
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("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&#39;")
return r.Replace(s)
}