aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--main.go13
-rw-r--r--pkg/auth/provider.go38
-rw-r--r--pkg/index/index.go11
-rw-r--r--pkg/site/site.go6
-rw-r--r--web/command/handler/authenticate.go52
-rw-r--r--web/command/handler/create.go4
-rw-r--r--web/command/handler/host.go57
-rw-r--r--web/command/html/authenticate.go43
-rw-r--r--web/command/html/create.go2
-rw-r--r--web/command/html/home.go3
-rw-r--r--web/command/html/host.go52
-rw-r--r--web/command/html/site.go1
-rw-r--r--web/command/middleware/authenticate.go25
-rw-r--r--web/mux.go25
16 files changed, 318 insertions, 17 deletions
diff --git a/go.mod b/go.mod
index 0e1d3f5..845b3f4 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
golang.org/x/crypto v0.33.0 // indirect
diff --git a/go.sum b/go.sum
index af7baf8..8adedd8 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
diff --git a/main.go b/main.go
index 7a38d82..5be14da 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "crypto/rand"
"fmt"
"log/slog"
"net/http"
@@ -8,6 +9,7 @@ import (
"path/filepath"
"github.com/LMBishop/scrapbook/api"
+ "github.com/LMBishop/scrapbook/pkg/auth"
"github.com/LMBishop/scrapbook/pkg/config"
"github.com/LMBishop/scrapbook/pkg/constants"
"github.com/LMBishop/scrapbook/pkg/index"
@@ -41,10 +43,19 @@ func main() {
slog.Warn("command interface host is empty - neither api or web interface will be accessible")
}
+ if cfg.Command.Secret == "" {
+ slog.Warn("command interface secret is empty - neither api or web interface will be accessible")
+ }
+
+ secretKey := make([]byte, 32)
+ rand.Read(secretKey)
+
+ authenticator := auth.NewAuthenticator(secretKey)
+
mux := http.NewServeMux()
mux.Handle(fmt.Sprintf("%s/api/", cfg.Command.Host), http.StripPrefix("/api/", api.NewMux(&cfg, siteIndex)))
- mux.Handle(fmt.Sprintf("%s/", cfg.Command.Host), web.NewMux(&cfg, siteIndex))
+ mux.Handle(fmt.Sprintf("%s/", cfg.Command.Host), web.NewMux(&cfg, siteIndex, authenticator))
mux.HandleFunc("/", server.ServeSite(siteIndex))
err = http.ListenAndServe(fmt.Sprintf("%s:%d", cfg.Listen.Address, cfg.Listen.Port), mux)
diff --git a/pkg/auth/provider.go b/pkg/auth/provider.go
new file mode 100644
index 0000000..0d515ab
--- /dev/null
+++ b/pkg/auth/provider.go
@@ -0,0 +1,38 @@
+package auth
+
+import (
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+type Authenticator struct {
+ secretKey []byte
+ parser *jwt.Parser
+}
+
+func NewAuthenticator(secretKey []byte) *Authenticator {
+ parser := jwt.NewParser(jwt.WithIssuer("scrapbook"), jwt.WithExpirationRequired())
+
+ a := &Authenticator{
+ secretKey: secretKey,
+ parser: parser,
+ }
+
+ return a
+}
+
+func (a *Authenticator) NewJwt() (string, error) {
+ t := jwt.NewWithClaims(jwt.SigningMethodHS256,
+ jwt.MapClaims{
+ "iss": "scrapbook",
+ "exp": jwt.NewNumericDate(time.Now().Add(time.Hour * 2)),
+ })
+
+ return t.SignedString(a.secretKey)
+}
+
+func (a *Authenticator) VerifyJwt(token string) error {
+ _, err := a.parser.Parse(token, func(t *jwt.Token) (interface{}, error) { return a.secretKey, nil })
+ return err
+}
diff --git a/pkg/index/index.go b/pkg/index/index.go
index 35423bd..f3dc00d 100644
--- a/pkg/index/index.go
+++ b/pkg/index/index.go
@@ -55,9 +55,18 @@ func (s *SiteIndex) AddSite(site *site.Site) {
s.updateSiteIndexes()
}
+func (s *SiteIndex) UpdateSiteIndexes() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.updateSiteIndexes()
+}
+
func (s *SiteIndex) updateSiteIndexes() {
clear(s.sitesByHost)
for _, site := range s.sites {
- s.sitesByHost[site.SiteConfig.Host] = site
+ if site.SiteConfig.Host != "" {
+ s.sitesByHost[site.SiteConfig.Host] = site
+ }
}
}
diff --git a/pkg/site/site.go b/pkg/site/site.go
index 3daf5e7..704c34d 100644
--- a/pkg/site/site.go
+++ b/pkg/site/site.go
@@ -133,6 +133,9 @@ func (s *Site) CreateNewVersion() (string, error) {
}
func (s *Site) EvaluateSiteStatus() string {
+ if s.SiteConfig.Host == "" {
+ return "inactive"
+ }
stat, err := os.Stat(s.GetCurrentPath())
if err != nil || !stat.IsDir() {
return "inactive"
@@ -145,6 +148,9 @@ func (s *Site) EvaluateSiteStatus() string {
}
func (s *Site) EvaluateSiteStatusReason() string {
+ if s.SiteConfig.Host == "" {
+ return "This site is not served by scrapbook"
+ }
stat, err := os.Stat(s.GetCurrentPath())
if err != nil || !stat.IsDir() {
return "This site is inacessible because no version is active"
diff --git a/web/command/handler/authenticate.go b/web/command/handler/authenticate.go
new file mode 100644
index 0000000..1c7d312
--- /dev/null
+++ b/web/command/handler/authenticate.go
@@ -0,0 +1,52 @@
+package handler
+
+import (
+ "crypto/subtle"
+ "fmt"
+ "net/http"
+
+ "github.com/LMBishop/scrapbook/pkg/auth"
+ "github.com/LMBishop/scrapbook/pkg/config"
+ "github.com/LMBishop/scrapbook/web/command/html"
+ . "maragu.dev/gomponents"
+ ghttp "maragu.dev/gomponents/http"
+)
+
+func GetAuthenticate() func(http.ResponseWriter, *http.Request) {
+ return ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) {
+ return html.AuthenticatePage(""), nil
+ })
+}
+
+func PostAuthenticate(mainConfig *config.MainConfig, authenticator *auth.Authenticator) func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ err := r.ParseForm()
+ if err != nil {
+ html.AuthenticatePage(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)
+ return
+ }
+
+ jwt, err := authenticator.NewJwt()
+ if err != nil {
+ html.AuthenticatePage(fmt.Errorf("Failed to create jwt: %w", err).Error()).Render(w)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "session",
+ Value: jwt,
+
+ Secure: true,
+ SameSite: http.SameSiteStrictMode,
+ HttpOnly: true,
+ })
+ http.Redirect(w, r, "/", 302)
+ }
+}
diff --git a/web/command/handler/create.go b/web/command/handler/create.go
index 21c6b1a..46c7779 100644
--- a/web/command/handler/create.go
+++ b/web/command/handler/create.go
@@ -36,10 +36,6 @@ func PostCreate(mainConfig *config.MainConfig, index *index.SiteIndex) func(http
return html.CreatePage("", "A name must be specified", formValues), nil
}
- if host == "" {
- return html.CreatePage("", "A host must be specified", formValues), nil
- }
-
site, err := site.CreateNewSite(name, path.Join(constants.SysDataDir, "sites"), host)
if err != nil {
diff --git a/web/command/handler/host.go b/web/command/handler/host.go
new file mode 100644
index 0000000..05b4ca6
--- /dev/null
+++ b/web/command/handler/host.go
@@ -0,0 +1,57 @@
+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 GetHost(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.HostPage("", "", siteName, site.SiteConfig.Host), nil
+ })
+}
+
+func PostHost(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.HostPage("", fmt.Errorf("Could not parse form: %w", err).Error(), siteName, site.SiteConfig.Host), nil
+ }
+
+ host := r.FormValue("host")
+
+ site.SiteConfig.Host = host
+ index.UpdateSiteIndexes()
+ err = config.WriteSiteConfig(path.Join(site.Path, "site.toml"), site.SiteConfig)
+ if err != nil {
+ return html.HostPage("", fmt.Errorf("Failed to persist flags: %w", err).Error(), siteName, host), nil
+ }
+
+ return html.HostPage(fmt.Sprintf("Successfully set host for %s to '%s'", siteName, host), "", siteName, host), nil
+ })
+}
diff --git a/web/command/html/authenticate.go b/web/command/html/authenticate.go
new file mode 100644
index 0000000..27a2321
--- /dev/null
+++ b/web/command/html/authenticate.go
@@ -0,0 +1,43 @@
+package html
+
+import (
+ . "maragu.dev/gomponents"
+ . "maragu.dev/gomponents/html"
+)
+
+func AuthenticatePage(err string) Node {
+ return page("Authenticate",
+ H1(Text("Welcome to scrapbook")),
+
+ If(err != "", alertError(err)),
+
+ Form(
+ Action("/authenticate"),
+ Method("post"),
+
+ FieldSet(
+ Legend(Text("Authentication")),
+ Label(
+ For("token"),
+ Text("Secret key"),
+ ),
+ Input(
+ ID("token"),
+ Name("token"),
+ ),
+ Span(
+ Class("form-help"),
+ Text("Enter the secret key to continue."),
+ ),
+ ),
+
+ Div(
+ Class("control-group group-right"),
+ Input(
+ Type("submit"),
+ Value("Submit"),
+ ),
+ ),
+ ),
+ )
+}
diff --git a/web/command/html/create.go b/web/command/html/create.go
index a0b77d1..8b76776 100644
--- a/web/command/html/create.go
+++ b/web/command/html/create.go
@@ -56,7 +56,7 @@ func CreatePage(success, err string, formValues CreatePageForm) Node {
),
Span(
Class("form-help"),
- Text("The fully qualified domain name for which this site is to be served on."),
+ Text("The fully qualified domain name for which this site is to be served on. If this site is not to be served by scrapbook, leave blank."),
),
),
diff --git a/web/command/html/home.go b/web/command/html/home.go
index 490b2b8..b9d585c 100644
--- a/web/command/html/home.go
+++ b/web/command/html/home.go
@@ -38,7 +38,8 @@ func HomePage(siteIndex *index.SiteIndex) Node {
Span(
Class("name"),
Span(Text(site.Name)),
- Span(Text(fmt.Sprintf("on %s", site.SiteConfig.Host))),
+ If(site.SiteConfig.Host == "", Span(Text("no host"))),
+ If(site.SiteConfig.Host != "", Span(Text(fmt.Sprintf("on %s", site.SiteConfig.Host)))),
),
Span(
Class("status"),
diff --git a/web/command/html/host.go b/web/command/html/host.go
new file mode 100644
index 0000000..36f0e6b
--- /dev/null
+++ b/web/command/html/host.go
@@ -0,0 +1,52 @@
+package html
+
+import (
+ "fmt"
+
+ . "maragu.dev/gomponents"
+ . "maragu.dev/gomponents/html"
+)
+
+func HostPage(success, err, siteName, hostValue string) Node {
+ return page("Change host for "+siteName,
+ H1(Text("Change host 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("Host")),
+ Input(
+ ID("host"),
+ Name("host"),
+ Value(hostValue),
+ ),
+ Span(
+ Class("form-help"),
+ Text("The fully qualified domain name for which this site is to be served on. If this site is not to be served by scrapbook, leave blank."),
+ ),
+ ),
+
+ 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 7616e6b..7da9dc0 100644
--- a/web/command/html/site.go
+++ b/web/command/html/site.go
@@ -26,6 +26,7 @@ func SitePage(mainConfig *config.MainConfig, site *site.Site) Node {
navButton("Upload new version", "upload"),
navButton("Set flags", "flags"),
+ navButton("Change host", "host"),
navButton("Delete site", "delete"),
),
),
diff --git a/web/command/middleware/authenticate.go b/web/command/middleware/authenticate.go
new file mode 100644
index 0000000..08e3552
--- /dev/null
+++ b/web/command/middleware/authenticate.go
@@ -0,0 +1,25 @@
+package middleware
+
+import (
+ "net/http"
+
+ "github.com/LMBishop/scrapbook/pkg/auth"
+)
+
+func MustAuthenticate(authenticator *auth.Authenticator, next http.HandlerFunc) http.HandlerFunc {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ jwt, err := r.Cookie("session")
+ if err != nil {
+ http.Redirect(w, r, "/authenticate", 302)
+ return
+ }
+
+ err = authenticator.VerifyJwt(jwt.Value)
+ if err != nil {
+ http.Redirect(w, r, "/authenticate", 302)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/web/mux.go b/web/mux.go
index 0821723..439eaa0 100644
--- a/web/mux.go
+++ b/web/mux.go
@@ -3,21 +3,28 @@ package web
import (
"net/http"
+ "github.com/LMBishop/scrapbook/pkg/auth"
"github.com/LMBishop/scrapbook/pkg/config"
"github.com/LMBishop/scrapbook/pkg/index"
"github.com/LMBishop/scrapbook/web/command/handler"
+ "github.com/LMBishop/scrapbook/web/command/middleware"
)
-func NewMux(cfg *config.MainConfig, siteIndex *index.SiteIndex) *http.ServeMux {
+func NewMux(cfg *config.MainConfig, siteIndex *index.SiteIndex, authenticator *auth.Authenticator) *http.ServeMux {
mux := http.NewServeMux()
- mux.HandleFunc("GET /", handler.GetHome(cfg, siteIndex))
- mux.HandleFunc("GET /create", handler.GetCreate())
- mux.HandleFunc("POST /create", handler.PostCreate(cfg, siteIndex))
- mux.HandleFunc("GET /site/{site}/", handler.GetSite(cfg, siteIndex))
- mux.HandleFunc("GET /site/{site}/upload", handler.GetUpload(siteIndex))
- mux.HandleFunc("POST /site/{site}/upload", handler.PostUpload(cfg, siteIndex))
- mux.HandleFunc("GET /site/{site}/flags", handler.GetFlags(siteIndex))
- mux.HandleFunc("POST /site/{site}/flags", handler.PostFlags(cfg, siteIndex))
+ mux.HandleFunc("GET /authenticate", handler.GetAuthenticate())
+ mux.HandleFunc("POST /authenticate", handler.PostAuthenticate(cfg, authenticator))
+
+ mux.HandleFunc("GET /", middleware.MustAuthenticate(authenticator, handler.GetHome(cfg, siteIndex)))
+ mux.HandleFunc("GET /create", middleware.MustAuthenticate(authenticator, handler.GetCreate()))
+ mux.HandleFunc("POST /create", middleware.MustAuthenticate(authenticator, handler.PostCreate(cfg, siteIndex)))
+ mux.HandleFunc("GET /site/{site}/", middleware.MustAuthenticate(authenticator, handler.GetSite(cfg, siteIndex)))
+ mux.HandleFunc("GET /site/{site}/upload", middleware.MustAuthenticate(authenticator, handler.GetUpload(siteIndex)))
+ mux.HandleFunc("POST /site/{site}/upload", middleware.MustAuthenticate(authenticator, handler.PostUpload(cfg, siteIndex)))
+ mux.HandleFunc("GET /site/{site}/flags", middleware.MustAuthenticate(authenticator, handler.GetFlags(siteIndex)))
+ 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)))
return mux
}