package handlers import ( "database/sql" "encoding/base64" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "crypto/rand" "github.com/gin-gonic/gin" "github.com/gorilla/sessions" "github.com/h2non/filetype" "golang.org/x/crypto/bcrypt" "gobsidian/internal/config" "gobsidian/internal/auth" "gobsidian/internal/markdown" "gobsidian/internal/models" "gobsidian/internal/utils" ) type Handlers struct { config *config.Config store *sessions.CookieStore renderer *markdown.Renderer authSvc *auth.Service } // AdminBanIP bans an IP (temporary via hours or permanent) func (h *Handlers) AdminBanIP(c *gin.Context) { ip := strings.TrimSpace(c.PostForm("ip")) if ip == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"}) return } reason := strings.TrimSpace(c.PostForm("reason")) permStr := strings.TrimSpace(c.PostForm("permanent")) hoursStr := strings.TrimSpace(c.PostForm("hours")) permanent := 0 if permStr == "1" || strings.EqualFold(permStr, "true") { permanent = 1 } var until sql.NullTime if permanent == 0 && hoursStr != "" { if hInt, err := strconv.Atoi(hoursStr); err == nil && hInt > 0 { t := time.Now().Add(time.Duration(hInt) * time.Hour) until = sql.NullTime{Time: t, Valid: true} } } // Upsert ban if permanent == 1 { _, err := h.authSvc.DB.Exec(`INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, updated_at) VALUES (?, ?, NULL, 1, 0, CURRENT_TIMESTAMP) ON CONFLICT(ip) DO UPDATE SET reason=excluded.reason, until=NULL, permanent=1, whitelisted=0, updated_at=CURRENT_TIMESTAMP`, ip, nullIfEmpty(reason)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return } } else { _, err := h.authSvc.DB.Exec(`INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, updated_at) VALUES (?, ?, ?, 0, 0, CURRENT_TIMESTAMP) ON CONFLICT(ip) DO UPDATE SET reason=excluded.reason, until=excluded.until, permanent=0, whitelisted=0, updated_at=CURRENT_TIMESTAMP`, ip, nullIfEmpty(reason), until) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return } } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminUnbanIP removes a ban entry (and whitelist) for an IP func (h *Handlers) AdminUnbanIP(c *gin.Context) { ip := strings.TrimSpace(c.PostForm("ip")) if ip == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"}) return } if _, err := h.authSvc.DB.Exec(`DELETE FROM ip_bans WHERE ip = ?`, ip); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminWhitelistIP sets or clears whitelist for an IP func (h *Handlers) AdminWhitelistIP(c *gin.Context) { ip := strings.TrimSpace(c.PostForm("ip")) if ip == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"}) return } val := strings.TrimSpace(c.PostForm("value")) w := 0 if val == "1" || strings.EqualFold(val, "true") { w = 1 } // Ensure row exists and update flags _, err := h.authSvc.DB.Exec(`INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, updated_at) VALUES (?, NULL, NULL, 0, ?, CURRENT_TIMESTAMP) ON CONFLICT(ip) DO UPDATE SET whitelisted=excluded.whitelisted, permanent=CASE WHEN excluded.whitelisted=1 THEN 0 ELSE permanent END, until=CASE WHEN excluded.whitelisted=1 THEN NULL ELSE until END, updated_at=CURRENT_TIMESTAMP`, ip, w) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // helper: turn empty string to sql.NullString func nullIfEmpty(s string) sql.NullString { if strings.TrimSpace(s) == "" { return sql.NullString{Valid: false} } return sql.NullString{String: s, Valid: true} } // AdminLogsPage shows recent access, error, failed login, and IP ban entries func (h *Handlers) AdminLogsPage(c *gin.Context) { // Parse filters ipFilter := strings.TrimSpace(c.Query("ip")) hoursStr := strings.TrimSpace(c.Query("hours")) limitStr := strings.TrimSpace(c.Query("limit")) bansFilter := strings.TrimSpace(c.Query("bans")) // all|active|perma|whitelist // Defaults var lastSince *time.Time if hoursStr != "" { if hInt, err := strconv.Atoi(hoursStr); err == nil && hInt > 0 { t := time.Now().Add(-time.Duration(hInt) * time.Hour) lastSince = &t } } lim := 100 if limitStr != "" { if v, err := strconv.Atoi(limitStr); err == nil { if v <= 0 { lim = 100 } else if v > 500 { lim = 500 } else { lim = v } } } if bansFilter == "" { bansFilter = "all" } // Query recent entries with filters type accessRow struct { CreatedAt time.Time IP string Method string Path string Status int Duration int64 UserAgent string UserID sql.NullInt64 Username sql.NullString } type errorRow struct { CreatedAt time.Time IP sql.NullString Path sql.NullString Message string } type failedRow struct { CreatedAt time.Time IP string Username sql.NullString Type string UserID sql.NullInt64 } type banRow struct { IP string Reason sql.NullString Until sql.NullTime Permanent int Whitelisted int UpdatedAt time.Time } // Access logs access := []accessRow{} { sb := strings.Builder{} sb.WriteString("SELECT al.created_at, al.ip, al.method, al.path, al.status, al.duration_ms, al.user_agent, al.user_id, u.username FROM access_logs al LEFT JOIN users u ON u.id = al.user_id WHERE 1=1") args := []any{} if lastSince != nil { sb.WriteString(" AND al.created_at >= ?") args = append(args, *lastSince) } if ipFilter != "" { sb.WriteString(" AND al.ip LIKE ?") args = append(args, "%"+ipFilter+"%") } sb.WriteString(" ORDER BY al.created_at DESC LIMIT ") sb.WriteString(strconv.Itoa(lim)) if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil { defer rows.Close() for rows.Next() { var r accessRow if err := rows.Scan(&r.CreatedAt, &r.IP, &r.Method, &r.Path, &r.Status, &r.Duration, &r.UserAgent, &r.UserID, &r.Username); err == nil { access = append(access, r) } } } } // Error logs errors := []errorRow{} { sb := strings.Builder{} sb.WriteString("SELECT created_at, ip, path, message FROM error_logs WHERE 1=1") args := []any{} if lastSince != nil { sb.WriteString(" AND created_at >= ?") args = append(args, *lastSince) } if ipFilter != "" { sb.WriteString(" AND ip IS NOT NULL AND ip LIKE ?") args = append(args, "%"+ipFilter+"%") } sb.WriteString(" ORDER BY created_at DESC LIMIT ") sb.WriteString(strconv.Itoa(lim)) if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil { defer rows.Close() for rows.Next() { var r errorRow if err := rows.Scan(&r.CreatedAt, &r.IP, &r.Path, &r.Message); err == nil { errors = append(errors, r) } } } } // Failed logins failed := []failedRow{} { sb := strings.Builder{} sb.WriteString("SELECT created_at, ip, username, type, user_id FROM failed_logins WHERE 1=1") args := []any{} if lastSince != nil { sb.WriteString(" AND created_at >= ?") args = append(args, *lastSince) } if ipFilter != "" { sb.WriteString(" AND ip LIKE ?") args = append(args, "%"+ipFilter+"%") } sb.WriteString(" ORDER BY created_at DESC LIMIT ") sb.WriteString(strconv.Itoa(lim)) if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil { defer rows.Close() for rows.Next() { var r failedRow if err := rows.Scan(&r.CreatedAt, &r.IP, &r.Username, &r.Type, &r.UserID); err == nil { failed = append(failed, r) } } } } // IP bans bans := []banRow{} { sb := strings.Builder{} sb.WriteString("SELECT ip, reason, until, permanent, whitelisted, updated_at FROM ip_bans WHERE 1=1") args := []any{} switch strings.ToLower(bansFilter) { case "perma", "perm", "permanent": sb.WriteString(" AND permanent = 1") case "whitelist", "whitelisted": sb.WriteString(" AND whitelisted = 1") case "active": // Active means currently in effect and not whitelisted sb.WriteString(" AND whitelisted = 0 AND (permanent = 1 OR (until IS NOT NULL AND until > CURRENT_TIMESTAMP))") default: // all } if ipFilter != "" { sb.WriteString(" AND ip LIKE ?") args = append(args, "%"+ipFilter+"%") } sb.WriteString(" ORDER BY updated_at DESC LIMIT ") sb.WriteString(strconv.Itoa(lim)) if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil { defer rows.Close() for rows.Next() { var r banRow if err := rows.Scan(&r.IP, &r.Reason, &r.Until, &r.Permanent, &r.Whitelisted, &r.UpdatedAt); err == nil { bans = append(bans, r) } } } } c.HTML(http.StatusOK, "admin_logs", gin.H{ "app_name": h.config.AppName, "NoSidebar": true, "breadcrumbs": []gin.H{{"Name": "/", "URL": "/"}, {"Name": "Admin", "URL": "/editor/admin"}, {"Name": "Logs", "URL": ""}}, "Page": "admin_logs", "AccessLogs": access, "ErrorLogs": errors, "FailedLogins": failed, "IPBans": bans, // Echo filters back to template "FilterIP": ipFilter, "FilterHours": hoursStr, "FilterLimit": lim, "FilterBans": bansFilter, }) } // AdminClearAccessLogs clears access logs based on days parameter: 0=all, default 7 func (h *Handlers) AdminClearAccessLogs(c *gin.Context) { daysStr := strings.TrimSpace(c.PostForm("days")) days := 7 if daysStr != "" { if v, err := strconv.Atoi(daysStr); err == nil && v >= 0 { days = v } } var ( query string args []any ) if days == 0 { query = `DELETE FROM access_logs` } else { query = `DELETE FROM access_logs WHERE created_at < DATETIME('now', ? || ' days')` // negative days for SQLite modifier, e.g., '-7 days' args = append(args, fmt.Sprintf("-%d", days)) } if _, err := h.authSvc.DB.Exec(query, args...); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // ProfilePage renders the user profile page for the signed-in user 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, h.config.URLPrefix+"/editor/login") return } // Load notes tree for sidebar notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) if err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to build tree structure", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Fetch current user basic info var email string var mfa sql.NullString row := h.authSvc.DB.QueryRow(`SELECT email, mfa_secret FROM users WHERE id = ?`, *uidPtr) if err := row.Scan(&email, &mfa); err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to load profile", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } c.HTML(http.StatusOK, "profile", gin.H{ "app_name": h.config.AppName, "notes_tree": notesTree, "active_path": []string{}, "current_note": nil, "breadcrumbs": utils.GenerateBreadcrumbs(""), "Authenticated": true, "IsAdmin": isAdmin(c), "Email": email, "MFAEnabled": mfa.Valid && mfa.String != "", "ContentTemplate": "profile_content", "ScriptsTemplate": "profile_scripts", "Page": "profile", }) } // PostProfileChangePassword allows the user to change their password with current password verification func (h *Handlers) PostProfileChangePassword(c *gin.Context) { uidPtr := getUserIDPtr(c) if uidPtr == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) return } current := c.PostForm("current_password") newpw := c.PostForm("new_password") confirm := c.PostForm("confirm_password") if current == "" || newpw == "" || confirm == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "all password fields are required"}) return } if newpw != confirm { c.JSON(http.StatusBadRequest, gin.H{"error": "new password and confirmation do not match"}) return } if len(newpw) < 8 { c.JSON(http.StatusBadRequest, gin.H{"error": "password must be at least 8 characters"}) return } var pwHash string row := h.authSvc.DB.QueryRow(`SELECT password_hash FROM users WHERE id = ?`, *uidPtr) if err := row.Scan(&pwHash); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if err := bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(current)); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "current password is incorrect"}) return } // Hash new password newHashBytes, err := bcrypt.GenerateFromPassword([]byte(newpw), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } if _, err := h.authSvc.DB.Exec(`UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, string(newHashBytes), *uidPtr); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // PostProfileChangeEmail allows the user to update their email func (h *Handlers) PostProfileChangeEmail(c *gin.Context) { uidPtr := getUserIDPtr(c) if uidPtr == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) return } email := strings.TrimSpace(c.PostForm("email")) if email == "" || !strings.Contains(email, "@") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email"}) return } if _, err := h.authSvc.DB.Exec(`UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, email, *uidPtr); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // PostProfileEnableMFA generates and stores a new MFA secret for the user func (h *Handlers) PostProfileEnableMFA(c *gin.Context) { uidPtr := getUserIDPtr(c) if uidPtr == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) return } // Create or replace enrollment for this user 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 (?, ?)`, *uidPtr, secret); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } 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 func (h *Handlers) PostProfileDisableMFA(c *gin.Context) { uidPtr := getUserIDPtr(c) if uidPtr == nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) return } if _, err := h.authSvc.DB.Exec(`UPDATE users SET mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, *uidPtr); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminCreateUser creates a new user (admin only) func (h *Handlers) AdminCreateUser(c *gin.Context) { username := strings.TrimSpace(c.PostForm("username")) email := strings.TrimSpace(c.PostForm("email")) password := c.PostForm("password") if username == "" || email == "" || password == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "username, email and password are required"}) return } // hash password pwHashBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) return } if _, err := h.authSvc.DB.Exec(`INSERT INTO users (username, email, password_hash, is_active, email_confirmed) VALUES (?,?,?,?,?)`, username, email, string(pwHashBytes), 1, 1, ); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminDeleteUser deletes a user by id (admin only) func (h *Handlers) AdminDeleteUser(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"}) return } // prevent deleting own account if v, ok := c.Get("user_id"); ok { if uid, ok2 := v.(int64); ok2 && uid == id { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete your own account"}) return } } if _, err := h.authSvc.DB.Exec(`DELETE FROM user_groups WHERE user_id = ?`, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if _, err := h.authSvc.DB.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminSetUserActive enables or disables a user account func (h *Handlers) AdminSetUserActive(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"}) return } activeStr := c.PostForm("active") if activeStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing active value"}) return } var activeInt int64 if activeStr == "1" || strings.EqualFold(activeStr, "true") { activeInt = 1 } else if activeStr == "0" || strings.EqualFold(activeStr, "false") { activeInt = 0 } else { c.JSON(http.StatusBadRequest, gin.H{"error": "active must be 0/1 or true/false"}) return } if _, err := h.authSvc.DB.Exec(`UPDATE users SET is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, activeInt, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminDisableUserMFA clears mfa_secret to disable MFA func (h *Handlers) AdminDisableUserMFA(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"}) return } if _, err := h.authSvc.DB.Exec(`UPDATE users SET mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminResetUserMFA resets MFA by clearing secret (user must re-enroll) func (h *Handlers) AdminResetUserMFA(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"}) return } if _, err := h.authSvc.DB.Exec(`UPDATE users SET mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminEnableUserMFA generates a new MFA secret for the user func (h *Handlers) AdminEnableUserMFA(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"}) return } // 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(`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}) } // generateSecret returns a URL-safe random string func generateSecret() (string, error) { // reuse auth.randomToken-style but local implementation b := make([]byte, 20) if _, err := io.ReadFull(randReader{}, b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } // randReader wraps crypto/rand.Reader to satisfy io.Reader in a context where imports are at top type randReader struct{} func (randReader) Read(p []byte) (int, error) { return rand.Read(p) } // AdminCreateGroup creates a group func (h *Handlers) AdminCreateGroup(c *gin.Context) { name := strings.TrimSpace(c.PostForm("name")) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "group name required"}) return } if _, err := h.authSvc.DB.Exec(`INSERT OR IGNORE INTO groups (name) VALUES (?)`, name); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminDeleteGroup deletes a group by id func (h *Handlers) AdminDeleteGroup(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group id"}) return } if _, err := h.authSvc.DB.Exec(`DELETE FROM user_groups WHERE group_id = ?`, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if _, err := h.authSvc.DB.Exec(`DELETE FROM groups WHERE id = ?`, id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminAddUserToGroup links a user to a group func (h *Handlers) AdminAddUserToGroup(c *gin.Context) { userIDStr := c.PostForm("user_id") groupIDStr := c.PostForm("group_id") userID, err1 := strconv.ParseInt(userIDStr, 10, 64) groupID, err2 := strconv.ParseInt(groupIDStr, 10, 64) if err1 != nil || err2 != nil || userID <= 0 || groupID <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user_id or group_id"}) return } if _, err := h.authSvc.DB.Exec(`INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)`, userID, groupID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // AdminRemoveUserFromGroup unlinks a user from a group func (h *Handlers) AdminRemoveUserFromGroup(c *gin.Context) { userIDStr := c.PostForm("user_id") groupIDStr := c.PostForm("group_id") userID, err1 := strconv.ParseInt(userIDStr, 10, 64) groupID, err2 := strconv.ParseInt(groupIDStr, 10, 64) if err1 != nil || err2 != nil || userID <= 0 || groupID <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user_id or group_id"}) return } if _, err := h.authSvc.DB.Exec(`DELETE FROM user_groups WHERE user_id = ? AND group_id = ?`, userID, groupID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // isAuthenticated returns true if a user_id exists in the Gin context func isAuthenticated(c *gin.Context) bool { _, ok := c.Get("user_id") return ok } // getUserIDPtr returns a pointer to user_id from context or nil if unauthenticated func getUserIDPtr(c *gin.Context) *int64 { if v, ok := c.Get("user_id"); ok { if id, ok2 := v.(int64); ok2 { return &id } } return nil } // isAdmin returns true if the Gin context has is_admin flag set by middleware func isAdmin(c *gin.Context) bool { _, ok := c.Get("is_admin") return ok } // EditTextPageHandler renders an editor for allowed text files (json, html, xml, yaml, etc.) func (h *Handlers) EditTextPageHandler(c *gin.Context) { filePath := strings.TrimPrefix(c.Param("path"), "/") // Security check if strings.Contains(filePath, "..") { c.HTML(http.StatusBadRequest, "error", gin.H{ "error": "Invalid path", "app_name": h.config.AppName, "message": "Path traversal is not allowed", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Access control if allowed, err := h.authSvc.HasReadAccess(getUserIDPtr(c), filePath); err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Permission check failed", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } else if !allowed { c.HTML(http.StatusForbidden, "error", gin.H{ "error": "Access denied", "app_name": h.config.AppName, "message": "You do not have permission to view this file", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } fullPath := filepath.Join(h.config.NotesDir, filePath) // Ensure file exists if _, err := os.Stat(fullPath); os.IsNotExist(err) { c.HTML(http.StatusNotFound, "error", gin.H{ "error": "File not found", "app_name": h.config.AppName, "message": "The requested file does not exist", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Only allow editing of configured text file types (not markdown here) ext := filepath.Ext(fullPath) ftype := models.GetFileType(ext, h.config.AllowedImageExtensions, h.config.AllowedFileExtensions) if ftype != models.FileTypeText { c.HTML(http.StatusForbidden, "error", gin.H{ "error": "Editing not allowed", "app_name": h.config.AppName, "message": "This file type cannot be edited here", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Load content data, err := os.ReadFile(fullPath) if err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to read file", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Build notes tree notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) if err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to build notes tree", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } folderPath := filepath.Dir(filePath) if folderPath == "." { folderPath = "" } c.HTML(http.StatusOK, "edit_text", gin.H{ "app_name": h.config.AppName, "title": filepath.Base(filePath), "content": string(data), "file_path": filePath, "file_ext": strings.TrimPrefix(strings.ToLower(ext), "."), "folder_path": folderPath, "notes_tree": notesTree, "active_path": utils.GetActivePath(folderPath), "breadcrumbs": utils.GenerateBreadcrumbs(folderPath), "Authenticated": isAuthenticated(c), "IsAdmin": isAdmin(c), "ContentTemplate": "edit_text_content", "ScriptsTemplate": "edit_text_scripts", "Page": "edit_text", }) } // PostEditTextHandler saves changes to an allowed text file func (h *Handlers) PostEditTextHandler(c *gin.Context) { filePath := strings.TrimPrefix(c.Param("path"), "/") if strings.Contains(filePath, "..") { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path"}) return } // Access control if allowed, err := h.authSvc.HasReadAccess(getUserIDPtr(c), filePath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Permission check failed"}) return } else if !allowed { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } fullPath := filepath.Join(h.config.NotesDir, filePath) // Enforce allowed file type ext := filepath.Ext(fullPath) ftype := models.GetFileType(ext, h.config.AllowedImageExtensions, h.config.AllowedFileExtensions) if ftype != models.FileTypeText { c.JSON(http.StatusForbidden, gin.H{"error": "This file type cannot be edited"}) return } content := c.PostForm("content") // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create parent directory"}) return } if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"}) return } 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 { return &Handlers{ config: cfg, store: store, renderer: markdown.NewRenderer(cfg), authSvc: authSvc, } } func (h *Handlers) IndexHandler(c *gin.Context) { fmt.Printf("DEBUG: IndexHandler called\n") folderContents, err := utils.GetFolderContents("", h.config) if err != nil { fmt.Printf("DEBUG: Error getting folder contents: %v\n", err) c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to read directory", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } fmt.Printf("DEBUG: Found %d folder contents\n", len(folderContents)) notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) if err != nil { fmt.Printf("DEBUG: Error building tree structure: %v\n", err) c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to build tree structure", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } fmt.Printf("DEBUG: Tree structure built, app_name: %s\n", h.config.AppName) // Access control if allowed, err := h.authSvc.HasReadAccess(getUserIDPtr(c), ""); err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Permission check failed", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } else if !allowed { c.HTML(http.StatusForbidden, "error", gin.H{ "error": "Access denied", "app_name": h.config.AppName, "message": "You do not have permission to view this folder", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } c.HTML(http.StatusOK, "folder", gin.H{ "app_name": h.config.AppName, "folder_path": "", "folder_contents": folderContents, "notes_tree": notesTree, "active_path": []string{}, "current_note": nil, "breadcrumbs": utils.GenerateBreadcrumbs(""), "allowed_image_extensions": h.config.AllowedImageExtensions, "allowed_file_extensions": h.config.AllowedFileExtensions, "Authenticated": isAuthenticated(c), "IsAdmin": isAdmin(c), "ContentTemplate": "folder_content", "ScriptsTemplate": "folder_scripts", "Page": "folder", }) } func (h *Handlers) FolderHandler(c *gin.Context) { folderPath := strings.TrimPrefix(c.Param("path"), "/") // Security check - prevent path traversal if strings.Contains(folderPath, "..") { c.HTML(http.StatusBadRequest, "error", gin.H{ "error": "Invalid path", "app_name": h.config.AppName, "message": "Path traversal is not allowed", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Check if path is in skipped directories if utils.IsPathInSkippedDirs(folderPath, h.config.NotesDirSkip) { c.HTML(http.StatusForbidden, "error", gin.H{ "error": "Access denied", "app_name": h.config.AppName, "message": "This directory is not accessible", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Access control if allowed, err := h.authSvc.HasReadAccess(getUserIDPtr(c), folderPath); err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Permission check failed", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } else if !allowed { c.HTML(http.StatusForbidden, "error", gin.H{ "error": "Access denied", "app_name": h.config.AppName, "message": "You do not have permission to view this folder", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } folderContents, err := utils.GetFolderContents(folderPath, h.config) if err != nil { c.HTML(http.StatusNotFound, "error", gin.H{ "error": "Folder not found", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) if err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to build tree structure", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } c.HTML(http.StatusOK, "folder", gin.H{ "app_name": h.config.AppName, "folder_path": folderPath, "folder_contents": folderContents, "notes_tree": notesTree, "active_path": utils.GetActivePath(folderPath), "current_note": nil, "breadcrumbs": utils.GenerateBreadcrumbs(folderPath), "allowed_image_extensions": h.config.AllowedImageExtensions, "allowed_file_extensions": h.config.AllowedFileExtensions, "Authenticated": isAuthenticated(c), "IsAdmin": isAdmin(c), "ContentTemplate": "folder_content", "ScriptsTemplate": "folder_scripts", "Page": "folder", }) } func (h *Handlers) NoteHandler(c *gin.Context) { notePath := strings.TrimPrefix(c.Param("path"), "/") if !strings.HasSuffix(notePath, ".md") { c.HTML(http.StatusBadRequest, "error", gin.H{ "error": "Invalid note path", "app_name": h.config.AppName, "message": "Note path must end with .md", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Security check if strings.Contains(notePath, "..") { c.HTML(http.StatusBadRequest, "error", gin.H{ "error": "Invalid path", "app_name": h.config.AppName, "message": "Path traversal is not allowed", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Check if path is in skipped directories if utils.IsPathInSkippedDirs(notePath, h.config.NotesDirSkip) { c.HTML(http.StatusForbidden, "error", gin.H{ "error": "Access denied", "app_name": h.config.AppName, "message": "This note is not accessible", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Access control if allowed, err := h.authSvc.HasReadAccess(getUserIDPtr(c), notePath); err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Permission check failed", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } else if !allowed { c.HTML(http.StatusForbidden, "error", gin.H{ "error": "Access denied", "app_name": h.config.AppName, "message": "You do not have permission to view this note", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } fullPath := filepath.Join(h.config.NotesDir, notePath) if _, err := os.Stat(fullPath); os.IsNotExist(err) { c.HTML(http.StatusNotFound, "error", gin.H{ "error": "Note not found", "app_name": h.config.AppName, "message": "The requested note does not exist", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } content, err := os.ReadFile(fullPath) if err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to read note", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } htmlContent, err := h.renderer.RenderMarkdown(string(content), notePath) if err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to render markdown", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) if err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to build tree structure", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } title := strings.TrimSuffix(filepath.Base(notePath), ".md") folderPath := filepath.Dir(notePath) if folderPath == "." { folderPath = "" } c.HTML(http.StatusOK, "note", gin.H{ "app_name": h.config.AppName, "title": title, "content": htmlContent, "note_path": notePath, "folder_path": folderPath, "notes_tree": notesTree, "active_path": utils.GetActivePath(folderPath), "current_note": notePath, "breadcrumbs": utils.GenerateBreadcrumbs(folderPath), "Authenticated": isAuthenticated(c), "IsAdmin": isAdmin(c), "ContentTemplate": "note_content", "ScriptsTemplate": "note_scripts", "Page": "note", }) } func (h *Handlers) ServeAttachedImageHandler(c *gin.Context) { imagePath := strings.TrimPrefix(c.Param("path"), "/") // Security check if strings.Contains(imagePath, "..") { c.AbortWithStatus(http.StatusBadRequest) return } var fullPath string switch h.config.ImageStorageMode { case 1: // Root directory fullPath = filepath.Join(h.config.NotesDir, imagePath) case 3: // Same as note directory fullPath = filepath.Join(h.config.NotesDir, imagePath) case 4: // Subfolder of note directory fullPath = filepath.Join(h.config.NotesDir, imagePath) default: c.AbortWithStatus(http.StatusNotFound) return } // Check if file exists and is an image if _, err := os.Stat(fullPath); os.IsNotExist(err) { c.AbortWithStatus(http.StatusNotFound) return } // Access control if allowed, err := h.authSvc.HasReadAccess(getUserIDPtr(c), imagePath); err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } else if !allowed { c.AbortWithStatus(http.StatusForbidden) return } if !models.IsImageFile(filepath.Base(imagePath), h.config.AllowedImageExtensions) { c.AbortWithStatus(http.StatusForbidden) return } c.File(fullPath) } func (h *Handlers) ServeStoredImageHandler(c *gin.Context) { filename := c.Param("filename") // Security check if strings.Contains(filename, "..") || strings.Contains(filename, "/") { c.AbortWithStatus(http.StatusBadRequest) return } if h.config.ImageStorageMode != 2 { c.AbortWithStatus(http.StatusNotFound) return } fullPath := filepath.Join(h.config.ImageStoragePath, filename) if _, err := os.Stat(fullPath); os.IsNotExist(err) { c.AbortWithStatus(http.StatusNotFound) return } if !models.IsImageFile(filename, h.config.AllowedImageExtensions) { c.AbortWithStatus(http.StatusForbidden) return } // Access control (stored images referenced by pathless filenames are assumed public unless permissions exist for the referencing path) // We cannot infer the note path here, so we allow by default per policy. c.File(fullPath) } func (h *Handlers) DownloadHandler(c *gin.Context) { filePath := strings.TrimPrefix(c.Param("path"), "/") // Security check if strings.Contains(filePath, "..") { c.AbortWithStatus(http.StatusBadRequest) return } // Access control if allowed, err := h.authSvc.HasReadAccess(getUserIDPtr(c), filePath); err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } else if !allowed { c.AbortWithStatus(http.StatusForbidden) return } fullPath := filepath.Join(h.config.NotesDir, filePath) if _, err := os.Stat(fullPath); os.IsNotExist(err) { c.AbortWithStatus(http.StatusNotFound) return } filename := filepath.Base(filePath) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) c.File(fullPath) } func (h *Handlers) ViewTextHandler(c *gin.Context) { filePath := strings.TrimPrefix(c.Param("path"), "/") // Security check if strings.Contains(filePath, "..") { c.HTML(http.StatusBadRequest, "error", gin.H{ "error": "Invalid path", "app_name": h.config.AppName, "message": "Path traversal is not allowed", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Access control if allowed, err := h.authSvc.HasReadAccess(getUserIDPtr(c), filePath); err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Permission check failed", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } else if !allowed { c.HTML(http.StatusForbidden, "error", gin.H{ "error": "Access denied", "app_name": h.config.AppName, "message": "You do not have permission to view this file", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } fullPath := filepath.Join(h.config.NotesDir, filePath) if _, err := os.Stat(fullPath); os.IsNotExist(err) { c.HTML(http.StatusNotFound, "error", gin.H{ "error": "File not found", "app_name": h.config.AppName, "message": "The requested file does not exist", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } // Check if file extension is allowed if !models.IsAllowedFile(filePath, h.config.AllowedFileExtensions) { c.HTML(http.StatusForbidden, "error", gin.H{ "error": "File type not allowed", "app_name": h.config.AppName, "message": "This file type cannot be viewed", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } content, err := os.ReadFile(fullPath) if err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to read file", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) if err != nil { c.HTML(http.StatusInternalServerError, "error", gin.H{ "error": "Failed to build tree structure", "app_name": h.config.AppName, "message": err.Error(), "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "Page": "error", }) return } folderPath := filepath.Dir(filePath) if folderPath == "." { folderPath = "" } // Determine extension and whether file is editable as text ext := filepath.Ext(filePath) ftype := models.GetFileType(ext, h.config.AllowedImageExtensions, h.config.AllowedFileExtensions) isEditable := ftype == models.FileTypeText c.HTML(http.StatusOK, "view_text", gin.H{ "app_name": h.config.AppName, "file_name": filepath.Base(filePath), "file_path": filePath, "content": string(content), "file_ext": strings.TrimPrefix(strings.ToLower(ext), "."), "is_editable": isEditable, "folder_path": folderPath, "notes_tree": notesTree, "active_path": utils.GetActivePath(folderPath), "breadcrumbs": utils.GenerateBreadcrumbs(folderPath), "Authenticated": isAuthenticated(c), "IsAdmin": isAdmin(c), "ContentTemplate": "view_text_content", "ScriptsTemplate": "view_text_scripts", "Page": "view_text", }) } func (h *Handlers) UploadHandler(c *gin.Context) { // Parse multipart form if err := c.Request.ParseMultipartForm(h.config.MaxContentLength); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "File too large or invalid form data"}) return } // Get the upload path uploadPath := c.PostForm("path") if uploadPath == "" { uploadPath = "" } // Security check if strings.Contains(uploadPath, "..") { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid upload path"}) return } file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"}) return } defer file.Close() // Validate file type buffer := make([]byte, 512) if _, err := file.Read(buffer); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read file"}) return } file.Seek(0, 0) // Reset file pointer kind, _ := filetype.Match(buffer) if kind == filetype.Unknown { // Allow text files and other allowed extensions if !models.IsAllowedFile(header.Filename, append(h.config.AllowedImageExtensions, h.config.AllowedFileExtensions...)) { c.JSON(http.StatusBadRequest, gin.H{"error": "File type not allowed"}) return } } else { // Check if detected type is allowed isImageType := strings.HasPrefix(kind.MIME.Value, "image/") if !isImageType && !models.IsAllowedFile(header.Filename, h.config.AllowedFileExtensions) { c.JSON(http.StatusBadRequest, gin.H{"error": "File type not allowed"}) return } } // Determine upload directory var uploadDir string isImage := models.IsImageFile(header.Filename, h.config.AllowedImageExtensions) if isImage { // For images, use the configured storage mode storageInfo := utils.GetImageStorageInfo(uploadPath, h.config) uploadDir = storageInfo.StorageDir } else { // For other files, upload to the current folder uploadDir = filepath.Join(h.config.NotesDir, uploadPath) } // Ensure upload directory exists if err := utils.EnsureDir(uploadDir); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"}) return } // Create destination file destPath := filepath.Join(uploadDir, header.Filename) dest, err := os.Create(destPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create destination file"}) return } defer dest.Close() // Copy file content if _, err := io.Copy(dest, file); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"}) return } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "File uploaded successfully", "filename": header.Filename, "size": header.Size, }) } func (h *Handlers) TreeAPIHandler(c *gin.Context) { notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, notesTree) } // AdminPage renders a simple admin dashboard listing users, groups, and permissions func (h *Handlers) AdminPage(c *gin.Context) { // Query users users := make([]auth.User, 0, 32) if rows, err := h.authSvc.DB.Query(`SELECT id, username, email, password_hash, is_active, email_confirmed, mfa_secret, created_at, updated_at FROM users ORDER BY username`); err == nil { defer rows.Close() for rows.Next() { var u auth.User var mfa sql.NullString if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.IsActive, &u.EmailConfirmed, &mfa, &u.CreatedAt, &u.UpdatedAt); err == nil { u.MFASecret = mfa users = append(users, u) } } } // Query groups type Group struct{ ID int64; Name string } groups := make([]Group, 0, 16) if gr, err := h.authSvc.DB.Query(`SELECT id, name FROM groups ORDER BY name`); err == nil { defer gr.Close() for gr.Next() { var g Group if err := gr.Scan(&g.ID, &g.Name); err == nil { groups = append(groups, g) } } } // Query permissions type Permission struct { Group string Path string CanRead bool CanWrite bool CanDelete bool } perms := make([]Permission, 0, 64) if pr, err := h.authSvc.DB.Query(` SELECT g.name, p.path_prefix, p.can_read, p.can_write, p.can_delete FROM permissions p JOIN groups g ON g.id = p.group_id ORDER BY g.name, p.path_prefix`); err == nil { defer pr.Close() for pr.Next() { var name, path string var r, w, d int if err := pr.Scan(&name, &path, &r, &w, &d); err == nil { perms = append(perms, Permission{Group: name, Path: path, CanRead: r == 1, CanWrite: w == 1, CanDelete: d == 1}) } } } // Build tree for sidebar notesTree, _ := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) // current user id for UI restrictions (e.g., prevent self-delete) var currentUserID int64 if v, ok := c.Get("user_id"); ok { if id, ok2 := v.(int64); ok2 { currentUserID = id } } c.HTML(http.StatusOK, "admin", gin.H{ "app_name": h.config.AppName, "notes_tree": notesTree, "active_path": []string{}, "current_note": nil, "breadcrumbs": utils.GenerateBreadcrumbs(""), "Authenticated": true, "IsAdmin": true, "users": users, "groups": groups, "permissions": perms, "CurrentUserID": currentUserID, "ContentTemplate": "admin_content", "ScriptsTemplate": "admin_scripts", "Page": "admin", }) } // SearchHandler performs a simple full-text search across markdown and allowed text files // within the notes directory, honoring skipped directories. // GET /api/search?q=term func (h *Handlers) SearchHandler(c *gin.Context) { query := strings.TrimSpace(c.Query("q")) if query == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing query"}) return } // Case-insensitive search qLower := strings.ToLower(query) type Snippet struct { Line int `json:"line"` Preview string `json:"preview"` } type Result struct { Path string `json:"path"` Type string `json:"type"` // "md" or "text" Snippets []Snippet `json:"snippets"` } results := make([]Result, 0, 32) // Walk the notes directory err := filepath.Walk(h.config.NotesDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // skip on error } if info.IsDir() { // Compute relative dir path to check skip list rel, _ := filepath.Rel(h.config.NotesDir, path) rel = filepath.ToSlash(rel) if rel == "." { rel = "" } if utils.IsPathInSkippedDirs(rel, h.config.NotesDirSkip) { return filepath.SkipDir } return nil } // Compute relative file path relPath, _ := filepath.Rel(h.config.NotesDir, path) relPath = filepath.ToSlash(relPath) // Skip disallowed files ext := strings.ToLower(filepath.Ext(relPath)) isMD := ext == ".md" if !isMD && !models.IsAllowedFile(relPath, h.config.AllowedFileExtensions) { return nil } // Read file content (limit size to prevent huge memory usage) data, readErr := os.ReadFile(path) if readErr != nil { return nil } content := string(data) contentLower := strings.ToLower(content) if !strings.Contains(contentLower, qLower) { return nil } // Build snippets: show up to 3 matches with 2 lines of context lines := strings.Split(content, "\n") linesLower := strings.Split(contentLower, "\n") snippets := make([]Snippet, 0, 3) for i := 0; i < len(linesLower) && len(snippets) < 3; i++ { if strings.Contains(linesLower[i], qLower) { start := i - 2 if start < 0 { start = 0 } end := i + 2 if end >= len(lines) { end = len(lines) - 1 } // Join preview lines preview := strings.Join(lines[start:end+1], "\n") snippets = append(snippets, Snippet{Line: i + 1, Preview: preview}) } } rtype := "text" if isMD { rtype = "md" } results = append(results, Result{Path: relPath, Type: rtype, Snippets: snippets}) return nil }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"query": query, "results": results}) }