Files
go_blog/main.go
T
kevin 76b744d5b5 feat: 个人资料接口 JSON 化——POST /api/profile
- profile.go:UpdateProfile 改 JSON 绑定(profileRequest),移除内嵌
  头像文件分支(头像统一走 /api/profile/avatar,XSS 链校验保留在
  UploadAvatar);错误改 APIError(400 profile_wrong_password/
  profile_password_short/profile_email_invalid、404 user_not_found、
  500),成功 {ok,redirect:/profile?saved=1}
- main.go:/profile 组旧 POST 移除,/api/profile[/avatar] 分组收敛
- profile.html:主表单改 blogAPI(头像 cropper 流程独立不变),
  新增 profileError 错误区
- 测试:TestProfilePasswordMinLength/TestProfileEmailValidation 改
  JSON(400+code);TestUpdateProfileAvatarRejectsNonImage 改打
  /api/profile/avatar;env 路由同步 /api/profile
- go build/vet/test 全绿
2026-08-27 19:56:03 +08:00

357 lines
12 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"
"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 才能影响客户端 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. 注册路由。
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)
}
}