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

Admin Dashboard

+

Manage users, groups, and permissions

+
+ +
+ +
+

+ Users +

+ +
+ + + + +
+
+ + + + + + + + + + + + {{if .users}} + {{range .users}} + + + + + + + + {{end}} + {{else}} + + {{end}} + +
UsernameEmailStatusMFAActions
{{.Username}}{{.Email}} + {{if .IsActive}}Active{{else}}Disabled{{end}} + + {{if .MFASecret.Valid}}Enabled{{else}}None{{end}} + + {{if ne .ID $.CurrentUserID}} + {{if .IsActive}} + + {{else}} + + {{end}} + {{if .MFASecret.Valid}} + + + {{else}} + + {{end}} + + {{else}} + Actions disabled for current user + {{end}} +
No users found.
+
+
+ + +
+

+ Groups +

+
+ + +
+
+ + + + + + + + + {{if .groups}} + {{range .groups}} + + + + + {{end}} + {{else}} + + {{end}} + +
NameActions
{{.Name}} + +
No groups found.
+
+
+ + +
+

+ Permissions

+
+ + + + + + + + + + + + {{if .permissions}} + {{range .permissions}} + + + + + + + + {{end}} + {{else}} + + {{end}} + +
GroupPathReadWriteDelete
{{.Group}}{{.Path}}{{if .CanRead}}✅{{else}}❌{{end}}{{if .CanWrite}}✅{{else}}❌{{end}}{{if .CanDelete}}✅{{else}}❌{{end}}
No permissions configured.
+
+ +

Manage Memberships

+
+ + + +
+
+ + + +
+
+
+
+{{end}} + +{{define "admin_scripts"}} + +{{end}} diff --git a/web/templates/base.html b/web/templates/base.html index 66cb5d4..4f52312 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -271,9 +271,28 @@ - - - + {{if .Authenticated}} + {{if .IsAdmin}} + + + + {{end}} + + + + + + + {{end}} + {{if .Authenticated}} + + {{else}} + + + + {{end}} - + New Note @@ -70,12 +70,12 @@
{{if eq .Type "md"}} - + {{end}} {{if eq .Type "text"}} - + {{end}} @@ -179,8 +179,11 @@ 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', + headers: csrf ? { 'X-CSRF-Token': csrf } : {}, body: formData }) .then(response => response.json()) @@ -237,8 +240,11 @@ document.getElementById('confirm-delete').addEventListener('click', function() { if (deleteTarget) { - fetch('/delete/' + deleteTarget, { - method: 'DELETE' + const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/); + 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(data => { diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..4831b8f --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,33 @@ +{{define "login"}} + {{template "base" .}} +{{end}} + +{{define "login_content"}} +
+

Sign in

+ {{if .error}} +
{{.error}}
+ {{end}} +
+ +
+ + +
+
+ + +
+
+ + Forgot password? +
+
+
+{{end}} + +{{define "login_scripts"}} + +{{end}} diff --git a/web/templates/mfa.html b/web/templates/mfa.html new file mode 100644 index 0000000..39ba62c --- /dev/null +++ b/web/templates/mfa.html @@ -0,0 +1,35 @@ +{{define "mfa"}} + {{template "base" .}} +{{end}} + +{{define "mfa_content"}} +
+

Multi‑Factor Authentication

+

Enter the 6‑digit code from your authenticator app.

+ + {{if .error}} +
{{.error}}
+ {{end}} + +
+ +
+ + +
+
+ +
+
+
+{{end}} + +{{define "mfa_scripts"}} + +{{end}} diff --git a/web/templates/mfa_setup.html b/web/templates/mfa_setup.html new file mode 100644 index 0000000..e64a52c --- /dev/null +++ b/web/templates/mfa_setup.html @@ -0,0 +1,62 @@ +{{define "mfa_setup"}} + {{template "base" .}} +{{end}} + +{{define "mfa_setup_content"}} +
+

Set up Multi‑Factor Authentication

+

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.

+ +
+
+ QR Code +
+
Secret
+
{{.Secret}}
+
URI
+
{{.OTPAuthURI}}
+
+
+
+ +
+
+ + +
+
+ +
+
+
+{{end}} + +{{define "mfa_setup_scripts"}} + +{{end}} diff --git a/web/templates/note.html b/web/templates/note.html index 03e5f27..90507c8 100644 --- a/web/templates/note.html +++ b/web/templates/note.html @@ -9,15 +9,19 @@

{{.title}}

- + {{if .Authenticated}} + Edit + {{end}} Download + {{if .Authenticated}} + {{end}}
@@ -73,8 +77,11 @@ document.addEventListener('DOMContentLoaded', function() { if (confirmBtn) { confirmBtn.addEventListener('click', function() { const path = deleteBtn.dataset.path; - fetch(`/delete/${path}`, { - method: 'DELETE' + const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/); + const csrf = m && m[1] ? decodeURIComponent(m[1]) : ''; + fetch(`/editor/delete/${path}`, { + method: 'DELETE', + headers: csrf ? { 'X-CSRF-Token': csrf } : {} }) .then(response => { if (response.ok) { diff --git a/web/templates/profile.html b/web/templates/profile.html new file mode 100644 index 0000000..36525fb --- /dev/null +++ b/web/templates/profile.html @@ -0,0 +1,175 @@ +{{define "profile"}} + {{template "base" .}} +{{end}} + +{{define "profile_content"}} +
+
+

Your Profile

+

Manage your account details and security

+
+ +
+ +
+

+ Email

+
+
+ + +
+
+ +
+
+
+ + +
+

+ Change Password

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + +
+

+ Multi‑Factor Authentication

+
+
+
Status: {{if .MFAEnabled}}Enabled{{else}}Disabled{{end}}
+
Add an extra layer of security to your account
+
+
+ + +
+
+

Note: This simple flow generates or clears your MFA secret. A full QR/TOTP enrollment flow can be added later.

+
+
+
+{{end}} + +{{define "profile_scripts"}} + +{{end}} diff --git a/web/templates/settings.html b/web/templates/settings.html index f69d0d3..e05e40b 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -179,7 +179,7 @@ // Load current settings function loadSettings() { // Load image storage settings - fetch('/settings/image_storage') + fetch('/editor/settings/image_storage') .then(response => response.json()) .then(data => { 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)); // Load notes directory settings - fetch('/settings/notes_dir') + fetch('/editor/settings/notes_dir') .then(response => response.json()) .then(data => { document.getElementById('notes_dir').value = data.notes_dir || ''; @@ -198,7 +198,7 @@ .catch(error => console.error('Error loading notes directory settings:', error)); // Load file extensions settings - fetch('/settings/file_extensions') + fetch('/editor/settings/file_extensions') .then(response => response.json()) .then(data => { document.getElementById('allowed_image_extensions').value = data.allowed_image_extensions || ''; @@ -237,9 +237,12 @@ e.preventDefault(); 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', + headers: csrf ? { 'X-CSRF-Token': csrf } : {}, body: formData }) .then(response => response.json()) @@ -263,9 +266,12 @@ e.preventDefault(); 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', + headers: csrf ? { 'X-CSRF-Token': csrf } : {}, body: formData }) .then(response => response.json()) @@ -289,9 +295,12 @@ e.preventDefault(); 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', + headers: csrf ? { 'X-CSRF-Token': csrf } : {}, body: formData }) .then(response => response.json()) diff --git a/web/templates/test.html b/web/templates/test.html deleted file mode 100644 index 0ef539c..0000000 --- a/web/templates/test.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - Test Page - - -

Test Page Works!

-

App Name: {{.app_name}}

-

Folder Contents Count: {{len .folder_contents}}

- {{if .folder_contents}} - - {{end}} - - diff --git a/web/templates/view_text.html b/web/templates/view_text.html index 973cab8..726b4fc 100644 --- a/web/templates/view_text.html +++ b/web/templates/view_text.html @@ -9,7 +9,7 @@

{{.file_name}}

- {{if .is_editable}} + {{if and .Authenticated .is_editable}} Edit @@ -17,9 +17,11 @@ Download + {{if .Authenticated}} + {{end}}