6 Commits

Author SHA1 Message Date
KartoffelChipss f0eeaecbb1 Added custom headers option 2026-03-31 14:17:46 +02:00
KartoffelChipss e4b9c36486 Added config setting to disable site 2026-03-31 13:56:13 +02:00
KartoffelChipss dcc8201099 Added regex option to forward rules 2026-03-31 12:45:56 +02:00
KartoffelChipss 55348057a0 Added forward rules 2026-03-31 12:33:03 +02:00
KartoffelChipss bedf8bc335 wsda 2026-03-31 11:38:49 +02:00
KartoffelChipss c5df17cd1f idk 2026-03-31 11:31:57 +02:00
4 changed files with 154 additions and 23 deletions
-8
View File
@@ -17,16 +17,8 @@ WORKDIR /app
RUN apk add --no-cache libwebp libstdc++
RUN adduser -D -g '' appuser
COPY --from=builder /app/quay .
RUN mkdir -p /storage /config && \
chmod -R 777 /storage /config && \
chown -R appuser:appuser /app
USER appuser
ENV PORT=4321
ENV CONFIG_DIR=/config
ENV STORAGE_PATH=/storage
+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
+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 {