Added github branch fetching
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+7
-1
@@ -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
|
||||
deploy_token: "${OTHER_DOCS_DEPLOY_TOKEN}"
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user