Files
SKYMirror 7a9015aad0
CI 测试 / go vet + go test (push) Failing after 5s
26.9:直链下载 + 过期回收 + SHA512 去重 + 防盗链 + 媒体预览 + 文件夹上传提示
- 对象存储直链:S3 引擎 302 到限时预签名 URL(有效期钳位分享剩余时效),失败自动回落代理
- 过期回收:janitor 定时扫描 + 取件惰性回收 + 管理端手动触发(POST /admin/recycle/run),
  retention_days 最长存储时长;删除走引用计数(去重对象安全)
- SHA512 内容去重:三条上传链路落库后计算哈希,命中即复用旧对象并删除本次副本
- 下载防盗链:Referer 白名单(同源/空 Referer/通配域名放行),挂 /share/download
- 取件页图片/音频内联预览(下载地址直连,加载失败回退下载按钮)
- 文件夹上传:拖拽目录明确提示"建议压缩后上传"(webkitGetAsEntry 探测)
- 管理端设置卡「回收与下载安全」8 个新配置键(KVSchema + configKeys + UI + i18n)
- 前端产物重建并同步 server/web/dist 与 web-embed
- 文档:10-config 配置表、03-file-share 直链/防盗链/文件夹章节、07-admin 回收端点、openapi
2026-09-08 04:54:05 +08:00

