- #20 AuthRequired 改为 AuthRequired(db):受保护路由每次回库校验 Status == StatusNormal 且未软删,失败清 session(保留 lang/csrf_token, 与登录轮换口径一致)并 302 /login;session user_id 先断言为数值再入 GORM(呼应 #19),AdminRequired 同步加固;SetUserContext 仅在用户 存在且状态正常时置 is_logged_in——禁用用户发评论不再自动通过, 回落游客审核策略 - #21 头像两个分支(UploadAvatar / UpdateProfile)强制 Category == image,解码失败直接拒绝、删除"回退存原始字节"路径, 统一经 processAvatar 解码→256px 缩放→JPEG 重编码;addUploadFileType 增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs), 拒绝添加并在上传设置页提示(模板 + 中英 i18n) - 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠 回退存原始字节掩盖,PNG 头像从未真正处理过),补 blank import - 新增回归测试 session_upload_security_test.go(6 用例:禁用/锁定/ 软删旧 cookie 302、锁定用户评论转 pending、6 组危险扩展拒绝、 伪装扩展名头像拒绝且磁盘零写入、正常图片转存 .jpg;已变异验证: 去掉任一修复对应测试即失败) - SECURITY_TODO.md 勾选 #20/#21 并更新执行顺序
316 lines
11 KiB
Go
316 lines
11 KiB
Go
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 embeds the static assets (Markdown CSS/JS) into the binary so a
|
|
// deployment only needs to replace the executable — no separate static
|
|
// directory has to be copied to the server.
|
|
//
|
|
//go:embed static
|
|
var staticFiles embed.FS
|
|
|
|
func main() {
|
|
// 0. Parse command-line flags.
|
|
configFlag := flag.String("config", "", "path to config file (default: OS-aware path)")
|
|
flag.Parse()
|
|
|
|
// 1. Load configuration (auto-creates if missing).
|
|
cfg := config.LoadConfig(*configFlag)
|
|
|
|
// 2. Initialize the database (auto-migrates, seeds admin).
|
|
db := models.InitDB(cfg)
|
|
|
|
// 2b. Warm the platform configuration cache from the database.
|
|
models.LoadConfigCache(db)
|
|
|
|
// 3. Create session store (cookie-based).
|
|
store := cookie.NewStore([]byte(cfg.Secret))
|
|
store.Options(sessions.Options{
|
|
Path: "/",
|
|
MaxAge: 86400, // 24 hours
|
|
HttpOnly: true, // prevent XSS access
|
|
SameSite: http.SameSiteLaxMode, // CSRF defense-in-depth; token check is the primary control
|
|
// Secure is set per request (over HTTPS only) in the middleware below.
|
|
})
|
|
|
|
// 4. Create Gin router.
|
|
router := gin.Default()
|
|
|
|
// 4b. Trusted proxies: only IPs listed here may influence the client IP
|
|
// (X-Forwarded-For). Without this, gin trusts every proxy and a client
|
|
// can spoof the IP recorded for comments/article views.
|
|
if err := router.SetTrustedProxies(cfg.Web.TrustedProxies); err != nil {
|
|
log.Fatalf("Invalid trusted_proxies in config: %v", err)
|
|
}
|
|
|
|
// 4c. Security response headers (registered first so they are present
|
|
// even on rejected responses).
|
|
router.Use(middleware.SecurityHeaders())
|
|
|
|
// 5. Load HTML templates.
|
|
router.LoadHTMLGlob("templates/**/*.html")
|
|
|
|
// 6. Serve uploaded files (avatars etc.) from the storage path. Only the
|
|
// known upload subdirectories are exposed — never the storage root
|
|
// itself, which also holds the SQLite database file: mounting the whole
|
|
// root would let anyone download /uploads/blog.db (SECURITY_TODO #18).
|
|
registerUploadRoutes(router.Group("/uploads"), cfg.Path, models.GetUploadConfig().StorageDir)
|
|
|
|
// 6b. Serve bundled static assets (embedded into the binary).
|
|
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. Global session middleware.
|
|
router.Use(sessions.Sessions("blog_session", store))
|
|
|
|
// 6a. Per-request session cookie hardening: Secure only over HTTPS, and
|
|
// SameSite=Lax. Applied per request because the app sits behind a TLS
|
|
// terminator (Caddy/Cloudflare) and cannot know at startup whether the
|
|
// client connection is encrypted.
|
|
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 protection (must run after the session middleware).
|
|
router.Use(middleware.CSRFProtect())
|
|
|
|
// 7. Global context middleware (sets IsLoggedIn, Username for templates).
|
|
router.Use(middleware.SetUserContext(db))
|
|
|
|
// 8. Register routes.
|
|
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))
|
|
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))
|
|
|
|
// Protected admin routes (admin role only).
|
|
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))
|
|
|
|
}
|
|
|
|
// Protected admin comment management routes (admin role only).
|
|
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))
|
|
}
|
|
|
|
// Protected admin user-management routes (admin role only).
|
|
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))
|
|
}
|
|
|
|
// Protected article attachment routes (admin role only).
|
|
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))
|
|
}
|
|
|
|
// Protected admin settings routes (platform configuration).
|
|
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))
|
|
}
|
|
|
|
// Protected admin analytics routes (reading statistics).
|
|
analytics := router.Group("/admin/analytics")
|
|
analytics.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
|
{
|
|
analytics.GET("/views", handlers.ViewAnalyticsPage(db))
|
|
}
|
|
|
|
// Protected profile routes.
|
|
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))
|
|
}
|
|
|
|
// Protected user article management routes (for non-admin users).
|
|
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))
|
|
}
|
|
|
|
// Protected article attachment routes for user articles.
|
|
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. Start the server.
|
|
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) // remove stale socket file if exists
|
|
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)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Block forever.
|
|
select {}
|
|
}
|
|
|
|
// registerUploadRoutes exposes the public upload subdirectories under the
|
|
// /uploads group: avatars, logos, and the configured attachment storage
|
|
// directory (plus the default "attachments" for backward compatibility).
|
|
// The storage root itself is never mounted — it also contains the SQLite
|
|
// database file, which must not be downloadable (SECURITY_TODO #18).
|
|
// Directory listing is disabled: only concrete files resolve.
|
|
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 clamps the configured attachment storage directory to a
|
|
// safe relative path: non-empty, not absolute, and free of ".." or "\".
|
|
// Anything unsafe falls back to the default "attachments" so a
|
|
// misconfigured storage_dir cannot escape the storage root (defense in
|
|
// depth for 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 serves concrete files from one upload subdirectory.
|
|
// Directory listings and traversal attempts are rejected with 404.
|
|
func serveUploadDir(root string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
rel := c.Param("file") // always begins with "/"
|
|
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)
|
|
}
|
|
}
|