package api import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "github.com/1f349/bluebell/database" "github.com/1f349/bluebell/upload" "github.com/1f349/bluebell/validation" "github.com/1f349/mjwt" "github.com/1f349/mjwt/auth" "github.com/julienschmidt/httprouter" "golang.org/x/net/publicsuffix" "net/http" ) type apiDB interface { AddSite(ctx context.Context, arg database.AddSiteParams) error UpdateSiteToken(ctx context.Context, arg database.UpdateSiteTokenParams) error SetBranchEnabled(ctx context.Context, arg database.SetBranchEnabledParams) error } func New(upload *upload.Handler, keyStore *mjwt.KeyStore, db apiDB) *httprouter.Router { router := httprouter.New() router.GET("/", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { http.Error(rw, "Bluebell API Endpoint", http.StatusOK) }) // Site upload endpoint router.POST("/u/:site/:branch", upload.Handle) router.POST("/u", func(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { q := req.URL.Query() upload.Handle(rw, req, httprouter.Params{ {"site", q.Get("site")}, {"branch", q.Get("branch")}, }) }) // Site creation endpoint router.PUT("/sites/:host", checkAuth(keyStore, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) { host := params.ByName("host") if !validation.IsValidSite(host) { http.Error(rw, "Invalid site", http.StatusBadRequest) return } if !validateDomainOwnershipClaims(host, b.Claims.Perms) { http.Error(rw, "Forbidden", http.StatusForbidden) return } token, err := generateToken() if err != nil { http.Error(rw, "Failed to generate token", http.StatusInternalServerError) return } err = db.AddSite(req.Context(), database.AddSiteParams{ Domain: host, Token: token, }) if err != nil { http.Error(rw, "Failed to register site", http.StatusInternalServerError) return } rw.WriteHeader(http.StatusOK) _ = json.NewEncoder(rw).Encode(struct { Token string `json:"token"` }{Token: token}) })) // Reset site token endpoint router.POST("/sites/:host/reset-token", checkAuth(keyStore, func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, b AuthClaims) { host := params.ByName("host") if !validation.IsValidSite(host) { http.Error(rw, "Invalid site", http.StatusBadRequest) return } if !validateDomainOwnershipClaims(host, b.Claims.Perms) { http.Error(rw, "Forbidden", http.StatusForbidden) return } token, err := generateToken() if err != nil { http.Error(rw, "Failed to generate token", http.StatusInternalServerError) return } err = db.UpdateSiteToken(req.Context(), database.UpdateSiteTokenParams{ Domain: host, Token: token, }) if err != nil { http.Error(rw, "Failed to register site", http.StatusInternalServerError) return } rw.WriteHeader(http.StatusOK) _ = json.NewEncoder(rw).Encode(struct { Token string `json:"token"` }{Token: token}) })) // Enable/disable site branch 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 !validation.IsValidSite(host) { http.Error(rw, "Invalid site", http.StatusBadRequest) return } if !validation.IsValidBranch(branch) { http.Error(rw, "Invalid branch", http.StatusBadRequest) return } if !validateDomainOwnershipClaims(host, b.Claims.Perms) { http.Error(rw, "Forbidden", http.StatusForbidden) return } err := db.SetBranchEnabled(req.Context(), database.SetBranchEnabledParams{ Domain: host, Branch: branch, Enable: enable, }) if err != nil { http.Error(rw, "Failed to update branch state", http.StatusInternalServerError) return } rw.WriteHeader(http.StatusOK) } func generateToken() (string, error) { b := make([]byte, 32) _, err := rand.Read(b) if err != nil { return "", err } return hex.EncodeToString(b), nil } // 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 }