diff --git a/cmd/api/cmd.go b/cmd/api/cmd.go index e11029d..42a9354 100644 --- a/cmd/api/cmd.go +++ b/cmd/api/cmd.go @@ -13,12 +13,11 @@ import ( "cbnr/util" - "github.com/spf13/cobra" - "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/go-chi/jwtauth/v5" + "github.com/spf13/cobra" ) var ApiCmd = &cobra.Command{ diff --git a/cmd/git/cmd.go b/cmd/git/cmd.go index 3e65cca..5b3921a 100644 --- a/cmd/git/cmd.go +++ b/cmd/git/cmd.go @@ -4,214 +4,127 @@ import ( "context" "fmt" "log" + "math/rand" "net/http" "os" - "os/exec" "os/signal" - "strings" "syscall" - "text/template" "time" "cbnr/util" - "github.com/spf13/cobra" - + "github.com/asim/git-http-backend/server" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/jwtauth/v5" - - "github.com/asim/git-http-backend/server" - - "github.com/carlmjohnson/requests" + "github.com/spf13/cobra" ) -type Storage struct { - Name string `json:"storage_name"` - Token string `json:"storage_token"` -} - -func GitInitMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(out http.ResponseWriter, req *http.Request) { - - // Only need to init the repository at the start of the "git push", which - // always begins with this GET to /reponame/info/refs, so if this request - // is not that, it must not be the start of a push - if !(req.Method == "GET" && strings.HasSuffix(req.URL.Path, "/info/refs")) { - next.ServeHTTP(out, req) - return - } - - repoName := strings.Split(req.URL.Path, "/")[1] - repoPath := "/tmp/" + repoName - - log.Printf("git init bare %s", repoPath) - - cmd := exec.Command("git", "init", repoPath) - stdout, err := cmd.Output() - - if err != nil { - log.Printf(err.Error()) - return - } - - // Print the output - log.Printf(string(stdout)) - - next.ServeHTTP(out, req) - }) -} - -// gets the site ID from the URL path, then makes a query to supabase (using -// the JWT gotten from BasicAuthJwtVerifier) to make sure the user actually owns -// that site ID and can push there, then uses the storage name and pass to create -// a post-receive hook in the repo created by GitInitMiddleware -func RenderPostReceiveHookMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(out http.ResponseWriter, req *http.Request) { - - // Only need to init the repository at the start of the "git push", which - // always begins with this GET to /reponame/info/refs, so if this request - // is not that, it must not be the start of a push - if !(req.Method == "GET" && strings.HasSuffix(req.URL.Path, "/info/refs")) { - next.ServeHTTP(out, req) - return - } - - token := req.Header.Get("Authorization") - - repoName := strings.Split(req.URL.Path, "/")[1] +var GitCmd = &cobra.Command{ + Use: "git", + Short: "git - handles administrative tasks regarding bunny and supabase", + Run: func(cmd *cobra.Command, args []string) { - var storage []Storage + log.Printf("[INFO] Starting up...") - err := requests. - URL("https://ewwccbgjnulfgcvfrsvj.supabase.co"). - Path("/rest/v1/site"). - Param("select", "storage_name,storage_token"). - Param("id", fmt.Sprintf("eq.%s", repoName)). - Header("apikey", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV3d2NjYmdqbnVsZmdjdmZyc3ZqIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTM1ODE2ODUsImV4cCI6MjAwOTE1NzY4NX0.gI3YdNSC5GMkda2D2QPRMvnBdaMOS2ynfFKxis5-WKs"). - Header("Authorization", token). - ToJSON(&storage). - Fetch(req.Context()) + // ------------------------------------------------------------------------ - if err != nil { - http.Error(out, fmt.Sprintf("Error occurred querying sites from supabase, dying: %v", err), http.StatusInternalServerError) - return - } + log.Printf("[INFO] Seeding randomness for generating IDs...") - if len(storage) == 0 { - http.Error(out, fmt.Sprintf("No result rows from supabase for site ID %v (possibly RLS unauthorized?)", repoName), http.StatusUnauthorized) - return - } + rand.Seed(time.Now().UnixNano()) - if len(storage) > 1 { - http.Error(out, fmt.Sprintf("Too many rows from supabase, what do I do: %v", storage), http.StatusInternalServerError) - return - } + // ------------------------------------------------------------------------ - log.Printf("site ID found for repo %s, storage %s", repoName, storage[0]) + log.Printf("[INFO] Registering int handlers for graceful shutdown...") - hookValues := HookValues{ - SiteId: repoName, - StorageHost: "storage.bunnycdn.com", - StorageName: storage[0].Name, - StorageToken: storage[0].Token, - } + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - hookPath := fmt.Sprintf("/tmp/%s/.git/hooks/post-receive", repoName) + // ------------------------------------------------------------------------ - tpl, err := template.New("naaaaame").Parse(HookTemplate) - if err != nil { - http.Error(out, fmt.Sprintf("Unable to render post-receive hook template: %v", err), http.StatusInternalServerError) - return - } + log.Printf("[INFO] Getting configs from environment...") - file, err := os.OpenFile(hookPath, os.O_RDWR|os.O_CREATE, 0777) - if err != nil { - http.Error(out, fmt.Sprintf("Could not create and open post-receive hook file: %v", err), http.StatusInternalServerError) - return + configNames := []string{ + "HTTP_LISTENER_PORT", + "PERMISSIVE_MODE", + "JWT_SECRET", + "SUPABASE_URL", + "SUPABASE_ANON_KEY", } - defer file.Close() - - err = tpl.Execute(file, hookValues) + config, err := util.GetEnvConfigs(configNames) if err != nil { - http.Error(out, fmt.Sprintf("Could not render template into hook file: %v", err), http.StatusInternalServerError) - return + log.Fatalf("[ERROR] Could not parse configs from environment: %v", err) } - next.ServeHTTP(out, req) - return - }) -} + // ------------------------------------------------------------------------ -var GitCmd = &cobra.Command{ - Use: "git", - Short: "git - handles administrative tasks regarding bunny and supabase", - Run: func(cmd *cobra.Command, args []string) { + log.Printf("[INFO] Setting up middlewares...") - log.Printf("STARTING") + // as soon as supabase supports RS256 / asymmetric JWT encryption, get + // this out of here and replace with the public key just for validation + // https://github.com/orgs/supabase/discussions/4059 + jwtSecret := jwtauth.New("HS256", []byte(config["JWT_SECRET"]), nil) - // set up channel to handle graceful shutdown - done := make(chan os.Signal, 1) - signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + // ------------------------------------------------------------------------ - httpPort, err := util.GetEnvConfig("HTTP_LISTENER_PORT") - if err != nil { - log.Fatalf("Unable to get http listener port from environment: %v", err) - } + log.Printf("[INFO] Setting up custom git-middlewarer client...") - permissiveStr, err := util.GetEnvConfig("PERMISSIVE_MODE") - if err != nil { - log.Fatalf("Unable to get permissive mode status from environment: %v", err) + mid := Middlewarer{ + SupabaseUrl: config["SUPABASE_URL"], + SupabaseAnonKey: config["SUPABASE_ANON_KEY"], } - permissive := permissiveStr == "true" - - jwtSecretStr, err := util.GetEnvConfig("JWT_SECRET") - if err != nil { - log.Fatalf("Unable to get JWT secret token from environment: %v", err) - } + // ------------------------------------------------------------------------ - // as soon as supabase supports RS256 / asymmetric JWT encryption, get this - // out of here and replace with the public key just for validation - // https://github.com/orgs/supabase/discussions/4059 - jwtSecret := jwtauth.New("HS256", []byte(jwtSecretStr), nil) + log.Printf("[INFO] Registering middlewares and handlers...") r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(middleware.Logger) - //r.Use(middleware.AllowContentType("application/json")) - r.Use(middleware.Timeout(time.Second)) + r.Use(middleware.Timeout(60 * time.Second)) r.Use(util.BasicAuthJwtVerifier(jwtSecret)) - r.Use(util.CheckJwtMiddleware(permissive, true)) - r.Use(GitInitMiddleware) - r.Use(RenderPostReceiveHookMiddleware) + r.Use(util.CheckJwtMiddleware((config["PERMISSIVE_MODE"] == "true"), true)) + r.Use(mid.CreateGitInitMiddleware()) + r.Use(mid.CreateGitHookMiddleware()) r.Get("/*", server.Handler()) r.Post("/*", server.Handler()) - log.Printf("MIDDLEWARES SET UP, WILL LISTEN ON %v...", httpPort) + // ------------------------------------------------------------------------ + + log.Printf("[INFO] Trying to listen %v...", config["HTTP_LISTENER_PORT"]) + + primary := http.Server{ + Addr: fmt.Sprintf(":%v", config["HTTP_LISTENER_PORT"]), + Handler: r, + } + + go func() { + err := primary.ListenAndServe() + if err != nil { + log.Fatalf("[ERROR] Primary server could not start: %v", err) + } + }() - server := http.Server{Addr: fmt.Sprintf(":%v", httpPort), Handler: r} - go server.ListenAndServe() + // ------------------------------------------------------------------------ - log.Printf("HTTP SERVER STARTED IN GOROUTINE, waiting to die...") + log.Printf("[INFO] Listening! Main thread now waiting for interrupt...") - // block here until we get some sort of interrupt or kill <-done - log.Printf("GOT SIGNAL TO DIE, cleaning up...") + log.Printf("[INFO] Got signal to die, cleaning up...") ctx := context.Background() ctxTimeout, cancel := context.WithTimeout(ctx, time.Second*5) defer cancel() - err = server.Shutdown(ctxTimeout) + err = primary.Shutdown(ctxTimeout) if err != nil { - log.Fatalf("Could not cleanly shut down http server: %v", err) + log.Fatalf("[ERROR] Could not cleanly shut down primary server: %v", err) } - log.Printf("ALL DONE, GOODBYE") + log.Printf("[INFO] ALL DONE, GOODBYE") }, } diff --git a/cmd/git/middleware.go b/cmd/git/middleware.go new file mode 100644 index 0000000..ecda1f0 --- /dev/null +++ b/cmd/git/middleware.go @@ -0,0 +1,140 @@ +package git + +import ( + "fmt" + "log" + "net/http" + "os" + "os/exec" + "strings" + "text/template" + + "github.com/carlmjohnson/requests" +) + +type Middlewarer struct { + SupabaseUrl string + SupabaseAnonKey string +} + +type Storage struct { + Name string `json:"storage_name"` + Token string `json:"storage_token"` +} + +func (m Middlewarer) CreateGitInitMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(out http.ResponseWriter, req *http.Request) { + + // Only need to init the repository at the start of the "git push", which + // always begins with this GET to /reponame/info/refs, so if this request + // is not that, it must not be the start of a push + if !(req.Method == "GET" && strings.HasSuffix(req.URL.Path, "/info/refs")) { + next.ServeHTTP(out, req) + return + } + + repoName := strings.Split(req.URL.Path, "/")[1] + repoPath := "/tmp/" + repoName + + log.Printf("git init bare %s", repoPath) + + cmd := exec.Command("git", "init", repoPath) + stdout, err := cmd.Output() + + if err != nil { + log.Printf(err.Error()) + return + } + + // Print the output + log.Printf(string(stdout)) + + next.ServeHTTP(out, req) + }) + } +} + +// gets the site ID from the URL path, then makes a query to supabase (using +// the JWT gotten from BasicAuthJwtVerifier) to make sure the user actually owns +// that site ID and can push there, then uses the storage name and pass to create +// a post-receive hook in the repo created by GitInitMiddleware +func (m Middlewarer) CreateGitHookMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(out http.ResponseWriter, req *http.Request) { + + // Only need to init the repository at the start of the "git push", which + // always begins with this GET to /reponame/info/refs, so if this request + // is not that, it must not be the start of a push + if !(req.Method == "GET" && strings.HasSuffix(req.URL.Path, "/info/refs")) { + next.ServeHTTP(out, req) + return + } + + token := req.Header.Get("Authorization") + + repoName := strings.Split(req.URL.Path, "/")[1] + + var storage []Storage + + err := requests. + URL(m.SupabaseUrl). + Path("/rest/v1/site"). + Param("select", "storage_name,storage_token"). + Param("id", fmt.Sprintf("eq.%s", repoName)). + Header("apikey", m.SupabaseAnonKey). + Header("Authorization", token). + ToJSON(&storage). + Fetch(req.Context()) + + if err != nil { + http.Error(out, fmt.Sprintf("Error occurred querying sites from supabase, dying: %v", err), http.StatusInternalServerError) + return + } + + if len(storage) == 0 { + http.Error(out, fmt.Sprintf("No result rows from supabase for site ID %v (possibly RLS unauthorized?)", repoName), http.StatusUnauthorized) + return + } + + if len(storage) > 1 { + http.Error(out, fmt.Sprintf("Too many rows from supabase, what do I do: %v", storage), http.StatusInternalServerError) + return + } + + log.Printf("site ID found for repo %s, storage %s", repoName, storage[0]) + + hookValues := HookValues{ + SiteId: repoName, + StorageHost: "storage.bunnycdn.com", + StorageName: storage[0].Name, + StorageToken: storage[0].Token, + } + + hookPath := fmt.Sprintf("/tmp/%s/.git/hooks/post-receive", repoName) + + tpl, err := template.New("naaaaame").Parse(HookTemplate) + if err != nil { + http.Error(out, fmt.Sprintf("Unable to render post-receive hook template: %v", err), http.StatusInternalServerError) + return + } + + file, err := os.OpenFile(hookPath, os.O_RDWR|os.O_CREATE, 0777) + if err != nil { + http.Error(out, fmt.Sprintf("Could not create and open post-receive hook file: %v", err), http.StatusInternalServerError) + return + } + + defer file.Close() + + err = tpl.Execute(file, hookValues) + if err != nil { + http.Error(out, fmt.Sprintf("Could not render template into hook file: %v", err), http.StatusInternalServerError) + return + } + + next.ServeHTTP(out, req) + return + }) + } +} diff --git a/toast.yml b/toast.yml index a9b1acc..c923eae 100644 --- a/toast.yml +++ b/toast.yml @@ -107,6 +107,7 @@ tasks: environment: HTTP_LISTENER_PORT: "8090" PERMISSIVE_MODE: "true" + JWT_SECRET: "supersecretplaceholder" SUPABASE_URL: "aaa.supabase.co" SUPABASE_ANON_KEY: "supersecretplaceholder" ports: