package main import ( _ "FileRelay/docs" "FileRelay/internal/api/admin" "FileRelay/internal/api/middleware" "FileRelay/internal/api/public" "FileRelay/internal/bootstrap" "FileRelay/internal/config" "FileRelay/internal/model" "FileRelay/internal/task" "context" "embed" "flag" "fmt" "io" "io/fs" "log" "mime" "net/http" "os" "path/filepath" "strings" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) //go:embed all:web var webFS embed.FS // @title 文件暂存柜 API // @version 1.0 // @description 自托管的文件暂存柜后端系统 API 文档 // @termsOfService http://swagger.io/terms/ // @contact.name API Support // @contact.url http://www.swagger.io/support // @contact.email support@swagger.io // @license.name Apache 2.0 // @license.url http://www.apache.org/licenses/LICENSE-2.0.html // @BasePath / // @securityDefinitions.apikey AdminAuth // @in header // @name Authorization // @description Type "Bearer " or "Bearer " to authenticate. API Token must have 'admin' scope. // @securityDefinitions.apikey APITokenAuth // @in header // @name Authorization // @description Type "Bearer " to authenticate. Required scope depends on the endpoint. func main() { // 注册常用 MIME 类型 mime.AddExtensionType(".js", "application/javascript") mime.AddExtensionType(".css", "text/css") mime.AddExtensionType(".woff", "font/woff") mime.AddExtensionType(".woff2", "font/woff2") mime.AddExtensionType(".svg", "image/svg+xml") // 解析命令行参数 configPath := flag.String("config", "config/config.yaml", "path to config file") flag.Parse() // 1. 加载配置 if err := config.LoadConfig(*configPath); err != nil { log.Fatalf("Failed to load config: %v", err) } port := 8080 // 打印配置信息 fmt.Println("========================================") fmt.Println("FileRelay 服务启动中...") fmt.Printf("配置文件路径: %s\n", *configPath) fmt.Printf("监听端口: %d\n", port) fmt.Printf("数据库文件: %s\n", config.GlobalConfig.Database.Path) fmt.Printf("存储模式: %s\n", config.GlobalConfig.Storage.Type) webPath := config.GlobalConfig.Web.Path useExternalWeb := false if webPath != "" { if info, err := os.Stat(webPath); err == nil && info.IsDir() { useExternalWeb = true } } if useExternalWeb { fmt.Printf("前端资源来源: 外部目录 (%s)\n", webPath) } else { fmt.Printf("前端资源来源: 内置嵌入 (嵌入 fs)\n") if webPath != "" { fmt.Printf("提示: 配置的外部前端路径 %s 不存在,已回退到内置资源\n", webPath) } } fmt.Println("========================================") // 2. 初始化 bootstrap.InitDB() // 3. 启动清理任务 cleaner := task.NewCleaner() go cleaner.Start(context.Background()) // 4. 设置路由 r := gin.Default() // 配置更完善的 CORS corsConfig := cors.DefaultConfig() corsConfig.AllowAllOrigins = true corsConfig.AllowHeaders = append(corsConfig.AllowHeaders, "Authorization", "Accept", "X-Requested-With") corsConfig.AllowMethods = append(corsConfig.AllowMethods, "OPTIONS") r.Use(cors.New(corsConfig)) // Swagger 文档 r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) // 公共接口 uploadHandler := public.NewUploadHandler() pickupHandler := public.NewPickupHandler() publicConfigHandler := public.NewConfigHandler() api := r.Group("/api") { api.GET("/config", publicConfigHandler.GetPublicConfig) // 统一使用 /batches 作为资源路径 api.POST("/batches", middleware.APITokenAuth(model.ScopeUpload, !config.GlobalConfig.Upload.RequireToken), uploadHandler.Upload) api.POST("/batches/text", middleware.APITokenAuth(model.ScopeUpload, !config.GlobalConfig.Upload.RequireToken), uploadHandler.UploadText) api.GET("/batches/:pickup_code", middleware.PickupRateLimit(), middleware.APITokenAuth(model.ScopePickup, true), pickupHandler.Pickup) api.GET("/batches/:pickup_code/download", middleware.APITokenAuth(model.ScopePickup, true), pickupHandler.DownloadBatch) // 文件下载路由,支持直观的文件名结尾 api.GET("/files/:file_id/:filename", middleware.APITokenAuth(model.ScopePickup, true), pickupHandler.DownloadFile) // 保持旧路由兼容性 api.GET("/files/:file_id/download", middleware.APITokenAuth(model.ScopePickup, true), pickupHandler.DownloadFile) // 保持旧路由兼容性 (可选,但为了平滑过渡通常建议保留一段时间或直接更新) // 这里根据需求“调整不符合规范的”,我将直接采用新路由 } // 管理员接口 authHandler := admin.NewAuthHandler() batchHandler := admin.NewBatchHandler() tokenHandler := admin.NewTokenHandler() configHandler := admin.NewConfigHandler() r.POST("/admin/login", authHandler.Login) adm := r.Group("/admin") adm.Use(middleware.AdminAuth()) { adm.GET("/config", configHandler.GetConfig) adm.PUT("/config", configHandler.UpdateConfig) adm.GET("/batches", batchHandler.ListBatches) adm.GET("/batches/:batch_id", batchHandler.GetBatch) adm.PUT("/batches/:batch_id", batchHandler.UpdateBatch) adm.DELETE("/batches/:batch_id", batchHandler.DeleteBatch) adm.GET("/api-tokens", tokenHandler.ListTokens) adm.POST("/api-tokens", tokenHandler.CreateToken) adm.DELETE("/api-tokens/:id", tokenHandler.DeleteToken) adm.POST("/api-tokens/:id/revoke", tokenHandler.RevokeToken) } // 静态资源服务 (放在最后,确保 API 路由优先) webSub, _ := fs.Sub(webFS, "web") r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path // 如果请求的是 API 或 Swagger,则不处理静态资源 (让其返回 404) // 注意:此处不排除 /admin,因为 /admin 通常是前端 SPA 的路由地址 if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/swagger") { return } // 辅助函数:尝试从外部或嵌入服务文件 serveFile := func(relPath string, allowExternal bool) bool { // 1. 优先尝试外部路径 if allowExternal && config.GlobalConfig.Web.Path != "" { fullPath := filepath.Join(config.GlobalConfig.Web.Path, relPath) if info, err := os.Stat(fullPath); err == nil && !info.IsDir() { c.File(fullPath) return true } } // 2. 尝试嵌入式文件 f, err := webSub.Open(relPath) if err == nil { defer f.Close() stat, err := f.Stat() if err == nil && !stat.IsDir() { // 使用 http.ServeContent 避免 c.FileFromFS 重定向问题 if rs, ok := f.(io.ReadSeeker); ok { // 显式设置 Content-Type,防止某些环境下识别失败 ext := filepath.Ext(relPath) ctype := mime.TypeByExtension(ext) if ctype != "" { c.Header("Content-Type", ctype) } http.ServeContent(c.Writer, c.Request, stat.Name(), stat.ModTime(), rs) return true } } } return false } // 1. 尝试直接请求的文件 (如果是 / 则尝试 index.html) requestedPath := strings.TrimPrefix(path, "/") if requestedPath == "" { requestedPath = "index.html" } if serveFile(requestedPath, true) { return } // 2. SPA 支持:对于非文件请求(没有后缀或不包含点),尝试返回 index.html // 如果请求的是 assets 目录下的文件(包含点)却没找到,不应该回退到 index.html isAsset := strings.Contains(requestedPath, ".") if !isAsset || requestedPath == "index.html" { if serveFile("index.html", true) { return } } // 最终找不到则返回 404 c.Status(http.StatusNotFound) }) // 5. 运行 r.Run(fmt.Sprintf(":%d", port)) }