- 数据库默认文件 filecodebox.db → fileshare.db(config.go 默认值与全部文档/编排同步)
- Go module filecodebox → fileshare(全部 import 同步,build/vet/test 全绿)
- 应用版本 APP_VERSION 2.5.6 → 26.9(health 接口已验证返回 26.9)
- deploy 编排统一:compose 项目名、Postgres 默认凭据、minio 桶名、env 注释
- JWT issuer、存储临时目录前缀、web 包名同步 fileshare
- CI:镜像 tag 以 APP_VERSION 为唯一版本源,main/tag 推送即发布
${VER} + latest;tag 触发时校验 tag 名与 APP_VERSION 一致,防错版
- 本地开发库文件已改名 fileshare.db(含 -shm/-wal 清理)
328 lines
12 KiB
Go
328 lines
12 KiB
Go
package api
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"fileshare/internal/response"
|
||
"fileshare/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)
|
||
}
|