diff options
| author | Leonardo Bishop <me@leonardobishop.net> | 2026-03-19 17:14:20 +0000 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.net> | 2026-03-19 17:14:20 +0000 |
| commit | 66bd5d2f7fd84eec39d69f4a8f5c435fc978804f (patch) | |
| tree | 20bfd8637cce28750b4a66bdd9aa47cb9831308b | |
| parent | 60cd7875c2c9ee595012078a3ba8f13b71c73dc9 (diff) | |
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 3 | ||||
| -rw-r--r-- | main.go | 6 | ||||
| -rw-r--r-- | pkg/auth/provider.go | 6 | ||||
| -rw-r--r-- | pkg/config/site.go | 5 | ||||
| -rw-r--r-- | pkg/html/password.go | 47 | ||||
| -rw-r--r-- | pkg/server/serve.go | 79 | ||||
| -rw-r--r-- | pkg/site/fs.go | 6 | ||||
| -rw-r--r-- | pkg/site/site.go | 11 | ||||
| -rw-r--r-- | web/command/handler/authenticate.go | 8 | ||||
| -rw-r--r-- | web/command/handler/password.go | 56 | ||||
| -rw-r--r-- | web/command/html/authenticate.go | 2 | ||||
| -rw-r--r-- | web/command/html/flags.go | 1 | ||||
| -rw-r--r-- | web/command/html/password.go | 53 | ||||
| -rw-r--r-- | web/command/html/site.go | 1 | ||||
| -rw-r--r-- | web/mux.go | 2 |
16 files changed, 263 insertions, 24 deletions
@@ -3,6 +3,7 @@ module github.com/LMBishop/scrapbook go 1.24.3 require ( + codeberg.org/emersion/go-scfg v0.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -1,3 +1,6 @@ +codeberg.org/emersion/go-scfg v0.1.0 h1:6dnGU0ZI4gX+O5rMjwhoaySItzHG710eXL5TIQKl+uM= +codeberg.org/emersion/go-scfg v0.1.0/go.mod h1:0nooW1ufBB4SlJEdTtiVN9Or+bnNM1icOkQ6Tbrq6O0= +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/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= @@ -1,7 +1,6 @@ package main import ( - "crypto/rand" "fmt" "log/slog" "net/http" @@ -43,10 +42,7 @@ func main() { slog.Info("initial data directory scan complete", "sites", len(siteIndex.GetSites())) - secretKey := make([]byte, 32) - rand.Read(secretKey) - - authenticator := auth.NewAuthenticator(secretKey) + authenticator := auth.NewAuthenticator() mux := http.NewServeMux() diff --git a/pkg/auth/provider.go b/pkg/auth/provider.go index 0d515ab..e521c8c 100644 --- a/pkg/auth/provider.go +++ b/pkg/auth/provider.go @@ -1,6 +1,7 @@ package auth import ( + "crypto/rand" "time" "github.com/golang-jwt/jwt/v5" @@ -11,9 +12,12 @@ type Authenticator struct { parser *jwt.Parser } -func NewAuthenticator(secretKey []byte) *Authenticator { +func NewAuthenticator() *Authenticator { parser := jwt.NewParser(jwt.WithIssuer("scrapbook"), jwt.WithExpirationRequired()) + secretKey := make([]byte, 32) + rand.Read(secretKey) + a := &Authenticator{ secretKey: secretKey, parser: parser, diff --git a/pkg/config/site.go b/pkg/config/site.go index 4b36f1a..bcdbe86 100644 --- a/pkg/config/site.go +++ b/pkg/config/site.go @@ -17,9 +17,10 @@ const ( ) type SiteConfig struct { - Host string + Flags SiteFlag `scfg:"flags"` - Flags SiteFlag + Host string `scfg:"host"` + Password string `scfg:"password"` } func ReadSiteConfig(filePath string, dst *SiteConfig) error { diff --git a/pkg/html/password.go b/pkg/html/password.go new file mode 100644 index 0000000..ba23a06 --- /dev/null +++ b/pkg/html/password.go @@ -0,0 +1,47 @@ +package html + +import ( + "net/url" + + . "github.com/LMBishop/scrapbook/web/skeleton" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func AuthenticateSitePage(err, redirect, siteName string) Node { + return Page("Authenticate", + H1(Text("A password is required to visit this site")), + + If(err != "", AlertError(err)), + + Form( + Action("/authenticate?redirect="+url.QueryEscape(redirect)), + Method("post"), + + FieldSet( + Legend(Text("Authentication")), + Label( + For("password"), + Text("Password"), + ), + Input( + ID("password"), + Name("password"), + Type("password"), + ), + Span( + Class("form-help"), + Text("Enter the password to continue."), + ), + ), + + Div( + Class("control-group group-right"), + Input( + Type("submit"), + Value("Submit"), + ), + ), + ), + ) +} diff --git a/pkg/server/serve.go b/pkg/server/serve.go index c938870..84d0a21 100644 --- a/pkg/server/serve.go +++ b/pkg/server/serve.go @@ -1,10 +1,16 @@ package server import ( + "crypto/subtle" + "fmt" "net/http" + "net/url" + "strings" + "github.com/LMBishop/scrapbook/pkg/config" "github.com/LMBishop/scrapbook/pkg/html" "github.com/LMBishop/scrapbook/pkg/index" + "github.com/LMBishop/scrapbook/pkg/site" ) func ServeSite(siteIndex *index.SiteIndex) func(w http.ResponseWriter, r *http.Request) { @@ -16,6 +22,79 @@ func ServeSite(siteIndex *index.SiteIndex) func(w http.ResponseWriter, r *http.R return } + if site.SiteConfig.Flags&config.FlagDisable != 0 { + w.WriteHeader(http.StatusForbidden) + html.ForbiddenDisabledPage(site.SiteConfig.Host).Render(w) + return + } + + if site.SiteConfig.Flags&config.FlagPassword != 0 { + jwt, err := r.Cookie("session") + if err != nil { + goto deny + } + + err = site.Authenticator.VerifyJwt(jwt.Value) + if err != nil { + goto deny + } + + goto permit + + deny: + if strings.HasPrefix(r.URL.Path, "/authenticate") { + goto ask + } + http.Redirect(w, r, "/authenticate?redirect="+url.QueryEscape(r.URL.Path), 302) + return + + ask: + handleAsk(w, r, site) + return + + permit: + } + site.Handler.ServeHTTP(w, r) } } + +func handleAsk(w http.ResponseWriter, r *http.Request, site *site.Site) { + redirect := r.URL.Query().Get("redirect") + + switch r.Method { + case "GET": + html.AuthenticateSitePage("", redirect, site.Name).Render(w) + case "POST": + err := r.ParseForm() + if err != nil { + html.AuthenticateSitePage(err.Error(), redirect, site.Name).Render(w) + return + } + + password := r.Form.Get("password") + + if len(site.SiteConfig.Password) == 0 || subtle.ConstantTimeCompare([]byte(password), []byte(site.SiteConfig.Password)) != 1 { + html.AuthenticateSitePage("The password is incorrect", redirect, site.Name).Render(w) + return + } + + jwt, err := site.Authenticator.NewJwt() + if err != nil { + html.AuthenticateSitePage(fmt.Errorf("Failed to create jwt: %w", err).Error(), redirect, site.Name).Render(w) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: jwt, + + Secure: true, + SameSite: http.SameSiteStrictMode, + HttpOnly: true, + }) + http.Redirect(w, r, redirect, 302) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} diff --git a/pkg/site/fs.go b/pkg/site/fs.go index ec57318..4542344 100644 --- a/pkg/site/fs.go +++ b/pkg/site/fs.go @@ -21,12 +21,6 @@ func NewSiteFileServer(root http.FileSystem, siteConfig *config.SiteConfig) *Sit } func (fs *SiteFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if fs.siteConfig.Flags&config.FlagDisable != 0 { - w.WriteHeader(http.StatusForbidden) - html.ForbiddenDisabledPage(fs.siteConfig.Host).Render(w) - return - } - path := filepath.Clean(r.URL.Path) var info os.FileInfo diff --git a/pkg/site/site.go b/pkg/site/site.go index 8b7af41..e15480f 100644 --- a/pkg/site/site.go +++ b/pkg/site/site.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/LMBishop/scrapbook/pkg/auth" "github.com/LMBishop/scrapbook/pkg/config" ) @@ -19,10 +20,11 @@ const versionRegex = "[0-9]{4}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]{2}" const timeFormat = "2006_01_02_15_04_05" type Site struct { - Name string - Path string - Handler http.Handler - SiteConfig *config.SiteConfig + Name string + Path string + Handler http.Handler + Authenticator *auth.Authenticator + SiteConfig *config.SiteConfig } func NewSite(name string, dir string, config *config.SiteConfig) *Site { @@ -30,6 +32,7 @@ func NewSite(name string, dir string, config *config.SiteConfig) *Site { site.Name = name site.Path = dir site.SiteConfig = config + site.Authenticator = auth.NewAuthenticator() site.Handler = NewSiteFileServer(http.Dir(path.Join(dir, "default")), config) return &site } diff --git a/web/command/handler/authenticate.go b/web/command/handler/authenticate.go index 1c7d312..a463e7f 100644 --- a/web/command/handler/authenticate.go +++ b/web/command/handler/authenticate.go @@ -14,7 +14,7 @@ import ( func GetAuthenticate() func(http.ResponseWriter, *http.Request) { return ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) { - return html.AuthenticatePage(""), nil + return html.AuthenticateScrapbookPage(""), nil }) } @@ -22,20 +22,20 @@ func PostAuthenticate(mainConfig *config.MainConfig, authenticator *auth.Authent return func(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { - html.AuthenticatePage(err.Error()).Render(w) + html.AuthenticateScrapbookPage(err.Error()).Render(w) return } token := r.Form.Get("token") if len(mainConfig.Command.Secret) == 0 || subtle.ConstantTimeCompare([]byte(token), []byte(mainConfig.Command.Secret)) != 1 { - html.AuthenticatePage("The secret key is incorrect").Render(w) + html.AuthenticateScrapbookPage("The secret key is incorrect").Render(w) return } jwt, err := authenticator.NewJwt() if err != nil { - html.AuthenticatePage(fmt.Errorf("Failed to create jwt: %w", err).Error()).Render(w) + html.AuthenticateScrapbookPage(fmt.Errorf("Failed to create jwt: %w", err).Error()).Render(w) return } diff --git a/web/command/handler/password.go b/web/command/handler/password.go new file mode 100644 index 0000000..7f74a78 --- /dev/null +++ b/web/command/handler/password.go @@ -0,0 +1,56 @@ +package handler + +import ( + "fmt" + "net/http" + "path" + + "github.com/LMBishop/scrapbook/pkg/config" + "github.com/LMBishop/scrapbook/pkg/index" + "github.com/LMBishop/scrapbook/web/command/html" + . "maragu.dev/gomponents" + ghttp "maragu.dev/gomponents/http" +) + +func GetPassword(index *index.SiteIndex) func(http.ResponseWriter, *http.Request) { + return ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) { + siteName := r.PathValue("site") + if siteName == "" { + return html.ErrorPage("Unknown site: " + siteName), nil + } + site := index.GetSite(siteName) + if site == nil { + return html.ErrorPage("Unknown site: " + siteName), nil + } + + return html.PasswordPage("", "", siteName, site.SiteConfig.Password), nil + }) +} + +func PostPassword(mainConfig *config.MainConfig, index *index.SiteIndex) func(http.ResponseWriter, *http.Request) { + return ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) { + siteName := r.PathValue("site") + if siteName == "" { + return html.ErrorPage("Unknown site: " + siteName), nil + } + site := index.GetSite(siteName) + if site == nil { + return html.ErrorPage("Unknown site: " + siteName), nil + } + + err := r.ParseForm() + if err != nil { + return html.PasswordPage("", fmt.Errorf("Could not parse form: %w", err).Error(), siteName, site.SiteConfig.Password), nil + } + + password := r.FormValue("password") + + site.SiteConfig.Password = password + err = config.WriteSiteConfig(path.Join(site.Path, "site.toml"), site.SiteConfig) + if err != nil { + return html.PasswordPage("", fmt.Errorf("Failed to persist site: %w", err).Error(), siteName, password), nil + } + + return html.PasswordPage(fmt.Sprintf("Successfully set password for %s to '%s'", siteName, password), "", siteName, password), nil + }) +} diff --git a/web/command/html/authenticate.go b/web/command/html/authenticate.go index 3d85325..fde99c1 100644 --- a/web/command/html/authenticate.go +++ b/web/command/html/authenticate.go @@ -6,7 +6,7 @@ import ( . "maragu.dev/gomponents/html" ) -func AuthenticatePage(err string) Node { +func AuthenticateScrapbookPage(err string) Node { return Page("Authenticate", H1(Text("Welcome to scrapbook")), diff --git a/web/command/html/flags.go b/web/command/html/flags.go index 61a1f50..cb19baf 100644 --- a/web/command/html/flags.go +++ b/web/command/html/flags.go @@ -88,7 +88,6 @@ func FlagsPage(success, err string, siteName string, flags config.SiteFlag) Node ID("password"), Name("password"), Type("checkbox"), - Disabled(), If(flags&config.FlagPassword != 0, Checked()), ), Label( diff --git a/web/command/html/password.go b/web/command/html/password.go new file mode 100644 index 0000000..3c53048 --- /dev/null +++ b/web/command/html/password.go @@ -0,0 +1,53 @@ +package html + +import ( + "fmt" + + . "github.com/LMBishop/scrapbook/web/skeleton" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +func PasswordPage(success, err, siteName, passwordValue string) Node { + return Page("Change password for "+siteName, + H1(Text("Change password for "+siteName)), + + If(success != "", Group{ + AlertSuccess(success), + Div( + Class("control-group group-right"), + NavButton("OK", fmt.Sprintf("/site/%s/", siteName)), + ), + }), + + If(success == "", Group{ + If(err != "", AlertError(err)), + + Form( + Method("post"), + + FieldSet( + Legend(Text("Password")), + Input( + ID("password"), + Name("password"), + Value(passwordValue), + ), + Span( + Class("form-help"), + Text("The password visitors must enter to be able to view the site."), + ), + ), + + Div( + Class("control-group group-right"), + NavButton("Go back", fmt.Sprintf("/site/%s/", siteName)), + Input( + Type("submit"), + Value("Submit"), + ), + ), + ), + }), + ) +} diff --git a/web/command/html/site.go b/web/command/html/site.go index e9bfbac..bd91c12 100644 --- a/web/command/html/site.go +++ b/web/command/html/site.go @@ -27,6 +27,7 @@ func SitePage(mainConfig *config.MainConfig, site *site.Site) Node { NavButton("Change host", "host"), NavButton("Set flags", "flags"), + NavButton("Change password", "password"), NavButton("Delete site", "delete"), ), ), @@ -25,6 +25,8 @@ func NewMux(cfg *config.MainConfig, siteIndex *index.SiteIndex, authenticator *a mux.HandleFunc("POST /site/{site}/flags", middleware.MustAuthenticate(authenticator, handler.PostFlags(cfg, siteIndex))) mux.HandleFunc("GET /site/{site}/host", middleware.MustAuthenticate(authenticator, handler.GetHost(siteIndex))) mux.HandleFunc("POST /site/{site}/host", middleware.MustAuthenticate(authenticator, handler.PostHost(cfg, siteIndex))) + mux.HandleFunc("GET /site/{site}/password", middleware.MustAuthenticate(authenticator, handler.GetPassword(siteIndex))) + mux.HandleFunc("POST /site/{site}/password", middleware.MustAuthenticate(authenticator, handler.PostPassword(cfg, siteIndex))) mux.HandleFunc("GET /site/{site}/delete", middleware.MustAuthenticate(authenticator, handler.GetDelete(siteIndex))) mux.HandleFunc("POST /site/{site}/delete", middleware.MustAuthenticate(authenticator, handler.PostDelete(cfg, siteIndex))) |
