package server import ( "fmt" "html/template" "io/fs" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/gorilla/sessions" webassets "gobsidian/web" "gobsidian/internal/auth" "gobsidian/internal/config" "gobsidian/internal/handlers" "gobsidian/internal/models" "gobsidian/internal/utils" ) type Server struct { config *config.Config router *gin.Engine store *sessions.CookieStore auth *auth.Service } func New(cfg *config.Config) *Server { if !cfg.Debug { gin.SetMode(gin.ReleaseMode) } router := gin.Default() store := sessions.NewCookieStore([]byte(cfg.SecretKey)) // Initialize auth service (panic on error during startup) authSvc, err := auth.Open(cfg) if err != nil { panic(fmt.Errorf("failed to initialize auth: %w", err)) } s := &Server{ config: cfg, router: router, store: store, auth: authSvc, } // Global middlewares s.router.Use(s.SessionUser()) // Enforce IP bans/whitelists and log access for every request s.router.Use(s.IPBanEnforce()) s.router.Use(s.AccessLogger()) s.setupRoutes() s.setupStaticFiles() s.setupTemplates() return s } func (s *Server) Start() error { // Ensure notes directory exists if err := utils.EnsureDir(s.config.NotesDir); err != nil { return fmt.Errorf("failed to create notes directory: %w", err) } // Ensure image storage directory exists for mode 2 if s.config.ImageStorageMode == 2 { if err := utils.EnsureDir(s.config.ImageStoragePath); err != nil { return fmt.Errorf("failed to create image storage directory: %w", err) } } // Start background cleanup for access logs older than 7 days (daily) go s.startAccessLogCleanup() addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) fmt.Printf("Starting Gobsidian server on %s\n", addr) fmt.Printf("Notes directory: %s\n", s.config.NotesDir) return s.router.Run(addr) } func (s *Server) setupRoutes() { h := handlers.New(s.config, s.store, s.auth) // Group all routes under optional URL prefix r := s.router.Group(s.config.URLPrefix) // Main routes r.GET("/", h.IndexHandler) r.GET("/folder/*path", h.FolderHandler) r.GET("/note/*path", h.NoteHandler) // File serving routes r.GET("/serve_attached_image/*path", h.ServeAttachedImageHandler) r.GET("/serve_stored_image/:filename", h.ServeStoredImageHandler) r.GET("/download/*path", h.DownloadHandler) r.GET("/view_text/*path", h.ViewTextHandler) // Auth routes r.GET("/editor/login", h.LoginPage) r.POST("/editor/login", s.CSRFRequire(), h.LoginPost) r.POST("/editor/logout", s.RequireAuth(), s.CSRFRequire(), h.LogoutPost) // MFA challenge routes (no auth yet, but CSRF) r.GET("/editor/mfa", s.CSRFRequire(), h.MFALoginPage) r.POST("/editor/mfa", s.CSRFRequire(), h.MFALoginVerify) // New /editor group protected by auth + CSRF editor := r.Group("/editor", s.RequireAuth(), s.CSRFRequire()) { editor.GET("/create", h.CreateNotePageHandler) editor.POST("/create", h.CreateNoteHandler) editor.GET("/edit/*path", h.EditNotePageHandler) editor.POST("/edit/*path", h.EditNoteHandler) editor.DELETE("/delete/*path", h.DeleteHandler) // Text editor routes under /editor editor.GET("/edit_text/*path", h.EditTextPageHandler) editor.POST("/edit_text/*path", h.PostEditTextHandler) // Upload under /editor (secured) editor.POST("/upload", h.UploadHandler) // Settings under /editor editor.GET("/settings", h.SettingsPageHandler) editor.GET("/settings/image_storage", h.GetImageStorageSettingsHandler) editor.POST("/settings/image_storage", h.PostImageStorageSettingsHandler) editor.GET("/settings/notes_dir", h.GetNotesDirSettingsHandler) editor.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler) editor.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler) editor.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler) // Security settings (IP ban thresholds/duration/permanent) editor.GET("/settings/security", h.GetSecuritySettingsHandler) editor.POST("/settings/security", h.PostSecuritySettingsHandler) // Profile editor.GET("/profile", h.ProfilePage) editor.POST("/profile/password", h.PostProfileChangePassword) editor.POST("/profile/email", h.PostProfileChangeEmail) editor.POST("/profile/mfa/enable", h.PostProfileEnableMFA) editor.POST("/profile/mfa/disable", h.PostProfileDisableMFA) // MFA setup during enrollment editor.GET("/profile/mfa/setup", h.ProfileMFASetupPage) editor.POST("/profile/mfa/verify", h.ProfileMFASetupVerify) // Admin dashboard editor.GET("/admin", s.RequireAdmin(), h.AdminPage) // Admin CRUD API under /editor/admin admin := editor.Group("/admin", s.RequireAdmin()) { // Logs page admin.GET("/logs", h.AdminLogsPage) // Manual clear old access logs (older than 7 days) admin.POST("/logs/clear_access", h.AdminClearAccessLogs) // Security: IP ban/whitelist actions admin.POST("/ip/ban", h.AdminBanIP) admin.POST("/ip/unban", h.AdminUnbanIP) admin.POST("/ip/whitelist", h.AdminWhitelistIP) admin.POST("/users", h.AdminCreateUser) admin.DELETE("/users/:id", h.AdminDeleteUser) admin.POST("/users/:id/active", h.AdminSetUserActive) admin.POST("/users/:id/mfa/enable", h.AdminEnableUserMFA) admin.POST("/users/:id/mfa/disable", h.AdminDisableUserMFA) admin.POST("/users/:id/mfa/reset", h.AdminResetUserMFA) admin.POST("/groups", h.AdminCreateGroup) admin.DELETE("/groups/:id", h.AdminDeleteGroup) admin.POST("/memberships/add", h.AdminAddUserToGroup) admin.POST("/memberships/remove", h.AdminRemoveUserFromGroup) } } // API routes r.GET("/api/tree", h.TreeAPIHandler) r.GET("/api/search", h.SearchHandler) } func (s *Server) setupStaticFiles() { // Serve /static from embedded web/static sub, err := fs.Sub(webassets.StaticFS, "static") if err != nil { panic(err) } s.router.StaticFS(s.config.URLPrefix+"/static", http.FS(sub)) // Favicon from same sub FS s.router.StaticFileFS(s.config.URLPrefix+"/favicon.ico", "favicon.ico", http.FS(sub)) } func (s *Server) setupTemplates() { // Add template functions funcMap := template.FuncMap{ "formatSize": utils.FormatFileSize, "formatTime": utils.FormatTime, "join": strings.Join, // base returns the configured URL prefix ("" for root) "base": func() string { return s.config.URLPrefix }, // url joins the base with the given path ensuring single slash "url": func(p string) string { bp := s.config.URLPrefix if bp == "" { if p == "" { return "" } if strings.HasPrefix(p, "/") { return p } return "/" + p } if p == "" || p == "/" { return bp + "/" } if strings.HasPrefix(p, "/") { return bp + p } return bp + "/" + p }, "contains": func(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false }, "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "fileTypeClass": func(fileType models.FileType) string { switch fileType { case models.FileTypeMarkdown: return "text-blue-400" case models.FileTypeDirectory: return "text-yellow-400" case models.FileTypeImage: return "text-green-400" case models.FileTypeText: return "text-gray-400" default: return "text-gray-500" } }, "fileTypeIcon": func(fileType models.FileType) string { switch fileType { case models.FileTypeMarkdown: return "📝" case models.FileTypeDirectory: return "📁" case models.FileTypeImage: return "🖼️" case models.FileTypeText: return "📄" default: return "📄" } }, "dict": func(values ...interface{}) map[string]interface{} { if len(values)%2 != 0 { return nil } dict := make(map[string]interface{}, len(values)/2) for i := 0; i < len(values); i += 2 { key, ok := values[i].(string) if !ok { return nil } dict[key] = values[i+1] } return dict }, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, } // Load templates from embedded FS tplFS, err := fs.Sub(webassets.TemplatesFS, "templates") if err != nil { panic(err) } templates := template.Must(template.New("").Funcs(funcMap).ParseFS(tplFS, "*.html")) s.router.SetHTMLTemplate(templates) fmt.Printf("DEBUG: Templates loaded successfully\n") } // startAccessLogCleanup deletes access logs older than 7 days once at startup and then daily. func (s *Server) startAccessLogCleanup() { // initial cleanup _, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`) ticker := time.NewTicker(24 * time.Hour) for range ticker.C { _, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`) } }