296 lines
8.6 KiB
Go
296 lines
8.6 KiB
Go
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')`)
|
|
}
|
|
}
|