package api import ( "errors" "fmt" "log" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "filecodebox/internal/config" "filecodebox/internal/middleware" "filecodebox/internal/model" "filecodebox/internal/response" "filecodebox/internal/settings" "filecodebox/internal/storage" ) // minutesDuration 分钟数转 Duration(0 回退 1 分钟,对齐 main.go 语义)。 func minutesDuration(n int) time.Duration { if n <= 0 { n = 1 } return time.Duration(n) * time.Minute } // syncRateRules 配置变更后同步限流规则(settings KV 驱动,运行时生效)。 func (d *Deps) syncRateRules() { cfg := d.Cfg d.Limiter.SetRule(middleware.LimitError, middleware.LimitRule{ Count: cfg.GetInt("errorCount"), Window: minutesDuration(cfg.GetInt("errorMinute"))}) d.Limiter.SetRule(middleware.LimitUpload, middleware.LimitRule{ Count: cfg.GetInt("uploadCount"), Window: minutesDuration(cfg.GetInt("uploadMinute"))}) d.Limiter.SetRule(middleware.LimitLogin, middleware.LimitRule{ Count: cfg.GetInt("loginCount"), Window: minutesDuration(cfg.GetInt("loginMinute"))}) d.Limiter.SetRule(middleware.LimitMeta, middleware.LimitRule{ Count: cfg.GetInt("errorCount"), Window: minutesDuration(cfg.GetInt("errorMinute"))}) } // registerAdmin 注册管理端路由:login 公开,其余需管理员 JWT。 func registerAdmin(r *gin.Engine, d *Deps) { admin := r.Group("/admin") admin.POST("/login", d.adminLogin) authed := admin.Group("", middleware.AdminAuth(d.Mgr.SecretProvider())) { authed.GET("/verify", d.adminVerify) authed.POST("/logout", d.adminLogout) authed.GET("/dashboard", d.adminDashboard) // 文件管理 authed.GET("/file/list", d.adminFileList) authed.GET("/file/detail", d.adminFileDetail) authed.POST("/file/detail", d.adminFileDetail) authed.PATCH("/file/update", d.adminFileUpdate) authed.POST("/file/update", d.adminFileUpdate) authed.DELETE("/file/delete", d.adminFileDelete) authed.POST("/file/delete", d.adminFileDelete) authed.DELETE("/file/batch-delete", d.adminFileBatchDelete) authed.POST("/file/batch-delete", d.adminFileBatchDelete) authed.PATCH("/file/batch-update", d.adminFileBatchUpdate) authed.POST("/file/batch-update", d.adminFileBatchUpdate) authed.PATCH("/file/policy-action", d.adminFilePolicyAction) authed.POST("/file/policy-action", d.adminFilePolicyAction) authed.PATCH("/file/batch-policy-action", d.adminFileBatchPolicyAction) authed.POST("/file/batch-policy-action", d.adminFileBatchPolicyAction) authed.GET("/file/download", d.adminFileDownload) authed.GET("/file/preview", d.adminFilePreview) // 配置与安全 authed.GET("/config/get", d.adminConfigGet) authed.PATCH("/config/update", d.adminConfigUpdate) authed.POST("/config/update", d.adminConfigUpdate) authed.PATCH("/settings/password", d.adminChangePassword) authed.POST("/settings/password", d.adminChangePassword) // v3 存储引擎:运行时热切换(健康检查通过才生效,失败保持原引擎) authed.POST("/storage/switch", d.adminStorageSwitch) // 审计日志查询(需求 ③;logs 为 list 的别名) authed.GET("/audit/list", d.adminAuditList) authed.GET("/audit/logs", d.adminAuditList) } } // ============ 认证 ============ // adminLogin 管理员登录(对齐参考 login):失败计入 login 限流。 func (d *Deps) adminLogin(c *gin.Context) { // 进入即检查登录限流(对齐参考 Depends(ip_limit["login"])) if allowed, _ := d.Limiter.Check(c, middleware.LimitLogin); !allowed { response.Fail(c, http.StatusLocked, "请求次数过多,请稍后再试") return } var body struct { Password string `json:"password" form:"password"` } if err := bindJSONOrForm(c, &body); err != nil { respondError(c, err) return } stored := d.Cfg.GetString("admin_token") if stored == "" || !settings.VerifyPassword(body.Password, stored) { d.Limiter.Add(c, middleware.LimitLogin) // 登录失败计数 response.Fail(c, http.StatusUnauthorized, "密码错误") return } // M1 透明迁移:旧格式(sha256/明文)或低 cost 哈希在登录成功后升级为 bcrypt if settings.NeedsRehash(stored) { if err := d.Mgr.UpdateKV(c.Request.Context(), map[string]any{ "admin_token": settings.HashPassword(body.Password), }); err != nil { log.Printf("[auth] 密码哈希升级失败(不影响本次登录): %v", err) } else if err := d.Mgr.Reload(c.Request.Context()); err != nil { log.Printf("[auth] 密码哈希升级后重载失败: %v", err) } else { log.Printf("[auth] 管理员密码哈希已升级为 bcrypt(旧格式兼容校验通过后自动迁移)") } } expiresIn := d.Cfg.AdminSessionExpireSeconds() token, expiresAt, err := middleware.SignAdminToken(d.jwtSecret(), time.Duration(expiresIn)*time.Second) if err != nil { respondError(c, errInternal("签发会话失败: "+err.Error())) return } response.OK(c, gin.H{ "id": "admin", "username": "admin", "token": token, "token_type": "Bearer", "expires_at": expiresAt.Unix(), "expires_in": expiresIn, }) } // adminVerify 会话校验(对齐参考 verify_admin)。 func (d *Deps) adminVerify(c *gin.Context) { header := c.GetHeader("Authorization") token := strings.TrimPrefix(header, "Bearer ") expiresAt := int64(0) if claims, err := middleware.VerifyAdminToken(d.jwtSecret(), token); err == nil && claims.ExpiresAt != nil { expiresAt = claims.ExpiresAt.Unix() } response.OK(c, gin.H{ "id": "admin", "username": "admin", "token": token, "token_type": "Bearer", "expires_at": expiresAt, }) } // adminLogout 登出(无状态 JWT,客户端丢弃 token 即可)。 func (d *Deps) adminLogout(c *gin.Context) { response.OK(c, gin.H{"ok": true}) } // ============ 仪表盘 ============ // adminDashboard 管理端统计(对齐参考 dashboard 字段语义)。 func (d *Deps) adminDashboard(c *gin.Context) { ctx := c.Request.Context() db := d.DB.WithContext(ctx) var all []model.FileCodes if err := db.Find(&all).Error; err != nil { respondError(c, errInternal("查询统计失败: "+err.Error())) return } now := time.Now() todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) yesterdayStart := todayStart.AddDate(0, 0, -1) yesterdayEnd := todayStart.Add(-time.Microsecond) var ( totalSize, usedCount int64 todayCount, todaySize int64 yesterdayCount, yesterdaySize int64 expiredCount, textCount, chunkedCount int64 ) suffixCounter := map[string]int64{} recent := make([]model.FileCodes, 0) for _, fc := range all { totalSize += fc.Size usedCount += int64(fc.UsedCount) if fc.CreatedAt.After(todayStart) { todayCount++ todaySize += fc.Size } if fc.CreatedAt.After(yesterdayStart) && (fc.CreatedAt.Before(yesterdayEnd) || fc.CreatedAt.Equal(yesterdayEnd)) { yesterdayCount++ yesterdaySize += fc.Size } if fc.Expired(now) { expiredCount++ } if fc.Text != nil { textCount++ } else { chunkedCount += boolToInt64(fc.IsChunked) } name := "Text" if fc.Text == nil { name = firstNonEmpty(fc.Suffix, "file") } suffixCounter[name]++ recent = append(recent, fc) } fileCount := int64(len(all)) - textCount // 最近 8 条 sortRecentDesc(recent) if len(recent) > 8 { recent = recent[:8] } recentFiles := make([]gin.H, 0, len(recent)) for _, fc := range recent { recentFiles = append(recentFiles, buildAdminFileItem(&fc, now)) } // 后缀 Top8 type suffixCount struct { Suffix string `json:"suffix"` Count int64 `json:"count"` } top := make([]suffixCount, 0, 8) for s, n := range suffixCounter { top = append(top, suffixCount{s, n}) } for i := 0; i < len(top); i++ { for j := i + 1; j < len(top); j++ { if top[j].Count > top[i].Count { top[i], top[j] = top[j], top[i] } } } if len(top) > 8 { top = top[:8] } // sys_start(启动时间戳毫秒) var sysStartRow model.KeyValue var sysUptime any if err := db.Where("key = ?", "sys_start").First(&sysStartRow).Error; err == nil && sysStartRow.Value != nil { var ms int64 if err := parseJSONNumber(*sysStartRow.Value, &ms); err == nil { sysUptime = ms } } response.OK(c, gin.H{ "totalFiles": len(all), "storageUsed": strconv.FormatInt(totalSize, 10), "sysUptime": sysUptime, "yesterdayCount": yesterdayCount, "yesterdaySize": strconv.FormatInt(yesterdaySize, 10), "todayCount": todayCount, "todaySize": strconv.FormatInt(todaySize, 10), "activeCount": int64(len(all)) - expiredCount, "expiredCount": expiredCount, "textCount": textCount, "fileCount": fileCount, "chunkedCount": chunkedCount, "usedCount": usedCount, "storageBackend": d.Cfg.Engine(), "uploadSizeLimit": d.Cfg.UploadSize(), "openUpload": boolToInt(d.Cfg.OpenUpload()), "enableChunk": boolToInt(d.Cfg.EnableChunk()), "maxSaveSeconds": d.Cfg.MaxSaveSeconds(), "topSuffixes": top, "recentFiles": recentFiles, "recentActivities": []gin.H{}, }) } // ============ 文件管理 ============ // adminFileList 分页查询分享列表(对齐参考 file_list:过滤/排序/统计)。 func (d *Deps) adminFileList(c *gin.Context) { page := maxInt(queryInt(c, "page", 1), 1) size := clampInt(queryInt(c, "size", 10), 1, 100) keyword := strings.ToLower(strings.TrimSpace(c.Query("keyword"))) status := strings.ToLower(strings.TrimSpace(c.Query("status"))) fileType := strings.ToLower(strings.TrimSpace(c.Query("type"))) sortBy := normalizeSortBy(c.Query("sortBy")) sortOrder := strings.ToLower(c.Query("sortOrder")) desc := sortOrder != "asc" ctx := c.Request.Context() db := d.DB.WithContext(ctx) q := db.Model(&model.FileCodes{}) if keyword != "" { // Info5:转义 LIKE 通配符(%/_/\),避免用户输入被当作通配模式 escaped := escapeLike(keyword) like := "%" + escaped + "%" q = q.Where( "LOWER(code) LIKE ? ESCAPE '\\' OR LOWER(prefix) LIKE ? ESCAPE '\\' OR LOWER(suffix) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(file_hash,'')) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(text,'')) LIKE ? ESCAPE '\\'", like, like, like, like, like) } switch fileType { case "text": q = q.Where("text IS NOT NULL") case "file": q = q.Where("text IS NULL") case "chunked": q = q.Where("is_chunked = ?", true) } // status 过滤在内存中判定(过期语义含 NULL 分支) var all []model.FileCodes if err := q.Find(&all).Error; err != nil { respondError(c, errInternal("查询文件列表失败: "+err.Error())) return } now := time.Now() filtered := make([]model.FileCodes, 0, len(all)) summary := gin.H{ "totalFiles": len(all), "activeCount": 0, "expiredCount": 0, "textCount": 0, "fileCount": 0, "chunkedCount": 0, "storageUsed": int64(0), "usedCount": int64(0), } for i := range all { fc := &all[i] isExpired := fc.Expired(now) summary["storageUsed"] = summary["storageUsed"].(int64) + fc.Size summary["usedCount"] = summary["usedCount"].(int64) + int64(fc.UsedCount) if isExpired { summary["expiredCount"] = summary["expiredCount"].(int) + 1 } else { summary["activeCount"] = summary["activeCount"].(int) + 1 } if fc.Text != nil { summary["textCount"] = summary["textCount"].(int) + 1 } else { summary["fileCount"] = summary["fileCount"].(int) + 1 if fc.IsChunked { summary["chunkedCount"] = summary["chunkedCount"].(int) + 1 } } switch status { case "active": if isExpired { continue } case "expired": if !isExpired { continue } } filtered = append(filtered, *fc) } // 排序(白名单字段,防注入) sortFileCodes(filtered, sortBy, desc) total := len(filtered) offset := (page - 1) * size end := offset + size if offset > total { offset = total } if end > total { end = total } items := make([]gin.H, 0, end-offset) for i := offset; i < end; i++ { items = append(items, buildAdminFileItem(&filtered[i], now)) } response.OK(c, gin.H{ "page": page, "size": size, "data": items, "total": total, "summary": summary, }) } // buildAdminFileItem 构造管理端文件条目(snake_case 与 camelCase 双份,前端宽松解析)。 func buildAdminFileItem(fc *model.FileCodes, now time.Time) gin.H { isText := fc.Text != nil isExpired := fc.Expired(now) name := fc.Prefix + fc.Suffix var remaining any if fc.ExpiredCount >= 0 { remaining = maxInt(fc.ExpiredCount, 0) } var expiredAt any if fc.ExpiredAt != nil { expiredAt = fc.ExpiredAt.Format(time.RFC3339) } item := gin.H{ "id": fc.ID, "code": fc.Code, "name": name, "prefix": fc.Prefix, "suffix": fc.Suffix, "size": fc.Size, "isText": isText, "is_text": isText, "isChunked": fc.IsChunked, "is_chunked": fc.IsChunked, "isExpired": isExpired, "is_expired": isExpired, "expiredAt": expiredAt, "expired_at": expiredAt, "expiredCount": fc.ExpiredCount, "expired_count": fc.ExpiredCount, "usedCount": fc.UsedCount, "used_count": fc.UsedCount, "createdAt": fc.CreatedAt.Format(time.RFC3339), "created_at": fc.CreatedAt.Format(time.RFC3339), "hasDownloadLimit": fc.ExpiredCount >= 0, "has_download_limit": fc.ExpiredCount >= 0, "isPermanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0, "is_permanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0, "remainingDownloads": remaining, "remaining_downloads": remaining, "engine": fc.Engine, // v3:归属引擎(管理端展示/排查用) } if fc.FileHash != nil { item["fileHash"] = *fc.FileHash item["file_hash"] = *fc.FileHash } else { item["fileHash"] = nil item["file_hash"] = nil } if isText { item["text"] = true } else { item["text"] = false } return item } // adminFileDetail 文件详情(GET ?id= 或 POST {id})。 func (d *Deps) adminFileDetail(c *gin.Context) { id, err := requestID(c) if err != nil { respondError(c, err) return } fc, err := d.fileByID(c, id) if err != nil { respondError(c, err) return } item := buildAdminFileItem(fc, time.Now()) if fc.Text != nil { item["content"] = *fc.Text } response.OK(c, item) } // adminFileUpdate 更新分享字段(对齐参考 update_file:code 冲突 400)。 func (d *Deps) adminFileUpdate(c *gin.Context) { var body struct { ID int64 `json:"id"` Code *string `json:"code"` Prefix *string `json:"prefix"` Suffix *string `json:"suffix"` ExpiredAt *string `json:"expired_at"` ExpiredCount *int `json:"expired_count"` } if err := bindJSONOrForm(c, &body); err != nil { respondError(c, err) return } if body.ID <= 0 { response.Fail(c, http.StatusBadRequest, "请选择要更新的文件") return } ctx := c.Request.Context() fc, err := d.fileByID(c, body.ID) if err != nil { respondError(c, err) return } updates := map[string]any{} if body.Code != nil && *body.Code != fc.Code { var cnt int64 if err := d.DB.WithContext(ctx).Model(&model.FileCodes{}). Where("code = ? AND id <> ?", *body.Code, fc.ID).Count(&cnt).Error; err != nil { respondError(c, errInternal("查询取件码失败: "+err.Error())) return } if cnt > 0 { response.Fail(c, http.StatusBadRequest, "code已存在") return } updates["code"] = *body.Code } if body.Prefix != nil && *body.Prefix != fc.Prefix { updates["prefix"] = *body.Prefix } if body.Suffix != nil && *body.Suffix != fc.Suffix { updates["suffix"] = *body.Suffix } if body.ExpiredAt != nil && *body.ExpiredAt != "" { t, err := parseISOTime(*body.ExpiredAt) if err != nil { response.Fail(c, http.StatusBadRequest, "expired_at 时间格式错误") return } updates["expired_at"] = t } if body.ExpiredCount != nil && *body.ExpiredCount != fc.ExpiredCount { updates["expired_count"] = *body.ExpiredCount } if len(updates) > 0 { if err := d.DB.WithContext(ctx).Model(&model.FileCodes{}). Where("id = ?", fc.ID).Updates(updates).Error; err != nil { respondError(c, errInternal("更新失败: "+err.Error())) return } } response.OK(c, "更新成功") } // adminFileDelete 删除单个分享(连带删除存储文件,DELETE {id} 或 POST {id})。 func (d *Deps) adminFileDelete(c *gin.Context) { var body struct { ID int64 `json:"id"` } if err := bindJSONOrForm(c, &body); err != nil { respondError(c, err) return } if body.ID <= 0 { if v := c.Query("id"); v != "" { body.ID, _ = strconv.ParseInt(v, 10, 64) } } if body.ID <= 0 { response.Fail(c, http.StatusBadRequest, "请选择要删除的文件") return } fc, err := d.fileByID(c, body.ID) if err != nil { respondError(c, err) return } if err := d.deleteFileCode(c, fc); err != nil { respondError(c, err) return } response.OK(c, nil) } // adminFileBatchDelete 批量删除(对齐参考 delete_files 的统计响应)。 func (d *Deps) adminFileBatchDelete(c *gin.Context) { var body struct { IDs []int64 `json:"ids"` } if err := bindJSONOrForm(c, &body); err != nil { respondError(c, err) return } if len(body.IDs) == 0 { response.Fail(c, http.StatusBadRequest, "请选择要删除的文件") return } deleted, missing, failed := d.deleteMany(c, body.IDs) response.OK(c, gin.H{ "requestedCount": len(body.IDs), "requested_count": len(body.IDs), "deletedCount": len(deleted), "deleted_count": len(deleted), "missingCount": len(missing), "missing_count": len(missing), "failedCount": len(failed), "failed_count": len(failed), "deleted": deleted, "missing": missing, "failed": failed, }) } // adminFileBatchUpdate 批量更新(对齐参考 batch_update_files)。 func (d *Deps) adminFileBatchUpdate(c *gin.Context) { var body struct { IDs []int64 `json:"ids"` ExpiredAt *string `json:"expired_at"` ExpiredCount *int `json:"expired_count"` ClearExpiredAt bool `json:"clearExpiredAt"` ClearExpiredAlt bool `json:"clear_expired_at"` } if err := bindJSONOrForm(c, &body); err != nil { respondError(c, err) return } if len(body.IDs) == 0 { response.Fail(c, http.StatusBadRequest, "请选择要更新的文件") return } updates := map[string]any{} shouldClear := body.ClearExpiredAt || body.ClearExpiredAlt switch { case shouldClear: updates["expired_at"] = nil updates["expired_count"] = -1 case body.ExpiredAt != nil && *body.ExpiredAt != "": t, err := parseISOTime(*body.ExpiredAt) if err != nil { response.Fail(c, http.StatusBadRequest, "expired_at 时间格式错误") return } updates["expired_at"] = t } if !shouldClear && body.ExpiredCount != nil { updates["expired_count"] = *body.ExpiredCount } if len(updates) == 0 { response.Fail(c, http.StatusBadRequest, "请选择要更新的字段") return } ctx := c.Request.Context() updated, missing, failed := 0, []int64{}, []gin.H{} for _, id := range body.IDs { res := d.DB.WithContext(ctx).Model(&model.FileCodes{}). Where("id = ?", id).Updates(updates) switch { case res.Error != nil: failed = append(failed, gin.H{"id": id, "reason": res.Error.Error()}) case res.RowsAffected == 0: missing = append(missing, id) default: updated++ } } response.OK(c, gin.H{ "requestedCount": len(body.IDs), "requested_count": len(body.IDs), "updatedCount": updated, "updated_count": updated, "missingCount": len(missing), "missing_count": len(missing), "failedCount": len(failed), "failed_count": len(failed), "updated": updated, "missing": missing, "failed": failed, }) } // adminFilePolicyAction 单文件策略动作(对齐参考 apply_file_policy_action)。 func (d *Deps) adminFilePolicyAction(c *gin.Context) { var body struct { ID int64 `json:"id"` Action string `json:"action"` DownloadLimit *int `json:"downloadLimit"` } if err := bindJSONOrForm(c, &body); err != nil { respondError(c, err) return } fc, err := d.fileByID(c, body.ID) if err != nil { respondError(c, err) return } updates, err := buildPolicyUpdate(fc, body.Action, body.DownloadLimit) if err != nil { respondError(c, err) return } if err := d.DB.WithContext(c.Request.Context()).Model(&model.FileCodes{}). Where("id = ?", fc.ID).Updates(updates).Error; err != nil { respondError(c, errInternal("策略执行失败: "+err.Error())) return } response.OK(c, gin.H{"id": fc.ID, "action": body.Action}) } // adminFileBatchPolicyAction 批量策略动作。 func (d *Deps) adminFileBatchPolicyAction(c *gin.Context) { var body struct { IDs []int64 `json:"ids"` Action string `json:"action"` DownloadLimit *int `json:"downloadLimit"` } if err := bindJSONOrForm(c, &body); err != nil { respondError(c, err) return } if len(body.IDs) == 0 { response.Fail(c, http.StatusBadRequest, "请选择要更新的文件") return } ctx := c.Request.Context() updated, missing, failed := 0, []int64{}, []gin.H{} for _, id := range body.IDs { fc, err := d.fileByID(c, id) if err != nil { if fc == nil { missing = append(missing, id) } else { failed = append(failed, gin.H{"id": id, "reason": err.Error()}) } continue } updates, err := buildPolicyUpdate(fc, body.Action, body.DownloadLimit) if err != nil { failed = append(failed, gin.H{"id": id, "reason": err.Error()}) continue } if err := d.DB.WithContext(ctx).Model(&model.FileCodes{}). Where("id = ?", id).Updates(updates).Error; err != nil { failed = append(failed, gin.H{"id": id, "reason": err.Error()}) continue } updated++ } response.OK(c, gin.H{ "requestedCount": len(body.IDs), "requested_count": len(body.IDs), "updatedCount": updated, "updated_count": updated, "missingCount": len(missing), "missing_count": len(missing), "failedCount": len(failed), "failed_count": len(failed), "updated": updated, "missing": missing, "failed": failed, }) } // buildPolicyUpdate 构造策略动作更新字段(对齐参考 _build_policy_action_update)。 func buildPolicyUpdate(fc *model.FileCodes, action string, downloadLimit *int) (map[string]any, error) { action = strings.ToLower(strings.TrimSpace(action)) now := time.Now() switch action { case "extend_24h": return map[string]any{"expired_at": extendExpiration(fc, now, 24*time.Hour)}, nil case "extend_7d": return map[string]any{"expired_at": extendExpiration(fc, now, 7*24*time.Hour)}, nil case "make_permanent": return map[string]any{"expired_at": nil, "expired_count": -1}, nil case "reset_download_limit": limit := 5 if downloadLimit != nil { limit = *downloadLimit } if limit < 1 { return nil, errBadRequest("取件次数必须大于 0") } return map[string]any{"expired_count": limit}, nil } return nil, errBadRequest("不支持的策略动作") } // extendExpiration 在现有过期时间(未过期时)或当前时间基础上延长。 func extendExpiration(fc *model.FileCodes, now time.Time, d time.Duration) time.Time { base := now if fc.ExpiredAt != nil && fc.ExpiredAt.After(now) { base = *fc.ExpiredAt } return base.Add(d) } // adminFileDownload 管理员下载原文件(不消耗次数;文本返回 JSON)。 func (d *Deps) adminFileDownload(c *gin.Context) { id, err := requestID(c) if err != nil { respondError(c, err) return } fc, err := d.fileByID(c, id) if err != nil { respondError(c, err) return } if fc.Text != nil { response.OK(c, *fc.Text) return } if fc.FilePath == nil || fc.UUIDFileName == nil { response.Fail(c, http.StatusNotFound, "文件不存在") return } d.serveFile(c, fc) } // adminFilePreview 文本预览(对齐参考 preview_file:仅文本分享)。 func (d *Deps) adminFilePreview(c *gin.Context) { id, err := requestID(c) if err != nil { respondError(c, err) return } maxChars := clampInt(queryInt(c, "maxChars", 4000), 1, 20000) fc, err := d.fileByID(c, id) if err != nil { respondError(c, err) return } if fc.Text == nil { response.Fail(c, http.StatusBadRequest, "仅文本分享支持预览") return } content := []rune(*fc.Text) truncated := len(content) > maxChars preview := string(content[:minInt(len(content), maxChars)]) response.OK(c, gin.H{ "id": fc.ID, "code": fc.Code, "name": fc.Prefix + fc.Suffix, "type": "text", "content": preview, "length": len(content), "previewLength": len([]rune(preview)), "preview_length": len([]rune(preview)), "truncated": truncated, "maxChars": maxChars, "max_chars": maxChars, "createdAt": fc.CreatedAt.Format(time.RFC3339), "created_at": fc.CreatedAt.Format(time.RFC3339), }) } // ============ 配置 ============ // configKeys 管理端可见/可改的配置键(不含 jwt_secret;admin_token 屏蔽展示)。 // v2 新增键(需求 ①②③④⑩):背景图、页脚、通知开关、保存/存储策略、频率限制。 var configKeys = []string{ "site_name", "name", "description", "page_explain", "keywords", "notify_title", "notify_content", "notify_enabled", "logo_url", "favicon_url", "footer_text", "footer_beian", "background_url", "openUpload", "uploadSize", "max_file_size", "allowed_file_types", "expireStyle", "max_save_count", "max_save_seconds", "storageLimit", "code_generate_type", "enableChunk", "uploadMinute", "uploadCount", "errorMinute", "errorCount", "loginCount", "loginMinute", "opacity", "background", "showAdminAddr", "robotsText", "site_domain", // v3.1:站点对外域名 "adminSessionExpire", "storage_path", "local_storage_path", "file_storage", // v3 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过) "storage_engine", "local_storage_path", "webdav_url", "webdav_root_path", "webdav_username", "webdav_password", "s3_endpoint_url", "s3_region_name", "s3_bucket_name", "s3_access_key_id", "s3_secret_access_key", "aws_session_token", "s3_addressing_style", } // intConfigKeys 需按 schema 边界校验的整型键(adminConfigUpdate 归一化用)。 // v1 既有键保留原语义;v2 新增键(max_file_size/max_save_count/notify_enabled) // 的边界来自 settings.KVSchema(单一事实来源在 config/schema.go)。 var intConfigKeys = []string{ "openUpload", "enableChunk", "showAdminAddr", "storageLimit", "uploadMinute", "uploadCount", "errorMinute", "errorCount", "loginCount", "loginMinute", "max_save_seconds", "uploadSize", "adminSessionExpire", "max_save_count", "max_file_size", "notify_enabled", } // validateConfigValue 按 settings.KVSchema 校验单个配置值: // - 整型键:Min/Max 边界(如 max_file_size ≤ 10GiB、notify_enabled ∈ {0,1}); // - 字符串键:长度上限; // - 列表键(expireStyle/allowed_file_types):必须可解析为字符串数组且非空。 // // 校验不通过返回中文 400 错误;未知键不做拦截(与 v1 行为一致,交由 configKeys 过滤)。 func validateConfigValue(key string, v any) error { entry := settings.KVSchemaByKey(key) if entry == nil { return nil } switch entry.Type { case "int", "int64": n, ok := toInt64(v) if !ok { return errBadRequest(fmt.Sprintf("%s 必须是整数", key)) } if n < entry.Min { return errBadRequest(fmt.Sprintf("%s 不能小于 %d", key, entry.Min)) } if entry.Max >= 0 && n > entry.Max { return errBadRequest(fmt.Sprintf("%s 不能大于 %d", key, entry.Max)) } case "string": s, ok := v.(string) if !ok { return errBadRequest(fmt.Sprintf("%s 必须是字符串", key)) } if entry.Max >= 0 && len([]rune(s)) > int(entry.Max) { return errBadRequest(fmt.Sprintf("%s 长度不能超过 %d 字符", key, entry.Max)) } case "[]string": list := toStrSlice(v) if list == nil { return errBadRequest(fmt.Sprintf("%s 必须是字符串数组", key)) } if len(list) == 0 { return errBadRequest(fmt.Sprintf("%s 至少保留一项", key)) } } return nil } // toInt64 宽松整型转换(JSON 数字 float64、字符串、int/int64)。 func toInt64(v any) (int64, bool) { switch n := v.(type) { case int: return int64(n), true case int64: return n, true case float64: return int64(n), true case string: if i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64); err == nil { return i, true } } return 0, false } // toStrSlice 宽松字符串数组转换:JSON 数组 / 逗号分隔字符串。 func toStrSlice(v any) []string { switch s := v.(type) { case []string: return s case []any: out := make([]string, 0, len(s)) for _, item := range s { if item == nil { continue } out = append(out, fmt.Sprintf("%v", item)) } return out case string: var out []string for _, item := range strings.Split(s, ",") { if item = strings.TrimSpace(item); item != "" { out = append(out, item) } } return out } return nil } // adminConfigGet 读取配置(对齐参考 get_config:admin_token 屏蔽、jwt_secret 不下发)。 // v3:引擎凭据类敏感键返回掩码占位(前端表单"留空=不修改");storage_engine 为当前热切换后的引擎。 func (d *Deps) adminConfigGet(c *gin.Context) { cfg := d.Cfg out := gin.H{} for _, key := range configKeys { if v, ok := cfg.Get(key); ok { out[key] = v } } for _, key := range settings.SensitiveKeys { if _, present := out[key]; present { if key == "admin_token" { out[key] = "" // 屏蔽(既有语义) } else { out[key] = settings.SensitiveMaskValue // 掩码占位 } } } // jwt_secret 永不下发 delete(out, "jwt_secret") // 引擎运行时状态(v3:热切换即时生效,无需重启) out["_engine_hint"] = gin.H{ "storage_backend": d.Store.CurrentName(), "engines": gin.H{"local": true, "s3": true, "webdav": true}, "note": "存储引擎支持运行时热切换(POST /admin/storage/switch);修改引擎参数保存后下次构建生效", } response.OK(c, out) } // adminConfigUpdate 部分更新配置(对齐参考 update_config:改密自动轮换 jwt_secret)。 func (d *Deps) adminConfigUpdate(c *gin.Context) { var patch map[string]any ct := c.GetHeader("Content-Type") var err error if strings.Contains(ct, "application/json") { err = c.ShouldBindJSON(&patch) } else { patch = map[string]any{} err = c.Request.ParseForm() if err == nil { for k, v := range c.Request.PostForm { if len(v) > 0 { patch[k] = v[0] } } } } if err != nil { response.Fail(c, http.StatusBadRequest, "请求体格式错误") return } if len(patch) == 0 { response.Fail(c, http.StatusBadRequest, "没有需要更新的配置") return } dbPatch := map[string]any{} for k, v := range patch { known := false for _, key := range configKeys { if key == k { known = true break } } if !known { continue } // 类型归一 + schema 边界校验(v2:数值/字符串长度/列表键统一走 KVSchema) isInt := false for _, key := range intConfigKeys { if key == k { isInt = true break } } switch { case isInt: if n, ok := toInt(v); ok { if err := validateConfigValue(k, n); err != nil { response.Fail(c, http.StatusBadRequest, err.Error()) return } dbPatch[k] = n } else { response.Fail(c, http.StatusBadRequest, fmt.Sprintf("%s 必须是整数", k)) return } case k == "opacity": if f, ok := toFloat(v); ok { dbPatch[k] = f } case k == "expireStyle" || k == "allowed_file_types": list := toStrSlice(v) if list == nil { response.Fail(c, http.StatusBadRequest, fmt.Sprintf("%s 必须是字符串数组", k)) return } if err := validateConfigValue(k, v); err != nil { response.Fail(c, http.StatusBadRequest, err.Error()) return } dbPatch[k] = list default: // 字符串键按 schema 长度上限校验(背景图/页脚/备案号/通知等) if err := validateConfigValue(k, v); err != nil { response.Fail(c, http.StatusBadRequest, err.Error()) return } dbPatch[k] = v } } // 管理员密码:空串忽略;明文则哈希并轮换 jwt_secret passwordChanged := false if raw, ok := patch["admin_token"]; ok { if s, isStr := raw.(string); isStr && strings.TrimSpace(s) != "" { if !settings.IsPasswordHashed(s) { dbPatch["admin_token"] = settings.HashPassword(s) } else { dbPatch["admin_token"] = s } passwordChanged = true } } // adminSessionExpire 校验(1~365 整天) if v, ok := dbPatch["adminSessionExpire"]; ok { sec, _ := toInt(v) if sec < 86400 || sec > 365*86400 || sec%86400 != 0 { response.Fail(c, http.StatusBadRequest, "adminSessionExpire 必须是 1 到 365 个整天") return } } if v, ok := dbPatch["storageLimit"]; ok { n, _ := toInt(v) if n < 0 { response.Fail(c, http.StatusBadRequest, "storageLimit 不能小于 0") return } } // 背景图 URL 白名单协议(需求 ①):http(s)、data: 与站内相对路径,防 javascript: 注入 if v, ok := dbPatch["background_url"]; ok { if s, isStr := v.(string); isStr { s = strings.TrimSpace(s) if s != "" && !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") && !strings.HasPrefix(s, "data:image/") && !strings.HasPrefix(s, "/") { response.Fail(c, http.StatusBadRequest, "background_url 仅支持 http(s) 地址、data:image 图片或站内相对路径") return } dbPatch["background_url"] = s } } // L7:notify_content 白名单净化(仅保留文本与 ),防存储型 XSS if v, ok := dbPatch["notify_content"]; ok { if s, isStr := v.(string); isStr { dbPatch["notify_content"] = settings.SanitizeInlineHTML(s) } } if passwordChanged { dbPatch["jwt_secret"] = settings.GenerateJWTSecret() } // v3.1:site_domain 规范化(http(s)://host[:port];空=用当前地址) if raw, ok := dbPatch["site_domain"]; ok { sv, _ := raw.(string) normalized, err := normalizeSiteDomain(sv) if err != nil { response.Fail(c, http.StatusBadRequest, err.Error()) return } dbPatch["site_domain"] = normalized } // v3 引擎键处理:参数键与 storage_engine 分离。 // 1) storage_engine 只接受合法枚举; // 2) 敏感凭据键空串/掩码=不修改(避免管理端表单回显把密钥抹掉); // 3) 先持久化普通键+参数键 → Invalidate 对应引擎缓存 → 再尝试 Switch 新引擎; // 4) Switch 失败回滚 storage_engine 的 KV 值并返回 503(参数与普通键保留)。 newEngine := "" if raw, ok := dbPatch["storage_engine"]; ok { s, isStr := raw.(string) if !isStr || !storage.ValidEngine(strings.TrimSpace(s)) { response.Fail(c, http.StatusBadRequest, "storage_engine 仅支持 local|s3|webdav") return } newEngine = strings.TrimSpace(s) delete(dbPatch, "storage_engine") } changedParamEngine := engineOfParamKeys(dbPatch) for _, sk := range settings.SensitiveKeys { if v, ok := dbPatch[sk]; ok { if s, isStr := v.(string); isStr && (strings.TrimSpace(s) == "" || s == settings.SensitiveMaskValue) { delete(dbPatch, sk) // 空/掩码=不修改 } } } ctx := c.Request.Context() if err := d.Mgr.UpdateKV(ctx, dbPatch); err != nil { respondError(c, errInternal("保存配置失败: "+err.Error())) return } // 引擎参数已变:使对应引擎实例缓存失效(下次构建用新参数) for _, eng := range changedParamEngine { d.Store.Invalidate(eng) } if newEngine != "" && newEngine != d.Store.CurrentName() { if _, err := d.Store.Switch(newEngine); err != nil { // 切换失败:不持久化新引擎名(保持旧引擎 KV),参数/普通键已保存 respondError(c, &apiError{Status: http.StatusServiceUnavailable, Msg: "存储引擎切换失败,已保持原引擎: " + err.Error()}) return } if err := d.Mgr.UpdateKV(ctx, map[string]any{config.KeyStorageEngine: newEngine}); err != nil { respondError(c, errInternal("保存存储引擎设置失败: "+err.Error())) return } log.Printf("[storage] 存储引擎已热切换: %s -> %s", d.Cfg.Engine(), newEngine) } if err := d.Mgr.Reload(ctx); err != nil { respondError(c, errInternal("配置重载失败: "+err.Error())) return } d.syncRateRules() response.OK(c, gin.H{"ok": true, "engine": d.Store.CurrentName()}) } // engineOfParamKeys 判断 patch 涉及哪些引擎的参数(返回需 Invalidate 的引擎列表)。 func engineOfParamKeys(patch map[string]any) []string { affect := map[string]bool{} for k := range patch { switch k { case "local_storage_path", "storage_path": affect["local"] = true case "webdav_url", "webdav_root_path", "webdav_username", "webdav_password": affect["webdav"] = true case "s3_endpoint_url", "s3_region_name", "s3_bucket_name", "s3_access_key_id", "s3_secret_access_key", "aws_session_token", "s3_addressing_style": affect["s3"] = true } } out := make([]string, 0, len(affect)) for eng := range affect { out = append(out, eng) } return out } // adminStorageSwitch v3 存储引擎热切换:{engine:"local"|"s3"|"webdav"}。 // 成功:持久化 storage_engine KV 并返回当前引擎;失败:503 且原引擎不变。 func (d *Deps) adminStorageSwitch(c *gin.Context) { var body struct { Engine string `json:"engine" form:"engine"` } if err := bindJSONOrForm(c, &body); err != nil { respondError(c, err) return } engine := strings.TrimSpace(body.Engine) if !storage.ValidEngine(engine) { response.Fail(c, http.StatusBadRequest, "engine 仅支持 local|s3|webdav") return } if engine != d.Store.CurrentName() { if _, err := d.Store.Switch(engine); err != nil { respondError(c, &apiError{Status: http.StatusServiceUnavailable, Msg: "存储引擎切换失败,已保持原引擎: " + err.Error()}) return } } if err := d.Mgr.UpdateKV(c.Request.Context(), map[string]any{config.KeyStorageEngine: engine}); err != nil { respondError(c, errInternal("保存存储引擎设置失败: "+err.Error())) return } if err := d.Mgr.Reload(c.Request.Context()); err != nil { respondError(c, errInternal("配置重载失败: "+err.Error())) return } log.Printf("[storage] 存储引擎已热切换 -> %s", engine) response.OK(c, gin.H{"ok": true, "engine": engine}) } // adminChangePassword 修改管理员密码(校验旧密码;新密码哈希 + 轮换 jwt_secret)。 func (d *Deps) adminChangePassword(c *gin.Context) { var body struct { OldPassword string `json:"old_password" form:"old_password"` NewPassword string `json:"new_password" form:"new_password"` } if err := bindJSONOrForm(c, &body); err != nil { respondError(c, err) return } if len(body.NewPassword) < 8 { response.Fail(c, http.StatusBadRequest, "新密码长度至少 8 位") return } stored := d.Cfg.GetString("admin_token") if stored == "" || !settings.VerifyPassword(body.OldPassword, stored) { response.Fail(c, http.StatusUnauthorized, "旧密码错误") return } ctx := c.Request.Context() patch := map[string]any{ "admin_token": settings.HashPassword(body.NewPassword), "jwt_secret": settings.GenerateJWTSecret(), } if err := d.Mgr.UpdateKV(ctx, patch); err != nil { respondError(c, errInternal("保存密码失败: "+err.Error())) return } if err := d.Mgr.Reload(ctx); err != nil { respondError(c, errInternal("配置重载失败: "+err.Error())) return } response.OK(c, gin.H{"ok": true}) } // ============ 审计日志查询(需求 ③)============ // adminAuditList 分页查询审计日志: // 参数 page/size/action/result/ip/start_time/end_time(ISO 8601)。 func (d *Deps) adminAuditList(c *gin.Context) { page := maxInt(queryInt(c, "page", 1), 1) size := clampInt(queryInt(c, "size", queryInt(c, "pageSize", 20)), 1, 200) action := strings.TrimSpace(c.Query("action")) result := strings.TrimSpace(c.Query("result")) ip := strings.TrimSpace(c.Query("ip")) var begin, end *time.Time if raw := strings.TrimSpace(c.Query("start_time")); raw != "" { if t, err := parseISOTime(raw); err == nil { begin = &t } else { response.Fail(c, http.StatusBadRequest, "start_time 时间格式错误") return } } if raw := strings.TrimSpace(c.Query("end_time")); raw != "" { if t, err := parseISOTime(raw); err == nil { end = &t } else { response.Fail(c, http.StatusBadRequest, "end_time 时间格式错误") return } } logs, total, err := d.AuditSvc.Query(page, size, action, ip, result, begin, end) if err != nil { respondError(c, errInternal("查询审计日志失败: "+err.Error())) return } items := make([]gin.H, 0, len(logs)) for i := range logs { items = append(items, buildAuditItem(&logs[i])) } response.OK(c, gin.H{"data": items, "total": total, "page": page, "size": size}) } // buildAuditItem 审计行(snake_case 原生 + camelCase 双份)。 func buildAuditItem(a *model.AuditLog) gin.H { return gin.H{ "id": a.ID, "action": a.Action, "file_code": a.FileCode, "fileCode": a.FileCode, "file_name": a.FileName, "fileName": a.FileName, "size_bytes": a.SizeBytes, "sizeBytes": a.SizeBytes, "transferred_bytes": a.TransferredBytes, "transferredBytes": a.TransferredBytes, "ip": a.IP, "user_agent": a.UserAgent, "userAgent": a.UserAgent, "device_os": a.DeviceOS, "deviceOs": a.DeviceOS, "device_browser": a.DeviceBrowser, "deviceBrowser": a.DeviceBrowser, "device_type": a.DeviceType, "deviceType": a.DeviceType, "actor": a.Actor, "result": a.Result, "error_msg": a.ErrorMsg, "errorMsg": a.ErrorMsg, "duration_ms": a.DurationMs, "durationMs": a.DurationMs, "created_at": a.CreatedAt.Format(time.RFC3339), "createdAt": a.CreatedAt.Format(time.RFC3339), } } // ============ 内部辅助 ============ // fileByID 按 ID 查询分享记录。 func (d *Deps) fileByID(c *gin.Context, id int64) (*model.FileCodes, error) { if id <= 0 { return nil, errBadRequest("无效的文件 ID") } var fc model.FileCodes if err := d.DB.WithContext(c.Request.Context()). Where("id = ?", id).First(&fc).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errNotFound("文件不存在") } return nil, errInternal("查询失败: " + err.Error()) } return &fc, nil } // deleteFileCode 删除分享记录与存储文件(文本分享无存储文件)。 func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error { if fc.Text == nil && fc.FilePath != nil && fc.UUIDFileName != nil { // v3:删除走文件归属引擎(旧引擎里的文件也要能删掉) if delStore, dErr := d.storeFor(fc.Engine); dErr == nil { if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) { return errInternal("存储文件删除失败: " + err.Error()) } } } if err := d.DB.WithContext(c.Request.Context()). Where("id = ?", fc.ID).Delete(&model.FileCodes{}).Error; err != nil { return errInternal("删除记录失败: " + err.Error()) } return nil } // deleteMany 批量删除:返回 (已删除, 不存在, 失败)。 func (d *Deps) deleteMany(c *gin.Context, ids []int64) (deleted []int64, missing []int64, failed []gin.H) { deleted, missing = []int64{}, []int64{} failed = []gin.H{} for _, id := range ids { fc, err := d.fileByID(c, id) if err != nil { missing = append(missing, id) continue } if err := d.deleteFileCode(c, fc); err != nil { failed = append(failed, gin.H{"id": id, "reason": err.Error()}) continue } deleted = append(deleted, id) } return } // requestID 从 query 或 JSON body 取 id。 func requestID(c *gin.Context) (int64, error) { if v := c.Query("id"); v != "" { n, err := strconv.ParseInt(v, 10, 64) if err != nil { return 0, errBadRequest("无效的文件 ID") } return n, nil } var body struct { ID int64 `json:"id"` } if err := bindJSONOrForm(c, &body); err != nil { return 0, err } return body.ID, nil } // queryInt 读取整数 query 参数。 func queryInt(c *gin.Context, key string, def int) int { raw := c.Query(key) if raw == "" { return def } n, err := strconv.Atoi(raw) if err != nil { return def } return n } // maxInt / minInt / clampInt 整数辅助。 func maxInt(a, b int) int { if a > b { return a } return b } func minInt(a, b int) int { if a < b { return a } return b } func clampInt(v, lo, hi int) int { return maxInt(lo, minInt(hi, v)) } func boolToInt(b bool) int { if b { return 1 } return 0 } func boolToInt64(b bool) int64 { if b { return 1 } return 0 } func firstNonEmpty(items ...string) string { for _, s := range items { if s != "" { return s } } return "" } // toInt / toFloat 宽松类型转换(JSON 数字 float64、字符串、int)。 func toInt(v any) (int, bool) { switch n := v.(type) { case int: return n, true case int64: return int(n), true case float64: return int(n), true case string: if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil { return i, true } } return 0, false } func toFloat(v any) (float64, bool) { switch n := v.(type) { case float64: return n, true case int: return float64(n), true case string: if f, err := strconv.ParseFloat(strings.TrimSpace(n), 64); err == nil { return f, true } } return 0, false } // parseJSONNumber 解析 JSON 标量数字。 func parseJSONNumber(raw string, out *int64) error { raw = strings.TrimSpace(raw) n, err := strconv.ParseInt(raw, 10, 64) if err != nil { return fmt.Errorf("非法数字: %s", raw) } *out = n return nil } // escapeLike 转义 LIKE 通配符(\ % _),配合 `LIKE ? ESCAPE '\'` 使用。 func escapeLike(s string) string { r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) return r.Replace(s) } // normalizeSortBy 归一化排序字段(白名单外回退 created_at)。 func normalizeSortBy(s string) string { s = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(s), "-", "_")) switch s { case "created_at", "createdat", "expired_at", "expiredat", "name", "size", "used_count", "usedcount", "code": if s == "createdat" { return "created_at" } if s == "expiredat" { return "expired_at" } if s == "usedcount" { return "used_count" } return s } return "created_at" } // sortRecentDesc 按创建时间倒序(插入排序,数据量小)。 func sortRecentDesc(items []model.FileCodes) { for i := 1; i < len(items); i++ { for j := i; j > 0 && items[j].CreatedAt.After(items[j-1].CreatedAt); j-- { items[j], items[j-1] = items[j-1], items[j] } } } // sortFileCodes 按白名单字段排序。 func sortFileCodes(items []model.FileCodes, sortBy string, desc bool) { less := func(a, b *model.FileCodes) bool { switch sortBy { case "expired_at": var ta, tb time.Time if a.ExpiredAt != nil { ta = *a.ExpiredAt } if b.ExpiredAt != nil { tb = *b.ExpiredAt } return ta.Before(tb) case "name": return a.Prefix+a.Suffix < b.Prefix+b.Suffix case "size": return a.Size < b.Size case "used_count": return a.UsedCount < b.UsedCount case "code": return a.Code < b.Code default: // created_at return a.CreatedAt.Before(b.CreatedAt) } } // 插入排序(分页前数据量有限;列表页通常数百条内) for i := 1; i < len(items); i++ { for j := i; j > 0; j-- { if desc { if less(&items[j-1], &items[j]) { items[j-1], items[j] = items[j], items[j-1] continue } } else if less(&items[j], &items[j-1]) { items[j-1], items[j] = items[j], items[j-1] continue } break } } }