package main import ( "embed" "flag" "fmt" "io/fs" "log" "net" "net/http" "os" "path" "path/filepath" "strings" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "gorm.io/gorm" "go_blog/config" "go_blog/handlers" "go_blog/middleware" "go_blog/models" ) // staticFiles 将静态资源(Markdown CSS/JS)嵌入二进制文件, // 使部署只需替换可执行文件——无需向服务器复制独立的静态目录。 // //go:embed static var staticFiles embed.FS func main() { // 0. 解析命令行参数。 configFlag := flag.String("config", "", "path to config file (default: OS-aware path)") flag.Parse() // 1. 加载配置(不存在时自动创建)。 cfg := config.LoadConfig(*configFlag) // 2. 初始化数据库(自动迁移、初始化管理员)。 db := models.InitDB(cfg) // 2b. 从数据库预热平台配置缓存。 models.LoadConfigCache(db) // 3. 创建会话存储(基于 Cookie)。 store := cookie.NewStore([]byte(cfg.Secret)) // 登录速率限制器(SECURITY_TODO #10):按 IP+用户名计数失败次数, // 使 libcurl/字典攻击者无法猛攻登录端点。 loginLimiter := handlers.NewLoginLimiter() store.Options(sessions.Options{ Path: "/", MaxAge: 86400, // 24 小时 HttpOnly: true, // 防止 XSS 访问 SameSite: http.SameSiteLaxMode, // CSRF 纵深防御;令牌校验是主控措施 // Secure 在下方的中间件中按请求设置(仅 HTTPS 时)。 }) // 4. 创建 Gin 路由器。 router := gin.Default() // 4b. 可信代理:只有列表中的 IP 才能影响客户端 IP(X-Forwarded-For)。 // 如果不设置,gin 会信任所有代理,客户端就能伪造评论/文章浏览 // 中记录的 IP。 if err := router.SetTrustedProxies(cfg.Web.TrustedProxies); err != nil { log.Fatalf("Invalid trusted_proxies in config: %v", err) } // 4c. 安全响应头(最先注册,确保被拒绝的响应上也包含它们)。 router.Use(middleware.SecurityHeaders()) // 5. 加载 HTML 模板。 router.LoadHTMLGlob("templates/**/*.html") // 6. 从存储路径提供上传文件(头像等)。只暴露已知的上传子目录—— // 绝不暴露存储根目录本身,其中还包含 SQLite 数据库文件:挂载整个 // 根目录会让任何人下载 /uploads/blog.db(SECURITY_TODO #18)。 registerUploadRoutes(router.Group("/uploads"), cfg.Path, models.GetUploadConfig().StorageDir) // 6b. 提供捆绑的静态资源(内嵌于二进制中)。 staticFS, err := fs.Sub(staticFiles, "static") if err != nil { log.Fatalf("Failed to open embedded static assets: %v", err) } router.StaticFS("/static", http.FS(staticFS)) // 6. 全局会话中间件。 router.Use(sessions.Sessions("blog_session", store)) // 6a. 按请求的会话 Cookie 加固:仅 HTTPS 时设置 Secure,以及 // SameSite=Lax。按请求应用是因为应用位于 TLS 终结端 //(Caddy/Cloudflare)之后,启动时无法得知客户端连接是否加密。 router.Use(func(c *gin.Context) { opts := sessions.Options{ Path: "/", MaxAge: 86400, HttpOnly: true, SameSite: http.SameSiteLaxMode, } if middleware.IsHTTPSRequest(c) { opts.Secure = true } sessions.Default(c).Options(opts) }) // 6b. CSRF 防护(必须在会话中间件之后运行)。 router.Use(middleware.CSRFProtect()) // 7. 全局上下文中间件(为模板设置 IsLoggedIn、Username 等)。 router.Use(middleware.SetUserContext(db)) // 8. 注册路由。 registerRoutes(router, cfg, db, loginLimiter) // 9. 启动服务器。 webPort := cfg.Web.Port socketPath := cfg.Web.Socket usePort := webPort != "" && webPort != "0" useSocket := socketPath != "" if !usePort && !useSocket { log.Fatalf("Neither port nor socket is configured — at least one must be enabled") } if usePort { go func() { addr := fmt.Sprintf(":%s", webPort) log.Printf("Go Blog starting on http://localhost%s", addr) if err := router.Run(addr); err != nil { log.Fatalf("Failed to start HTTP server: %v", err) } }() } if useSocket { go func() { os.Remove(socketPath) // 移除遗留的 socket 文件(若存在) listener, err := net.Listen("unix", socketPath) if err != nil { log.Fatalf("Failed to listen on unix socket %s: %v", socketPath, err) } log.Printf("Go Blog starting on unix socket %s", socketPath) if err := router.RunListener(listener); err != nil { log.Fatalf("Failed to serve on unix socket: %v", err) } }() } // 永久阻塞。 select {} } // registerRoutes 注册全部业务路由。独立成函数便于测试: // 签名包含 db 与 loginLimiter,但注册阶段不会触碰它们(handler 是惰性工厂), // 因此冒烟测试可传 nil。 func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLimiter *handlers.LoginRateLimiter) { // 公开页面。 router.GET("/", handlers.HomePage(db)) router.GET("/search", handlers.SearchPage(db)) router.GET("/rss", handlers.RSSFeed(db)) router.GET("/feed", handlers.RSSFeed(db)) router.GET("/login", handlers.LoginPage()) router.GET("/register", handlers.RegisterPage(db)) router.GET("/article/:slug", handlers.ArticleDetail(db)) // 公开 JSON API。 api := router.Group("/api") { api.GET("/articles", handlers.HomeArticlesAPI(db)) api.POST("/auth/login", handlers.Login(db, loginLimiter)) api.POST("/auth/register", handlers.Register(db)) api.POST("/auth/logout", handlers.Logout()) api.POST("/article/:slug/comments", handlers.PostComment(db)) } // 受保护的后台路由(仅管理员角色)。 admin := router.Group("/admin") admin.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { admin.GET("", handlers.AdminDashboard(db)) admin.GET("/articles", handlers.ArticleListPage(db)) admin.GET("/articles/new", handlers.ArticleCreatePage(db)) admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db)) } adminArticleAPI := router.Group("/api/admin/articles") adminArticleAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { adminArticleAPI.POST("", handlers.ArticleCreate(db, "/admin")) adminArticleAPI.PUT("/:id", handlers.ArticleUpdate(db, "/admin/articles")) adminArticleAPI.DELETE("/:id", handlers.ArticleDelete(db, "/admin/articles")) } // 受保护的后台评论管理路由(仅管理员角色)。 comments := router.Group("/admin/comments") comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { comments.GET("", handlers.CommentListPage(db)) } commentsAPI := router.Group("/api/admin/comments") commentsAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { commentsAPI.POST("/:id/approve", handlers.CommentApprove(db)) commentsAPI.POST("/:id/reject", handlers.CommentReject(db)) commentsAPI.POST("/:id/delete", handlers.CommentDelete(db)) } // 受保护的后台用户管理路由(仅管理员角色)。 users := router.Group("/admin/users") users.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { users.GET("", handlers.UserListPage(db)) users.GET("/new", handlers.UserCreatePage(db)) users.GET("/:id/edit", handlers.UserEditPage(db)) } usersAPI := router.Group("/api/admin/users") usersAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { usersAPI.POST("", handlers.UserCreate(db)) usersAPI.PUT("/:id", handlers.UserUpdate(db)) usersAPI.DELETE("/:id", handlers.UserDelete(db)) } // 受保护的后台文章附件 API / 路由(仅管理员角色)。 // 注意 /api/admin/articles/attachments 的静态段与 /:id 参数段共存, // gin 对静态段优先,无冲突(由 main_test.go 冒烟测试验证)。 adminAPI := router.Group("/api/admin") adminAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { adminAPI.POST("/articles/attachments", handlers.UploadAttachment(db, cfg.Path)) adminAPI.DELETE("/articles/attachments/:id", handlers.DeleteAttachment(db, cfg.Path)) adminAPI.GET("/articles/:id/attachments", handlers.ListAttachments(db)) } // 受保护的后台设置路由(平台配置)。 settings := router.Group("/admin/settings") settings.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { settings.GET("/site", handlers.SiteSettingsPage(db)) settings.GET("/navlinks", handlers.NavLinksSettingsPage(db)) settings.GET("/upload", handlers.UploadSettingsPage(db)) settings.GET("/download", handlers.DownloadSettingsPage(db)) settings.GET("/comments", handlers.CommentSettingsPage(db)) } settingsAPI := router.Group("/api/admin/settings") settingsAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { settingsAPI.POST("/site", handlers.SiteSettingsSave(db, cfg.Path)) settingsAPI.POST("/site/favicon", handlers.SiteFaviconUpload(db, cfg.Path)) settingsAPI.POST("/site/logo", handlers.SiteLogoUpload(db, cfg.Path)) settingsAPI.POST("/navlinks", handlers.NavLinksSettingsSave(db)) settingsAPI.POST("/upload", handlers.UploadSettingsSave(db)) settingsAPI.POST("/download", handlers.DownloadSettingsSave(db)) settingsAPI.POST("/comments", handlers.CommentSettingsSave(db)) } // 受保护的后台统计路由(读取统计信息)。 analytics := router.Group("/admin/analytics") analytics.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { analytics.GET("/views", handlers.ViewAnalyticsPage(db)) } // 受保护的个人资料路由。 profile := router.Group("/profile") profile.Use(middleware.AuthRequired(db)) { profile.GET("", handlers.ProfilePage(db)) } profileAPI := router.Group("/api/profile") profileAPI.Use(middleware.AuthRequired(db)) { profileAPI.POST("", handlers.UpdateProfile(db, cfg.Path)) profileAPI.POST("/avatar", handlers.UploadAvatar(db, cfg.Path)) } // 受保护的用户文章管理路由(面向非管理员用户)。 myArticles := router.Group("/my") myArticles.Use(middleware.AuthRequired(db)) { myArticles.GET("/articles", handlers.MyArticlesPage(db)) myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db)) myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db)) } // 用户文章的受保护 API(仅登录用户,含 attachments 静态段与 :id 参数段)。 myAPI := router.Group("/api/my/articles") myAPI.Use(middleware.AuthRequired(db)) { myAPI.POST("", handlers.MyArticleCreate(db)) myAPI.PUT("/:id", handlers.MyArticleUpdate(db)) myAPI.DELETE("/:id", handlers.MyArticleDelete(db)) myAPI.POST("/attachments", handlers.UploadAttachment(db, cfg.Path)) myAPI.DELETE("/attachments/:id", handlers.DeleteAttachment(db, cfg.Path)) myAPI.GET("/:id/attachments", handlers.ListAttachments(db)) } } // registerUploadRoutes 在 /uploads 组下暴露公开的上传子目录:avatars、 // logos,以及配置的附件存储目录(外加向后兼容的默认 "attachments")。 // 存储根目录绝不挂载——其中还包含 SQLite 数据库文件, // 该文件不可被下载(SECURITY_TODO #18)。 // 禁用目录列表:仅具体文件可解析。 func registerUploadRoutes(g *gin.RouterGroup, storagePath, storageDir string) { dirs := []string{"attachments", "avatars", "logos"} if dir := safeStorageDir(storageDir); dir != "attachments" && dir != "avatars" && dir != "logos" { dirs = append(dirs, dir) } for _, d := range dirs { h := serveUploadDir(filepath.Join(storagePath, d)) g.GET("/"+d+"/*file", h) g.HEAD("/"+d+"/*file", h) } } // safeStorageDir 将配置的附件存储目录收窄为安全的相对路径: // 非空、非绝对路径,且不含 ".." 或 "\"。 // 任何不安全值回退到默认的 "attachments", // 使配置错误的 storage_dir 无法逃逸出存储根目录 // (针对 SECURITY_TODO #22 的纵深防御)。 func safeStorageDir(dir string) string { const fallback = "attachments" if dir == "" { return fallback } cleaned := path.Clean(dir) if path.IsAbs(cleaned) || cleaned == "." || strings.Contains(cleaned, "..") || strings.Contains(cleaned, "\\") { return fallback } return cleaned } // serveUploadDir 从一个上传子目录提供具体文件。 // 目录列表和路径穿越尝试以 404 拒绝。 func serveUploadDir(root string) gin.HandlerFunc { return func(c *gin.Context) { rel := c.Param("file") // 始终以 "/" 开头 if strings.Contains(rel, "..") || strings.ContainsRune(rel, '\\') { c.Status(http.StatusNotFound) return } full := filepath.Join(root, rel) if st, err := os.Stat(full); err != nil || st.IsDir() { c.Status(http.StatusNotFound) return } c.File(full) } }