add optional prefix to url
This commit is contained in:
@@ -17,6 +17,7 @@ type Config struct {
|
||||
SecretKey string
|
||||
Debug bool
|
||||
MaxContentLength int64 // in bytes
|
||||
URLPrefix string
|
||||
|
||||
// MD Notes App settings
|
||||
AppName string
|
||||
@@ -68,6 +69,7 @@ var defaultConfig = map[string]map[string]string{
|
||||
"SECRET_KEY": "change-this-secret-key",
|
||||
"DEBUG": "false",
|
||||
"MAX_CONTENT_LENGTH": "16", // in MB
|
||||
"URL_PREFIX": "",
|
||||
},
|
||||
"MD_NOTES_APP": {
|
||||
"APP_NAME": "Gobsidian",
|
||||
@@ -148,6 +150,14 @@ func Load() (*Config, error) {
|
||||
config.Debug, _ = SERVERSection.Key("DEBUG").Bool()
|
||||
maxContentMB, _ := SERVERSection.Key("MAX_CONTENT_LENGTH").Int()
|
||||
config.MaxContentLength = int64(maxContentMB) * 1024 * 1024 // Convert MB to bytes
|
||||
// Normalize URL prefix: "" or starts with '/' and no trailing '/'
|
||||
rawPrefix := strings.TrimSpace(SERVERSection.Key("URL_PREFIX").String())
|
||||
if rawPrefix == "/" { rawPrefix = "" }
|
||||
if rawPrefix != "" {
|
||||
if !strings.HasPrefix(rawPrefix, "/") { rawPrefix = "/" + rawPrefix }
|
||||
rawPrefix = strings.TrimRight(rawPrefix, "/")
|
||||
}
|
||||
config.URLPrefix = rawPrefix
|
||||
|
||||
// Load MD_NOTES_APP section
|
||||
notesSection := cfg.Section("MD_NOTES_APP")
|
||||
@@ -340,6 +350,14 @@ func (c *Config) SaveSetting(section, key, value string) error {
|
||||
if size, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
c.MaxContentLength = size * 1024 * 1024
|
||||
}
|
||||
case "URL_PREFIX":
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "/" { v = "" }
|
||||
if v != "" {
|
||||
if !strings.HasPrefix(v, "/") { v = "/" + v }
|
||||
v = strings.TrimRight(v, "/")
|
||||
}
|
||||
c.URLPrefix = v
|
||||
}
|
||||
case "MD_NOTES_APP":
|
||||
switch key {
|
||||
|
||||
@@ -20,6 +20,11 @@ const sessionCookieName = "gobsidian_session"
|
||||
|
||||
// LoginPage renders the login form
|
||||
func (h *Handlers) LoginPage(c *gin.Context) {
|
||||
// If already authenticated, redirect to home (respect URL prefix)
|
||||
if isAuthenticated(c) {
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/")
|
||||
return
|
||||
}
|
||||
token, _ := c.Get("csrf_token")
|
||||
c.HTML(http.StatusOK, "login", gin.H{
|
||||
"app_name": h.config.AppName,
|
||||
@@ -128,7 +133,7 @@ func isAllDigits(s string) bool {
|
||||
func (h *Handlers) MFALoginPage(c *gin.Context) {
|
||||
session, _ := h.store.Get(c.Request, sessionCookieName)
|
||||
if _, ok := session.Values["mfa_user_id"]; !ok {
|
||||
c.Redirect(http.StatusFound, "/editor/login")
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login")
|
||||
return
|
||||
}
|
||||
token, _ := c.Get("csrf_token")
|
||||
@@ -161,7 +166,7 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) {
|
||||
session, _ := h.store.Get(c.Request, sessionCookieName)
|
||||
uidAny, ok := session.Values["mfa_user_id"]
|
||||
if !ok {
|
||||
c.Redirect(http.StatusFound, "/editor/login")
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login")
|
||||
return
|
||||
}
|
||||
uid, _ := uidAny.(int64)
|
||||
@@ -188,14 +193,14 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) {
|
||||
delete(session.Values, "mfa_user_id")
|
||||
session.Values["user_id"] = uid
|
||||
_ = session.Save(c.Request, c.Writer)
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/")
|
||||
}
|
||||
|
||||
// ProfileMFASetupPage shows QR and input to verify during enrollment
|
||||
func (h *Handlers) ProfileMFASetupPage(c *gin.Context) {
|
||||
uidPtr := getUserIDPtr(c)
|
||||
if uidPtr == nil {
|
||||
c.Redirect(http.StatusFound, "/editor/login")
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login")
|
||||
return
|
||||
}
|
||||
// ensure enrollment exists, otherwise create one
|
||||
@@ -329,7 +334,7 @@ func (h *Handlers) LoginPost(c *gin.Context) {
|
||||
session, _ := h.store.Get(c.Request, sessionCookieName)
|
||||
session.Values["mfa_user_id"] = user.ID
|
||||
_ = session.Save(c.Request, c.Writer)
|
||||
c.Redirect(http.StatusFound, "/editor/mfa")
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/mfa")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -340,7 +345,7 @@ func (h *Handlers) LoginPost(c *gin.Context) {
|
||||
session, _ := h.store.Get(c.Request, sessionCookieName)
|
||||
session.Values["user_id"] = user.ID
|
||||
_ = session.Save(c.Request, c.Writer)
|
||||
c.Redirect(http.StatusFound, "/editor/profile/mfa/setup")
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/profile/mfa/setup")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -349,7 +354,7 @@ func (h *Handlers) LoginPost(c *gin.Context) {
|
||||
session.Values["user_id"] = user.ID
|
||||
_ = session.Save(c.Request, c.Writer)
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/")
|
||||
}
|
||||
|
||||
// LogoutPost clears the session
|
||||
@@ -357,5 +362,5 @@ func (h *Handlers) LogoutPost(c *gin.Context) {
|
||||
session, _ := h.store.Get(c.Request, sessionCookieName)
|
||||
session.Options.MaxAge = -1
|
||||
_ = session.Save(c.Request, c.Writer)
|
||||
c.Redirect(http.StatusFound, "/editor/login")
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login")
|
||||
}
|
||||
|
||||
@@ -142,9 +142,9 @@ func (h *Handlers) CreateNoteHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Redirect based on extension
|
||||
redirect := "/note/" + notePath
|
||||
redirect := h.config.URLPrefix + "/note/" + notePath
|
||||
if strings.ToLower(ext) != "md" {
|
||||
redirect = "/view_text/" + notePath
|
||||
redirect = h.config.URLPrefix + "/view_text/" + notePath
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -297,7 +297,7 @@ func (h *Handlers) EditNoteHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Note saved successfully",
|
||||
"redirect": "/note/" + notePath,
|
||||
"redirect": h.config.URLPrefix + "/note/" + notePath,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ func (h *Handlers) ProfilePage(c *gin.Context) {
|
||||
// Must be authenticated; middleware ensures user_id is set
|
||||
uidPtr := getUserIDPtr(c)
|
||||
if uidPtr == nil {
|
||||
c.Redirect(http.StatusFound, "/editor/login")
|
||||
c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@ func (h *Handlers) PostProfileEnableMFA(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "setup": true, "redirect": "/editor/profile/mfa/setup"})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "setup": true, "redirect": h.config.URLPrefix+"/editor/profile/mfa/setup"})
|
||||
}
|
||||
|
||||
// PostProfileDisableMFA clears the user's MFA secret
|
||||
@@ -611,16 +611,18 @@ func (h *Handlers) AdminEnableUserMFA(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
// Create or replace an enrollment so user is prompted on next login
|
||||
// Admin enable: set a new secret directly so MFA is immediately enabled
|
||||
secret, err := generateBase32Secret()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secret"})
|
||||
return
|
||||
}
|
||||
if _, err := h.authSvc.DB.Exec(`INSERT OR REPLACE INTO mfa_enrollments (user_id, secret) VALUES (?, ?)`, id, secret); err != nil {
|
||||
if _, err := h.authSvc.DB.Exec(`UPDATE users SET mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, secret, id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Remove any pending enrollment rows
|
||||
_, _ = h.authSvc.DB.Exec(`DELETE FROM mfa_enrollments WHERE user_id = ?`, id)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -890,7 +892,7 @@ func (h *Handlers) PostEditTextHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "redirect": "/view_text/" + filePath})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "redirect": h.config.URLPrefix + "/view_text/" + filePath})
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, store *sessions.CookieStore, authSvc *auth.Service) *Handlers {
|
||||
|
||||
@@ -30,10 +30,9 @@ func (h *Handlers) SettingsPageHandler(c *gin.Context) {
|
||||
"notes_tree": notesTree,
|
||||
"active_path": []string{},
|
||||
"current_note": nil,
|
||||
"breadcrumbs": []gin.H{
|
||||
{"name": "/", "url": "/"},
|
||||
{"name": "Settings", "url": ""},
|
||||
},
|
||||
"breadcrumbs": utils.GenerateBreadcrumbs(""),
|
||||
"Authenticated": isAuthenticated(c),
|
||||
"IsAdmin": isAdmin(c),
|
||||
"ContentTemplate": "settings_content",
|
||||
"ScriptsTemplate": "settings_scripts",
|
||||
"Page": "settings",
|
||||
|
||||
@@ -100,6 +100,11 @@ func (r *Renderer) processObsidianImages(content string, notePath string) string
|
||||
imageURL = fmt.Sprintf("/serve_attached_image/%s", cleanPath)
|
||||
}
|
||||
|
||||
// Prefix with configured URL base
|
||||
if bp := r.config.URLPrefix; bp != "" {
|
||||
imageURL = bp + imageURL
|
||||
}
|
||||
|
||||
// Convert to standard markdown image syntax
|
||||
alt := filepath.Base(imagePath)
|
||||
return fmt.Sprintf("", alt, imageURL)
|
||||
@@ -127,6 +132,9 @@ func (r *Renderer) processObsidianLinks(content string) string {
|
||||
// Convert note name to URL-friendly format
|
||||
noteURL := strings.ReplaceAll(noteName, " ", "%20")
|
||||
noteURL = fmt.Sprintf("/note/%s.md", noteURL)
|
||||
if bp := r.config.URLPrefix; bp != "" {
|
||||
noteURL = bp + noteURL
|
||||
}
|
||||
|
||||
// Convert to standard markdown link syntax
|
||||
return fmt.Sprintf("[%s](%s)", displayText, noteURL)
|
||||
|
||||
@@ -84,7 +84,7 @@ func (s *Server) CSRFRequire() gin.HandlerFunc {
|
||||
func (s *Server) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("user_id"); !exists {
|
||||
c.Redirect(http.StatusFound, "/editor/login")
|
||||
c.Redirect(http.StatusFound, s.config.URLPrefix+"/editor/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func (s *Server) RequireAuth() gin.HandlerFunc {
|
||||
func (s *Server) RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("user_id"); !exists {
|
||||
c.Redirect(http.StatusFound, "/editor/login")
|
||||
c.Redirect(http.StatusFound, s.config.URLPrefix+"/editor/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,28 +85,30 @@ func (s *Server) Start() error {
|
||||
|
||||
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
|
||||
s.router.GET("/", h.IndexHandler)
|
||||
s.router.GET("/folder/*path", h.FolderHandler)
|
||||
s.router.GET("/note/*path", h.NoteHandler)
|
||||
r.GET("/", h.IndexHandler)
|
||||
r.GET("/folder/*path", h.FolderHandler)
|
||||
r.GET("/note/*path", h.NoteHandler)
|
||||
|
||||
// File serving routes
|
||||
s.router.GET("/serve_attached_image/*path", h.ServeAttachedImageHandler)
|
||||
s.router.GET("/serve_stored_image/:filename", h.ServeStoredImageHandler)
|
||||
s.router.GET("/download/*path", h.DownloadHandler)
|
||||
s.router.GET("/view_text/*path", h.ViewTextHandler)
|
||||
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
|
||||
s.router.GET("/editor/login", h.LoginPage)
|
||||
s.router.POST("/editor/login", s.CSRFRequire(), h.LoginPost)
|
||||
s.router.POST("/editor/logout", s.RequireAuth(), s.CSRFRequire(), h.LogoutPost)
|
||||
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)
|
||||
s.router.GET("/editor/mfa", s.CSRFRequire(), h.MFALoginPage)
|
||||
s.router.POST("/editor/mfa", s.CSRFRequire(), h.MFALoginVerify)
|
||||
r.GET("/editor/mfa", s.CSRFRequire(), h.MFALoginPage)
|
||||
r.POST("/editor/mfa", s.CSRFRequire(), h.MFALoginVerify)
|
||||
|
||||
// New /editor group protected by auth + CSRF
|
||||
editor := s.router.Group("/editor", s.RequireAuth(), s.CSRFRequire())
|
||||
editor := r.Group("/editor", s.RequireAuth(), s.CSRFRequire())
|
||||
{
|
||||
editor.GET("/create", h.CreateNotePageHandler)
|
||||
editor.POST("/create", h.CreateNoteHandler)
|
||||
@@ -171,8 +173,8 @@ func (s *Server) setupRoutes() {
|
||||
}
|
||||
|
||||
// API routes
|
||||
s.router.GET("/api/tree", h.TreeAPIHandler)
|
||||
s.router.GET("/api/search", h.SearchHandler)
|
||||
r.GET("/api/tree", h.TreeAPIHandler)
|
||||
r.GET("/api/search", h.SearchHandler)
|
||||
}
|
||||
|
||||
func (s *Server) setupStaticFiles() {
|
||||
@@ -181,9 +183,9 @@ func (s *Server) setupStaticFiles() {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s.router.StaticFS("/static", http.FS(sub))
|
||||
s.router.StaticFS(s.config.URLPrefix+"/static", http.FS(sub))
|
||||
// Favicon from same sub FS
|
||||
s.router.StaticFileFS("/favicon.ico", "favicon.ico", http.FS(sub))
|
||||
s.router.StaticFileFS(s.config.URLPrefix+"/favicon.ico", "favicon.ico", http.FS(sub))
|
||||
}
|
||||
|
||||
func (s *Server) setupTemplates() {
|
||||
@@ -192,6 +194,24 @@ func (s *Server) setupTemplates() {
|
||||
"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 {
|
||||
|
||||
Reference in New Issue
Block a user