diff --git a/go.mod b/go.mod
index 072dee5..47cbcd2 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,8 @@
module gobsidian
-go 1.21
+go 1.23.0
+
+toolchain go1.24.5
require (
github.com/alecthomas/chroma/v2 v2.8.0
@@ -9,34 +11,43 @@ require (
github.com/h2non/filetype v1.1.3
github.com/yuin/goldmark v1.6.0
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
+ golang.org/x/crypto v0.9.0
gopkg.in/ini.v1 v1.67.0
+ modernc.org/sqlite v1.38.2
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
- github.com/mattn/go-isatty v0.0.19 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
- golang.org/x/crypto v0.9.0 // indirect
+ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.10.0 // indirect
- golang.org/x/sys v0.8.0 // indirect
+ golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+ modernc.org/libc v1.66.3 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
)
diff --git a/go.sum b/go.sum
index 3c9b088..701d8a2 100644
--- a/go.sum
+++ b/go.sum
@@ -19,6 +19,8 @@ github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -36,9 +38,14 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
@@ -54,17 +61,21 @@ github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZX
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
-github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
-github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -90,15 +101,22 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
@@ -110,4 +128,30 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
+modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
+modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
+modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/internal/auth/service.go b/internal/auth/service.go
new file mode 100644
index 0000000..3176cfa
--- /dev/null
+++ b/internal/auth/service.go
@@ -0,0 +1,385 @@
+package auth
+
+import (
+ "crypto/rand"
+ "database/sql"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "golang.org/x/crypto/bcrypt"
+ _ "modernc.org/sqlite"
+
+ "gobsidian/internal/config"
+)
+
+type Service struct {
+ DB *sql.DB
+ Config *config.Config
+}
+
+// HasReadAccess determines if a user (or the public) can read a given path.
+// Semantics:
+// - If no permission rows exist matching the path (by prefix), access is ALLOWED by default.
+// - If permission rows exist for the path:
+// - Access is allowed if any matching row has can_read=1 and the user belongs to that group.
+// - Unauthenticated users are treated as belonging only to the implicit 'public' group.
+// - Any row for group name 'public' grants access to everyone.
+// Path matching uses prefix match on path_prefix.
+func (s *Service) HasReadAccess(userID *int64, path string) (bool, error) {
+ // First check if any permission rows exist for this path prefix
+ var total int
+ err := s.DB.QueryRow(`
+ SELECT COUNT(1)
+ FROM permissions p
+ WHERE ? LIKE p.path_prefix || '%'
+ `, path).Scan(&total)
+ if err != nil {
+ return false, err
+ }
+ // Default allow when no permissions defined for this path
+ if total == 0 {
+ return true, nil
+ }
+
+ // If user is nil (public), allow if any matching permission for group 'public' with can_read
+ if userID == nil {
+ var exists int
+ err := s.DB.QueryRow(`
+ SELECT 1
+ FROM permissions p
+ JOIN groups g ON g.id = p.group_id
+ WHERE p.can_read = 1
+ AND ? LIKE p.path_prefix || '%'
+ AND g.name = 'public'
+ LIMIT 1
+ `, path).Scan(&exists)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+ }
+
+ // For authenticated users, allow if any matching row with can_read and group's either 'public' or user is member
+ var exists int
+ err = s.DB.QueryRow(`
+ SELECT 1
+ FROM permissions p
+ JOIN groups g ON g.id = p.group_id
+ LEFT JOIN user_groups ug ON ug.group_id = g.id AND ug.user_id = ?
+ WHERE p.can_read = 1
+ AND ? LIKE p.path_prefix || '%'
+ AND (g.name = 'public' OR ug.user_id IS NOT NULL)
+ LIMIT 1
+ `, *userID, path).Scan(&exists)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
+
+// IsUserInGroup returns true if the given user is a member of the given group name.
+func (s *Service) IsUserInGroup(userID int64, groupName string) (bool, error) {
+ var exists int
+ err := s.DB.QueryRow(`
+ SELECT 1
+ FROM user_groups ug
+ JOIN groups g ON g.id = ug.group_id
+ WHERE ug.user_id = ? AND g.name = ?
+ LIMIT 1
+ `, userID, groupName).Scan(&exists)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
+
+type User struct {
+ ID int64
+ Username string
+ Email string
+ PasswordHash string
+ IsActive bool
+ EmailConfirmed bool
+ MFASecret sql.NullString
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+func Open(cfg *config.Config) (*Service, error) {
+ if strings.ToLower(cfg.DBType) != "sqlite" {
+ return nil, fmt.Errorf("unsupported DB type: %s", cfg.DBType)
+ }
+ dsn := cfg.DBPath
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil {
+ return nil, err
+ }
+ s := &Service{DB: db, Config: cfg}
+ if err := s.migrate(); err != nil {
+ return nil, err
+ }
+ if err := s.ensureDefaultAdmin(); err != nil {
+ return nil, err
+ }
+ return s, nil
+}
+
+func (s *Service) migrate() error {
+ stmts := []string{
+ `CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL,
+ is_active INTEGER NOT NULL DEFAULT 1,
+ email_confirmed INTEGER NOT NULL DEFAULT 0,
+ mfa_secret TEXT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )` ,
+ `CREATE TABLE IF NOT EXISTS groups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE NOT NULL
+ )` ,
+ `CREATE TABLE IF NOT EXISTS user_groups (
+ user_id INTEGER NOT NULL,
+ group_id INTEGER NOT NULL,
+ PRIMARY KEY(user_id, group_id),
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE
+ )` ,
+ `CREATE TABLE IF NOT EXISTS permissions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ group_id INTEGER NOT NULL,
+ path_prefix TEXT NOT NULL,
+ can_read INTEGER NOT NULL DEFAULT 1,
+ can_write INTEGER NOT NULL DEFAULT 0,
+ can_delete INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE
+ )` ,
+ `CREATE TABLE IF NOT EXISTS email_verification_tokens (
+ user_id INTEGER NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ expires_at DATETIME NOT NULL,
+ PRIMARY KEY(user_id),
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )` ,
+ `CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ user_id INTEGER NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ expires_at DATETIME NOT NULL,
+ PRIMARY KEY(user_id),
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )` ,
+ `CREATE TABLE IF NOT EXISTS mfa_enrollments (
+ user_id INTEGER PRIMARY KEY,
+ secret TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )` ,
+ }
+ for _, stmt := range stmts {
+ if _, err := s.DB.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Service) ensureDefaultAdmin() error {
+ tx, err := s.DB.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ _ = tx.Rollback()
+ }
+ }()
+
+ // Ensure groups exist
+ if _, err = tx.Exec(`INSERT OR IGNORE INTO groups (name) VALUES (?), (?)`, "admin", "public"); err != nil {
+ return err
+ }
+
+ // Ensure admin user exists
+ var adminID int64
+ err = tx.QueryRow(`SELECT id FROM users WHERE username = ?`, "admin").Scan(&adminID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ pwHash, e := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
+ if e != nil {
+ return e
+ }
+ res, e := tx.Exec(`INSERT INTO users (username, email, password_hash, is_active, email_confirmed) VALUES (?,?,?,?,?)`,
+ "admin", "admin@local", string(pwHash), 1, 1,
+ )
+ if e != nil {
+ return e
+ }
+ adminID, err = res.LastInsertId()
+ if err != nil {
+ return err
+ }
+ } else {
+ return err
+ }
+ }
+
+ // Ensure admin group id
+ var adminGroupID int64
+ if err = tx.QueryRow(`SELECT id FROM groups WHERE name = ?`, "admin").Scan(&adminGroupID); err != nil {
+ return err
+ }
+
+ // Ensure membership admin -> admin group
+ if _, err = tx.Exec(`INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)`, adminID, adminGroupID); err != nil {
+ return err
+ }
+
+ // Commit
+ if err = tx.Commit(); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *Service) Authenticate(usernameOrEmail, password string) (*User, error) {
+ u := &User{}
+ row := s.DB.QueryRow(`SELECT id, username, email, password_hash, is_active, email_confirmed, mfa_secret, created_at, updated_at FROM users WHERE username = ? OR email = ?`, usernameOrEmail, usernameOrEmail)
+ var mfa sql.NullString
+ if err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &u.IsActive, &u.EmailConfirmed, &mfa, &u.CreatedAt, &u.UpdatedAt); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, errors.New("invalid credentials")
+ }
+ return nil, err
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
+ return nil, errors.New("invalid credentials")
+ }
+ u.MFASecret = mfa
+ if !u.IsActive {
+ return nil, errors.New("account not active")
+ }
+ if s.Config.RequireEmailConfirmation && !u.EmailConfirmed {
+ return nil, errors.New("email not confirmed")
+ }
+ return u, nil
+}
+
+// CSRF utilities
+const csrfSessionKey = "csrf_token"
+const csrfHeader = "X-CSRF-Token"
+const csrfFormField = "csrf_token"
+
+func randomToken(n int) (string, error) {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func IssueCSRF() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ sessAny, exists := c.Get("session")
+ if !exists {
+ c.Next()
+ return
+ }
+ sess, _ := sessAny.(map[string]interface{})
+ var token string
+ if v, ok := sess[csrfSessionKey].(string); ok && v != "" {
+ token = v
+ } else {
+ var err error
+ token, err = randomToken(32)
+ if err == nil {
+ sess[csrfSessionKey] = token
+ }
+ }
+ c.Set("csrf_token", token)
+ c.Next()
+ }
+}
+
+func RequireCSRF() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions {
+ c.Next()
+ return
+ }
+ var token string
+ // header first
+ if h := c.GetHeader(csrfHeader); h != "" {
+ token = h
+ } else {
+ token = c.PostForm(csrfFormField)
+ }
+ sessAny, exists := c.Get("session")
+ if !exists {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "missing session for CSRF"})
+ return
+ }
+ sess, _ := sessAny.(map[string]interface{})
+ if expected, ok := sess[csrfSessionKey].(string); !ok || expected == "" || token == "" || !hmacEqual(expected, token) {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid CSRF token"})
+ return
+ }
+ c.Next()
+ }
+}
+
+func hmacEqual(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ var v byte
+ for i := 0; i < len(a); i++ {
+ v |= a[i] ^ b[i]
+ }
+ return v == 0
+}
+
+// Session helpers
+const sessionCookieName = "gobsidian_session"
+
+// AttachSession should be installed early to create a simple session map backed by cookie store in handlers package
+func AttachSession() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Handlers already use gorilla sessions. Here we just ensure a map exists to carry CSRF and auth info for templates.
+ if _, exists := c.Get("session"); !exists {
+ c.Set("session", map[string]interface{}{})
+ }
+ c.Next()
+ }
+}
+
+// RequireAuth checks presence of user_id in context
+func RequireAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if _, exists := c.Get("user_id"); !exists {
+ c.Redirect(http.StatusFound, "/editor/login")
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 6d88899..c8b4486 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -35,6 +35,23 @@ type Config struct {
ShowFilesInTree bool
ShowImagesInFolder bool
ShowFilesInFolder bool
+
+ // Database settings
+ DBType string
+ DBPath string
+
+ // Auth settings
+ RequireAdminActivation bool
+ RequireEmailConfirmation bool
+ MFAEnabledByDefault bool
+
+ // Email (SMTP) settings
+ SMTPHost string
+ SMTPPort int
+ SMTPUsername string
+ SMTPPassword string
+ SMTPSender string
+ SMTPUseTLS bool
}
var defaultConfig = map[string]map[string]string{
@@ -62,6 +79,23 @@ var defaultConfig = map[string]map[string]string{
"SHOW_IMAGES_IN_FOLDER": "true",
"SHOW_FILES_IN_FOLDER": "true",
},
+ "DATABASE": {
+ "TYPE": "sqlite",
+ "PATH": "data/gobsidian.db",
+ },
+ "AUTH": {
+ "REQUIRE_ADMIN_ACTIVATION": "true",
+ "REQUIRE_EMAIL_CONFIRMATION": "true",
+ "MFA_ENABLED_BY_DEFAULT": "false",
+ },
+ "EMAIL": {
+ "SMTP_HOST": "",
+ "SMTP_PORT": "587",
+ "SMTP_USERNAME": "",
+ "SMTP_PASSWORD": "",
+ "SMTP_SENDER": "",
+ "SMTP_USE_TLS": "true",
+ },
}
func Load() (*Config, error) {
@@ -130,6 +164,36 @@ func Load() (*Config, error) {
config.ImageStoragePath = filepath.Join(wd, config.ImageStoragePath)
}
+ // Load DATABASE section
+ dbSection := cfg.Section("DATABASE")
+ config.DBType = strings.ToLower(strings.TrimSpace(dbSection.Key("TYPE").String()))
+ config.DBPath = dbSection.Key("PATH").String()
+ if config.DBType == "sqlite" {
+ if !filepath.IsAbs(config.DBPath) {
+ wd, _ := os.Getwd()
+ config.DBPath = filepath.Join(wd, config.DBPath)
+ }
+ // ensure parent dir exists
+ if err := os.MkdirAll(filepath.Dir(config.DBPath), 0o755); err != nil {
+ return nil, fmt.Errorf("failed to create db directory: %w", err)
+ }
+ }
+
+ // Load AUTH section
+ authSection := cfg.Section("AUTH")
+ config.RequireAdminActivation, _ = authSection.Key("REQUIRE_ADMIN_ACTIVATION").Bool()
+ config.RequireEmailConfirmation, _ = authSection.Key("REQUIRE_EMAIL_CONFIRMATION").Bool()
+ config.MFAEnabledByDefault, _ = authSection.Key("MFA_ENABLED_BY_DEFAULT").Bool()
+
+ // Load EMAIL (SMTP) section
+ emailSection := cfg.Section("EMAIL")
+ config.SMTPHost = emailSection.Key("SMTP_HOST").String()
+ config.SMTPPort, _ = emailSection.Key("SMTP_PORT").Int()
+ config.SMTPUsername = emailSection.Key("SMTP_USERNAME").String()
+ config.SMTPPassword = emailSection.Key("SMTP_PASSWORD").String()
+ config.SMTPSender = emailSection.Key("SMTP_SENDER").String()
+ config.SMTPUseTLS, _ = emailSection.Key("SMTP_USE_TLS").Bool()
+
return config, nil
}
@@ -265,6 +329,39 @@ func (c *Config) SaveSetting(section, key, value string) error {
case "SHOW_FILES_IN_FOLDER":
c.ShowFilesInFolder = value == "true"
}
+ case "DATABASE":
+ switch key {
+ case "TYPE":
+ c.DBType = strings.ToLower(strings.TrimSpace(value))
+ case "PATH":
+ c.DBPath = value
+ }
+ case "AUTH":
+ switch key {
+ case "REQUIRE_ADMIN_ACTIVATION":
+ c.RequireAdminActivation = value == "true"
+ case "REQUIRE_EMAIL_CONFIRMATION":
+ c.RequireEmailConfirmation = value == "true"
+ case "MFA_ENABLED_BY_DEFAULT":
+ c.MFAEnabledByDefault = value == "true"
+ }
+ case "EMAIL":
+ switch key {
+ case "SMTP_HOST":
+ c.SMTPHost = value
+ case "SMTP_PORT":
+ if v, err := strconv.Atoi(value); err == nil {
+ c.SMTPPort = v
+ }
+ case "SMTP_USERNAME":
+ c.SMTPUsername = value
+ case "SMTP_PASSWORD":
+ c.SMTPPassword = value
+ case "SMTP_SENDER":
+ c.SMTPSender = value
+ case "SMTP_USE_TLS":
+ c.SMTPUseTLS = value == "true"
+ }
}
return cfg.SaveTo(configPath)
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
new file mode 100644
index 0000000..05c36ee
--- /dev/null
+++ b/internal/handlers/auth.go
@@ -0,0 +1,274 @@
+package handlers
+
+import (
+ "crypto/hmac"
+ "crypto/sha1"
+ "crypto/subtle"
+ "crypto/rand"
+ "encoding/base32"
+ "encoding/binary"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+const sessionCookieName = "gobsidian_session"
+
+// LoginPage renders the login form
+func (h *Handlers) LoginPage(c *gin.Context) {
+ token, _ := c.Get("csrf_token")
+ c.HTML(http.StatusOK, "login", gin.H{
+ "app_name": h.config.AppName,
+ "csrf_token": token,
+ "ContentTemplate": "login_content",
+ "ScriptsTemplate": "login_scripts",
+ "Page": "login",
+ })
+}
+
+// isAllDigits returns true if s consists only of ASCII digits 0-9
+func isAllDigits(s string) bool {
+ if s == "" {
+ return false
+ }
+ for i := 0; i < len(s); i++ {
+ if s[i] < '0' || s[i] > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+// MFALoginPage shows OTP prompt when MFA is enabled
+func (h *Handlers) MFALoginPage(c *gin.Context) {
+ session, _ := h.store.Get(c.Request, sessionCookieName)
+ if _, ok := session.Values["mfa_user_id"]; !ok {
+ c.Redirect(http.StatusFound, "/editor/login")
+ return
+ }
+ token, _ := c.Get("csrf_token")
+ c.HTML(http.StatusOK, "mfa", gin.H{
+ "app_name": h.config.AppName,
+ "csrf_token": token,
+ "ContentTemplate": "mfa_content",
+ "ScriptsTemplate": "mfa_scripts",
+ "Page": "mfa",
+ })
+}
+
+// MFALoginVerify verifies OTP and completes login
+func (h *Handlers) MFALoginVerify(c *gin.Context) {
+ code := strings.TrimSpace(c.PostForm("code"))
+ if len(code) != 6 || !isAllDigits(code) {
+ token, _ := c.Get("csrf_token")
+ c.HTML(http.StatusUnauthorized, "mfa", gin.H{
+ "app_name": h.config.AppName,
+ "csrf_token": token,
+ "error": "Invalid code format",
+ "ContentTemplate": "mfa_content",
+ "ScriptsTemplate": "mfa_scripts",
+ "Page": "mfa",
+ })
+ return
+ }
+ session, _ := h.store.Get(c.Request, sessionCookieName)
+ uidAny, ok := session.Values["mfa_user_id"]
+ if !ok {
+ c.Redirect(http.StatusFound, "/editor/login")
+ return
+ }
+ uid, _ := uidAny.(int64)
+ var secret string
+ if err := h.authSvc.DB.QueryRow(`SELECT mfa_secret FROM users WHERE id = ?`, uid).Scan(&secret); err != nil || secret == "" {
+ c.HTML(http.StatusUnauthorized, "mfa", gin.H{"error": "MFA not enabled", "Page": "mfa", "ContentTemplate": "mfa_content", "ScriptsTemplate": "mfa_scripts", "app_name": h.config.AppName})
+ return
+ }
+ if !verifyTOTP(secret, code, time.Now()) {
+ token, _ := c.Get("csrf_token")
+ c.HTML(http.StatusUnauthorized, "mfa", gin.H{
+ "app_name": h.config.AppName,
+ "csrf_token": token,
+ "error": "Invalid code",
+ "ContentTemplate": "mfa_content",
+ "ScriptsTemplate": "mfa_scripts",
+ "Page": "mfa",
+ })
+ return
+ }
+ // success: set user_id and clear mfa_user_id
+ delete(session.Values, "mfa_user_id")
+ session.Values["user_id"] = uid
+ _ = session.Save(c.Request, c.Writer)
+ c.Redirect(http.StatusFound, "/")
+}
+
+// ProfileMFASetupPage shows QR and input to verify during enrollment
+func (h *Handlers) ProfileMFASetupPage(c *gin.Context) {
+ uidPtr := getUserIDPtr(c)
+ if uidPtr == nil {
+ c.Redirect(http.StatusFound, "/editor/login")
+ return
+ }
+ // ensure enrollment exists, otherwise create one
+ var secret string
+ err := h.authSvc.DB.QueryRow(`SELECT secret FROM mfa_enrollments WHERE user_id = ?`, *uidPtr).Scan(&secret)
+ if err != nil || secret == "" {
+ // create new enrollment
+ s, e := generateBase32Secret()
+ if e != nil {
+ c.HTML(http.StatusInternalServerError, "error", gin.H{"error": "Failed to create enrollment", "Page": "error", "ContentTemplate": "error_content", "ScriptsTemplate": "error_scripts", "app_name": h.config.AppName})
+ return
+ }
+ _, _ = h.authSvc.DB.Exec(`INSERT OR REPLACE INTO mfa_enrollments (user_id, secret) VALUES (?, ?)`, *uidPtr, s)
+ secret = s
+ }
+ // Fetch username for label
+ var username string
+ _ = h.authSvc.DB.QueryRow(`SELECT username FROM users WHERE id = ?`, *uidPtr).Scan(&username)
+ issuer := h.config.AppName
+ label := url.PathEscape(fmt.Sprintf("%s:%s", issuer, username))
+ otpauth := fmt.Sprintf("otpauth://totp/%s?secret=%s&issuer=%s&digits=6&period=30&algorithm=SHA1", label, secret, url.QueryEscape(issuer))
+
+ // Render simple page (uses base.html shell)
+ c.HTML(http.StatusOK, "mfa_setup", gin.H{
+ "app_name": h.config.AppName,
+ "Secret": secret,
+ "OTPAuthURI": otpauth,
+ "ContentTemplate": "mfa_setup_content",
+ "ScriptsTemplate": "mfa_setup_scripts",
+ "Page": "mfa_setup",
+ })
+}
+
+// ProfileMFASetupVerify finalizes enrollment by verifying a TOTP code and setting mfa_secret
+func (h *Handlers) ProfileMFASetupVerify(c *gin.Context) {
+ uidPtr := getUserIDPtr(c)
+ if uidPtr == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
+ return
+ }
+ code := strings.TrimSpace(c.PostForm("code"))
+ if len(code) != 6 || !isAllDigits(code) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid code format"})
+ return
+ }
+ var secret string
+ if err := h.authSvc.DB.QueryRow(`SELECT secret FROM mfa_enrollments WHERE user_id = ?`, *uidPtr).Scan(&secret); err != nil || secret == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "no enrollment found"})
+ return
+ }
+ if !verifyTOTP(secret, code, time.Now()) {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid code"})
+ return
+ }
+ // move secret to users and delete enrollment
+ if _, err := h.authSvc.DB.Exec(`UPDATE users SET mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, secret, *uidPtr); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ _, _ = h.authSvc.DB.Exec(`DELETE FROM mfa_enrollments WHERE user_id = ?`, *uidPtr)
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
+
+// TOTP helpers (SHA1, 30s window, 6 digits)
+func generateBase32Secret() (string, error) {
+ // 20 random bytes -> base32 without padding
+ b := make([]byte, 20)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ enc := base32.StdEncoding.WithPadding(base32.NoPadding)
+ return enc.EncodeToString(b), nil
+}
+
+func hotp(secret []byte, counter uint64) string {
+ // HMAC-SHA1
+ var buf [8]byte
+ binary.BigEndian.PutUint64(buf[:], counter)
+ mac := hmac.New(sha1.New, secret)
+ mac.Write(buf[:])
+ sum := mac.Sum(nil)
+ // dynamic truncation
+ offset := sum[len(sum)-1] & 0x0F
+ code := (uint32(sum[offset])&0x7F)<<24 | (uint32(sum[offset+1])&0xFF)<<16 | (uint32(sum[offset+2])&0xFF)<<8 | (uint32(sum[offset+3]) & 0xFF)
+ return fmt.Sprintf("%06d", code%1000000)
+}
+
+func verifyTOTP(base32Secret, code string, t time.Time) bool {
+ enc := base32.StdEncoding.WithPadding(base32.NoPadding)
+ key, err := enc.DecodeString(strings.ToUpper(base32Secret))
+ if err != nil {
+ return false
+ }
+ timestep := uint64(t.Unix() / 30)
+ // allow +/- 1 window
+ candidates := []string{
+ hotp(key, timestep-1),
+ hotp(key, timestep),
+ hotp(key, timestep+1),
+ }
+ for _, c := range candidates {
+ if subtle.ConstantTimeCompare([]byte(c), []byte(code)) == 1 {
+ return true
+ }
+ }
+ return false
+}
+
+// LoginPost processes the login form
+func (h *Handlers) LoginPost(c *gin.Context) {
+ username := c.PostForm("username")
+ password := c.PostForm("password")
+
+ user, err := h.authSvc.Authenticate(username, password)
+ if err != nil {
+ token, _ := c.Get("csrf_token")
+ c.HTML(http.StatusUnauthorized, "login", gin.H{
+ "app_name": h.config.AppName,
+ "csrf_token": token,
+ "error": err.Error(),
+ "ContentTemplate": "login_content",
+ "ScriptsTemplate": "login_scripts",
+ "Page": "login",
+ })
+ return
+ }
+ // If user has MFA enabled, require OTP before setting user_id
+ if user.MFASecret.Valid && user.MFASecret.String != "" {
+ session, _ := h.store.Get(c.Request, sessionCookieName)
+ session.Values["mfa_user_id"] = user.ID
+ _ = session.Save(c.Request, c.Writer)
+ c.Redirect(http.StatusFound, "/editor/mfa")
+ return
+ }
+
+ // If admin created an enrollment for this user, force MFA setup after login
+ var pending int
+ if err := h.authSvc.DB.QueryRow(`SELECT 1 FROM mfa_enrollments WHERE user_id = ?`, user.ID).Scan(&pending); err == nil {
+ // normal login, then redirect to setup
+ session, _ := h.store.Get(c.Request, sessionCookieName)
+ session.Values["user_id"] = user.ID
+ _ = session.Save(c.Request, c.Writer)
+ c.Redirect(http.StatusFound, "/editor/profile/mfa/setup")
+ return
+ }
+
+ // Create normal session
+ session, _ := h.store.Get(c.Request, sessionCookieName)
+ session.Values["user_id"] = user.ID
+ _ = session.Save(c.Request, c.Writer)
+
+ c.Redirect(http.StatusFound, "/")
+}
+
+// LogoutPost clears the session
+func (h *Handlers) LogoutPost(c *gin.Context) {
+ session, _ := h.store.Get(c.Request, sessionCookieName)
+ session.Options.MaxAge = -1
+ _ = session.Save(c.Request, c.Writer)
+ c.Redirect(http.StatusFound, "/editor/login")
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 5a4b347..5c0bd43 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -1,18 +1,25 @@
package handlers
import (
+ "database/sql"
+ "encoding/base64"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
+ "strconv"
"strings"
+ "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"
@@ -22,144 +29,569 @@ type Handlers struct {
config *config.Config
store *sessions.CookieStore
renderer *markdown.Renderer
+ authSvc *auth.Service
+}
+
+// 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, "/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": "/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
+ }
+ // Create or replace an enrollment so user is prompted on next login
+ secret, err := generateBase32Secret()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secret"})
+ return
+ }
+ if _, err := h.authSvc.DB.Exec(`INSERT OR REPLACE INTO mfa_enrollments (user_id, secret) VALUES (?, ?)`, id, secret); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ 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"), "/")
+ 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
- }
+ // 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
+ }
- fullPath := filepath.Join(h.config.NotesDir, filePath)
+ // 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
+ }
- // 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
- }
+ fullPath := filepath.Join(h.config.NotesDir, filePath)
- // 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
- }
+ // 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
+ }
- // 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
- }
+ // 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
+ }
- // 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
- }
+ // 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
+ }
- folderPath := filepath.Dir(filePath)
- if folderPath == "." {
- folderPath = ""
- }
+ // 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
+ }
- 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),
- "ContentTemplate": "edit_text_content",
- "ScriptsTemplate": "edit_text_scripts",
- "Page": "edit_text",
- })
+ 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"), "/")
+ filePath := strings.TrimPrefix(c.Param("path"), "/")
- if strings.Contains(filePath, "..") {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path"})
- return
- }
+ if strings.Contains(filePath, "..") {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path"})
+ return
+ }
- fullPath := filepath.Join(h.config.NotesDir, filePath)
+ // 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
+ }
- // 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
- }
+ fullPath := filepath.Join(h.config.NotesDir, filePath)
- content := c.PostForm("content")
+ // 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
+ }
- // 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
- }
+ content := c.PostForm("content")
- if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
- return
- }
+ // 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
+ }
- c.JSON(http.StatusOK, gin.H{"success": true, "redirect": "/view_text/" + filePath})
+ 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": "/view_text/" + filePath})
}
-func New(cfg *config.Config, store *sessions.CookieStore) *Handlers {
+func New(cfg *config.Config, store *sessions.CookieStore, authSvc *auth.Service) *Handlers {
return &Handlers{
config: cfg,
store: store,
renderer: markdown.NewRenderer(cfg),
+ authSvc: authSvc,
}
}
@@ -198,6 +630,29 @@ func (h *Handlers) IndexHandler(c *gin.Context) {
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": "",
@@ -208,6 +663,8 @@ func (h *Handlers) IndexHandler(c *gin.Context) {
"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",
@@ -243,6 +700,29 @@ func (h *Handlers) FolderHandler(c *gin.Context) {
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{
@@ -279,6 +759,8 @@ func (h *Handlers) FolderHandler(c *gin.Context) {
"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",
@@ -326,6 +808,29 @@ func (h *Handlers) NoteHandler(c *gin.Context) {
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) {
@@ -395,6 +900,8 @@ func (h *Handlers) NoteHandler(c *gin.Context) {
"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",
@@ -430,6 +937,15 @@ func (h *Handlers) ServeAttachedImageHandler(c *gin.Context) {
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
@@ -464,6 +980,9 @@ func (h *Handlers) ServeStoredImageHandler(c *gin.Context) {
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)
}
@@ -476,6 +995,15 @@ func (h *Handlers) DownloadHandler(c *gin.Context) {
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) {
@@ -504,6 +1032,29 @@ func (h *Handlers) ViewTextHandler(c *gin.Context) {
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) {
@@ -578,6 +1129,8 @@ func (h *Handlers) ViewTextHandler(c *gin.Context) {
"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",
@@ -686,6 +1239,88 @@ func (h *Handlers) TreeAPIHandler(c *gin.Context) {
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
diff --git a/internal/server/middleware.go b/internal/server/middleware.go
new file mode 100644
index 0000000..789a86e
--- /dev/null
+++ b/internal/server/middleware.go
@@ -0,0 +1,107 @@
+package server
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+const csrfSessionKey = "csrf_token"
+
+func (s *Server) randomToken(n int) (string, error) {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// SessionUser loads gorilla session and exposes user_id and csrf token to context
+func (s *Server) SessionUser() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ sess, _ := s.store.Get(c.Request, "gobsidian_session")
+ if v, ok := sess.Values["user_id"].(int64); ok {
+ c.Set("user_id", v)
+ // derive admin flag
+ if ok, err := s.auth.IsUserInGroup(v, "admin"); err == nil && ok {
+ c.Set("is_admin", true)
+ }
+ }
+ // ensure CSRF token exists in session
+ tok, _ := sess.Values[csrfSessionKey].(string)
+ if tok == "" {
+ if t, err := s.randomToken(32); err == nil {
+ sess.Values[csrfSessionKey] = t
+ _ = sess.Save(c.Request, c.Writer)
+ tok = t
+ }
+ }
+ c.Set("csrf_token", tok)
+ // expose CSRF token to client: header + non-HttpOnly cookie
+ if tok != "" {
+ c.Writer.Header().Set("X-CSRF-Token", tok)
+ // cookie accessible to JS (HttpOnly=false). Secure/ SameSite Lax for CSRF
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: "csrf_token",
+ Value: tok,
+ Path: "/",
+ HttpOnly: false,
+ SameSite: http.SameSiteLaxMode,
+ })
+ }
+ c.Next()
+ }
+}
+
+// CSRFRequire validates the CSRF token for state-changing requests
+func (s *Server) CSRFRequire() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions {
+ c.Next()
+ return
+ }
+ sess, _ := s.store.Get(c.Request, "gobsidian_session")
+ expected, _ := sess.Values[csrfSessionKey].(string)
+ var token string
+ if h := c.GetHeader("X-CSRF-Token"); h != "" {
+ token = h
+ } else {
+ token = c.PostForm("csrf_token")
+ }
+ if expected == "" || token == "" || expected != token {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid CSRF token"})
+ return
+ }
+ c.Next()
+ }
+}
+
+// RequireAuth enforces authenticated access
+func (s *Server) RequireAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if _, exists := c.Get("user_id"); !exists {
+ c.Redirect(http.StatusFound, "/editor/login")
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
+
+// RequireAdmin enforces admin-only access
+func (s *Server) RequireAdmin() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if _, exists := c.Get("user_id"); !exists {
+ c.Redirect(http.StatusFound, "/editor/login")
+ c.Abort()
+ return
+ }
+ if _, ok := c.Get("is_admin"); !ok {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin required"})
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 66e1d94..85726a6 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
+ "gobsidian/internal/auth"
"gobsidian/internal/config"
"gobsidian/internal/handlers"
"gobsidian/internal/models"
@@ -18,6 +19,7 @@ type Server struct {
config *config.Config
router *gin.Engine
store *sessions.CookieStore
+ auth *auth.Service
}
func New(cfg *config.Config) *Server {
@@ -28,12 +30,22 @@ func New(cfg *config.Config) *Server {
router := gin.Default()
store := sessions.NewCookieStore([]byte(cfg.SecretKey))
+ // Initialize auth service (panic on error during startup)
+ authSvc, err := auth.Open(cfg)
+ if err != nil {
+ panic(fmt.Errorf("failed to initialize auth: %w", err))
+ }
+
s := &Server{
config: cfg,
router: router,
store: store,
+ auth: authSvc,
}
+ // Global middlewares: session user + template setup
+ s.router.Use(s.SessionUser())
+
s.setupRoutes()
s.setupStaticFiles()
s.setupTemplates()
@@ -62,7 +74,7 @@ func (s *Server) Start() error {
}
func (s *Server) setupRoutes() {
- h := handlers.New(s.config, s.store)
+ h := handlers.New(s.config, s.store, s.auth)
// Main routes
s.router.GET("/", h.IndexHandler)
@@ -74,27 +86,68 @@ func (s *Server) setupRoutes() {
s.router.GET("/serve_stored_image/:filename", h.ServeStoredImageHandler)
s.router.GET("/download/*path", h.DownloadHandler)
s.router.GET("/view_text/*path", h.ViewTextHandler)
- s.router.GET("/edit_text/*path", h.EditTextPageHandler)
- s.router.POST("/edit_text/*path", h.PostEditTextHandler)
- // Upload routes
- s.router.POST("/upload", h.UploadHandler)
+ // Auth routes
+ s.router.GET("/editor/login", h.LoginPage)
+ s.router.POST("/editor/login", s.CSRFRequire(), h.LoginPost)
+ s.router.POST("/editor/logout", s.RequireAuth(), s.CSRFRequire(), h.LogoutPost)
+ // MFA challenge routes (no auth yet, but CSRF)
+ s.router.GET("/editor/mfa", s.CSRFRequire(), h.MFALoginPage)
+ s.router.POST("/editor/mfa", s.CSRFRequire(), h.MFALoginVerify)
- // Settings routes
- s.router.GET("/settings", h.SettingsPageHandler)
- s.router.GET("/settings/image_storage", h.GetImageStorageSettingsHandler)
- s.router.POST("/settings/image_storage", h.PostImageStorageSettingsHandler)
- s.router.GET("/settings/notes_dir", h.GetNotesDirSettingsHandler)
- s.router.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler)
- s.router.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler)
- s.router.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler)
+ // New /editor group protected by auth + CSRF
+ editor := s.router.Group("/editor", s.RequireAuth(), s.CSRFRequire())
+ {
+ editor.GET("/create", h.CreateNotePageHandler)
+ editor.POST("/create", h.CreateNoteHandler)
+ editor.GET("/edit/*path", h.EditNotePageHandler)
+ editor.POST("/edit/*path", h.EditNoteHandler)
+ editor.DELETE("/delete/*path", h.DeleteHandler)
- // Editor routes
- s.router.GET("/create", h.CreateNotePageHandler)
- s.router.POST("/create", h.CreateNoteHandler)
- s.router.GET("/edit/*path", h.EditNotePageHandler)
- s.router.POST("/edit/*path", h.EditNoteHandler)
- s.router.DELETE("/delete/*path", h.DeleteHandler)
+ // Text editor routes under /editor
+ editor.GET("/edit_text/*path", h.EditTextPageHandler)
+ editor.POST("/edit_text/*path", h.PostEditTextHandler)
+
+ // Upload under /editor (secured)
+ editor.POST("/upload", h.UploadHandler)
+
+ // Settings under /editor
+ editor.GET("/settings", h.SettingsPageHandler)
+ editor.GET("/settings/image_storage", h.GetImageStorageSettingsHandler)
+ editor.POST("/settings/image_storage", h.PostImageStorageSettingsHandler)
+ editor.GET("/settings/notes_dir", h.GetNotesDirSettingsHandler)
+ editor.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler)
+ editor.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler)
+ editor.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler)
+
+ // Profile
+ editor.GET("/profile", h.ProfilePage)
+ editor.POST("/profile/password", h.PostProfileChangePassword)
+ editor.POST("/profile/email", h.PostProfileChangeEmail)
+ editor.POST("/profile/mfa/enable", h.PostProfileEnableMFA)
+ editor.POST("/profile/mfa/disable", h.PostProfileDisableMFA)
+ // MFA setup during enrollment
+ editor.GET("/profile/mfa/setup", h.ProfileMFASetupPage)
+ editor.POST("/profile/mfa/verify", h.ProfileMFASetupVerify)
+
+ // Admin dashboard
+ editor.GET("/admin", s.RequireAdmin(), h.AdminPage)
+
+ // Admin CRUD API under /editor/admin
+ admin := editor.Group("/admin", s.RequireAdmin())
+ {
+ admin.POST("/users", h.AdminCreateUser)
+ admin.DELETE("/users/:id", h.AdminDeleteUser)
+ admin.POST("/users/:id/active", h.AdminSetUserActive)
+ admin.POST("/users/:id/mfa/enable", h.AdminEnableUserMFA)
+ admin.POST("/users/:id/mfa/disable", h.AdminDisableUserMFA)
+ admin.POST("/users/:id/mfa/reset", h.AdminResetUserMFA)
+ admin.POST("/groups", h.AdminCreateGroup)
+ admin.DELETE("/groups/:id", h.AdminDeleteGroup)
+ admin.POST("/memberships/add", h.AdminAddUserToGroup)
+ admin.POST("/memberships/remove", h.AdminRemoveUserFromGroup)
+ }
+ }
// API routes
s.router.GET("/api/tree", h.TreeAPIHandler)
diff --git a/web/static/app.js b/web/static/app.js
index 455745f..abacc9b 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -115,7 +115,12 @@ function initEnhancedUpload() {
showNotification('Upload failed: Network error', 'error');
});
- xhr.open('POST', '/upload');
+ const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
+ const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
+ xhr.open('POST', '/editor/upload');
+ if (csrf) {
+ try { xhr.setRequestHeader('X-CSRF-Token', csrf); } catch (_) {}
+ }
xhr.send(formData);
}
@@ -205,13 +210,13 @@ function initKeyboardShortcuts() {
case 'n':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
- window.location.href = '/create';
+ window.location.href = '/editor/create';
}
break;
case 's':
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
- window.location.href = '/settings';
+ window.location.href = '/editor/settings';
}
break;
}
diff --git a/web/templates/admin.html b/web/templates/admin.html
new file mode 100644
index 0000000..d5f149e
--- /dev/null
+++ b/web/templates/admin.html
@@ -0,0 +1,294 @@
+{{define "admin"}}
+ {{template "base" .}}
+{{end}}
+
+{{define "admin_content"}}
+
+
+
+
Admin Dashboard
+
Manage users, groups, and permissions
+
+
+
+
+
+
+ Users
+
+
+
+
+
+
+
+ | Username |
+ Email |
+ Status |
+ MFA |
+ Actions |
+
+
+
+ {{if .users}}
+ {{range .users}}
+
+ | {{.Username}} |
+ {{.Email}} |
+
+ {{if .IsActive}}Active{{else}}Disabled{{end}}
+ |
+
+ {{if .MFASecret.Valid}}Enabled{{else}}None{{end}}
+ |
+
+ {{if ne .ID $.CurrentUserID}}
+ {{if .IsActive}}
+
+ {{else}}
+
+ {{end}}
+ {{if .MFASecret.Valid}}
+
+
+ {{else}}
+
+ {{end}}
+
+ {{else}}
+ Actions disabled for current user
+ {{end}}
+ |
+
+ {{end}}
+ {{else}}
+ | No users found. |
+ {{end}}
+
+
+
+
+
+
+
+
+ Groups
+
+
+
+
+
+
+ | Name |
+ Actions |
+
+
+
+ {{if .groups}}
+ {{range .groups}}
+
+ | {{.Name}} |
+
+
+ |
+
+ {{end}}
+ {{else}}
+ | No groups found. |
+ {{end}}
+
+
+
+
+
+
+
+
+ Permissions
+
+
+
+
+ | Group |
+ Path |
+ Read |
+ Write |
+ Delete |
+
+
+
+ {{if .permissions}}
+ {{range .permissions}}
+
+ | {{.Group}} |
+ {{.Path}} |
+ {{if .CanRead}}✅{{else}}❌{{end}} |
+ {{if .CanWrite}}✅{{else}}❌{{end}} |
+ {{if .CanDelete}}✅{{else}}❌{{end}} |
+
+ {{end}}
+ {{else}}
+ | No permissions configured. |
+ {{end}}
+
+
+
+
+
Manage Memberships
+
+
+
+
+
+{{end}}
+
+{{define "admin_scripts"}}
+
+{{end}}
diff --git a/web/templates/base.html b/web/templates/base.html
index 66cb5d4..4f52312 100644
--- a/web/templates/base.html
+++ b/web/templates/base.html
@@ -271,9 +271,28 @@
-
-
-
+ {{if .Authenticated}}
+ {{if .IsAdmin}}
+
+
+
+ {{end}}
+
+
+
+
+
+
+ {{end}}
+ {{if .Authenticated}}
+
+ {{else}}
+
+
+
+ {{end}}