192 lines
7.4 KiB
Go
Raw Permalink 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"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fileshare/internal/audit"
"fileshare/internal/config"
"fileshare/internal/middleware"
"fileshare/internal/response"
"fileshare/internal/settings"
"fileshare/internal/storage"
)
// Deps API 层共享依赖(main.go 装配后注入)。
type Deps struct {
DB *gorm.DB
Cfg *config.Config
Mgr *settings.Manager
AuditSvc *audit.Service
Limiter *middleware.RateLimiter
Store *storage.Manager // 26.9:可热切换引擎管理器(实现 Storage 接口)
Version string
}
// jwtSecret 当前 JWT 签名密钥(settings KV 运行时可变)。
func (d *Deps) jwtSecret() string { return d.Mgr.SecretProvider()() }
// Register 注册全部 API 路由与前端静态资源回退。
// 业务路由挂根路径(/share /chunk /presign /admin),与审计中间件
// DefaultClassifier 的路由模式一致(t1 冻结契约);公共接口保留
// /api/v1/health 与 /api/v1/config(对齐 t1 骨架)。
func Register(r *gin.Engine, d *Deps) {
// —— 公共接口 ——
r.GET("/api/v1/health", d.health)
r.GET("/api/v1/config", d.publicConfig)
// Info3robotsText 配置键此前无路由承接,补上(内容可在管理端自定义)
r.GET("/robots.txt", d.robotsText)
// —— 初始化向导(未初始化时唯一可用入口,GuardNotInitialized 白名单)——
registerSetup(r, d)
// —— 分享 ——
share := r.Group("/share")
{
share.POST("/text", d.shareText)
share.POST("/file", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.shareFile)
// metadata:每次访问即计数(RequireRateLimit=进入检查+完成计数)
share.GET("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadata)
share.POST("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadataPost)
share.GET("/select", d.shareSelect)
share.POST("/select", d.shareSelectPost)
// 26.9:下载防盗链(动态开关,空 Referer 放行)
share.GET("/download", middleware.HotlinkMiddleware(d.Cfg), d.shareDownload)
}
// —— 分片上传 ——
chunk := r.Group("/chunk")
{
chunk.POST("/upload/init", d.chunkInit)
// 主路径(参考语义):/chunk/upload/{uploadID}/{index}
// 扁平兼容:/chunk/upload + 表单/query 传 upload_id/chunk_index
chunk.POST("/upload/:uploadID/:chunkIndex", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUpload)
chunk.POST("/upload", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.chunkUploadFlat)
chunk.GET("/upload/status/:uploadID", d.chunkStatus)
chunk.POST("/upload/complete/:uploadID", d.chunkComplete)
chunk.DELETE("/upload/:uploadID", d.chunkCancel)
}
// —— 预签名直传 ——
presign := r.Group("/presign")
{
presign.POST("/upload/init", d.presignInit)
presign.PUT("/upload/proxy/:uploadID", middleware.UploadBandwidthMiddleware(d.Cfg.UploadRate), d.presignProxy)
presign.POST("/upload/confirm/:uploadID", d.presignConfirm)
presign.GET("/upload/status/:uploadID", d.presignStatus)
presign.DELETE("/upload/:uploadID", d.presignCancel)
}
// —— 管理端(login 公开,其余需管理员 JWT)——
registerAdmin(r, d)
// —— 前端静态资源 + SPA 回退(须最后注册)——
registerWeb(r, d)
}
// health 健康检查(对齐 t1 骨架,保持 /api/v1/health 语义不变)。
func (d *Deps) health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "ok",
"data": gin.H{
"status": "ok",
"version": d.Version,
"storage": d.Cfg.Engine(),
"time": nowRFC3339(),
},
})
}
// robotsText 输出管理端可配置的 robots.txt 内容。
func (d *Deps) robotsText(c *gin.Context) {
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(d.Cfg.GetString("robotsText")))
}
// publicConfig 公共配置(前端首页/上传页所需;26.9 需求 ①②③④⑩ 扩展):
// - 展示字段:站点信息、Logo/favicon、背景图、页脚文案/备案号、通知;
// - 策略范围(上传页动态渲染):大小上限、类型白名单、过期方式、保存
// 时间/次数上限、上传频率(仅范围,不含内部实现键)。
//
// 敏感键(admin_token/jwt_secretsettings.SensitiveKeys)与本端点无关:
// 下发字段为白名单显式构造,任何敏感键均不会出现在响应中。
func (d *Deps) publicConfig(c *gin.Context) {
cfg := d.Cfg
policy := d.CurrentUploadPolicy()
uploadCount := cfg.GetInt("uploadCount")
uploadMinute := cfg.GetInt("uploadMinute")
// uploadSize 为参考语义的回落上限,单独下发供管理端联动展示
c.JSON(http.StatusOK, gin.H{
"code": 200,
"msg": "ok",
"data": gin.H{
"config": gin.H{
"name": cfg.SiteName(),
"description": cfg.GetString("description"),
"explain": cfg.GetString("page_explain"),
// 需求 ①:Logo/favicon/背景图
"logo_url": cfg.LogoURL(),
"favicon_url": cfg.FaviconURL(),
"background_url": cfg.BackgroundURL(),
// 需求 ②:页脚自定义内容与备案号
"footer_text": cfg.FooterText(),
"footer_beian": cfg.FooterBeian(),
// 26.9:当前存储引擎名(仅名称,任何引擎参数/凭据不下发)
"storage_engine": d.Store.CurrentName(),
"site_domain": d.Cfg.SiteDomain(),
// 26.9:上下行带宽字节/秒(公开下发,0=不限速,方便管理端展示当前值)
"upload_rate": d.Cfg.UploadRate(),
"download_rate": d.Cfg.DownloadRate(),
// 需求 ③:系统通知(开关 + 内容,前台右上角悬浮窗)
// L7:读取侧再做一次白名单净化,覆盖历史存量与直改库的数据
"notify_enabled": boolToInt(cfg.NotifyEnabled()),
"notify_title": cfg.GetString("notify_title"),
"notify_content": settings.SanitizeInlineHTML(cfg.GetString("notify_content")),
// 策略范围(需求 ④⑩):上传页动态读取并在范围内选择
"uploadSize": cfg.UploadSize(),
"max_file_size": policy.MaxFileSize,
"maxFileSize": policy.MaxFileSize,
"allowedFileTypes": policy.AllowedTypes,
"expireStyle": policy.ExpireStyles,
"max_save_seconds": policy.MaxSaveSeconds,
"maxSaveSeconds": policy.MaxSaveSeconds,
"max_save_count": policy.MaxSaveCount,
"maxSaveCount": policy.MaxSaveCount,
"uploadCount": uploadCount,
"uploadMinute": uploadMinute,
"enableChunk": cfg.EnableChunk(),
"openUpload": cfg.OpenUpload(),
},
"meta": gin.H{
"version": d.Version,
"features": gin.H{
"chunkUpload": cfg.EnableChunk(),
"guestUpload": cfg.OpenUpload(),
},
},
},
})
}
// requireShareLogin 分享上传权限(对齐参考 share_required_login):
// openUpload 开启时游客可传;关闭时要求管理员 Bearer token403)。
func (d *Deps) requireShareLogin(c *gin.Context) bool {
if d.Cfg.OpenUpload() {
return true
}
header := c.GetHeader("Authorization")
const prefix = "Bearer "
if len(header) <= len(prefix) || header[:len(prefix)] != prefix {
response.Fail(c, http.StatusForbidden, "本站未开启游客上传,如需上传请先登录后台")
return false
}
token := header[len(prefix):]
if _, err := middleware.VerifyAdminToken(d.jwtSecret(), token); err != nil {
response.Fail(c, http.StatusForbidden, "本站未开启游客上传,如需上传请先登录后台")
return false
}
return true
}