10 Commits

7 changed files with 180 additions and 26 deletions
-6
View File
@@ -17,14 +17,8 @@ WORKDIR /app
RUN apk add --no-cache libwebp libstdc++
RUN adduser -D -g '' appuser
COPY --from=builder /app/quay .
RUN chown -R appuser:appuser /app
USER appuser
ENV PORT=4321
ENV CONFIG_DIR=/config
ENV STORAGE_PATH=/storage
+4 -2
View File
@@ -35,7 +35,9 @@ func FetchAndDeployBranch(repoOwner, repoName, branch, pat, destDir string) erro
return fmt.Errorf("GitHub returned %s for %s", resp.Status, archiveURL)
}
tmpZip, err := os.CreateTemp("", "ghbranch-*.zip")
storageRoot := filepath.Dir(destDir)
tmpZip, err := os.CreateTemp(storageRoot, "ghbranch-*.zip")
if err != nil {
return fmt.Errorf("creating temp zip: %w", err)
}
@@ -47,7 +49,7 @@ func FetchAndDeployBranch(repoOwner, repoName, branch, pat, destDir string) erro
}
tmpZip.Close()
tmpDir, err := os.MkdirTemp("", "ghbranch-unpack-*")
tmpDir, err := os.MkdirTemp(storageRoot, "ghbranch-unpack-*")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
+32
View File
@@ -2,6 +2,7 @@ package handlers
import (
"os"
"path"
"path/filepath"
"quay/internal/config"
@@ -15,7 +16,38 @@ func NewStaticHandler(storagePath string, siteMap map[string]config.SiteConfig)
return c.Status(fiber.StatusNotFound).SendString("Site not found")
}
if !site.Enabled {
return c.Status(fiber.StatusServiceUnavailable).SendString("Site is currently unavailable")
}
urlPath := filepath.Clean(c.Path())
for _, header := range site.CustomHeaders {
var matched bool
if header.Regex && header.Compiled != nil {
matched = header.Compiled.MatchString(urlPath)
} else {
matched, _ = path.Match(header.Source, urlPath)
}
if matched {
for key, value := range header.Headers {
c.Set(key, value)
}
}
}
for _, rule := range site.ForwardRules {
if rule.Regex && rule.Compiled != nil {
match := rule.Compiled.FindStringSubmatchIndex(urlPath)
if match != nil {
dest := rule.Compiled.ExpandString([]byte{}, rule.Destination, urlPath, match)
return c.Redirect().Status(rule.StatusCode).To(string(dest))
}
} else if rule.Source == urlPath {
return c.Redirect().Status(rule.StatusCode).To(rule.Destination)
}
}
if urlPath == "/" || urlPath == "." {
urlPath = "/index.html"
}
+26 -1
View File
@@ -8,6 +8,29 @@ sites:
branch: gh-pages
domain: docs.my-lib.com
deploy_token: "${MY_LIB_DEPLOY_TOKEN}"
enabled: true
not_found_file: 404.html
forward_rules:
- source: /npm
destination: https://www.npmjs.com/package/my-lib
status_code: 302
- source: ^/docs/v(\d+)$
destination: https://v$1.docs.my-lib.com/
status_code: 301
regex: true
- source: ^/blog/(?P<slug>[^/]+)$
destination: /posts/${slug}
status_code: 302
regex: true
custom_headers:
- source: /json/* # glob (default)
headers:
Content-Type: "application/json"
Cache-Control: "max-age=7200, must-revalidate"
- source: ^/api/v\d+/ # regex opt-in
regex: true
headers:
Cache-Control: "no-store"
- repo: portfolio
owner: yourname
@@ -15,9 +38,11 @@ sites:
domain: yourname.com
spa: true
deploy_token: "${PORTFOLIO_DEPLOY_TOKEN}"
enabled: true
- repo: other-docs
owner: yourname
branch: gh-pages
domain: other-docs.yourdomain.com
deploy_token: "${OTHER_DOCS_DEPLOY_TOKEN}"
deploy_token: "${OTHER_DOCS_DEPLOY_TOKEN}"
enabled: true
+21
View File
@@ -0,0 +1,21 @@
services:
quay:
build:
context: .
dockerfile: Dockerfile
container_name: quay
env_file:
- .env
ports:
- '8080:4321'
volumes:
- ./config:/config
- ./storage:/storage
restart: unless-stopped
healthcheck:
test:
['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
+1 -3
View File
@@ -1,8 +1,6 @@
services:
quay:
build:
context: .
dockerfile: Dockerfile
image: kartoffelchipss/quay:latest
container_name: quay
env_file:
- .env
+96 -14
View File
@@ -1,20 +1,42 @@
package config
import (
"fmt"
"os"
"path"
"regexp"
"strconv"
"gopkg.in/yaml.v3"
)
type ForwardRule struct {
Source string `yaml:"source"`
Destination string `yaml:"destination"`
StatusCode int `yaml:"status_code"`
Regex bool `yaml:"regex"`
Compiled *regexp.Regexp
}
type CustomHeader struct {
Source string `yaml:"source"`
Regex bool `yaml:"regex"`
Headers map[string]string `yaml:"headers"`
Compiled *regexp.Regexp
}
type SiteConfig struct {
Name string `yaml:"name"`
Repo string `yaml:"repo"`
Owner string `yaml:"owner"`
Branch string `yaml:"branch"`
Domain string `yaml:"domain"`
SPA bool `yaml:"spa"`
NotFoundFile string `yaml:"not_found_file"`
DeployToken string `yaml:"deploy_token"`
Name string `yaml:"name"`
Repo string `yaml:"repo"`
Owner string `yaml:"owner"`
Branch string `yaml:"branch"`
Domain string `yaml:"domain"`
Enabled bool `yaml:"enabled"`
SPA bool `yaml:"spa"`
NotFoundFile string `yaml:"not_found_file"`
DeployToken string `yaml:"deploy_token"`
ForwardRules []ForwardRule `yaml:"forward_rules"`
CustomHeaders []CustomHeader `yaml:"custom_headers"`
}
type Config struct {
@@ -33,19 +55,68 @@ func (c Error) Error() string {
func validateConfig(config *Config) error {
for i, site := range config.Sites {
if site.Name == "" {
return &Error{Field: "sites[" + string(i) + "].name", Message: "Name is required"}
return &Error{Field: "sites[" + strconv.Itoa(i) + "].name", Message: "Name is required"}
}
if site.Repo == "" {
return &Error{Field: "sites[" + string(i) + "].repo", Message: "Repo is required"}
return &Error{Field: "sites[" + strconv.Itoa(i) + "].repo", Message: "Repo is required"}
}
if site.Owner == "" {
return &Error{Field: "sites[" + string(i) + "].owner", Message: "Owner is required"}
return &Error{Field: "sites[" + strconv.Itoa(i) + "].owner", Message: "Owner is required"}
}
if site.Branch == "" {
return &Error{Field: "sites[" + string(i) + "].branch", Message: "Branch is required"}
return &Error{Field: "sites[" + strconv.Itoa(i) + "].branch", Message: "Branch is required"}
}
if site.Domain == "" {
return &Error{Field: "sites[" + string(i) + "].domain", Message: "Domain is required"}
return &Error{Field: "sites[" + strconv.Itoa(i) + "].domain", Message: "Domain is required"}
}
for j, rule := range site.ForwardRules {
if rule.Source == "" {
return &Error{Field: "sites[" + strconv.Itoa(i) + "].forward_rules[" + strconv.Itoa(j) + "].source", Message: "Source is required"}
}
if rule.Destination == "" {
return &Error{Field: "sites[" + strconv.Itoa(i) + "].forward_rules[" + strconv.Itoa(j) + "].destination", Message: "Destination is required"}
}
if rule.StatusCode == 0 {
return &Error{Field: "sites[" + strconv.Itoa(i) + "].forward_rules[" + strconv.Itoa(j) + "].status_code", Message: "Status code is required"}
}
if rule.StatusCode < 300 || rule.StatusCode >= 400 {
return &Error{Field: "sites[" + strconv.Itoa(i) + "].forward_rules[" + strconv.Itoa(j) + "].status_code", Message: "Status code must be a valid redirect code (300-399)"}
}
if rule.Regex {
re, err := regexp.Compile(rule.Source)
if err != nil {
return &Error{
Field: fmt.Sprintf("sites[%d].forward_rules[%d].source", i, j),
Message: "Invalid regex: " + err.Error(),
}
}
config.Sites[i].ForwardRules[j].Compiled = re
}
}
for k, header := range site.CustomHeaders {
if header.Source == "" {
return &Error{Field: fmt.Sprintf("sites[%d].custom_headers[%d].source", i, k), Message: "Source is required"}
}
if len(header.Headers) == 0 {
return &Error{Field: fmt.Sprintf("sites[%d].custom_headers[%d].headers", i, k), Message: "At least one header is required"}
}
if header.Regex {
re, err := regexp.Compile(header.Source)
if err != nil {
return &Error{
Field: fmt.Sprintf("sites[%d].custom_headers[%d].source", i, k),
Message: "Invalid regex: " + err.Error(),
}
}
config.Sites[i].CustomHeaders[k].Compiled = re
} else {
if _, err := path.Match(header.Source, ""); err != nil {
return &Error{
Field: fmt.Sprintf("sites[%d].custom_headers[%d].source", i, k),
Message: "Invalid glob pattern: " + err.Error(),
}
}
}
}
}
return nil
@@ -56,6 +127,11 @@ func applyDefaults(config *Config) {
if config.Sites[i].NotFoundFile == "" {
config.Sites[i].NotFoundFile = "404.html"
}
for j := range config.Sites[i].ForwardRules {
if config.Sites[i].ForwardRules[j].StatusCode == 0 {
config.Sites[i].ForwardRules[j].StatusCode = 302
}
}
}
}
@@ -64,7 +140,13 @@ func Load(path string) (*Config, error) {
if err != nil {
return nil, err
}
expanded := os.Expand(string(f), os.Getenv)
expanded := os.Expand(string(f), func(key string) string {
val, ok := os.LookupEnv(key)
if !ok {
return "${" + key + "}" // env vars that aren't set are left as is to not cause errors with regex patterns
}
return val
})
var config Config
err = yaml.Unmarshal([]byte(expanded), &config)
if err != nil {