Files
go_blog/main.go
T
kevin f307781f58 docs: 全部 Go 代码注释汉化
- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文
- 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等
- 代码、字符串字面量、日志消息保持英文原文,零逻辑改动
- go build/vet 通过,go test -count=1 ./... 全绿
2026-08-27 19:03:03 +08:00

314 lines
11 KiB
Go
Raw 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 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"
"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 才能影响客户端 IPX-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.dbSECURITY_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. 注册路由。
router.GET("/", handlers.HomePage(db))
router.GET("/search", handlers.SearchPage(db))
router.GET("/api/articles", handlers.HomeArticlesAPI(db))
router.GET("/rss", handlers.RSSFeed(db))
router.GET("/feed", handlers.RSSFeed(db))
router.GET("/login", handlers.LoginPage())
router.POST("/login", handlers.Login(db, loginLimiter))
router.GET("/register", handlers.RegisterPage(db))
router.POST("/register", handlers.Register(db))
router.POST("/logout", handlers.Logout())
router.GET("/article/:slug", handlers.ArticleDetail(db))
router.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.POST("/articles/new", handlers.ArticleCreate(db))
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db))
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
}
// 受保护的后台评论管理路由(仅管理员角色)。
comments := router.Group("/admin/comments")
comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
comments.GET("", handlers.CommentListPage(db))
comments.POST("/:id/approve", handlers.CommentApprove(db))
comments.POST("/:id/reject", handlers.CommentReject(db))
comments.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.POST("/new", handlers.UserCreate(db))
users.GET("/:id/edit", handlers.UserEditPage(db))
users.POST("/:id/edit", handlers.UserUpdate(db))
users.POST("/:id/delete", handlers.UserDelete(db))
}
// 受保护的文章附件路由(仅管理员角色)。
attachments := router.Group("/admin/articles")
attachments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
attachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
attachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
attachments.GET("/: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.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
settings.GET("/navlinks", handlers.NavLinksSettingsPage(db))
settings.POST("/navlinks", handlers.NavLinksSettingsSave(db))
settings.GET("/upload", handlers.UploadSettingsPage(db))
settings.POST("/upload", handlers.UploadSettingsSave(db))
settings.GET("/download", handlers.DownloadSettingsPage(db))
settings.POST("/download", handlers.DownloadSettingsSave(db))
settings.GET("/comments", handlers.CommentSettingsPage(db))
settings.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))
profile.POST("", handlers.UpdateProfile(db, cfg.Path))
profile.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.POST("/articles/new", handlers.MyArticleCreate(db))
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db))
myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db))
}
// 用户文章的受保护附件路由。
myAttachments := router.Group("/my/articles")
myAttachments.Use(middleware.AuthRequired(db))
{
myAttachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
myAttachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
myAttachments.GET("/:id/attachments", handlers.ListAttachments(db))
}
// 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 {}
}
// 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)
}
}