From c9e78305a1d9eeb59fb8eeb4d80e97f2243b40dc Mon Sep 17 00:00:00 2001 From: MrMelon54 Date: Sun, 5 Jan 2025 20:37:12 +0000 Subject: [PATCH] Add branch enable/disable api endpoint --- api/api.go | 68 ++++++++++++++++++++++++++++++++++++++++++++ api/auth.go | 49 +++++++++++++++++++++++++++++++ cmd/bluebell/main.go | 22 +++++++------- go.mod | 9 ++++++ go.sum | 29 ++++++++++++++++++- 5 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 api/api.go create mode 100644 api/auth.go diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..b3aaaa5 --- /dev/null +++ b/api/api.go @@ -0,0 +1,68 @@ +package api + +import ( + "context" + "encoding/json" + "github.com/1f349/bluebell/database" + "github.com/1f349/bluebell/upload" + "github.com/1f349/mjwt" + "github.com/1f349/mjwt/auth" + "github.com/julienschmidt/httprouter" + "golang.org/x/net/publicsuffix" + "net/http" +) + +type apiDB interface { + SetDomainBranchEnabled(ctx context.Context, arg database.SetDomainBranchEnabledParams) error +} + +func New(upload *upload.Handler, keyStore *mjwt.KeyStore, db apiDB) *httprouter.Router { + router := httprouter.New() + router.POST("/u/:site/:branch", upload.Handle) + router.PUT("/sites/:host/:branch/enable", checkAuth(keyStore, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) { + setEnabled(rw, req, params, b, db, true) + })) + router.DELETE("/sites/:host/:branch/enable", checkAuth(keyStore, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) { + setEnabled(rw, req, params, b, db, false) + })) + return router +} + +func setEnabled(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims, db apiDB, enable bool) { + host := params.ByName("host") + branch := params.ByName("branch") + + if !validateDomainOwnershipClaims(host, b.Claims.Perms) { + http.Error(rw, "Forbidden", http.StatusForbidden) + } + + err := db.SetDomainBranchEnabled(req.Context(), database.SetDomainBranchEnabledParams{ + Domain: host, + Branch: branch, + Enable: enable, + }) + if err != nil { + http.Error(rw, "Failed to update branch state", http.StatusInternalServerError) + return + } + rw.WriteHeader(http.StatusAccepted) +} + +// apiError outputs a generic JSON error message +func apiError(rw http.ResponseWriter, code int, m string) { + rw.WriteHeader(code) + _ = json.NewEncoder(rw).Encode(map[string]string{ + "error": m, + }) +} + +// validateDomainOwnershipClaims validates if the claims contain the +// `domain:owns=` field with the matching top level domain +func validateDomainOwnershipClaims(a string, perms *auth.PermStorage) bool { + if fqdn, err := publicsuffix.EffectiveTLDPlusOne(a); err == nil { + if perms.Has("domain:owns=" + fqdn) { + return true + } + } + return false +} diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000..181bdf6 --- /dev/null +++ b/api/auth.go @@ -0,0 +1,49 @@ +package api + +import ( + "github.com/1f349/mjwt" + "github.com/1f349/mjwt/auth" + "github.com/julienschmidt/httprouter" + "net/http" + "strings" +) + +type AuthClaims mjwt.BaseTypeClaims[auth.AccessTokenClaims] + +type AuthCallback func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) + +// checkAuth validates the bearer token against a mjwt.Verifier and returns an +// error message or continues to the next handler +func checkAuth(verify *mjwt.KeyStore, cb AuthCallback) httprouter.Handle { + return func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + // Get bearer token + bearer, ok := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ") + if !ok || bearer == "" { + apiError(rw, http.StatusForbidden, "Missing bearer token") + return + } + + // Read claims from mjwt + _, b, err := mjwt.ExtractClaims[auth.AccessTokenClaims](verify, bearer) + if err != nil { + apiError(rw, http.StatusForbidden, "Invalid token") + return + } + + cb(rw, req, params, AuthClaims(b)) + } +} + +// checkAuthWithPerm validates the bearer token and checks if it contains a +// required permission and returns an error message or continues to the next +// handler +func checkAuthWithPerm(verify *mjwt.KeyStore, perm string, cb AuthCallback) httprouter.Handle { + return checkAuth(verify, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) { + // check perms + if !b.Claims.Perms.Has(perm) { + apiError(rw, http.StatusForbidden, "No permission") + return + } + cb(rw, req, params, b) + }) +} diff --git a/cmd/bluebell/main.go b/cmd/bluebell/main.go index 8fa68cd..66242f2 100644 --- a/cmd/bluebell/main.go +++ b/cmd/bluebell/main.go @@ -5,14 +5,15 @@ import ( "errors" "flag" "github.com/1f349/bluebell" + "github.com/1f349/bluebell/api" "github.com/1f349/bluebell/conf" "github.com/1f349/bluebell/logger" "github.com/1f349/bluebell/serve" "github.com/1f349/bluebell/upload" + "github.com/1f349/mjwt" "github.com/charmbracelet/log" "github.com/cloudflare/tableflip" "github.com/dustin/go-humanize" - "github.com/julienschmidt/httprouter" "github.com/spf13/afero" "gopkg.in/yaml.v3" "net/http" @@ -69,6 +70,11 @@ func main() { wd := filepath.Dir(*configPath) sitesDir := filepath.Join(wd, "sites") + keyStore, err := mjwt.NewKeyStoreFromPath(filepath.Join(wd, "keystore")) + if err != nil { + logger.Logger.Fatal("Failed to load MJWT keystore", "dir", filepath.Join(wd, "keystore"), "err", err) + } + _, err = os.Stat(sitesDir) if err != nil { logger.Logger.Fatal("Failed to find sites, does the directory exist? Error: ", err) @@ -105,17 +111,9 @@ func main() { logger.Logger.Fatal("Listen failed", "err", err) } - uploadHandler := upload.New(sitesFs, db) serveHandler := serve.New(sitesFs, db) - - router := httprouter.New() - router.POST("/u/:site/:branch", uploadHandler.Handle) - router.POST("/sites/:host", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - - }) - router.DELETE("/sites/:host", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - - }) + uploadHandler := upload.New(sitesFs, db) + apiHandler := api.New(uploadHandler, keyStore, db) serverHttp := &http.Server{ Handler: serveHandler, @@ -134,7 +132,7 @@ func main() { }() serverApi := &http.Server{ - Handler: router, + Handler: apiHandler, ReadTimeout: 1 * time.Minute, ReadHeaderTimeout: 1 * time.Minute, WriteTimeout: 1 * time.Minute, diff --git a/go.mod b/go.mod index 6d9299e..f9d6ec0 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/1f349/bluebell go 1.23.4 require ( + github.com/1f349/mjwt v0.4.1 github.com/charmbracelet/log v0.4.0 github.com/cloudflare/tableflip v1.2.3 github.com/dustin/go-humanize v1.0.1 @@ -10,15 +11,20 @@ require ( github.com/julienschmidt/httprouter v1.3.0 github.com/spf13/afero v1.11.0 github.com/stretchr/testify v1.10.0 + golang.org/x/net v0.33.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/1f349/rsa-helper v0.0.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/becheran/wildmatch-go v1.0.0 // indirect github.com/charmbracelet/lipgloss v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.6.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -26,10 +32,13 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/muesli/termenv v0.15.2 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect + golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index 8127b46..d3e7971 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +github.com/1f349/mjwt v0.4.1 h1:ooCroMMw2kcL5c9L3sLbdtxI0H4/QC8RfTxiloKr+4Y= +github.com/1f349/mjwt v0.4.1/go.mod h1:qwnzokkqc7Z9YmKA1m9beI3OZL1GvGYHOQU2rOwoV1M= +github.com/1f349/rsa-helper v0.0.2 h1:N/fLQqg5wrjIzG6G4zdwa5Xcv9/jIPutCls9YekZr9U= +github.com/1f349/rsa-helper v0.0.2/go.mod h1:VUQ++1tYYhYrXeOmVFkQ82BegR24HQEJHl5lHbjg7yg= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= +github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= @@ -12,10 +18,16 @@ 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -23,6 +35,10 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -35,26 +51,37 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=