2025-08-25 08:48:52 +01:00
package handlers
import (
2025-08-25 21:19:15 +01:00
"database/sql"
"encoding/base64"
2025-08-25 08:48:52 +01:00
"fmt"
"io"
"net/http"
"os"
"path/filepath"
2025-08-25 21:19:15 +01:00
"strconv"
2025-08-25 08:48:52 +01:00
"strings"
2025-08-26 07:46:01 +01:00
"time"
2025-08-25 08:48:52 +01:00
2025-08-25 21:19:15 +01:00
"crypto/rand"
2025-08-25 08:48:52 +01:00
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"github.com/h2non/filetype"
2025-08-25 21:19:15 +01:00
"golang.org/x/crypto/bcrypt"
2025-08-25 08:48:52 +01:00
"gobsidian/internal/config"
2025-08-25 21:19:15 +01:00
"gobsidian/internal/auth"
2025-08-25 08:48:52 +01:00
"gobsidian/internal/markdown"
"gobsidian/internal/models"
"gobsidian/internal/utils"
)
type Handlers struct {
config * config . Config
store * sessions . CookieStore
renderer * markdown . Renderer
2025-08-25 21:19:15 +01:00
authSvc * auth . Service
2025-08-25 08:48:52 +01:00
}
2025-08-26 07:46:01 +01:00
// 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 } )
}
2025-08-25 21:19:15 +01:00
// 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 {
2025-08-26 20:55:08 +01:00
c . Redirect ( http . StatusFound , h . config . URLPrefix + "/editor/login" )
2025-08-25 21:19:15 +01:00
return
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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" ,
2025-08-25 18:32:31 +01:00
"app_name" : h . config . AppName ,
2025-08-25 21:19:15 +01:00
"message" : err . Error ( ) ,
2025-08-25 18:32:31 +01:00
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
} )
return
}
2025-08-25 21:19:15 +01:00
// 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" ,
2025-08-25 18:32:31 +01:00
"app_name" : h . config . AppName ,
2025-08-25 21:19:15 +01:00
"message" : err . Error ( ) ,
2025-08-25 18:32:31 +01:00
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
} )
return
}
2025-08-25 21:19:15 +01:00
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" } )
2025-08-25 18:32:31 +01:00
return
}
2025-08-25 21:19:15 +01:00
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 )
2025-08-25 18:32:31 +01:00
if err != nil {
2025-08-25 21:19:15 +01:00
c . JSON ( http . StatusInternalServerError , gin . H { "error" : "failed to hash password" } )
2025-08-25 18:32:31 +01:00
return
}
2025-08-25 21:19:15 +01:00
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 } )
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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 ( )
2025-08-25 18:32:31 +01:00
if err != nil {
2025-08-25 21:19:15 +01:00
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 ( ) } )
2025-08-25 18:32:31 +01:00
return
}
2025-08-26 20:55:08 +01:00
c . JSON ( http . StatusOK , gin . H { "success" : true , "setup" : true , "redirect" : h . config . URLPrefix + "/editor/profile/mfa/setup" } )
2025-08-25 21:19:15 +01:00
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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
2025-08-25 18:32:31 +01:00
}
2025-08-25 21:19:15 +01:00
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 } )
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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 } )
2025-08-25 18:32:31 +01:00
}
2025-08-25 21:19:15 +01:00
// 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 } )
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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 ( ) } )
2025-08-25 18:32:31 +01:00
return
}
2025-08-25 21:19:15 +01:00
c . JSON ( http . StatusOK , gin . H { "success" : true } )
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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 } )
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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" } )
2025-08-25 18:32:31 +01:00
return
}
2025-08-26 20:55:08 +01:00
// Admin enable: set a new secret directly so MFA is immediately enabled
2025-08-25 21:19:15 +01:00
secret , err := generateBase32Secret ( )
if err != nil {
c . JSON ( http . StatusInternalServerError , gin . H { "error" : "failed to generate secret" } )
return
}
2025-08-26 20:55:08 +01:00
if _ , err := h . authSvc . DB . Exec ( ` UPDATE users SET mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ` , secret , id ) ; err != nil {
2025-08-25 21:19:15 +01:00
c . JSON ( http . StatusInternalServerError , gin . H { "error" : err . Error ( ) } )
return
}
2025-08-26 20:55:08 +01:00
// Remove any pending enrollment rows
_ , _ = h . authSvc . DB . Exec ( ` DELETE FROM mfa_enrollments WHERE user_id = ? ` , id )
2025-08-25 21:19:15 +01:00
c . JSON ( http . StatusOK , gin . H { "success" : true } )
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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 } )
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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" } )
2025-08-25 18:32:31 +01:00
return
}
2025-08-25 21:19:15 +01:00
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 } )
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
// 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 ( ) } )
2025-08-25 18:32:31 +01:00
return
}
2025-08-25 21:19:15 +01:00
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 = ""
}
2025-08-25 18:32:31 +01:00
2025-08-25 21:19:15 +01:00
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" ,
} )
2025-08-25 18:32:31 +01:00
}
2025-08-25 21:19:15 +01:00
// 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 ) , 0 o755 ) ; err != nil {
c . JSON ( http . StatusInternalServerError , gin . H { "error" : "Failed to create parent directory" } )
return
}
if err := os . WriteFile ( fullPath , [ ] byte ( content ) , 0 o644 ) ; err != nil {
c . JSON ( http . StatusInternalServerError , gin . H { "error" : "Failed to save file" } )
return
}
2025-08-26 20:55:08 +01:00
c . JSON ( http . StatusOK , gin . H { "success" : true , "redirect" : h . config . URLPrefix + "/view_text/" + filePath } )
2025-08-25 21:19:15 +01:00
}
func New ( cfg * config . Config , store * sessions . CookieStore , authSvc * auth . Service ) * Handlers {
2025-08-25 08:48:52 +01:00
return & Handlers {
config : cfg ,
store : store ,
renderer : markdown . NewRenderer ( cfg ) ,
2025-08-25 21:19:15 +01:00
authSvc : authSvc ,
2025-08-25 08:48:52 +01:00
}
}
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 )
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusInternalServerError , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Failed to read directory" ,
"app_name" : h . config . AppName ,
"message" : err . Error ( ) ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
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 )
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusInternalServerError , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Failed to build tree structure" ,
"app_name" : h . config . AppName ,
"message" : err . Error ( ) ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
fmt . Printf ( "DEBUG: Tree structure built, app_name: %s\n" , h . config . AppName )
2025-08-25 21:19:15 +01:00
// 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
}
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusOK , "folder" , gin . H {
2025-08-25 08:48:52 +01:00
"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 ,
2025-08-25 21:19:15 +01:00
"Authenticated" : isAuthenticated ( c ) ,
"IsAdmin" : isAdmin ( c ) ,
2025-08-25 17:26:27 +01:00
"ContentTemplate" : "folder_content" ,
"ScriptsTemplate" : "folder_scripts" ,
"Page" : "folder" ,
2025-08-25 08:48:52 +01:00
} )
}
func ( h * Handlers ) FolderHandler ( c * gin . Context ) {
folderPath := strings . TrimPrefix ( c . Param ( "path" ) , "/" )
// Security check - prevent path traversal
if strings . Contains ( folderPath , ".." ) {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusBadRequest , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Invalid path" ,
"app_name" : h . config . AppName ,
"message" : "Path traversal is not allowed" ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
// Check if path is in skipped directories
if utils . IsPathInSkippedDirs ( folderPath , h . config . NotesDirSkip ) {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusForbidden , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Access denied" ,
"app_name" : h . config . AppName ,
"message" : "This directory is not accessible" ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
2025-08-25 21:19:15 +01:00
// 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
}
2025-08-25 08:48:52 +01:00
folderContents , err := utils . GetFolderContents ( folderPath , h . config )
if err != nil {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusNotFound , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Folder not found" ,
"app_name" : h . config . AppName ,
"message" : err . Error ( ) ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
notesTree , err := utils . BuildTreeStructure ( h . config . NotesDir , h . config . NotesDirHideSidepane , h . config )
if err != nil {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusInternalServerError , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Failed to build tree structure" ,
"app_name" : h . config . AppName ,
"message" : err . Error ( ) ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusOK , "folder" , gin . H {
2025-08-25 08:48:52 +01:00
"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 ,
2025-08-25 21:19:15 +01:00
"Authenticated" : isAuthenticated ( c ) ,
"IsAdmin" : isAdmin ( c ) ,
2025-08-25 17:26:27 +01:00
"ContentTemplate" : "folder_content" ,
"ScriptsTemplate" : "folder_scripts" ,
"Page" : "folder" ,
2025-08-25 08:48:52 +01:00
} )
}
func ( h * Handlers ) NoteHandler ( c * gin . Context ) {
notePath := strings . TrimPrefix ( c . Param ( "path" ) , "/" )
if ! strings . HasSuffix ( notePath , ".md" ) {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusBadRequest , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Invalid note path" ,
"app_name" : h . config . AppName ,
"message" : "Note path must end with .md" ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
// Security check
if strings . Contains ( notePath , ".." ) {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusBadRequest , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Invalid path" ,
"app_name" : h . config . AppName ,
"message" : "Path traversal is not allowed" ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
// Check if path is in skipped directories
if utils . IsPathInSkippedDirs ( notePath , h . config . NotesDirSkip ) {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusForbidden , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Access denied" ,
"app_name" : h . config . AppName ,
"message" : "This note is not accessible" ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
2025-08-25 21:19:15 +01:00
// 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
}
2025-08-25 08:48:52 +01:00
fullPath := filepath . Join ( h . config . NotesDir , notePath )
if _ , err := os . Stat ( fullPath ) ; os . IsNotExist ( err ) {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusNotFound , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Note not found" ,
"app_name" : h . config . AppName ,
"message" : "The requested note does not exist" ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
content , err := os . ReadFile ( fullPath )
if err != nil {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusInternalServerError , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Failed to read note" ,
"app_name" : h . config . AppName ,
"message" : err . Error ( ) ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
htmlContent , err := h . renderer . RenderMarkdown ( string ( content ) , notePath )
if err != nil {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusInternalServerError , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Failed to render markdown" ,
"app_name" : h . config . AppName ,
"message" : err . Error ( ) ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
notesTree , err := utils . BuildTreeStructure ( h . config . NotesDir , h . config . NotesDirHideSidepane , h . config )
if err != nil {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusInternalServerError , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Failed to build tree structure" ,
"app_name" : h . config . AppName ,
"message" : err . Error ( ) ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
title := strings . TrimSuffix ( filepath . Base ( notePath ) , ".md" )
folderPath := filepath . Dir ( notePath )
if folderPath == "." {
folderPath = ""
}
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusOK , "note" , gin . H {
2025-08-25 08:48:52 +01:00
"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 ) ,
2025-08-25 21:19:15 +01:00
"Authenticated" : isAuthenticated ( c ) ,
"IsAdmin" : isAdmin ( c ) ,
2025-08-25 17:26:27 +01:00
"ContentTemplate" : "note_content" ,
"ScriptsTemplate" : "note_scripts" ,
"Page" : "note" ,
2025-08-25 08:48:52 +01:00
} )
}
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
}
2025-08-25 21:19:15 +01:00
// 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
}
2025-08-25 08:48:52 +01:00
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
}
2025-08-25 21:19:15 +01:00
// 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.
2025-08-25 08:48:52 +01:00
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
}
2025-08-25 21:19:15 +01:00
// 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
}
2025-08-25 08:48:52 +01:00
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 , ".." ) {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusBadRequest , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Invalid path" ,
"app_name" : h . config . AppName ,
"message" : "Path traversal is not allowed" ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
2025-08-25 21:19:15 +01:00
// 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
}
2025-08-25 08:48:52 +01:00
fullPath := filepath . Join ( h . config . NotesDir , filePath )
if _ , err := os . Stat ( fullPath ) ; os . IsNotExist ( err ) {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusNotFound , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "File not found" ,
"app_name" : h . config . AppName ,
"message" : "The requested file does not exist" ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
// Check if file extension is allowed
if ! models . IsAllowedFile ( filePath , h . config . AllowedFileExtensions ) {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusForbidden , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"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" ,
2025-08-25 08:48:52 +01:00
} )
return
}
content , err := os . ReadFile ( fullPath )
if err != nil {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusInternalServerError , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Failed to read file" ,
"app_name" : h . config . AppName ,
"message" : err . Error ( ) ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
notesTree , err := utils . BuildTreeStructure ( h . config . NotesDir , h . config . NotesDirHideSidepane , h . config )
if err != nil {
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusInternalServerError , "error" , gin . H {
2025-08-25 17:26:27 +01:00
"error" : "Failed to build tree structure" ,
"app_name" : h . config . AppName ,
"message" : err . Error ( ) ,
"ContentTemplate" : "error_content" ,
"ScriptsTemplate" : "error_scripts" ,
"Page" : "error" ,
2025-08-25 08:48:52 +01:00
} )
return
}
folderPath := filepath . Dir ( filePath )
if folderPath == "." {
folderPath = ""
}
2025-08-25 18:32:31 +01:00
// 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
2025-08-25 09:44:14 +01:00
c . HTML ( http . StatusOK , "view_text" , gin . H {
2025-08-25 08:48:52 +01:00
"app_name" : h . config . AppName ,
"file_name" : filepath . Base ( filePath ) ,
"file_path" : filePath ,
"content" : string ( content ) ,
2025-08-25 18:32:31 +01:00
"file_ext" : strings . TrimPrefix ( strings . ToLower ( ext ) , "." ) ,
"is_editable" : isEditable ,
2025-08-25 08:48:52 +01:00
"folder_path" : folderPath ,
"notes_tree" : notesTree ,
"active_path" : utils . GetActivePath ( folderPath ) ,
"breadcrumbs" : utils . GenerateBreadcrumbs ( folderPath ) ,
2025-08-25 21:19:15 +01:00
"Authenticated" : isAuthenticated ( c ) ,
"IsAdmin" : isAdmin ( c ) ,
2025-08-25 17:26:27 +01:00
"ContentTemplate" : "view_text_content" ,
"ScriptsTemplate" : "view_text_scripts" ,
"Page" : "view_text" ,
2025-08-25 08:48:52 +01:00
} )
}
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 )
}
2025-08-25 18:02:41 +01:00
2025-08-25 21:19:15 +01:00
// 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" ,
} )
}
2025-08-25 18:02:41 +01:00
// 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 } )
}