user authentication

This commit is contained in:
nahakubuilde
2025-08-25 21:19:15 +01:00
parent 6c82e2014c
commit e21a0b5b10
23 changed files with 2479 additions and 189 deletions

19
go.mod
View File

@@ -1,6 +1,8 @@
module gobsidian module gobsidian
go 1.21 go 1.23.0
toolchain go1.24.5
require ( require (
github.com/alecthomas/chroma/v2 v2.8.0 github.com/alecthomas/chroma/v2 v2.8.0
@@ -9,34 +11,43 @@ require (
github.com/h2non/filetype v1.1.3 github.com/h2non/filetype v1.1.3
github.com/yuin/goldmark v1.6.0 github.com/yuin/goldmark v1.6.0
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 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 gopkg.in/ini.v1 v1.67.0
modernc.org/sqlite v1.38.2
) )
require ( require (
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dlclark/regexp2 v1.10.0 // 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/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // 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/gorilla/securecookie v1.1.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // 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/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 golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // 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
) )

56
go.sum
View File

@@ -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.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 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= 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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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/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.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/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 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 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= 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/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 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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/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 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= 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 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 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= 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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

385
internal/auth/service.go Normal file
View File

@@ -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()
}
}

View File

@@ -35,6 +35,23 @@ type Config struct {
ShowFilesInTree bool ShowFilesInTree bool
ShowImagesInFolder bool ShowImagesInFolder bool
ShowFilesInFolder 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{ var defaultConfig = map[string]map[string]string{
@@ -62,6 +79,23 @@ var defaultConfig = map[string]map[string]string{
"SHOW_IMAGES_IN_FOLDER": "true", "SHOW_IMAGES_IN_FOLDER": "true",
"SHOW_FILES_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) { func Load() (*Config, error) {
@@ -130,6 +164,36 @@ func Load() (*Config, error) {
config.ImageStoragePath = filepath.Join(wd, config.ImageStoragePath) 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 return config, nil
} }
@@ -265,6 +329,39 @@ func (c *Config) SaveSetting(section, key, value string) error {
case "SHOW_FILES_IN_FOLDER": case "SHOW_FILES_IN_FOLDER":
c.ShowFilesInFolder = value == "true" 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) return cfg.SaveTo(configPath)

274
internal/handlers/auth.go Normal file
View File

@@ -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")
}

View File

@@ -1,18 +1,25 @@
package handlers package handlers
import ( import (
"database/sql"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"crypto/rand"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/h2non/filetype" "github.com/h2non/filetype"
"golang.org/x/crypto/bcrypt"
"gobsidian/internal/config" "gobsidian/internal/config"
"gobsidian/internal/auth"
"gobsidian/internal/markdown" "gobsidian/internal/markdown"
"gobsidian/internal/models" "gobsidian/internal/models"
"gobsidian/internal/utils" "gobsidian/internal/utils"
@@ -22,144 +29,569 @@ type Handlers struct {
config *config.Config config *config.Config
store *sessions.CookieStore store *sessions.CookieStore
renderer *markdown.Renderer 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.) // EditTextPageHandler renders an editor for allowed text files (json, html, xml, yaml, etc.)
func (h *Handlers) EditTextPageHandler(c *gin.Context) { func (h *Handlers) EditTextPageHandler(c *gin.Context) {
filePath := strings.TrimPrefix(c.Param("path"), "/") filePath := strings.TrimPrefix(c.Param("path"), "/")
// Security check // Security check
if strings.Contains(filePath, "..") { if strings.Contains(filePath, "..") {
c.HTML(http.StatusBadRequest, "error", gin.H{ c.HTML(http.StatusBadRequest, "error", gin.H{
"error": "Invalid path", "error": "Invalid path",
"app_name": h.config.AppName, "app_name": h.config.AppName,
"message": "Path traversal is not allowed", "message": "Path traversal is not allowed",
"ContentTemplate": "error_content", "ContentTemplate": "error_content",
"ScriptsTemplate": "error_scripts", "ScriptsTemplate": "error_scripts",
"Page": "error", "Page": "error",
}) })
return 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 fullPath := filepath.Join(h.config.NotesDir, filePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
c.HTML(http.StatusNotFound, "error", gin.H{
"error": "File not found",
"app_name": h.config.AppName,
"message": "The requested file does not exist",
"ContentTemplate": "error_content",
"ScriptsTemplate": "error_scripts",
"Page": "error",
})
return
}
// Only allow editing of configured text file types (not markdown here) // Ensure file exists
ext := filepath.Ext(fullPath) if _, err := os.Stat(fullPath); os.IsNotExist(err) {
ftype := models.GetFileType(ext, h.config.AllowedImageExtensions, h.config.AllowedFileExtensions) c.HTML(http.StatusNotFound, "error", gin.H{
if ftype != models.FileTypeText { "error": "File not found",
c.HTML(http.StatusForbidden, "error", gin.H{ "app_name": h.config.AppName,
"error": "Editing not allowed", "message": "The requested file does not exist",
"app_name": h.config.AppName, "ContentTemplate": "error_content",
"message": "This file type cannot be edited here", "ScriptsTemplate": "error_scripts",
"ContentTemplate": "error_content", "Page": "error",
"ScriptsTemplate": "error_scripts", })
"Page": "error", return
}) }
return
}
// Load content // Only allow editing of configured text file types (not markdown here)
data, err := os.ReadFile(fullPath) ext := filepath.Ext(fullPath)
if err != nil { ftype := models.GetFileType(ext, h.config.AllowedImageExtensions, h.config.AllowedFileExtensions)
c.HTML(http.StatusInternalServerError, "error", gin.H{ if ftype != models.FileTypeText {
"error": "Failed to read file", c.HTML(http.StatusForbidden, "error", gin.H{
"app_name": h.config.AppName, "error": "Editing not allowed",
"message": err.Error(), "app_name": h.config.AppName,
"ContentTemplate": "error_content", "message": "This file type cannot be edited here",
"ScriptsTemplate": "error_scripts", "ContentTemplate": "error_content",
"Page": "error", "ScriptsTemplate": "error_scripts",
}) "Page": "error",
return })
} return
}
// Build notes tree // Load content
notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config) data, err := os.ReadFile(fullPath)
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "error", gin.H{ c.HTML(http.StatusInternalServerError, "error", gin.H{
"error": "Failed to build notes tree", "error": "Failed to read file",
"app_name": h.config.AppName, "app_name": h.config.AppName,
"message": err.Error(), "message": err.Error(),
"ContentTemplate": "error_content", "ContentTemplate": "error_content",
"ScriptsTemplate": "error_scripts", "ScriptsTemplate": "error_scripts",
"Page": "error", "Page": "error",
}) })
return return
} }
folderPath := filepath.Dir(filePath) // Build notes tree
if folderPath == "." { notesTree, err := utils.BuildTreeStructure(h.config.NotesDir, h.config.NotesDirHideSidepane, h.config)
folderPath = "" 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{ folderPath := filepath.Dir(filePath)
"app_name": h.config.AppName, if folderPath == "." {
"title": filepath.Base(filePath), folderPath = ""
"content": string(data), }
"file_path": filePath,
"file_ext": strings.TrimPrefix(strings.ToLower(ext), "."), c.HTML(http.StatusOK, "edit_text", gin.H{
"folder_path": folderPath, "app_name": h.config.AppName,
"notes_tree": notesTree, "title": filepath.Base(filePath),
"active_path": utils.GetActivePath(folderPath), "content": string(data),
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath), "file_path": filePath,
"ContentTemplate": "edit_text_content", "file_ext": strings.TrimPrefix(strings.ToLower(ext), "."),
"ScriptsTemplate": "edit_text_scripts", "folder_path": folderPath,
"Page": "edit_text", "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 // PostEditTextHandler saves changes to an allowed text file
func (h *Handlers) PostEditTextHandler(c *gin.Context) { func (h *Handlers) PostEditTextHandler(c *gin.Context) {
filePath := strings.TrimPrefix(c.Param("path"), "/") filePath := strings.TrimPrefix(c.Param("path"), "/")
if strings.Contains(filePath, "..") { if strings.Contains(filePath, "..") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid path"})
return 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 fullPath := filepath.Join(h.config.NotesDir, filePath)
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") // 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 content := c.PostForm("content")
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create parent directory"})
return
}
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil { // Ensure parent directory exists
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"}) if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
return 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{ return &Handlers{
config: cfg, config: cfg,
store: store, store: store,
renderer: markdown.NewRenderer(cfg), 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) 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{ c.HTML(http.StatusOK, "folder", gin.H{
"app_name": h.config.AppName, "app_name": h.config.AppName,
"folder_path": "", "folder_path": "",
@@ -208,6 +663,8 @@ func (h *Handlers) IndexHandler(c *gin.Context) {
"breadcrumbs": utils.GenerateBreadcrumbs(""), "breadcrumbs": utils.GenerateBreadcrumbs(""),
"allowed_image_extensions": h.config.AllowedImageExtensions, "allowed_image_extensions": h.config.AllowedImageExtensions,
"allowed_file_extensions": h.config.AllowedFileExtensions, "allowed_file_extensions": h.config.AllowedFileExtensions,
"Authenticated": isAuthenticated(c),
"IsAdmin": isAdmin(c),
"ContentTemplate": "folder_content", "ContentTemplate": "folder_content",
"ScriptsTemplate": "folder_scripts", "ScriptsTemplate": "folder_scripts",
"Page": "folder", "Page": "folder",
@@ -243,6 +700,29 @@ func (h *Handlers) FolderHandler(c *gin.Context) {
return 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) folderContents, err := utils.GetFolderContents(folderPath, h.config)
if err != nil { if err != nil {
c.HTML(http.StatusNotFound, "error", gin.H{ c.HTML(http.StatusNotFound, "error", gin.H{
@@ -279,6 +759,8 @@ func (h *Handlers) FolderHandler(c *gin.Context) {
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath), "breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
"allowed_image_extensions": h.config.AllowedImageExtensions, "allowed_image_extensions": h.config.AllowedImageExtensions,
"allowed_file_extensions": h.config.AllowedFileExtensions, "allowed_file_extensions": h.config.AllowedFileExtensions,
"Authenticated": isAuthenticated(c),
"IsAdmin": isAdmin(c),
"ContentTemplate": "folder_content", "ContentTemplate": "folder_content",
"ScriptsTemplate": "folder_scripts", "ScriptsTemplate": "folder_scripts",
"Page": "folder", "Page": "folder",
@@ -326,6 +808,29 @@ func (h *Handlers) NoteHandler(c *gin.Context) {
return 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) fullPath := filepath.Join(h.config.NotesDir, notePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) { if _, err := os.Stat(fullPath); os.IsNotExist(err) {
@@ -395,6 +900,8 @@ func (h *Handlers) NoteHandler(c *gin.Context) {
"active_path": utils.GetActivePath(folderPath), "active_path": utils.GetActivePath(folderPath),
"current_note": notePath, "current_note": notePath,
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath), "breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
"Authenticated": isAuthenticated(c),
"IsAdmin": isAdmin(c),
"ContentTemplate": "note_content", "ContentTemplate": "note_content",
"ScriptsTemplate": "note_scripts", "ScriptsTemplate": "note_scripts",
"Page": "note", "Page": "note",
@@ -430,6 +937,15 @@ func (h *Handlers) ServeAttachedImageHandler(c *gin.Context) {
return 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) { if !models.IsImageFile(filepath.Base(imagePath), h.config.AllowedImageExtensions) {
c.AbortWithStatus(http.StatusForbidden) c.AbortWithStatus(http.StatusForbidden)
return return
@@ -464,6 +980,9 @@ func (h *Handlers) ServeStoredImageHandler(c *gin.Context) {
return 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) c.File(fullPath)
} }
@@ -476,6 +995,15 @@ func (h *Handlers) DownloadHandler(c *gin.Context) {
return 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) fullPath := filepath.Join(h.config.NotesDir, filePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) { if _, err := os.Stat(fullPath); os.IsNotExist(err) {
@@ -504,6 +1032,29 @@ func (h *Handlers) ViewTextHandler(c *gin.Context) {
return 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) fullPath := filepath.Join(h.config.NotesDir, filePath)
if _, err := os.Stat(fullPath); os.IsNotExist(err) { if _, err := os.Stat(fullPath); os.IsNotExist(err) {
@@ -578,6 +1129,8 @@ func (h *Handlers) ViewTextHandler(c *gin.Context) {
"notes_tree": notesTree, "notes_tree": notesTree,
"active_path": utils.GetActivePath(folderPath), "active_path": utils.GetActivePath(folderPath),
"breadcrumbs": utils.GenerateBreadcrumbs(folderPath), "breadcrumbs": utils.GenerateBreadcrumbs(folderPath),
"Authenticated": isAuthenticated(c),
"IsAdmin": isAdmin(c),
"ContentTemplate": "view_text_content", "ContentTemplate": "view_text_content",
"ScriptsTemplate": "view_text_scripts", "ScriptsTemplate": "view_text_scripts",
"Page": "view_text", "Page": "view_text",
@@ -686,6 +1239,88 @@ func (h *Handlers) TreeAPIHandler(c *gin.Context) {
c.JSON(http.StatusOK, notesTree) 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 // SearchHandler performs a simple full-text search across markdown and allowed text files
// within the notes directory, honoring skipped directories. // within the notes directory, honoring skipped directories.
// GET /api/search?q=term // GET /api/search?q=term

View File

@@ -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()
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"gobsidian/internal/auth"
"gobsidian/internal/config" "gobsidian/internal/config"
"gobsidian/internal/handlers" "gobsidian/internal/handlers"
"gobsidian/internal/models" "gobsidian/internal/models"
@@ -18,6 +19,7 @@ type Server struct {
config *config.Config config *config.Config
router *gin.Engine router *gin.Engine
store *sessions.CookieStore store *sessions.CookieStore
auth *auth.Service
} }
func New(cfg *config.Config) *Server { func New(cfg *config.Config) *Server {
@@ -28,12 +30,22 @@ func New(cfg *config.Config) *Server {
router := gin.Default() router := gin.Default()
store := sessions.NewCookieStore([]byte(cfg.SecretKey)) 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{ s := &Server{
config: cfg, config: cfg,
router: router, router: router,
store: store, store: store,
auth: authSvc,
} }
// Global middlewares: session user + template setup
s.router.Use(s.SessionUser())
s.setupRoutes() s.setupRoutes()
s.setupStaticFiles() s.setupStaticFiles()
s.setupTemplates() s.setupTemplates()
@@ -62,7 +74,7 @@ func (s *Server) Start() error {
} }
func (s *Server) setupRoutes() { func (s *Server) setupRoutes() {
h := handlers.New(s.config, s.store) h := handlers.New(s.config, s.store, s.auth)
// Main routes // Main routes
s.router.GET("/", h.IndexHandler) 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("/serve_stored_image/:filename", h.ServeStoredImageHandler)
s.router.GET("/download/*path", h.DownloadHandler) s.router.GET("/download/*path", h.DownloadHandler)
s.router.GET("/view_text/*path", h.ViewTextHandler) 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 // Auth routes
s.router.POST("/upload", h.UploadHandler) 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 // New /editor group protected by auth + CSRF
s.router.GET("/settings", h.SettingsPageHandler) editor := s.router.Group("/editor", s.RequireAuth(), s.CSRFRequire())
s.router.GET("/settings/image_storage", h.GetImageStorageSettingsHandler) {
s.router.POST("/settings/image_storage", h.PostImageStorageSettingsHandler) editor.GET("/create", h.CreateNotePageHandler)
s.router.GET("/settings/notes_dir", h.GetNotesDirSettingsHandler) editor.POST("/create", h.CreateNoteHandler)
s.router.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler) editor.GET("/edit/*path", h.EditNotePageHandler)
s.router.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler) editor.POST("/edit/*path", h.EditNoteHandler)
s.router.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler) editor.DELETE("/delete/*path", h.DeleteHandler)
// Editor routes // Text editor routes under /editor
s.router.GET("/create", h.CreateNotePageHandler) editor.GET("/edit_text/*path", h.EditTextPageHandler)
s.router.POST("/create", h.CreateNoteHandler) editor.POST("/edit_text/*path", h.PostEditTextHandler)
s.router.GET("/edit/*path", h.EditNotePageHandler)
s.router.POST("/edit/*path", h.EditNoteHandler) // Upload under /editor (secured)
s.router.DELETE("/delete/*path", h.DeleteHandler) 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 // API routes
s.router.GET("/api/tree", h.TreeAPIHandler) s.router.GET("/api/tree", h.TreeAPIHandler)

View File

@@ -115,7 +115,12 @@ function initEnhancedUpload() {
showNotification('Upload failed: Network error', 'error'); 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); xhr.send(formData);
} }
@@ -205,13 +210,13 @@ function initKeyboardShortcuts() {
case 'n': case 'n':
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
e.preventDefault(); e.preventDefault();
window.location.href = '/create'; window.location.href = '/editor/create';
} }
break; break;
case 's': case 's':
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
e.preventDefault(); e.preventDefault();
window.location.href = '/settings'; window.location.href = '/editor/settings';
} }
break; break;
} }

294
web/templates/admin.html Normal file
View File

@@ -0,0 +1,294 @@
{{define "admin"}}
{{template "base" .}}
{{end}}
{{define "admin_content"}}
<div class="max-w-6xl mx-auto p-6">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Admin Dashboard</h1>
<p class="text-gray-400">Manage users, groups, and permissions</p>
</div>
<div class="space-y-8">
<!-- Users -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-users mr-2"></i>Users
</h2>
<!-- Create User -->
<form id="form-create-user" class="mb-4 grid grid-cols-1 md:grid-cols-4 gap-2">
<input name="username" type="text" class="form-input" placeholder="Username" required />
<input name="email" type="email" class="form-input" placeholder="Email" required />
<input name="password" type="password" class="form-input" placeholder="Password" required />
<button type="submit" class="btn-primary text-sm"><i class="fas fa-plus mr-2"></i>Create</button>
</form>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-gray-300">
<th class="px-3 py-2">Username</th>
<th class="px-3 py-2">Email</th>
<th class="px-3 py-2">Status</th>
<th class="px-3 py-2">MFA</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{{if .users}}
{{range .users}}
<tr class="border-t border-gray-700" data-user-id="{{.ID}}" data-username="{{.Username}}" data-has-mfa="{{if .MFASecret.Valid}}1{{else}}0{{end}}">
<td class="px-3 py-2 text-white">{{.Username}}</td>
<td class="px-3 py-2 text-gray-300">{{.Email}}</td>
<td class="px-3 py-2">
{{if .IsActive}}<span class="text-green-400">Active</span>{{else}}<span class="text-red-400">Disabled</span>{{end}}
</td>
<td class="px-3 py-2">
{{if .MFASecret.Valid}}<span class="text-green-400">Enabled</span>{{else}}<span class="text-gray-400">None</span>{{end}}
</td>
<td class="px-3 py-2 space-x-1">
{{if ne .ID $.CurrentUserID}}
{{if .IsActive}}
<button class="btn-secondary text-xs px-2 py-1" data-action="user-deactivate" title="Disable account">Disable</button>
{{else}}
<button class="btn-primary text-xs px-2 py-1" data-action="user-activate" title="Enable account">Enable</button>
{{end}}
{{if .MFASecret.Valid}}
<button class="btn-secondary text-xs px-2 py-1" data-action="mfa-disable" title="Disable MFA">MFA Off</button>
<button class="btn-warning text-xs px-2 py-1" data-action="mfa-reset" title="Reset MFA secret">MFA Reset</button>
{{else}}
<button class="btn-primary text-xs px-2 py-1" data-action="mfa-enable" title="Enable MFA">MFA On</button>
{{end}}
<button class="btn-danger text-xs px-2 py-1" data-action="delete-user" title="Delete user"><i class="fas fa-trash"></i></button>
{{else}}
<span class="text-xs text-gray-500">Actions disabled for current user</span>
{{end}}
</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="5" class="px-3 py-4 text-gray-400">No users found.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Groups -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-user-group mr-2"></i>Groups
</h2>
<form id="form-create-group" class="mb-4 grid grid-cols-1 md:grid-cols-4 gap-2">
<input name="name" type="text" class="form-input md:col-span-3" placeholder="Group name" required />
<button type="submit" class="btn-primary text-sm"><i class="fas fa-plus mr-2"></i>Create</button>
</form>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-gray-300">
<th class="px-3 py-2">Name</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{{if .groups}}
{{range .groups}}
<tr class="border-t border-gray-700" data-group-id="{{.ID}}" data-group-name="{{.Name}}">
<td class="px-3 py-2 text-white">{{.Name}}</td>
<td class="px-3 py-2">
<button class="btn-danger text-xs px-2 py-1" data-action="delete-group" title="Delete group"><i class="fas fa-trash"></i></button>
</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="2" class="px-3 py-4 text-gray-400">No groups found.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Permissions and Memberships -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-key mr-2"></i>Permissions</h2>
<div class="overflow-x-auto mb-6">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-gray-300">
<th class="px-3 py-2">Group</th>
<th class="px-3 py-2">Path</th>
<th class="px-3 py-2">Read</th>
<th class="px-3 py-2">Write</th>
<th class="px-3 py-2">Delete</th>
</tr>
</thead>
<tbody>
{{if .permissions}}
{{range .permissions}}
<tr class="border-t border-gray-700">
<td class="px-3 py-2 text-white">{{.Group}}</td>
<td class="px-3 py-2"><code class="bg-gray-900 px-1 py-0.5 rounded">{{.Path}}</code></td>
<td class="px-3 py-2">{{if .CanRead}}✅{{else}}❌{{end}}</td>
<td class="px-3 py-2">{{if .CanWrite}}✅{{else}}❌{{end}}</td>
<td class="px-3 py-2">{{if .CanDelete}}✅{{else}}❌{{end}}</td>
</tr>
{{end}}
{{else}}
<tr><td colspan="5" class="px-3 py-4 text-gray-400">No permissions configured.</td></tr>
{{end}}
</tbody>
</table>
</div>
<h3 class="text-white font-semibold mb-3">Manage Memberships</h3>
<form id="form-add-membership" class="grid grid-cols-1 md:grid-cols-3 gap-2 mb-3">
<select name="user_id" class="form-input" required>
<option value="" disabled selected>Select user</option>
{{range .users}}<option value="{{.ID}}">{{.Username}}</option>{{end}}
</select>
<select name="group_id" class="form-input" required>
<option value="" disabled selected>Select group</option>
{{range .groups}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<button type="submit" class="btn-primary text-sm"><i class="fas fa-user-plus mr-2"></i>Add to Group</button>
</form>
<form id="form-remove-membership" class="grid grid-cols-1 md:grid-cols-3 gap-2">
<select name="user_id" class="form-input" required>
<option value="" disabled selected>Select user</option>
{{range .users}}<option value="{{.ID}}">{{.Username}}</option>{{end}}
</select>
<select name="group_id" class="form-input" required>
<option value="" disabled selected>Select group</option>
{{range .groups}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<button type="submit" class="btn-danger text-sm"><i class="fas fa-user-minus mr-2"></i>Remove from Group</button>
</form>
</div>
</div>
</div>
{{end}}
{{define "admin_scripts"}}
<script>
document.addEventListener('DOMContentLoaded', function() {
const getCSRF = () => {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return m && m[1] ? decodeURIComponent(m[1]) : '';
};
// Create user
const formCreateUser = document.getElementById('form-create-user');
if (formCreateUser) {
formCreateUser.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(formCreateUser);
const res = await fetch('/editor/admin/users', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User created', 'success'); window.location.reload(); }
else { showNotification('Create user failed: ' + (data.error || res.statusText), 'error'); }
});
}
// Delete user
document.querySelectorAll('[data-action="delete-user"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const row = e.currentTarget.closest('[data-user-id]');
const id = row && row.getAttribute('data-user-id');
const username = (row && row.getAttribute('data-username')) || '';
if (!id) return;
if (username === 'admin') { showNotification('Cannot delete default admin user', 'error'); return; }
if (!confirm('Delete user ' + username + ' ?')) return;
const res = await fetch('/editor/admin/users/' + encodeURIComponent(id), { method: 'DELETE', headers: { 'X-CSRF-Token': getCSRF() } });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User deleted', 'success'); window.location.reload(); }
else { showNotification('Delete user failed: ' + (data.error || res.statusText), 'error'); }
});
});
// Activate/deactivate user
document.querySelectorAll('[data-action="user-activate"], [data-action="user-deactivate"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const row = e.currentTarget.closest('[data-user-id]');
const id = row && row.getAttribute('data-user-id');
const action = e.currentTarget.getAttribute('data-action');
const active = action === 'user-activate' ? '1' : '0';
const fd = new FormData();
fd.set('active', active);
const res = await fetch('/editor/admin/users/' + encodeURIComponent(id) + '/active', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User status updated', 'success'); window.location.reload(); }
else { showNotification('Update status failed: ' + (data.error || res.statusText), 'error'); }
});
});
// MFA actions
const mfaRequest = async (row, path, okMsg) => {
const id = row && row.getAttribute('data-user-id');
const res = await fetch('/editor/admin/users/' + encodeURIComponent(id) + path, { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() } });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification(okMsg, 'success'); window.location.reload(); }
else { showNotification('MFA action failed: ' + (data.error || res.statusText), 'error'); }
};
document.querySelectorAll('[data-action="mfa-enable"]').forEach(btn => btn.addEventListener('click', (e) => mfaRequest(e.currentTarget.closest('[data-user-id]'), '/mfa/enable', 'MFA enabled')));
document.querySelectorAll('[data-action="mfa-disable"]').forEach(btn => btn.addEventListener('click', (e) => mfaRequest(e.currentTarget.closest('[data-user-id]'), '/mfa/disable', 'MFA disabled')));
document.querySelectorAll('[data-action="mfa-reset"]').forEach(btn => btn.addEventListener('click', (e) => mfaRequest(e.currentTarget.closest('[data-user-id]'), '/mfa/reset', 'MFA reset')));
// Create group
const formCreateGroup = document.getElementById('form-create-group');
if (formCreateGroup) {
formCreateGroup.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(formCreateGroup);
const res = await fetch('/editor/admin/groups', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('Group created', 'success'); window.location.reload(); }
else { showNotification('Create group failed: ' + (data.error || res.statusText), 'error'); }
});
}
// Delete group
document.querySelectorAll('[data-action="delete-group"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const card = e.currentTarget.closest('[data-group-id]');
const id = card && card.getAttribute('data-group-id');
const name = (card && card.getAttribute('data-group-name')) || '';
if (!id) return;
if (name === 'admin' || name === 'public') { showNotification('Cannot delete core group: ' + name, 'error'); return; }
if (!confirm('Delete group ' + name + ' ?')) return;
const res = await fetch('/editor/admin/groups/' + encodeURIComponent(id), { method: 'DELETE', headers: { 'X-CSRF-Token': getCSRF() } });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('Group deleted', 'success'); window.location.reload(); }
else { showNotification('Delete group failed: ' + (data.error || res.statusText), 'error'); }
});
});
// Add membership
const formAddMem = document.getElementById('form-add-membership');
if (formAddMem) {
formAddMem.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(formAddMem);
const res = await fetch('/editor/admin/memberships/add', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User added to group', 'success'); }
else { showNotification('Add membership failed: ' + (data.error || res.statusText), 'error'); }
});
}
// Remove membership
const formRemMem = document.getElementById('form-remove-membership');
if (formRemMem) {
formRemMem.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(formRemMem);
const res = await fetch('/editor/admin/memberships/remove', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: fd });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) { showNotification('User removed from group', 'success'); }
else { showNotification('Remove membership failed: ' + (data.error || res.statusText), 'error'); }
});
}
});
</script>
{{end}}

View File

@@ -271,9 +271,28 @@
<button id="open-search" class="text-gray-400 hover:text-white transition-colors" title="Search" aria-label="Search"> <button id="open-search" class="text-gray-400 hover:text-white transition-colors" title="Search" aria-label="Search">
<i class="fas fa-magnifying-glass"></i> <i class="fas fa-magnifying-glass"></i>
</button> </button>
<a href="/settings" class="text-gray-400 hover:text-white transition-colors" title="Settings"> {{if .Authenticated}}
<i class="fas fa-cog"></i> {{if .IsAdmin}}
</a> <a href="/editor/admin" class="text-gray-400 hover:text-white transition-colors" title="Admin">
<i class="fas fa-user-shield"></i>
</a>
{{end}}
<a href="/editor/profile" class="text-gray-400 hover:text-white transition-colors" title="Profile">
<i class="fas fa-user"></i>
</a>
<a href="/editor/settings" class="text-gray-400 hover:text-white transition-colors" title="Settings">
<i class="fas fa-gear"></i>
</a>
{{end}}
{{if .Authenticated}}
<button id="logout-btn" class="text-gray-400 hover:text-white transition-colors" title="Logout">
<i class="fas fa-right-from-bracket"></i>
</button>
{{else}}
<a href="/editor/login" class="text-gray-400 hover:text-white transition-colors" title="Login">
<i class="fas fa-right-to-bracket"></i>
</a>
{{end}}
</div> </div>
<button id="sidebar-toggle" class="toggle-btn" title="Toggle sidebar" aria-label="Toggle sidebar"> <button id="sidebar-toggle" class="toggle-btn" title="Toggle sidebar" aria-label="Toggle sidebar">
<i id="sidebar-toggle-icon" class="fas fa-chevron-left"></i> <i id="sidebar-toggle-icon" class="fas fa-chevron-left"></i>
@@ -284,9 +303,11 @@
<!-- Navigation --> <!-- Navigation -->
<div class="sidebar-content px-4 py-4"> <div class="sidebar-content px-4 py-4">
<a href="/create" class="btn-primary text-sm w-full text-center"> {{if .Authenticated}}
<i class="fas fa-plus mr-2"></i>New Note <a href="/editor/create" class="btn-primary text-sm w-full text-center">
</a> <i class="fas fa-plus mr-2"></i>New Note
</a>
{{end}}
</div> </div>
<!-- File Tree --> <!-- File Tree -->
@@ -334,8 +355,18 @@
{{template "edit_content" .}} {{template "edit_content" .}}
{{else if eq .Page "settings"}} {{else if eq .Page "settings"}}
{{template "settings_content" .}} {{template "settings_content" .}}
{{else if eq .Page "admin"}}
{{template "admin_content" .}}
{{else if eq .Page "profile"}}
{{template "profile_content" .}}
{{else if eq .Page "error"}} {{else if eq .Page "error"}}
{{template "error_content" .}} {{template "error_content" .}}
{{else if eq .Page "login"}}
{{template "login_content" .}}
{{else if eq .Page "mfa"}}
{{template "mfa_content" .}}
{{else if eq .Page "mfa_setup"}}
{{template "mfa_setup_content" .}}
{{end}} {{end}}
</div> </div>
</div> </div>
@@ -653,6 +684,29 @@
if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) closeSearch(); if (e.key === 'Escape' && !searchModal.classList.contains('hidden')) closeSearch();
}); });
} }
// Logout handler (CSRF protected)
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', async () => {
try {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
const res = await fetch('/editor/logout', {
method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
});
if (res.ok) {
window.location.href = '/editor/login';
} else {
const data = await res.json().catch(() => ({}));
showNotification('Logout failed: ' + (data.error || res.statusText), 'error');
}
} catch (e) {
showNotification('Logout error: ' + e.message, 'error');
}
});
}
}); });
</script> </script>
@@ -670,8 +724,18 @@
{{template "edit_scripts" .}} {{template "edit_scripts" .}}
{{else if eq .Page "settings"}} {{else if eq .Page "settings"}}
{{template "settings_scripts" .}} {{template "settings_scripts" .}}
{{else if eq .Page "admin"}}
{{template "admin_scripts" .}}
{{else if eq .Page "profile"}}
{{template "profile_scripts" .}}
{{else if eq .Page "error"}} {{else if eq .Page "error"}}
{{template "error_scripts" .}} {{template "error_scripts" .}}
{{else if eq .Page "login"}}
{{template "login_scripts" .}}
{{else if eq .Page "mfa"}}
{{template "mfa_scripts" .}}
{{else if eq .Page "mfa_setup"}}
{{template "mfa_setup_scripts" .}}
{{end}} {{end}}
</body> </body>
</html> </html>

