From 18a030a0a894599fe2557acb6cab290e542acd5b Mon Sep 17 00:00:00 2001 From: KartoffelChips <104089082+KartoffelChipss@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:31:19 +0200 Subject: [PATCH] Added sqlite database --- .idea/dataSources.xml | 12 + .idea/sqldialects.xml | 7 + app/handlers/site.go | 176 +++++++++++++ app/handlers/update.go | 148 +++++------ app/models/api_error.go | 2 +- app/models/site.go | 41 +++ app/repository/site_repository.go | 19 ++ app/routes/routes.go | 15 +- go.mod | 1 + go.sum | 2 + internal/database/init_sqlite.go | 54 ++++ internal/database/site_sqlite.go | 411 ++++++++++++++++++++++++++++++ internal/database/sqlite.go | 23 ++ internal/envconfig/envconfig.go | 9 +- main.go | 22 +- theme.css | 173 +++++++++++++ 16 files changed, 1036 insertions(+), 79 deletions(-) create mode 100644 .idea/dataSources.xml create mode 100644 .idea/sqldialects.xml create mode 100644 app/handlers/site.go create mode 100644 app/models/site.go create mode 100644 app/repository/site_repository.go create mode 100644 internal/database/init_sqlite.go create mode 100644 internal/database/site_sqlite.go create mode 100644 internal/database/sqlite.go create mode 100644 theme.css 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; + } +}