1733 lines
57 KiB
Go
1733 lines
57 KiB
Go
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})
|
|
}
|