diff --git a/.gitignore b/.gitignore index 4756a96..ce23271 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ __pycache__/ *.sqlite3 *.log *.bak + +./gobsidian \ No newline at end of file diff --git a/gobsidian b/gobsidian deleted file mode 100755 index 0d861eb..0000000 Binary files a/gobsidian and /dev/null differ diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 5c6e026..7372627 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -14,6 +14,7 @@ import ( "time" "github.com/gin-gonic/gin" + "gobsidian/internal/utils" ) const sessionCookieName = "gobsidian_session" @@ -26,9 +27,12 @@ func (h *Handlers) LoginPage(c *gin.Context) { return } token, _ := c.Get("csrf_token") + // propagate return_to if provided + returnTo := c.Query("return_to") c.HTML(http.StatusOK, "login", gin.H{ "app_name": h.config.AppName, "csrf_token": token, + "return_to": returnTo, "ContentTemplate": "login_content", "ScriptsTemplate": "login_scripts", "Page": "login", @@ -192,8 +196,17 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) { // success: set user_id and clear mfa_user_id delete(session.Values, "mfa_user_id") session.Values["user_id"] = uid + // use return_to if set in session + var dest string + if v, ok := session.Values["return_to"].(string); ok { + dest = sanitizeReturnTo(h.config.URLPrefix, v) + delete(session.Values, "return_to") + } _ = session.Save(c.Request, c.Writer) - c.Redirect(http.StatusFound, h.config.URLPrefix+"/") + if dest == "" { + dest = h.config.URLPrefix + "/" + } + c.Redirect(http.StatusFound, dest) } // ProfileMFASetupPage shows QR and input to verify during enrollment @@ -223,9 +236,16 @@ func (h *Handlers) ProfileMFASetupPage(c *gin.Context) { label := url.PathEscape(fmt.Sprintf("%s:%s", issuer, username)) otpauth := fmt.Sprintf("otpauth://totp/%s?secret=%s&issuer=%s&digits=6&period=30&algorithm=SHA1", label, secret, url.QueryEscape(issuer)) - // Render simple page (uses base.html shell) + // Build sidebar tree for consistent UI and pass auth flags + notesTree, _ := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) c.HTML(http.StatusOK, "mfa_setup", gin.H{ "app_name": h.config.AppName, + "notes_tree": notesTree, + "active_path": []string{}, + "current_note": nil, + "breadcrumbs": utils.GenerateBreadcrumbs(""), + "Authenticated": true, + "IsAdmin": isAdmin(c), "Secret": secret, "OTPAuthURI": otpauth, "ContentTemplate": "mfa_setup_content", @@ -313,6 +333,7 @@ func verifyTOTP(base32Secret, code string, t time.Time) bool { func (h *Handlers) LoginPost(c *gin.Context) { username := c.PostForm("username") password := c.PostForm("password") + returnTo := strings.TrimSpace(c.PostForm("return_to")) user, err := h.authSvc.Authenticate(username, password) if err != nil { @@ -323,6 +344,7 @@ func (h *Handlers) LoginPost(c *gin.Context) { "app_name": h.config.AppName, "csrf_token": token, "error": err.Error(), + "return_to": returnTo, "ContentTemplate": "login_content", "ScriptsTemplate": "login_scripts", "Page": "login", @@ -333,28 +355,30 @@ func (h *Handlers) LoginPost(c *gin.Context) { if user.MFASecret.Valid && user.MFASecret.String != "" { session, _ := h.store.Get(c.Request, sessionCookieName) session.Values["mfa_user_id"] = user.ID + if rt := sanitizeReturnTo(h.config.URLPrefix, returnTo); rt != "" { + session.Values["return_to"] = rt + } _ = session.Save(c.Request, c.Writer) c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/mfa") return } - // If admin created an enrollment for this user, force MFA setup after login - var pending int - if err := h.authSvc.DB.QueryRow(`SELECT 1 FROM mfa_enrollments WHERE user_id = ?`, user.ID).Scan(&pending); err == nil { - // normal login, then redirect to setup - session, _ := h.store.Get(c.Request, sessionCookieName) - session.Values["user_id"] = user.ID - _ = session.Save(c.Request, c.Writer) - c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/profile/mfa/setup") - return - } + // Do NOT automatically force MFA setup just because an enrollment row exists. + // Some deployments may leave stale enrollment rows; we only require MFA when + // the user actually has MFA enabled (mfa_secret set) or when they explicitly + // navigate to setup from profile. // Create normal session session, _ := h.store.Get(c.Request, sessionCookieName) session.Values["user_id"] = user.ID _ = session.Save(c.Request, c.Writer) - c.Redirect(http.StatusFound, h.config.URLPrefix+"/") + // Redirect to requested page if provided and safe; otherwise home + if rt := sanitizeReturnTo(h.config.URLPrefix, returnTo); rt != "" { + c.Redirect(http.StatusFound, rt) + } else { + c.Redirect(http.StatusFound, h.config.URLPrefix+"/") + } } // LogoutPost clears the session @@ -364,3 +388,34 @@ func (h *Handlers) LogoutPost(c *gin.Context) { _ = session.Save(c.Request, c.Writer) c.Redirect(http.StatusFound, h.config.URLPrefix+"/editor/login") } + +// sanitizeReturnTo ensures the provided return_to is a safe in-app path. +// It rejects absolute URLs and protocol-relative URLs. When URLPrefix is set, +// it enforces that the destination stays within that prefix; if a bare +// "/..." path is provided, it will be rewritten to include the prefix. +func sanitizeReturnTo(prefix, v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + // Disallow absolute and protocol-relative URLs + if strings.HasPrefix(v, "//") { + return "" + } + if u, err := url.Parse(v); err != nil || (u != nil && u.IsAbs()) { + return "" + } + // Must be a path + if !strings.HasPrefix(v, "/") { + v = "/" + v + } + // Enforce prefix containment when configured + if prefix != "" { + if strings.HasPrefix(v, prefix+"/") || v == prefix || v == prefix+"/" { + return v + } + // If it's a root-relative path without prefix, rewrite into prefix + return prefix + v + } + return v +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go index a4872b7..5b7bd28 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/base64" "net/http" + "net/url" "time" "github.com/gin-gonic/gin" @@ -84,7 +85,17 @@ 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, s.config.URLPrefix+"/editor/login") + // Attach return_to so user can be redirected back after login + requested := c.Request.URL.RequestURI() + q := url.Values{} + if requested != "" { + q.Set("return_to", requested) + } + loginURL := s.config.URLPrefix + "/editor/login" + if qs := q.Encode(); qs != "" { + loginURL = loginURL + "?" + qs + } + c.Redirect(http.StatusFound, loginURL) c.Abort() return } @@ -96,7 +107,16 @@ 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, s.config.URLPrefix+"/editor/login") + requested := c.Request.URL.RequestURI() + q := url.Values{} + if requested != "" { + q.Set("return_to", requested) + } + loginURL := s.config.URLPrefix + "/editor/login" + if qs := q.Encode(); qs != "" { + loginURL = loginURL + "?" + qs + } + c.Redirect(http.StatusFound, loginURL) c.Abort() return } diff --git a/web/templates/base.html b/web/templates/base.html index aef09e8..3c6c0ef 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -328,13 +328,18 @@ {{if .breadcrumbs}}
-
+ {{if .Authenticated}}
+ {{end}} @@ -69,15 +71,17 @@
- {{if eq .Type "md"}} - - - - {{end}} - {{if eq .Type "text"}} - - - + {{if $.Authenticated}} + {{if eq .Type "md"}} + + + + {{end}} + {{if eq .Type "text"}} + + + + {{end}} {{end}} {{if eq .Type "image"}} @@ -89,9 +93,11 @@ {{end}} - + {{if $.Authenticated}} + + {{end}}
@@ -132,39 +138,47 @@ let deleteTarget = null; // Toggle upload area - uploadBtn.addEventListener('click', function() { - uploadArea.classList.toggle('hidden'); - }); + if (uploadBtn) { + uploadBtn.addEventListener('click', function() { + uploadArea && uploadArea.classList.toggle('hidden'); + }); + } // File selection - selectFilesBtn.addEventListener('click', function() { - fileInput.click(); - }); + if (selectFilesBtn && fileInput) { + selectFilesBtn.addEventListener('click', function() { + fileInput.click(); + }); + } - fileInput.addEventListener('change', function() { - if (this.files.length > 0) { - uploadFiles(this.files); - } - }); + if (fileInput) { + fileInput.addEventListener('change', function() { + if (this.files.length > 0) { + uploadFiles(this.files); + } + }); + } // Drag and drop - uploadArea.addEventListener('dragover', function(e) { - e.preventDefault(); - this.classList.add('dragover'); - }); + if (uploadArea) { + uploadArea.addEventListener('dragover', function(e) { + e.preventDefault(); + this.classList.add('dragover'); + }); - uploadArea.addEventListener('dragleave', function(e) { - e.preventDefault(); - this.classList.remove('dragover'); - }); + uploadArea.addEventListener('dragleave', function(e) { + e.preventDefault(); + this.classList.remove('dragover'); + }); - uploadArea.addEventListener('drop', function(e) { - e.preventDefault(); - this.classList.remove('dragover'); - if (e.dataTransfer.files.length > 0) { - uploadFiles(e.dataTransfer.files); - } - }); + uploadArea.addEventListener('drop', function(e) { + e.preventDefault(); + this.classList.remove('dragover'); + if (e.dataTransfer.files.length > 0) { + uploadFiles(e.dataTransfer.files); + } + }); + } // Upload files function function uploadFiles(files) { diff --git a/web/templates/login.html b/web/templates/login.html index ae3da08..eb75178 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -10,6 +10,9 @@ {{end}}
+ {{if .return_to}} + + {{end}}