View File

@@ -93,7 +93,7 @@ console.log('Hello, World!');
const editorGrid = document.getElementById('editor-grid'); const editorGrid = document.getElementById('editor-grid');
const togglePreviewBtn = document.getElementById('toggle-preview'); const togglePreviewBtn = document.getElementById('toggle-preview');
const imageStorageMode = {{.image_storage_mode}}; const imageStorageMode = parseInt('{{.image_storage_mode}}', 10) || 1;
const imageSubfolderName = "{{.image_subfolder_name}}"; const imageSubfolderName = "{{.image_subfolder_name}}";
const currentFolderPath = "{{.folder_path}}"; const currentFolderPath = "{{.folder_path}}";
@@ -174,8 +174,12 @@ console.log('Hello, World!');
formData.append('content', content); formData.append('content', content);
formData.append('folder_path', folderPath); formData.append('folder_path', folderPath);
fetch('/create', { // CSRF token from cookie
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/create', {
method: 'POST', method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData body: formData
}) })
.then(response => response.json()) .then(response => response.json())

View File

@@ -84,7 +84,7 @@
const editorGrid = document.getElementById('editor-grid'); const editorGrid = document.getElementById('editor-grid');
const togglePreviewBtn = document.getElementById('toggle-preview'); const togglePreviewBtn = document.getElementById('toggle-preview');
const imageStorageMode = {{.image_storage_mode}}; const imageStorageMode = parseInt('{{.image_storage_mode}}', 10) || 1;
const imageSubfolderName = "{{.image_subfolder_name}}"; const imageSubfolderName = "{{.image_subfolder_name}}";
const currentFolderPath = "{{.folder_path}}"; const currentFolderPath = "{{.folder_path}}";
const currentNotePath = "{{.note_path}}"; const currentNotePath = "{{.note_path}}";
@@ -158,8 +158,12 @@
const formData = new FormData(); const formData = new FormData();
formData.append('content', content); formData.append('content', content);
fetch('/edit/' + notePath, { // CSRF token from cookie
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/edit/' + notePath, {
method: 'POST', method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData body: formData
}) })
.then(response => response.json()) .then(response => response.json())

