Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 515b77d407 | |||
| 5e9279223b | |||
| e23f23fa74 | |||
| e5fd59f3ed | |||
| b28b9b127b | |||
| c8ec394774 | |||
| 663c1137dd | |||
| 6169dea989 | |||
| 0344215b42 | |||
| b0dc65cb3e | |||
| ce4ed9744b | |||
| 596e94794e | |||
| 6b6565caee | |||
| 087aa01876 | |||
| a1234158fe | |||
| ab5005f7df | |||
| c3f4c95150 | |||
| a68368df3c | |||
| 86132bdc8d | |||
| 76e3454c4c | |||
| 38d2083d06 | |||
| 36a5911fe4 | |||
| f1fd72520a | |||
| e1c6ea9e51 | |||
| 5997a29d92 | |||
| 24ade563db | |||
| 41334bbd9b | |||
| 88323ed4fe | |||
| 1978a31cbf | |||
| a95c76ce7e | |||
| 38fcdbf391 | |||
| 3f1deae70e | |||
| 3a96717d02 | |||
| 538bd8920c | |||
| a741d769bc | |||
| a6a29461bc | |||
| a6f60a5a38 | |||
| 7bb7454d7f | |||
| 6622e2807b | |||
| 29ee01afba | |||
| 12678f8241 | |||
| 60461e49af | |||
| 2931729f97 | |||
| a2e5c735a6 | |||
| 1aba69cfb5 | |||
| 78b84a33b8 | |||
| 59fb96cc26 | |||
| 6e7aa21c40 | |||
| e98de9a513 | |||
| e71530bb30 | |||
| 18a030a0a8 | |||
| f0eeaecbb1 | |||
| e4b9c36486 | |||
| dcc8201099 | |||
| 55348057a0 | |||
| bedf8bc335 | |||
| c5df17cd1f | |||
| 5115da61ab | |||
| d4a5c0e7d8 |
@@ -0,0 +1,47 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ gitea.event.repository.name }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ gitea.event.repository.name }}:buildcache
|
||||
cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/${{ gitea.event.repository.name }}:buildcache,mode=max
|
||||
@@ -1,5 +1,3 @@
|
||||
.env
|
||||
config/config.yaml
|
||||
storage
|
||||
quay
|
||||
config
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="db.sqlite" uuid="01e48c0a-92f2-41f7-94e8-3dee9d9f5a2a">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/backend/config/db.sqlite</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="PROJECT" dialect="SQLite" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -2,5 +2,6 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/frontend" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,35 +1,53 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache build-base libwebp-dev
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
|
||||
RUN npm install -g pnpm \
|
||||
&& pnpm install --frozen-lockfile
|
||||
|
||||
COPY frontend .
|
||||
|
||||
RUN pnpm run build
|
||||
|
||||
# Stage 2: Build backend
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
|
||||
WORKDIR /backend
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY backend .
|
||||
|
||||
RUN go build -tags "fts5" -o quay
|
||||
RUN CGO_ENABLED=0 \
|
||||
GOOS=$TARGETOS \
|
||||
GOARCH=$TARGETARCH \
|
||||
go build -o server ./
|
||||
|
||||
FROM alpine:3.19
|
||||
# Stage 3: Final image
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
# backend
|
||||
COPY --from=backend-builder /backend/server /server
|
||||
|
||||
RUN apk add --no-cache libwebp libstdc++
|
||||
# frontend
|
||||
COPY --from=frontend-builder /app/dist /app/dist
|
||||
|
||||
RUN adduser -D -g '' appuser
|
||||
|
||||
COPY --from=builder /app/quay .
|
||||
|
||||
RUN mkdir -p /storage /config && \
|
||||
chown -R appuser:appuser /app /storage /config
|
||||
|
||||
USER appuser
|
||||
RUN mkdir -p /config
|
||||
|
||||
ENV PORT=4321
|
||||
ENV CONFIG_DIR=/config
|
||||
ENV STORAGE_PATH=/storage
|
||||
ENV STATIC_DASHBOARD_PATH=/app/dist
|
||||
|
||||
EXPOSE 4321
|
||||
|
||||
CMD ["./quay"]
|
||||
CMD ["/server"]
|
||||
@@ -1,177 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"quay/internal/config"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func NewStaticHandler(storagePath string, siteMap map[string]config.SiteConfig) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
site, ok := siteMap[c.Hostname()]
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusNotFound).SendString("Site not found")
|
||||
}
|
||||
|
||||
urlPath := filepath.Clean(c.Path())
|
||||
if urlPath == "/" || urlPath == "." {
|
||||
urlPath = "/index.html"
|
||||
}
|
||||
|
||||
basePath := filepath.Join(storagePath, site.Name)
|
||||
filePath := filepath.Join(basePath, urlPath)
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err == nil {
|
||||
if info.IsDir() {
|
||||
indexPath := filepath.Join(filePath, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
return c.SendFile(indexPath)
|
||||
}
|
||||
} else {
|
||||
return c.SendFile(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
if site.SPA {
|
||||
indexPath := filepath.Join(basePath, "index.html")
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
return c.SendFile(indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
if site.NotFoundFile != "" {
|
||||
notFoundPath := filepath.Join(basePath, site.NotFoundFile)
|
||||
if _, err := os.Stat(notFoundPath); err == nil {
|
||||
return c.SendFile(notFoundPath)
|
||||
}
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"quay/app/github"
|
||||
"quay/app/models"
|
||||
"quay/internal/config"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
"quay/app/handlers"
|
||||
"quay/internal/config"
|
||||
"quay/internal/envconfig"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func Register(app *fiber.App, cfg *config.Config, envCfg *envconfig.EnvConfig) {
|
||||
api := app.Group("/api")
|
||||
api.Get("/health", handlers.HealthCheck)
|
||||
|
||||
api.Post("/update", handlers.NewUpdateSiteHandler(cfg, envCfg))
|
||||
|
||||
storagePath, err := filepath.Abs(envCfg.StoragePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve storage path: %v", err)
|
||||
}
|
||||
|
||||
siteMap := make(map[string]config.SiteConfig)
|
||||
for _, site := range cfg.Sites {
|
||||
siteMap[site.Domain] = site
|
||||
}
|
||||
|
||||
app.Use(handlers.NewStaticHandler(storagePath, siteMap))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
config/config.yaml
|
||||
storage
|
||||
quay
|
||||
config
|
||||
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="db" uuid="d033e48d-c7eb-4bb4-b830-4003120cd8b8">
|
||||
<driver-ref>sqlite.xerial</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/config/db.sqlite</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,268 @@
|
||||
package cachedrepo
|
||||
|
||||
import (
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
|
||||
"github.com/maypok86/otter/v2"
|
||||
)
|
||||
|
||||
type CachedSiteRepository struct {
|
||||
inner repository.SiteRepository
|
||||
sites *otter.Cache[string, *models.Site]
|
||||
list *otter.Cache[string, []models.Site]
|
||||
forwardRules *otter.Cache[string, *models.ForwardRule]
|
||||
customHeaders *otter.Cache[string, *models.CustomHeaders]
|
||||
headers *otter.Cache[string, *models.Header]
|
||||
}
|
||||
|
||||
const siteListKey = "__list__"
|
||||
|
||||
func NewCachedSiteRepository(inner repository.SiteRepository) *CachedSiteRepository {
|
||||
return &CachedSiteRepository{
|
||||
inner: inner,
|
||||
sites: otter.Must(&otter.Options[string, *models.Site]{
|
||||
MaximumSize: 1_000,
|
||||
}),
|
||||
list: otter.Must(&otter.Options[string, []models.Site]{
|
||||
MaximumSize: 1,
|
||||
}),
|
||||
forwardRules: otter.Must(&otter.Options[string, *models.ForwardRule]{
|
||||
MaximumSize: 10_000,
|
||||
}),
|
||||
customHeaders: otter.Must(&otter.Options[string, *models.CustomHeaders]{
|
||||
MaximumSize: 10_000,
|
||||
}),
|
||||
headers: otter.Must(&otter.Options[string, *models.Header]{
|
||||
MaximumSize: 10_000,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
var _ repository.SiteRepository = (*CachedSiteRepository)(nil)
|
||||
|
||||
// Sites
|
||||
|
||||
func (c *CachedSiteRepository) GetSite(id string) (*models.Site, error) {
|
||||
if s, ok := c.sites.GetIfPresent(id); ok {
|
||||
return s, nil
|
||||
}
|
||||
s, err := c.inner.GetSite(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.sites.Set(id, s)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) GetSiteByDomain(domain string) (*models.Site, error) {
|
||||
if s, ok := c.sites.GetIfPresent(domain); ok {
|
||||
return s, nil
|
||||
}
|
||||
s, err := c.inner.GetSiteByDomain(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.sites.Set(domain, s)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) ListSites() ([]models.Site, error) {
|
||||
if sites, ok := c.list.GetIfPresent(siteListKey); ok {
|
||||
return sites, nil
|
||||
}
|
||||
sites, err := c.inner.ListSites()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.list.Set(siteListKey, sites)
|
||||
for i := range sites {
|
||||
c.sites.Set(sites[i].ID, &sites[i])
|
||||
}
|
||||
return sites, nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) CreateSite(s *models.Site) error {
|
||||
if err := c.inner.CreateSite(s); err != nil {
|
||||
return err
|
||||
}
|
||||
c.sites.Set(s.ID, s)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) UpdateSite(s *models.Site) error {
|
||||
if err := c.inner.UpdateSite(s); err != nil {
|
||||
return err
|
||||
}
|
||||
c.sites.Set(s.ID, s)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) ToggleEnabled(id string) (bool, error) {
|
||||
enabled, err := c.inner.ToggleEnabled(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
c.sites.Invalidate(id)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) DeleteSite(id string) error {
|
||||
if err := c.inner.DeleteSite(id); err != nil {
|
||||
return err
|
||||
}
|
||||
c.sites.Invalidate(id)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Forward Rules
|
||||
|
||||
func (c *CachedSiteRepository) GetForwardRule(id string) (*models.ForwardRule, error) {
|
||||
if fr, ok := c.forwardRules.GetIfPresent(id); ok {
|
||||
return fr, nil
|
||||
}
|
||||
fr, err := c.inner.GetForwardRule(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.forwardRules.Set(id, fr)
|
||||
return fr, nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) CreateForwardRule(siteID string, fr *models.ForwardRule) error {
|
||||
if err := c.inner.CreateForwardRule(siteID, fr); err != nil {
|
||||
return err
|
||||
}
|
||||
c.forwardRules.Set(fr.ID, fr)
|
||||
c.sites.Invalidate(siteID)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) UpdateForwardRule(siteID string, fr *models.ForwardRule) error {
|
||||
if err := c.inner.UpdateForwardRule(siteID, fr); err != nil {
|
||||
return err
|
||||
}
|
||||
c.forwardRules.Set(fr.ID, fr)
|
||||
c.sites.Invalidate(siteID)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) DeleteForwardRule(siteID string, id string) error {
|
||||
if err := c.inner.DeleteForwardRule(siteID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
c.forwardRules.Invalidate(id)
|
||||
c.sites.Invalidate(siteID)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Custom Headers
|
||||
|
||||
func (c *CachedSiteRepository) GetCustomHeaders(id string) (*models.CustomHeaders, error) {
|
||||
if ch, ok := c.customHeaders.GetIfPresent(id); ok {
|
||||
return ch, nil
|
||||
}
|
||||
ch, err := c.inner.GetCustomHeaders(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.customHeaders.Set(id, ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) CreateCustomHeaders(siteID string, ch *models.CustomHeaders) error {
|
||||
if err := c.inner.CreateCustomHeaders(siteID, ch); err != nil {
|
||||
return err
|
||||
}
|
||||
c.customHeaders.Set(ch.ID, ch)
|
||||
c.sites.Invalidate(siteID)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) UpdateCustomHeaders(siteID string, ch *models.CustomHeaders) error {
|
||||
if err := c.inner.UpdateCustomHeaders(siteID, ch); err != nil {
|
||||
return err
|
||||
}
|
||||
c.customHeaders.Set(ch.ID, ch)
|
||||
c.sites.Invalidate(siteID)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) DeleteCustomHeaders(siteID string, id string) error {
|
||||
if err := c.inner.DeleteCustomHeaders(siteID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
c.customHeaders.Invalidate(id)
|
||||
c.sites.Invalidate(siteID)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Headers
|
||||
|
||||
func (c *CachedSiteRepository) GetHeader(id string) (*models.Header, error) {
|
||||
if h, ok := c.headers.GetIfPresent(id); ok {
|
||||
return h, nil
|
||||
}
|
||||
h, err := c.inner.GetHeader(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.headers.Set(id, h)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) CreateHeader(siteID string, customHeaderID string, h *models.Header) error {
|
||||
if err := c.inner.CreateHeader(siteID, customHeaderID, h); err != nil {
|
||||
return err
|
||||
}
|
||||
c.headers.Set(h.ID, h)
|
||||
c.customHeaders.Invalidate(customHeaderID)
|
||||
c.sites.Invalidate(siteID)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) UpdateHeader(siteID string, h *models.Header) error {
|
||||
if err := c.inner.UpdateHeader(siteID, h); err != nil {
|
||||
return err
|
||||
}
|
||||
c.headers.Set(h.ID, h)
|
||||
c.sites.Invalidate(siteID)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CachedSiteRepository) DeleteHeader(siteID string, id string) error {
|
||||
if err := c.inner.DeleteHeader(siteID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
c.headers.Invalidate(id)
|
||||
c.sites.Invalidate(siteID)
|
||||
c.list.Invalidate(siteListKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Invalidate explicitly evicts all cached data.
|
||||
func (c *CachedSiteRepository) Invalidate() {
|
||||
c.sites.InvalidateAll()
|
||||
c.list.InvalidateAll()
|
||||
c.forwardRules.InvalidateAll()
|
||||
c.customHeaders.InvalidateAll()
|
||||
c.headers.InvalidateAll()
|
||||
}
|
||||
|
||||
// InvalidateSite evicts a single site and marks the list stale.
|
||||
func (c *CachedSiteRepository) InvalidateSite(id string) {
|
||||
c.sites.Invalidate(id)
|
||||
c.list.Invalidate(siteListKey)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package gitprovider
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package gitprovider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NewProvider(serverType, protocol, baseUrl, authToken string) (GitProvider, error) {
|
||||
switch strings.ToLower(serverType) {
|
||||
case "github":
|
||||
return &GitHubProvider{
|
||||
protocol: protocol,
|
||||
baseUrl: baseUrl,
|
||||
authToken: authToken,
|
||||
}, nil
|
||||
case "gitea":
|
||||
return &GiteaProvider{
|
||||
protocol: protocol,
|
||||
baseUrl: baseUrl,
|
||||
authToken: authToken,
|
||||
}, nil
|
||||
case "gitlab":
|
||||
return &GitLabProvider{
|
||||
protocol: protocol,
|
||||
baseUrl: baseUrl,
|
||||
authToken: authToken,
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported git provider: %s", serverType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package gitprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type GiteaProvider struct {
|
||||
protocol string
|
||||
baseUrl string
|
||||
authToken string
|
||||
}
|
||||
|
||||
var _ GitProvider = (*GiteaProvider)(nil)
|
||||
|
||||
func (p *GiteaProvider) FetchAndDeployBranch(owner, repo, branch, destPath string) (*DeployResult, error) {
|
||||
apiUrl := fmt.Sprintf("%s://%s/api/v1/", p.protocol, strings.TrimSuffix(p.baseUrl, "/"))
|
||||
|
||||
result, err := fetchGiteaBranchHead(apiUrl, owner, repo, branch, p.authToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching branch head: %w", err)
|
||||
}
|
||||
|
||||
if err = downloadAndExtractGitea(apiUrl, owner, repo, branch, p.authToken, destPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchGiteaBranchHead(apiUrl, owner, repo, branch, token string) (*DeployResult, error) {
|
||||
url := fmt.Sprintf("%srepos/%s/%s/branches/%s", apiUrl, owner, repo, branch)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching branch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Gitea returned %s for %s", resp.Status, url)
|
||||
}
|
||||
|
||||
// GET /repos/{owner}/{repo}/branches/{branch} returns a Branch object
|
||||
// whose commit field is a PayloadCommit.
|
||||
var payload struct {
|
||||
Commit struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("decoding branch response: %w", err)
|
||||
}
|
||||
|
||||
return &DeployResult{
|
||||
CommitHash: payload.Commit.ID,
|
||||
CommitMessage: strings.SplitN(payload.Commit.Message, "\n", 2)[0],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func downloadAndExtractGitea(apiUrl, owner, repo, branch, token, destDir string) error {
|
||||
// GET /repos/{owner}/{repo}/archive/{sha}.zip
|
||||
archiveURL := fmt.Sprintf("%srepos/%s/%s/archive/%s.zip", apiUrl, owner, repo, branch)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
|
||||
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("Gitea returned %s for %s", resp.Status, archiveURL)
|
||||
}
|
||||
|
||||
storageRoot := filepath.Dir(destDir)
|
||||
|
||||
tmpZip, err := os.CreateTemp(storageRoot, "giteabranch-*.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(storageRoot, "giteabranch-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)
|
||||
}
|
||||
|
||||
// Gitea wraps everything in {repo}-{branch}/ (all lowercase).
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package gitprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type GitHubProvider struct {
|
||||
protocol string
|
||||
baseUrl string
|
||||
authToken string
|
||||
}
|
||||
|
||||
var _ GitProvider = (*GitHubProvider)(nil)
|
||||
|
||||
func (p *GitHubProvider) FetchAndDeployBranch(owner, repo, branch, destPath string) (*DeployResult, error) {
|
||||
baseUrl := strings.TrimSuffix(p.baseUrl, "/")
|
||||
apiUrl := fmt.Sprintf("%s://api.%s/", p.protocol, baseUrl)
|
||||
|
||||
result, err := fetchBranchHead(apiUrl, owner, repo, branch, p.authToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching branch head: %w", err)
|
||||
}
|
||||
|
||||
if err = downloadAndExtract(owner, repo, branch, p.authToken, destPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchBranchHead returns the SHA and commit message for the tip of the given branch.
|
||||
func fetchBranchHead(apiUrl, owner, repo, branch, pat string) (*DeployResult, error) {
|
||||
url := fmt.Sprintf(
|
||||
"%srepos/%s/%s/commits/%s",
|
||||
apiUrl, owner, repo, branch,
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 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 nil, fmt.Errorf("fetching commit: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub returned %s for %s", resp.Status, url)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
SHA string `json:"sha"`
|
||||
Commit struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("decoding commit response: %w", err)
|
||||
}
|
||||
|
||||
return &DeployResult{
|
||||
CommitHash: payload.SHA,
|
||||
CommitMessage: strings.SplitN(payload.Commit.Message, "\n", 2)[0],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func downloadAndExtract(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)
|
||||
}
|
||||
|
||||
storageRoot := filepath.Dir(destDir)
|
||||
|
||||
tmpZip, err := os.CreateTemp(storageRoot, "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(storageRoot, "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
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package gitprovider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type GitLabProvider struct {
|
||||
protocol string
|
||||
baseUrl string
|
||||
authToken string
|
||||
}
|
||||
|
||||
var _ GitProvider = (*GitLabProvider)(nil)
|
||||
|
||||
func (p *GitLabProvider) FetchAndDeployBranch(owner, repo, branch, destPath string) (*DeployResult, error) {
|
||||
apiUrl := fmt.Sprintf("%s://%s/api/v4/", p.protocol, strings.TrimSuffix(p.baseUrl, "/"))
|
||||
|
||||
result, err := fetchGitLabBranchHead(apiUrl, owner, repo, branch, p.authToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching branch head: %w", err)
|
||||
}
|
||||
|
||||
if err = downloadAndExtractGitLab(apiUrl, owner, repo, branch, p.authToken, destPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func fetchGitLabBranchHead(apiUrl, owner, repo, branch, token string) (*DeployResult, error) {
|
||||
// GitLab identifies projects by URL-encoded "namespace/repo" path.
|
||||
projectID := url.PathEscape(owner + "/" + repo)
|
||||
encodedBranch := url.PathEscape(branch)
|
||||
reqUrl := fmt.Sprintf("%sprojects/%s/repository/branches/%s", apiUrl, projectID, encodedBranch)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqUrl, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("PRIVATE-TOKEN", token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching branch: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitLab returned %s for %s", resp.Status, reqUrl)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Commit struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"` // first line of the commit message
|
||||
} `json:"commit"`
|
||||
}
|
||||
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("decoding branch response: %w", err)
|
||||
}
|
||||
|
||||
return &DeployResult{
|
||||
CommitHash: payload.Commit.ID,
|
||||
CommitMessage: payload.Commit.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func downloadAndExtractGitLab(apiUrl, owner, repo, branch, token, destDir string) error {
|
||||
// GET /projects/:id/repository/archive.zip?sha=<branch>
|
||||
projectID := url.PathEscape(owner + "/" + repo)
|
||||
archiveURL := fmt.Sprintf("%sprojects/%s/repository/archive.zip?sha=%s", apiUrl, projectID, url.QueryEscape(branch))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, archiveURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("PRIVATE-TOKEN", token)
|
||||
|
||||
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("GitLab returned %s for %s", resp.Status, archiveURL)
|
||||
}
|
||||
|
||||
storageRoot := filepath.Dir(destDir)
|
||||
|
||||
tmpZip, err := os.CreateTemp(storageRoot, "gitlabbranch-*.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(storageRoot, "gitlabbranch-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)
|
||||
}
|
||||
|
||||
// GitLab names the root folder {repo}-{branch}-{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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package gitprovider
|
||||
|
||||
type DeployResult struct {
|
||||
CommitHash string
|
||||
CommitMessage string
|
||||
}
|
||||
|
||||
type GitProvider interface {
|
||||
FetchAndDeployBranch(owner, repo, branch, destPath string) (*DeployResult, error)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"quay/app/repository"
|
||||
"quay/internal/security"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
Repo repository.UserRepository
|
||||
}
|
||||
|
||||
func NewAuthHandler(repo repository.UserRepository) *AuthHandler {
|
||||
return &AuthHandler{Repo: repo}
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c fiber.Ctx) error {
|
||||
var req LoginRequest
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
|
||||
}
|
||||
|
||||
user, err := h.Repo.GetUserByName(req.Name)
|
||||
if err != nil || user == nil {
|
||||
log.Println("login: user lookup failed", err)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
|
||||
}
|
||||
|
||||
if !security.CheckPasswordHash(req.Password, user.HashedPassword) {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
|
||||
}
|
||||
|
||||
token, err := security.GenerateToken(user.ID, user.Role, 24*time.Hour)
|
||||
if err != nil {
|
||||
log.Println("login: token generation failed", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
|
||||
}
|
||||
|
||||
return c.JSON(LoginResponse{Token: token})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"quay/app/gitprovider"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
"quay/internal/envconfig"
|
||||
"quay/internal/security"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type DeploySiteHandler struct {
|
||||
EnvCfg *envconfig.EnvConfig
|
||||
SiteRepo repository.SiteRepository
|
||||
GitServerRepo repository.GitServerRepository
|
||||
DeploymentRepo repository.DeploymentRepository
|
||||
}
|
||||
|
||||
func NewDeploySiteHandler(
|
||||
envCfg *envconfig.EnvConfig,
|
||||
siteRepo repository.SiteRepository,
|
||||
gitServerRepo repository.GitServerRepository,
|
||||
deploymentRepo repository.DeploymentRepository) *DeploySiteHandler {
|
||||
return &DeploySiteHandler{
|
||||
EnvCfg: envCfg,
|
||||
SiteRepo: siteRepo,
|
||||
GitServerRepo: gitServerRepo,
|
||||
DeploymentRepo: deploymentRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func failDeployment(deployId string, repo repository.DeploymentRepository) {
|
||||
deployUpdateErr := repo.UpdateDeploymentStatus(deployId, models.DeploymentStatusFailed)
|
||||
if deployUpdateErr != nil {
|
||||
log.Println("Error updating deployment status to failed: ", deployUpdateErr)
|
||||
}
|
||||
deployUpdateErr = repo.UpdateDeploymentFinishTime(deployId, time.Now().UTC())
|
||||
if deployUpdateErr != nil {
|
||||
log.Println("Error updating deployment finish time: ", deployUpdateErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DeploySiteHandler) PostDeploy(c fiber.Ctx) error {
|
||||
siteId := c.Query("site")
|
||||
if siteId == "" {
|
||||
return c.Status(400).JSON(models.APIError{
|
||||
Message: "Missing 'site' query parameter",
|
||||
})
|
||||
}
|
||||
|
||||
site, err := h.SiteRepo.GetSite(siteId)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Site not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error 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",
|
||||
})
|
||||
}
|
||||
|
||||
gitServer, err := h.GitServerRepo.GetGitServer(site.GitServer)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Git server not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error getting git server: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while getting git server",
|
||||
})
|
||||
}
|
||||
|
||||
if gitServer == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Git server not found",
|
||||
})
|
||||
}
|
||||
|
||||
deployToken := site.DeployToken
|
||||
if deployToken == "" {
|
||||
log.Println("No deploy token configured for site " + siteId)
|
||||
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 security.CompareDeployTokens(providedToken, deployToken) == false {
|
||||
return c.Status(403).JSON(models.APIError{
|
||||
Message: "Invalid deploy token",
|
||||
})
|
||||
}
|
||||
|
||||
sitePath := filepath.Join(h.EnvCfg.StoragePath, site.ID)
|
||||
if _, err := filepath.Abs(sitePath); err != nil {
|
||||
log.Println("Error getting absolute path of site: ", err)
|
||||
return c.Status(500).JSON(models.APIError{
|
||||
Message: "Failed to resolve site path",
|
||||
})
|
||||
}
|
||||
|
||||
deployId, err := h.DeploymentRepo.CreateDeployment(&models.Deployment{
|
||||
SiteId: siteId,
|
||||
Status: models.DeploymentStatusRunning,
|
||||
StartTime: time.Now().UTC().Format(time.DateTime),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Println("Error creating deployment: ", err)
|
||||
failDeployment(deployId, h.DeploymentRepo)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Failed to create deployment",
|
||||
})
|
||||
}
|
||||
|
||||
provider, err := gitprovider.NewProvider(gitServer.Type, gitServer.Protocol, gitServer.BaseUrl, gitServer.AuthToken)
|
||||
if err != nil {
|
||||
failDeployment(deployId, h.DeploymentRepo)
|
||||
return c.Status(400).JSON(models.APIError{
|
||||
Message: "Unsupported git provider: " + gitServer.Type,
|
||||
})
|
||||
}
|
||||
|
||||
res, err := provider.FetchAndDeployBranch(
|
||||
site.Owner,
|
||||
site.Repository,
|
||||
site.Branch,
|
||||
sitePath,
|
||||
)
|
||||
|
||||
if res != nil {
|
||||
deployUpdateErr := h.DeploymentRepo.UpdateDeploymentGitInfo(deployId, res.CommitHash, res.CommitMessage)
|
||||
if deployUpdateErr != nil {
|
||||
log.Println("Error updating deployment git info: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Println("Error deploying site: ", err)
|
||||
failDeployment(deployId, h.DeploymentRepo)
|
||||
|
||||
return c.Status(500).JSON(models.APIError{
|
||||
Message: "Failed to deploy site: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
deployUpdateErr := h.DeploymentRepo.UpdateDeploymentStatus(deployId, models.DeploymentStatusSuccess)
|
||||
if deployUpdateErr != nil {
|
||||
log.Println("Error updating deployment status to success: ", err)
|
||||
}
|
||||
deployUpdateErr = h.DeploymentRepo.UpdateDeploymentFinishTime(deployId, time.Now().UTC())
|
||||
if deployUpdateErr != nil {
|
||||
log.Println("Error updating deployment finish time: ", err)
|
||||
}
|
||||
|
||||
return c.SendStatus(201)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type DeploymentHandler struct {
|
||||
Repo repository.DeploymentRepository
|
||||
}
|
||||
|
||||
func NewDeploymentHandler(repo repository.DeploymentRepository) *DeploymentHandler {
|
||||
return &DeploymentHandler{Repo: repo}
|
||||
}
|
||||
|
||||
// GetDeployment godoc
|
||||
// @Summary Get deployment by ID
|
||||
// @Description Get a single deployment by its ID
|
||||
// @Tags Deployments
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Deployment ID"
|
||||
// @Success 200 {object} models.Deployment
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
// @Router /deployments/{id} [get]
|
||||
func (h *DeploymentHandler) GetDeployment(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
deployment, err := h.Repo.GetDeploymentByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Deployment not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error getting deployment by id: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while getting deployment by id",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(&deployment)
|
||||
}
|
||||
|
||||
// GetDeploymentsBySite godoc
|
||||
// @Summary Get deployments for a site
|
||||
// @Description Get a list of deployments for a specific site
|
||||
// @Tags Deployments
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param siteId path string true "Site ID"
|
||||
// @Param limit query int false "Maximum number of deployments to return" default(100)
|
||||
// @Success 200 {object} models.GetDeploymentsBySiteResponse
|
||||
// @Failure 500 {object} models.APIError
|
||||
// @Router /sites/{siteId}/deployments [get]
|
||||
func (h *DeploymentHandler) GetDeploymentsBySite(c fiber.Ctx) error {
|
||||
siteId := c.Params("id")
|
||||
limit, err := strconv.Atoi(c.Query("limit", "100"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||
Message: "Invalid limit",
|
||||
})
|
||||
}
|
||||
|
||||
deployments, err := h.Repo.GetDeploymentsForSite(siteId, limit)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.JSON(models.GetDeploymentsBySiteResponse{
|
||||
Deployments: []models.Deployment{},
|
||||
Total: 0,
|
||||
})
|
||||
}
|
||||
|
||||
log.Println("Error getting deployments: ", err)
|
||||
return c.JSON(models.APIError{
|
||||
Message: "Unexpected error while getting deployments: ",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(models.GetDeploymentsBySiteResponse{
|
||||
Deployments: deployments,
|
||||
Total: len(deployments),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type GitServerHandler struct {
|
||||
Repo repository.GitServerRepository
|
||||
}
|
||||
|
||||
func NewGitServerHandler(repo repository.GitServerRepository) *GitServerHandler {
|
||||
return &GitServerHandler{Repo: repo}
|
||||
}
|
||||
|
||||
func (h *GitServerHandler) GetGitServers(c fiber.Ctx) error {
|
||||
gs, err := h.Repo.ListGitServers()
|
||||
if err != nil {
|
||||
log.Println("Error listing gitservers: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while listing git servers"})
|
||||
}
|
||||
if gs == nil {
|
||||
gs = []models.GitServer{}
|
||||
}
|
||||
return c.JSON(gs)
|
||||
}
|
||||
|
||||
func (h *GitServerHandler) GetGitServer(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
g, err := h.Repo.GetGitServer(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Git server not found"})
|
||||
}
|
||||
log.Println("Error getting gitserver: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while getting git server"})
|
||||
}
|
||||
if g == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Git server not found"})
|
||||
}
|
||||
return c.JSON(g)
|
||||
}
|
||||
|
||||
func validateIncomingGitServer(gs *models.GitServer) error {
|
||||
return models.ValidateGitServer(gs)
|
||||
}
|
||||
|
||||
func (h *GitServerHandler) PostGitServer(c fiber.Ctx) error {
|
||||
var gs models.GitServer
|
||||
if err := c.Bind().Body(&gs); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body"})
|
||||
}
|
||||
|
||||
if err := validateIncomingGitServer(&gs); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()})
|
||||
}
|
||||
|
||||
if err := h.Repo.CreateGitServer(&gs); err != nil {
|
||||
log.Println("Error creating gitserver: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while creating git server"})
|
||||
}
|
||||
|
||||
return c.JSON(gs)
|
||||
}
|
||||
|
||||
func (h *GitServerHandler) PutGitServer(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if _, err := h.Repo.GetGitServer(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Git server not found"})
|
||||
}
|
||||
log.Println("Error checking gitserver before update: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating git server"})
|
||||
}
|
||||
|
||||
var gs models.GitServer
|
||||
if err := c.Bind().Body(&gs); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body"})
|
||||
}
|
||||
|
||||
if err := validateIncomingGitServer(&gs); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()})
|
||||
}
|
||||
|
||||
gs.ID = id
|
||||
if err := h.Repo.UpdateGitServer(&gs); err != nil {
|
||||
log.Println("Error updating gitserver: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating git server"})
|
||||
}
|
||||
|
||||
updated, err := h.Repo.GetGitServer(id)
|
||||
if err != nil {
|
||||
log.Println("Error getting updated gitserver: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Git server was updated but could not be retrieved"})
|
||||
}
|
||||
|
||||
return c.JSON(updated)
|
||||
}
|
||||
|
||||
func (h *GitServerHandler) DeleteGitServer(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
if _, err := h.Repo.GetGitServer(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Git server not found"})
|
||||
}
|
||||
log.Println("Error checking gitserver before delete: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting git server"})
|
||||
}
|
||||
|
||||
if err := h.Repo.DeleteGitServer(id); err != nil {
|
||||
log.Println("Error deleting gitserver: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting git server"})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
"quay/internal/security"
|
||||
|
||||
"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.Name == "" {
|
||||
return errors.New("site name 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.IndexFile == "" {
|
||||
site.IndexFile = "index.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.CreateSiteResponse
|
||||
// @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(),
|
||||
})
|
||||
}
|
||||
|
||||
rawDeployToken, hashedDeployToken, err := security.CreateDeployToken()
|
||||
|
||||
if err != nil {
|
||||
log.Println("Error creating deploy token: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while creating deploy token: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
site.DeployToken = hashedDeployToken
|
||||
|
||||
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(&models.CreateSiteResponse{
|
||||
Site: site,
|
||||
RawDeployToken: rawDeployToken,
|
||||
})
|
||||
}
|
||||
|
||||
// PutSite godoc
|
||||
// @Summary Update an existing site
|
||||
// @Description Update an existing site by its ID
|
||||
// @Tags Sites
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Site ID"
|
||||
// @Param site body models.Site true "Updated site details"
|
||||
// @Success 200 {object} models.Site
|
||||
// @Failure 400 {object} models.APIError
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
// @Router /sites/{id} [put]
|
||||
func (h *SiteHandler) PutSite(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
if _, err := h.Repo.GetSite(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Site not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error checking site before update: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while updating site",
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
site.ID = id
|
||||
if err := h.Repo.UpdateSite(&site); err != nil {
|
||||
log.Println("Error updating site: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while updating site",
|
||||
})
|
||||
}
|
||||
|
||||
updatedSite, err := h.Repo.GetSite(id)
|
||||
if err != nil {
|
||||
log.Println("Error getting updated site: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Site was updated but could not be retrieved",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(updatedSite)
|
||||
}
|
||||
|
||||
// ToggleEnabled godoc
|
||||
// @Summary Toggle site enabled status
|
||||
// @Description Enable or disable a site by its ID
|
||||
// @Tags Sites
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Site ID"
|
||||
// @Success 200 {object} models.ToggleEnabledResponse
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
// @Router /sites/{id}/enabled [patch]
|
||||
func (h *SiteHandler) ToggleEnabled(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
if _, err := h.Repo.GetSite(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Site not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error checking site before update: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while updating site",
|
||||
})
|
||||
}
|
||||
|
||||
enabled, err := h.Repo.ToggleEnabled(id)
|
||||
|
||||
if err != nil {
|
||||
log.Println("Error while toggling enabled: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while toggling enabled",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(&models.ToggleEnabledResponse{
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteSite godoc
|
||||
// @Summary Delete a site
|
||||
// @Description Delete a site by its ID
|
||||
// @Tags Sites
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Site ID"
|
||||
// @Success 204
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
// @Router /sites/{id} [delete]
|
||||
func (h *SiteHandler) DeleteSite(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
if _, err := h.Repo.GetSite(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Site not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error checking site before delete: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while deleting site",
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.Repo.DeleteSite(id); err != nil {
|
||||
log.Println("Error deleting site: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while deleting site",
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"quay/app/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func validateForwardRule(rule *models.ForwardRule) error {
|
||||
if rule == nil {
|
||||
return errors.New("forward rule is required")
|
||||
}
|
||||
if rule.Source == "" {
|
||||
return errors.New("forward rule source is required")
|
||||
}
|
||||
if rule.Destination == "" {
|
||||
return errors.New("forward rule destination is required")
|
||||
}
|
||||
if rule.StatusCode < 300 || rule.StatusCode > 399 {
|
||||
return errors.New("forward rule status code must be between 300 and 399")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHeader(header *models.Header) error {
|
||||
if header == nil {
|
||||
return errors.New("header is required")
|
||||
}
|
||||
if header.Key == "" {
|
||||
return errors.New("header key is required")
|
||||
}
|
||||
if header.Value == "" {
|
||||
return errors.New("header value is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomHeaders(customHeaders *models.CustomHeaders) error {
|
||||
if customHeaders == nil {
|
||||
return errors.New("custom headers are required")
|
||||
}
|
||||
if customHeaders.Source == "" {
|
||||
return errors.New("custom header source required")
|
||||
}
|
||||
if customHeaders.Headers == nil {
|
||||
customHeaders.Headers = []models.Header{}
|
||||
}
|
||||
for i := range customHeaders.Headers {
|
||||
if err := validateHeader(&customHeaders.Headers[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSiteForwardRules godoc
|
||||
//
|
||||
// @Summary List forward rules for a site
|
||||
// @Description Returns all forward rules associated with the given site
|
||||
// @Tags Forward-Rules
|
||||
// @Produce json
|
||||
// @Param id path string true "Site ID"
|
||||
// @Success 200 {array} models.ForwardRule "List of forward rules"
|
||||
// @Failure 404 {object} models.APIError "Site not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /sites/{id}/forward-rules [get]
|
||||
func (h *SiteHandler) GetSiteForwardRules(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
|
||||
site, err := h.Repo.GetSite(siteID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Site not found"})
|
||||
}
|
||||
log.Println("Error getting site forward rules: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while listing forward rules"})
|
||||
}
|
||||
|
||||
if site.ForwardRules == nil {
|
||||
site.ForwardRules = []models.ForwardRule{}
|
||||
}
|
||||
return c.JSON(site.ForwardRules)
|
||||
}
|
||||
|
||||
// GetForwardRule godoc
|
||||
//
|
||||
// @Summary Get a forward rule
|
||||
// @Description Returns a single forward rule by ID
|
||||
// @Tags Forward-Rules
|
||||
// @Produce json
|
||||
// @Param id path string true "Forward Rule ID"
|
||||
// @Success 200 {object} models.ForwardRule "Forward rule"
|
||||
// @Failure 404 {object} models.APIError "Forward rule not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /forward-rules/{id} [get]
|
||||
func (h *SiteHandler) GetForwardRule(c fiber.Ctx) error {
|
||||
id := c.Params("ruleId")
|
||||
|
||||
rule, err := h.Repo.GetForwardRule(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Forward rule not found"})
|
||||
}
|
||||
log.Println("Error getting forward rule: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while getting forward rule"})
|
||||
}
|
||||
|
||||
return c.JSON(rule)
|
||||
}
|
||||
|
||||
// PostForwardRule godoc
|
||||
//
|
||||
// @Summary Create a forward rule
|
||||
// @Description Creates a new forward rule for the given site
|
||||
// @Tags Forward-Rules
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Site ID"
|
||||
// @Param rule body models.ForwardRule true "Forward rule to create"
|
||||
// @Success 200 {object} models.ForwardRule "Created forward rule"
|
||||
// @Failure 400 {object} models.APIError "Invalid request body"
|
||||
// @Failure 404 {object} models.APIError "Site not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /sites/{id}/forward-rules [post]
|
||||
func (h *SiteHandler) PostForwardRule(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
|
||||
if _, err := h.Repo.GetSite(siteID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Site not found"})
|
||||
}
|
||||
log.Println("Error checking site before creating forward rule: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while creating forward rule"})
|
||||
}
|
||||
|
||||
var rule models.ForwardRule
|
||||
if err := c.Bind().Body(&rule); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body"})
|
||||
}
|
||||
|
||||
if err := validateForwardRule(&rule); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()})
|
||||
}
|
||||
|
||||
if err := h.Repo.CreateForwardRule(siteID, &rule); err != nil {
|
||||
log.Println("Error creating forward rule: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while creating forward rule"})
|
||||
}
|
||||
|
||||
return c.JSON(rule)
|
||||
}
|
||||
|
||||
// PutForwardRule godoc
|
||||
//
|
||||
// @Summary Update a forward rule
|
||||
// @Description Replaces an existing forward rule by ID
|
||||
// @Tags Forward-Rules
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Forward Rule ID"
|
||||
// @Param rule body models.ForwardRule true "Updated forward rule"
|
||||
// @Success 200 {object} models.ForwardRule "Updated forward rule"
|
||||
// @Failure 400 {object} models.APIError "Invalid request body"
|
||||
// @Failure 404 {object} models.APIError "Forward rule not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /forward-rules/{id} [put]
|
||||
func (h *SiteHandler) PutForwardRule(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
id := c.Params("ruleId")
|
||||
|
||||
if _, err := h.Repo.GetForwardRule(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Forward rule not found"})
|
||||
}
|
||||
log.Println("Error checking forward rule before update: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating forward rule"})
|
||||
}
|
||||
|
||||
var rule models.ForwardRule
|
||||
if err := c.Bind().Body(&rule); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body"})
|
||||
}
|
||||
|
||||
if err := validateForwardRule(&rule); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()})
|
||||
}
|
||||
|
||||
rule.ID = id
|
||||
if err := h.Repo.UpdateForwardRule(siteID, &rule); err != nil {
|
||||
log.Println("Error updating forward rule: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating forward rule"})
|
||||
}
|
||||
|
||||
updated, err := h.Repo.GetForwardRule(id)
|
||||
if err != nil {
|
||||
log.Println("Error getting updated forward rule: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Forward rule was updated but could not be retrieved"})
|
||||
}
|
||||
|
||||
return c.JSON(updated)
|
||||
}
|
||||
|
||||
// DeleteForwardRule godoc
|
||||
//
|
||||
// @Summary Delete a forward rule
|
||||
// @Description Deletes a forward rule by ID
|
||||
// @Tags Forward-Rules
|
||||
// @Produce json
|
||||
// @Param id path string true "Forward Rule ID"
|
||||
// @Success 204 "No content"
|
||||
// @Failure 404 {object} models.APIError "Forward rule not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /forward-rules/{id} [delete]
|
||||
func (h *SiteHandler) DeleteForwardRule(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
id := c.Params("ruleId")
|
||||
|
||||
if _, err := h.Repo.GetForwardRule(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Forward rule not found"})
|
||||
}
|
||||
log.Println("Error checking forward rule before delete: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting forward rule"})
|
||||
}
|
||||
|
||||
if err := h.Repo.DeleteForwardRule(siteID, id); err != nil {
|
||||
log.Println("Error deleting forward rule: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting forward rule"})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetSiteCustomHeaders godoc
|
||||
//
|
||||
// @Summary List custom header groups for a site
|
||||
// @Description Returns all custom header groups associated with the given site
|
||||
// @Tags Custom-Headers
|
||||
// @Produce json
|
||||
// @Param id path string true "Site ID"
|
||||
// @Success 200 {array} models.CustomHeaders "List of custom header groups"
|
||||
// @Failure 404 {object} models.APIError "Site not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /sites/{id}/custom-headers [get]
|
||||
func (h *SiteHandler) GetSiteCustomHeaders(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
|
||||
site, err := h.Repo.GetSite(siteID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Site not found"})
|
||||
}
|
||||
log.Println("Error getting site custom headers: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while listing custom headers"})
|
||||
}
|
||||
|
||||
if site.CustomHeaders == nil {
|
||||
site.CustomHeaders = []models.CustomHeaders{}
|
||||
}
|
||||
return c.JSON(site.CustomHeaders)
|
||||
}
|
||||
|
||||
// GetCustomHeaders godoc
|
||||
//
|
||||
// @Summary Get a custom header group
|
||||
// @Description Returns a single custom header group by ID
|
||||
// @Tags Custom-Headers
|
||||
// @Produce json
|
||||
// @Param id path string true "Custom Headers ID"
|
||||
// @Success 200 {object} models.CustomHeaders "Custom header group"
|
||||
// @Failure 404 {object} models.APIError "Custom headers not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /custom-headers/{id} [get]
|
||||
func (h *SiteHandler) GetCustomHeaders(c fiber.Ctx) error {
|
||||
id := c.Params("customHeaderId")
|
||||
|
||||
customHeaders, err := h.Repo.GetCustomHeaders(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Custom headers not found"})
|
||||
}
|
||||
log.Println("Error getting custom headers: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while getting custom headers"})
|
||||
}
|
||||
|
||||
return c.JSON(customHeaders)
|
||||
}
|
||||
|
||||
// PostCustomHeaders godoc
|
||||
//
|
||||
// @Summary Create a custom header group
|
||||
// @Description Creates a new custom header group for the given site
|
||||
// @Tags Custom-Headers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Site ID"
|
||||
// @Param customHeaders body models.CustomHeaders true "Custom header group to create"
|
||||
// @Success 200 {object} models.CustomHeaders "Created custom header group"
|
||||
// @Failure 400 {object} models.APIError "Invalid request body"
|
||||
// @Failure 404 {object} models.APIError "Site not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /sites/{id}/custom-headers [post]
|
||||
func (h *SiteHandler) PostCustomHeaders(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
|
||||
if _, err := h.Repo.GetSite(siteID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Site not found"})
|
||||
}
|
||||
log.Println("Error checking site before creating custom headers: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while creating custom headers"})
|
||||
}
|
||||
|
||||
var customHeaders models.CustomHeaders
|
||||
if err := c.Bind().Body(&customHeaders); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body"})
|
||||
}
|
||||
|
||||
if err := validateCustomHeaders(&customHeaders); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()})
|
||||
}
|
||||
|
||||
if err := h.Repo.CreateCustomHeaders(siteID, &customHeaders); err != nil {
|
||||
log.Println("Error creating custom headers: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while creating custom headers"})
|
||||
}
|
||||
|
||||
return c.JSON(customHeaders)
|
||||
}
|
||||
|
||||
// PutCustomHeaders godoc
|
||||
//
|
||||
// @Summary Update a custom header group
|
||||
// @Description Replaces an existing custom header group by ID
|
||||
// @Tags Custom-Headers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Custom Headers ID"
|
||||
// @Param customHeaders body models.CustomHeaders true "Updated custom header group"
|
||||
// @Success 200 {object} models.CustomHeaders "Updated custom header group"
|
||||
// @Failure 400 {object} models.APIError "Invalid request body"
|
||||
// @Failure 404 {object} models.APIError "Custom headers not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /custom-headers/{id} [put]
|
||||
func (h *SiteHandler) PutCustomHeaders(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
id := c.Params("customHeaderId")
|
||||
|
||||
if _, err := h.Repo.GetCustomHeaders(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Custom headers not found"})
|
||||
}
|
||||
log.Println("Error checking custom headers before update: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating custom headers"})
|
||||
}
|
||||
|
||||
var customHeaders models.CustomHeaders
|
||||
if err := c.Bind().Body(&customHeaders); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body"})
|
||||
}
|
||||
|
||||
if err := validateCustomHeaders(&customHeaders); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()})
|
||||
}
|
||||
|
||||
customHeaders.ID = id
|
||||
if err := h.Repo.UpdateCustomHeaders(siteID, &customHeaders); err != nil {
|
||||
log.Println("Error updating custom headers: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating custom headers"})
|
||||
}
|
||||
|
||||
updated, err := h.Repo.GetCustomHeaders(id)
|
||||
if err != nil {
|
||||
log.Println("Error getting updated custom headers: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Custom headers were updated but could not be retrieved"})
|
||||
}
|
||||
|
||||
return c.JSON(updated)
|
||||
}
|
||||
|
||||
// DeleteCustomHeaders godoc
|
||||
//
|
||||
// @Summary Delete a custom header group
|
||||
// @Description Deletes a custom header group by ID
|
||||
// @Tags Custom-Headers
|
||||
// @Produce json
|
||||
// @Param id path string true "Custom Headers ID"
|
||||
// @Success 204 "No content"
|
||||
// @Failure 404 {object} models.APIError "Custom headers not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /custom-headers/{id} [delete]
|
||||
func (h *SiteHandler) DeleteCustomHeaders(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
id := c.Params("customHeaderId")
|
||||
|
||||
if _, err := h.Repo.GetCustomHeaders(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Custom headers not found"})
|
||||
}
|
||||
log.Println("Error checking custom headers before delete: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting custom headers"})
|
||||
}
|
||||
|
||||
if err := h.Repo.DeleteCustomHeaders(siteID, id); err != nil {
|
||||
log.Println("Error deleting custom headers: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting custom headers"})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetCustomHeaderHeaders godoc
|
||||
//
|
||||
// @Summary List headers in a custom header group
|
||||
// @Description Returns all individual headers belonging to the given custom header group
|
||||
// @Tags Custom-Headers
|
||||
// @Produce json
|
||||
// @Param id path string true "Custom Headers ID"
|
||||
// @Success 200 {array} models.Header "List of headers"
|
||||
// @Failure 404 {object} models.APIError "Custom headers not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /custom-headers/{id}/headers [get]
|
||||
func (h *SiteHandler) GetCustomHeaderHeaders(c fiber.Ctx) error {
|
||||
customHeaderID := c.Params("customHeaderId")
|
||||
|
||||
customHeaders, err := h.Repo.GetCustomHeaders(customHeaderID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Custom headers not found"})
|
||||
}
|
||||
log.Println("Error listing headers: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while listing headers"})
|
||||
}
|
||||
|
||||
if customHeaders.Headers == nil {
|
||||
customHeaders.Headers = []models.Header{}
|
||||
}
|
||||
return c.JSON(customHeaders.Headers)
|
||||
}
|
||||
|
||||
// GetHeader godoc
|
||||
//
|
||||
// @Summary Get a header
|
||||
// @Description Returns a single header by ID
|
||||
// @Tags Headers
|
||||
// @Produce json
|
||||
// @Param id path string true "Header ID"
|
||||
// @Success 200 {object} models.Header "Header"
|
||||
// @Failure 404 {object} models.APIError "Header not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /headers/{id} [get]
|
||||
func (h *SiteHandler) GetHeader(c fiber.Ctx) error {
|
||||
id := c.Params("headerId")
|
||||
|
||||
header, err := h.Repo.GetHeader(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Header not found"})
|
||||
}
|
||||
log.Println("Error getting header: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while getting header"})
|
||||
}
|
||||
|
||||
return c.JSON(header)
|
||||
}
|
||||
|
||||
// PostHeader godoc
|
||||
//
|
||||
// @Summary Create a header
|
||||
// @Description Creates a new header within the given custom header group
|
||||
// @Tags Headers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Custom Headers ID"
|
||||
// @Param header body models.Header true "Header to create"
|
||||
// @Success 200 {object} models.Header "Created header"
|
||||
// @Failure 400 {object} models.APIError "Invalid request body"
|
||||
// @Failure 404 {object} models.APIError "Custom headers not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /custom-headers/{id}/headers [post]
|
||||
func (h *SiteHandler) PostHeader(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
customHeaderID := c.Params("customHeaderId")
|
||||
|
||||
if _, err := h.Repo.GetCustomHeaders(customHeaderID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Custom headers not found"})
|
||||
}
|
||||
log.Println("Error checking custom headers before creating header: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while creating header"})
|
||||
}
|
||||
|
||||
var header models.Header
|
||||
if err := c.Bind().Body(&header); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body"})
|
||||
}
|
||||
|
||||
if err := validateHeader(&header); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()})
|
||||
}
|
||||
|
||||
if err := h.Repo.CreateHeader(siteID, customHeaderID, &header); err != nil {
|
||||
log.Println("Error creating header: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while creating header"})
|
||||
}
|
||||
|
||||
return c.JSON(header)
|
||||
}
|
||||
|
||||
// PutHeader godoc
|
||||
//
|
||||
// @Summary Update a header
|
||||
// @Description Replaces an existing header by ID
|
||||
// @Tags Headers
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Header ID"
|
||||
// @Param header body models.Header true "Updated header"
|
||||
// @Success 200 {object} models.Header "Updated header"
|
||||
// @Failure 400 {object} models.APIError "Invalid request body"
|
||||
// @Failure 404 {object} models.APIError "Header not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /headers/{id} [put]
|
||||
func (h *SiteHandler) PutHeader(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
id := c.Params("headerId")
|
||||
|
||||
if _, err := h.Repo.GetHeader(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Header not found"})
|
||||
}
|
||||
log.Println("Error checking header before update: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating header"})
|
||||
}
|
||||
|
||||
var header models.Header
|
||||
if err := c.Bind().Body(&header); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body"})
|
||||
}
|
||||
|
||||
if err := validateHeader(&header); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{Message: "Invalid request body: " + err.Error()})
|
||||
}
|
||||
|
||||
header.ID = id
|
||||
if err := h.Repo.UpdateHeader(siteID, &header); err != nil {
|
||||
log.Println("Error updating header: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while updating header"})
|
||||
}
|
||||
|
||||
updated, err := h.Repo.GetHeader(id)
|
||||
if err != nil {
|
||||
log.Println("Error getting updated header: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Header was updated but could not be retrieved"})
|
||||
}
|
||||
|
||||
return c.JSON(updated)
|
||||
}
|
||||
|
||||
// DeleteHeader godoc
|
||||
//
|
||||
// @Summary Delete a header
|
||||
// @Description Deletes a header by ID
|
||||
// @Tags Headers
|
||||
// @Produce json
|
||||
// @Param id path string true "Header ID"
|
||||
// @Success 204 "No content"
|
||||
// @Failure 404 {object} models.APIError "Header not found"
|
||||
// @Failure 500 {object} models.APIError "Internal server error"
|
||||
// @Router /headers/{id} [delete]
|
||||
func (h *SiteHandler) DeleteHeader(c fiber.Ctx) error {
|
||||
siteID := c.Params("id")
|
||||
id := c.Params("headerId")
|
||||
|
||||
if _, err := h.Repo.GetHeader(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "Header not found"})
|
||||
}
|
||||
log.Println("Error checking header before delete: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting header"})
|
||||
}
|
||||
|
||||
if err := h.Repo.DeleteHeader(siteID, id); err != nil {
|
||||
log.Println("Error deleting header: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while deleting header"})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"quay/app/repository"
|
||||
"regexp"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func NewStaticHandler(storagePath string, siteRepo repository.SiteRepository, staticDashboardPath, dashboardHost string) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
domain := c.Hostname()
|
||||
|
||||
if staticDashboardPath != "" && domain == dashboardHost {
|
||||
urlPath := c.Path()
|
||||
filePath := filepath.Join(staticDashboardPath, filepath.Clean(urlPath))
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err == nil && !info.IsDir() {
|
||||
return c.SendFile(filePath)
|
||||
}
|
||||
|
||||
return c.SendFile(filepath.Join(staticDashboardPath, "index.html"))
|
||||
}
|
||||
|
||||
site, err := siteRepo.GetSiteByDomain(domain)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).SendString("Failed to resolve site")
|
||||
}
|
||||
|
||||
if site == nil {
|
||||
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 _, rule := range site.ForwardRules {
|
||||
if rule.Regex {
|
||||
re, err := regexp.Compile(rule.Source)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
match := re.FindStringSubmatchIndex(urlPath)
|
||||
if match != nil {
|
||||
dest := re.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)
|
||||
}
|
||||
}
|
||||
|
||||
for _, customHeader := range site.CustomHeaders {
|
||||
var matched bool
|
||||
if customHeader.Regex {
|
||||
re, err := regexp.Compile(customHeader.Source)
|
||||
if err == nil {
|
||||
matched = re.MatchString(urlPath)
|
||||
}
|
||||
} else {
|
||||
matched, _ = path.Match(customHeader.Source, urlPath)
|
||||
}
|
||||
if matched {
|
||||
for _, header := range customHeader.Headers {
|
||||
c.Set(header.Key, header.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if urlPath == "/" || urlPath == "." {
|
||||
urlPath = "/" + site.IndexFile
|
||||
}
|
||||
|
||||
basePath := filepath.Join(storagePath, site.ID)
|
||||
filePath := filepath.Join(basePath, urlPath)
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err == nil {
|
||||
if info.IsDir() {
|
||||
indexPath := filepath.Join(filePath, site.IndexFile)
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
return c.SendFile(indexPath)
|
||||
}
|
||||
} else {
|
||||
return c.SendFile(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
if site.Spa {
|
||||
indexPath := filepath.Join(basePath, site.IndexFile)
|
||||
if _, err := os.Stat(indexPath); err == nil {
|
||||
return c.SendFile(indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
if site.NotFoundFile != "" {
|
||||
notFoundPath := filepath.Join(basePath, site.NotFoundFile)
|
||||
if _, err := os.Stat(notFoundPath); err == nil {
|
||||
return c.SendFile(notFoundPath)
|
||||
}
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
"quay/internal/security"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
Repo repository.UserRepository
|
||||
}
|
||||
|
||||
func NewUserHandler(repo repository.UserRepository) *UserHandler {
|
||||
return &UserHandler{Repo: repo}
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetAllUsers(c fiber.Ctx) error {
|
||||
users, err := h.Repo.GetAllUsers()
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error getting user: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while getting site",
|
||||
})
|
||||
}
|
||||
|
||||
if users == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
|
||||
for i := range users {
|
||||
users[i].HashedPassword = ""
|
||||
}
|
||||
|
||||
return c.JSON(users)
|
||||
}
|
||||
|
||||
// GetUserById godoc
|
||||
// @Summary Get user by ID
|
||||
// @Description Get a single user by its ID
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) GetUserById(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
user, err := h.Repo.GetUserById(id)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error getting user: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while getting site",
|
||||
})
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
|
||||
user.HashedPassword = ""
|
||||
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
// GetUserByName godoc
|
||||
// @Summary Get user by Name
|
||||
// @Description Get a single user by its Name
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "User Name"
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) GetUserByName(c fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
|
||||
user, err := h.Repo.GetUserByName(name)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error getting user: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while getting site",
|
||||
})
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
|
||||
user.HashedPassword = ""
|
||||
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
// CreateUser godoc
|
||||
// @Summary Create a new user
|
||||
// @Description Create a new user with the provided name, role, and password
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 400 {object} models.APIError
|
||||
// @Failure 409 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) CreateUser(c fiber.Ctx) error {
|
||||
var createUserRequest models.CreateUserRequest
|
||||
|
||||
if err := c.Bind().Body(&createUserRequest); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
if err := models.ValidateCreateUserRequest(&createUserRequest); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||
Message: "Invalid request body: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
hashedPassword, err := security.HashPassword(createUserRequest.Password)
|
||||
if err != nil {
|
||||
log.Println("Error hashing password: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while hashing password",
|
||||
})
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
Name: createUserRequest.Name,
|
||||
Role: createUserRequest.Role,
|
||||
HashedPassword: hashedPassword,
|
||||
CreatedAt: time.Now().UTC().Format(time.DateTime),
|
||||
}
|
||||
|
||||
err = h.Repo.CreateUser(&user)
|
||||
if err != nil {
|
||||
log.Println("Error creating user: ", err)
|
||||
if errors.Is(err, repository.ErrUserAlreadyExists) {
|
||||
return c.Status(fiber.StatusConflict).JSON(&models.APIError{
|
||||
Message: "User name already taken",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while creating user",
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(user)
|
||||
}
|
||||
|
||||
// UpdateUser godoc
|
||||
// @Summary Update an existing user
|
||||
// @Description Update an existing user with the provided name, role, and password
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {object} models.User
|
||||
// @Failure 400 {object} models.APIError
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 409 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) UpdateUser(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var updateUserRequest models.UpdateUserRequest
|
||||
|
||||
if err := c.Bind().Body(&updateUserRequest); err != nil {
|
||||
log.Println("Error parsing body: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
}
|
||||
|
||||
if err := models.ValidateUpdateUserRequest(&updateUserRequest); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(&models.APIError{
|
||||
Message: "Invalid request body: " + err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
ID: id,
|
||||
Name: updateUserRequest.Name,
|
||||
Role: updateUserRequest.Role,
|
||||
}
|
||||
|
||||
err := h.Repo.UpdateUser(&user)
|
||||
if err != nil {
|
||||
log.Println("Error updating user: ", err)
|
||||
if errors.Is(err, repository.ErrUserAlreadyExists) {
|
||||
return c.Status(fiber.StatusConflict).JSON(&models.APIError{
|
||||
Message: "User name already taken",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while updating user",
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteUser godoc
|
||||
// @Summary Delete a user
|
||||
// @Description Delete a user by its ID
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} models.APIError
|
||||
// @Failure 500 {object} models.APIError
|
||||
func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
|
||||
if _, err := h.Repo.GetUserById(id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
log.Println("Error checking user before delete: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while deleting user",
|
||||
})
|
||||
}
|
||||
|
||||
err := h.Repo.DeleteUser(id)
|
||||
if err != nil {
|
||||
log.Println("Error deleting user: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{
|
||||
Message: "Unexpected error while deleting user",
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetMe returns the currently authenticated user's details
|
||||
func (h *UserHandler) GetMe(c fiber.Ctx) error {
|
||||
uid, ok := c.Locals("user_id").(string)
|
||||
if !ok || uid == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(&models.APIError{Message: "Unauthorized"})
|
||||
}
|
||||
|
||||
user, err := h.Repo.GetUserById(uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{Message: "User not found"})
|
||||
}
|
||||
log.Println("Error getting user: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(&models.APIError{Message: "Unexpected error while getting user"})
|
||||
}
|
||||
|
||||
user.HashedPassword = ""
|
||||
return c.JSON(user)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/gofiber/fiber/v3"
|
||||
|
||||
func APIHostGuard(allowedHost string, alternativeHandler fiber.Handler) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
if c.Hostname() != allowedHost {
|
||||
if alternativeHandler != nil {
|
||||
return alternativeHandler(c)
|
||||
}
|
||||
return c.Status(fiber.StatusForbidden).SendString("Forbidden: Invalid API host")
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"quay/internal/security"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func RequireAuth() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
auth := c.Get("Authorization")
|
||||
if auth == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing authorization header"})
|
||||
}
|
||||
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid authorization header"})
|
||||
}
|
||||
|
||||
claims, err := security.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token"})
|
||||
}
|
||||
|
||||
c.Locals("user_id", claims.UserID)
|
||||
c.Locals("role", claims.Role)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
package models
|
||||
|
||||
type APIError struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
type DeploymentStatus string
|
||||
|
||||
const (
|
||||
DeploymentStatusPending DeploymentStatus = "pending"
|
||||
DeploymentStatusRunning DeploymentStatus = "running"
|
||||
DeploymentStatusSuccess DeploymentStatus = "success"
|
||||
DeploymentStatusFailed DeploymentStatus = "failed"
|
||||
)
|
||||
|
||||
func (s DeploymentStatus) IsValid() bool {
|
||||
switch s {
|
||||
case DeploymentStatusPending, DeploymentStatusRunning,
|
||||
DeploymentStatusSuccess, DeploymentStatusFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Deployment struct {
|
||||
Id string `json:"id"`
|
||||
SiteId string `json:"site_id"`
|
||||
CommitHash string `json:"commit_hash"`
|
||||
Message string `json:"message"`
|
||||
Status DeploymentStatus `json:"status"`
|
||||
StartTime string `json:"start_time"`
|
||||
FinishTime string `json:"finish_time"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type GetDeploymentsBySiteResponse struct {
|
||||
Deployments []Deployment `json:"deployments"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
import "errors"
|
||||
|
||||
type GitServer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol"`
|
||||
BaseUrl string `json:"baseUrl"`
|
||||
Type string `json:"type"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func ValidateGitServer(gs *GitServer) error {
|
||||
if gs.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if gs.Protocol == "" {
|
||||
return errors.New("protocol is required")
|
||||
}
|
||||
if gs.Protocol != "http" && gs.Protocol != "https" {
|
||||
return errors.New("protocol must be either 'http' or 'https'")
|
||||
}
|
||||
if gs.BaseUrl == "" {
|
||||
return errors.New("baseUrl is required")
|
||||
}
|
||||
if gs.Type == "" {
|
||||
return errors.New("type is required")
|
||||
}
|
||||
if gs.Type != "github" && gs.Type != "gitlab" && gs.Type != "gitea" {
|
||||
return errors.New("type must be either 'github', 'gitlab' or 'gitea'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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"`
|
||||
Regex bool `json:"regex"`
|
||||
Headers []Header `json:"headers"`
|
||||
}
|
||||
|
||||
type Site struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
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"`
|
||||
IndexFile string `json:"index_file"`
|
||||
TrailingSlash *bool `json:"trailing_slash"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ForwardRules []ForwardRule `json:"forward_rules"`
|
||||
CustomHeaders []CustomHeaders `json:"custom_headers"`
|
||||
}
|
||||
|
||||
type GetAllSitesResponse struct {
|
||||
Sites []Site `json:"sites"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type CreateSiteResponse struct {
|
||||
Site Site `json:"site"`
|
||||
RawDeployToken string `json:"raw_deploy_token"`
|
||||
}
|
||||
|
||||
type ToggleEnabledResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package models
|
||||
|
||||
import "errors"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
HashedPassword string `json:"hashed_password"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func ValidateCreateUserRequest(req *CreateUserRequest) error {
|
||||
if req.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if req.Role == "" {
|
||||
return errors.New("role is required")
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "user" {
|
||||
return errors.New("role must be either 'admin' or 'user'")
|
||||
}
|
||||
if req.Password == "" {
|
||||
return errors.New("password is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func ValidateUpdateUserRequest(req *UpdateUserRequest) error {
|
||||
if req.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if req.Role == "" {
|
||||
return errors.New("role is required")
|
||||
}
|
||||
if req.Role != "admin" && req.Role != "user" {
|
||||
return errors.New("role must be either 'admin' or 'user'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"quay/app/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeploymentRepository interface {
|
||||
GetDeploymentByID(id string) (*models.Deployment, error)
|
||||
GetDeploymentsForSite(deploymentId string, limit int) ([]models.Deployment, error)
|
||||
CreateDeployment(deployment *models.Deployment) (string, error)
|
||||
UpdateDeployment(deployment *models.Deployment) error
|
||||
UpdateDeploymentStatus(deploymentId string, status models.DeploymentStatus) error
|
||||
UpdateDeploymentFinishTime(deploymentId string, finishTime time.Time) error
|
||||
UpdateDeploymentGitInfo(deploymentId, commitHash, message string) error
|
||||
DeleteDeployment(id string) error
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"quay/app/models"
|
||||
)
|
||||
|
||||
var ErrGitServerAlreadyExists = errors.New("gitserver already exists")
|
||||
|
||||
type GitServerRepository interface {
|
||||
ListGitServers() ([]models.GitServer, error)
|
||||
GetGitServer(id string) (*models.GitServer, error)
|
||||
CreateGitServer(gs *models.GitServer) error
|
||||
UpdateGitServer(gs *models.GitServer) error
|
||||
DeleteGitServer(id string) error
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package repository
|
||||
|
||||
import "quay/app/models"
|
||||
|
||||
type SiteRepository interface {
|
||||
GetSite(id string) (*models.Site, error)
|
||||
GetSiteByDomain(domain string) (*models.Site, error)
|
||||
ListSites() ([]models.Site, error)
|
||||
CreateSite(s *models.Site) error
|
||||
UpdateSite(s *models.Site) error
|
||||
ToggleEnabled(id string) (enabled bool, err error)
|
||||
DeleteSite(id string) error
|
||||
GetForwardRule(id string) (*models.ForwardRule, error)
|
||||
CreateForwardRule(siteID string, fr *models.ForwardRule) error
|
||||
UpdateForwardRule(siteID string, fr *models.ForwardRule) error
|
||||
DeleteForwardRule(siteID string, id string) error
|
||||
GetCustomHeaders(id string) (*models.CustomHeaders, error)
|
||||
CreateCustomHeaders(siteID string, ch *models.CustomHeaders) error
|
||||
UpdateCustomHeaders(siteID string, ch *models.CustomHeaders) error
|
||||
DeleteCustomHeaders(siteID string, id string) error
|
||||
GetHeader(id string) (*models.Header, error)
|
||||
CreateHeader(siteID string, customHeaderID string, h *models.Header) error
|
||||
UpdateHeader(siteID string, h *models.Header) error
|
||||
DeleteHeader(siteID string, id string) error
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"quay/app/models"
|
||||
)
|
||||
|
||||
var ErrUserAlreadyExists = errors.New("user already exists")
|
||||
|
||||
type UserRepository interface {
|
||||
GetAllUsers() ([]models.User, error)
|
||||
GetUserById(id string) (*models.User, error)
|
||||
GetUserByName(name string) (*models.User, error)
|
||||
CreateUser(user *models.User) error
|
||||
UpdateUser(user *models.User) error
|
||||
DeleteUser(id string) error
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"quay/app/handlers"
|
||||
"quay/app/middleware"
|
||||
"quay/app/models"
|
||||
"quay/internal/database"
|
||||
"quay/internal/envconfig"
|
||||
"quay/internal/security"
|
||||
|
||||
"github.com/Flussen/swagger-fiber-v3"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
)
|
||||
|
||||
const BootstrapUserUsername = "admin"
|
||||
const BootstrapUserPassword = "admin"
|
||||
|
||||
func Register(app *fiber.App, envCfg *envconfig.EnvConfig, db *sql.DB) {
|
||||
siteRepository := database.NewSQLiteSiteRepository(db)
|
||||
deploymentRepository := database.NewSQLiteDeploymentRepository(db)
|
||||
userRepository := database.NewSQLiteUserRepository(db)
|
||||
gitServerRepository := database.NewSQLiteGitServerRepository(db)
|
||||
|
||||
if uList, err := userRepository.GetAllUsers(); err != nil {
|
||||
log.Printf("Warning checking users: %v", err)
|
||||
} else if len(uList) == 0 {
|
||||
pwd := BootstrapUserUsername
|
||||
hashedPassword, err := security.HashPassword(pwd)
|
||||
if err != nil {
|
||||
log.Println("Error hashing default user password: ", err)
|
||||
}
|
||||
defaultUser := models.User{
|
||||
Name: BootstrapUserPassword,
|
||||
HashedPassword: hashedPassword,
|
||||
Role: "admin",
|
||||
}
|
||||
if err := userRepository.CreateUser(&defaultUser); err != nil {
|
||||
log.Printf("Warning creating default user: %v", err)
|
||||
} else {
|
||||
log.Printf("Created default user: username '%s' with password '%s'", BootstrapUserUsername, pwd)
|
||||
log.Printf("Please log in and change the default password immediately")
|
||||
}
|
||||
}
|
||||
|
||||
storagePath, err := filepath.Abs(envCfg.StoragePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to resolve storage path: %v", err)
|
||||
}
|
||||
|
||||
siteHandler := handlers.NewSiteHandler(siteRepository)
|
||||
deploySiteHandler := handlers.NewDeploySiteHandler(envCfg, siteRepository, gitServerRepository, deploymentRepository)
|
||||
userHandler := handlers.NewUserHandler(userRepository)
|
||||
gitServerHandler := handlers.NewGitServerHandler(gitServerRepository)
|
||||
deploymentsHandler := handlers.NewDeploymentHandler(deploymentRepository)
|
||||
staticSiteHandler := handlers.NewStaticHandler(storagePath, siteRepository, envCfg.StaticDashboardPath, envCfg.DashboardHost)
|
||||
|
||||
api := app.Group("/api/v1", middleware.APIHostGuard(envCfg.DashboardHost, staticSiteHandler))
|
||||
|
||||
public := api.Group("")
|
||||
public.Get("/health", handlers.HealthCheck)
|
||||
|
||||
authHandler := handlers.NewAuthHandler(userRepository)
|
||||
public.Post("/login", authHandler.Login)
|
||||
|
||||
public.Post("/deploy", deploySiteHandler.PostDeploy)
|
||||
|
||||
public.Get("/docs.json", static.New("./docs/swagger.json"))
|
||||
|
||||
public.Get("/swagger/*", swagger.New(swagger.Config{
|
||||
URL: "/api/v1/docs.json",
|
||||
Title: "Quay API Documentation",
|
||||
}))
|
||||
|
||||
// Protected routes - require auth for everything by default
|
||||
protected := api.Group("", middleware.RequireAuth())
|
||||
|
||||
// Sites
|
||||
protected.Get("/sites", siteHandler.GetSites)
|
||||
protected.Get("/sites/:id", siteHandler.GetSite)
|
||||
protected.Post("/sites", siteHandler.PostSite)
|
||||
protected.Put("/sites/:id", siteHandler.PutSite)
|
||||
protected.Delete("/sites/:id", siteHandler.DeleteSite)
|
||||
protected.Patch("/sites/:id/enabled", siteHandler.ToggleEnabled)
|
||||
|
||||
// Git servers
|
||||
protected.Get("/gitservers", gitServerHandler.GetGitServers)
|
||||
protected.Get("/gitservers/:id", gitServerHandler.GetGitServer)
|
||||
protected.Post("/gitservers", gitServerHandler.PostGitServer)
|
||||
protected.Put("/gitservers/:id", gitServerHandler.PutGitServer)
|
||||
protected.Delete("/gitservers/:id", gitServerHandler.DeleteGitServer)
|
||||
|
||||
// Forward rules
|
||||
protected.Get("/sites/:id/forward-rules", siteHandler.GetSiteForwardRules)
|
||||
protected.Post("/sites/:id/forward-rules", siteHandler.PostForwardRule)
|
||||
protected.Get("/sites/:id/forward-rules/:ruleId", siteHandler.GetForwardRule)
|
||||
protected.Put("/sites/:id/forward-rules/:ruleId", siteHandler.PutForwardRule)
|
||||
protected.Delete("/sites/:id/forward-rules/:ruleId", siteHandler.DeleteForwardRule)
|
||||
|
||||
// Custom headers (header rules)
|
||||
protected.Get("/sites/:id/custom-headers", siteHandler.GetSiteCustomHeaders)
|
||||
protected.Post("/sites/:id/custom-headers", siteHandler.PostCustomHeaders)
|
||||
protected.Get("/sites/:id/custom-headers/:customHeaderId", siteHandler.GetCustomHeaders)
|
||||
protected.Put("/sites/:id/custom-headers/:customHeaderId", siteHandler.PutCustomHeaders)
|
||||
protected.Delete("/sites/:id/custom-headers/:customHeaderId", siteHandler.DeleteCustomHeaders)
|
||||
|
||||
// Headers
|
||||
protected.Get("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.GetCustomHeaderHeaders)
|
||||
protected.Post("/sites/:id/custom-headers/:customHeaderId/headers", siteHandler.PostHeader)
|
||||
protected.Get("/sites/:id/headers/:headerId", siteHandler.GetHeader)
|
||||
protected.Put("/sites/:id/headers/:headerId", siteHandler.PutHeader)
|
||||
protected.Delete("/sites/:id/headers/:headerId", siteHandler.DeleteHeader)
|
||||
|
||||
// Deployments
|
||||
protected.Get("/deployments/:id", deploymentsHandler.GetDeployment)
|
||||
protected.Get("/sites/:id/deployments", deploymentsHandler.GetDeploymentsBySite)
|
||||
|
||||
// Users
|
||||
protected.Get("/users", userHandler.GetAllUsers)
|
||||
protected.Get("/users/:id", userHandler.GetUserById)
|
||||
protected.Get("/users/by-name/:name", userHandler.GetUserByName)
|
||||
protected.Get("/me", userHandler.GetMe)
|
||||
protected.Post("/users", userHandler.CreateUser)
|
||||
protected.Put("/users/:id", userHandler.UpdateUser)
|
||||
protected.Delete("/users/:id", userHandler.DeleteUser)
|
||||
|
||||
api.Use(func(c fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusNotFound).JSON(&models.APIError{
|
||||
Message: "Endpoint not found",
|
||||
})
|
||||
})
|
||||
|
||||
app.Use(staticSiteHandler)
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
basePath: /api/v1
|
||||
definitions:
|
||||
models.APIError:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
models.CreateSiteResponse:
|
||||
properties:
|
||||
raw_deploy_token:
|
||||
type: string
|
||||
site:
|
||||
$ref: '#/definitions/models.Site'
|
||||
type: object
|
||||
models.CustomHeaders:
|
||||
properties:
|
||||
headers:
|
||||
items:
|
||||
$ref: '#/definitions/models.Header'
|
||||
type: array
|
||||
id:
|
||||
type: string
|
||||
regex:
|
||||
type: boolean
|
||||
source:
|
||||
type: string
|
||||
type: object
|
||||
models.Deployment:
|
||||
properties:
|
||||
commit_hash:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
finish_time:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
site_id:
|
||||
type: string
|
||||
start_time:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/definitions/models.DeploymentStatus'
|
||||
type: object
|
||||
models.DeploymentStatus:
|
||||
enum:
|
||||
- pending
|
||||
- running
|
||||
- success
|
||||
- failed
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- DeploymentStatusPending
|
||||
- DeploymentStatusRunning
|
||||
- DeploymentStatusSuccess
|
||||
- DeploymentStatusFailed
|
||||
models.ForwardRule:
|
||||
properties:
|
||||
destination:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
regex:
|
||||
type: boolean
|
||||
source:
|
||||
type: string
|
||||
status_code:
|
||||
type: integer
|
||||
type: object
|
||||
models.GetAllSitesResponse:
|
||||
properties:
|
||||
sites:
|
||||
items:
|
||||
$ref: '#/definitions/models.Site'
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
type: object
|
||||
models.GetDeploymentsBySiteResponse:
|
||||
properties:
|
||||
deployments:
|
||||
items:
|
||||
$ref: '#/definitions/models.Deployment'
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
type: object
|
||||
models.Header:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
key:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
models.Site:
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
custom_headers:
|
||||
items:
|
||||
$ref: '#/definitions/models.CustomHeaders'
|
||||
type: array
|
||||
deploy_token:
|
||||
type: string
|
||||
domain:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
forward_rules:
|
||||
items:
|
||||
$ref: '#/definitions/models.ForwardRule'
|
||||
type: array
|
||||
git_server:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
index_file:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
not_found_file:
|
||||
type: string
|
||||
owner:
|
||||
type: string
|
||||
repository:
|
||||
type: string
|
||||
spa:
|
||||
type: boolean
|
||||
trailing_slash:
|
||||
type: boolean
|
||||
type: object
|
||||
models.ToggleEnabledResponse:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
type: object
|
||||
host: localhost:4321
|
||||
info:
|
||||
contact: {}
|
||||
description: Self-hosted static site deployment service
|
||||
title: Quay API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/custom-headers/{id}:
|
||||
delete:
|
||||
description: Deletes a custom header group by ID
|
||||
parameters:
|
||||
- description: Custom Headers ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: No content
|
||||
"404":
|
||||
description: Custom headers not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Delete a custom header group
|
||||
tags:
|
||||
- Custom-Headers
|
||||
get:
|
||||
description: Returns a single custom header group by ID
|
||||
parameters:
|
||||
- description: Custom Headers ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Custom header group
|
||||
schema:
|
||||
$ref: '#/definitions/models.CustomHeaders'
|
||||
"404":
|
||||
description: Custom headers not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Get a custom header group
|
||||
tags:
|
||||
- Custom-Headers
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Replaces an existing custom header group by ID
|
||||
parameters:
|
||||
- description: Custom Headers ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Updated custom header group
|
||||
in: body
|
||||
name: customHeaders
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.CustomHeaders'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Updated custom header group
|
||||
schema:
|
||||
$ref: '#/definitions/models.CustomHeaders'
|
||||
"400":
|
||||
description: Invalid request body
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"404":
|
||||
description: Custom headers not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Update a custom header group
|
||||
tags:
|
||||
- Custom-Headers
|
||||
/custom-headers/{id}/headers:
|
||||
get:
|
||||
description: Returns all individual headers belonging to the given custom header
|
||||
group
|
||||
parameters:
|
||||
- description: Custom Headers ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of headers
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.Header'
|
||||
type: array
|
||||
"404":
|
||||
description: Custom headers not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: List headers in a custom header group
|
||||
tags:
|
||||
- Custom-Headers
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Creates a new header within the given custom header group
|
||||
parameters:
|
||||
- description: Custom Headers ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Header to create
|
||||
in: body
|
||||
name: header
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Header'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Created header
|
||||
schema:
|
||||
$ref: '#/definitions/models.Header'
|
||||
"400":
|
||||
description: Invalid request body
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"404":
|
||||
description: Custom headers not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Create a header
|
||||
tags:
|
||||
- Headers
|
||||
/deployments/{id}:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get a single deployment by its ID
|
||||
parameters:
|
||||
- description: Deployment ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Deployment'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Get deployment by ID
|
||||
tags:
|
||||
- Deployments
|
||||
/forward-rules/{id}:
|
||||
delete:
|
||||
description: Deletes a forward rule by ID
|
||||
parameters:
|
||||
- description: Forward Rule ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: No content
|
||||
"404":
|
||||
description: Forward rule not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Delete a forward rule
|
||||
tags:
|
||||
- Forward-Rules
|
||||
get:
|
||||
description: Returns a single forward rule by ID
|
||||
parameters:
|
||||
- description: Forward Rule ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Forward rule
|
||||
schema:
|
||||
$ref: '#/definitions/models.ForwardRule'
|
||||
"404":
|
||||
description: Forward rule not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Get a forward rule
|
||||
tags:
|
||||
- Forward-Rules
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Replaces an existing forward rule by ID
|
||||
parameters:
|
||||
- description: Forward Rule ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Updated forward rule
|
||||
in: body
|
||||
name: rule
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.ForwardRule'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Updated forward rule
|
||||
schema:
|
||||
$ref: '#/definitions/models.ForwardRule'
|
||||
"400":
|
||||
description: Invalid request body
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"404":
|
||||
description: Forward rule not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Update a forward rule
|
||||
tags:
|
||||
- Forward-Rules
|
||||
/headers/{id}:
|
||||
delete:
|
||||
description: Deletes a header by ID
|
||||
parameters:
|
||||
- description: Header ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: No content
|
||||
"404":
|
||||
description: Header not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Delete a header
|
||||
tags:
|
||||
- Headers
|
||||
get:
|
||||
description: Returns a single header by ID
|
||||
parameters:
|
||||
- description: Header ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Header
|
||||
schema:
|
||||
$ref: '#/definitions/models.Header'
|
||||
"404":
|
||||
description: Header not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Get a header
|
||||
tags:
|
||||
- Headers
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Replaces an existing header by ID
|
||||
parameters:
|
||||
- description: Header ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Updated header
|
||||
in: body
|
||||
name: header
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Header'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Updated header
|
||||
schema:
|
||||
$ref: '#/definitions/models.Header'
|
||||
"400":
|
||||
description: Invalid request body
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"404":
|
||||
description: Header not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Update a header
|
||||
tags:
|
||||
- Headers
|
||||
/sites:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get a list of all sites
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.GetAllSitesResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Get all sites
|
||||
tags:
|
||||
- Sites
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a new site with the provided details
|
||||
parameters:
|
||||
- description: Site details
|
||||
in: body
|
||||
name: site
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Site'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.CreateSiteResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Create a new site
|
||||
tags:
|
||||
- Sites
|
||||
/sites/{id}:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Delete a site by its ID
|
||||
parameters:
|
||||
- description: Site ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Delete a site
|
||||
tags:
|
||||
- Sites
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get a single site by its ID
|
||||
parameters:
|
||||
- description: Site ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Site'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Get site by ID
|
||||
tags:
|
||||
- Sites
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Update an existing site by its ID
|
||||
parameters:
|
||||
- description: Site ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Updated site details
|
||||
in: body
|
||||
name: site
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Site'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Site'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Update an existing site
|
||||
tags:
|
||||
- Sites
|
||||
/sites/{id}/custom-headers:
|
||||
get:
|
||||
description: Returns all custom header groups associated with the given site
|
||||
parameters:
|
||||
- description: Site ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of custom header groups
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.CustomHeaders'
|
||||
type: array
|
||||
"404":
|
||||
description: Site not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: List custom header groups for a site
|
||||
tags:
|
||||
- Custom-Headers
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Creates a new custom header group for the given site
|
||||
parameters:
|
||||
- description: Site ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Custom header group to create
|
||||
in: body
|
||||
name: customHeaders
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.CustomHeaders'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Created custom header group
|
||||
schema:
|
||||
$ref: '#/definitions/models.CustomHeaders'
|
||||
"400":
|
||||
description: Invalid request body
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"404":
|
||||
description: Site not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Create a custom header group
|
||||
tags:
|
||||
- Custom-Headers
|
||||
/sites/{id}/enabled:
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Enable or disable a site by its ID
|
||||
parameters:
|
||||
- description: Site ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.ToggleEnabledResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Toggle site enabled status
|
||||
tags:
|
||||
- Sites
|
||||
/sites/{id}/forward-rules:
|
||||
get:
|
||||
description: Returns all forward rules associated with the given site
|
||||
parameters:
|
||||
- description: Site ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of forward rules
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/models.ForwardRule'
|
||||
type: array
|
||||
"404":
|
||||
description: Site not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: List forward rules for a site
|
||||
tags:
|
||||
- Forward-Rules
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Creates a new forward rule for the given site
|
||||
parameters:
|
||||
- description: Site ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Forward rule to create
|
||||
in: body
|
||||
name: rule
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.ForwardRule'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Created forward rule
|
||||
schema:
|
||||
$ref: '#/definitions/models.ForwardRule'
|
||||
"400":
|
||||
description: Invalid request body
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"404":
|
||||
description: Site not found
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Create a forward rule
|
||||
tags:
|
||||
- Forward-Rules
|
||||
/sites/{siteId}/deployments:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get a list of deployments for a specific site
|
||||
parameters:
|
||||
- description: Site ID
|
||||
in: path
|
||||
name: siteId
|
||||
required: true
|
||||
type: string
|
||||
- default: 100
|
||||
description: Maximum number of deployments to return
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.GetDeploymentsBySiteResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIError'
|
||||
summary: Get deployments for a site
|
||||
tags:
|
||||
- Deployments
|
||||
swagger: "2.0"
|
||||
tags:
|
||||
- description: Manage sites
|
||||
name: Sites
|
||||
- description: Manage redirect rules for a site
|
||||
name: Forward-Rules
|
||||
- description: Manage custom header groups for a site
|
||||
name: Custom-Headers
|
||||
- description: Manage individual headers within a custom header group
|
||||
name: Headers
|
||||
@@ -0,0 +1,52 @@
|
||||
module quay
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Flussen/swagger-fiber-v3 v1.0.1
|
||||
github.com/gofiber/fiber/v3 v3.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/maypok86/otter/v2 v2.3.0
|
||||
github.com/swaggo/swag v1.16.4
|
||||
golang.org/x/crypto v0.48.0
|
||||
modernc.org/sqlite v1.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/gofiber/schema v1.7.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.2 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
github.com/Flussen/swagger-fiber-v3 v1.0.1 h1:lgR2+ADJRx7Kh4oGidsf790UVwXrgC4I7p/3SAmHimw=
|
||||
github.com/Flussen/swagger-fiber-v3 v1.0.1/go.mod h1:rHViWTgpklVFVsYkWgL8zip4QHJlKwuBax8wY0G3sPw=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
|
||||
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
|
||||
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
|
||||
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
|
||||
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
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/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
|
||||
github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,149 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SQLiteDeploymentRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteDeploymentRepository(db *sql.DB) *SQLiteDeploymentRepository {
|
||||
return &SQLiteDeploymentRepository{db: db}
|
||||
}
|
||||
|
||||
var _ repository.DeploymentRepository = (*SQLiteDeploymentRepository)(nil)
|
||||
|
||||
func (r *SQLiteDeploymentRepository) GetDeploymentByID(id string) (*models.Deployment, error) {
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, site_id, commit_hash, message, status, start_time, finish_time, created_at
|
||||
FROM deployments WHERE id = ?`, id)
|
||||
|
||||
d, err := scanDeployment(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get deployment: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteDeploymentRepository) GetDeploymentsForSite(siteId string, limit int) ([]models.Deployment, error) {
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, site_id, commit_hash, message, status, start_time, finish_time, created_at
|
||||
FROM deployments WHERE site_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`, siteId, limit)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get deployments: %w", err)
|
||||
}
|
||||
|
||||
var deployments []models.Deployment
|
||||
for rows.Next() {
|
||||
d, err := scanDeployment(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan deployment: %w", err)
|
||||
}
|
||||
deployments = append(deployments, *d)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate deployments: %w", err)
|
||||
}
|
||||
|
||||
return deployments, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteDeploymentRepository) CreateDeployment(d *models.Deployment) (string, error) {
|
||||
d.Id = uuid.NewString()
|
||||
if d.Status == "" || !d.Status.IsValid() {
|
||||
d.Status = models.DeploymentStatusPending
|
||||
}
|
||||
_, err := r.db.Exec(`
|
||||
INSERT INTO deployments (id, site_id, commit_hash, message, status, start_time, finish_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
d.Id, d.SiteId, d.CommitHash, d.Message, d.Status, d.StartTime, d.FinishTime,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create deployment: %w", err)
|
||||
}
|
||||
return d.Id, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteDeploymentRepository) UpdateDeployment(d *models.Deployment) error {
|
||||
_, err := r.db.Exec(`
|
||||
UPDATE deployments SET site_id=?, commit_hash=?, message=?, status=?, start_time=?, finish_time=?
|
||||
WHERE id=?`,
|
||||
d.SiteId, d.CommitHash, d.Message, d.Status, d.StartTime, d.FinishTime, d.Id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update deployment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteDeploymentRepository) UpdateDeploymentStatus(deploymentId string, status models.DeploymentStatus) error {
|
||||
if !status.IsValid() {
|
||||
return fmt.Errorf("invalid deployment status")
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(`
|
||||
UPDATE deployments SET status=? WHERE id=?`,
|
||||
status, deploymentId,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update deployment status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteDeploymentRepository) UpdateDeploymentFinishTime(deploymentId string, finishTime time.Time) error {
|
||||
_, err := r.db.Exec(`
|
||||
UPDATE deployments SET finish_time=? WHERE id=?`,
|
||||
finishTime.Format(time.DateTime), deploymentId,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update deployment finish time: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteDeploymentRepository) UpdateDeploymentGitInfo(deploymentId, commitHash, message string) error {
|
||||
_, err := r.db.Exec(`
|
||||
UPDATE deployments SET commit_hash=?, message=? WHERE id=?`,
|
||||
commitHash, message, deploymentId,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update deployment git info: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteDeploymentRepository) DeleteDeployment(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM deployments WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete deployment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanDeployment(s scanner) (*models.Deployment, error) {
|
||||
var d models.Deployment
|
||||
err := s.Scan(
|
||||
&d.Id, &d.SiteId, &d.CommitHash, &d.Message,
|
||||
&d.Status, &d.StartTime, &d.FinishTime, &d.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SQLiteGitServerRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteGitServerRepository(db *sql.DB) *SQLiteGitServerRepository {
|
||||
return &SQLiteGitServerRepository{db: db}
|
||||
}
|
||||
|
||||
var _ repository.GitServerRepository = (*SQLiteGitServerRepository)(nil)
|
||||
|
||||
func (r *SQLiteGitServerRepository) ListGitServers() ([]models.GitServer, error) {
|
||||
rows, err := r.db.Query(`SELECT id, name, protocol, base_url, type, auth_token, created_at FROM gitservers`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list gitservers: %w", err)
|
||||
}
|
||||
|
||||
var out []models.GitServer
|
||||
for rows.Next() {
|
||||
gs, err := scanGitServer(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("list gitservers scan: %w", err)
|
||||
}
|
||||
out = append(out, *gs)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteGitServerRepository) GetGitServer(id string) (*models.GitServer, error) {
|
||||
row := r.db.QueryRow(`SELECT id, name, protocol, base_url, type, auth_token, created_at FROM gitservers WHERE id = ?`, id)
|
||||
return scanGitServer(row)
|
||||
}
|
||||
|
||||
func (r *SQLiteGitServerRepository) CreateGitServer(gs *models.GitServer) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create gitserver begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
gs.ID = uuid.NewString()
|
||||
|
||||
_, err = tx.Exec(`INSERT INTO gitservers (id, name, protocol, base_url, type, auth_token) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
gs.ID, gs.Name, gs.Protocol, gs.BaseUrl, gs.Type, gs.AuthToken)
|
||||
if err != nil {
|
||||
if isSQLiteUniqueConstraintError(err) {
|
||||
return fmt.Errorf("create gitserver insert: %w", repository.ErrGitServerAlreadyExists)
|
||||
}
|
||||
return fmt.Errorf("create gitserver insert: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *SQLiteGitServerRepository) UpdateGitServer(gs *models.GitServer) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update gitserver begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`UPDATE gitservers SET name = ?, protocol = ?, base_url = ?, type = ?, auth_token = ? WHERE id = ?`,
|
||||
gs.Name, gs.Protocol, gs.BaseUrl, gs.Type, gs.AuthToken, gs.ID)
|
||||
if err != nil {
|
||||
if isSQLiteUniqueConstraintError(err) {
|
||||
return fmt.Errorf("update gitserver: %w", repository.ErrGitServerAlreadyExists)
|
||||
}
|
||||
return fmt.Errorf("update gitserver: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *SQLiteGitServerRepository) DeleteGitServer(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM gitservers WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete gitserver: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanGitServer(s scanner) (*models.GitServer, error) {
|
||||
g := new(models.GitServer)
|
||||
err := s.Scan(&g.ID, &g.Name, &g.Protocol, &g.BaseUrl, &g.Type, &g.AuthToken, &g.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
name TEXT NOT NULL,
|
||||
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',
|
||||
index_file TEXT NOT NULL DEFAULT 'index.html',
|
||||
trailing_slash INTEGER DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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,
|
||||
regex INTEGER NOT NULL DEFAULT 0,
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deployments (
|
||||
id TEXT PRIMARY KEY,
|
||||
site_id TEXT NOT NULL,
|
||||
commit_hash TEXT,
|
||||
message TEXT,
|
||||
status TEXT NOT NULL,
|
||||
start_time TIMESTAMP NOT NULL DEFAULT 0,
|
||||
finish_time TIMESTAMP NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL,
|
||||
hashed_password TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gitservers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
protocol TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
auth_token TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
|
||||
if err == nil {
|
||||
log.Println("Database initialized successfully")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
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, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file, index_file, trailing_slash, created_at
|
||||
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) GetSiteByDomain(domain string) (*models.Site, error) {
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file, index_file, trailing_slash, created_at
|
||||
FROM sites WHERE domain = ?`, domain)
|
||||
|
||||
s, err := scanSite(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get site by domain: %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, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file, index_file, trailing_slash, created_at
|
||||
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, name, git_server, owner, repository, branch, domain, deploy_token, spa, enabled, not_found_file, index_file, trailing_slash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.ID, s.Name, s.GitServer, s.Owner, s.Repository, s.Branch,
|
||||
s.Domain, s.DeployToken, s.Spa, s.Enabled, s.NotFoundFile, s.IndexFile, s.TrailingSlash,
|
||||
)
|
||||
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 name=?, git_server=?, owner=?, repository=?, branch=?, domain=?,
|
||||
deploy_token=?, spa=?, enabled=?, not_found_file=?, index_file=?, trailing_slash=? WHERE id=?`,
|
||||
s.Name, s.GitServer, s.Owner, s.Repository, s.Branch, s.Domain,
|
||||
s.DeployToken, s.Spa, s.Enabled, s.NotFoundFile, s.IndexFile, s.TrailingSlash,
|
||||
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) ToggleEnabled(id string) (enabledReturn bool, err error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("toggle enabled begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var enabled int
|
||||
err = tx.QueryRow(`SELECT enabled FROM sites WHERE id = ?`, id).Scan(&enabled)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("toggle enabled select: %w", err)
|
||||
}
|
||||
|
||||
newEnabled := 0
|
||||
if enabled == 0 {
|
||||
newEnabled = 1
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`UPDATE sites SET enabled = ? WHERE id = ?`, newEnabled, id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("toggle enabled update: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, fmt.Errorf("toggle enabled commit: %w", err)
|
||||
}
|
||||
return newEnabled != 0, nil
|
||||
}
|
||||
|
||||
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(siteID string, 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(_ string, 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, regex FROM custom_headers WHERE id = ?`, id)
|
||||
|
||||
var ch models.CustomHeaders
|
||||
var regex int
|
||||
if err := row.Scan(&ch.ID, &ch.Source, ®ex); 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
|
||||
ch.Regex = regex != 0
|
||||
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(siteID string, 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=?, regex=? WHERE id=?`, ch.Source, ch.Regex, 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(_ string, 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
|
||||
}
|
||||
|
||||
// Headers
|
||||
|
||||
func (r *SQLiteSiteRepository) GetHeader(id string) (*models.Header, error) {
|
||||
row := r.db.QueryRow(`SELECT id, key, value FROM headers WHERE id = ?`, id)
|
||||
|
||||
var h models.Header
|
||||
if err := row.Scan(&h.ID, &h.Key, &h.Value); err != nil {
|
||||
return nil, fmt.Errorf("get header: %w", err)
|
||||
}
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteSiteRepository) CreateHeader(_ string, customHeaderID string, h *models.Header) error {
|
||||
h.ID = uuid.NewString()
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO headers (id, custom_header_id, key, value) VALUES (?, ?, ?, ?)`,
|
||||
h.ID, customHeaderID, h.Key, h.Value,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create header: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteSiteRepository) UpdateHeader(_ string, h *models.Header) error {
|
||||
_, err := r.db.Exec(`UPDATE headers SET key=?, value=? WHERE id=?`, h.Key, h.Value, h.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update header: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SQLiteSiteRepository) DeleteHeader(_ string, id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM headers WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete header: %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.Name, &site.GitServer, &site.Owner, &site.Repository,
|
||||
&site.Branch, &site.Domain, &site.DeployToken, &site.Spa, &enabled, &site.NotFoundFile,
|
||||
&site.IndexFile, &site.TrailingSlash, &site.CreatedAt,
|
||||
)
|
||||
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, regex 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
|
||||
var regex int
|
||||
if err := rows.Scan(&ch.ID, &ch.Source, ®ex); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("list custom headers scan: %w", err)
|
||||
}
|
||||
ch.Regex = regex != 0
|
||||
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, regex) VALUES (?, ?, ?, ?)`,
|
||||
ch.ID, siteID, ch.Source, ch.Regex,
|
||||
)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func ConnectSQLite(dbPath string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", 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
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"quay/app/models"
|
||||
"quay/app/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"modernc.org/sqlite"
|
||||
sqlite3 "modernc.org/sqlite/lib"
|
||||
)
|
||||
|
||||
type SQLiteUserRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteUserRepository(db *sql.DB) *SQLiteUserRepository {
|
||||
return &SQLiteUserRepository{db: db}
|
||||
}
|
||||
|
||||
var _ repository.UserRepository = (*SQLiteUserRepository)(nil)
|
||||
|
||||
func (r *SQLiteUserRepository) GetAllUsers() ([]models.User, error) {
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, name, role, hashed_password, created_at
|
||||
FROM users`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
|
||||
var users []models.User
|
||||
for rows.Next() {
|
||||
s, err := scanUser(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("list users scan: %w", err)
|
||||
}
|
||||
users = append(users, *s)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) GetUserById(id string) (*models.User, error) {
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, name, role, hashed_password, created_at
|
||||
FROM users WHERE id = ?`, id)
|
||||
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user by id: %w", err)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) GetUserByName(name string) (*models.User, error) {
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, name, role, hashed_password, created_at
|
||||
FROM users WHERE name = ?`, name)
|
||||
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user by name: %w", err)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) CreateUser(user *models.User) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create user begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
user.ID = uuid.NewString()
|
||||
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO users (id, name, role, hashed_password, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
user.ID, user.Name, user.Role, user.HashedPassword, user.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if isSQLiteUniqueConstraintError(err) {
|
||||
return fmt.Errorf("create user insert: %w", repository.ErrUserAlreadyExists)
|
||||
}
|
||||
return fmt.Errorf("create user insert: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) UpdateUser(user *models.User) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update user begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`
|
||||
UPDATE users SET name=?, role=? WHERE id=?`,
|
||||
user.Name, user.Role, user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
if isSQLiteUniqueConstraintError(err) {
|
||||
return fmt.Errorf("update user: %w", repository.ErrUserAlreadyExists)
|
||||
}
|
||||
return fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) DeleteUser(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanUser(s scanner) (*models.User, error) {
|
||||
u := new(models.User)
|
||||
err := s.Scan(
|
||||
&u.ID,
|
||||
&u.Name,
|
||||
&u.Role,
|
||||
&u.HashedPassword,
|
||||
&u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *SQLiteUserRepository) AdminUserExists() (bool, error) {
|
||||
var count int
|
||||
err := r.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin user exists: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func isSQLiteUniqueConstraintError(err error) bool {
|
||||
var sqliteErr *sqlite.Error
|
||||
if !errors.As(err, &sqliteErr) {
|
||||
return false
|
||||
}
|
||||
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package envconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
type EnvConfig struct {
|
||||
Port string
|
||||
ConfigDir string
|
||||
GithubPat string
|
||||
StoragePath string
|
||||
DashboardHost string
|
||||
StaticDashboardPath string
|
||||
}
|
||||
|
||||
func Load() EnvConfig {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "4321"
|
||||
}
|
||||
|
||||
configDir := os.Getenv("CONFIG_DIR")
|
||||
if configDir == "" {
|
||||
configDir = "./"
|
||||
}
|
||||
|
||||
githubPat := os.Getenv("GITHUB_PAT")
|
||||
if githubPat == "" {
|
||||
githubPat = ""
|
||||
}
|
||||
|
||||
storagePath := os.Getenv("STORAGE_PATH")
|
||||
if storagePath == "" {
|
||||
storagePath = "./storage"
|
||||
}
|
||||
|
||||
dashboardHost := os.Getenv("DASHBOARD_HOST")
|
||||
if dashboardHost == "" {
|
||||
dashboardHost = "localhost"
|
||||
}
|
||||
|
||||
staticDashboardPath := os.Getenv("STATIC_DASHBOARD_PATH")
|
||||
|
||||
return EnvConfig{
|
||||
Port: port,
|
||||
ConfigDir: configDir,
|
||||
GithubPat: githubPat,
|
||||
StoragePath: storagePath,
|
||||
DashboardHost: dashboardHost,
|
||||
StaticDashboardPath: staticDashboardPath,
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,6 @@ import (
|
||||
)
|
||||
|
||||
func Setup(app *fiber.App) {
|
||||
app.Use("/api", func(c fiber.Ctx) error {
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Next()
|
||||
})
|
||||
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "[${time}] ${status} - ${method} ${path} (${latency}) - ${ip}\n",
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
@@ -0,0 +1,40 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func generateToken() (rawToken string, err error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err = rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "quay_" + hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func hashToken(rawToken string) string {
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func CreateDeployToken() (rawToken, hashedToken string, err error) {
|
||||
rawToken, err = generateToken()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
hashedToken = hashToken(rawToken)
|
||||
return rawToken, hashedToken, nil
|
||||
}
|
||||
|
||||
func CompareDeployTokens(incomingRawToken, storedHashedToken string) bool {
|
||||
incomingHash := hashToken(incomingRawToken)
|
||||
|
||||
return subtle.ConstantTimeCompare(
|
||||
[]byte(incomingHash),
|
||||
[]byte(storedHashedToken),
|
||||
) == 1
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var ErrMissingSecret = errors.New("JWT secret not set")
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"sub"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func getSecret() (string, error) {
|
||||
s := os.Getenv("JWT_SECRET")
|
||||
if s == "" {
|
||||
return "", ErrMissingSecret
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func GenerateToken(userID, role string, ttl time.Duration) (string, error) {
|
||||
secret, err := getSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
func ValidateToken(tokenString string) (*Claims, error) {
|
||||
secret, err := getSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package security
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"quay/app/routes"
|
||||
"quay/internal/database"
|
||||
"quay/internal/envconfig"
|
||||
"quay/internal/fiberconfig"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// @title Quay API
|
||||
// @version 1.0
|
||||
// @description Self-hosted static site deployment service
|
||||
// @host localhost:4321
|
||||
// @BasePath /api/v1
|
||||
|
||||
// @tag.name Sites
|
||||
// @tag.description Manage sites
|
||||
|
||||
// @tag.name Forward-Rules
|
||||
// @tag.description Manage redirect rules for a site
|
||||
|
||||
// @tag.name Custom-Headers
|
||||
// @tag.description Manage custom header groups for a site
|
||||
|
||||
// @tag.name Headers
|
||||
// @tag.description Manage individual headers within a custom header group
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
envCfg := envconfig.Load()
|
||||
|
||||
if os.Getenv("JWT_SECRET") == "" || os.Getenv("JWT_SECRET") == "CHANGE_ME" {
|
||||
log.Fatal("JWT_SECRET environment variable is not set or is set to the default value. Please set it to a secure random string before running the application.")
|
||||
}
|
||||
|
||||
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, &envCfg, db)
|
||||
|
||||
log.Fatal(app.Listen(":" + envCfg.Port))
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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.
|
||||
|
||||
sites:
|
||||
- repo: my-lib-docs
|
||||
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}"
|
||||
@@ -6,6 +6,9 @@ services:
|
||||
container_name: quay
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- JWT_SECRET=b9bddc359f9f1b346582e9b50ce65c3c6c6242a8b6e21421a7411b325b8682c4
|
||||
- DASHBOARD_HOST=localhost
|
||||
ports:
|
||||
- '8080:4321'
|
||||
volumes:
|
||||
|
||||
@@ -4,6 +4,9 @@ services:
|
||||
container_name: quay
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- JWT_SECRET=b9bddc359f9f1b346582e9b50ce65c3c6c6242a8b6e21421a7411b325b8682c4
|
||||
- DASHBOARD_HOST=localhost
|
||||
ports:
|
||||
- '8080:4321'
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Quay</title>
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Quay" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "quay-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "^7.14.0",
|
||||
"shadcn": "^4.1.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 175 KiB |
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 6363 3024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g id="logo-dark" transform="matrix(2.503983,0,0,1.189934,1923.05932,128.512862)">
|
||||
<rect x="-768" y="-108" width="2541" height="2541" style="fill:none;"/>
|
||||
<g transform="matrix(0.357525,0,0,0.752342,-756.725876,32.105967)">
|
||||
<circle cx="1493.5" cy="1502.5" r="1279.5" style="fill:none;stroke:rgb(50,128,255);stroke-width:325.8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.47686,0.74535,-0.261402,0.740553,-139.624552,-719.592067)">
|
||||
<path d="M1176,1846L1914,1846" style="fill:none;stroke:rgb(50,128,255);stroke-width:223.13px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.399364,0,0,0.840383,-902.585552,-184.474836)">
|
||||
<g transform="matrix(1879.078391,0,0,1879.078391,3396,2509)">
|
||||
<path d="M0.258,0.007C0.221,0.007 0.189,-0.001 0.161,-0.017C0.133,-0.032 0.111,-0.055 0.095,-0.086C0.08,-0.117 0.072,-0.154 0.072,-0.199L0.072,-0.546L0.18,-0.546L0.18,-0.212C0.18,-0.173 0.19,-0.142 0.211,-0.12C0.231,-0.098 0.259,-0.087 0.295,-0.087C0.319,-0.087 0.341,-0.092 0.36,-0.103C0.379,-0.113 0.394,-0.129 0.405,-0.149C0.416,-0.169 0.422,-0.194 0.422,-0.222L0.422,-0.546L0.53,-0.546L0.53,-0L0.427,-0L0.426,-0.134L0.437,-0.134C0.421,-0.084 0.397,-0.049 0.367,-0.026C0.336,-0.004 0.3,0.007 0.258,0.007Z" style="fill:rgb(229,229,229);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1879.078391,0,0,1879.078391,4526.767653,2509)">
|
||||
<path d="M0.225,0.012C0.19,0.012 0.159,0.006 0.131,-0.007C0.103,-0.02 0.081,-0.039 0.065,-0.063C0.049,-0.088 0.04,-0.119 0.04,-0.154C0.04,-0.185 0.046,-0.211 0.058,-0.231C0.07,-0.25 0.086,-0.266 0.107,-0.278C0.127,-0.289 0.149,-0.298 0.174,-0.304C0.199,-0.31 0.225,-0.314 0.251,-0.317C0.283,-0.321 0.309,-0.324 0.329,-0.327C0.349,-0.329 0.364,-0.333 0.374,-0.339C0.383,-0.345 0.388,-0.354 0.388,-0.367L0.388,-0.37C0.388,-0.39 0.384,-0.407 0.376,-0.421C0.369,-0.435 0.357,-0.446 0.341,-0.454C0.326,-0.461 0.307,-0.465 0.283,-0.465C0.26,-0.465 0.24,-0.462 0.223,-0.454C0.206,-0.447 0.192,-0.438 0.181,-0.426C0.17,-0.415 0.162,-0.403 0.157,-0.391L0.056,-0.417C0.068,-0.449 0.086,-0.476 0.109,-0.496C0.132,-0.515 0.159,-0.53 0.189,-0.539C0.219,-0.548 0.25,-0.553 0.282,-0.553C0.304,-0.553 0.327,-0.55 0.352,-0.545C0.377,-0.54 0.4,-0.53 0.422,-0.517C0.443,-0.503 0.461,-0.484 0.475,-0.459C0.489,-0.434 0.496,-0.402 0.496,-0.362L0.496,-0L0.392,-0L0.392,-0.075L0.386,-0.075C0.379,-0.061 0.369,-0.047 0.355,-0.034C0.341,-0.02 0.323,-0.009 0.302,-0.001C0.28,0.008 0.255,0.012 0.225,0.012ZM0.249,-0.074C0.278,-0.074 0.303,-0.08 0.324,-0.091C0.345,-0.103 0.361,-0.118 0.372,-0.137C0.383,-0.155 0.389,-0.175 0.389,-0.197L0.389,-0.267C0.385,-0.263 0.377,-0.259 0.366,-0.256C0.355,-0.253 0.343,-0.25 0.329,-0.248C0.315,-0.245 0.302,-0.243 0.289,-0.242C0.275,-0.24 0.265,-0.239 0.256,-0.238C0.236,-0.235 0.218,-0.231 0.201,-0.224C0.184,-0.218 0.171,-0.21 0.161,-0.198C0.151,-0.187 0.146,-0.172 0.146,-0.153C0.146,-0.136 0.151,-0.121 0.159,-0.109C0.168,-0.098 0.18,-0.089 0.196,-0.083C0.211,-0.077 0.229,-0.074 0.249,-0.074Z" style="fill:rgb(229,229,229);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1879.078391,0,0,1879.078391,5564.44417,2509)">
|
||||
<path d="M0.065,0.196L0.091,0.109L0.103,0.112C0.122,0.117 0.139,0.118 0.153,0.116C0.168,0.114 0.181,0.107 0.192,0.095C0.203,0.083 0.212,0.066 0.218,0.043L0.23,0.002L0.023,-0.546L0.139,-0.546L0.247,-0.236C0.259,-0.201 0.269,-0.167 0.277,-0.133C0.285,-0.099 0.293,-0.065 0.302,-0.031L0.27,-0.031C0.279,-0.065 0.288,-0.099 0.297,-0.133C0.305,-0.167 0.315,-0.201 0.327,-0.236L0.438,-0.546L0.553,-0.546L0.315,0.076C0.304,0.105 0.291,0.129 0.275,0.149C0.259,0.169 0.24,0.183 0.217,0.193C0.195,0.203 0.169,0.208 0.14,0.208C0.123,0.208 0.108,0.207 0.095,0.204C0.081,0.202 0.072,0.199 0.065,0.196Z" style="fill:rgb(229,229,229);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1879.078391,0,0,1879.078391,6645.445391,2509)">
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 6363 3024" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1,0,0,1,0,-3436)">
|
||||
<g id="logo-light" transform="matrix(2.503983,0,0,1.189934,1923.05932,3564.512862)">
|
||||
<rect x="-768" y="-108" width="2541" height="2541" style="fill:none;"/>
|
||||
<g transform="matrix(0.357525,0,0,0.752342,-756.725876,32.105967)">
|
||||
<circle cx="1493.5" cy="1502.5" r="1279.5" style="fill:none;stroke:rgb(50,128,255);stroke-width:325.8px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.47686,0.74535,-0.261402,0.740553,-139.624552,-719.592067)">
|
||||
<path d="M1176,1846L1914,1846" style="fill:none;stroke:rgb(50,128,255);stroke-width:223.13px;"/>
|
||||
</g>
|
||||
<g transform="matrix(0.399364,0,0,0.840383,-902.585552,-184.474836)">
|
||||
<g transform="matrix(1879.078391,0,0,1879.078391,3396,2509)">
|
||||
<path d="M0.258,0.007C0.221,0.007 0.189,-0.001 0.161,-0.017C0.133,-0.032 0.111,-0.055 0.095,-0.086C0.08,-0.117 0.072,-0.154 0.072,-0.199L0.072,-0.546L0.18,-0.546L0.18,-0.212C0.18,-0.173 0.19,-0.142 0.211,-0.12C0.231,-0.098 0.259,-0.087 0.295,-0.087C0.319,-0.087 0.341,-0.092 0.36,-0.103C0.379,-0.113 0.394,-0.129 0.405,-0.149C0.416,-0.169 0.422,-0.194 0.422,-0.222L0.422,-0.546L0.53,-0.546L0.53,-0L0.427,-0L0.426,-0.134L0.437,-0.134C0.421,-0.084 0.397,-0.049 0.367,-0.026C0.336,-0.004 0.3,0.007 0.258,0.007Z" style="fill:rgb(51,51,51);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1879.078391,0,0,1879.078391,4526.767653,2509)">
|
||||
<path d="M0.225,0.012C0.19,0.012 0.159,0.006 0.131,-0.007C0.103,-0.02 0.081,-0.039 0.065,-0.063C0.049,-0.088 0.04,-0.119 0.04,-0.154C0.04,-0.185 0.046,-0.211 0.058,-0.231C0.07,-0.25 0.086,-0.266 0.107,-0.278C0.127,-0.289 0.149,-0.298 0.174,-0.304C0.199,-0.31 0.225,-0.314 0.251,-0.317C0.283,-0.321 0.309,-0.324 0.329,-0.327C0.349,-0.329 0.364,-0.333 0.374,-0.339C0.383,-0.345 0.388,-0.354 0.388,-0.367L0.388,-0.37C0.388,-0.39 0.384,-0.407 0.376,-0.421C0.369,-0.435 0.357,-0.446 0.341,-0.454C0.326,-0.461 0.307,-0.465 0.283,-0.465C0.26,-0.465 0.24,-0.462 0.223,-0.454C0.206,-0.447 0.192,-0.438 0.181,-0.426C0.17,-0.415 0.162,-0.403 0.157,-0.391L0.056,-0.417C0.068,-0.449 0.086,-0.476 0.109,-0.496C0.132,-0.515 0.159,-0.53 0.189,-0.539C0.219,-0.548 0.25,-0.553 0.282,-0.553C0.304,-0.553 0.327,-0.55 0.352,-0.545C0.377,-0.54 0.4,-0.53 0.422,-0.517C0.443,-0.503 0.461,-0.484 0.475,-0.459C0.489,-0.434 0.496,-0.402 0.496,-0.362L0.496,-0L0.392,-0L0.392,-0.075L0.386,-0.075C0.379,-0.061 0.369,-0.047 0.355,-0.034C0.341,-0.02 0.323,-0.009 0.302,-0.001C0.28,0.008 0.255,0.012 0.225,0.012ZM0.249,-0.074C0.278,-0.074 0.303,-0.08 0.324,-0.091C0.345,-0.103 0.361,-0.118 0.372,-0.137C0.383,-0.155 0.389,-0.175 0.389,-0.197L0.389,-0.267C0.385,-0.263 0.377,-0.259 0.366,-0.256C0.355,-0.253 0.343,-0.25 0.329,-0.248C0.315,-0.245 0.302,-0.243 0.289,-0.242C0.275,-0.24 0.265,-0.239 0.256,-0.238C0.236,-0.235 0.218,-0.231 0.201,-0.224C0.184,-0.218 0.171,-0.21 0.161,-0.198C0.151,-0.187 0.146,-0.172 0.146,-0.153C0.146,-0.136 0.151,-0.121 0.159,-0.109C0.168,-0.098 0.18,-0.089 0.196,-0.083C0.211,-0.077 0.229,-0.074 0.249,-0.074Z" style="fill:rgb(51,51,51);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1879.078391,0,0,1879.078391,5564.44417,2509)">
|
||||
<path d="M0.065,0.196L0.091,0.109L0.103,0.112C0.122,0.117 0.139,0.118 0.153,0.116C0.168,0.114 0.181,0.107 0.192,0.095C0.203,0.083 0.212,0.066 0.218,0.043L0.23,0.002L0.023,-0.546L0.139,-0.546L0.247,-0.236C0.259,-0.201 0.269,-0.167 0.277,-0.133C0.285,-0.099 0.293,-0.065 0.302,-0.031L0.27,-0.031C0.279,-0.065 0.288,-0.099 0.297,-0.133C0.305,-0.167 0.315,-0.201 0.327,-0.236L0.438,-0.546L0.553,-0.546L0.315,0.076C0.304,0.105 0.291,0.129 0.275,0.149C0.259,0.169 0.24,0.183 0.217,0.193C0.195,0.203 0.169,0.208 0.14,0.208C0.123,0.208 0.108,0.207 0.095,0.204C0.081,0.202 0.072,0.199 0.065,0.196Z" style="fill:rgb(51,51,51);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(1879.078391,0,0,1879.078391,6645.445391,2509)">
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Quay",
|
||||
"short_name": "Quay",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#171717",
|
||||
"background_color": "#171717",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,27 @@
|
||||
import { makeApiUrl, fetchWithAuth } from '.';
|
||||
import type { User } from './types/user';
|
||||
|
||||
export const login = async (name: string, password: string): Promise<string> => {
|
||||
const response = await fetch(makeApiUrl('/login'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to login');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.token;
|
||||
};
|
||||
|
||||
export const getMe = async (): Promise<User> => {
|
||||
const response = await fetchWithAuth('/me', { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch current user');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { fetchWithAuth } from '.';
|
||||
import type {
|
||||
CreateCustomHeadersRequest,
|
||||
CreateHeaderRequest,
|
||||
CustomHeaders,
|
||||
Header,
|
||||
} from './types/site';
|
||||
|
||||
export const getCustomHeaders = async (siteId: string): Promise<CustomHeaders[]> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch custom headers');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createCustomHeaders = async (
|
||||
siteId: string,
|
||||
data: CreateCustomHeadersRequest
|
||||
): Promise<CustomHeaders> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create custom headers');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateCustomHeaders = async (
|
||||
siteId: string,
|
||||
groupId: string,
|
||||
data: CreateCustomHeadersRequest
|
||||
): Promise<CustomHeaders> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers/${groupId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update custom headers');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteCustomHeaders = async (siteId: string, groupId: string): Promise<void> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers/${groupId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete custom headers');
|
||||
}
|
||||
};
|
||||
|
||||
export const createHeader = async (
|
||||
siteId: string,
|
||||
groupId: string,
|
||||
data: CreateHeaderRequest
|
||||
): Promise<Header> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/custom-headers/${groupId}/headers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create header');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateHeader = async (
|
||||
siteId: string,
|
||||
headerId: string,
|
||||
data: CreateHeaderRequest
|
||||
): Promise<Header> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/headers/${headerId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update header');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteHeader = async (siteId: string, headerId: string): Promise<void> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/headers/${headerId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete header');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { fetchWithAuth } from '.';
|
||||
import type { Deployment } from './types/deployments';
|
||||
|
||||
export const getDeploymentsForSite = async (
|
||||
siteId: string,
|
||||
limit: number = 100
|
||||
): Promise<Deployment[]> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/deployments?limit=${limit}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deployments');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.deployments;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { fetchWithAuth } from '.';
|
||||
import type { CreateForwardRuleRequest, ForwardRule } from './types/site';
|
||||
|
||||
export const getForwardRules = async (siteId: string): Promise<ForwardRule[]> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/forward-rules`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch forward rules');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createForwardRule = async (
|
||||
siteId: string,
|
||||
data: CreateForwardRuleRequest
|
||||
): Promise<ForwardRule> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/forward-rules`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create forward rule');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateForwardRule = async (
|
||||
siteId: string,
|
||||
ruleId: string,
|
||||
data: CreateForwardRuleRequest
|
||||
): Promise<ForwardRule> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/forward-rules/${ruleId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update forward rule');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteForwardRule = async (siteId: string, ruleId: string): Promise<void> => {
|
||||
const response = await fetchWithAuth(`/sites/${siteId}/forward-rules/${ruleId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete forward rule');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { fetchWithAuth } from '.';
|
||||
import type { CreateGitServerRequest, GitServer, UpdateGitServerRequest } from './types/gitserver';
|
||||
|
||||
export const getGitServers = async (): Promise<GitServer[]> => {
|
||||
const response = await fetchWithAuth('/gitservers', {
|
||||
method: 'GET',
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch git servers');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getGitServerById = async (id: string): Promise<GitServer> => {
|
||||
const response = await fetchWithAuth(`/gitservers/${id}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch git server');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createGitServer = async (data: CreateGitServerRequest): Promise<GitServer> => {
|
||||
const response = await fetchWithAuth('/gitservers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create git server');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateGitServer = async (
|
||||
id: string,
|
||||
data: UpdateGitServerRequest
|
||||
): Promise<GitServer> => {
|
||||
const response = await fetchWithAuth(`/gitservers/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update git server');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteGitServer = async (id: string): Promise<void> => {
|
||||
const response = await fetchWithAuth(`/gitservers/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete git server');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
export const API_BASE_URL = '/api/v1';
|
||||
|
||||
export const makeApiUrl = (endpoint: string) => {
|
||||
const base = API_BASE_URL.endsWith('/') ? API_BASE_URL.slice(0, -1) : API_BASE_URL;
|
||||
const path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
return `${base}${path}`;
|
||||
};
|
||||
|
||||
export const authHeaders = () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchWithAuth = (endpoint: string, options: RequestInit = {}) => {
|
||||
const headers = {
|
||||
...(options.headers || {}),
|
||||
...authHeaders(),
|
||||
} as Record<string, string>;
|
||||
return fetch(makeApiUrl(endpoint), { ...options, headers });
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fetchWithAuth } from '.';
|
||||
import type {
|
||||
CreateSiteRequest,
|
||||
CreateSiteResponse,
|
||||
GetAllSitesResponse,
|
||||
Site,
|
||||
ToggleSiteEnabledResponse,
|
||||
} from './types/site';
|
||||
|
||||
export const getSites = async (): Promise<GetAllSitesResponse> => {
|
||||
const response = await fetchWithAuth('/sites', {
|
||||
method: 'GET',
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return { sites: [], total: 0 };
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sites');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getSite = async (id: string): Promise<Site> => {
|
||||
const response = await fetchWithAuth(`/sites/${id}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch site');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createSite = async (data: CreateSiteRequest): Promise<CreateSiteResponse> => {
|
||||
const response = await fetchWithAuth('/sites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create site');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateSite = async (id: string, data: Partial<CreateSiteRequest>): Promise<Site> => {
|
||||
const response = await fetchWithAuth(`/sites/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update site');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const toggleSiteEnabled = async (id: string): Promise<ToggleSiteEnabledResponse> => {
|
||||
const response = await fetchWithAuth(`/sites/${id}/enabled`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to toggle site enabled state');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteSite = async (id: string): Promise<void> => {
|
||||
const response = await fetchWithAuth(`/sites/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete site');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export type DeploymentStatus = 'pending' | 'running' | 'success' | 'failed';
|
||||
|
||||
export interface Deployment {
|
||||
id: string;
|
||||
site_id: string;
|
||||
commit_hash: string;
|
||||
message: string;
|
||||
status: DeploymentStatus;
|
||||
start_time: string;
|
||||
finish_time: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GetDeploymentsBySiteResponse {
|
||||
deployments: Deployment[];
|
||||
total: number;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type GitServerProtocol = 'http' | 'https';
|
||||
export type GitServerType = 'github' | 'gitlab' | 'gitea';
|
||||
|
||||
export interface GitServer {
|
||||
id: string;
|
||||
name: string;
|
||||
protocol: GitServerProtocol;
|
||||
baseUrl: string;
|
||||
type: GitServerType;
|
||||
auth_token?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateGitServerRequest {
|
||||
name: string;
|
||||
protocol: GitServerProtocol;
|
||||
baseUrl: string;
|
||||
type: GitServerType;
|
||||
auth_token?: string;
|
||||
}
|
||||
|
||||
export interface UpdateGitServerRequest {
|
||||
name?: string;
|
||||
protocol?: GitServerProtocol;
|
||||
baseUrl?: string;
|
||||
type?: GitServerType;
|
||||
auth_token?: string;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
export interface ForwardRule {
|
||||
id: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
status_code: number;
|
||||
regex: boolean;
|
||||
}
|
||||
|
||||
export interface Header {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CustomHeaders {
|
||||
id: string;
|
||||
source: string;
|
||||
regex: boolean;
|
||||
headers: Header[];
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
git_server: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
branch: string;
|
||||
domain: string;
|
||||
deploy_token: string;
|
||||
enabled: boolean;
|
||||
spa: boolean;
|
||||
not_found_file: string;
|
||||
index_file: string;
|
||||
trailing_slash: boolean | null;
|
||||
created_at: string;
|
||||
forward_rules: ForwardRule[];
|
||||
custom_headers: CustomHeaders[];
|
||||
}
|
||||
|
||||
export interface CreateSiteRequest {
|
||||
name: string;
|
||||
git_server: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
branch: string;
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
spa: boolean;
|
||||
not_found_file: string;
|
||||
index_file: string;
|
||||
}
|
||||
|
||||
export interface GetAllSitesResponse {
|
||||
sites: Site[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreateSiteResponse {
|
||||
site: Site;
|
||||
raw_deploy_token: string;
|
||||
}
|
||||
|
||||
export interface ToggleSiteEnabledResponse {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CreateForwardRuleRequest {
|
||||
source: string;
|
||||
destination: string;
|
||||
status_code: number;
|
||||
regex: boolean;
|
||||
}
|
||||
|
||||
export interface CreateCustomHeadersRequest {
|
||||
source: string;
|
||||
regex: boolean;
|
||||
}
|
||||
|
||||
export interface CreateHeaderRequest {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'admin' | 'user';
|
||||
hashed_password: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
name: string;
|
||||
role: 'admin' | 'user';
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
name?: string;
|
||||
role?: 'admin' | 'user';
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { fetchWithAuth } from '.';
|
||||
import type { CreateUserRequest, UpdateUserRequest, User } from './types/user';
|
||||
|
||||
export const getUsers = async (): Promise<User[]> => {
|
||||
const response = await fetchWithAuth('/users', {
|
||||
method: 'GET',
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return [];
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sites');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const getUserById = async (id: string): Promise<User> => {
|
||||
const response = await fetchWithAuth(`/users/${id}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch user');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const createUser = async (data: CreateUserRequest): Promise<User> => {
|
||||
const response = await fetchWithAuth('/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const updateUser = async (id: string, data: Partial<UpdateUserRequest>): Promise<User> => {
|
||||
const response = await fetchWithAuth(`/users/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update user');
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const deleteUser = async (id: string): Promise<void> => {
|
||||
const response = await fetchWithAuth(`/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { PanelsTopLeft, Users as UsersIcon, Code2 } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { NavUser } from './NavUser';
|
||||
import { Logo } from './logo';
|
||||
import { Separator } from './ui/separator';
|
||||
|
||||
interface AppSidebarProps {
|
||||
userName?: string;
|
||||
profilePictureUrl?: string;
|
||||
}
|
||||
|
||||
const AppSidebar = ({ userName, profilePictureUrl }: AppSidebarProps) => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<Sidebar variant="floating" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="hover:bg-transparent active:bg-transparent"
|
||||
asChild
|
||||
>
|
||||
<Link to="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<Separator className="mb-2" />
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
{/* <SidebarGroupLabel>Sites</SidebarGroupLabel> */}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={location.pathname === '/'}>
|
||||
<Link to={'/'}>
|
||||
<PanelsTopLeft />
|
||||
Sites
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={location.pathname === '/gitservers'}
|
||||
>
|
||||
<Link to={'/gitservers'}>
|
||||
<Code2 />
|
||||
Git Servers
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={location.pathname === '/users'}
|
||||
>
|
||||
<Link to={'/users'}>
|
||||
<UsersIcon />
|
||||
Users
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser userName={userName} profilePictureUrl={profilePictureUrl} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CheckCircle2, Clock, Loader2, XCircle } from 'lucide-react';
|
||||
import type { DeploymentStatus } from '../api/types/deployments';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const StatusIcon = ({ status }: { status: DeploymentStatus }) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle2 className="w-4 h-4 text-success" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||
case 'running':
|
||||
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
||||
case 'pending':
|
||||
return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const DeploymentStatusBadge = ({ status }: { status: DeploymentStatus }) => {
|
||||
const statusText = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={status} />
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm capitalize hidden sm:inline',
|
||||
status === 'success' && 'text-success',
|
||||
status === 'failed' && 'text-destructive',
|
||||
status === 'running' && 'text-primary',
|
||||
status === 'pending' && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentStatusBadge;
|
||||