aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--go.mod1
-rw-r--r--go.sum3
-rw-r--r--main.go6
-rw-r--r--pkg/auth/provider.go6
-rw-r--r--pkg/config/site.go5
-rw-r--r--pkg/html/password.go47
-rw-r--r--pkg/server/serve.go79
-rw-r--r--pkg/site/fs.go6
-rw-r--r--pkg/site/site.go11
-rw-r--r--web/command/handler/authenticate.go8
-rw-r--r--web/command/handler/password.go56
-rw-r--r--web/command/html/authenticate.go2
-rw-r--r--web/command/html/flags.go1
-rw-r--r--web/command/html/password.go53
-rw-r--r--web/command/html/site.go1
-rw-r--r--web/mux.go2
16 files changed, 263 insertions, 24 deletions
diff --git a/go.mod b/go.mod
index 845b3f4..36f2fe0 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 8adedd8..5593dc9 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/main.go b/main.go
index c4605c9..4e8e22a 100644
--- a/main.go
+++ b/main.go
@@ -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"),
),
),
diff --git a/web/mux.go b/web/mux.go
index e3ad8d4..ea18711 100644
--- a/web/mux.go
+++ b/web/mux.go
@@ -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)))