diff --git a/app/github/github.go b/app/github/github.go new file mode 100644 index 0000000..0cfbd2d --- /dev/null +++ b/app/github/github.go @@ -0,0 +1,177 @@ +package github + +import ( + "archive/zip" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" +) + +func FetchAndDeployBranch(repoOwner, repoName, branch, pat, destDir string) error { + archiveURL := fmt.Sprintf( + "https://api.github.com/repos/%s/%s/zipball/%s", + repoOwner, repoName, branch, + ) + + req, err := http.NewRequest(http.MethodGet, archiveURL, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+pat) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("fetching archive: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GitHub returned %s for %s", resp.Status, archiveURL) + } + + tmpZip, err := os.CreateTemp("", "ghbranch-*.zip") + if err != nil { + return fmt.Errorf("creating temp zip: %w", err) + } + defer os.Remove(tmpZip.Name()) + + if _, err = io.Copy(tmpZip, resp.Body); err != nil { + tmpZip.Close() + return fmt.Errorf("writing zip: %w", err) + } + tmpZip.Close() + + tmpDir, err := os.MkdirTemp("", "ghbranch-unpack-*") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err = unzip(tmpZip.Name(), tmpDir); err != nil { + return fmt.Errorf("unzipping: %w", err) + } + + // github wraps everything in one top-level folder ({owner}-{repo}-{sha}/). + entries, err := os.ReadDir(tmpDir) + if err != nil { + return fmt.Errorf("reading temp dir: %w", err) + } + if len(entries) != 1 || !entries[0].IsDir() { + return fmt.Errorf("unexpected archive layout: expected a single root directory") + } + extractedRoot := filepath.Join(tmpDir, entries[0].Name()) + + if err = os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { + return fmt.Errorf("creating destination parent dirs: %w", err) + } + os.RemoveAll(destDir) + + if err = os.Rename(extractedRoot, destDir); err == nil { + return nil + } + + if err = copyDir(extractedRoot, destDir); err != nil { + return fmt.Errorf("cross-device copy to destination: %w", err) + } + return nil +} + +// unzip extracts the zip at src into the dest directory, +// guarding against zip-slip path traversal attacks. +func unzip(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + destClean := filepath.Clean(dest) + string(os.PathSeparator) + + for _, f := range r.File { + target := filepath.Join(dest, filepath.Clean("/"+f.Name)) + if !strings.HasPrefix(target, destClean) { + return fmt.Errorf("zip slip attempt: %q resolves outside destination", f.Name) + } + + if f.FileInfo().IsDir() { + if err = os.MkdirAll(target, f.Mode()); err != nil { + return err + } + continue + } + + if err = os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + + if err = writeZipEntry(f, target); err != nil { + return err + } + } + return nil +} + +func writeZipEntry(f *zip.File, target string) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, rc) + return err +} + +// copyDir recursively copies src to dst, preserving file modes. +// Used as a cross-device fallback when os.Rename fails. +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + + info, err := d.Info() + if err != nil { + return err + } + return copyFile(path, target, info.Mode()) + }) +} + +func copyFile(src, dst string, mode fs.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/app/handlers/static.go b/app/handlers/static.go index 7f3b7a1..b3c16cc 100644 --- a/app/handlers/static.go +++ b/app/handlers/static.go @@ -42,12 +42,9 @@ func NewStaticHandler(storagePath string, siteMap map[string]config.SiteConfig) return c.SendFile(notFoundFilePath) } - //log.Printf("File not found: %s (requested by host: %s, path: %s)", filePath, c.Hostname(), c.Path()) return c.SendStatus(fiber.StatusNotFound) } - //log.Printf("Serving file: %s (requested by host: %s, path: %s)", filePath, c.Hostname(), c.Path()) - return c.SendFile(filePath) } } diff --git a/app/handlers/update.go b/app/handlers/update.go new file mode 100644 index 0000000..7d3640b --- /dev/null +++ b/app/handlers/update.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "path/filepath" + "quay/app/github" + "quay/app/models" + "quay/internal/config" + "strings" + + "crypto/subtle" + + "github.com/gofiber/fiber/v3" +) + +func NewUpdateSiteHandler(cfg *config.Config) fiber.Handler { + return func(c fiber.Ctx) error { + sitename := c.Query("site") + if sitename == "" { + return c.Status(400).JSON(models.APIError{ + Error: "Missing 'site' query parameter", + }) + } + + var siteConfig *config.SiteConfig + for _, site := range cfg.Sites { + if site.Name == sitename { + siteConfig = &site + break + } + } + + if siteConfig == nil { + return c.Status(404).JSON(models.APIError{ + Error: "Site not found", + }) + } + + deployToken := siteConfig.DeployToken + if deployToken == "" { + return c.Status(500).JSON(models.APIError{ + Error: "Deploy token not configured for this site", + }) + } + + providedToken := c.Get("Authorization") + if strings.HasPrefix(providedToken, "Bearer ") { + providedToken = strings.TrimPrefix(providedToken, "Bearer ") + } + if providedToken == "" { + return c.Status(401).JSON(models.APIError{ + Error: "Missing Authorization header", + }) + } + + if subtle.ConstantTimeCompare([]byte(providedToken), []byte(deployToken)) != 1 { + return c.Status(403).JSON(models.APIError{ + Error: "Invalid deploy token", + }) + } + + sitePath := filepath.Join(cfg.Global.StoragePath, siteConfig.Name) + if _, err := filepath.Abs(sitePath); err != nil { + return c.Status(500).JSON(models.APIError{ + Error: "Failed to resolve site path", + }) + } + + err := github.FetchAndDeployBranch( + siteConfig.Owner, + siteConfig.Repo, + siteConfig.Branch, + cfg.Global.GithubPat, + sitePath, + ) + + if err != nil { + return c.Status(500).JSON(models.APIError{ + Error: "Failed to deploy site: " + err.Error(), + }) + } + + return c.SendStatus(201) + } +} diff --git a/app/routes/routes.go b/app/routes/routes.go index 29c4ad0..af60d2b 100644 --- a/app/routes/routes.go +++ b/app/routes/routes.go @@ -13,6 +13,8 @@ func Register(app *fiber.App, cfg *config.Config) { api := app.Group("/api") api.Get("/health", handlers.HealthCheck) + api.Post("/update", handlers.NewUpdateSiteHandler(cfg)) + storagePath, err := filepath.Abs(cfg.Global.StoragePath) if err != nil { log.Fatalf("Failed to resolve storage path: %v", err) diff --git a/config.example.yaml b/config.example.yaml index 40e47d8..9a34d60 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,5 +1,8 @@ +# Configuration file for Quay +# This file defines the global settings and the sites to be deployed. +# You can use ${VARIABLE_NAME} to reference environment variables for sensitive information. + global: - token: "${DEPLOY_TOKEN}" github_pat: "${GITHUB_PAT}" storage_path: /var/www/sites @@ -8,14 +11,17 @@ sites: owner: yourname branch: gh-pages domain: docs.my-lib.com + deploy_token: "${MY_LIB_DEPLOY_TOKEN}" - repo: portfolio owner: yourname branch: main domain: yourname.com spa: true + deploy_token: "${PORTFOLIO_DEPLOY_TOKEN}" - repo: other-docs owner: yourname branch: gh-pages - domain: other-docs.yourdomain.com \ No newline at end of file + domain: other-docs.yourdomain.com + deploy_token: "${OTHER_DOCS_DEPLOY_TOKEN}" \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 5052900..721aed1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,7 +7,6 @@ import ( ) type GlobalConfig struct { - Token string `yaml:"token"` GithubPat string `yaml:"github_pat"` StoragePath string `yaml:"storage_path"` } @@ -20,6 +19,7 @@ type SiteConfig struct { Domain string `yaml:"domain"` SPA bool `yaml:"spa"` NotFoundFile string `yaml:"not_found_file"` + DeployToken string `yaml:"deploy_token"` } type Config struct { @@ -37,9 +37,6 @@ func (c Error) Error() string { } func validateConfig(config *Config) error { - if config.Global.Token == "" { - return &Error{Field: "global.token", Message: "Token is required"} - } if config.Global.GithubPat == "" { return &Error{Field: "global.github_pat", Message: "GitHub PAT is required"} }