server auth
This commit is contained in:
@@ -3,16 +3,25 @@ module github.com/tim/cairnquire/apps/server
|
|||||||
go 1.24.2
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
github.com/go-webauthn/webauthn v0.15.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04
|
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04
|
||||||
github.com/yuin/goldmark v1.8.2
|
github.com/yuin/goldmark v1.8.2
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.1.26 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.6 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,26 +1,54 @@
|
|||||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||||
|
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||||
|
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
|
||||||
|
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
|
||||||
|
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
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/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
|
||||||
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc=
|
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc=
|
||||||
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
||||||
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||||
@@ -61,6 +62,11 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
|||||||
|
|
||||||
syncRepo := sync.NewRepository(db.SQL())
|
syncRepo := sync.NewRepository(db.SQL())
|
||||||
syncService := sync.NewService(syncRepo, service, contentStore, cfg.Content.SourceDir, logger)
|
syncService := sync.NewService(syncRepo, service, contentStore, cfg.Content.SourceDir, logger)
|
||||||
|
authRepo := auth.NewRepository(db.SQL())
|
||||||
|
authService, err := auth.NewService(authRepo, cfg.Auth.PublicOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build auth service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
handler, err := httpserver.New(httpserver.Dependencies{
|
handler, err := httpserver.New(httpserver.Dependencies{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -71,6 +77,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
|||||||
Hub: hub,
|
Hub: hub,
|
||||||
SyncService: syncService,
|
SyncService: syncService,
|
||||||
SyncRepo: syncRepo,
|
SyncRepo: syncRepo,
|
||||||
|
Auth: authService,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build http handler: %w", err)
|
return nil, fmt.Errorf("build http handler: %w", err)
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fileWatcherDebounce = 500 * time.Millisecond
|
||||||
|
|
||||||
type FileWatcher struct {
|
type FileWatcher struct {
|
||||||
watcher *fsnotify.Watcher
|
watcher *fsnotify.Watcher
|
||||||
rootDir string
|
rootDir string
|
||||||
onChange func(path string)
|
onChange func(path string)
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
debounce time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
||||||
@@ -25,9 +29,10 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fw := &FileWatcher{
|
fw := &FileWatcher{
|
||||||
watcher: watcher,
|
watcher: watcher,
|
||||||
rootDir: rootDir,
|
rootDir: rootDir,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
debounce: fileWatcherDebounce,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := fw.addWatches(); err != nil {
|
if err := fw.addWatches(); err != nil {
|
||||||
@@ -39,19 +44,150 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (fw *FileWatcher) addWatches() error {
|
func (fw *FileWatcher) addWatches() error {
|
||||||
return filepath.Walk(fw.rootDir, func(path string, info os.FileInfo, err error) error {
|
return filepath.WalkDir(fw.rootDir, func(path string, entry os.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
if !entry.IsDir() {
|
||||||
if err := fw.watcher.Add(path); err != nil {
|
return nil
|
||||||
fw.logger.Warn("watch directory", "path", path, "error", err)
|
}
|
||||||
}
|
if path != fw.rootDir && fw.shouldIgnorePath(path) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
if err := fw.watcher.Add(path); err != nil {
|
||||||
|
fw.logger.Warn("watch directory", "path", path, "error", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) addWatchIfDirectory(path string) {
|
||||||
|
if fw.shouldIgnorePath(path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := fw.watcher.Add(path); err != nil {
|
||||||
|
fw.logger.Warn("watch new directory", "path", path, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) flushPending(pending map[string]struct{}) {
|
||||||
|
if len(pending) == 0 || fw.onChange == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var changedPath string
|
||||||
|
for path := range pending {
|
||||||
|
changedPath = path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
clear(pending)
|
||||||
|
fw.onChange(changedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) scheduleSync(path string, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
|
||||||
|
pending[path] = struct{}{}
|
||||||
|
|
||||||
|
if *timer == nil {
|
||||||
|
*timer = time.NewTimer(fw.debounce)
|
||||||
|
*timerC = (*timer).C
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(*timer).Stop() {
|
||||||
|
select {
|
||||||
|
case <-(*timer).C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(*timer).Reset(fw.debounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) stopTimer(timer *time.Timer) {
|
||||||
|
if timer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) isMarkdownPath(path string) bool {
|
||||||
|
return strings.EqualFold(filepath.Ext(path), ".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) shouldIgnorePath(path string) bool {
|
||||||
|
rel, err := filepath.Rel(fw.rootDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(part, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if isTempFileName(part) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTempFileName(name string) bool {
|
||||||
|
if strings.HasSuffix(name, "~") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(filepath.Ext(name)) {
|
||||||
|
case ".swp", ".swo", ".tmp", ".temp", ".bak", ".orig", ".part", ".crdownload":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case ".DS_Store", "Thumbs.db":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) isProcessableEvent(event fsnotify.Event) bool {
|
||||||
|
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fw.shouldIgnorePath(event.Name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return fw.isMarkdownPath(event.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FileWatcher) handleEvent(event fsnotify.Event, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) {
|
||||||
|
if event.Op&(fsnotify.Create|fsnotify.Rename) != 0 {
|
||||||
|
fw.addWatchIfDirectory(event.Name)
|
||||||
|
}
|
||||||
|
if fw.isProcessableEvent(event) {
|
||||||
|
fw.scheduleSync(event.Name, pending, timer, timerC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (fw *FileWatcher) SetOnChange(fn func(path string)) {
|
func (fw *FileWatcher) SetOnChange(fn func(path string)) {
|
||||||
fw.onChange = fn
|
fw.onChange = fn
|
||||||
}
|
}
|
||||||
@@ -59,24 +195,24 @@ func (fw *FileWatcher) SetOnChange(fn func(path string)) {
|
|||||||
func (fw *FileWatcher) Run(ctx context.Context) {
|
func (fw *FileWatcher) Run(ctx context.Context) {
|
||||||
defer fw.watcher.Close()
|
defer fw.watcher.Close()
|
||||||
|
|
||||||
|
pending := make(map[string]struct{})
|
||||||
|
var timer *time.Timer
|
||||||
|
var timerC <-chan time.Time
|
||||||
|
defer fw.stopTimer(timer)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
|
case <-timerC:
|
||||||
|
fw.flushPending(pending)
|
||||||
|
timer = nil
|
||||||
|
timerC = nil
|
||||||
case event, ok := <-fw.watcher.Events:
|
case event, ok := <-fw.watcher.Events:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if fw.shouldProcess(event) {
|
fw.handleEvent(event, pending, &timer, &timerC)
|
||||||
if event.Op&fsnotify.Create == fsnotify.Create {
|
|
||||||
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
|
|
||||||
fw.watcher.Add(event.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fw.onChange != nil {
|
|
||||||
fw.onChange(event.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case err, ok := <-fw.watcher.Errors:
|
case err, ok := <-fw.watcher.Errors:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -85,17 +221,3 @@ func (fw *FileWatcher) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fw *FileWatcher) shouldProcess(event fsnotify.Event) bool {
|
|
||||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
name := filepath.Base(event.Name)
|
|
||||||
if strings.HasPrefix(name, ".") || strings.HasSuffix(name, "~") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if filepath.Ext(event.Name) == ".md" || event.Op&fsnotify.Remove != 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
84
apps/server/internal/app/watcher_test.go
Normal file
84
apps/server/internal/app/watcher_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileWatcherFiltersProcessableEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
watcher := &FileWatcher{rootDir: root, logger: slog.Default()}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
op fsnotify.Op
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "markdown write", path: "guide.md", op: fsnotify.Write, want: true},
|
||||||
|
{name: "markdown create", path: "nested/guide.md", op: fsnotify.Create, want: true},
|
||||||
|
{name: "case insensitive markdown", path: "guide.MD", op: fsnotify.Write, want: true},
|
||||||
|
{name: "markdown delete", path: "guide.md", op: fsnotify.Remove, want: true},
|
||||||
|
{name: "non markdown write", path: "logo.webp", op: fsnotify.Write, want: false},
|
||||||
|
{name: "non markdown delete", path: "logo.webp", op: fsnotify.Remove, want: false},
|
||||||
|
{name: "hidden markdown file", path: ".guide.md", op: fsnotify.Write, want: false},
|
||||||
|
{name: "hidden directory", path: ".cache/guide.md", op: fsnotify.Write, want: false},
|
||||||
|
{name: "backup file", path: "guide.md~", op: fsnotify.Write, want: false},
|
||||||
|
{name: "vim swap", path: "guide.md.swp", op: fsnotify.Write, want: false},
|
||||||
|
{name: "emacs lock file", path: "#guide.md#", op: fsnotify.Write, want: false},
|
||||||
|
{name: "chmod only", path: "guide.md", op: fsnotify.Chmod, want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
event := fsnotify.Event{Name: filepath.Join(root, tt.path), Op: tt.op}
|
||||||
|
if got := watcher.isProcessableEvent(event); got != tt.want {
|
||||||
|
t.Fatalf("isProcessableEvent() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileWatcherDebouncesPendingSyncs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
watcher := &FileWatcher{
|
||||||
|
rootDir: root,
|
||||||
|
logger: slog.Default(),
|
||||||
|
debounce: 10 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
var changed []string
|
||||||
|
watcher.SetOnChange(func(path string) {
|
||||||
|
changed = append(changed, path)
|
||||||
|
})
|
||||||
|
|
||||||
|
pending := make(map[string]struct{})
|
||||||
|
var timer *time.Timer
|
||||||
|
var timerC <-chan time.Time
|
||||||
|
defer watcher.stopTimer(timer)
|
||||||
|
|
||||||
|
watcher.scheduleSync(filepath.Join(root, "one.md"), pending, &timer, &timerC)
|
||||||
|
watcher.scheduleSync(filepath.Join(root, "two.md"), pending, &timer, &timerC)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-timerC:
|
||||||
|
watcher.flushPending(pending)
|
||||||
|
case <-time.After(250 * time.Millisecond):
|
||||||
|
t.Fatal("debounce timer did not fire")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(changed) != 1 {
|
||||||
|
t.Fatalf("onChange calls = %d, want 1", len(changed))
|
||||||
|
}
|
||||||
|
if len(pending) != 0 {
|
||||||
|
t.Fatalf("pending changes = %d, want 0", len(pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
171
apps/server/internal/auth/crypto.go
Normal file
171
apps/server/internal/auth/crypto.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
passwordTime uint32 = 3
|
||||||
|
passwordMemory uint32 = 64 * 1024
|
||||||
|
passwordThreads uint8 = 4
|
||||||
|
passwordKeyLen uint32 = 32
|
||||||
|
passwordSaltLen = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
func randomBytes(length int) ([]byte, error) {
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken(length int) (string, error) {
|
||||||
|
buf, err := randomBytes(length)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(length int) (string, error) {
|
||||||
|
buf, err := randomBytes(length)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSecret(secret string) string {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func constantTimeEqualHex(a, b string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPassword(password string) (string, error) {
|
||||||
|
salt, err := randomBytes(passwordSaltLen)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hash := argon2.IDKey([]byte(password), salt, passwordTime, passwordMemory, passwordThreads, passwordKeyLen)
|
||||||
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
passwordMemory,
|
||||||
|
passwordTime,
|
||||||
|
passwordThreads,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(hash),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyPassword(password, encoded string) (bool, error) {
|
||||||
|
parts := strings.Split(encoded, "$")
|
||||||
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
|
return false, fmt.Errorf("invalid password hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
var memory uint64
|
||||||
|
var timeCost uint64
|
||||||
|
var threads uint64
|
||||||
|
for _, param := range strings.Split(parts[3], ",") {
|
||||||
|
keyValue := strings.SplitN(param, "=", 2)
|
||||||
|
if len(keyValue) != 2 {
|
||||||
|
return false, fmt.Errorf("invalid password hash parameters")
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseUint(keyValue[1], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("parse password hash parameter: %w", err)
|
||||||
|
}
|
||||||
|
switch keyValue[0] {
|
||||||
|
case "m":
|
||||||
|
memory = value
|
||||||
|
case "t":
|
||||||
|
timeCost = value
|
||||||
|
case "p":
|
||||||
|
threads = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memory == 0 || timeCost == 0 || threads == 0 {
|
||||||
|
return false, fmt.Errorf("missing password hash parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("decode password salt: %w", err)
|
||||||
|
}
|
||||||
|
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("decode password hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := argon2.IDKey([]byte(password), salt, uint32(timeCost), uint32(memory), uint8(threads), uint32(len(expected)))
|
||||||
|
return subtle.ConstantTimeCompare(actual, expected) == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeScopes(scopes []Scope) []Scope {
|
||||||
|
if len(scopes) == 0 {
|
||||||
|
return []Scope{ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite}
|
||||||
|
}
|
||||||
|
seen := make(map[Scope]struct{}, len(scopes))
|
||||||
|
var normalized []Scope
|
||||||
|
for _, scope := range scopes {
|
||||||
|
switch scope {
|
||||||
|
case ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite, ScopeAdmin:
|
||||||
|
if _, ok := seen[scope]; !ok {
|
||||||
|
seen[scope] = struct{}{}
|
||||||
|
normalized = append(normalized, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(normalized) == 0 {
|
||||||
|
return []Scope{ScopeDocsRead}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopesToString(scopes []Scope) string {
|
||||||
|
normalized := normalizeScopes(scopes)
|
||||||
|
parts := make([]string, 0, len(normalized))
|
||||||
|
for _, scope := range normalized {
|
||||||
|
parts = append(parts, string(scope))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopesFromString(raw string) []Scope {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
scopes := make([]Scope, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
trimmed := strings.TrimSpace(part)
|
||||||
|
if trimmed != "" {
|
||||||
|
scopes = append(scopes, Scope(trimmed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeScopes(scopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasScope(scopes []Scope, required Scope) bool {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
if scope == ScopeAdmin || scope == required {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
534
apps/server/internal/auth/repository.go
Normal file
534
apps/server/internal/auth/repository.go
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(db *sql.DB) *Repository {
|
||||||
|
return &Repository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpsertUser(ctx context.Context, user User) (User, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if user.ID == "" {
|
||||||
|
user.ID = "user:" + user.Email
|
||||||
|
}
|
||||||
|
if user.Role == "" {
|
||||||
|
user.Role = RoleViewer
|
||||||
|
}
|
||||||
|
if user.DisplayName == "" {
|
||||||
|
user.DisplayName = user.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(email) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
password_hash = COALESCE(excluded.password_hash, users.password_hash),
|
||||||
|
role = excluded.role,
|
||||||
|
last_seen_at = excluded.last_seen_at
|
||||||
|
`, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(user.Role), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
|
||||||
|
return User{}, fmt.Errorf("upsert user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetUserByEmail(ctx, user.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
|
var user User
|
||||||
|
var created, lastSeen, passwordHash, role string
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
||||||
|
FROM users
|
||||||
|
WHERE email = ?
|
||||||
|
`, email).Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash, &role, &created, &lastSeen)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
user.PasswordHash = passwordHash
|
||||||
|
user.Role = Role(role)
|
||||||
|
user.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("parse user created_at: %w", err)
|
||||||
|
}
|
||||||
|
if lastSeen != "" {
|
||||||
|
user.LastSeenAt, err = time.Parse(time.RFC3339, lastSeen)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("parse user last_seen_at: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Credentials, err = r.ListCredentials(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetUserByID(ctx context.Context, userID string) (User, error) {
|
||||||
|
var email string
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT email FROM users WHERE id = ?`, userID).Scan(&email); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return r.GetUserByEmail(ctx, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CountUsers(ctx context.Context) (int, error) {
|
||||||
|
var count int
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CountAdmins(ctx context.Context) (int, error) {
|
||||||
|
var count int
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&count); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListUsers(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '')
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []User
|
||||||
|
for rows.Next() {
|
||||||
|
var user User
|
||||||
|
var created, lastSeen, role string
|
||||||
|
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &created, &lastSeen); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan user: %w", err)
|
||||||
|
}
|
||||||
|
user.Role = Role(role)
|
||||||
|
user.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||||
|
if lastSeen != "" {
|
||||||
|
user.LastSeenAt, _ = time.Parse(time.RFC3339, lastSeen)
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpdatePasswordHash(ctx context.Context, userID, passwordHash string) error {
|
||||||
|
result, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?, last_seen_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, passwordHash, time.Now().UTC().Format(time.RFC3339), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpdateUserRole(ctx context.Context, userID string, role Role) error {
|
||||||
|
result, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET role = ?, last_seen_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, string(role), time.Now().UTC().Format(time.RFC3339), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SaveCredential(ctx context.Context, userID string, credential webauthn.Credential) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
payload, err := json.Marshal(credential)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal credential: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO webauthn_credentials (id, user_id, credential_id, credential_json, created_at, last_used_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(credential_id) DO UPDATE SET
|
||||||
|
credential_json = excluded.credential_json,
|
||||||
|
last_used_at = excluded.last_used_at
|
||||||
|
`, "cred:"+hashSecret(string(credential.ID))[:24], userID, credential.ID, string(payload), now, now); err != nil {
|
||||||
|
return fmt.Errorf("save credential: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListCredentials(ctx context.Context, userID string) ([]webauthn.Credential, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT credential_json
|
||||||
|
FROM webauthn_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list credentials: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var credentials []webauthn.Credential
|
||||||
|
for rows.Next() {
|
||||||
|
var raw string
|
||||||
|
if err := rows.Scan(&raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan credential: %w", err)
|
||||||
|
}
|
||||||
|
var credential webauthn.Credential
|
||||||
|
if err := json.Unmarshal([]byte(raw), &credential); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal credential: %w", err)
|
||||||
|
}
|
||||||
|
credentials = append(credentials, credential)
|
||||||
|
}
|
||||||
|
return credentials, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) SaveChallenge(ctx context.Context, userID, challengeType string, session webauthn.SessionData, ttl time.Duration) (string, error) {
|
||||||
|
idPart, err := randomToken(18)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(session)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal challenge: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
id := "chal:" + idPart
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO auth_challenges (id, user_id, type, session_json, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`, id, userID, challengeType, string(payload), now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339)); err != nil {
|
||||||
|
return "", fmt.Errorf("save challenge: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ConsumeChallenge(ctx context.Context, id, challengeType string) (webauthn.SessionData, string, error) {
|
||||||
|
var raw, userID, expires, used string
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT session_json, user_id, expires_at, COALESCE(used_at, '')
|
||||||
|
FROM auth_challenges
|
||||||
|
WHERE id = ? AND type = ?
|
||||||
|
`, id, challengeType).Scan(&raw, &userID, &expires, &used)
|
||||||
|
if err != nil {
|
||||||
|
return webauthn.SessionData{}, "", err
|
||||||
|
}
|
||||||
|
if used != "" {
|
||||||
|
return webauthn.SessionData{}, "", fmt.Errorf("challenge already used")
|
||||||
|
}
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339, expires)
|
||||||
|
if err != nil {
|
||||||
|
return webauthn.SessionData{}, "", fmt.Errorf("parse challenge expiry: %w", err)
|
||||||
|
}
|
||||||
|
if time.Now().UTC().After(expiresAt) {
|
||||||
|
return webauthn.SessionData{}, "", fmt.Errorf("challenge expired")
|
||||||
|
}
|
||||||
|
var session webauthn.SessionData
|
||||||
|
if err := json.Unmarshal([]byte(raw), &session); err != nil {
|
||||||
|
return webauthn.SessionData{}, "", fmt.Errorf("unmarshal challenge: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(ctx, `UPDATE auth_challenges SET used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id); err != nil {
|
||||||
|
return webauthn.SessionData{}, "", fmt.Errorf("consume challenge: %w", err)
|
||||||
|
}
|
||||||
|
return session, userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CreateSession(ctx context.Context, userID, tokenHash, ip, userAgent string, ttl time.Duration) (Session, error) {
|
||||||
|
idPart, err := randomToken(18)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
session := Session{
|
||||||
|
ID: "sess:" + idPart,
|
||||||
|
UserID: userID,
|
||||||
|
TokenHash: tokenHash,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now.Add(ttl),
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at, last_seen_at, ip_address, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, session.ID, session.UserID, session.TokenHash, session.CreatedAt.Format(time.RFC3339), session.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339), ip, userAgent); err != nil {
|
||||||
|
return Session{}, fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ValidateSession(ctx context.Context, tokenHash string) (Session, User, error) {
|
||||||
|
var session Session
|
||||||
|
var created, expires, revoked string
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, user_id, token_hash, created_at, expires_at, COALESCE(revoked_at, '')
|
||||||
|
FROM sessions
|
||||||
|
WHERE token_hash = ?
|
||||||
|
`, tokenHash).Scan(&session.ID, &session.UserID, &session.TokenHash, &created, &expires, &revoked)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, User{}, err
|
||||||
|
}
|
||||||
|
session.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, User{}, err
|
||||||
|
}
|
||||||
|
session.ExpiresAt, err = time.Parse(time.RFC3339, expires)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, User{}, err
|
||||||
|
}
|
||||||
|
if revoked != "" {
|
||||||
|
return Session{}, User{}, fmt.Errorf("session revoked")
|
||||||
|
}
|
||||||
|
if time.Now().UTC().After(session.ExpiresAt) {
|
||||||
|
return Session{}, User{}, fmt.Errorf("session expired")
|
||||||
|
}
|
||||||
|
user, err := r.GetUserByID(ctx, session.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, User{}, err
|
||||||
|
}
|
||||||
|
_, _ = r.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), session.ID)
|
||||||
|
return session, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) RevokeSession(ctx context.Context, sessionID string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `UPDATE sessions SET revoked_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), sessionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CreateAPIKey(ctx context.Context, key APIKey) error {
|
||||||
|
if _, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO api_keys (id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at, revoked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, key.ID, key.UserID, key.Name, key.KeyHash, scopesToString(key.Scopes), key.CreatedAt.Format(time.RFC3339), timePtrString(key.ExpiresAt), timePtrString(key.LastUsedAt), timePtrString(key.RevokedAt)); err != nil {
|
||||||
|
return fmt.Errorf("create api key: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ValidateAPIKey(ctx context.Context, keyID, keyHash string) (APIKey, User, error) {
|
||||||
|
var key APIKey
|
||||||
|
var scopesRaw, created, expires, lastUsed, revoked string
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, user_id, name, key_hash, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '')
|
||||||
|
FROM api_keys
|
||||||
|
WHERE id = ?
|
||||||
|
`, keyID).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyHash, &scopesRaw, &created, &expires, &lastUsed, &revoked)
|
||||||
|
if err != nil {
|
||||||
|
return APIKey{}, User{}, err
|
||||||
|
}
|
||||||
|
if !constantTimeEqualHex(key.KeyHash, keyHash) {
|
||||||
|
return APIKey{}, User{}, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
key.Scopes = scopesFromString(scopesRaw)
|
||||||
|
key.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||||
|
if err != nil {
|
||||||
|
return APIKey{}, User{}, err
|
||||||
|
}
|
||||||
|
if expires != "" {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, expires)
|
||||||
|
if err != nil {
|
||||||
|
return APIKey{}, User{}, err
|
||||||
|
}
|
||||||
|
if time.Now().UTC().After(parsed) {
|
||||||
|
return APIKey{}, User{}, fmt.Errorf("api key expired")
|
||||||
|
}
|
||||||
|
key.ExpiresAt = &parsed
|
||||||
|
}
|
||||||
|
if revoked != "" {
|
||||||
|
return APIKey{}, User{}, fmt.Errorf("api key revoked")
|
||||||
|
}
|
||||||
|
if lastUsed != "" {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, lastUsed)
|
||||||
|
if err == nil {
|
||||||
|
key.LastUsedAt = &parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user, err := r.GetUserByID(ctx, key.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return APIKey{}, User{}, err
|
||||||
|
}
|
||||||
|
_, _ = r.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), key.ID)
|
||||||
|
return key, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT id, user_id, name, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '')
|
||||||
|
FROM api_keys
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var keys []APIKey
|
||||||
|
for rows.Next() {
|
||||||
|
var key APIKey
|
||||||
|
var scopesRaw, created, expires, lastUsed, revoked string
|
||||||
|
if err := rows.Scan(&key.ID, &key.UserID, &key.Name, &scopesRaw, &created, &expires, &lastUsed, &revoked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key.Scopes = scopesFromString(scopesRaw)
|
||||||
|
key.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||||
|
if expires != "" {
|
||||||
|
parsed, _ := time.Parse(time.RFC3339, expires)
|
||||||
|
key.ExpiresAt = &parsed
|
||||||
|
}
|
||||||
|
if lastUsed != "" {
|
||||||
|
parsed, _ := time.Parse(time.RFC3339, lastUsed)
|
||||||
|
key.LastUsedAt = &parsed
|
||||||
|
}
|
||||||
|
if revoked != "" {
|
||||||
|
parsed, _ := time.Parse(time.RFC3339, revoked)
|
||||||
|
key.RevokedAt = &parsed
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
return keys, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
|
||||||
|
result, err := r.db.ExecContext(ctx, `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ?`, time.Now().UTC().Format(time.RFC3339), keyID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CreateDeviceCode(ctx context.Context, device DeviceCode) error {
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO device_codes (id, device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, device.ID, device.DeviceCodeHash, device.UserCodeHash, device.UserCodeDisplay, device.Name, scopesToString(device.Scopes), device.Status, device.CreatedAt.Format(time.RFC3339), device.ExpiresAt.Format(time.RFC3339))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetDeviceCodeByUserCode(ctx context.Context, userCodeHash string) (DeviceCode, error) {
|
||||||
|
return r.getDeviceCode(ctx, `user_code_hash = ?`, userCodeHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetDeviceCodeByDeviceCode(ctx context.Context, deviceCodeHash string) (DeviceCode, error) {
|
||||||
|
return r.getDeviceCode(ctx, `device_code_hash = ?`, deviceCodeHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) getDeviceCode(ctx context.Context, predicate string, value string) (DeviceCode, error) {
|
||||||
|
var device DeviceCode
|
||||||
|
var scopesRaw, created, expires, approved, lastPolled, userID string
|
||||||
|
row := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, COALESCE(user_id, ''), device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at, COALESCE(approved_at, ''), COALESCE(last_polled_at, '')
|
||||||
|
FROM device_codes
|
||||||
|
WHERE `+predicate, value)
|
||||||
|
if err := row.Scan(&device.ID, &userID, &device.DeviceCodeHash, &device.UserCodeHash, &device.UserCodeDisplay, &device.Name, &scopesRaw, &device.Status, &created, &expires, &approved, &lastPolled); err != nil {
|
||||||
|
return DeviceCode{}, err
|
||||||
|
}
|
||||||
|
device.UserID = userID
|
||||||
|
device.Scopes = scopesFromString(scopesRaw)
|
||||||
|
device.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||||
|
device.ExpiresAt, _ = time.Parse(time.RFC3339, expires)
|
||||||
|
if approved != "" {
|
||||||
|
parsed, _ := time.Parse(time.RFC3339, approved)
|
||||||
|
device.ApprovedAt = &parsed
|
||||||
|
}
|
||||||
|
if lastPolled != "" {
|
||||||
|
parsed, _ := time.Parse(time.RFC3339, lastPolled)
|
||||||
|
device.LastPolledAt = &parsed
|
||||||
|
}
|
||||||
|
if time.Now().UTC().After(device.ExpiresAt) && device.Status == "pending" {
|
||||||
|
device.Status = "expired"
|
||||||
|
_, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired' WHERE id = ?`, device.ID)
|
||||||
|
}
|
||||||
|
return device, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ApproveDeviceCode(ctx context.Context, userCodeHash, userID string) (DeviceCode, error) {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
result, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE device_codes
|
||||||
|
SET status = 'approved', user_id = ?, approved_at = ?
|
||||||
|
WHERE user_code_hash = ? AND status = 'pending'
|
||||||
|
`, userID, now, userCodeHash)
|
||||||
|
if err != nil {
|
||||||
|
return DeviceCode{}, err
|
||||||
|
}
|
||||||
|
if rows, _ := result.RowsAffected(); rows == 0 {
|
||||||
|
return DeviceCode{}, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return r.GetDeviceCodeByUserCode(ctx, userCodeHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) TouchDevicePoll(ctx context.Context, id string) {
|
||||||
|
_, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FinishDeviceCode(ctx context.Context, id string) {
|
||||||
|
_, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired', last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) Audit(ctx context.Context, actorUserID, eventType, resourceType, resourceID, ip, userAgent string, metadata map[string]any) {
|
||||||
|
idPart, err := randomToken(18)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if metadata == nil {
|
||||||
|
metadata = map[string]any{}
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
payload = []byte("{}")
|
||||||
|
}
|
||||||
|
_, _ = r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO audit_log (id, actor_user_id, event_type, resource_type, resource_id, ip_address, user_agent, created_at, metadata_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "audit:"+idPart, nullString(actorUserID), eventType, nullString(resourceType), nullString(resourceID), ip, userAgent, time.Now().UTC().Format(time.RFC3339), string(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullString(value string) any {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func timePtrString(value *time.Time) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNoRows(err error) bool {
|
||||||
|
return errors.Is(err, sql.ErrNoRows)
|
||||||
|
}
|
||||||
546
apps/server/internal/auth/service.go
Normal file
546
apps/server/internal/auth/service.go
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionCookieName = "cairnquire_session"
|
||||||
|
sessionTTL = 24 * time.Hour
|
||||||
|
challengeTTL = 5 * time.Minute
|
||||||
|
deviceCodeTTL = 15 * time.Minute
|
||||||
|
devicePollSeconds = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repo *Repository
|
||||||
|
webauthn *webauthn.WebAuthn
|
||||||
|
publicOrigin string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(repo *Repository, publicOrigin string) (*Service, error) {
|
||||||
|
if publicOrigin == "" {
|
||||||
|
publicOrigin = "http://localhost:8080"
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(publicOrigin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse public origin: %w", err)
|
||||||
|
}
|
||||||
|
rpID := parsed.Hostname()
|
||||||
|
if rpID == "" {
|
||||||
|
return nil, fmt.Errorf("public origin must include host")
|
||||||
|
}
|
||||||
|
web, err := webauthn.New(&webauthn.Config{
|
||||||
|
RPID: rpID,
|
||||||
|
RPDisplayName: "Cairnquire",
|
||||||
|
RPOrigins: []string{publicOrigin},
|
||||||
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||||
|
ResidentKey: protocol.ResidentKeyRequirementPreferred,
|
||||||
|
UserVerification: protocol.VerificationPreferred,
|
||||||
|
},
|
||||||
|
AttestationPreference: protocol.PreferNoAttestation,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName, password, role string) (User, error) {
|
||||||
|
email = normalizeEmail(email)
|
||||||
|
if email == "" {
|
||||||
|
return User{}, fmt.Errorf("email is required")
|
||||||
|
}
|
||||||
|
if len(password) < 12 {
|
||||||
|
return User{}, fmt.Errorf("password must be at least 12 characters")
|
||||||
|
}
|
||||||
|
if _, err := s.repo.GetUserByEmail(ctx, email); err == nil {
|
||||||
|
return User{}, fmt.Errorf("user already exists")
|
||||||
|
} else if err != nil && !isNoRows(err) {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
passwordHash, err := hashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
userCount, err := s.repo.CountUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
normalizedRole := RoleViewer
|
||||||
|
if userCount == 0 {
|
||||||
|
normalizedRole = RoleAdmin
|
||||||
|
} else if strings.EqualFold(role, string(RoleViewer)) {
|
||||||
|
normalizedRole = RoleViewer
|
||||||
|
}
|
||||||
|
return s.repo.UpsertUser(ctx, User{
|
||||||
|
Email: email,
|
||||||
|
DisplayName: strings.TrimSpace(displayName),
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Role: normalizedRole,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAgent string) (Principal, string, error) {
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||||
|
if err != nil {
|
||||||
|
if isNoRows(err) {
|
||||||
|
_, _ = verifyPassword(password, mustDummyPasswordHash())
|
||||||
|
}
|
||||||
|
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
if user.PasswordHash == "" {
|
||||||
|
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
ok, err := verifyPassword(password, user.PasswordHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return Principal{}, "", fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
principal, token, err := s.createSession(ctx, user, ip, userAgent)
|
||||||
|
if err == nil {
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.password.login", "user", user.ID, ip, userAgent, nil)
|
||||||
|
}
|
||||||
|
return principal, token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BeginPasskeyRegistration(ctx context.Context, email, displayName, role string) (any, string, error) {
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||||
|
if err != nil {
|
||||||
|
if !isNoRows(err) {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
userCount, err := s.repo.CountUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
normalizedRole := RoleViewer
|
||||||
|
if userCount == 0 {
|
||||||
|
normalizedRole = RoleAdmin
|
||||||
|
}
|
||||||
|
user, err = s.repo.UpsertUser(ctx, User{
|
||||||
|
Email: normalizeEmail(email),
|
||||||
|
DisplayName: strings.TrimSpace(displayName),
|
||||||
|
Role: normalizedRole,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, "", fmt.Errorf("user already exists")
|
||||||
|
}
|
||||||
|
creation, session, err := s.webauthn.BeginRegistration(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return creation, challengeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID string, r *http.Request) (User, error) {
|
||||||
|
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration")
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
credential, err := s.webauthn.FinishRegistration(user, session, r)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.passkey.register", "user", user.ID, clientIP(r), r.UserAgent(), nil)
|
||||||
|
return s.repo.GetUserByID(ctx, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) {
|
||||||
|
user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
assertion, session, err := s.webauthn.BeginLogin(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_login", *session, challengeTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return assertion, challengeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r *http.Request) (Principal, string, error) {
|
||||||
|
session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_login")
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
credential, err := s.webauthn.FinishLogin(user, session, r)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
principal, token, err := s.createSession(ctx, user, clientIP(r), r.UserAgent())
|
||||||
|
if err == nil {
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.passkey.login", "user", user.ID, clientIP(r), r.UserAgent(), nil)
|
||||||
|
}
|
||||||
|
return principal, token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Principal, error) {
|
||||||
|
session, user, err := s.repo.ValidateSession(ctx, hashSecret(token))
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, err
|
||||||
|
}
|
||||||
|
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RevokeSession(ctx context.Context, sessionID string) error {
|
||||||
|
return s.repo.RevokeSession(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ChangePassword(ctx context.Context, principal Principal, currentPassword, newPassword string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
if len(newPassword) < 12 {
|
||||||
|
return fmt.Errorf("password must be at least 12 characters")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if user.PasswordHash != "" {
|
||||||
|
ok, err := verifyPassword(currentPassword, user.PasswordHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passwordHash, err := hashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.UpdatePasswordHash(ctx, user.ID, passwordHash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.password.change", "user", user.ID, "", "", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteAccount(ctx context.Context, principal Principal, currentPassword string) error {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if user.Role == RoleAdmin {
|
||||||
|
admins, err := s.repo.CountAdmins(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if admins <= 1 {
|
||||||
|
return fmt.Errorf("cannot delete the last admin account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user.PasswordHash != "" {
|
||||||
|
ok, err := verifyPassword(currentPassword, user.PasswordHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, user.ID, "auth.account.delete", "user", user.ID, "", "", nil)
|
||||||
|
return s.repo.DeleteUser(ctx, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListUsers(ctx context.Context) ([]User, error) {
|
||||||
|
return s.repo.ListUsers(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) {
|
||||||
|
if !Allows(actor, ScopeAdmin) {
|
||||||
|
return User{}, fmt.Errorf("admin role required")
|
||||||
|
}
|
||||||
|
normalizedRole := Role(strings.ToLower(strings.TrimSpace(role)))
|
||||||
|
switch normalizedRole {
|
||||||
|
case RoleViewer, RoleEditor, RoleAdmin:
|
||||||
|
default:
|
||||||
|
return User{}, fmt.Errorf("invalid role")
|
||||||
|
}
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if user.Role == RoleAdmin && normalizedRole != RoleAdmin {
|
||||||
|
admins, err := s.repo.CountAdmins(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if admins <= 1 {
|
||||||
|
return User{}, fmt.Errorf("cannot demote the last admin account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
s.repo.Audit(ctx, actor.UserID, "auth.user.role.update", "user", user.ID, "", "", map[string]any{"role": normalizedRole})
|
||||||
|
return s.repo.GetUserByID(ctx, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) {
|
||||||
|
user, err := s.repo.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
name = "API token"
|
||||||
|
}
|
||||||
|
tokenID, err := randomHex(12)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
secret, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
return CreatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
keyID := "api:" + tokenID
|
||||||
|
token := "cq_pat_" + tokenID + "_" + secret
|
||||||
|
record := APIKey{
|
||||||
|
ID: keyID,
|
||||||
|
UserID: user.ID,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
KeyHash: hashSecret(secret),
|
||||||
|
Scopes: normalizeScopes(scopes),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
if err := s.repo.CreateAPIKey(ctx, record); err != nil {
|
||||||
|
return CreatedAPIKey{}, err
|
||||||
|
}
|
||||||
|
return CreatedAPIKey{Record: record, Token: token}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Principal, error) {
|
||||||
|
keyID, secret, err := parseAPIToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, err
|
||||||
|
}
|
||||||
|
key, user, err := s.repo.ValidateAPIKey(ctx, keyID, hashSecret(secret))
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, err
|
||||||
|
}
|
||||||
|
expiresAt := time.Time{}
|
||||||
|
if key.ExpiresAt != nil {
|
||||||
|
expiresAt = *key.ExpiresAt
|
||||||
|
}
|
||||||
|
return principalFromUser(user, "api_token", "", key.ID, key.Scopes, expiresAt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
|
||||||
|
return s.repo.ListAPIKeys(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
|
||||||
|
return s.repo.RevokeAPIKey(ctx, userID, keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) StartDeviceFlow(ctx context.Context, name string, scopes []Scope) (DeviceStart, error) {
|
||||||
|
deviceSecret, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return DeviceStart{}, err
|
||||||
|
}
|
||||||
|
rawUserCode, err := randomToken(5)
|
||||||
|
if err != nil {
|
||||||
|
return DeviceStart{}, err
|
||||||
|
}
|
||||||
|
userCode := formatUserCode(rawUserCode)
|
||||||
|
idPart, err := randomToken(12)
|
||||||
|
if err != nil {
|
||||||
|
return DeviceStart{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
device := DeviceCode{
|
||||||
|
ID: "dev:" + idPart,
|
||||||
|
DeviceCodeHash: hashSecret(deviceSecret),
|
||||||
|
UserCodeHash: hashSecret(normalizeUserCode(userCode)),
|
||||||
|
UserCodeDisplay: userCode,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
Scopes: normalizeScopes(scopes),
|
||||||
|
Status: "pending",
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now.Add(deviceCodeTTL),
|
||||||
|
}
|
||||||
|
if device.Name == "" {
|
||||||
|
device.Name = "Device"
|
||||||
|
}
|
||||||
|
if err := s.repo.CreateDeviceCode(ctx, device); err != nil {
|
||||||
|
return DeviceStart{}, err
|
||||||
|
}
|
||||||
|
verifyURI := strings.TrimRight(s.publicOrigin, "/") + "/device/verify"
|
||||||
|
return DeviceStart{
|
||||||
|
DeviceCode: deviceSecret,
|
||||||
|
UserCode: userCode,
|
||||||
|
VerificationURI: verifyURI,
|
||||||
|
VerificationURIComplete: verifyURI + "?user_code=" + url.QueryEscape(userCode),
|
||||||
|
ExpiresIn: int(deviceCodeTTL.Seconds()),
|
||||||
|
Interval: devicePollSeconds,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ApproveDeviceFlow(ctx context.Context, principal Principal, userCode string) (DeviceCode, error) {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return DeviceCode{}, fmt.Errorf("authentication required")
|
||||||
|
}
|
||||||
|
return s.repo.ApproveDeviceCode(ctx, hashSecret(normalizeUserCode(userCode)), principal.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (CreatedAPIKey, string, error) {
|
||||||
|
device, err := s.repo.GetDeviceCodeByDeviceCode(ctx, hashSecret(deviceCode))
|
||||||
|
if err != nil {
|
||||||
|
if errorsIsNoRows(err) {
|
||||||
|
return CreatedAPIKey{}, "invalid_device_code", err
|
||||||
|
}
|
||||||
|
return CreatedAPIKey{}, "", err
|
||||||
|
}
|
||||||
|
s.repo.TouchDevicePoll(ctx, device.ID)
|
||||||
|
switch device.Status {
|
||||||
|
case "pending":
|
||||||
|
return CreatedAPIKey{}, "authorization_pending", sql.ErrNoRows
|
||||||
|
case "denied", "expired":
|
||||||
|
return CreatedAPIKey{}, device.Status, fmt.Errorf("device flow %s", device.Status)
|
||||||
|
case "approved":
|
||||||
|
created, err := s.CreateAPIKey(ctx, device.UserID, device.Name, device.Scopes, nil)
|
||||||
|
if err == nil {
|
||||||
|
s.repo.FinishDeviceCode(ctx, device.ID)
|
||||||
|
}
|
||||||
|
return created, "", err
|
||||||
|
default:
|
||||||
|
return CreatedAPIKey{}, "invalid_state", fmt.Errorf("invalid device status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) {
|
||||||
|
token, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
session, err := s.repo.CreateSession(ctx, user.ID, hashSecret(token), ip, userAgent, sessionTTL)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", err
|
||||||
|
}
|
||||||
|
return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalFromUser(user User, method, sessionID, apiKeyID string, scopes []Scope, expiresAt time.Time) Principal {
|
||||||
|
return Principal{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
DisplayName: user.DisplayName,
|
||||||
|
Role: user.Role,
|
||||||
|
Scopes: scopes,
|
||||||
|
Method: method,
|
||||||
|
SessionID: sessionID,
|
||||||
|
APIKeyID: apiKeyID,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAPIToken(token string) (string, string, error) {
|
||||||
|
parts := strings.SplitN(token, "_", 4)
|
||||||
|
if len(parts) != 4 || parts[0] != "cq" || parts[1] != "pat" {
|
||||||
|
return "", "", fmt.Errorf("invalid api token")
|
||||||
|
}
|
||||||
|
return "api:" + parts[2], parts[3], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEmail(email string) string {
|
||||||
|
return strings.TrimSpace(strings.ToLower(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUserCode(raw string) string {
|
||||||
|
clean := strings.ToUpper(strings.NewReplacer("-", "", "_", "").Replace(raw))
|
||||||
|
if len(clean) > 8 {
|
||||||
|
clean = clean[:8]
|
||||||
|
}
|
||||||
|
if len(clean) > 4 {
|
||||||
|
return clean[:4] + "-" + clean[4:]
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUserCode(code string) string {
|
||||||
|
return strings.ToUpper(strings.NewReplacer("-", "", " ", "", "_", "").Replace(strings.TrimSpace(code)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorsIsNoRows(err error) bool {
|
||||||
|
return err == sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
var dummyPasswordHash string
|
||||||
|
|
||||||
|
func mustDummyPasswordHash() string {
|
||||||
|
if dummyPasswordHash == "" {
|
||||||
|
hash, err := hashPassword("not-the-password")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
dummyPasswordHash = hash
|
||||||
|
}
|
||||||
|
return dummyPasswordHash
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
parts := strings.Split(forwarded, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func RoleAllows(role Role, required Scope) bool {
|
||||||
|
switch required {
|
||||||
|
case ScopeDocsRead, ScopeSyncRead:
|
||||||
|
return role == RoleViewer || role == RoleEditor || role == RoleAdmin
|
||||||
|
case ScopeDocsWrite, ScopeSyncWrite:
|
||||||
|
return role == RoleEditor || role == RoleAdmin
|
||||||
|
case ScopeAdmin:
|
||||||
|
return role == RoleAdmin
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Allows(principal Principal, required Scope) bool {
|
||||||
|
if principal.UserID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(principal.Scopes) > 0 && !hasScope(principal.Scopes, required) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return RoleAllows(principal.Role, required)
|
||||||
|
}
|
||||||
181
apps/server/internal/auth/service_test.go
Normal file
181
apps/server/internal/auth/service_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupAuthTestService(t *testing.T) *Service {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := sql.Open("libsql", "file:"+t.TempDir()+"/auth.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
service, err := NewService(NewRepository(db), "http://localhost:8080")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new auth service: %v", err)
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordLoginCreatesValidSession(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user, err := service.RegisterPasswordUser(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", "editor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
if user.Email != "dev@example.com" {
|
||||||
|
t.Fatalf("email = %q, want normalized", user.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
principal, token, err := service.LoginPassword(ctx, "dev@example.com", "correct horse battery staple", "127.0.0.1", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoginPassword() error = %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("expected session token")
|
||||||
|
}
|
||||||
|
if principal.Role != RoleAdmin {
|
||||||
|
t.Fatalf("role = %s, want first registered user to bootstrap as admin", principal.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
validated, err := service.ValidateSessionToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateSessionToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if validated.UserID != principal.UserID {
|
||||||
|
t.Fatalf("validated user = %q, want %q", validated.UserID, principal.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
expires := time.Now().UTC().Add(time.Hour)
|
||||||
|
created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAPIKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(created.Token, "cq_pat_") {
|
||||||
|
t.Fatalf("token prefix = %q, want cq_pat_", created.Token)
|
||||||
|
}
|
||||||
|
if created.Record.KeyHash != "" && strings.Contains(created.Record.KeyHash, created.Token) {
|
||||||
|
t.Fatal("record contains raw token")
|
||||||
|
}
|
||||||
|
|
||||||
|
principal, err := service.ValidateBearerToken(ctx, created.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateBearerToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if principal.APIKeyID != created.Record.ID {
|
||||||
|
t.Fatalf("api key id = %q, want %q", principal.APIKeyID, created.Record.ID)
|
||||||
|
}
|
||||||
|
if !Allows(principal, ScopeDocsRead) {
|
||||||
|
t.Fatal("expected docs:read to be allowed")
|
||||||
|
}
|
||||||
|
if Allows(principal, ScopeAdmin) {
|
||||||
|
t.Fatal("did not expect admin scope to be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartDeviceFlow() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "authorization_pending" || err == nil {
|
||||||
|
t.Fatalf("PollDeviceFlow() before approval code=%q err=%v, want authorization_pending", code, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.ApproveDeviceFlow(ctx, principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)), start.UserCode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApproveDeviceFlow() error = %v", err)
|
||||||
|
}
|
||||||
|
created, code, err := service.PollDeviceFlow(ctx, start.DeviceCode)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PollDeviceFlow() after approval code=%q err=%v", code, err)
|
||||||
|
}
|
||||||
|
if created.Token == "" {
|
||||||
|
t.Fatal("expected device flow to mint api token")
|
||||||
|
}
|
||||||
|
if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "expired" || err == nil {
|
||||||
|
t.Fatalf("PollDeviceFlow() after token issuance code=%q err=%v, want expired", code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordChangeInvalidatesOldPassword(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||||
|
if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("ChangePassword() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "correct horse battery staple", "127.0.0.1", "test"); err == nil {
|
||||||
|
t.Fatal("old password still works")
|
||||||
|
}
|
||||||
|
if _, _, err := service.LoginPassword(ctx, "admin@example.com", "new correct horse battery staple", "127.0.0.1", "test"); err != nil {
|
||||||
|
t.Fatalf("new password login error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour))
|
||||||
|
if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil {
|
||||||
|
t.Fatal("expected demoting last admin to fail")
|
||||||
|
}
|
||||||
|
if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil {
|
||||||
|
t.Fatal("expected deleting last admin to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) {
|
||||||
|
service := setupAuthTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil {
|
||||||
|
t.Fatalf("RegisterPasswordUser() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "another correct horse", "viewer"); err == nil {
|
||||||
|
t.Fatal("expected duplicate password registration to fail")
|
||||||
|
}
|
||||||
|
if _, _, err := service.BeginPasskeyRegistration(ctx, "dev@example.com", "Dev", "viewer"); err == nil {
|
||||||
|
t.Fatal("expected public passkey registration for existing account to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
117
apps/server/internal/auth/types.go
Normal file
117
apps/server/internal/auth/types.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleViewer Role = "viewer"
|
||||||
|
RoleEditor Role = "editor"
|
||||||
|
RoleAdmin Role = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScopeDocsRead Scope = "docs:read"
|
||||||
|
ScopeDocsWrite Scope = "docs:write"
|
||||||
|
ScopeSyncRead Scope = "sync:read"
|
||||||
|
ScopeSyncWrite Scope = "sync:write"
|
||||||
|
ScopeAdmin Scope = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Scopes []Scope `json:"scopes,omitempty"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
SessionID string `json:"sessionId,omitempty"`
|
||||||
|
APIKeyID string `json:"apiKeyId,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
DisplayName string
|
||||||
|
Role Role
|
||||||
|
PasswordHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastSeenAt time.Time
|
||||||
|
Credentials []webauthn.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) WebAuthnID() []byte {
|
||||||
|
return []byte(u.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) WebAuthnName() string {
|
||||||
|
return u.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) WebAuthnDisplayName() string {
|
||||||
|
if u.DisplayName != "" {
|
||||||
|
return u.DisplayName
|
||||||
|
}
|
||||||
|
return u.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) WebAuthnCredentials() []webauthn.Credential {
|
||||||
|
return u.Credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
TokenHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyHash string `json:"-"`
|
||||||
|
Scopes []Scope `json:"scopes"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||||
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||||
|
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatedAPIKey struct {
|
||||||
|
Record APIKey `json:"record"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceCode struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
DeviceCodeHash string
|
||||||
|
UserCodeHash string
|
||||||
|
UserCodeDisplay string
|
||||||
|
Name string
|
||||||
|
Scopes []Scope
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
ApprovedAt *time.Time
|
||||||
|
LastPolledAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceStart struct {
|
||||||
|
DeviceCode string `json:"deviceCode"`
|
||||||
|
UserCode string `json:"userCode"`
|
||||||
|
VerificationURI string `json:"verificationUri"`
|
||||||
|
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||||
|
ExpiresIn int `json:"expiresIn"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ type Config struct {
|
|||||||
Database DatabaseConfig `json:"database"`
|
Database DatabaseConfig `json:"database"`
|
||||||
Content ContentConfig `json:"content"`
|
Content ContentConfig `json:"content"`
|
||||||
Web WebConfig `json:"web"`
|
Web WebConfig `json:"web"`
|
||||||
|
Auth AuthConfig `json:"auth"`
|
||||||
LogLevel string `json:"logLevel"`
|
LogLevel string `json:"logLevel"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ type WebConfig struct {
|
|||||||
DevViteURL string `json:"devViteUrl"`
|
DevViteURL string `json:"devViteUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
PublicOrigin string `json:"publicOrigin"`
|
||||||
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
@@ -51,6 +56,9 @@ func Default() Config {
|
|||||||
DistDir: filepath.Join("..", "web", "dist"),
|
DistDir: filepath.Join("..", "web", "dist"),
|
||||||
DevViteURL: os.Getenv("CAIRNQUIRE_DEV_VITE_URL"),
|
DevViteURL: os.Getenv("CAIRNQUIRE_DEV_VITE_URL"),
|
||||||
},
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
PublicOrigin: "http://localhost:8080",
|
||||||
|
},
|
||||||
LogLevel: "INFO",
|
LogLevel: "INFO",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +80,7 @@ func Load() (Config, error) {
|
|||||||
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
|
overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR")
|
||||||
overrideString(&cfg.Web.DistDir, "CAIRNQUIRE_WEB_DIST_DIR")
|
overrideString(&cfg.Web.DistDir, "CAIRNQUIRE_WEB_DIST_DIR")
|
||||||
overrideString(&cfg.Web.DevViteURL, "CAIRNQUIRE_DEV_VITE_URL")
|
overrideString(&cfg.Web.DevViteURL, "CAIRNQUIRE_DEV_VITE_URL")
|
||||||
|
overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN")
|
||||||
overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL")
|
overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL")
|
||||||
|
|
||||||
if cfg.Server.Addr == "" {
|
if cfg.Server.Addr == "" {
|
||||||
|
|||||||
90
apps/server/internal/database/migrations/000009_auth.up.sql
Normal file
90
apps/server/internal/database/migrations/000009_auth.up.sql
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
revoked_at TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
credential_id BLOB NOT NULL UNIQUE,
|
||||||
|
credential_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_challenges (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('webauthn_registration', 'webauthn_login')),
|
||||||
|
session_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_challenges_user_type ON auth_challenges(user_id, type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS device_codes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
device_code_hash TEXT NOT NULL UNIQUE,
|
||||||
|
user_code_hash TEXT NOT NULL UNIQUE,
|
||||||
|
user_code_display TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
scopes TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'approved', 'denied', 'expired')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
approved_at TEXT,
|
||||||
|
last_polled_at TEXT,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_codes_user_code_hash ON device_codes(user_code_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_codes_device_code_hash ON device_codes(device_code_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document')),
|
||||||
|
resource_id TEXT,
|
||||||
|
permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')),
|
||||||
|
granted_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
actor_user_id TEXT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
FOREIGN KEY(actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
||||||
|
|
||||||
|
ALTER TABLE api_keys ADD COLUMN revoked_at TEXT;
|
||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||||
@@ -110,6 +112,69 @@ func TestSaveSourcePageWithBaseHashAcceptsMatchingHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveSourcePageWithBaseHashCanResolveWithLatestHash(t *testing.T) {
|
||||||
|
service, sourceDir := setupDocsTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
page, err := service.LoadSourcePage(ctx, "hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSourcePage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverContent := "# Hello\n\nServer edit"
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil {
|
||||||
|
t.Fatalf("write server edit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nQueued offline edit", page.Hash)
|
||||||
|
var conflict *DocumentConflictError
|
||||||
|
if !errors.As(err, &conflict) {
|
||||||
|
t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedContent := conflict.CurrentContent + "\n\nResolved local addition"
|
||||||
|
updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", resolvedContent, conflict.CurrentHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveSourcePageWithBaseHash() latest hash error = %v", err)
|
||||||
|
}
|
||||||
|
if updated.Hash == conflict.CurrentHash {
|
||||||
|
t.Fatal("expected resolved save to produce a new hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read hello.md: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != resolvedContent {
|
||||||
|
t.Fatalf("resolved content = %q, want %q", string(content), resolvedContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
|
||||||
|
service, sourceDir := setupDocsTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
name := filepath.Join(sourceDir, fmt.Sprintf("note-%03d.md", i))
|
||||||
|
content := fmt.Sprintf("# Note %03d\n\nDeterministic body %03d\n", i, i)
|
||||||
|
if err := os.WriteFile(name, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("create %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
changes, err := service.SyncSourceDir(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SyncSourceDir() error = %v", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||||
|
t.Fatalf("SyncSourceDir() took %s, want <= 10s", elapsed)
|
||||||
|
}
|
||||||
|
if len(changes) != 101 {
|
||||||
|
t.Fatalf("changes = %d, want 101", len(changes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setupDocsTestService(t *testing.T) (*Service, string) {
|
func setupDocsTestService(t *testing.T) (*Service, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
23
apps/server/internal/httpserver/auth_context.go
Normal file
23
apps/server/internal/httpserver/auth_context.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authContextKey struct{}
|
||||||
|
|
||||||
|
func withPrincipal(ctx context.Context, principal auth.Principal) context.Context {
|
||||||
|
return context.WithValue(ctx, authContextKey{}, principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalFromContext(ctx context.Context) (auth.Principal, bool) {
|
||||||
|
principal, ok := ctx.Value(authContextKey{}).(auth.Principal)
|
||||||
|
return principal, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func requirePrincipal(r *http.Request) (auth.Principal, bool) {
|
||||||
|
return principalFromContext(r.Context())
|
||||||
|
}
|
||||||
477
apps/server/internal/httpserver/auth_handlers.go
Normal file
477
apps/server/internal/httpserver/auth_handlers.go
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type passwordRegisterRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passwordLoginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passkeyBeginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passkeyFinishRequest struct {
|
||||||
|
ChallengeID string `json:"challengeId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createTokenRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scopes []auth.Scope `json:"scopes"`
|
||||||
|
ExpiresAt *time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceStartRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scopes []auth.Scope `json:"scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type changePasswordRequest struct {
|
||||||
|
CurrentPassword string `json:"currentPassword"`
|
||||||
|
NewPassword string `json:"newPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteAccountRequest struct {
|
||||||
|
CurrentPassword string `json:"currentPassword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateUserRoleRequest struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := requirePrincipal(r); ok {
|
||||||
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextPath := r.URL.Query().Get("next")
|
||||||
|
if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") {
|
||||||
|
nextPath = "/account"
|
||||||
|
}
|
||||||
|
s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{
|
||||||
|
Title: "Sign in",
|
||||||
|
WebEnabled: s.webEnabled,
|
||||||
|
BodyClass: "page-auth",
|
||||||
|
BodyTemplate: "login_content",
|
||||||
|
Data: struct {
|
||||||
|
Next string
|
||||||
|
}{Next: nextPath},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{
|
||||||
|
Title: "Account",
|
||||||
|
WebEnabled: s.webEnabled,
|
||||||
|
BodyClass: "page-account",
|
||||||
|
BodyTemplate: "account_content",
|
||||||
|
Data: principal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswordRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passwordRegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.auth.RegisterPasswordUser(r.Context(), req.Email, req.DisplayName, req.Password, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusCreated, map[string]any{"user": publicUser(user)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passwordLoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent())
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, principal.ExpiresAt)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if principal.SessionID != "" {
|
||||||
|
_ = s.auth.RevokeSession(r.Context(), principal.SessionID)
|
||||||
|
}
|
||||||
|
clearSessionCookie(w)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req changePasswordRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.auth.ChangePassword(r.Context(), principal, req.CurrentPassword, req.NewPassword); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req deleteAccountRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.auth.DeleteAccount(r.Context(), principal, req.CurrentPassword); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearSessionCookie(w)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "account_deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passkeyBeginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options, challengeID, err := s.auth.BeginPasskeyRegistration(r.Context(), req.Email, req.DisplayName, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
challengeID := r.URL.Query().Get("challengeId")
|
||||||
|
if challengeID == "" {
|
||||||
|
var req passkeyFinishRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
challengeID = req.ChallengeID
|
||||||
|
}
|
||||||
|
if challengeID == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.auth.FinishPasskeyRegistration(r.Context(), challengeID, r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req passkeyBeginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options, challengeID, err := s.auth.BeginPasskeyLogin(r.Context(), req.Email)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
challengeID := r.URL.Query().Get("challengeId")
|
||||||
|
if challengeID == "" {
|
||||||
|
var req passkeyFinishRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
challengeID = req.ChallengeID
|
||||||
|
}
|
||||||
|
if challengeID == "" {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, token, err := s.auth.FinishPasskeyLogin(r.Context(), challengeID, r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, principal.ExpiresAt)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keys, err := s.auth.ListAPIKeys(r.Context(), principal.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"tokens": keys})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := s.auth.CreateAPIKey(r.Context(), principal.UserID, req.Name, req.Scopes, req.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyID := chi.URLParam(r, "id")
|
||||||
|
if !strings.HasPrefix(keyID, "api:") {
|
||||||
|
keyID = "api:" + keyID
|
||||||
|
}
|
||||||
|
if err := s.auth.RevokeAPIKey(r.Context(), principal.UserID, keyID); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "token not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req deviceStartRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start, err := s.auth.StartDeviceFlow(r.Context(), req.Name, req.Scopes)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, start)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
if !requireSessionPrincipal(w, principal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UserCode string `json:"userCode"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
device, err := s.auth.ApproveDeviceFlow(r.Context(), principal, req.UserCode)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"status": "approved", "device": map[string]any{"name": device.Name, "scopes": device.Scopes}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.allowAuthAttempt(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
DeviceCode string `json:"deviceCode"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, code, err := s.auth.PollDeviceFlow(r.Context(), req.DeviceCode)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
if code == "authorization_pending" {
|
||||||
|
status = http.StatusAccepted
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, status, map[string]string{"error": code})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userCode := template.HTMLEscapeString(r.URL.Query().Get("user_code"))
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(`<!doctype html><html><body><main><h1>Approve Device</h1><p>Sign in, then approve this code with <code>POST /api/device/approve</code>.</p><p>User code: <strong>` + userCode + `</strong></p></main></body></html>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
|
||||||
|
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{
|
||||||
|
Title: "Approve device",
|
||||||
|
WebEnabled: s.webEnabled,
|
||||||
|
BodyClass: "page-auth",
|
||||||
|
BodyTemplate: "device_verify_content",
|
||||||
|
Data: struct {
|
||||||
|
UserCode string
|
||||||
|
}{UserCode: userCode},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminUpdateUserRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, _ := requirePrincipal(r)
|
||||||
|
userID := chi.URLParam(r, "id")
|
||||||
|
var req updateUserRoleRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.auth.UpdateUserRole(r.Context(), principal, userID, req.Role)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := s.auth.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
public := make([]map[string]any, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
public = append(public, publicUser(user))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"users": public})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if s.authLimiter.Allow(authRateLimitKey(r)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusTooManyRequests, map[string]string{"error": "rate limited"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: auth.SessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
Expires: expires,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: auth.SessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicUser(user auth.User) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"displayName": user.DisplayName,
|
||||||
|
"role": user.Role,
|
||||||
|
"createdAt": user.CreatedAt,
|
||||||
|
"lastSeenAt": user.LastSeenAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireSessionPrincipal(w http.ResponseWriter, principal auth.Principal) bool {
|
||||||
|
if principal.Method == "session" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "browser session required"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestIP(r *http.Request) string {
|
||||||
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
return strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSecureRequest(r *http.Request) bool {
|
||||||
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
@@ -137,6 +137,10 @@ func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
||||||
pagePath := chi.URLParam(r, "*")
|
pagePath := chi.URLParam(r, "*")
|
||||||
|
if isContentAssetPath(pagePath) {
|
||||||
|
s.handleContentAsset(w, r, pagePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
if editPath, ok := trimEditSuffix(pagePath); ok {
|
if editPath, ok := trimEditSuffix(pagePath); ok {
|
||||||
s.renderDocumentEditor(w, r, editPath)
|
s.renderDocumentEditor(w, r, editPath)
|
||||||
return
|
return
|
||||||
@@ -144,6 +148,64 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderDocumentPage(w, r, pagePath)
|
s.renderDocumentPage(w, r, pagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) {
|
||||||
|
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/")))
|
||||||
|
if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(s.config.Content.SourceDir, cleanPath)
|
||||||
|
sourceRoot, err := filepath.Abs(s.config.Content.SourceDir)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "resolve content root")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assetAbs, err := filepath.Abs(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(sourceRoot, assetAbs)
|
||||||
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(assetAbs)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "read asset")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := make([]byte, 512)
|
||||||
|
n, _ := file.Read(buffer)
|
||||||
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||||
|
s.renderError(w, r, http.StatusInternalServerError, "read asset")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentType := http.DetectContentType(buffer[:n])
|
||||||
|
if !isAllowedContentType(contentType) {
|
||||||
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
||||||
|
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
|
func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||||
page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
|
page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -469,6 +531,11 @@ func trimEditSuffix(path string) (string, bool) {
|
|||||||
return strings.TrimSuffix(path, "/edit"), true
|
return strings.TrimSuffix(path, "/edit"), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isContentAssetPath(path string) bool {
|
||||||
|
extension := strings.ToLower(filepath.Ext(path))
|
||||||
|
return extension != "" && extension != ".md"
|
||||||
|
}
|
||||||
|
|
||||||
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
|
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
|
||||||
paths := make([]string, 0, len(records))
|
paths := make([]string, 0, len(records))
|
||||||
titleByPath := make(map[string]string, len(records))
|
titleByPath := make(map[string]string, len(records))
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,3 +68,56 @@ func TestTrimEditSuffix(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsContentAssetPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{path: "logo.webp", want: true},
|
||||||
|
{path: "guide/logo.png", want: true},
|
||||||
|
{path: "guide/page", want: false},
|
||||||
|
{path: "guide/page.md", want: false},
|
||||||
|
{path: "guide/page/edit", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
if got := isContentAssetPath(tt.path); got != tt.want {
|
||||||
|
t.Fatalf("isContentAssetPath(%q) = %t, want %t", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
image := []byte{
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||||
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
||||||
|
0x89,
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "logo.png"), image, 0o644); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &Server{
|
||||||
|
config: config.Config{
|
||||||
|
Content: config.ContentConfig{SourceDir: root},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/docs/logo.png", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
server.handleContentAsset(recorder, request, "logo.png")
|
||||||
|
|
||||||
|
response := recorder.Result()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := response.Header.Get("Content-Type"); got != "image/png" {
|
||||||
|
t.Fatalf("content-type = %q, want image/png", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
|
func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler {
|
||||||
@@ -49,6 +52,88 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
principal, ok := s.authenticateRequest(r)
|
||||||
|
if ok {
|
||||||
|
r = r.WithContext(withPrincipal(r.Context(), principal))
|
||||||
|
}
|
||||||
|
|
||||||
|
required, protected := requiredScopeForPath(r)
|
||||||
|
if !protected {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !auth.Allows(principal, required) {
|
||||||
|
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
|
||||||
|
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
|
||||||
|
principal, err := s.auth.ValidateBearerToken(r.Context(), strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")))
|
||||||
|
if err == nil {
|
||||||
|
return principal, true
|
||||||
|
}
|
||||||
|
s.logger.Warn("invalid bearer token", "error", err)
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
cookie, err := r.Cookie(auth.SessionCookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
principal, err := s.auth.ValidateSessionToken(r.Context(), cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
return auth.Principal{}, false
|
||||||
|
}
|
||||||
|
return principal, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||||
|
path := r.URL.Path
|
||||||
|
method := r.Method
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(path, "/api/auth/"):
|
||||||
|
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" {
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
case strings.HasPrefix(path, "/api/device/"):
|
||||||
|
if path == "/api/device/approve" || path == "/api/device/verify" {
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
case strings.HasPrefix(path, "/api/tokens"):
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case path == "/ws":
|
||||||
|
return auth.ScopeDocsRead, true
|
||||||
|
case strings.HasPrefix(path, "/api/admin/"):
|
||||||
|
return auth.ScopeAdmin, true
|
||||||
|
case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet:
|
||||||
|
return auth.ScopeDocsWrite, true
|
||||||
|
case strings.HasPrefix(path, "/api/uploads"):
|
||||||
|
return auth.ScopeDocsWrite, true
|
||||||
|
case strings.HasPrefix(path, "/api/sync/"):
|
||||||
|
if method == http.MethodPost {
|
||||||
|
return auth.ScopeSyncWrite, true
|
||||||
|
}
|
||||||
|
return auth.ScopeSyncRead, true
|
||||||
|
case strings.HasPrefix(path, "/api/content/"):
|
||||||
|
return auth.ScopeSyncRead, true
|
||||||
|
case strings.HasSuffix(path, "/edit"):
|
||||||
|
return auth.ScopeDocsWrite, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type statusWriter struct {
|
type statusWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
status int
|
status int
|
||||||
|
|||||||
49
apps/server/internal/httpserver/rate_limit.go
Normal file
49
apps/server/internal/httpserver/rate_limit.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
window time.Duration
|
||||||
|
limit int
|
||||||
|
attempts map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
|
||||||
|
return &rateLimiter{
|
||||||
|
window: window,
|
||||||
|
limit: limit,
|
||||||
|
attempts: make(map[string][]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *rateLimiter) Allow(key string) bool {
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-l.window)
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
attempts := l.attempts[key]
|
||||||
|
kept := attempts[:0]
|
||||||
|
for _, attempt := range attempts {
|
||||||
|
if attempt.After(cutoff) {
|
||||||
|
kept = append(kept, attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) >= l.limit {
|
||||||
|
l.attempts[key] = kept
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
kept = append(kept, now)
|
||||||
|
l.attempts[key] = kept
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func authRateLimitKey(r *http.Request) string {
|
||||||
|
return requestIP(r) + ":" + r.URL.Path
|
||||||
|
}
|
||||||
19
apps/server/internal/httpserver/rate_limit_test.go
Normal file
19
apps/server/internal/httpserver/rate_limit_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRateLimiterRejectsAfterLimit(t *testing.T) {
|
||||||
|
limiter := newRateLimiter(2, time.Minute)
|
||||||
|
if !limiter.Allow("ip:path") {
|
||||||
|
t.Fatal("first attempt should be allowed")
|
||||||
|
}
|
||||||
|
if !limiter.Allow("ip:path") {
|
||||||
|
t.Fatal("second attempt should be allowed")
|
||||||
|
}
|
||||||
|
if limiter.Allow("ip:path") {
|
||||||
|
t.Fatal("third attempt should be rate limited")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||||
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
"github.com/tim/cairnquire/apps/server/internal/realtime"
|
||||||
@@ -31,6 +32,7 @@ type Dependencies struct {
|
|||||||
Hub *realtime.Hub
|
Hub *realtime.Hub
|
||||||
SyncService *sync.Service
|
SyncService *sync.Service
|
||||||
SyncRepo *sync.Repository
|
SyncRepo *sync.Repository
|
||||||
|
Auth *auth.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -42,6 +44,8 @@ type Server struct {
|
|||||||
hub *realtime.Hub
|
hub *realtime.Hub
|
||||||
syncService *sync.Service
|
syncService *sync.Service
|
||||||
syncRepo *sync.Repository
|
syncRepo *sync.Repository
|
||||||
|
auth *auth.Service
|
||||||
|
authLimiter *rateLimiter
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
webEnabled bool
|
webEnabled bool
|
||||||
}
|
}
|
||||||
@@ -68,6 +72,8 @@ func New(deps Dependencies) (http.Handler, error) {
|
|||||||
hub: deps.Hub,
|
hub: deps.Hub,
|
||||||
syncService: deps.SyncService,
|
syncService: deps.SyncService,
|
||||||
syncRepo: deps.SyncRepo,
|
syncRepo: deps.SyncRepo,
|
||||||
|
auth: deps.Auth,
|
||||||
|
authLimiter: newRateLimiter(5, time.Minute),
|
||||||
templates: templates,
|
templates: templates,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,15 +88,37 @@ func New(deps Dependencies) (http.Handler, error) {
|
|||||||
router.Use(server.timeoutExceptWebSocket(30 * time.Second))
|
router.Use(server.timeoutExceptWebSocket(30 * time.Second))
|
||||||
router.Use(server.requestLogger)
|
router.Use(server.requestLogger)
|
||||||
router.Use(server.securityHeaders)
|
router.Use(server.securityHeaders)
|
||||||
router.Use(server.apiKeyMiddleware)
|
router.Use(server.authMiddleware)
|
||||||
|
|
||||||
router.Get("/", server.handleIndex)
|
router.Get("/", server.handleIndex)
|
||||||
|
router.Get("/login", server.handleLoginPage)
|
||||||
|
router.Get("/account", server.handleAccountPage)
|
||||||
|
router.Get("/device/verify", server.handleDeviceVerifyPage)
|
||||||
router.Get("/health", server.handleHealth)
|
router.Get("/health", server.handleHealth)
|
||||||
router.Get("/sw.js", server.handleServiceWorker)
|
router.Get("/sw.js", server.handleServiceWorker)
|
||||||
router.Get("/ws", server.handleWebSocket)
|
router.Get("/ws", server.handleWebSocket)
|
||||||
|
router.Get("/api/auth/me", server.handleAuthMe)
|
||||||
|
router.Post("/api/auth/register/password", server.handlePasswordRegister)
|
||||||
|
router.Post("/api/auth/login/password", server.handlePasswordLogin)
|
||||||
|
router.Post("/api/auth/logout", server.handleLogout)
|
||||||
|
router.Post("/api/auth/password/change", server.handleChangePassword)
|
||||||
|
router.Delete("/api/auth/account", server.handleDeleteAccount)
|
||||||
|
router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin)
|
||||||
|
router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish)
|
||||||
|
router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin)
|
||||||
|
router.Post("/api/auth/passkeys/login/finish", server.handlePasskeyLoginFinish)
|
||||||
|
router.Get("/api/tokens", server.handleListAPITokens)
|
||||||
|
router.Post("/api/tokens", server.handleCreateAPIToken)
|
||||||
|
router.Delete("/api/tokens/{id}", server.handleRevokeAPIToken)
|
||||||
|
router.Post("/api/device/start", server.handleDeviceStart)
|
||||||
|
router.Post("/api/device/approve", server.handleDeviceApprove)
|
||||||
|
router.Post("/api/device/token", server.handleDeviceToken)
|
||||||
|
router.Get("/api/device/verify", server.handleDeviceVerify)
|
||||||
router.Post("/api/admin/login", server.handleAdminLogin)
|
router.Post("/api/admin/login", server.handleAdminLogin)
|
||||||
router.Get("/api/admin/users", server.handleAdminUsers)
|
router.Get("/api/admin/users", server.handleAdminUsers)
|
||||||
router.Post("/api/admin/users", server.handleAdminCreateUser)
|
router.Post("/api/admin/users", server.handleAdminCreateUser)
|
||||||
|
router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole)
|
||||||
|
router.Get("/api/admin/auth-users", server.handleAdminAuthUsers)
|
||||||
router.Get("/api/admin/workspace", server.handleAdminWorkspace)
|
router.Get("/api/admin/workspace", server.handleAdminWorkspace)
|
||||||
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
|
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
|
||||||
router.Get("/docs", server.handleDocsIndex)
|
router.Get("/docs", server.handleDocsIndex)
|
||||||
|
|||||||
262
apps/server/internal/httpserver/static/auth.js
Normal file
262
apps/server/internal/httpserver/static/auth.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
(() => {
|
||||||
|
const jsonHeaders = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
const postJSON = async (url, body, options = {}) => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: options.method || "POST",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (selector, message, isError = false) => {
|
||||||
|
const target = document.querySelector(selector);
|
||||||
|
if (!target) return;
|
||||||
|
target.textContent = message;
|
||||||
|
target.dataset.state = isError ? "error" : "ok";
|
||||||
|
};
|
||||||
|
|
||||||
|
const authNext = () => document.querySelector("[data-auth-next]")?.value || "/account";
|
||||||
|
|
||||||
|
const formJSON = (form) => Object.fromEntries(new FormData(form).entries());
|
||||||
|
|
||||||
|
const base64URLToBuffer = (value) => {
|
||||||
|
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes.buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bufferToBase64URL = (value) => {
|
||||||
|
const bytes = new Uint8Array(value);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const credentialToJSON = (credential) => {
|
||||||
|
if (credential instanceof ArrayBuffer) return bufferToBase64URL(credential);
|
||||||
|
if (ArrayBuffer.isView(credential)) return bufferToBase64URL(credential.buffer);
|
||||||
|
if (Array.isArray(credential)) return credential.map(credentialToJSON);
|
||||||
|
if (credential && typeof credential === "object") {
|
||||||
|
const next = {};
|
||||||
|
for (const [key, value] of Object.entries(credential)) next[key] = credentialToJSON(value);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return credential;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCreateOptions = (options) => {
|
||||||
|
const publicKey = { ...options.publicKey };
|
||||||
|
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
|
||||||
|
publicKey.user = { ...publicKey.user, id: base64URLToBuffer(publicKey.user.id) };
|
||||||
|
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map((credential) => ({
|
||||||
|
...credential,
|
||||||
|
id: base64URLToBuffer(credential.id),
|
||||||
|
}));
|
||||||
|
return { publicKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRequestOptions = (options) => {
|
||||||
|
const publicKey = { ...options.publicKey };
|
||||||
|
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
|
||||||
|
publicKey.allowCredentials = (publicKey.allowCredentials || []).map((credential) => ({
|
||||||
|
...credential,
|
||||||
|
id: base64URLToBuffer(credential.id),
|
||||||
|
}));
|
||||||
|
return { publicKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelector("[data-auth-login]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/login/password", formJSON(event.currentTarget));
|
||||||
|
window.location.href = authNext();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-login-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/register/password", formJSON(event.currentTarget));
|
||||||
|
setStatus("[data-register-status]", "Account created. Sign in with your password.");
|
||||||
|
event.currentTarget.reset();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-register-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-passkey-register]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!window.PublicKeyCredential) {
|
||||||
|
setStatus("[data-passkey-register-status]", "Passkeys are not available in this browser.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const begin = await postJSON("/api/auth/passkeys/register/begin", formJSON(event.currentTarget));
|
||||||
|
const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options));
|
||||||
|
await postJSON(`/api/auth/passkeys/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||||
|
setStatus("[data-passkey-register-status]", "Passkey created. Sign in with your passkey.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-passkey-register-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!window.PublicKeyCredential) {
|
||||||
|
setStatus("[data-passkey-login-status]", "Passkeys are not available in this browser.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const begin = await postJSON("/api/auth/passkeys/login/begin", formJSON(event.currentTarget));
|
||||||
|
const credential = await navigator.credentials.get(normalizeRequestOptions(begin.options));
|
||||||
|
await postJSON(`/api/auth/passkeys/login/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential));
|
||||||
|
window.location.href = authNext();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-passkey-login-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => {
|
||||||
|
await postJSON("/api/auth/logout", {});
|
||||||
|
window.location.href = "/login";
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadTokens = async () => {
|
||||||
|
const list = document.querySelector("[data-token-list]");
|
||||||
|
if (!list) return;
|
||||||
|
const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json());
|
||||||
|
list.replaceChildren(
|
||||||
|
...(data.tokens || []).map((token) => {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
const meta = document.createElement("span");
|
||||||
|
meta.textContent = `${token.name} - ${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = "Revoke";
|
||||||
|
button.disabled = Boolean(token.revokedAt);
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await fetch(`/api/tokens/${encodeURIComponent(token.id.replace(/^api:/, ""))}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
await loadTokens();
|
||||||
|
});
|
||||||
|
item.append(meta, button);
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelector("[data-token-create]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const scopes = [...form.querySelectorAll('input[name="scope"]:checked')].map((input) => input.value);
|
||||||
|
try {
|
||||||
|
const created = await postJSON("/api/tokens", { name: form.elements.name.value, scopes });
|
||||||
|
const output = document.querySelector("[data-token-output]");
|
||||||
|
if (output) {
|
||||||
|
output.hidden = false;
|
||||||
|
output.textContent = created.token;
|
||||||
|
}
|
||||||
|
setStatus("[data-token-status]", "Token created. Copy it now; it will not be shown again.");
|
||||||
|
form.reset();
|
||||||
|
await loadTokens();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-token-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-password-change]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/password/change", formJSON(event.currentTarget));
|
||||||
|
setStatus("[data-password-status]", "Password changed.");
|
||||||
|
event.currentTarget.reset();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-password-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!confirm("Delete this account permanently?")) return;
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/account", formJSON(event.currentTarget), { method: "DELETE" });
|
||||||
|
window.location.href = "/login";
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-delete-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("[data-device-approve]")?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON("/api/device/approve", formJSON(event.currentTarget));
|
||||||
|
setStatus("[data-device-status]", "Device approved.");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === "authentication required") {
|
||||||
|
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname + window.location.search)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("[data-device-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadAdminUsers = async () => {
|
||||||
|
const section = document.querySelector("[data-admin-users-section]");
|
||||||
|
const list = document.querySelector("[data-admin-users]");
|
||||||
|
if (!section || !list) return;
|
||||||
|
const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null);
|
||||||
|
if (me?.principal?.role !== "admin") return;
|
||||||
|
section.hidden = false;
|
||||||
|
const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json());
|
||||||
|
list.replaceChildren(
|
||||||
|
...(data.users || []).map((user) => {
|
||||||
|
const row = document.createElement("form");
|
||||||
|
row.className = "user-row";
|
||||||
|
const identity = document.createElement("span");
|
||||||
|
const name = document.createElement("strong");
|
||||||
|
name.textContent = user.displayName;
|
||||||
|
const email = document.createElement("small");
|
||||||
|
email.textContent = user.email;
|
||||||
|
identity.append(name, email);
|
||||||
|
const select = document.createElement("select");
|
||||||
|
for (const role of ["viewer", "editor", "admin"]) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = role;
|
||||||
|
option.textContent = role;
|
||||||
|
option.selected = user.role === role;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "submit";
|
||||||
|
button.textContent = "Save";
|
||||||
|
row.append(identity, select, button);
|
||||||
|
row.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postJSON(`/api/admin/users/${encodeURIComponent(user.id)}/role`, { role: select.value }, { method: "PATCH" });
|
||||||
|
setStatus("[data-admin-users-status]", "Role updated.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("[data-admin-users-status]", error.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTokens().catch(() => {});
|
||||||
|
loadAdminUsers().catch(() => {});
|
||||||
|
})();
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
const STORE_CONTENT = "content";
|
const STORE_CONTENT = "content";
|
||||||
const STORE_PAGES = "pages";
|
const STORE_PAGES = "pages";
|
||||||
const STORE_PENDING_EDITS = "pending_edits";
|
const STORE_PENDING_EDITS = "pending_edits";
|
||||||
|
const CACHE_ENTRY_LIMIT = 100;
|
||||||
|
|
||||||
function openDB() {
|
function openDB() {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
@@ -39,6 +40,27 @@
|
|||||||
return path.replace(/\/$/, "") || "/";
|
return path.replace(/\/$/, "") || "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneStore(store, limit) {
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onsuccess = function () {
|
||||||
|
const records = request.result || [];
|
||||||
|
if (records.length <= limit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
records
|
||||||
|
.sort(function (a, b) {
|
||||||
|
return (a.cachedAt || 0) - (b.cachedAt || 0);
|
||||||
|
})
|
||||||
|
.slice(0, records.length - limit)
|
||||||
|
.forEach(function (record) {
|
||||||
|
if (record && record.path) {
|
||||||
|
store.delete(record.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cacheDocuments(documents) {
|
function cacheDocuments(documents) {
|
||||||
return openDB().then(function (db) {
|
return openDB().then(function (db) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
@@ -63,6 +85,7 @@
|
|||||||
const tx = db.transaction(STORE_CONTENT, "readwrite");
|
const tx = db.transaction(STORE_CONTENT, "readwrite");
|
||||||
const store = tx.objectStore(STORE_CONTENT);
|
const store = tx.objectStore(STORE_CONTENT);
|
||||||
store.put({ path: path, html: html, title: title, cachedAt: Date.now() });
|
store.put({ path: path, html: html, title: title, cachedAt: Date.now() });
|
||||||
|
pruneStore(store, CACHE_ENTRY_LIMIT);
|
||||||
tx.oncomplete = function () {
|
tx.oncomplete = function () {
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
@@ -117,6 +140,7 @@
|
|||||||
title: title,
|
title: title,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
pruneStore(store, CACHE_ENTRY_LIMIT);
|
||||||
tx.oncomplete = function () {
|
tx.oncomplete = function () {
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -10,7 +10,9 @@
|
|||||||
pre.replaceWith(container);
|
pre.replaceWith(container);
|
||||||
});
|
});
|
||||||
mermaid.initialize({ startOnLoad: false });
|
mermaid.initialize({ startOnLoad: false });
|
||||||
mermaid.run({ querySelector: ".mermaid" });
|
mermaid.run({ querySelector: ".mermaid:not([data-processed])" }).catch(function (error) {
|
||||||
|
console.warn("Mermaid render failed:", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMath() {
|
function renderMath() {
|
||||||
@@ -66,8 +68,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
renderMermaid();
|
|
||||||
renderMath();
|
renderMath();
|
||||||
|
renderMermaid();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.renderMermaid = renderMermaid;
|
window.renderMermaid = renderMermaid;
|
||||||
|
|||||||
@@ -152,6 +152,229 @@ code {
|
|||||||
height: calc(100vh - 64px);
|
height: calc(100vh - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-shell,
|
||||||
|
.account-shell {
|
||||||
|
min-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: clamp(1rem, 3vw, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
display: grid;
|
||||||
|
align-items: start;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel,
|
||||||
|
.account-section {
|
||||||
|
width: min(100%, 920px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
padding: clamp(1rem, 3vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__header,
|
||||||
|
.account-header {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel h1,
|
||||||
|
.account-header h1 {
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.25rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 700 0.72rem/1.2 ui-monospace, SFMono-Regular, monospace;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-grid,
|
||||||
|
.account-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form h2,
|
||||||
|
.account-section h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form label,
|
||||||
|
.scope-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input,
|
||||||
|
.auth-form select,
|
||||||
|
.user-row select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form button,
|
||||||
|
.account-header button,
|
||||||
|
.token-list button,
|
||||||
|
.user-row button {
|
||||||
|
min-height: 2.35rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form button[type="submit"],
|
||||||
|
.account-header button {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-details {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status {
|
||||||
|
min-height: 1.2rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status[data-state="error"] {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-status[data-state="ok"] {
|
||||||
|
color: #067647;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-note {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
width: min(100%, 1120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-header p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-grid {
|
||||||
|
width: min(100%, 1120px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-section {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-section--danger {
|
||||||
|
border-color: color-mix(in srgb, #b42318 40%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-grid legend {
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-grid label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-output {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-list,
|
||||||
|
.user-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-list li,
|
||||||
|
.user-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Workspace layout - adaptive grid */
|
/* Workspace layout - adaptive grid */
|
||||||
.workspace-shell {
|
.workspace-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -583,7 +806,9 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body img {
|
.markdown-body img {
|
||||||
max-width: 100%;
|
display: block;
|
||||||
|
max-width: min(85%, 100%);
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-notice {
|
.version-notice {
|
||||||
@@ -640,6 +865,96 @@ code {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-queue {
|
||||||
|
position: fixed;
|
||||||
|
left: 1rem;
|
||||||
|
bottom: 7.9rem;
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
width: min(28rem, calc(100vw - 2rem));
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border: 1px solid rgba(146, 64, 14, 0.24);
|
||||||
|
background: rgba(255, 251, 235, 0.97);
|
||||||
|
color: #78350f;
|
||||||
|
box-shadow: 0 18px 48px rgba(24, 32, 42, 0.12);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue__header strong,
|
||||||
|
.sync-queue__header p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue__header p {
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue button {
|
||||||
|
min-height: 2rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 0.65rem;
|
||||||
|
border: 1px solid rgba(146, 64, 14, 0.25);
|
||||||
|
background: #fff;
|
||||||
|
color: #78350f;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 700 0.82rem/1 ui-monospace, SFMono-Regular, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue li span:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue li span:last-child {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue li[data-state="conflict"] span:last-child {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
.offline-icon {
|
.offline-icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.6rem;
|
width: 0.6rem;
|
||||||
@@ -1231,6 +1546,29 @@ code {
|
|||||||
background: oklch(0.60 0.15 250);
|
background: oklch(0.60 0.15 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-queue {
|
||||||
|
border-color: oklch(0.65 0.12 75 / 0.32);
|
||||||
|
background: oklch(0.21 0.035 75 / 0.96);
|
||||||
|
color: oklch(0.75 0.12 75);
|
||||||
|
box-shadow: 0 18px 48px oklch(0.08 0.02 55 / 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue__header p,
|
||||||
|
.sync-queue li,
|
||||||
|
.sync-queue li span:last-child {
|
||||||
|
color: oklch(0.72 0.11 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue button {
|
||||||
|
border-color: oklch(0.65 0.12 75 / 0.28);
|
||||||
|
background: oklch(0.17 0.018 55);
|
||||||
|
color: oklch(0.75 0.12 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-queue li[data-state="conflict"] span:last-child {
|
||||||
|
color: oklch(0.68 0.16 25);
|
||||||
|
}
|
||||||
|
|
||||||
.version-notice button {
|
.version-notice button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const STATIC_CACHE = "md-hub-static-v1";
|
const STATIC_CACHE = "md-hub-static-v2";
|
||||||
const DB_NAME = "md-hub-cache";
|
const DB_NAME = "md-hub-cache";
|
||||||
const DB_VERSION = 3;
|
const DB_VERSION = 3;
|
||||||
const STORE_PAGES = "pages";
|
const STORE_PAGES = "pages";
|
||||||
|
|||||||
@@ -3,11 +3,107 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncQueue = document.querySelector("[data-sync-queue]");
|
||||||
|
const syncQueueTitle = document.querySelector("[data-sync-queue-title]");
|
||||||
|
const syncQueueSummary = document.querySelector("[data-sync-queue-summary]");
|
||||||
|
const syncQueueList = document.querySelector("[data-sync-queue-list]");
|
||||||
|
const syncNow = document.querySelector("[data-sync-now]");
|
||||||
|
|
||||||
function normalizeDocPath(path) {
|
function normalizeDocPath(path) {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
return path.replace(/^\/+/, "");
|
return path.replace(/^\/+/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPath(path) {
|
||||||
|
return (path || "").replace(/^\/docs\//, "") || "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize(count, singular, plural) {
|
||||||
|
return count === 1 ? singular : plural;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitSyncState(detail) {
|
||||||
|
window.dispatchEvent(new CustomEvent("mdhub:sync-state", { detail: detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPendingEdits(pending, state) {
|
||||||
|
if (!syncQueue || !syncQueueTitle || !syncQueueSummary || !syncQueueList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = pending.filter(function (edit) {
|
||||||
|
return Boolean(edit.conflict);
|
||||||
|
});
|
||||||
|
const clean = pending.filter(function (edit) {
|
||||||
|
return !edit.conflict;
|
||||||
|
});
|
||||||
|
|
||||||
|
syncQueue.hidden = pending.length === 0;
|
||||||
|
if (pending.length === 0) {
|
||||||
|
syncQueueList.replaceChildren();
|
||||||
|
syncQueueTitle.textContent = "Synced";
|
||||||
|
syncQueueSummary.textContent = "No pending changes.";
|
||||||
|
if (syncNow) {
|
||||||
|
syncNow.disabled = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countLabel = pending.length + " pending " + pluralize(pending.length, "change", "changes");
|
||||||
|
syncQueueTitle.textContent = conflicts.length > 0 ? countLabel + " with conflicts" : countLabel;
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
syncQueueSummary.textContent = "Offline. Changes will sync when the connection returns.";
|
||||||
|
} else if (state === "syncing") {
|
||||||
|
syncQueueSummary.textContent = "Syncing queued edits...";
|
||||||
|
} else if (conflicts.length > 0) {
|
||||||
|
syncQueueSummary.textContent = conflicts.length + " " + pluralize(conflicts.length, "file needs", "files need") + " conflict resolution.";
|
||||||
|
} else {
|
||||||
|
syncQueueSummary.textContent = "Queued edits are ready to sync.";
|
||||||
|
}
|
||||||
|
|
||||||
|
syncQueueList.replaceChildren();
|
||||||
|
pending.slice(0, 5).forEach(function (edit) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.dataset.state = edit.conflict ? "conflict" : "queued";
|
||||||
|
|
||||||
|
const path = document.createElement("span");
|
||||||
|
path.textContent = formatPath(edit.path);
|
||||||
|
item.appendChild(path);
|
||||||
|
|
||||||
|
const status = document.createElement("span");
|
||||||
|
status.textContent = edit.conflict ? "Conflict" : "Queued";
|
||||||
|
item.appendChild(status);
|
||||||
|
|
||||||
|
syncQueueList.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pending.length > 5) {
|
||||||
|
const overflow = document.createElement("li");
|
||||||
|
overflow.textContent = "+" + (pending.length - 5) + " more";
|
||||||
|
syncQueueList.appendChild(overflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncNow) {
|
||||||
|
syncNow.disabled = !navigator.onLine || clean.length === 0 || state === "syncing";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSyncQueue(state) {
|
||||||
|
const pending = await window.MDHubCache.getPendingEdits();
|
||||||
|
pending.sort(function (a, b) {
|
||||||
|
return (b.updatedAt || 0) - (a.updatedAt || 0);
|
||||||
|
});
|
||||||
|
renderPendingEdits(pending, state || "idle");
|
||||||
|
emitSyncState({
|
||||||
|
state: state || "idle",
|
||||||
|
pending: pending.length,
|
||||||
|
conflicts: pending.filter(function (edit) {
|
||||||
|
return Boolean(edit.conflict);
|
||||||
|
}).length,
|
||||||
|
});
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
async function postDocument(path, content, baseHash) {
|
async function postDocument(path, content, baseHash) {
|
||||||
const response = await fetch("/api/documents/" + normalizeDocPath(path), {
|
const response = await fetch("/api/documents/" + normalizeDocPath(path), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -40,6 +136,7 @@
|
|||||||
try {
|
try {
|
||||||
const result = await postDocument(path, content, baseHash);
|
const result = await postDocument(path, content, baseHash);
|
||||||
await window.MDHubCache.deletePendingEdit(normalizedPath);
|
await window.MDHubCache.deletePendingEdit(normalizedPath);
|
||||||
|
await refreshSyncQueue("saved");
|
||||||
return {
|
return {
|
||||||
status: "saved",
|
status: "saved",
|
||||||
result: result,
|
result: result,
|
||||||
@@ -48,12 +145,14 @@
|
|||||||
if (error.name === "DocumentConflictError") {
|
if (error.name === "DocumentConflictError") {
|
||||||
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
|
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
|
||||||
await window.MDHubCache.markPendingEditConflict(normalizedPath, error.details);
|
await window.MDHubCache.markPendingEditConflict(normalizedPath, error.details);
|
||||||
|
await refreshSyncQueue("conflict");
|
||||||
return {
|
return {
|
||||||
status: "conflict",
|
status: "conflict",
|
||||||
conflict: error.details,
|
conflict: error.details,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
|
await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash);
|
||||||
|
await refreshSyncQueue("queued");
|
||||||
return {
|
return {
|
||||||
status: "queued",
|
status: "queued",
|
||||||
};
|
};
|
||||||
@@ -62,15 +161,19 @@
|
|||||||
|
|
||||||
async function syncPending() {
|
async function syncPending() {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
|
await refreshSyncQueue("offline");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = await window.MDHubCache.getPendingEdits();
|
const pending = await refreshSyncQueue("syncing");
|
||||||
pending.sort(function (a, b) {
|
pending.sort(function (a, b) {
|
||||||
return (a.updatedAt || 0) - (b.updatedAt || 0);
|
return (a.updatedAt || 0) - (b.updatedAt || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const edit of pending) {
|
for (const edit of pending) {
|
||||||
|
if (edit.conflict) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await postDocument(edit.path.replace(/^\/docs\//, ""), edit.content, edit.baseHash);
|
await postDocument(edit.path.replace(/^\/docs\//, ""), edit.content, edit.baseHash);
|
||||||
await window.MDHubCache.deletePendingEdit(edit.path);
|
await window.MDHubCache.deletePendingEdit(edit.path);
|
||||||
@@ -81,16 +184,31 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await refreshSyncQueue("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("online", function () {
|
window.addEventListener("online", function () {
|
||||||
syncPending().catch(function () {});
|
syncPending().catch(function () {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener("offline", function () {
|
||||||
|
refreshSyncQueue("offline").catch(function () {});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (syncNow) {
|
||||||
|
syncNow.addEventListener("click", function () {
|
||||||
|
syncPending().catch(function () {
|
||||||
|
refreshSyncQueue("queued").catch(function () {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
syncPending().catch(function () {});
|
syncPending().catch(function () {});
|
||||||
|
refreshSyncQueue("idle").catch(function () {});
|
||||||
|
|
||||||
window.MDHubSync = {
|
window.MDHubSync = {
|
||||||
saveDocument: saveDocument,
|
saveDocument: saveDocument,
|
||||||
syncPending: syncPending,
|
syncPending: syncPending,
|
||||||
|
refreshSyncQueue: refreshSyncQueue,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -24,9 +22,13 @@ func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := r.Context().Value("userID").(string)
|
principal, ok := requirePrincipal(r)
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, userID)
|
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, principal.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("sync init failed", "error", err)
|
s.logger.Error("sync init failed", "error", err)
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
|
||||||
@@ -112,30 +114,3 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write(content)
|
w.Write(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Skip for non-sync routes
|
|
||||||
if !strings.HasPrefix(r.URL.Path, "/api/sync/") && !strings.HasPrefix(r.URL.Path, "/api/content/") {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
apiKey := r.Header.Get("X-API-Key")
|
|
||||||
if apiKey == "" {
|
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing API key"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate API key against database
|
|
||||||
record, err := s.syncRepo.ValidateAPIKey(r.Context(), apiKey)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("invalid api key", "error", err)
|
|
||||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid API key"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), "userID", record.UserID)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
72
apps/server/internal/httpserver/templates/account.gohtml
Normal file
72
apps/server/internal/httpserver/templates/account.gohtml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{{ define "account.gohtml" }}{{ template "base" . }}{{ end }}
|
||||||
|
|
||||||
|
{{ define "account_content" }}
|
||||||
|
<section class="account-shell">
|
||||||
|
<header class="account-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Account</p>
|
||||||
|
<h1>{{ .DisplayName }}</h1>
|
||||||
|
<p>{{ .Email }} · {{ .Role }}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-auth-logout>Sign out</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="account-grid">
|
||||||
|
<section class="account-section">
|
||||||
|
<h2>API Tokens</h2>
|
||||||
|
<form class="auth-form" data-token-create>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input type="text" name="name" placeholder="CLI on laptop" required />
|
||||||
|
</label>
|
||||||
|
<fieldset class="scope-grid">
|
||||||
|
<legend>Scopes</legend>
|
||||||
|
<label><input type="checkbox" name="scope" value="docs:read" checked /> Docs read</label>
|
||||||
|
<label><input type="checkbox" name="scope" value="docs:write" /> Docs write</label>
|
||||||
|
<label><input type="checkbox" name="scope" value="sync:read" checked /> Sync read</label>
|
||||||
|
<label><input type="checkbox" name="scope" value="sync:write" /> Sync write</label>
|
||||||
|
<label><input type="checkbox" name="scope" value="admin" /> Admin</label>
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit">Create token</button>
|
||||||
|
<p class="form-status" data-token-status></p>
|
||||||
|
<pre class="token-output" data-token-output hidden></pre>
|
||||||
|
</form>
|
||||||
|
<ul class="token-list" data-token-list></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="account-section">
|
||||||
|
<h2>Password</h2>
|
||||||
|
<form class="auth-form" data-password-change>
|
||||||
|
<label>
|
||||||
|
Current password
|
||||||
|
<input type="password" name="currentPassword" autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
New password
|
||||||
|
<input type="password" name="newPassword" autocomplete="new-password" minlength="12" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Change password</button>
|
||||||
|
<p class="form-status" data-password-status></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="account-section" data-admin-users-section hidden>
|
||||||
|
<h2>Users</h2>
|
||||||
|
<div class="user-list" data-admin-users></div>
|
||||||
|
<p class="form-status" data-admin-users-status></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="account-section account-section--danger">
|
||||||
|
<h2>Delete Account</h2>
|
||||||
|
<form class="auth-form" data-account-delete>
|
||||||
|
<label>
|
||||||
|
Current password
|
||||||
|
<input type="password" name="currentPassword" autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Delete account</button>
|
||||||
|
<p class="form-status" data-delete-status></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="site-header__inner">
|
<div class="site-header__inner">
|
||||||
<a class="site-brand" href="/" aria-label="Cairnquire home">
|
<a class="site-brand" href="/" aria-label="Cairnquire home">
|
||||||
<img src="/static/cairnquire%20logo%402x.webp" alt="" width="160" height="40" decoding="async" />
|
<img src="/static/favicon.png" alt="" width="64" height="64" decoding="async" />
|
||||||
<span class="sr-only">Cairnquire</span>
|
<span class="sr-only">Cairnquire</span>
|
||||||
</a>
|
</a>
|
||||||
<form class="site-search" action="/" method="get">
|
<form class="site-search" action="/" method="get">
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<nav class="site-nav">
|
<nav class="site-nav">
|
||||||
<a href="/">Docs</a>
|
<a href="/">Docs</a>
|
||||||
|
<a href="/account">Account</a>
|
||||||
{{ if .WebEnabled }}<a href="/app/">Admin</a>{{ end }}
|
{{ if .WebEnabled }}<a href="/app/">Admin</a>{{ end }}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,6 +42,12 @@
|
|||||||
{{ template "document_edit_content" .Data }}
|
{{ template "document_edit_content" .Data }}
|
||||||
{{ else if eq .BodyTemplate "search_content" }}
|
{{ else if eq .BodyTemplate "search_content" }}
|
||||||
{{ template "search_content" .Data }}
|
{{ template "search_content" .Data }}
|
||||||
|
{{ else if eq .BodyTemplate "login_content" }}
|
||||||
|
{{ template "login_content" .Data }}
|
||||||
|
{{ else if eq .BodyTemplate "account_content" }}
|
||||||
|
{{ template "account_content" .Data }}
|
||||||
|
{{ else if eq .BodyTemplate "device_verify_content" }}
|
||||||
|
{{ template "device_verify_content" .Data }}
|
||||||
{{ else if eq .BodyTemplate "error_content" }}
|
{{ else if eq .BodyTemplate "error_content" }}
|
||||||
{{ template "error_content" .Data }}
|
{{ template "error_content" .Data }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -53,6 +60,16 @@
|
|||||||
<span aria-hidden="true" class="cached-icon"></span>
|
<span aria-hidden="true" class="cached-icon"></span>
|
||||||
<p>Viewing cached offline content.</p>
|
<p>Viewing cached offline content.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="sync-queue" data-sync-queue hidden aria-live="polite">
|
||||||
|
<div class="sync-queue__header">
|
||||||
|
<div>
|
||||||
|
<strong data-sync-queue-title>Pending changes</strong>
|
||||||
|
<p data-sync-queue-summary>Waiting to sync.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-sync-now>Sync now</button>
|
||||||
|
</div>
|
||||||
|
<ul data-sync-queue-list></ul>
|
||||||
|
</section>
|
||||||
<div class="version-notice" data-version-notice hidden>
|
<div class="version-notice" data-version-notice hidden>
|
||||||
<p>A newer version is available.</p>
|
<p>A newer version is available.</p>
|
||||||
<button type="button" data-version-reload>Reload</button>
|
<button type="button" data-version-reload>Reload</button>
|
||||||
@@ -61,6 +78,7 @@
|
|||||||
<script src="/static/sync.js" defer></script>
|
<script src="/static/sync.js" defer></script>
|
||||||
<script src="/static/realtime.js" defer></script>
|
<script src="/static/realtime.js" defer></script>
|
||||||
<script src="/static/editor.js" defer></script>
|
<script src="/static/editor.js" defer></script>
|
||||||
|
<script src="/static/auth.js" defer></script>
|
||||||
<script src="/static/render.js" defer></script>
|
<script src="/static/render.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{ define "device_verify.gohtml" }}{{ template "base" . }}{{ end }}
|
||||||
|
|
||||||
|
{{ define "device_verify_content" }}
|
||||||
|
<section class="auth-shell">
|
||||||
|
<div class="auth-panel">
|
||||||
|
<div class="auth-panel__header">
|
||||||
|
<p class="eyebrow">Device authorization</p>
|
||||||
|
<h1>Approve device</h1>
|
||||||
|
</div>
|
||||||
|
<form class="auth-form" data-device-approve>
|
||||||
|
<label>
|
||||||
|
User code
|
||||||
|
<input type="text" name="userCode" value="{{ .UserCode }}" autocomplete="one-time-code" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Approve</button>
|
||||||
|
<p class="form-status" data-device-status></p>
|
||||||
|
</form>
|
||||||
|
<p class="auth-note">You must be signed in with a browser session before approving a device.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
76
apps/server/internal/httpserver/templates/login.gohtml
Normal file
76
apps/server/internal/httpserver/templates/login.gohtml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{{ define "login.gohtml" }}{{ template "base" . }}{{ end }}
|
||||||
|
|
||||||
|
{{ define "login_content" }}
|
||||||
|
<section class="auth-shell">
|
||||||
|
<input type="hidden" data-auth-next value="{{ .Next }}" />
|
||||||
|
<div class="auth-panel">
|
||||||
|
<div class="auth-panel__header">
|
||||||
|
<p class="eyebrow">Cairnquire</p>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-grid">
|
||||||
|
<form class="auth-form" data-auth-login>
|
||||||
|
<h2>Password</h2>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" autocomplete="email" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" name="password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
<p class="form-status" data-login-status></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="auth-form" data-passkey-login>
|
||||||
|
<h2>Passkey</h2>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" autocomplete="username webauthn" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Use passkey</button>
|
||||||
|
<p class="form-status" data-passkey-login-status></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="auth-details">
|
||||||
|
<summary>Create an account</summary>
|
||||||
|
<div class="auth-grid">
|
||||||
|
<form class="auth-form" data-auth-register>
|
||||||
|
<h2>Password account</h2>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" autocomplete="email" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Display name
|
||||||
|
<input type="text" name="displayName" autocomplete="name" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" name="password" autocomplete="new-password" minlength="12" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
<p class="form-status" data-register-status></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="auth-form" data-passkey-register>
|
||||||
|
<h2>Passkey account</h2>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" autocomplete="email" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Display name
|
||||||
|
<input type="text" name="displayName" autocomplete="name" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Create passkey</button>
|
||||||
|
<p class="form-status" data-passkey-register-status></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
@@ -46,6 +46,7 @@ func NewRenderer() *Renderer {
|
|||||||
),
|
),
|
||||||
goldmark.WithRendererOptions(
|
goldmark.WithRendererOptions(
|
||||||
html.WithHardWraps(),
|
html.WithHardWraps(),
|
||||||
|
html.WithUnsafe(),
|
||||||
html.WithXHTML(),
|
html.WithXHTML(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -51,3 +51,16 @@ func TestRenderPathUsesFolderNameForIndexFiles(t *testing.T) {
|
|||||||
t.Fatalf("Title = %q, want Guide", result.Title)
|
t.Fatalf("Title = %q, want Guide", result.Title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderAllowsRawHTML(t *testing.T) {
|
||||||
|
renderer := NewRenderer()
|
||||||
|
|
||||||
|
result, err := renderer.Render([]byte("Before <strong>hi</strong> after\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(result.HTML), "<strong>hi</strong>") {
|
||||||
|
t.Fatalf("expected raw HTML to render, got %s", result.HTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sync
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -174,6 +175,75 @@ func TestApplyDeltaDetectsConflicts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyDeltaAllowsDifferentFileEditsWithoutConflict(t *testing.T) {
|
||||||
|
service, sourceDir := setupTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitSync() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, "guide.md"), []byte("# Guide\n\nServer-side guide update"), 0o644); err != nil {
|
||||||
|
t.Fatalf("update guide.md: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientChange := Change{
|
||||||
|
Type: ChangeUpdate,
|
||||||
|
Path: "hello.md",
|
||||||
|
Hash: "clienthash-different-file",
|
||||||
|
Size: 128,
|
||||||
|
Modified: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: []Change{clientChange}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyDelta() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Conflicts) != 0 {
|
||||||
|
t.Fatalf("expected no conflicts for different-file edits, got %#v", result.Conflicts)
|
||||||
|
}
|
||||||
|
if len(result.ServerDelta) != 1 {
|
||||||
|
t.Fatalf("expected one server delta for guide.md, got %d", len(result.ServerDelta))
|
||||||
|
}
|
||||||
|
if result.ServerDelta[0].Path != "guide.md" {
|
||||||
|
t.Fatalf("server delta path = %q, want guide.md", result.ServerDelta[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveConflictsPreservesServerContent(t *testing.T) {
|
||||||
|
service, sourceDir := setupTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitSync() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverContent := "# Hello\n\nServer Update"
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil {
|
||||||
|
t.Fatalf("update hello.md: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.ResolveConflicts(ctx, snap.ID, []Resolution{{
|
||||||
|
Path: "hello.md",
|
||||||
|
Strategy: ResolutionServerWins,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveConflicts() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read hello.md: %v", err)
|
||||||
|
}
|
||||||
|
if string(content) != serverContent {
|
||||||
|
t.Fatalf("resolved content = %q, want %q", string(content), serverContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveConflictsCreatesNewSnapshot(t *testing.T) {
|
func TestResolveConflictsCreatesNewSnapshot(t *testing.T) {
|
||||||
service, _ := setupTestService(t)
|
service, _ := setupTestService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -229,3 +299,47 @@ func TestGetContentReturnsFileBytes(t *testing.T) {
|
|||||||
t.Fatal("expected non-empty content")
|
t.Fatal("expected non-empty content")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||||
|
service, sourceDir := setupTestService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
name := filepath.Join(sourceDir, fmt.Sprintf("note-%03d.md", i))
|
||||||
|
content := fmt.Sprintf("# Note %03d\n\nDeterministic body %03d\n", i, i)
|
||||||
|
if err := os.WriteFile(name, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("create %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
snap, err := service.InitSync(ctx, "device-100", "user:test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitSync() error = %v", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||||
|
t.Fatalf("InitSync() took %s, want <= 10s", elapsed)
|
||||||
|
}
|
||||||
|
if len(snap.Files) != 102 {
|
||||||
|
t.Fatalf("snapshot files = %d, want 102", len(snap.Files))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(sourceDir, "note-050.md"), []byte("# Note 050\n\nUpdated body\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("update note-050.md: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
result, err := service.ApplyDelta(ctx, snap.ID, Delta{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyDelta() error = %v", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
||||||
|
t.Fatalf("ApplyDelta() took %s, want <= 10s", elapsed)
|
||||||
|
}
|
||||||
|
if len(result.ServerDelta) != 1 {
|
||||||
|
t.Fatalf("server delta count = %d, want 1", len(result.ServerDelta))
|
||||||
|
}
|
||||||
|
if result.ServerDelta[0].Path != "note-050.md" {
|
||||||
|
t.Fatalf("server delta path = %q, want note-050.md", result.ServerDelta[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user