diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 0000000..0ebf462
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ sqlite.xerial
+ true
+ org.sqlite.JDBC
+ jdbc:sqlite:$PROJECT_DIR$/config/db.sqlite
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml
new file mode 100644
index 0000000..12103cf
--- /dev/null
+++ b/.idea/sqldialects.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/handlers/site.go b/app/handlers/site.go
new file mode 100644
index 0000000..8d681f7
--- /dev/null
+++ b/app/handlers/site.go
@@ -0,0 +1,176 @@
+package handlers
+
+import (
+ "database/sql"
+ "errors"
+ "log"
+ "quay/app/models"
+ "quay/app/repository"
+
+ "github.com/gofiber/fiber/v3"
+)
+
+type SiteHandler struct {
+ Repo repository.SiteRepository
+}
+
+func NewSiteHandler(repo repository.SiteRepository) *SiteHandler {
+ return &SiteHandler{Repo: repo}
+}
+
+// GetSites godoc
+// @Summary Get all sites
+// @Description Get a list of all sites
+// @Tags Sites
+// @Accept json
+// @Produce json
+// @Success 200 {object} models.GetAllSitesResponse
+// @Failure 500 {object} models.APIError
+// @Router /sites [get]
+func (h *SiteHandler) GetSites(c fiber.Ctx) error {
+ sites, err := h.Repo.ListSites()
+
+ if err != nil {
+ log.Println("Error listing sites: ", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
+ Message: "Unexpected error while listing sites",
+ })
+ }
+
+ if sites == nil {
+ sites = []models.Site{}
+ }
+
+ return c.JSON(models.GetAllSitesResponse{
+ Sites: sites,
+ Total: len(sites),
+ })
+}
+
+// GetSite godoc
+// @Summary Get site by ID
+// @Description Get a single site by its ID
+// @Tags Sites
+// @Accept json
+// @Produce json
+// @Param id path string true "Site ID"
+// @Success 200 {object} models.Site
+// @Failure 404 {object} models.APIError
+// @Failure 500 {object} models.APIError
+// @Router /sites/{id} [get]
+func (h *SiteHandler) GetSite(c fiber.Ctx) error {
+ id := c.Params("id")
+
+ site, err := h.Repo.GetSite(id)
+
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
+ Message: "Site not found",
+ })
+ }
+ log.Println("Message getting site: ", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
+ Message: "Unexpected error while getting site",
+ })
+ }
+
+ if site == nil {
+ return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
+ Message: "Site not found",
+ })
+ }
+
+ return c.JSON(site)
+}
+
+func validateIncomingSite(site *models.Site) error {
+ if site == nil {
+ return errors.New("site is required")
+ }
+ if site.GitServer == "" {
+ return errors.New("git server required")
+ }
+ if site.Owner == "" {
+ return errors.New("owner required")
+ }
+ if site.Repository == "" {
+ return errors.New("repository required")
+ }
+ if site.Branch == "" {
+ return errors.New("branch required")
+ }
+ if site.Domain == "" {
+ return errors.New("domain required")
+ }
+ if site.NotFoundFile == "" {
+ site.NotFoundFile = "404.html"
+ }
+ if site.ForwardRules == nil {
+ site.ForwardRules = []models.ForwardRule{}
+ }
+ if site.ForwardRules != nil {
+ for _, r := range site.ForwardRules {
+ if r.Source == "" {
+ return errors.New("forward rule source is required")
+ }
+ if r.Destination == "" {
+ return errors.New("forward rule destination is required")
+ }
+ if r.StatusCode < 300 || r.StatusCode > 399 {
+ return errors.New("forward rule status code must be between 300 and 399")
+ }
+ }
+ }
+ if site.CustomHeaders == nil {
+ site.CustomHeaders = []models.CustomHeaders{}
+ }
+ if site.CustomHeaders != nil {
+ for _, h := range site.CustomHeaders {
+ if h.Source == "" {
+ return errors.New("custom header source required")
+ }
+ if h.Headers == nil {
+ return errors.New("custom header is required")
+ }
+ }
+ }
+ return nil
+}
+
+// PostSite godoc
+// @Summary Create a new site
+// @Description Create a new site with the provided details
+// @Tags Sites
+// @Accept json
+// @Produce json
+// @Param site body models.Site true "Site details"
+// @Success 200 {object} models.Site
+// @Failure 400 {object} models.APIError
+// @Failure 500 {object} models.APIError
+// @Router /sites [post]
+func (h *SiteHandler) PostSite(c fiber.Ctx) error {
+ var site models.Site
+
+ if err := c.Bind().Body(&site); err != nil {
+ log.Println("Error parsing body: ", err)
+ return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
+ Message: "Invalid request body",
+ })
+ }
+
+ if err := validateIncomingSite(&site); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
+ Message: "Invalid request body: " + err.Error(),
+ })
+ }
+
+ if err := h.Repo.CreateSite(&site); err != nil {
+ log.Println("Error creating site: ", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
+ Message: "Unexpected error while creating site",
+ })
+ }
+
+ return c.JSON(site)
+}
diff --git a/app/handlers/update.go b/app/handlers/update.go
index 623871c..2b633a9 100644
--- a/app/handlers/update.go
+++ b/app/handlers/update.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "crypto/subtle"
"path/filepath"
"quay/app/github"
"quay/app/models"
@@ -8,78 +9,83 @@ import (
"quay/internal/envconfig"
"strings"
- "crypto/subtle"
-
"github.com/gofiber/fiber/v3"
)
-func NewUpdateSiteHandler(cfg *config.Config, envCfg *envconfig.EnvConfig) 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(envCfg.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,
- envCfg.GithubPat,
- sitePath,
- )
-
- if err != nil {
- return c.Status(500).JSON(models.APIError{
- Error: "Failed to deploy site: " + err.Error(),
- })
- }
-
- return c.SendStatus(201)
- }
+type UpdateSiteHandler struct {
+ Cfg *config.Config
+ EnvCfg *envconfig.EnvConfig
+}
+
+func NewUpdateSiteHandler(cfg *config.Config, envCfg *envconfig.EnvConfig) *UpdateSiteHandler {
+ return &UpdateSiteHandler{Cfg: cfg, EnvCfg: envCfg}
+}
+
+func (h *UpdateSiteHandler) PostUpdate(c fiber.Ctx) error {
+ sitename := c.Query("site")
+ if sitename == "" {
+ return c.Status(400).JSON(models.APIError{
+ Message: "Missing 'site' query parameter",
+ })
+ }
+
+ var siteConfig *config.SiteConfig
+ for _, site := range h.Cfg.Sites {
+ if site.Name == sitename {
+ siteConfig = &site
+ break
+ }
+ }
+
+ if siteConfig == nil {
+ return c.Status(404).JSON(models.APIError{
+ Message: "Site not found",
+ })
+ }
+
+ deployToken := siteConfig.DeployToken
+ if deployToken == "" {
+ return c.Status(500).JSON(models.APIError{
+ Message: "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{
+ Message: "Missing Authorization header",
+ })
+ }
+
+ if subtle.ConstantTimeCompare([]byte(providedToken), []byte(deployToken)) != 1 {
+ return c.Status(403).JSON(models.APIError{
+ Message: "Invalid deploy token",
+ })
+ }
+
+ sitePath := filepath.Join(h.EnvCfg.StoragePath, siteConfig.Name)
+ if _, err := filepath.Abs(sitePath); err != nil {
+ return c.Status(500).JSON(models.APIError{
+ Message: "Failed to resolve site path",
+ })
+ }
+
+ err := github.FetchAndDeployBranch(
+ siteConfig.Owner,
+ siteConfig.Repo,
+ siteConfig.Branch,
+ h.EnvCfg.GithubPat,
+ sitePath,
+ )
+
+ if err != nil {
+ return c.Status(500).JSON(models.APIError{
+ Message: "Failed to deploy site: " + err.Error(),
+ })
+ }
+
+ return c.SendStatus(201)
}
diff --git a/app/models/api_error.go b/app/models/api_error.go
index 9934619..e11cf2e 100644
--- a/app/models/api_error.go
+++ b/app/models/api_error.go
@@ -1,5 +1,5 @@
package models
type APIError struct {
- Error string `json:"error"`
+ Message string `json:"message"`
}
diff --git a/app/models/site.go b/app/models/site.go
new file mode 100644
index 0000000..8b49fd7
--- /dev/null
+++ b/app/models/site.go
@@ -0,0 +1,41 @@
+package models
+
+type ForwardRule struct {
+ ID string `json:"id"`
+ Source string `json:"source"`
+ Destination string `json:"destination"`
+ StatusCode int `json:"status_code"`
+ Regex bool `json:"regex"`
+}
+
+type Header struct {
+ ID string `json:"id"`
+ Key string `json:"key"`
+ Value string `json:"value"`
+}
+
+type CustomHeaders struct {
+ ID string `json:"id"`
+ Source string `json:"source"`
+ Headers []Header `json:"headers"`
+}
+
+type Site struct {
+ ID string `json:"id"`
+ GitServer string `json:"git_server"`
+ Owner string `json:"owner"`
+ Repository string `json:"repository"`
+ Branch string `json:"branch"`
+ Domain string `json:"domain"`
+ DeployToken string `json:"deploy_token"`
+ Enabled bool `json:"enabled"`
+ Spa bool `json:"spa"`
+ NotFoundFile string `json:"not_found_file"`
+ ForwardRules []ForwardRule `json:"forward_rules"`
+ CustomHeaders []CustomHeaders `json:"custom_headers"`
+}
+
+type GetAllSitesResponse struct {
+ Sites []Site `json:"sites"`
+ Total int `json:"total"`
+}
diff --git a/app/repository/site_repository.go b/app/repository/site_repository.go
new file mode 100644
index 0000000..6687309
--- /dev/null
+++ b/app/repository/site_repository.go
@@ -0,0 +1,19 @@
+package repository
+
+import "quay/app/models"
+
+type SiteRepository interface {
+ GetSite(id string) (*models.Site, error)
+ ListSites() ([]models.Site, error)
+ CreateSite(s *models.Site) error
+ UpdateSite(s *models.Site) error
+ DeleteSite(id string) error
+ GetForwardRule(id string) (*models.ForwardRule, error)
+ CreateForwardRule(siteID string, fr *models.ForwardRule) error
+ UpdateForwardRule(fr *models.ForwardRule) error
+ DeleteForwardRule(id string) error
+ GetCustomHeaders(id string) (*models.CustomHeaders, error)
+ CreateCustomHeaders(siteID string, ch *models.CustomHeaders) error
+ UpdateCustomHeaders(ch *models.CustomHeaders) error
+ DeleteCustomHeaders(id string) error
+}
diff --git a/app/routes/routes.go b/app/routes/routes.go
index 71e4e7c..36c1514 100644
--- a/app/routes/routes.go
+++ b/app/routes/routes.go
@@ -1,20 +1,31 @@
package routes
import (
+ "database/sql"
"log"
"path/filepath"
"quay/app/handlers"
"quay/internal/config"
+ "quay/internal/database"
"quay/internal/envconfig"
"github.com/gofiber/fiber/v3"
)
-func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig) {
+func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig, db *sql.DB) {
+ siteRepository := database.NewSQLiteSiteRepository(db)
+
+ updateSiteHandler := handlers.NewUpdateSiteHandler(cfg, envCfg)
+ siteHandler := handlers.NewSiteHandler(siteRepository)
+
api := app.Group("/api")
api.Get("/health", handlers.HealthCheck)
- api.Post("/update", handlers.NewUpdateSiteHandler(cfg, envCfg))
+ api.Post("/update", updateSiteHandler.PostUpdate)
+
+ api.Get("/sites", siteHandler.GetSites)
+ api.Get("/sites/:id", siteHandler.GetSite)
+ api.Post("/sites", siteHandler.PostSite)
storagePath, err := filepath.Abs(envCfg.StoragePath)
if err != nil {
diff --git a/go.mod b/go.mod
index 8b4d5a3..7712785 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
github.com/klauspost/compress v1.18.4 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-sqlite3 v1.14.38 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index 81320aa..c17ccd9 100644
--- a/go.sum
+++ b/go.sum
@@ -16,6 +16,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
+github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
diff --git a/internal/database/init_sqlite.go b/internal/database/init_sqlite.go
new file mode 100644
index 0000000..28d3ea7
--- /dev/null
+++ b/internal/database/init_sqlite.go
@@ -0,0 +1,54 @@
+package database
+
+import (
+ "database/sql"
+ "log"
+)
+
+func InitializeSQLite(db *sql.DB) error {
+ _, err := db.Exec(`
+CREATE TABLE IF NOT EXISTS sites (
+ id TEXT PRIMARY KEY,
+ git_server TEXT NOT NULL,
+ owner TEXT NOT NULL,
+ repository TEXT NOT NULL,
+ branch TEXT NOT NULL,
+ domain TEXT NOT NULL,
+ deploy_token TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ spa INTEGER NOT NULL DEFAULT 0,
+ not_found_file TEXT NOT NULL DEFAULT '404.html'
+);
+
+CREATE TABLE IF NOT EXISTS forward_rules (
+ id TEXT PRIMARY KEY,
+ site_id TEXT NOT NULL,
+ source TEXT NOT NULL,
+ destination TEXT NOT NULL,
+ status_code INTEGER NOT NULL,
+ regex INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS custom_headers (
+ id TEXT PRIMARY KEY,
+ site_id TEXT NOT NULL,
+ source TEXT NOT NULL,
+ FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS headers (
+ id TEXT PRIMARY KEY,
+ custom_header_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ FOREIGN KEY (custom_header_id) REFERENCES custom_headers(id) ON DELETE CASCADE
+);
+`)
+
+ if err == nil {
+ log.Println("Database initialized successfully")
+ }
+
+ return err
+}
diff --git a/internal/database/site_sqlite.go b/internal/database/site_sqlite.go
new file mode 100644
index 0000000..684dd93
--- /dev/null
+++ b/internal/database/site_sqlite.go
@@ -0,0 +1,411 @@
+package database
+
+import (
+ "database/sql"
+ "fmt"
+ "quay/app/models"
+ "quay/app/repository"
+
+ "github.com/google/uuid"
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type SQLiteSiteRepository struct {
+ db *sql.DB
+}
+
+func NewSQLiteSiteRepository(db *sql.DB) *SQLiteSiteRepository {
+ return &SQLiteSiteRepository{db: db}
+}
+
+var _ repository.SiteRepository = (*SQLiteSiteRepository)(nil)
+
+// Sites
+
+func (r *SQLiteSiteRepository) GetSite(id string) (*models.Site, error) {
+ row := r.db.QueryRow(`
+ SELECT id, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
+ FROM sites WHERE id = ?`, id)
+
+ s, err := scanSite(row)
+ if err != nil {
+ return nil, fmt.Errorf("get site: %w", err)
+ }
+
+ if err := r.populateSiteRelations(s); err != nil {
+ return nil, err
+ }
+ return s, nil
+}
+
+func (r *SQLiteSiteRepository) ListSites() ([]models.Site, error) {
+ rows, err := r.db.Query(`
+ SELECT id, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file
+ FROM sites`)
+ if err != nil {
+ return nil, fmt.Errorf("list sites: %w", err)
+ }
+
+ var sites []models.Site
+ for rows.Next() {
+ s, err := scanSite(rows)
+ if err != nil {
+ rows.Close()
+ return nil, fmt.Errorf("list sites scan: %w", err)
+ }
+ sites = append(sites, *s)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ for i := range sites {
+ if err := r.populateSiteRelations(&sites[i]); err != nil {
+ return nil, err
+ }
+ }
+ return sites, nil
+}
+
+func (r *SQLiteSiteRepository) CreateSite(s *models.Site) error {
+ tx, err := r.db.Begin()
+ if err != nil {
+ return fmt.Errorf("create site begin tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ s.ID = uuid.NewString()
+
+ _, err = tx.Exec(`
+ INSERT INTO sites (id, git_server, owner, repository, branch, domain, deploy_token, enabled, not_found_file)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ s.ID, s.GitServer, s.Owner, s.Repository, s.Branch,
+ s.Domain, s.DeployToken, s.Enabled, s.NotFoundFile,
+ )
+ if err != nil {
+ return fmt.Errorf("create site insert: %w", err)
+ }
+
+ for i := range s.ForwardRules {
+ if err := insertForwardRule(tx, s.ID, &s.ForwardRules[i]); err != nil {
+ return err
+ }
+ }
+
+ for i := range s.CustomHeaders {
+ if err := insertCustomHeaders(tx, s.ID, &s.CustomHeaders[i]); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+func (r *SQLiteSiteRepository) UpdateSite(s *models.Site) error {
+ tx, err := r.db.Begin()
+ if err != nil {
+ return fmt.Errorf("update site begin tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ _, err = tx.Exec(`
+ UPDATE sites SET git_server=?, owner=?, repository=?, branch=?, domain=?,
+ deploy_token=?, enabled=?, not_found_file=? WHERE id=?`,
+ s.GitServer, s.Owner, s.Repository, s.Branch, s.Domain,
+ s.DeployToken, s.Enabled, s.NotFoundFile, s.ID,
+ )
+ if err != nil {
+ return fmt.Errorf("update site: %w", err)
+ }
+
+ if _, err := tx.Exec(`DELETE FROM forward_rules WHERE site_id = ?`, s.ID); err != nil {
+ return fmt.Errorf("update site delete forward rules: %w", err)
+ }
+ for i := range s.ForwardRules {
+ if err := insertForwardRule(tx, s.ID, &s.ForwardRules[i]); err != nil {
+ return err
+ }
+ }
+
+ if _, err := tx.Exec(`
+ DELETE FROM headers WHERE custom_header_id IN (
+ SELECT id FROM custom_headers WHERE site_id = ?
+ )`, s.ID); err != nil {
+ return fmt.Errorf("update site delete headers: %w", err)
+ }
+ if _, err := tx.Exec(`DELETE FROM custom_headers WHERE site_id = ?`, s.ID); err != nil {
+ return fmt.Errorf("update site delete custom headers: %w", err)
+ }
+ for _, ch := range s.CustomHeaders {
+ if err := insertCustomHeaders(tx, s.ID, &ch); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit()
+}
+
+func (r *SQLiteSiteRepository) DeleteSite(id string) error {
+ _, err := r.db.Exec(`DELETE FROM sites WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("delete site: %w", err)
+ }
+ return nil
+}
+
+// Forward Rules
+
+func (r *SQLiteSiteRepository) GetForwardRule(id string) (*models.ForwardRule, error) {
+ row := r.db.QueryRow(`
+ SELECT id, source, destination, status_code, regex
+ FROM forward_rules WHERE id = ?`, id)
+
+ var fr models.ForwardRule
+ var regex int
+ err := row.Scan(&fr.ID, &fr.Source, &fr.Destination, &fr.StatusCode, ®ex)
+ if err != nil {
+ return nil, fmt.Errorf("get forward rule: %w", err)
+ }
+ fr.Regex = regex != 0
+ return &fr, nil
+}
+
+func (r *SQLiteSiteRepository) CreateForwardRule(siteID string, fr *models.ForwardRule) error {
+ tx, err := r.db.Begin()
+ if err != nil {
+ return fmt.Errorf("create forward rule begin tx: %w", err)
+ }
+ defer tx.Rollback()
+ if err := insertForwardRule(tx, siteID, fr); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (r *SQLiteSiteRepository) UpdateForwardRule(fr *models.ForwardRule) error {
+ _, err := r.db.Exec(`
+ UPDATE forward_rules SET source=?, destination=?, status_code=?, regex=? WHERE id=?`,
+ fr.Source, fr.Destination, fr.StatusCode, fr.Regex, fr.ID,
+ )
+ if err != nil {
+ return fmt.Errorf("update forward rule: %w", err)
+ }
+ return nil
+}
+
+func (r *SQLiteSiteRepository) DeleteForwardRule(id string) error {
+ _, err := r.db.Exec(`DELETE FROM forward_rules WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("delete forward rule: %w", err)
+ }
+ return nil
+}
+
+// Custom Headers
+
+func (r *SQLiteSiteRepository) GetCustomHeaders(id string) (*models.CustomHeaders, error) {
+ row := r.db.QueryRow(`SELECT id, source FROM custom_headers WHERE id = ?`, id)
+
+ var ch models.CustomHeaders
+ if err := row.Scan(&ch.ID, &ch.Source); err != nil {
+ return nil, fmt.Errorf("get custom headers: %w", err)
+ }
+
+ headers, err := r.listHeaders(ch.ID)
+ if err != nil {
+ return nil, err
+ }
+ ch.Headers = headers
+ return &ch, nil
+}
+
+func (r *SQLiteSiteRepository) CreateCustomHeaders(siteID string, ch *models.CustomHeaders) error {
+ tx, err := r.db.Begin()
+ if err != nil {
+ return fmt.Errorf("create custom headers begin tx: %w", err)
+ }
+ defer tx.Rollback()
+ if err := insertCustomHeaders(tx, siteID, ch); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (r *SQLiteSiteRepository) UpdateCustomHeaders(ch *models.CustomHeaders) error {
+ tx, err := r.db.Begin()
+ if err != nil {
+ return fmt.Errorf("update custom headers begin tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ if _, err := tx.Exec(`UPDATE custom_headers SET source=? WHERE id=?`, ch.Source, ch.ID); err != nil {
+ return fmt.Errorf("update custom headers: %w", err)
+ }
+ if _, err := tx.Exec(`DELETE FROM headers WHERE custom_header_id = ?`, ch.ID); err != nil {
+ return fmt.Errorf("update custom headers delete headers: %w", err)
+ }
+ for _, h := range ch.Headers {
+ if err := insertHeader(tx, ch.ID, &h); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (r *SQLiteSiteRepository) DeleteCustomHeaders(id string) error {
+ _, err := r.db.Exec(`DELETE FROM custom_headers WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("delete custom headers: %w", err)
+ }
+ return nil
+}
+
+// Helpers
+
+type scanner interface {
+ Scan(dest ...any) error
+}
+
+func scanSite(s scanner) (*models.Site, error) {
+ var site models.Site
+ var enabled int
+ err := s.Scan(
+ &site.ID, &site.GitServer, &site.Owner, &site.Repository,
+ &site.Branch, &site.Domain, &site.DeployToken, &enabled, &site.NotFoundFile,
+ )
+ if err != nil {
+ return nil, err
+ }
+ site.Enabled = enabled != 0
+ return &site, nil
+}
+
+func (r *SQLiteSiteRepository) populateSiteRelations(s *models.Site) error {
+ rules, err := r.listForwardRules(s.ID)
+ if err != nil {
+ return err
+ }
+ s.ForwardRules = rules
+
+ customHeaders, err := r.listCustomHeaders(s.ID)
+ if err != nil {
+ return err
+ }
+ s.CustomHeaders = customHeaders
+
+ return nil
+}
+
+func (r *SQLiteSiteRepository) listForwardRules(siteID string) ([]models.ForwardRule, error) {
+ rows, err := r.db.Query(`
+ SELECT id, source, destination, status_code, regex
+ FROM forward_rules WHERE site_id = ?`, siteID)
+ if err != nil {
+ return nil, fmt.Errorf("list forward rules: %w", err)
+ }
+ defer rows.Close()
+
+ var rules []models.ForwardRule
+ for rows.Next() {
+ var fr models.ForwardRule
+ var regex int
+ if err := rows.Scan(&fr.ID, &fr.Source, &fr.Destination, &fr.StatusCode, ®ex); err != nil {
+ return nil, fmt.Errorf("list forward rules scan: %w", err)
+ }
+ fr.Regex = regex != 0
+ rules = append(rules, fr)
+ }
+ return rules, rows.Err()
+}
+
+func (r *SQLiteSiteRepository) listCustomHeaders(siteID string) ([]models.CustomHeaders, error) {
+ rows, err := r.db.Query(`SELECT id, source FROM custom_headers WHERE site_id = ?`, siteID)
+ if err != nil {
+ return nil, fmt.Errorf("list custom headers: %w", err)
+ }
+
+ var result []models.CustomHeaders
+ for rows.Next() {
+ var ch models.CustomHeaders
+ if err := rows.Scan(&ch.ID, &ch.Source); err != nil {
+ rows.Close()
+ return nil, fmt.Errorf("list custom headers scan: %w", err)
+ }
+ result = append(result, ch)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ for i := range result {
+ headers, err := r.listHeaders(result[i].ID)
+ if err != nil {
+ return nil, err
+ }
+ result[i].Headers = headers
+ }
+ return result, nil
+}
+
+func (r *SQLiteSiteRepository) listHeaders(customHeaderID string) ([]models.Header, error) {
+ rows, err := r.db.Query(`
+ SELECT id, key, value FROM headers WHERE custom_header_id = ?`, customHeaderID)
+ if err != nil {
+ return nil, fmt.Errorf("list headers: %w", err)
+ }
+ defer rows.Close()
+
+ var headers []models.Header
+ for rows.Next() {
+ var h models.Header
+ if err := rows.Scan(&h.ID, &h.Key, &h.Value); err != nil {
+ return nil, fmt.Errorf("list headers scan: %w", err)
+ }
+ headers = append(headers, h)
+ }
+ return headers, rows.Err()
+}
+
+func insertForwardRule(tx *sql.Tx, siteID string, fr *models.ForwardRule) error {
+ fr.ID = uuid.NewString()
+ _, err := tx.Exec(`
+ INSERT INTO forward_rules (id, site_id, source, destination, status_code, regex)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ fr.ID, siteID, fr.Source, fr.Destination, fr.StatusCode, fr.Regex,
+ )
+ if err != nil {
+ return fmt.Errorf("insert forward rule: %w", err)
+ }
+ return nil
+}
+
+func insertCustomHeaders(tx *sql.Tx, siteID string, ch *models.CustomHeaders) error {
+ ch.ID = uuid.NewString()
+ _, err := tx.Exec(`
+ INSERT INTO custom_headers (id, site_id, source) VALUES (?, ?, ?)`,
+ ch.ID, siteID, ch.Source,
+ )
+ if err != nil {
+ return fmt.Errorf("insert custom headers: %w", err)
+ }
+ for _, h := range ch.Headers {
+ if err := insertHeader(tx, ch.ID, &h); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func insertHeader(tx *sql.Tx, customHeaderID string, h *models.Header) error {
+ h.ID = uuid.NewString()
+ _, err := tx.Exec(`
+ INSERT INTO headers (id, custom_header_id, key, value) VALUES (?, ?, ?, ?)`,
+ h.ID, customHeaderID, h.Key, h.Value,
+ )
+ if err != nil {
+ return fmt.Errorf("insert header: %w", err)
+ }
+ return nil
+}
diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go
new file mode 100644
index 0000000..7ae0da6
--- /dev/null
+++ b/internal/database/sqlite.go
@@ -0,0 +1,23 @@
+package database
+
+import (
+ "database/sql"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+func ConnectSQLite(dbPath string) (*sql.DB, error) {
+ db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000&_journal_mode=WAL")
+ if err != nil {
+ return nil, err
+ }
+
+ // SQLite doesn't like multiple connections
+ db.SetMaxOpenConns(1)
+
+ if err = db.Ping(); err != nil {
+ return nil, err
+ }
+
+ return db, nil
+}
diff --git a/internal/envconfig/envconfig.go b/internal/envconfig/envconfig.go
index 625d7a0..565a24d 100644
--- a/internal/envconfig/envconfig.go
+++ b/internal/envconfig/envconfig.go
@@ -5,10 +5,11 @@ import (
)
type EnvConfig struct {
- Port string
- ConfigDir string
- GithubPat string
- StoragePath string
+ Port string
+ ConfigDir string
+ GithubPat string
+ StoragePath string
+ DatabasePath string
}
func Load() EnvConfig {
diff --git a/main.go b/main.go
index 51e3106..d6efd37 100644
--- a/main.go
+++ b/main.go
@@ -1,10 +1,12 @@
package main
import (
+ "database/sql"
"log"
"path/filepath"
"quay/app/routes"
"quay/internal/config"
+ "quay/internal/database"
"quay/internal/envconfig"
"quay/internal/fiberconfig"
@@ -23,10 +25,28 @@ func main() {
panic("Failed to load config: " + err.Error())
}
+ dbPath := filepath.Join(envCfg.ConfigDir, "db.sqlite")
+ db, err := database.ConnectSQLite(dbPath)
+ if err != nil {
+ log.Fatal("Failed to connect to database:", err)
+ }
+ log.Println("Connected to database")
+ defer func(db *sql.DB) {
+ err := db.Close()
+ if err != nil {
+ log.Println("Failed to close database:", err)
+ }
+ }(db)
+
+ err = database.InitializeSQLite(db)
+ if err != nil {
+ log.Fatal("Failed to initialize database:", err)
+ }
+
app := fiber.New()
fiberconfig.Setup(app)
- routes.Register(app, cfg, &envCfg)
+ routes.Register(app, cfg, &envCfg, db)
log.Fatal(app.Listen(":" + envCfg.Port))
}
diff --git a/theme.css b/theme.css
new file mode 100644
index 0000000..7841966
--- /dev/null
+++ b/theme.css
@@ -0,0 +1,173 @@
+@import "tailwindcss";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: oklch(1.0000 0 0);
+ --foreground: oklch(0.3211 0 0);
+ --card: oklch(1.0000 0 0);
+ --card-foreground: oklch(0.3211 0 0);
+ --popover: oklch(1.0000 0 0);
+ --popover-foreground: oklch(0.3211 0 0);
+ --primary: oklch(0.6225 0.2041 259.9027);
+ --primary-foreground: oklch(1.0000 0 0);
+ --secondary: oklch(0.9665 0.0045 258.3247);
+ --secondary-foreground: oklch(0.4419 0.0375 257.2811);
+ --muted: oklch(0.9846 0.0017 247.8389);
+ --muted-foreground: oklch(0.5471 0.0321 263.2921);
+ --accent: oklch(0.9510 0.0267 237.5723);
+ --accent-foreground: oklch(0.3742 0.1844 263.9420);
+ --destructive: oklch(0.6496 0.2362 26.9032);
+ --destructive-foreground: oklch(1.0000 0 0);
+ --border: oklch(0.9271 0.0075 260.7315);
+ --input: oklch(0.9271 0.0075 260.7315);
+ --ring: oklch(0.6225 0.2041 259.9027);
+ --chart-1: oklch(0.6225 0.2041 259.9027);
+ --chart-2: oklch(0.5469 0.2507 262.8085);
+ --chart-3: oklch(0.4902 0.2693 263.7106);
+ --chart-4: oklch(0.4234 0.2370 263.9162);
+ --chart-5: oklch(0.3742 0.1844 263.9420);
+ --sidebar: oklch(0.9846 0.0017 247.8389);
+ --sidebar-foreground: oklch(0.3211 0 0);
+ --sidebar-primary: oklch(0.6225 0.2041 259.9027);
+ --sidebar-primary-foreground: oklch(1.0000 0 0);
+ --sidebar-accent: oklch(0.9510 0.0267 237.5723);
+ --sidebar-accent-foreground: oklch(0.3742 0.1844 263.9420);
+ --sidebar-border: oklch(0.9271 0.0075 260.7315);
+ --sidebar-ring: oklch(0.6225 0.2041 259.9027);
+ --font-sans: Inter, sans-serif;
+ --font-serif: Source Serif 4, serif;
+ --font-mono: JetBrains Mono, monospace;
+ --radius: 1.15rem;
+ --shadow-x: 0;
+ --shadow-y: 1px;
+ --shadow-blur: 3px;
+ --shadow-spread: 0px;
+ --shadow-opacity: 0.1;
+ --shadow-color: oklch(0 0 0);
+ --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
+ --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
+ --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
+ --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
+ --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
+ --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
+ --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
+ --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
+ --tracking-normal: 0em;
+ --spacing: 0.25rem;
+}
+
+.dark {
+ --background: oklch(0.2046 0 0);
+ --foreground: oklch(0.9219 0 0);
+ --card: oklch(0.2686 0 0);
+ --card-foreground: oklch(0.9219 0 0);
+ --popover: oklch(0.2686 0 0);
+ --popover-foreground: oklch(0.9219 0 0);
+ --primary: oklch(0.6225 0.2041 259.9027);
+ --primary-foreground: oklch(1.0000 0 0);
+ --secondary: oklch(0.2686 0 0);
+ --secondary-foreground: oklch(0.9219 0 0);
+ --muted: oklch(0.2393 0 0);
+ --muted-foreground: oklch(0.7155 0 0);
+ --accent: oklch(0.5802 0.1915 259.7416);
+ --accent-foreground: oklch(0.8820 0.0588 253.9688);
+ --destructive: oklch(0.6496 0.2362 26.9032);
+ --destructive-foreground: oklch(1.0000 0 0);
+ --border: oklch(0.3715 0 0);
+ --input: oklch(0.3715 0 0);
+ --ring: oklch(0.6225 0.2041 259.9027);
+ --chart-1: oklch(0.7122 0.1526 254.9868);
+ --chart-2: oklch(0.6225 0.2041 259.9027);
+ --chart-3: oklch(0.5739 0.2334 262.7735);
+ --chart-4: oklch(0.6225 0.2041 259.9027);
+ --chart-5: oklch(0.6225 0.2041 259.9027);
+ --sidebar: oklch(0.2046 0 0);
+ --sidebar-foreground: oklch(0.9219 0 0);
+ --sidebar-primary: oklch(0.6225 0.2041 259.9027);
+ --sidebar-primary-foreground: oklch(1.0000 0 0);
+ --sidebar-accent: oklch(0.6225 0.2041 259.9027);
+ --sidebar-accent-foreground: oklch(0.8820 0.0588 253.9688);
+ --sidebar-border: oklch(0.3715 0 0);
+ --sidebar-ring: oklch(0.6225 0.2041 259.9027);
+ --font-sans: Inter, sans-serif;
+ --font-serif: Source Serif 4, serif;
+ --font-mono: JetBrains Mono, monospace;
+ --radius: 1.15rem;
+ --shadow-x: 0;
+ --shadow-y: 1px;
+ --shadow-blur: 3px;
+ --shadow-spread: 0px;
+ --shadow-opacity: 0.1;
+ --shadow-color: oklch(0 0 0);
+ --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
+ --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
+ --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
+ --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
+ --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
+ --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
+ --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
+ --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-mono);
+ --font-serif: var(--font-serif);
+
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+
+ --shadow-2xs: var(--shadow-2xs);
+ --shadow-xs: var(--shadow-xs);
+ --shadow-sm: var(--shadow-sm);
+ --shadow: var(--shadow);
+ --shadow-md: var(--shadow-md);
+ --shadow-lg: var(--shadow-lg);
+ --shadow-xl: var(--shadow-xl);
+ --shadow-2xl: var(--shadow-2xl);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}