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) } }