View File

@@ -169,7 +169,9 @@
} }
const formData = new FormData(); const formData = new FormData();
formData.append('content', cm ? cm.getValue() : contentEl.value); formData.append('content', cm ? cm.getValue() : contentEl.value);
fetch('/edit_text/' + filePath, { method: 'POST', body: formData }) const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch('/editor/edit_text/' + filePath, { method: 'POST', headers: csrf ? { 'X-CSRF-Token': csrf } : {}, body: formData })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {

View File

@@ -26,7 +26,7 @@
<button id="upload-btn" class="btn-primary"> <button id="upload-btn" class="btn-primary">
<i class="fas fa-upload mr-2"></i>Upload File <i class="fas fa-upload mr-2"></i>Upload File
</button> </button>
<a href="/create?folder={{.folder_path}}" class="btn-secondary"> <a href="/editor/create?folder={{.folder_path}}" class="btn-secondary">
<i class="fas fa-plus mr-2"></i>New Note <i class="fas fa-plus mr-2"></i>New Note
</a> </a>
</div> </div>
@@ -70,12 +70,12 @@
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{{if eq .Type "md"}} {{if eq .Type "md"}}
<a href="/edit/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit"> <a href="/editor/edit/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
{{end}} {{end}}
{{if eq .Type "text"}} {{if eq .Type "text"}}
<a href="/edit_text/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit"> <a href="/editor/edit_text/{{.Path}}" class="text-blue-400 hover:text-blue-300 p-2" title="Edit">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
{{end}} {{end}}
@@ -179,8 +179,11 @@
formData.append('file', file); formData.append('file', file);
} }
fetch('/upload', { const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch('/editor/upload', {
method: 'POST', method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData body: formData
}) })
.then(response => response.json()) .then(response => response.json())
@@ -237,8 +240,11 @@
document.getElementById('confirm-delete').addEventListener('click', function() { document.getElementById('confirm-delete').addEventListener('click', function() {
if (deleteTarget) { if (deleteTarget) {
fetch('/delete/' + deleteTarget, { const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
method: 'DELETE' const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch('/editor/delete/' + deleteTarget, {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {}
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {

33
web/templates/login.html Normal file
View File

@@ -0,0 +1,33 @@
{{define "login"}}
{{template "base" .}}
{{end}}
{{define "login_content"}}
<div class="max-w-md mx-auto p-6">
<h1 class="text-2xl font-bold text-white mb-4">Sign in</h1>
{{if .error}}
<div class="bg-red-900/50 border border-red-700 text-red-200 rounded p-3 mb-4">{{.error}}</div>
{{end}}
<form method="POST" action="/editor/login" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}" />
<div>
<label class="block text-sm text-gray-300 mb-1" for="username">Username or Email</label>
<input id="username" name="username" type="text" required class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white" />
</div>
<div>
<label class="block text-sm text-gray-300 mb-1" for="password">Password</label>
<input id="password" name="password" type="password" required class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white" />
</div>
<div class="flex items-center justify-between">
<button type="submit" class="btn-primary"><i class="fas fa-sign-in-alt mr-2"></i>Login</button>
<a href="#" class="text-sm text-blue-400 hover:underline">Forgot password?</a>
</div>
</form>
</div>
{{end}}
{{define "login_scripts"}}
<script>
// optional: add client-side logic here
</script>
{{end}}

35
web/templates/mfa.html Normal file
View File

@@ -0,0 +1,35 @@
{{define "mfa"}}
{{template "base" .}}
{{end}}
{{define "mfa_content"}}
<div class="max-w-md mx-auto p-6">
<h1 class="text-2xl font-bold text-white mb-2">MultiFactor Authentication</h1>
<p class="text-gray-400 mb-6">Enter the 6digit code from your authenticator app.</p>
{{if .error}}
<div class="mb-4 p-3 rounded bg-red-700 text-white">{{.error}}</div>
{{end}}
<form id="mfa-form" class="space-y-4" method="POST" action="/editor/mfa">
<input type="hidden" name="csrf_token" value="{{.csrf_token}}" />
<div>
<label for="code" class="block text-sm font-medium text-gray-300 mb-2">Authentication code</label>
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" class="form-input" placeholder="123456" required />
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary"><i class="fas fa-right-to-bracket mr-2"></i>Verify</button>
</div>
</form>
</div>
{{end}}
{{define "mfa_scripts"}}
<script>
// Optional: autofocus the code field
document.addEventListener('DOMContentLoaded', () => {
const code = document.getElementById('code');
if (code) code.focus();
});
</script>
{{end}}

View File

@@ -0,0 +1,62 @@
{{define "mfa_setup"}}
{{template "base" .}}
{{end}}
{{define "mfa_setup_content"}}
<div class="max-w-lg mx-auto p-6">
<h1 class="text-2xl font-bold text-white mb-2">Set up MultiFactor Authentication</h1>
<p class="text-gray-400 mb-6">Scan the QR code below with your authenticator app (Google Authenticator, Authy, 1Password, etc.), or enter the secret manually, then enter a code to confirm.</p>
<div class="bg-slate-800 border border-slate-700 rounded p-4 mb-4">
<div class="flex items-start gap-4">
<img alt="QR Code" class="bg-white rounded p-2" src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={{urlquery .OTPAuthURI}}" width="180" height="180"/>
<div class="flex-1">
<div class="text-sm text-gray-400">Secret</div>
<div class="font-mono text-lg text-white break-all">{{.Secret}}</div>
<div class="text-sm text-gray-400 mt-2">URI</div>
<div class="text-xs text-gray-300 break-all">{{.OTPAuthURI}}</div>
</div>
</div>
</div>
<form id="mfa-verify-form" class="space-y-4">
<div>
<label for="code" class="block text-sm font-medium text-gray-300 mb-2">Enter 6digit code</label>
<input id="code" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" class="form-input" placeholder="123456" required />
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary"><i class="fas fa-check mr-2"></i>Verify and Enable</button>
</div>
</form>
</div>
{{end}}
{{define "mfa_setup_scripts"}}
<script>
function getCSRF() {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return m && m[1] ? decodeURIComponent(m[1]) : '';
}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('mfa-verify-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const params = new URLSearchParams(Array.from(fd.entries()));
try {
const res = await fetch('/editor/profile/mfa/verify', { method: 'POST', headers: { 'X-CSRF-Token': getCSRF() }, body: params });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
showNotification('MFA enabled', 'success');
window.location.href = '/editor/profile';
} else {
throw new Error(data.error || res.statusText);
}
} catch (e) {
showNotification('Verification failed: ' + e.message, 'error');
}
});
});
</script>
{{end}}

View File

@@ -9,15 +9,19 @@
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white">{{.title}}</h1> <h1 class="text-3xl font-bold text-white">{{.title}}</h1>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<a href="/edit/{{.note_path}}" class="btn-primary"> {{if .Authenticated}}
<a href="/editor/edit/{{.note_path}}" class="btn-primary">
<i class="fas fa-edit mr-2"></i>Edit <i class="fas fa-edit mr-2"></i>Edit
</a> </a>
{{end}}
<a href="/download/{{.note_path}}" class="btn-secondary"> <a href="/download/{{.note_path}}" class="btn-secondary">
<i class="fas fa-download mr-2"></i>Download <i class="fas fa-download mr-2"></i>Download
</a> </a>
{{if .Authenticated}}
<button class="btn-danger delete-note-btn" data-path="{{.note_path}}"> <button class="btn-danger delete-note-btn" data-path="{{.note_path}}">
<i class="fas fa-trash mr-2"></i>Delete <i class="fas fa-trash mr-2"></i>Delete
</button> </button>
{{end}}
</div> </div>
</div> </div>
@@ -73,8 +77,11 @@ document.addEventListener('DOMContentLoaded', function() {
if (confirmBtn) { if (confirmBtn) {
confirmBtn.addEventListener('click', function() { confirmBtn.addEventListener('click', function() {
const path = deleteBtn.dataset.path; const path = deleteBtn.dataset.path;
fetch(`/delete/${path}`, { const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
method: 'DELETE' const csrf = m && m[1] ? decodeURIComponent(m[1]) : '';
fetch(`/editor/delete/${path}`, {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {}
}) })
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {

175
web/templates/profile.html Normal file
View File

@@ -0,0 +1,175 @@
{{define "profile"}}
{{template "base" .}}
{{end}}
{{define "profile_content"}}
<div class="max-w-3xl mx-auto p-6">
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Your Profile</h1>
<p class="text-gray-400">Manage your account details and security</p>
</div>
<div class="space-y-8">
<!-- Email -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-envelope mr-2"></i>Email</h2>
<form id="email-form" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-2">Email</label>
<input type="email" id="email" name="email" value="{{.Email}}"
class="form-input" placeholder="you@example.com" required>
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary">
<i class="fas fa-save mr-2"></i>Update Email
</button>
</div>
</form>
</div>
<!-- Password -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-key mr-2"></i>Change Password</h2>
<form id="password-form" class="space-y-4">
<div>
<label for="current_password" class="block text-sm font-medium text-gray-300 mb-2">Current Password</label>
<input type="password" id="current_password" name="current_password" class="form-input" required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="new_password" class="block text-sm font-medium text-gray-300 mb-2">New Password</label>
<input type="password" id="new_password" name="new_password" class="form-input" minlength="8" required>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-300 mb-2">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" class="form-input" minlength="8" required>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary">
<i class="fas fa-save mr-2"></i>Change Password
</button>
</div>
</form>
</div>
<!-- MFA -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">
<i class="fas fa-shield-halved mr-2"></i>MultiFactor Authentication</h2>
<div class="flex items-center justify-between">
<div>
<div class="text-white font-medium">Status: <span id="mfa-status" class="ml-1">{{if .MFAEnabled}}Enabled{{else}}Disabled{{end}}</span></div>
<div class="text-sm text-gray-400">Add an extra layer of security to your account</div>
</div>
<div class="space-x-2">
<button id="mfa-enable" class="btn-primary {{if .MFAEnabled}}hidden{{end}}"><i class="fas fa-toggle-on mr-2"></i>Enable</button>
<button id="mfa-disable" class="btn-danger {{if not .MFAEnabled}}hidden{{end}}"><i class="fas fa-toggle-off mr-2"></i>Disable</button>
</div>
</div>
<p class="text-xs text-gray-500 mt-3">Note: This simple flow generates or clears your MFA secret. A full QR/TOTP enrollment flow can be added later.</p>
</div>
</div>
</div>
{{end}}
{{define "profile_scripts"}}
<script>
function getCSRF() {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
return m && m[1] ? decodeURIComponent(m[1]) : '';
}
function formToJSON(form) {
const fd = new FormData(form);
return new URLSearchParams(Array.from(fd.entries()));
}
document.addEventListener('DOMContentLoaded', () => {
const emailForm = document.getElementById('email-form');
const passwordForm = document.getElementById('password-form');
const btnEnable = document.getElementById('mfa-enable');
const btnDisable = document.getElementById('mfa-disable');
const mfaStatus = document.getElementById('mfa-status');
if (emailForm) emailForm.addEventListener('submit', async (e) => {
e.preventDefault();
try {
const res = await fetch('/editor/profile/email', {
method: 'POST',
headers: Object.assign({'X-CSRF-Token': getCSRF()}),
body: formToJSON(emailForm)
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
showNotification('Email updated', 'success');
} else {
throw new Error(data.error || res.statusText);
}
} catch (err) {
showNotification('Update failed: ' + err.message, 'error');
}
});
if (passwordForm) passwordForm.addEventListener('submit', async (e) => {
e.preventDefault();
const newpw = document.getElementById('new_password').value;
const conf = document.getElementById('confirm_password').value;
if (newpw !== conf) {
showNotification('New passwords do not match', 'error');
return;
}
try {
const res = await fetch('/editor/profile/password', {
method: 'POST',
headers: Object.assign({'X-CSRF-Token': getCSRF()}),
body: formToJSON(passwordForm)
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
passwordForm.reset();
showNotification('Password changed', 'success');
} else {
throw new Error(data.error || res.statusText);
}
} catch (err) {
showNotification('Password change failed: ' + err.message, 'error');
}
});
async function toggleMFA(enable) {
try {
const url = enable ? '/editor/profile/mfa/enable' : '/editor/profile/mfa/disable';
const res = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCSRF()} });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
if (enable) {
if (data.setup && data.redirect) {
// Enrollment flow: go to setup page; do not toggle UI yet
window.location.href = data.redirect;
return;
}
// Direct enable (no setup)
btnEnable.classList.add('hidden');
btnDisable.classList.remove('hidden');
mfaStatus.textContent = 'Enabled';
} else {
btnDisable.classList.add('hidden');
btnEnable.classList.remove('hidden');
mfaStatus.textContent = 'Disabled';
}
showNotification('MFA ' + (enable ? 'enabled' : 'disabled'), 'success');
} else {
throw new Error(data.error || res.statusText);
}
} catch (err) {
showNotification('MFA update failed: ' + err.message, 'error');
}
}
if (btnEnable) btnEnable.addEventListener('click', (e) => { e.preventDefault(); toggleMFA(true); });
if (btnDisable) btnDisable.addEventListener('click', (e) => { e.preventDefault(); toggleMFA(false); });
});
</script>
{{end}}

View File

@@ -179,7 +179,7 @@
// Load current settings // Load current settings
function loadSettings() { function loadSettings() {
// Load image storage settings // Load image storage settings
fetch('/settings/image_storage') fetch('/editor/settings/image_storage')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
document.querySelector(`input[name="storage_mode"][value="${data.mode}"]`).checked = true; document.querySelector(`input[name="storage_mode"][value="${data.mode}"]`).checked = true;
@@ -190,7 +190,7 @@
.catch(error => console.error('Error loading image storage settings:', error)); .catch(error => console.error('Error loading image storage settings:', error));
// Load notes directory settings // Load notes directory settings
fetch('/settings/notes_dir') fetch('/editor/settings/notes_dir')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
document.getElementById('notes_dir').value = data.notes_dir || ''; document.getElementById('notes_dir').value = data.notes_dir || '';
@@ -198,7 +198,7 @@
.catch(error => console.error('Error loading notes directory settings:', error)); .catch(error => console.error('Error loading notes directory settings:', error));
// Load file extensions settings // Load file extensions settings
fetch('/settings/file_extensions') fetch('/editor/settings/file_extensions')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
document.getElementById('allowed_image_extensions').value = data.allowed_image_extensions || ''; document.getElementById('allowed_image_extensions').value = data.allowed_image_extensions || '';
@@ -237,9 +237,12 @@
e.preventDefault(); e.preventDefault();
const formData = new FormData(this); const formData = new FormData(this);
fetch('/settings/image_storage', { const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/image_storage', {
method: 'POST', method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData body: formData
}) })
.then(response => response.json()) .then(response => response.json())
@@ -263,9 +266,12 @@
e.preventDefault(); e.preventDefault();
const formData = new FormData(this); const formData = new FormData(this);
fetch('/settings/notes_dir', { const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/notes_dir', {
method: 'POST', method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData body: formData
}) })
.then(response => response.json()) .then(response => response.json())
@@ -289,9 +295,12 @@
e.preventDefault(); e.preventDefault();
const formData = new FormData(this); const formData = new FormData(this);
fetch('/settings/file_extensions', { const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
fetch('/editor/settings/file_extensions', {
method: 'POST', method: 'POST',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
body: formData body: formData
}) })
.then(response => response.json()) .then(response => response.json())

View File

@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>Test Page Works!</h1>
<p>App Name: {{.app_name}}</p>
<p>Folder Contents Count: {{len .folder_contents}}</p>
{{if .folder_contents}}
<ul>
{{range .folder_contents}}
<li>{{.DisplayName}} ({{.Type}})</li>
{{end}}
</ul>
{{end}}
</body>
</html>

View File

@@ -9,7 +9,7 @@
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h1 class="text-3xl font-bold text-white">{{.file_name}}</h1> <h1 class="text-3xl font-bold text-white">{{.file_name}}</h1>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
{{if .is_editable}} {{if and .Authenticated .is_editable}}
<a href="/edit_text/{{.file_path}}" class="btn-primary"> <a href="/edit_text/{{.file_path}}" class="btn-primary">
<i class="fas fa-edit mr-2"></i>Edit <i class="fas fa-edit mr-2"></i>Edit
</a> </a>
@@ -17,9 +17,11 @@
<a href="/download/{{.file_path}}" class="btn-secondary"> <a href="/download/{{.file_path}}" class="btn-secondary">
<i class="fas fa-download mr-2"></i>Download <i class="fas fa-download mr-2"></i>Download
</a> </a>
{{if .Authenticated}}
<button class="btn-danger delete-file-btn" data-path="{{.file_path}}"> <button class="btn-danger delete-file-btn" data-path="{{.file_path}}">
<i class="fas fa-trash mr-2"></i>Delete <i class="fas fa-trash mr-2"></i>Delete
</button> </button>
{{end}}
</div> </div>
</div> </div>