detached 0383cdf

feat: Initial Gordon v2 architecture - streamlined core implementation

Major refactoring to create a simplified, focused Gordon v2:
- Remove complex web UI, templating, and database layers
- Strip down to essential proxy and container management functionality
- Update dependencies and build configuration
- Establish foundation for cleaner, more maintainable architecture
commit
0383cdff2abc1be4efd7712efd900e1b343c2f2d
author
bamen · · 2025-06-01 20:40 UTC
committer
bamen · · 2025-06-01 16:21 UTC
parent
54cdd9d
signature
unsigned
1783 files · +2639 -205099

this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.

D .air.toml +0 -44
@@ -1,44 +0,0 @@
1-root = "."
2-testdata_dir = "testdata"
3-tmp_dir = "tmp"
4-
5-[build]
6- args_bin = ["serve"]
7- bin = "./dist/tmp/main"
8- cmd = "go build -o ./dist/tmp/main ./main.go"
9- delay = 0
10- exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"]
11- exclude_file = []
12- exclude_regex = ["_test.go"]
13- exclude_unchanged = false
14- follow_symlink = false
15- full_bin = "./dist/tmp/main"
16- include_dir = []
17- include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "css", "js"]
18- include_file = []
19- kill_delay = "0s"
20- log = "build-errors.log"
21- poll = false
22- poll_interval = 0
23- rerun = false
24- rerun_delay = 1000
25- send_interrupt = true
26- stop_on_error = false
27-
28-[color]
29- app = ""
30- build = "yellow"
31- main = "magenta"
32- runner = "green"
33- watcher = "cyan"
34-
35-[log]
36- main_only = false
37- time = false
38-
39-[misc]
40- clean_on_exit = false
41-
42-[screen]
43- clear_on_rebuild = true
44- keep_scroll = true
D .env.example +0 -1
@@ -1,1 +0,0 @@
1-# its empty here⏎̸
M .gitignore +1 -23
@@ -1,23 +1,1 @@
1-.env
2-# Node.js/Bun specific (removed)
3-# node_modules/
4-*tmp/
5-tmp/*
6-config.yml
7-# bun.lockb
8-*.log
9-*.txt
10-*sync-conflict*
11-main
12-gordon
13-dist/
14-data/
15-setup.sh
16-.iscontainer
17-sqlite.db
18-.vscode/
19-admin-ui/
20-
21-# Standalone Tailwind/DaisyUI
22-tailwindcss
23-daisyui.js
1+gordon⏎̸
M .goreleaser.yaml +0 -3
@@ -2,9 +2,6 @@ version: 2
22
33 before:
44 hooks:
5- - go mod tidy
6- - templ generate
7- - tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css
85
96 release:
107 draft: true
D .templ.config.json +0 -4
@@ -1,4 +0,0 @@
1-{
2- "root": "internal/templating/models/templ",
3- "paths": ["internal/templating/models/templ"]
4-}⏎̸
D .vscode/launch.json +0 -18
@@ -1,18 +0,0 @@
1-{
2- // Utilisez IntelliSense pour en savoir plus sur les attributs possibles.
3- // Pointez pour afficher la description des attributs existants.
4- // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
5- "version": "0.2.0",
6- "configurations": [
7- {
8- "name": "Launch file",
9- "type": "go",
10- "request": "launch",
11- "mode": "debug",
12- "program": "./main.go",
13- }
14-
15-
16- ]
17-
18-}⏎̸
D Dockerfile +0 -8
@@ -1,8 +0,0 @@
1-FROM --platform=$TARGETPLATFORM alpine:latest
2-
3-COPY gordon /gordon
4-
5-RUN touch /.iscontainer
6-
7-ENTRYPOINT ["/gordon"]
8-CMD ["serve"]
D Makefile +0 -88
@@ -1,88 +0,0 @@
1-# This Makefile is used for dev purposes
2-# Variables
3-REPO := ghcr.io/bnema/gordon
4-TAG := dev
5-DIST_DIR := ./dist
6-ENGINE := podman
7-
8-# Version information
9-VERSION := $(shell git describe --tags --always --dirty)
10-COMMIT := $(shell git rev-parse --short HEAD)
11-BUILD_DATE := $(shell date -u '+%Y-%m-%d_%I:%M:%S%p')
12-
13-# Build flags
14-LDFLAGS := -s -w \
15- -X main.version=$(VERSION) \
16- -X main.commit=$(COMMIT) \
17- -X main.date=$(BUILD_DATE)
18-
19-# Architectures
20-ARCHS := amd64 arm64
21-
22-# Phony targets
23-.PHONY: all build build-push clean build-css build-templ
24-
25-# Default target
26-all: build
27-
28-# Build binaries
29-build: build-css build-templ
30- @echo "Building Go binaries..."
31- @mkdir -p $(DIST_DIR)
32- @rm -f $(DIST_DIR)/*
33- @echo "Building with version $(VERSION), commit $(COMMIT), date $(BUILD_DATE)"
34- @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go
35- @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go
36- @echo "Go binaries built successfully"
37-
38-# Build CSS with tailwindcss
39-build-css:
40- @echo "Building CSS with tailwindcss..."
41- @tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css
42- @echo "CSS built successfully"
43-
44-# Generate Go code from templ templates
45-build-templ:
46- @echo "Generating Go code from templ templates..."
47- @templ generate
48- @echo "templ generation completed successfully"
49-
50-# Build and push Docker images
51-build-push: build-css build
52- @echo "Cleaning up dangling images..."
53- @$(ENGINE) image prune -f
54-
55- @echo "Building and pushing Docker images..."
56- @for arch in $(ARCHS); do \
57- cp $(DIST_DIR)/gordon-linux-$$arch gordon; \
58- $(ENGINE) build -t $(REPO):$(TAG)-$$arch .; \
59- rm gordon; \
60- $(ENGINE) push $(REPO):$(TAG)-$$arch; \
61- done
62-
63- @echo "Removing existing manifest..."
64- @$(ENGINE) manifest rm $(REPO):$(TAG) || true
65-
66- @echo "Creating multi-arch manifest..."
67- @$(ENGINE) manifest create $(REPO):$(TAG) \
68- $(REPO):$(TAG)-amd64 \
69- $(REPO):$(TAG)-arm64
70-
71- @echo "Annotating arm64 image..."
72- @$(ENGINE) manifest annotate $(REPO):$(TAG) \
73- $(REPO):$(TAG)-arm64 --arch arm64 --variant v8
74-
75- @echo "Inspecting manifest..."
76- @$(ENGINE) manifest inspect $(REPO):$(TAG)
77-
78- @echo "Pushing multi-arch manifest..."
79- @$(ENGINE) manifest push --all $(REPO):$(TAG)
80-
81- @echo "Script completed successfully."
82-
83-# Clean up
84-clean:
85- @echo "Cleaning up..."
86- @rm -rf $(DIST_DIR)
87- @$(ENGINE) system prune -f
88- @echo "Cleanup completed."
D README.md +0 -81
@@ -1,81 +0,0 @@
1-<h1 align="center">
2- <br>
3- <img src="https://github.com/bnema/gordon/blob/main/internal/webui/public/assets/imgs/gordon-mascot-mq-trsp.png?raw=true" alt="Gordon" width="192">
4- <br>
5- Gordon
6- <br>
7-</h1>
8-
9-<h4 align="center">Minimalist self-hosted containerized app deployment tool.</h4>
10-
11-
12-Gordon is a tool that aims to simplify the deployment of your containerized web applications. It automates the process of integrating your locally built image into your self-hosted setup without the need of a container registry.
13-
14-## **Key Features**
15-
16-- **Ease of use**:
17- - Push your container images effortlessly using the **`gordon push`** command. A container creation URL is generated upon successful push.
18-
19- ![Demo Push](assets/vhs/demo_push.gif?raw=true)
20-
21- - Deploy your web applications quickly with **`gordon deploy`**.
22-
23- ![Demo Deploy](assets/vhs/demo_deploy.gif?raw=true)
24-
25-- **Self-Hosted**: Operates within your existing self-hosted environment without interfering with your setup.
26-- **Self-Updated**: The client can update itself to the latest version with `gordon update`. The backend is updated by pulling the latest image from ghcr.io.
27-- **Minimal WebUI**: Provides a [simple web UI](assets/screenshots/webui-image-deploy.png) to manage your deployments, domains, exposed ports. Using a very minimal stack (sqlite3, Go templates, HTMX and Tailwind CSS).
28-- **Simple authentication**: Use GitHub OAuth for the web UI and OAuth 2.0 device authorization grant (device flow) for the CLI client.
29-- **Automated Routing**: Gordon automatically routes your application to your desired domain or subdomain, with no manual configuration needed.
30-- **One binary**: Gordon follows the Golang philosophy of keeping things simple, offering a single binary for all functionalities.
31-
32-## **Why ?**
33-
34-I needed a simple tool that would automate the process of quickly spin up my web apps and preferably not on someone else's computer. And since I wanted to learn Go, I decided to build it myself.
35-
36-## **Installation / Getting Started / Usage**
37-
38-For detailed instructions, please refer to the [wiki](https://github.com/bnema/gordon/wiki/)
39-
40-- [Setting up Gordon's Backend](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Backend)
41-- [Setting up Gordon's Client](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Client)
42-- [Deploying your first app](https://github.com/bnema/gordon/wiki/First-deployment)
43-
44-
45-## **Development Status & Disclaimer**
46-
47-🛠️ **Note**: Gordon is under heavy development and is absolutely not ready for production. Thank you in advance for taking the time to test it and report any issues you may encounter.
48-
49-I also want to emphasize that I am far from being a Go expert yet. Hence, if you notice any poor practices, I welcome your feedback—respectfully, of course. It's a valuable part of the learning process!
50-
51-## **Roadmap beyond 0.1**
52-
53-- [x] Improved error handling
54-- [x] Improved logging
55-- [x] Refined CLI to backend authentication process (utilizing GitHub OAuth Device flow)
56-- [x] `push` command
57-- [x] `version` command
58-- [x] `update` command
59-- [x] `deploy` command
60-- [x] Survey(s) on deploy command (stop/remove if already exists)
61-- [ ] Improved web UI
62-- [ ] Quick database deploy templates (libsql, postgresql)
63-- [ ] GitHub Repository Integration:
64- - Repository selection and branch/tag filtering
65- - Dockerfile detection and validation
66- - Build configuration UI
67- - Build logs streaming
68- - Automated deployment triggers
69- - Build status notifications
70-
71-Have suggestions? Feel free to open an issue!
72-
73-## **License**
74-
75-Gordon is licensed under the GPL-3.0 license. Please see the LICENSE file for more details.
76-
77-## Features
78-
79-- **Container Management**: Connect to your Docker daemon to manage your containers, whether they're running locally or remotely.
80-- **Image Management**: Download, delete, and manage your container images with ease.
81-- **Built-in Reverse Proxy**: Gordon includes a built-in reverse proxy that automatically handles domain routing, Let's Encrypt TLS certificates, and HTTP to HTTPS redirects.
A cmd/generate.go +112 -0
@@ -0,0 +1,112 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ "github.com/spf13/cobra"
8+ "gordon/internal/middleware"
9+)
10+
11+var generateCmd = &cobra.Command{
12+ Use: "generate",
13+ Short: "Generate authentication tokens and secrets",
14+ Long: `Generate JWT tokens, API keys, and JWT secrets for Gordon authentication`,
15+}
16+
17+var generateSecretCmd = &cobra.Command{
18+ Use: "jwt-secret",
19+ Short: "Generate a secure JWT secret",
20+ Long: `Generate a cryptographically secure JWT secret for signing tokens`,
21+ Run: func(cmd *cobra.Command, args []string) {
22+ secret, err := middleware.GenerateJWTSecret()
23+ if err != nil {
24+ fmt.Printf("Error generating JWT secret: %v\n", err)
25+ return
26+ }
27+
28+ fmt.Printf("Generated JWT Secret:\n")
29+ fmt.Printf("%s\n\n", secret)
30+ fmt.Printf("Add this to your gordon.toml configuration:\n")
31+ fmt.Printf("[auth]\n")
32+ fmt.Printf("enabled = true\n")
33+ fmt.Printf("method = \"jwt\"\n")
34+ fmt.Printf("jwt_secret = \"%s\"\n", secret)
35+ },
36+}
37+
38+var generateTokenCmd = &cobra.Command{
39+ Use: "jwt-token",
40+ Short: "Generate a JWT token",
41+ Long: `Generate a JWT token for accessing Gordon's management API`,
42+ Run: func(cmd *cobra.Command, args []string) {
43+ username, _ := cmd.Flags().GetString("username")
44+ roles, _ := cmd.Flags().GetStringSlice("roles")
45+ jwtSecret, _ := cmd.Flags().GetString("secret")
46+ validFor, _ := cmd.Flags().GetDuration("valid-for")
47+
48+ if username == "" {
49+ fmt.Println("Error: username is required")
50+ return
51+ }
52+
53+ if jwtSecret == "" {
54+ fmt.Println("Error: JWT secret is required (use --secret flag or generate one with 'gordon generate jwt-secret')")
55+ return
56+ }
57+
58+ token, err := middleware.GenerateJWTToken(username, roles, jwtSecret, validFor)
59+ if err != nil {
60+ fmt.Printf("Error generating JWT token: %v\n", err)
61+ return
62+ }
63+
64+ fmt.Printf("Generated JWT Token for user '%s':\n", username)
65+ fmt.Printf("%s\n\n", token)
66+ fmt.Printf("Token expires in: %v\n", validFor)
67+ fmt.Printf("Roles: %v\n\n", roles)
68+ fmt.Printf("Use this token in API requests:\n")
69+ fmt.Printf("curl -H \"Authorization: Bearer %s\" http://localhost:8080/api/status\n", token)
70+ },
71+}
72+
73+var generateAPIKeyCmd = &cobra.Command{
74+ Use: "api-key",
75+ Short: "Generate a secure API key",
76+ Long: `Generate a cryptographically secure API key for Gordon authentication`,
77+ Run: func(cmd *cobra.Command, args []string) {
78+ apiKey, err := middleware.GenerateSecureAPIKey()
79+ if err != nil {
80+ fmt.Printf("Error generating API key: %v\n", err)
81+ return
82+ }
83+
84+ fmt.Printf("Generated API Key:\n")
85+ fmt.Printf("%s\n\n", apiKey)
86+ fmt.Printf("Add this to your gordon.toml configuration:\n")
87+ fmt.Printf("[auth]\n")
88+ fmt.Printf("enabled = true\n")
89+ fmt.Printf("method = \"api_key\"\n")
90+ fmt.Printf("api_key = \"%s\"\n\n", apiKey)
91+ fmt.Printf("Use this API key in requests:\n")
92+ fmt.Printf("curl -H \"X-API-Key: %s\" http://localhost:8080/api/status\n", apiKey)
93+ fmt.Printf("# OR\n")
94+ fmt.Printf("curl \"http://localhost:8080/api/status?api_key=%s\"\n", apiKey)
95+ },
96+}
97+
98+func init() {
99+ rootCmd.AddCommand(generateCmd)
100+ generateCmd.AddCommand(generateSecretCmd)
101+ generateCmd.AddCommand(generateTokenCmd)
102+ generateCmd.AddCommand(generateAPIKeyCmd)
103+
104+ // JWT token flags
105+ generateTokenCmd.Flags().StringP("username", "u", "", "Username for the token (required)")
106+ generateTokenCmd.Flags().StringSliceP("roles", "r", []string{"admin"}, "Roles for the token")
107+ generateTokenCmd.Flags().StringP("secret", "s", "", "JWT secret for signing (required)")
108+ generateTokenCmd.Flags().DurationP("valid-for", "t", 24*time.Hour, "Token validity duration")
109+
110+ generateTokenCmd.MarkFlagRequired("username")
111+ generateTokenCmd.MarkFlagRequired("secret")
112+}⏎̸
D cmd/gordon.go +0 -68
@@ -1,68 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/gordon/internal/cli"
8- "github.com/bnema/gordon/internal/cli/cmd"
9- "github.com/bnema/gordon/internal/common"
10- "github.com/bnema/gordon/internal/server"
11- "github.com/spf13/cobra"
12-
13- _ "github.com/joho/godotenv/autoload"
14-)
15-
16-var (
17- rootCmd = &cobra.Command{Use: "gordon"}
18- proxyURL = "https://gordon-proxy.bamen.dev"
19-)
20-
21-func InitializeCommands(client *cli.App, server *server.App) {
22- rootCmd.AddCommand(cmd.NewRootCommand(client))
23- rootCmd.AddCommand(cmd.NewServeCommand(server))
24- rootCmd.AddCommand(cmd.NewPingCommand(client))
25- rootCmd.AddCommand(cmd.NewDeployCommand(client))
26- rootCmd.AddCommand(cmd.NewUpdateCommand(client))
27- rootCmd.AddCommand(cmd.NewPushCommand(client))
28- rootCmd.AddCommand(cmd.NewVersionCommand(client))
29- rootCmd.AddCommand(cmd.NewProxyCommand(server))
30-}
31-
32-func Execute(client *cli.App, server *server.App) {
33- InitializeCommands(client, server)
34- cobra.CheckErr(rootCmd.Execute())
35-}
36-
37-func ExecuteCLI(build, commit, date string) {
38- versionInfo := common.GetVersionInfo(build, commit, date, proxyURL)
39-
40- buildInfo := &common.BuildConfig{
41- BuildVersion: build,
42- BuildCommit: commit,
43- BuildDate: date,
44- ProxyURL: proxyURL,
45- }
46-
47- // Load global configuration once before initializing the apps
48- _, err := common.GetGlobalConfig(buildInfo)
49- if err != nil {
50- fmt.Println("Error initializing global configuration:", err)
51- return
52- }
53-
54- // Now initialize the client and server apps, which will use the already loaded config
55- a, err := cli.NewClientApp(buildInfo)
56- if err != nil {
57- fmt.Println("Error initializing app:", err)
58- }
59- s, err := server.NewServerApp(buildInfo)
60- if err != nil {
61- fmt.Println("Error initializing app:", err)
62- }
63-
64- // Start periodic version checking in the background
65- go common.CheckVersionPeriodically(versionInfo, 3*time.Hour)
66-
67- Execute(a, s)
68-}
A cmd/root.go +44 -0
@@ -0,0 +1,44 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/rs/zerolog/log"
8+ "github.com/spf13/cobra"
9+ "github.com/spf13/viper"
10+)
11+
12+var cfgFile string
13+
14+var rootCmd = &cobra.Command{
15+ Use: "gordon",
16+ Short: "Gordon - Container Registry & Deployment System",
17+ Long: `Gordon is a single-binary application that combines a container registry
18+with an intelligent reverse proxy and automated deployment system.`,
19+}
20+
21+func Execute() error {
22+ return rootCmd.Execute()
23+}
24+
25+func init() {
26+ cobra.OnInitialize(initConfig)
27+ rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is ./gordon.toml)")
28+}
29+
30+func initConfig() {
31+ if cfgFile != "" {
32+ viper.SetConfigFile(cfgFile)
33+ } else {
34+ log.Fatal().Msg("config file not found")
35+ }
36+
37+ viper.AutomaticEnv()
38+
39+ if err := viper.ReadInConfig(); err == nil {
40+ fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
41+ } else {
42+ fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err)
43+ }
44+}
A cmd/start.go +104 -0
@@ -0,0 +1,104 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "os"
6+ "os/signal"
7+ "sync"
8+ "syscall"
9+ "time"
10+
11+ "github.com/rs/zerolog"
12+ "github.com/rs/zerolog/log"
13+ "github.com/spf13/cobra"
14+ "gordon/internal/config"
15+ "gordon/internal/container"
16+ "gordon/internal/proxy"
17+ "gordon/internal/registry"
18+)
19+
20+var startCmd = &cobra.Command{
21+ Use: "start",
22+ Short: "Start the Gordon server",
23+ Long: `Start the container registry and reverse proxy server`,
24+ Run: runStart,
25+}
26+
27+func init() {
28+ rootCmd.AddCommand(startCmd)
29+}
30+
31+func runStart(cmd *cobra.Command, args []string) {
32+ // Configure zerolog
33+ zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
34+ log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
35+
36+ cfg, err := config.Load()
37+ if err != nil {
38+ log.Fatal().Err(err).Msg("Failed to load config")
39+ }
40+
41+ log.Info().Msg("Starting Gordon server...")
42+ log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server")
43+ log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server")
44+ log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime")
45+
46+ // Initialize container manager
47+ manager, err := container.NewManager(cfg)
48+ if err != nil {
49+ log.Fatal().Err(err).Msg("Failed to initialize container manager")
50+ }
51+
52+ // Sync existing containers
53+ ctx, cancel := context.WithCancel(context.Background())
54+ if err := manager.SyncContainers(ctx); err != nil {
55+ log.Warn().Err(err).Msg("Failed to sync existing containers")
56+ }
57+
58+ var wg sync.WaitGroup
59+
60+ // Start registry server
61+ registryServer, err := registry.NewServer(cfg)
62+ if err != nil {
63+ log.Fatal().Err(err).Msg("Failed to initialize registry server")
64+ }
65+ wg.Add(1)
66+ go func() {
67+ defer wg.Done()
68+ if err := registryServer.Start(ctx); err != nil {
69+ log.Error().Err(err).Msg("Registry server error")
70+ }
71+ }()
72+
73+ // Start proxy server
74+ proxyServer := proxy.NewServer(cfg, manager)
75+ wg.Add(1)
76+ go func() {
77+ defer wg.Done()
78+ if err := proxyServer.Start(ctx); err != nil {
79+ log.Error().Err(err).Msg("Proxy server error")
80+ }
81+ }()
82+
83+ // Wait for interrupt signal
84+ sigChan := make(chan os.Signal, 1)
85+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
86+ <-sigChan
87+
88+ log.Info().Msg("Shutting down servers...")
89+ cancel()
90+
91+ // Give servers time to shutdown gracefully
92+ done := make(chan struct{})
93+ go func() {
94+ wg.Wait()
95+ close(done)
96+ }()
97+
98+ select {
99+ case <-done:
100+ log.Info().Msg("Servers stopped gracefully")
101+ case <-time.After(10 * time.Second):
102+ log.Warn().Msg("Force shutdown after timeout")
103+ }
104+}⏎̸
D docker-compose.yml.example +0 -119
@@ -1,119 +0,0 @@
1-version: '3'
2-
3-services:
4- gordon:
5- image: ghcr.io/bnema/gordon:latest
6- container_name: gordon
7- volumes:
8- - ./data:/data
9- - ./data/config.yml:/config.yml # Mount config.yml directly at the root
10- - ./data/blacklist.yml:/blacklist.yml # Mount blacklist.yml
11- - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates
12- # For Docker:
13- - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management
14- # For Podman (uncomment and comment the Docker line above):
15- # - /run/user/1000/podman/podman.sock:/var/run/docker.sock # For Podman
16- ports:
17- - "8080:8080" # HTTP API (Gordon main server)
18- - "443:443" # HTTPS for reverse proxy
19- - "80:80" # HTTP for reverse proxy
20- environment:
21- # General Configuration
22- - GORDON_STORAGE_DIR=/data
23- - GORDON_TOKEN=your_secure_token
24-
25- # HTTP Configuration
26- - GORDON_HTTP_PORT=8080 # IMPORTANT: API port must be different from proxy HTTP port
27- - GORDON_HTTP_DOMAIN=example.com
28- - GORDON_HTTP_SUBDOMAIN=gordon
29- - GORDON_HTTP_HTTPS=true
30-
31- # Container Engine Configuration - Docker
32- - GORDON_CONTAINER_SOCK=/var/run/docker.sock
33- - GORDON_CONTAINER_NETWORK=gordon
34-
35- # Container Engine Configuration - Podman (Method 1: Auto-detection)
36- # Just specify the Podman socket path and Gordon will auto-detect Podman:
37- # - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock
38-
39- # Container Engine Configuration - Podman (Method 2: Explicit Configuration)
40- # - GORDON_CONTAINER_PODMAN=true
41- # - GORDON_CONTAINER_PODMAN_SOCK=/run/podman/podman.sock
42- # - GORDON_CONTAINER_SOCK=/run/podman/podman.sock
43-
44- # Reverse Proxy Configuration
45- - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
46- - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy
47- - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container)
48- - GORDON_PROXY_CERT_DIR=/certs
49- - GORDON_PROXY_EMAIL=admin@example.com
50- - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable)
51- - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable)
52-
53- # Session Secret for security
54- - SESSION_SECRET=replace_with_secure_random_string
55-
56- # Runtime Environment
57- - RUN_ENV=prod
58- restart: always
59- networks:
60- - gordon
61-
62-networks:
63- gordon:
64- external: false
65-
66-# -------------------------------------
67-# Example for Podman Users (podman-compose)
68-# -------------------------------------
69-# version: '3'
70-#
71-# services:
72-# gordon:
73-# image: ghcr.io/bnema/gordon:latest
74-# container_name: gordon
75-# volumes:
76-# - ./data:/data
77-# - ./data/config.yml:/config.yml
78-# - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates
79-# - /run/user/1000/podman/podman.sock:/var/run/docker.sock
80-# ports:
81-# - "8080:8080" # HTTP API (Gordon main server)
82-# - "443:443" # HTTPS for reverse proxy
83-# - "80:80" # HTTP for reverse proxy
84-# environment:
85-# # General Configuration
86-# - GORDON_STORAGE_DIR=/data
87-# - GORDON_TOKEN=your_secure_token
88-#
89-# # HTTP Configuration
90-# - GORDON_HTTP_PORT=8080 # API port must be different from proxy HTTP port
91-# - GORDON_HTTP_DOMAIN=example.com
92-# - GORDON_HTTP_SUBDOMAIN=gordon
93-# - GORDON_HTTP_HTTPS=true
94-#
95-# # Container Engine Configuration
96-# - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock
97-# - GORDON_CONTAINER_NETWORK=gordon
98-#
99-# # Reverse Proxy Configuration
100-# - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
101-# - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy
102-# - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container)
103-# - GORDON_PROXY_CERT_DIR=/certs
104-# - GORDON_PROXY_EMAIL=admin@example.com
105-# - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable)
106-# - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable)
107-#
108-# # Session Secret for security
109-# - SESSION_SECRET=replace_with_secure_random_string
110-#
111-# # Runtime Environment
112-# - RUN_ENV=prod
113-# restart: always
114-# networks:
115-# - gordon
116-#
117-# networks:
118-# gordon:
119-# external: false ⏎̸
D docs/CONTAINER_CONFIG.md +0 -232
@@ -1,232 +0,0 @@
1-# Configuring Gordon in a Container
2-
3-This document explains how to configure Gordon when running inside a Docker container using environment variables.
4-
5-## Container Detection
6-
7-Gordon automatically detects when it's running inside a container by checking for the presence of `/.iscontainer` or `/.dockerenv` files. When Gordon is running in a container, it:
8-
9-1. Uses the root directory (`/`) as its configuration directory
10-2. Looks for a directly mounted `/config.yml` file
11-3. Reads configuration from environment variables provided through Docker or docker-compose
12-
13-## Using docker-compose for Configuration
14-
15-The recommended way to configure Gordon in a container is through a `docker-compose.yml` file. Here's an example:
16-
17-```yaml
18-version: '3'
19-
20-services:
21- gordon:
22- image: ghcr.io/bnema/gordon:latest
23- container_name: gordon
24- volumes:
25- - ./data:/data
26- - ./data/config.yml:/config.yml # Mount config.yml directly at the root
27- - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management
28- ports:
29- - "8080:8080" # HTTP API (Gordon main server)
30- - "443:443" # HTTPS for reverse proxy
31- - "80:80" # HTTP for reverse proxy
32- environment:
33- # General Configuration
34- - GORDON_STORAGE_DIR=/data
35- - GORDON_TOKEN=your_secure_token
36-
37- # HTTP Configuration
38- - GORDON_HTTP_PORT=8080 # API port must be different from proxy HTTP port
39- - GORDON_HTTP_DOMAIN=example.com
40- - GORDON_HTTP_SUBDOMAIN=gordon
41- - GORDON_HTTP_HTTPS=true
42-
43- # Container Engine Configuration
44- - GORDON_CONTAINER_SOCK=/var/run/docker.sock
45- - GORDON_CONTAINER_NETWORK=gordon
46-
47- # Reverse Proxy Configuration
48- - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy
49- - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy
50- - GORDON_PROXY_CERT_DIR=/certs
51- - GORDON_PROXY_EMAIL=admin@example.com
52-
53- # Session Secret for security
54- - SESSION_SECRET=replace_with_secure_random_string
55-
56- # Runtime Environment
57- - RUN_ENV=prod
58- restart: always
59- networks:
60- - gordon
61-
62-networks:
63- gordon:
64- external: false
65-```
66-
67-## Port Configuration
68-
69-Gordon runs two HTTP servers:
70-
71-1. **API Server**: Defaults to port 8080 in containers (configurable with `GORDON_HTTP_PORT`)
72-2. **Reverse Proxy**: Uses ports 80 (HTTP) and 443 (HTTPS) for the reverse proxy (configurable with `GORDON_PROXY_HTTP_PORT` and `GORDON_PROXY_PORT`)
73-
74-⚠️ **Important**: The API server port (`GORDON_HTTP_PORT`) must be different from the reverse proxy HTTP port (`GORDON_PROXY_HTTP_PORT`) to avoid conflicts. By default, they are set to 8080 and 80 respectively.
75-
76-## Using with Podman
77-
78-Gordon now automatically detects Podman based on the socket path. You can use Podman in one of two ways:
79-
80-### Method 1: Implicit Podman Detection (Recommended)
81-
82-Simply mount the Podman socket and set the container socket path:
83-
84-```yaml
85-volumes:
86- - /run/user/1000/podman/podman.sock:/var/run/docker.sock
87-environment:
88- - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock
89-```
90-
91-Gordon will automatically detect that this is a Podman socket and enable Podman support without requiring additional configuration.
92-
93-### Method 2: Explicit Podman Configuration
94-
95-Alternatively, you can explicitly enable Podman mode with:
96-
97-```yaml
98-environment:
99- - GORDON_CONTAINER_PODMAN=true
100- - GORDON_CONTAINER_PODMAN_SOCK=/run/podman/podman.sock
101- - GORDON_CONTAINER_SOCK=/run/podman/podman.sock
102-```
103-
104-### Socket Path Auto-Detection
105-
106-Gordon will automatically try different common socket paths if the specified path doesn't work:
107-- `/run/podman/podman.sock` (Standard path)
108-- `/var/run/podman/podman.sock` (Alternative standard path)
109-- `$HOME/.local/podman/podman.sock` (Some distros)
110-- `/run/user/1000/podman/podman.sock` (User-specific socket)
111-- `/var/run/docker.sock` (Docker fallback)
112-
113-### Common Podman Issues
114-
115-If you encounter errors like `no such file or directory` when accessing the Podman socket:
116-
117-1. **Check socket permissions**: Make sure your user has permission to access the Podman socket
118-2. **Verify socket exists**: Run `ls -la /run/podman/podman.sock` to verify the socket exists
119-3. **Enable Podman socket service**: Run `systemctl --user enable --now podman.socket`
120-
121-For rootless Podman, the socket might be at `/run/user/$(id -u)/podman/podman.sock`. Check with:
122-```bash
123-echo "/run/user/$(id -u)/podman/podman.sock"
124-```
125-
126-## Config File Persistence
127-
128-To ensure your configuration persists between container restarts, mount the `config.yml` file directly:
129-
130-```yaml
131-volumes:
132- - ./data/config.yml:/config.yml
133-```
134-
135-This is important as Gordon looks for a file at `/config.yml` in the container.
136-
137-## Available Environment Variables
138-
139-Gordon supports the following environment variables for configuration:
140-
141-### General Configuration
142-
143-| Environment Variable | Description | Default |
144-|---------------------|-------------|---------|
145-| `GORDON_STORAGE_DIR` | Directory for Gordon to store data | `~/.gordon` |
146-| `GORDON_TOKEN` | Authentication token | Empty |
147-
148-### HTTP Configuration
149-
150-| Environment Variable | Description | Default |
151-|---------------------|-------------|---------|
152-| `GORDON_HTTP_PORT` | HTTP port for Gordon API | `8080` |
153-| `GORDON_HTTP_DOMAIN` | Domain name | `localhost` |
154-| `GORDON_HTTP_SUBDOMAIN` | Subdomain | Empty |
155-| `GORDON_HTTP_BACKEND_URL` | Backend URL | Empty |
156-| `GORDON_HTTP_HTTPS` | Enable HTTPS | `true` |
157-
158-### Admin Configuration
159-
160-| Environment Variable | Description | Default |
161-|---------------------|-------------|---------|
162-| `GORDON_ADMIN_PATH` | Admin panel path | `/admin` |
163-
164-### Container Engine Configuration
165-
166-| Environment Variable | Description | Default |
167-|---------------------|-------------|---------|
168-| `GORDON_CONTAINER_SOCK` | Docker socket path | `/var/run/docker.sock` |
169-| `GORDON_CONTAINER_PODMAN_SOCK` | Podman socket path | `/run/podman/podman.sock` |
170-| `GORDON_CONTAINER_PODMAN` | Use Podman instead of Docker | `true` |
171-| `GORDON_CONTAINER_NETWORK` | Container network | `gordon` |
172-
173-### Reverse Proxy Configuration
174-
175-| Environment Variable | Description | Default |
176-|---------------------|-------------|---------|
177-| `GORDON_PROXY_PORT` | HTTPS port for reverse proxy | `443` |
178-| `GORDON_PROXY_HTTP_PORT` | HTTP port for reverse proxy | `80` |
179-| `GORDON_PROXY_CERT_DIR` | Directory for Let's Encrypt certificates | `/certs` |
180-| `GORDON_PROXY_AUTO_RENEW` | Auto-renew certificates | `true` |
181-| `GORDON_PROXY_RENEW_BEFORE` | Days before expiry to renew certificates | `30` |
182-| `GORDON_PROXY_LETSENCRYPT_MODE` | Let's Encrypt mode (`staging` or `production`) | `staging` |
183-| `GORDON_PROXY_EMAIL` | Email for Let's Encrypt | Empty |
184-| `GORDON_PROXY_CACHE_SIZE` | Certificate cache size | `1000` |
185-| `GORDON_PROXY_GRACE_PERIOD` | Shutdown grace period in seconds | `30` |
186-| `GORDON_PROXY_ENABLE_LOGS` | Controls whether HTTP request logs are enabled for the proxy | `true` |
187-
188-### Other Configuration
189-
190-| Environment Variable | Description | Default |
191-|---------------------|-------------|---------|
192-| `SESSION_SECRET` | Secret for session encryption | Required |
193-| `RUN_ENV` | Runtime environment (`dev` or `prod`) | `prod` |
194-
195-## Default Values for Configuration
196-
197-Gordon now automatically applies default values for critical configuration settings that are missing or set to zero values in your config.yml. This ensures that your reverse proxy and Let's Encrypt integration work properly without requiring you to manually specify every field.
198-
199-### Important Default Values Applied
200-
201-If not specified in your config.yml, Gordon will automatically apply these recommended values:
202-
203-| Setting | Default Value | Why It's Important |
204-|---------|--------------|---------------------|
205-| `ReverseProxy.RenewBefore` | 30 days | Ensures certificates are renewed well before expiry |
206-| `ReverseProxy.CacheSize` | 1000 entries | Optimizes performance when serving multiple domains |
207-| `ReverseProxy.GracePeriod` | 30 seconds | Allows for graceful server shutdowns |
208-| `ReverseProxy.AutoRenew` | true | Prevents certificate expiration issues |
209-| `ReverseProxy.EnableLogs` | true | Controls whether HTTP request logs are enabled for the proxy |
210-| `ReverseProxy.LetsEncryptMode` | staging | Safe default for testing; change to "production" for real certificates |
211-
212-This behavior prevents issues that could occur with zero values like:
213-- Certificates only being renewed at the last moment (or not at all)
214-- Poor performance due to disabled caching
215-- Abrupt connection termination during shutdowns
216-
217-These defaults are applied when:
218-1. A new configuration file is created
219-2. An existing configuration file is loaded but is missing these values
220-3. The values are explicitly set to zero or empty in your configuration
221-
222-You can always override these defaults by explicitly setting values in your config.yml or through environment variables.
223-
224-## Secrets Management
225-
226-For sensitive values like `GORDON_TOKEN` and `SESSION_SECRET`, consider using Docker secrets or a proper secrets management system in production deployments.
227-
228-## Persistence
229-
230-To ensure your data persists between container restarts, mount a volume to the `/data` directory or the path specified in `GORDON_STORAGE_DIR`:
231-
232-```⏎̸
M go.mod +37 -254
@@ -1,274 +1,57 @@
1-module github.com/bnema/gordon
1+module gordon
22
3-go 1.24.1
3+go 1.24.3
44
55 require (
6- github.com/AlecAivazis/survey/v2 v2.3.7
7- github.com/Masterminds/semver/v3 v3.3.1
8- github.com/a-h/templ v0.3.833
9- github.com/charmbracelet/bubbles v0.20.0
10- github.com/charmbracelet/lipgloss v1.0.0
11- github.com/charmbracelet/log v0.4.0
12- github.com/docker/docker v28.0.1+incompatible
6+ github.com/docker/docker v28.2.2+incompatible
137 github.com/docker/go-connections v0.5.0
14- github.com/fatih/color v1.18.0
15- github.com/go-acme/lego/v4 v4.22.2
16- github.com/gorilla/sessions v1.4.0
17- github.com/joho/godotenv v1.5.1
18- github.com/labstack/echo-contrib v0.17.2
19- github.com/labstack/echo/v4 v4.13.3
20- github.com/microcosm-cc/bluemonday v1.0.27
8+ github.com/rs/zerolog v1.34.0
219 github.com/spf13/cobra v1.9.1
22- github.com/starskey-io/starskey v0.1.8
23- gopkg.in/yaml.v3 v3.0.1
24- modernc.org/sqlite v1.36.0
10+ github.com/spf13/viper v1.20.1
2511 )
2612
27-require github.com/PuerkitoBio/goquery v1.10.2 // indirect
28-
2913 require (
30- cloud.google.com/go/auth v0.13.0 // indirect
31- cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
32- cloud.google.com/go/compute/metadata v0.6.0 // indirect
33- github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
34- github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
35- github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 // indirect
36- github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 // indirect
37- github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
38- github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
39- github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
40- github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
41- github.com/Azure/go-autorest v14.2.0+incompatible // indirect
42- github.com/Azure/go-autorest/autorest v0.11.29 // indirect
43- github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect
44- github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect
45- github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect
46- github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
47- github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
48- github.com/Azure/go-autorest/logger v0.2.1 // indirect
49- github.com/Azure/go-autorest/tracing v0.6.0 // indirect
50- github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
51- github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
52- github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
53- github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
54- github.com/aliyun/alibaba-cloud-sdk-go v1.63.72 // indirect
55- github.com/andybalholm/brotli v1.1.0 // indirect
56- github.com/aws/aws-sdk-go-v2 v1.32.7 // indirect
57- github.com/aws/aws-sdk-go-v2/config v1.28.7 // indirect
58- github.com/aws/aws-sdk-go-v2/credentials v1.17.48 // indirect
59- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 // indirect
60- github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 // indirect
61- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 // indirect
62- github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
63- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
64- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect
65- github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.8 // indirect
66- github.com/aws/aws-sdk-go-v2/service/route53 v1.46.4 // indirect
67- github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 // indirect
68- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 // indirect
69- github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 // indirect
70- github.com/aws/smithy-go v1.22.1 // indirect
71- github.com/benbjohnson/clock v1.3.0 // indirect
72- github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
73- github.com/cenkalti/backoff/v4 v4.3.0 // indirect
74- github.com/cespare/xxhash/v2 v2.3.0 // indirect
75- github.com/civo/civogo v0.3.11 // indirect
76- github.com/cli/browser v1.3.0 // indirect
77- github.com/cloudflare/cloudflare-go v0.112.0 // indirect
78- github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
79- github.com/dimchansky/utfbom v1.1.1 // indirect
80- github.com/dnsimple/dnsimple-go v1.7.0 // indirect
81- github.com/exoscale/egoscale/v3 v3.1.7 // indirect
82- github.com/fatih/structs v1.1.0 // indirect
83- github.com/fsnotify/fsnotify v1.8.0 // indirect
84- github.com/gabriel-vasile/mimetype v1.4.2 // indirect
85- github.com/ghodss/yaml v1.0.0 // indirect
86- github.com/go-errors/errors v1.0.1 // indirect
87- github.com/go-jose/go-jose/v4 v4.0.4 // indirect
88- github.com/go-playground/locales v0.14.1 // indirect
89- github.com/go-playground/universal-translator v0.18.1 // indirect
90- github.com/go-playground/validator/v10 v10.16.0 // indirect
91- github.com/go-resty/resty/v2 v2.16.2 // indirect
92- github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
93- github.com/goccy/go-json v0.10.4 // indirect
94- github.com/gofrs/flock v0.12.1 // indirect
95- github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
96- github.com/golang-jwt/jwt/v5 v5.2.2 // direct
97- github.com/google/go-querystring v1.1.0 // indirect
98- github.com/google/s2a-go v0.1.8 // indirect
99- github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
100- github.com/googleapis/gax-go/v2 v2.14.0 // indirect
101- github.com/gophercloud/gophercloud v1.14.1 // indirect
102- github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
103- github.com/hashicorp/errwrap v1.0.0 // indirect
104- github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
105- github.com/hashicorp/go-multierror v1.1.1 // indirect
106- github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
107- github.com/hashicorp/go-uuid v1.0.3 // indirect
108- github.com/hashicorp/hcl v1.0.0 // indirect
109- github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.128 // indirect
110- github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
111- github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
112- github.com/jmespath/go-jmespath v0.4.0 // indirect
113- github.com/json-iterator/go v1.1.12 // indirect
114- github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
115- github.com/klauspost/compress v1.18.0 // indirect
116- github.com/klauspost/cpuid/v2 v2.2.10 // indirect
117- github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
118- github.com/kylelemons/godebug v1.1.0 // indirect
119- github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
120- github.com/labbsr0x/goh v1.0.1 // indirect
121- github.com/leodido/go-urn v1.2.4 // indirect
122- github.com/linode/linodego v1.44.0 // indirect
123- github.com/liquidweb/liquidweb-cli v0.6.9 // indirect
124- github.com/liquidweb/liquidweb-go v1.6.4 // indirect
125- github.com/magiconair/properties v1.8.7 // indirect
126- github.com/miekg/dns v1.1.62 // indirect
127- github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
128- github.com/mitchellh/go-homedir v1.1.0 // indirect
129- github.com/mitchellh/mapstructure v1.5.0 // indirect
130- github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
131- github.com/modern-go/reflect2 v1.0.2 // indirect
132- github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
133- github.com/natefinch/atomic v1.0.1 // indirect
134- github.com/nrdcg/auroradns v1.1.0 // indirect
135- github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 // indirect
136- github.com/nrdcg/desec v0.10.0 // indirect
137- github.com/nrdcg/dnspod-go v0.4.0 // indirect
138- github.com/nrdcg/freemyip v0.3.0 // indirect
139- github.com/nrdcg/goacmedns v0.2.0 // indirect
140- github.com/nrdcg/goinwx v0.10.0 // indirect
141- github.com/nrdcg/mailinabox v0.2.0 // indirect
142- github.com/nrdcg/namesilo v0.2.1 // indirect
143- github.com/nrdcg/nodion v0.1.0 // indirect
144- github.com/nrdcg/porkbun v0.4.0 // indirect
145- github.com/nzdjb/go-metaname v1.0.0 // indirect
146- github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
147- github.com/oracle/oci-go-sdk/v65 v65.81.1 // indirect
148- github.com/ovh/go-ovh v1.6.0 // indirect
149- github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
150- github.com/pelletier/go-toml/v2 v2.1.0 // indirect
151- github.com/peterhellberg/link v1.2.0 // indirect
152- github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
153- github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
154- github.com/pquerna/otp v1.4.0 // indirect
155- github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect
156- github.com/sacloud/api-client-go v0.2.10 // indirect
157- github.com/sacloud/go-http v0.1.8 // indirect
158- github.com/sacloud/iaas-api-go v1.14.0 // indirect
159- github.com/sacloud/packages-go v0.0.10 // indirect
160- github.com/sagikazarmark/locafero v0.4.0 // indirect
161- github.com/sagikazarmark/slog-shim v0.1.0 // indirect
162- github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 // indirect
163- github.com/selectel/domains-go v1.1.0 // indirect
164- github.com/selectel/go-selvpcclient/v3 v3.2.1 // indirect
165- github.com/shopspring/decimal v1.3.1 // indirect
166- github.com/sirupsen/logrus v1.9.3 // indirect
167- github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
168- github.com/softlayer/softlayer-go v1.1.7 // indirect
169- github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
170- github.com/sony/gobreaker v0.5.0 // indirect
171- github.com/sourcegraph/conc v0.3.0 // indirect
172- github.com/spf13/afero v1.11.0 // indirect
173- github.com/spf13/cast v1.6.0 // indirect
174- github.com/spf13/viper v1.18.2 // indirect
175- github.com/stretchr/testify v1.10.0 // indirect
176- github.com/subosito/gotenv v1.6.0 // indirect
177- github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1065 // indirect
178- github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1065 // indirect
179- github.com/tjfoc/gmsm v1.4.1 // indirect
180- github.com/transip/gotransip/v6 v6.26.0 // indirect
181- github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect
182- github.com/vinyldns/go-vinyldns v0.9.16 // indirect
183- github.com/volcengine/volc-sdk-golang v1.0.189 // indirect
184- github.com/vultr/govultr/v3 v3.9.1 // indirect
185- github.com/yandex-cloud/go-genproto v0.0.0-20241220122821-aeb3b05efd1c // indirect
186- github.com/yandex-cloud/go-sdk v0.0.0-20241220131134-2393e243c134 // indirect
187- github.com/zeebo/xxh3 v1.0.2 // indirect
188- go.mongodb.org/mongo-driver v1.17.3 // indirect
189- go.opentelemetry.io/auto/sdk v1.1.0 // indirect
190- go.uber.org/atomic v1.11.0 // indirect
191- go.uber.org/multierr v1.9.0 // indirect
192- go.uber.org/ratelimit v0.3.0 // indirect
193- golang.org/x/mod v0.24.0 // indirect
194- golang.org/x/oauth2 v0.24.0 // indirect
195- golang.org/x/tools v0.31.0 // indirect
196- google.golang.org/api v0.214.0 // indirect
197- google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect
198- google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
199- google.golang.org/grpc v1.69.2 // indirect
200- google.golang.org/protobuf v1.36.1 // indirect
201- gopkg.in/ini.v1 v1.67.0 // indirect
202- gopkg.in/ns1/ns1-go.v2 v2.13.0 // indirect
203- gopkg.in/yaml.v2 v2.4.0 // indirect
204-)
205-
206-require (
207- github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
208- github.com/charmbracelet/bubbletea v1.3.4 // direct
209- github.com/charmbracelet/x/ansi v0.8.0 // indirect
210- github.com/charmbracelet/x/term v0.2.1 // indirect
14+ github.com/Microsoft/go-winio v0.4.14 // indirect
15+ github.com/containerd/errdefs v1.0.0 // indirect
16+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
21117 github.com/containerd/log v0.1.0 // indirect
212- github.com/creack/pty v1.1.18 // indirect
21318 github.com/distribution/reference v0.6.0 // indirect
214- github.com/dustin/go-humanize v1.0.1 // indirect
215- github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
19+ github.com/docker/go-units v0.5.0 // indirect
21620 github.com/felixge/httpsnoop v1.0.4 // indirect
217- github.com/go-logfmt/logfmt v0.6.0 // indirect
21+ github.com/fsnotify/fsnotify v1.8.0 // indirect
21822 github.com/go-logr/logr v1.4.2 // indirect
21923 github.com/go-logr/stdr v1.2.2 // indirect
24+ github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
25+ github.com/gogo/protobuf v1.3.2 // indirect
26+ github.com/golang-jwt/jwt/v5 v5.2.2 // direct
22027 github.com/inconshreveable/mousetrap v1.1.0 // indirect
221- github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
222- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
223- github.com/mattn/go-localereader v0.0.1 // indirect
224- github.com/mattn/go-runewidth v0.0.16 // indirect
225- github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
28+ github.com/mattn/go-colorable v0.1.13 // indirect
29+ github.com/mattn/go-isatty v0.0.19 // indirect
22630 github.com/moby/docker-image-spec v1.3.1 // indirect
227- github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
228- github.com/muesli/cancelreader v0.2.2 // indirect
229- github.com/muesli/termenv v0.16.0 // indirect
230- github.com/ncruces/go-strftime v0.1.9 // indirect
231- github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
232- github.com/rivo/uniseg v0.4.7 // indirect
233- github.com/spf13/pflag v1.0.6 // indirect
234- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
235- go.opentelemetry.io/otel v1.35.0 // indirect
236- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
237- go.opentelemetry.io/otel/metric v1.35.0 // indirect
238- go.opentelemetry.io/otel/trace v1.35.0 // indirect
239- golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // direct
240- golang.org/x/sync v0.12.0 // indirect
241- golang.org/x/term v0.30.0 // indirect
242- google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect
243- modernc.org/libc v1.61.13 // indirect
244- modernc.org/mathutil v1.7.1 // indirect
245- modernc.org/memory v1.8.2 // indirect
246-)
247-
248-require (
249- github.com/Microsoft/go-winio v0.6.2 // indirect
250- github.com/andybalholm/cascadia v1.3.3 // indirect
251- github.com/aymerick/douceur v0.2.0 // indirect
252- github.com/docker/go-units v0.5.0
253- github.com/gogo/protobuf v1.3.2 // indirect
254- github.com/google/uuid v1.6.0 // direct
255- github.com/gorilla/context v1.1.2 // indirect
256- github.com/gorilla/css v1.0.1 // indirect
257- github.com/gorilla/securecookie v1.1.2 // indirect
258- github.com/labstack/gommon v0.4.2 // indirect
259- github.com/mattn/go-colorable v0.1.14 // indirect
260- github.com/mattn/go-isatty v0.0.20 // indirect
261- github.com/moby/term v0.5.0 // indirect
31+ github.com/moby/sys/atomicwriter v0.1.0 // indirect
32+ github.com/moby/term v0.5.2 // indirect
26233 github.com/morikuni/aec v1.0.0 // indirect
26334 github.com/opencontainers/go-digest v1.0.0 // indirect
26435 github.com/opencontainers/image-spec v1.1.1 // indirect
36+ github.com/pelletier/go-toml/v2 v2.2.3 // indirect
26537 github.com/pkg/errors v0.9.1 // indirect
266- github.com/valyala/bytebufferpool v1.0.0 // indirect
267- github.com/valyala/fasttemplate v1.2.2 // indirect
268- golang.org/x/crypto v0.36.0 // indirect
269- golang.org/x/net v0.37.0 // indirect
270- golang.org/x/sys v0.31.0 // indirect
271- golang.org/x/text v0.23.0 // indirect
272- golang.org/x/time v0.11.0 // direct
273- gotest.tools/v3 v3.5.0 // indirect
38+ github.com/sagikazarmark/locafero v0.7.0 // indirect
39+ github.com/sourcegraph/conc v0.3.0 // indirect
40+ github.com/spf13/afero v1.12.0 // indirect
41+ github.com/spf13/cast v1.7.1 // indirect
42+ github.com/spf13/pflag v1.0.6 // indirect
43+ github.com/subosito/gotenv v1.6.0 // indirect
44+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
45+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
46+ go.opentelemetry.io/otel v1.36.0 // indirect
47+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
48+ go.opentelemetry.io/otel/metric v1.36.0 // indirect
49+ go.opentelemetry.io/otel/sdk v1.36.0 // indirect
50+ go.opentelemetry.io/otel/trace v1.36.0 // indirect
51+ go.uber.org/atomic v1.9.0 // indirect
52+ go.uber.org/multierr v1.9.0 // indirect
53+ golang.org/x/sys v0.33.0 // indirect
54+ golang.org/x/text v0.25.0 // indirect
55+ gopkg.in/yaml.v3 v3.0.1 // indirect
56+ gotest.tools/v3 v3.5.2 // indirect
27457 )
M go.sum +79 -1472 generated file: collapsed
A gordon.hcl +13 -0
@@ -0,0 +1,13 @@
1+server {
2+ port = 8080
3+ registry_port = 5000
4+ runtime = "docker"
5+ ssl_email = "admin@example.com"
6+ data_dir = "./data"
7+}
8+
9+routes = {
10+ "example.com" = "nginx:latest"
11+ "api.example.com" = "myapi:latest"
12+ "http://dev.example.com" = "myblog:latest"
13+}⏎̸
A gordon.toml +32 -0
@@ -0,0 +1,32 @@
1+[server]
2+port = 8080
3+registry_port = 5000
4+runtime = "docker"
5+ssl_email = "admin@example.com"
6+data_dir = "./data"
7+
8+# Authentication and Security Configuration
9+[auth]
10+enabled = false # Set to true to enable authentication
11+method = "basic" # Authentication method: "jwt", "api_key", or "basic"
12+
13+# JWT Authentication (recommended for production)
14+jwt_secret = "" # Generate with: gordon generate jwt-secret
15+
16+# API Key Authentication (simple but secure)
17+api_key = "" # Generate with: gordon generate api-key
18+
19+# Basic Authentication (least secure, use only for development)
20+username = "" # Username for Basic Auth
21+password = "" # Password for Basic Auth
22+
23+# Registry Authentication
24+registry_auth = false # Enable authentication for Docker registry push/pull
25+
26+# IP Whitelist (additional security layer)
27+allowed_ips = ["127.0.0.1", "::1"] # Whitelist of IPs allowed to access management API
28+
29+[routes]
30+"example.com" = "nginx:latest"
31+"api.example.com" = "myapi:latest"
32+"http://dev.example.com" = "myblog:latest"⏎̸
A gordon.yaml +11 -0
@@ -0,0 +1,11 @@
1+server:
2+ port: 8080
3+ registry_port: 5000
4+ runtime: docker
5+ ssl_email: admin@example.com
6+ data_dir: ./data
7+
8+routes:
9+ "example.com": "nginx:latest"
10+ "api.example.com": "myapi:latest"
11+ "http://dev.example.com": "myblog:latest"⏎̸
D internal/cli/auth/device_flow.go +0 -124
@@ -1,124 +0,0 @@
1-// internal/cli/auth/device_flow.go
2-package auth
3-
4-import (
5- "encoding/json"
6- "fmt"
7- "io"
8- "net/http"
9- "net/url"
10- "time"
11-
12- "github.com/bnema/gordon/internal/cli"
13-)
14-
15-func DeviceFlowAuth(a *cli.App) (token string, err error) {
16- fmt.Println("Starting device flow authentication...")
17-
18- // Request device code
19- deviceCode, err := requestDeviceCode(a)
20- if err != nil {
21- return "", fmt.Errorf("error requesting device code: %w", err)
22- }
23-
24- fmt.Printf("Please go to %v and enter the code: %v\n", deviceCode["verification_uri"], deviceCode["user_code"])
25-
26- // Poll for access token
27- interval := 5 // Default to 5 seconds if not provided
28- if i, ok := deviceCode["interval"].(float64); ok {
29- interval = int(i)
30- }
31-
32- token, err = pollForAccessToken(a, deviceCode["device_code"].(string), interval)
33- if err != nil {
34- return "", fmt.Errorf("error polling for access token: %w", err)
35- }
36-
37- // Save the obtained JWT into the JWTSecret field in the config
38- a.Config.General.JwtToken = token
39- err = a.Config.SaveConfig() // Save the updated config
40- if err != nil {
41- // Log the error but don't necessarily fail the whole flow,
42- // as the token might still be usable in memory for the current session.
43- fmt.Printf("\nWarning: Failed to save token to config file: %v\n", err)
44- fmt.Println("Authentication successful, but token might not persist.")
45- } else {
46- fmt.Println("Authentication successful! Token saved to configuration.")
47- }
48-
49- return token, nil
50-}
51-
52-func requestDeviceCode(a *cli.App) (map[string]interface{}, error) {
53- resp, err := http.PostForm(a.Config.GetBackendURL()+"/api/device/code", url.Values{})
54- if err != nil {
55- return nil, fmt.Errorf("error making request: %w", err)
56- }
57- defer resp.Body.Close()
58-
59- body, err := io.ReadAll(resp.Body)
60- if err != nil {
61- return nil, fmt.Errorf("error reading response body: %w", err)
62- }
63-
64- if resp.StatusCode != http.StatusOK {
65- return nil, fmt.Errorf("server returned non-200 status code: %d. Body: %s", resp.StatusCode, string(body))
66- }
67-
68- var deviceCode map[string]interface{}
69- if err := json.Unmarshal(body, &deviceCode); err != nil {
70- return nil, fmt.Errorf("error parsing JSON response: %w", err)
71- }
72-
73- // Check for required fields
74- requiredFields := []string{"verification_uri", "user_code", "device_code", "interval"}
75- for _, field := range requiredFields {
76- if _, ok := deviceCode[field]; !ok {
77- return nil, fmt.Errorf("missing required field in response: %s", field)
78- }
79- }
80-
81- return deviceCode, nil
82-}
83-
84-func pollForAccessToken(a *cli.App, deviceCode string, interval int) (string, error) {
85- maxAttempts := 60 // Maximum number of attempts (5 minutes with 5-second intervals)
86- for attempt := 0; attempt < maxAttempts; attempt++ {
87- time.Sleep(time.Duration(interval) * time.Second)
88-
89- resp, err := http.PostForm(a.Config.GetBackendURL()+"/api/device/token", url.Values{
90- "device_code": {deviceCode},
91- })
92- if err != nil {
93- continue
94- }
95- defer resp.Body.Close()
96-
97- body, err := io.ReadAll(resp.Body)
98- if err != nil {
99- continue
100- }
101-
102- var tokenResp map[string]interface{}
103- if err := json.Unmarshal(body, &tokenResp); err != nil {
104- continue
105- }
106-
107- // Expect "jwt_token" instead of "access_token"
108- if jwtToken, ok := tokenResp["jwt_token"].(string); ok && jwtToken != "" {
109- fmt.Println() // Print a newline after successful polling dots
110- return jwtToken, nil
111- }
112-
113- if errorMsg, ok := tokenResp["error"].(string); ok && errorMsg == "authorization_pending" {
114- // Optionally, you can print a dot to show progress without cluttering the output
115- fmt.Print(".")
116- continue
117- }
118-
119- // If we get here, there's an unexpected response but we can continue to try
120- // fmt.Printf("Unexpected response from server (attempt %d): %v\n", attempt+1, tokenResp)
121- }
122-
123- return "", fmt.Errorf("max attempts reached, failed to obtain access token")
124-}
D internal/cli/auth/token_cache.go +0 -87
@@ -1,87 +0,0 @@
1-package auth
2-
3-import (
4- "sync"
5- "time"
6-
7- "github.com/bnema/gordon/internal/db"
8- "github.com/charmbracelet/log"
9-)
10-
11-type TokenCache struct {
12- cache sync.Map
13-}
14-
15-type cacheEntry struct {
16- token string
17- expiration time.Time
18- githubUser *db.GithubUserInfo
19-}
20-
21-var (
22- defaultCache *TokenCache
23- once sync.Once
24-)
25-
26-// GetTokenCache returns a singleton instance of TokenCache
27-func GetTokenCache() *TokenCache {
28- once.Do(func() {
29- defaultCache = &TokenCache{}
30- log.Debug("initialized token cache singleton")
31- })
32- return defaultCache
33-}
34-
35-// GetWithUser retrieves a token and GitHub user info from the cache
36-func (tc *TokenCache) GetWithUser(key string) (string, *db.GithubUserInfo, bool) {
37- if value, ok := tc.cache.Load(key); ok {
38- entry := value.(cacheEntry)
39- if time.Now().Before(entry.expiration) {
40- log.Debug("cache hit", "key", key, "expires_in", time.Until(entry.expiration))
41- return entry.token, entry.githubUser, true
42- }
43- log.Debug("cache entry expired", "key", key)
44- tc.cache.Delete(key)
45- }
46- log.Debug("cache miss", "key", key)
47- return "", nil, false
48-}
49-
50-// Get retrieves just the token validation status
51-func (tc *TokenCache) Get(key string) (bool, bool) {
52- if value, ok := tc.cache.Load(key); ok {
53- entry := value.(cacheEntry)
54- if time.Now().Before(entry.expiration) {
55- log.Debug("cache hit (validation status)", "key", key, "expires_in", time.Until(entry.expiration))
56- return true, true
57- }
58- log.Debug("cache entry expired (validation status)", "key", key)
59- tc.cache.Delete(key)
60- }
61- log.Debug("cache miss (validation status)", "key", key)
62- return false, false
63-}
64-
65-// SetWithUser stores a token and GitHub user info in the cache
66-func (tc *TokenCache) SetWithUser(key string, token string, user *db.GithubUserInfo, duration time.Duration) {
67- tc.cache.Store(key, cacheEntry{
68- token: token,
69- githubUser: user,
70- expiration: time.Now().Add(duration),
71- })
72- log.Debug("stored token with user info in cache",
73- "key", key,
74- "duration", duration,
75- "github_user", user.Login)
76-}
77-
78-// Set stores just the token validation status
79-func (tc *TokenCache) Set(key string, duration time.Duration) {
80- tc.cache.Store(key, cacheEntry{
81- token: key,
82- expiration: time.Now().Add(duration),
83- })
84- log.Debug("stored token validation status in cache",
85- "key", key,
86- "duration", duration)
87-}
D internal/cli/auth/token_check.go +0 -64
@@ -1,64 +0,0 @@
1-package auth
2-
3-import (
4- "net/http"
5- "strings"
6- "time"
7-
8- "github.com/charmbracelet/log"
9-)
10-
11-// IsValidToken validates a GitHub token
12-func IsValidGitHubToken(token string) bool {
13- // Use the existing token cache
14- tokenCache := GetTokenCache()
15-
16- // Check if token exists in cache
17- if _, _, found := tokenCache.GetWithUser(token); found {
18- log.Debug("token found in cache")
19- return true
20- }
21-
22- // If not in cache, validate with GitHub
23- client := &http.Client{Timeout: 10 * time.Second}
24- req, err := http.NewRequest("GET", "https://api.github.com/user", nil)
25- if err != nil {
26- log.Error("failed to create GitHub API request", "error", err)
27- return false
28- }
29-
30- req.Header.Set("Authorization", "token "+token)
31- req.Header.Set("Accept", "application/vnd.github.v3+json")
32-
33- resp, err := client.Do(req)
34- if err != nil {
35- log.Error("GitHub API request failed", "error", err)
36- return false
37- }
38- defer resp.Body.Close()
39-
40- if resp.StatusCode != http.StatusOK {
41- log.Warn("invalid GitHub token", "status", resp.Status)
42- return false
43- }
44-
45- return true
46-}
47-
48-// ExtractAndValidateToken extracts and validates a token from an Authorization header
49-func ExtractAndValidateToken(authHeader string) (string, bool) {
50- if authHeader == "" {
51- return "", false
52- }
53-
54- token := strings.TrimPrefix(authHeader, "Bearer ")
55- if token == "" {
56- return "", false
57- }
58-
59- if !IsValidGitHubToken(token) {
60- return "", false
61- }
62-
63- return token, true
64-}
D internal/cli/cmd/deploy.go +0 -252
@@ -1,252 +0,0 @@
1-// gordon/internal/cli/cmd/deploy.go
2-
3-package cmd
4-
5-import (
6- "bytes"
7- "context"
8- "encoding/json"
9- "fmt"
10- "io"
11- "net/http"
12- "os"
13- "strings"
14- "time"
15-
16- "github.com/bnema/gordon/internal/cli"
17- "github.com/bnema/gordon/internal/cli/handler"
18- "github.com/bnema/gordon/internal/common"
19- "github.com/bnema/gordon/pkg/logger"
20- "github.com/spf13/cobra"
21-)
22-
23-// No need for logger initialization here as it's handled by the centralized logger
24-
25-func NewDeployCommand(a *cli.App) *cobra.Command {
26- var port string
27- var targetDomain string
28- var skipProxySetup bool
29-
30- deployCmd := &cobra.Command{
31- Use: "deploy [image:tag]",
32- Short: "Deploy an image to your remote Gordon instance",
33- Args: cobra.ExactArgs(1),
34- PreRun: func(cmd *cobra.Command, args []string) {
35- if err := handler.FieldCheck(a); err != nil {
36- logger.Error("Field check failed", "error", err)
37- os.Exit(1)
38- }
39- },
40- Run: func(cmd *cobra.Command, args []string) {
41- imageName := args[0]
42- logger.Info("Pushing image", "image", imageName)
43-
44- if err := validateDeployInputs(imageName, port, targetDomain); err != nil {
45- logger.Error("Validation failed", "error", err)
46- return
47- }
48-
49- reader, actualSize, err := handler.ExportDockerImage(imageName)
50- if err != nil {
51- logger.Error("Error exporting image", "error", err)
52- return
53- }
54- defer reader.Close()
55-
56- sizeInMB := float64(actualSize) / 1024 / 1024
57-
58- logger.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB))
59-
60- if err := deployImage(a, reader, imageName, port, targetDomain, skipProxySetup); err != nil {
61- os.Exit(1)
62- }
63- },
64- }
65-
66- deployCmd.Flags().StringVarP(&port, "port", "p", "", "Container port for the proxy to route traffic to")
67- deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for the proxy")
68- deployCmd.Flags().BoolVar(&skipProxySetup, "skip-proxy", false, "Skip proxy setup (create container only)")
69-
70- return deployCmd
71-}
72-
73-func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string, skipProxySetup bool) error {
74- // Check authentication early
75- if err := handler.CheckAndRefreshAuth(a); err != nil {
76- logger.Error("Authentication check failed", "error", err)
77- return err
78- }
79-
80- logger.Info("Deploying image", "image", imageName)
81- // Check for conflicts first
82- resp, err := checkDeployConflict(a, targetDomain, port)
83- if err != nil {
84- return fmt.Errorf("conflict check failed: %w", err)
85- }
86-
87- var shortID string
88- if len(resp.ContainerID) >= 12 {
89- shortID = resp.ContainerID[:12]
90- } else {
91- shortID = resp.ContainerID // Use full ID if less than 12 chars
92- }
93-
94- // If there's a conflict, handle it before proceeding
95- if !resp.Success && resp.ContainerID != "" {
96- logger.Warn("Container already exists",
97- "name", resp.ContainerName,
98- "id", shortID,
99- "state", resp.State,
100- "uptime", resp.RunningTime)
101-
102- if err := handler.HandleExistingContainer(a, resp); err != nil {
103- if strings.Contains(err.Error(), "cancelled by user") {
104- return fmt.Errorf("deployment cancelled by user")
105- }
106- return fmt.Errorf("failed to handle existing container: %w", err)
107- }
108-
109- }
110-
111- var buf bytes.Buffer
112- size, err := io.Copy(&buf, reader)
113- if err != nil {
114- return fmt.Errorf("failed to read image data: %w", err)
115- }
116-
117- imageReader := bytes.NewReader(buf.Bytes())
118- sizeInMB := float64(size) / 1024 / 1024
119- logger.Info("Attempting to deploy...",
120- "image", imageName,
121- "size", fmt.Sprintf("%.2fMB", sizeInMB),
122- "port", port,
123- "target", targetDomain,
124- )
125-
126- headers := http.Header{
127- "X-Image-Name": {imageName},
128- "X-Ports": {port},
129- "X-Target-Domain": {targetDomain},
130- "Content-Type": {"application/octet-stream"},
131- }
132-
133- // Add skip proxy setup header if enabled
134- if skipProxySetup {
135- headers.Add("X-Skip-Proxy-Setup", "true")
136- logger.Info("Skipping proxy setup as requested")
137- }
138-
139- chunkedClient := handler.NewChunkedClient(a)
140- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
141- defer cancel()
142-
143- chunkResp, err := chunkedClient.SendFileAsChunks(ctx, "/deploy/chunked", headers, imageReader, size, imageName)
144- if err != nil {
145- return fmt.Errorf("failed to deploy image: %w", err)
146- }
147-
148- if chunkResp == nil {
149- return fmt.Errorf("received nil response from server")
150- }
151-
152- var deployResp common.DeployResponse
153- if err := json.Unmarshal(chunkResp.Body, &deployResp); err != nil {
154- return fmt.Errorf("failed to parse deployment response: %w", err)
155- }
156-
157- return waitForDeployment(deployResp.Domain, deployResp.ContainerID)
158-}
159-
160-func waitForDeployment(domain string, containerID string) error {
161- // If domain is empty, it means proxy setup was skipped
162- if domain == "" {
163- logger.Info("Deployment successful (proxy setup skipped)",
164- "container_id", containerID)
165- return nil
166- }
167-
168- client := &http.Client{Timeout: 10 * time.Second}
169- maxRetries := 10
170- retryInterval := time.Second
171-
172- var shortContainerID string
173- if containerID != "" && len(containerID) >= 12 {
174- shortContainerID = containerID[:12]
175- } else {
176- shortContainerID = containerID
177- }
178-
179- logger.Info("Waiting for deployment to be reachable",
180- "domain", domain,
181- "container_id", shortContainerID)
182-
183- for i := 0; i < maxRetries; i++ {
184- resp, err := client.Get(domain)
185- if err == nil {
186- defer resp.Body.Close()
187- if resp.StatusCode == http.StatusOK {
188- logger.Info("Deployment successful",
189- "domain", domain,
190- "container_id", shortContainerID)
191- return nil
192- }
193- body, _ := io.ReadAll(resp.Body)
194- if strings.Contains(string(body), "failed to create container:") {
195- return fmt.Errorf("deployment failed: %s", string(body))
196- }
197- }
198- logger.Warn("Deployment not ready yet, retrying",
199- "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries))
200- time.Sleep(retryInterval)
201- }
202-
203- return fmt.Errorf("deployment not ready after %d attempts", maxRetries)
204-}
205-
206-func validateDeployInputs(imageName, port, targetDomain string) error {
207- if err := handler.ValidateImageName(imageName); err != nil {
208- return err
209- }
210-
211- if err := handler.EnsureImageTag(&imageName); err != nil {
212- return fmt.Errorf("failed to ensure image tag: %w", err)
213- }
214-
215- if port == "" {
216- return fmt.Errorf("port is required")
217- }
218-
219- return handler.ValidateTargetDomain(targetDomain)
220-}
221-
222-func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common.DeployResponse, error) {
223-
224- // Create request to check-conflict endpoint with both domain and port parameters
225- reqUrl := fmt.Sprintf("%s/api/deploy/check-conflict?domain=%s&port=%s",
226- a.Config.Http.BackendURL, targetDomain, port)
227-
228- req, err := http.NewRequest("GET", reqUrl, nil)
229- if err != nil {
230- return nil, fmt.Errorf("failed to create conflict check request: %w", err)
231- }
232-
233- // Set auth header
234- req.Header.Set("Authorization", "Bearer "+a.Config.General.JwtToken)
235-
236- // Send request
237- client := &http.Client{
238- Timeout: 10 * time.Second,
239- }
240- resp, err := client.Do(req)
241- if err != nil {
242- return nil, fmt.Errorf("failed to send conflict check request: %w", err)
243- }
244- defer resp.Body.Close()
245-
246- var conflictResp common.DeployResponse
247- if err := json.NewDecoder(resp.Body).Decode(&conflictResp); err != nil {
248- return nil, fmt.Errorf("failed to parse conflict check response: %w", err)
249- }
250-
251- return &conflictResp, nil
252-}
D internal/cli/cmd/ping.go +0 -50
@@ -1,50 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "os"
6- "time"
7-
8- "github.com/bnema/gordon/internal/cli"
9- "github.com/bnema/gordon/internal/cli/handler"
10- "github.com/bnema/gordon/pkg/humanize"
11-
12- "github.com/spf13/cobra"
13-)
14-
15-func NewPingCommand(a *cli.App) *cobra.Command {
16- // handler.FieldCheck(a)
17- return &cobra.Command{
18- Use: "ping",
19- Short: "Send a ping request to your remote Gordon instance",
20- PreRun: func(cmd *cobra.Command, args []string) {
21- if err := handler.FieldCheck(a); err != nil {
22- fmt.Println("Field check failed:", err)
23- os.Exit(1)
24- }
25- },
26- Run: func(cmd *cobra.Command, args []string) {
27- pingResp, err := handler.PerformPingRequest(a)
28- if err != nil {
29- fmt.Println("Error:", err)
30- return
31- }
32-
33- // Parse the uptime duration string
34- duration, err := time.ParseDuration(pingResp.Uptime)
35- if err != nil {
36- fmt.Println("Error parsing uptime duration:", err)
37- return
38- }
39-
40- // Calculate the start time of the server
41- startTime := time.Now().Add(-duration)
42-
43- // Use humanize to get a human-readable representation of the start time
44- humanizedUptime := humanize.TimeAgo(startTime)
45-
46- // Print the information
47- fmt.Printf("Pong!\nServer up since: %s\nServer version: %s\n", humanizedUptime, pingResp.Version)
48- },
49- }
50-}
D internal/cli/cmd/proxy.go +0 -196
@@ -1,196 +0,0 @@
1-package cmd
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/bnema/gordon/internal/cli/handler"
8- "github.com/bnema/gordon/internal/proxy"
9- "github.com/bnema/gordon/internal/server"
10- "github.com/bnema/gordon/pkg/docker"
11- "github.com/charmbracelet/log"
12- "github.com/spf13/cobra"
13-)
14-
15-// NewProxyCommand creates a new proxy command
16-func NewProxyCommand(a *server.App) *cobra.Command {
17- var (
18- domain string
19- containerID string
20- containerIP string
21- containerPort string
22- protocol string
23- path string
24- remove bool
25- update bool
26- force bool
27- )
28-
29- proxyCmd := &cobra.Command{
30- Use: "proxy",
31- Short: "Manage the reverse proxy routes",
32- Long: "Add, remove, or list proxy routes for domains to containers",
33- Run: func(cmd *cobra.Command, args []string) {
34- // Initialize the database if not already initialized
35- _, err := server.InitializeDB(a)
36- if err != nil {
37- log.Fatal("Failed to initialize database:", err)
38- }
39-
40- // Register the server instance for global signal handling
41- handler.SetGlobalServer(a)
42-
43- // Create a new proxy instance
44- p, err := proxy.NewProxy(a)
45- if err != nil {
46- log.Fatal("Failed to create proxy:", err)
47- }
48-
49- // If update flag is set, update a container's IP address
50- if update {
51- if containerID == "" || containerIP == "" {
52- log.Fatal("Container ID and IP address are required for update")
53- }
54-
55- // Get container info to verify the IP
56- containerInfo, err := docker.GetContainerInfo(containerID)
57- if err != nil {
58- log.Warn("Failed to get container info, using provided IP", "error", err)
59- } else {
60- // Look for the network specified in the config
61- networkName := a.Config.ContainerEngine.Network
62- if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
63- if networkSettings.IPAddress != containerIP {
64- log.Warn("Provided IP doesn't match container's actual IP",
65- "provided", containerIP,
66- "actual", networkSettings.IPAddress)
67- if !force {
68- log.Info("Using actual container IP instead of provided IP",
69- "container_id", containerID,
70- "new_ip", networkSettings.IPAddress)
71- containerIP = networkSettings.IPAddress
72- } else {
73- log.Warn("Force flag is set, using provided IP despite mismatch")
74- }
75- }
76- }
77- }
78-
79- // Update the route
80- err = p.ForceUpdateRouteIP(domain, containerIP)
81- if err != nil {
82- log.Fatal("Failed to update route IP:", err)
83- }
84-
85- log.Info("Route IP updated successfully")
86-
87- // Close the database connection
88- if err := a.Shutdown(); err != nil {
89- log.Error("Error closing database", "error", err)
90- }
91- return
92- }
93-
94- // If remove flag is set, remove a route
95- if remove {
96- if domain == "" {
97- log.Fatal("Domain is required for removal")
98- }
99- if err := p.RemoveRoute(domain); err != nil {
100- log.Fatal("Failed to remove route:", err)
101- }
102- log.Info("Route removed successfully")
103-
104- // Close the database connection
105- if err := a.Shutdown(); err != nil {
106- log.Error("Error closing database", "error", err)
107- }
108- return
109- }
110-
111- // If domain and containerID are provided, add a route
112- if domain != "" && containerID != "" {
113- if containerPort == "" {
114- log.Fatal("Container port is required")
115- }
116- if protocol == "" {
117- protocol = "http"
118- }
119- if err := p.AddRoute(domain, containerID, containerIP, containerPort, protocol, path); err != nil {
120- log.Fatal("Failed to add route:", err)
121- }
122- log.Info("Route added successfully")
123-
124- // Close the database connection
125- if err := a.Shutdown(); err != nil {
126- log.Error("Error closing database", "error", err)
127- }
128- return
129- }
130-
131- // If no flags are set, list all routes
132- if err := p.Reload(); err != nil {
133- log.Fatal("Failed to load routes:", err)
134- }
135-
136- // List routes from the database
137- // Use the query from the instantiated proxy queries
138- rows, err := a.DB.Query(p.Queries.GetAllRoutes)
139- if err != nil {
140- log.Fatal("Failed to query routes:", err)
141- }
142- defer rows.Close()
143-
144- fmt.Println("--- Proxy Routes ---")
145- fmt.Printf("%-30s %-12s %-15s %-6s %-7s\n", "DOMAIN", "CONTAINER ID", "IP", "PORT", "ACTIVE")
146- fmt.Println(strings.Repeat("-", 75))
147-
148- var count int
149- for rows.Next() {
150- // Adjust variables to match the columns selected by GetAllRoutes
151- var id, domain, containerID, containerIP, containerPort, protocol, path string
152- var active bool
153- // Adjust scan targets
154- if err := rows.Scan(&id, &domain, &containerID, &containerIP, &containerPort, &protocol, &path, &active); err != nil {
155- log.Fatal("Failed to scan row:", err)
156- }
157-
158- // Format container ID to show just first 12 chars
159- shortID := containerID
160- if len(containerID) > 12 {
161- shortID = containerID[:12]
162- }
163-
164- // Format active status
165- activeStr := "✓"
166- if !active {
167- activeStr = "✗"
168- }
169-
170- fmt.Printf("%-30s %-12s %-15s %-6s %-7s\n",
171- domain, shortID, containerIP, containerPort, activeStr)
172- count++
173- }
174-
175- fmt.Println(strings.Repeat("-", 75))
176- fmt.Printf("Total routes: %d\n", count)
177-
178- // Close the database connection
179- if err := a.Shutdown(); err != nil {
180- log.Error("Error closing database", "error", err)
181- }
182- },
183- }
184-
185- proxyCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain name")
186- proxyCmd.Flags().StringVarP(&containerID, "container", "c", "", "Container ID")
187- proxyCmd.Flags().StringVarP(&containerIP, "ip", "i", "", "Container IP address")
188- proxyCmd.Flags().StringVarP(&containerPort, "port", "p", "", "Container port")
189- proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "", "Protocol (http or https)")
190- proxyCmd.Flags().StringVarP(&path, "path", "a", "", "Path prefix")
191- proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove a route")
192- proxyCmd.Flags().BoolVarP(&update, "update", "u", false, "Update a container's IP address")
193- proxyCmd.Flags().BoolVarP(&force, "force", "f", false, "Force removal of a route")
194-
195- return proxyCmd
196-}
D internal/cli/cmd/push.go +0 -136
@@ -1,136 +0,0 @@
1-package cmd
2-
3-import (
4- "bytes"
5- "context"
6- "encoding/json"
7- "fmt"
8- "io"
9- "net/http"
10- "os"
11- "strings"
12- "time"
13-
14- "github.com/bnema/gordon/internal/cli"
15- "github.com/bnema/gordon/internal/cli/handler"
16- "github.com/bnema/gordon/internal/common"
17- "github.com/charmbracelet/log"
18- "github.com/spf13/cobra"
19-)
20-
21-func init() {
22- log.SetReportTimestamp(true)
23- log.SetTimeFormat("15:04")
24-}
25-
26-func NewPushCommand(a *cli.App) *cobra.Command {
27- var port string
28-
29- pushCmd := &cobra.Command{
30- Use: "push [image:tag]",
31- Short: "Push an image to your remote Gordon instance",
32- Args: cobra.ExactArgs(1),
33- PreRun: func(cmd *cobra.Command, args []string) {
34- if err := handler.FieldCheck(a); err != nil {
35- log.Error("Field check failed", "error", err)
36- os.Exit(1)
37- }
38- },
39- Run: func(cmd *cobra.Command, args []string) {
40- imageName := args[0]
41- log.Info("Pushing image", "image", imageName)
42-
43- if err := validatePushInputs(imageName); err != nil {
44- log.Error("Validation failed", "error", err)
45- return
46- }
47-
48- reader, actualSize, err := handler.ExportDockerImage(imageName)
49- if err != nil {
50- log.Error("Error exporting image", "error", err)
51- return
52- }
53- defer reader.Close()
54-
55- sizeInMB := float64(actualSize) / 1024 / 1024
56-
57- log.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB))
58-
59- if err := pushImage(a, reader, imageName); err != nil {
60- os.Exit(1)
61- }
62- },
63- }
64-
65- pushCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container (e.g., 8080:80/tcp)")
66-
67- return pushCmd
68-}
69-
70-func pushImage(a *cli.App, reader io.Reader, imageName string) error {
71- // Check authentication early
72- if err := handler.CheckAndRefreshAuth(a); err != nil {
73- log.Error("Authentication check failed", "error", err)
74- return err
75- }
76-
77- var buf bytes.Buffer
78- size, err := io.Copy(&buf, reader)
79- if err != nil {
80- return fmt.Errorf("failed to read image data: %w", err)
81- }
82-
83- imageReader := bytes.NewReader(buf.Bytes())
84- sizeInMB := float64(size) / 1024 / 1024
85- log.Info("Attempting to push...",
86- "image", imageName,
87- "size", fmt.Sprintf("%.2fMB", sizeInMB),
88- )
89-
90- headers := http.Header{
91- "X-Image-Name": {imageName},
92- "Content-Type": {"application/octet-stream"},
93- }
94-
95- chunkedClient := handler.NewChunkedClient(a)
96- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
97- defer cancel()
98-
99- resp, err := chunkedClient.SendFileAsChunks(ctx, "/push/chunked", headers, imageReader, size, imageName)
100- if err != nil {
101- log.Error("Failed to send chunks", "error", err)
102- return fmt.Errorf("failed to send chunks: %w", err)
103- }
104-
105- if resp == nil {
106- return fmt.Errorf("received nil response from server")
107- }
108-
109- var pushResponse common.PushResponse
110- if err := json.Unmarshal(resp.Body, &pushResponse); err != nil {
111- log.Error("Error parsing response", "error", err)
112- return fmt.Errorf("failed to parse response: %w", err)
113- }
114-
115- if !pushResponse.Success {
116- log.Error("Push failed", "resp", pushResponse.Message)
117- return fmt.Errorf("%s", pushResponse.Message)
118- }
119-
120- // Remove the double quotes from the message
121- message := strings.Trim(pushResponse.Message, "\"")
122- log.Info(message)
123-
124- if pushResponse.CreateContainerURL != "" {
125- log.Info("Container creation available", "url", pushResponse.CreateContainerURL)
126- }
127-
128- return nil
129-}
130-
131-func validatePushInputs(imageName string) error {
132- if err := handler.ValidateImageName(imageName); err != nil {
133- return err
134- }
135- return handler.EnsureImageTag(&imageName)
136-}
D internal/cli/cmd/root.go +0 -39
@@ -1,39 +0,0 @@
1-// the root command is the entrypoint for the gordon cli (default: client)
2-package cmd
3-
4-import (
5- "fmt"
6-
7- "github.com/bnema/gordon/internal/cli"
8- "github.com/bnema/gordon/internal/cli/handler"
9- "github.com/fatih/color"
10- "github.com/spf13/cobra"
11-)
12-
13-// NewRootCommand creates a new root command
14-func NewRootCommand(a *cli.App) *cobra.Command {
15- return &cobra.Command{
16- Run: func(cmd *cobra.Command, args []string) {
17-
18- backendVersion := getBackendVersion(a)
19- color.Green("Gordon %s", a.Config.GetVersion())
20- color.Blue("Gordon Backend %s", backendVersion)
21-
22- if backendVersion != a.Config.GetVersion() {
23- color.Yellow("A new version of Gordon is available")
24- }
25-
26- fmt.Println()
27- fmt.Println("Use \"gordon --help\" for more information about a command.")
28- },
29- }
30-}
31-
32-func getBackendVersion(a *cli.App) string {
33- pingResp, err := handler.PerformPingRequest(a)
34- if err != nil {
35- return fmt.Sprintf("Error: %s", err)
36- }
37-
38- return pingResp.Version
39-}
D internal/cli/cmd/serve.go +0 -57
@@ -1,57 +0,0 @@
1-// the serve command is used to start the gordon server
2-// Path: internal/cli/cmd/serve.go
3-package cmd
4-
5-import (
6- "fmt"
7-
8- "github.com/bnema/gordon/internal/cli/handler"
9- "github.com/bnema/gordon/internal/common"
10- "github.com/bnema/gordon/internal/server"
11- "github.com/bnema/gordon/pkg/docker"
12- "github.com/bnema/gordon/pkg/logger"
13- "github.com/spf13/cobra"
14-)
15-
16-// NewServeCommand creates a new serve command
17-func NewServeCommand(a *server.App) *cobra.Command {
18- defaultport := "1323"
19- var port string
20-
21- // if the server is running in a docker container, the default port is 8080
22- // (changed from 80 to avoid conflict with the reverse proxy)
23- if docker.IsRunningInContainer() {
24- defaultport = "8080"
25- }
26-
27- // if no flags -p or --port are specified, the default port is used
28- serveCmd := &cobra.Command{
29- Use: "serve",
30- Short: "Start a new Gordon server instance",
31- Args: cobra.NoArgs,
32- Run: func(cmd *cobra.Command, args []string) {
33- // Initialize Docker client here, before starting the server
34- if err := common.DockerInit(&a.Config.ContainerEngine); err != nil {
35- fmt.Printf("Warning: %s\n", err)
36- fmt.Println("Continuing with Docker/Podman functionality disabled")
37- }
38-
39- // handler.StartServer will use the value of port, which will be the default
40- // value if no flag is specified
41- a.Config.Http.Port = port
42- err := handler.StartServer(a, port)
43- if err != nil {
44- logger.Error("Server error", "error", err)
45- // Ensure database is closed on error
46- if err := a.Shutdown(); err != nil {
47- logger.Error("Error during shutdown after server error", "error", err)
48- }
49- panic(err)
50- }
51- },
52- }
53-
54- // Attach the flag to serveCmd and store its value in the variable port
55- serveCmd.Flags().StringVarP(&port, "port", "p", defaultport, "port to listen on")
56- return serveCmd
57-}
D internal/cli/cmd/update.go +0 -473
@@ -1,473 +0,0 @@
1-// This upload feature is based on pocketbase's ghupdate command.
2-// Thank you for your work, @Gani Georgiev
3-// Check the original code here: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go
4-package cmd
5-
6-import (
7- "archive/zip"
8- "context"
9- "encoding/json"
10- "errors"
11- "fmt"
12- "io"
13- "log"
14- "net/http"
15- "os"
16- "path/filepath"
17- "runtime"
18- "strconv"
19- "strings"
20-
21- "github.com/AlecAivazis/survey/v2"
22- "github.com/bnema/gordon/internal/cli"
23- "github.com/bnema/gordon/internal/cli/handler"
24- "github.com/bnema/gordon/pkg/docker"
25- "github.com/fatih/color"
26- "github.com/spf13/cobra"
27-)
28-
29-type HttpClient interface {
30- Do(req *http.Request) (*http.Response, error)
31-}
32-
33-type Config struct {
34- Owner string
35- Repo string
36- ArchiveExecutable string
37- Context context.Context
38- HttpClient HttpClient
39-}
40-
41-var pluginInstance *plugin
42-
43-type plugin struct {
44- app *cli.App
45- currentVersion string
46- config Config
47-}
48-
49-func NewUpdateCommand(a *cli.App) *cobra.Command {
50-
51- pluginInstance = &plugin{
52- app: a,
53- currentVersion: a.Config.Build.BuildVersion,
54- config: Config{
55- Owner: "bnema",
56- Repo: "gordon",
57- ArchiveExecutable: "gordon",
58- Context: context.Background(),
59- HttpClient: http.DefaultClient,
60- },
61- }
62-
63- if pluginInstance.app != a {
64- panic("app instance is not the same")
65- }
66-
67- if a == nil {
68- panic("app instance is nil")
69- }
70-
71- command := &cobra.Command{
72- Use: "update",
73- Short: "Update the Gordon executable",
74- SilenceErrors: true,
75- SilenceUsage: true,
76- RunE: func(command *cobra.Command, args []string) error {
77- var needConfirm bool
78- if docker.IsRunningInContainer() {
79- needConfirm = true
80- color.Yellow("It seems that you are in a Docker container.")
81- color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.")
82- }
83-
84- if needConfirm {
85- confirm := false
86- prompt := &survey.Confirm{
87- Message: "Do you want to proceed with the update?",
88- }
89- survey.AskOne(prompt, &confirm)
90- if !confirm {
91- fmt.Println("The command has been cancelled.")
92- return nil
93- }
94- }
95-
96- // Vérifier si l'utilisateur est root
97- if isRoot() {
98- color.Yellow("Running update as root. Skipping configuration check.")
99- return pluginInstance.update()
100- }
101-
102- // Si non root, effectuer la vérification des champs
103- if err := handler.FieldCheck(a); err != nil {
104- return fmt.Errorf("field check failed: %w", err)
105- }
106-
107- return pluginInstance.update()
108- },
109- }
110- return command
111-}
112-
113-func (p *plugin) update() error {
114- color.Yellow("Fetching release information...")
115-
116- latest, err := fetchLatestRelease(
117- p.config.Context,
118- p.config.HttpClient,
119- p.config.Owner,
120- p.config.Repo,
121- )
122- if err != nil {
123- return err
124- }
125-
126- if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 {
127- color.Green("You already have the latest version %s.", p.currentVersion)
128- return nil
129- }
130-
131- // Prompt user for update
132- confirm := false
133- prompt := &survey.Confirm{
134- Message: fmt.Sprintf("A new version (%s) is available. Do you want to update?", latest.Tag),
135- }
136- survey.AskOne(prompt, &confirm)
137- if !confirm {
138- fmt.Println("Update cancelled by user.")
139- return nil
140- }
141-
142- suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
143- if suffix == "" {
144- return errors.New("unsupported platform")
145- }
146-
147- asset, err := latest.findAssetBySuffix(suffix)
148- if err != nil {
149- return err
150- }
151-
152- // temporary file is under common.Config.General.StorageDir
153- releaseDir := filepath.Join(p.app.Config.General.StorageDir, "update")
154- defer os.RemoveAll(releaseDir)
155-
156- color.Yellow("Downloading %s...", asset.Name)
157-
158- // download the release asset
159- assetZip := filepath.Join(releaseDir, asset.Name)
160- if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil {
161- return err
162- }
163-
164- color.Yellow("Extracting %s...", asset.Name)
165-
166- extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
167- defer os.RemoveAll(extractDir)
168-
169- if err := extractZip(assetZip, extractDir); err != nil {
170- return err
171- }
172-
173- color.Yellow("Replacing the executable...")
174-
175- oldExec, err := os.Executable()
176- if err != nil {
177- return err
178- }
179- renamedOldExec := oldExec + ".old"
180- defer os.Remove(renamedOldExec)
181-
182- // Check if the user has write permissions
183- if err := checkWritePermission(oldExec); err != nil {
184- color.Yellow("Administrator rights are required to perform the update.")
185- color.Yellow("Please run the following command with sudo:")
186- color.HiBlack("sudo gordon update")
187- return nil
188- }
189-
190- newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
191- if _, err := os.Stat(newExec); err != nil {
192- // try again with an .exe extension
193- newExec = newExec + ".exe"
194- if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
195- return fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
196- }
197- }
198-
199- // rename the current executable
200- if err := os.Rename(oldExec, renamedOldExec); err != nil {
201- return fmt.Errorf("failed to rename the current executable: %w", err)
202- }
203-
204- tryToRevertExecChanges := func() {
205- if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
206- log.Println(revertErr)
207- }
208- }
209-
210- // replace with the extracted binary
211- if err := os.Rename(newExec, oldExec); err != nil {
212- tryToRevertExecChanges()
213- return fmt.Errorf("failed replacing the executable: %w", err)
214- }
215-
216- color.HiBlack("---")
217- color.Green("Update completed successfully! You can start the executable as usual.")
218-
219- return nil
220-}
221-
222-func (r *release) findAssetBySuffix(suffix string) (*asset, error) {
223- for _, asset := range r.Assets {
224- if strings.HasSuffix(asset.Name, suffix) {
225- return &asset, nil
226- }
227- }
228-
229- return nil, fmt.Errorf("no asset found with suffix %q", suffix)
230-}
231-
232-type release struct {
233- Tag string `json:"tag_name"`
234- Assets []asset `json:"assets"`
235-}
236-
237-type asset struct {
238- Name string `json:"name"`
239- DownloadUrl string `json:"browser_download_url"`
240-}
241-
242-func fetchLatestRelease(
243- ctx context.Context,
244- client HttpClient,
245- owner string,
246- repo string,
247-) (*release, error) {
248- url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
249-
250- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
251- if err != nil {
252- return nil, err
253- }
254-
255- res, err := client.Do(req)
256- if err != nil {
257- return nil, err
258- }
259- defer res.Body.Close()
260-
261- rawBody, err := io.ReadAll(res.Body)
262- if err != nil {
263- return nil, err
264- }
265-
266- // http.Client doesn't treat non 2xx responses as error
267- if res.StatusCode >= 400 {
268- return nil, fmt.Errorf(
269- "(%d) failed to fetch latest releases:\n%s",
270- res.StatusCode,
271- string(rawBody),
272- )
273- }
274-
275- result := &release{}
276- if err := json.Unmarshal(rawBody, result); err != nil {
277- return nil, err
278- }
279-
280- return result, nil
281-}
282-
283-func downloadFile(
284- ctx context.Context,
285- client HttpClient,
286- url string,
287- destPath string,
288-) error {
289- req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
290- if err != nil {
291- return err
292- }
293-
294- res, err := client.Do(req)
295- if err != nil {
296- return err
297- }
298- defer res.Body.Close()
299-
300- // http.Client doesn't treat non 2xx responses as error
301- if res.StatusCode >= 400 {
302- return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
303- }
304-
305- // ensure that the dest parent dir(s) exist
306- if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
307- return err
308- }
309-
310- dest, err := os.Create(destPath)
311- if err != nil {
312- return err
313- }
314- defer dest.Close()
315-
316- if _, err := io.Copy(dest, res.Body); err != nil {
317- return err
318- }
319-
320- return nil
321-}
322-
323-// archives:
324-// - format: zip
325-// name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
326-
327-func archiveSuffix(goos, goarch string) string {
328- // Define a map to hold the combinations of goos and goarch.
329- var archMap = map[string]map[string]string{
330- "linux": {
331- "amd64": "_linux_amd64.zip",
332- "arm64": "_linux_arm64.zip",
333- "arm": "_linux_armv7.zip",
334- },
335- "darwin": {
336- "amd64": "_darwin_amd64.zip",
337- "arm64": "_darwin_arm64.zip",
338- },
339- "windows": {
340- "amd64": "_windows_amd64.zip",
341- "arm64": "_windows_arm64.zip",
342- },
343- }
344-
345- // Look up goos then goarch.
346- if arch, ok := archMap[goos]; ok {
347- if suffix, ok := arch[goarch]; ok {
348- return suffix
349- }
350- }
351-
352- // Return a default value if no match is found.
353- return ""
354-}
355-func compareVersions(a, b string) int {
356- aSplit := strings.Split(a, ".")
357- aTotal := len(aSplit)
358-
359- bSplit := strings.Split(b, ".")
360- bTotal := len(bSplit)
361-
362- limit := aTotal
363- if bTotal > aTotal {
364- limit = bTotal
365- }
366-
367- for i := 0; i < limit; i++ {
368- var x, y int
369-
370- if i < aTotal {
371- x, _ = strconv.Atoi(aSplit[i])
372- }
373-
374- if i < bTotal {
375- y, _ = strconv.Atoi(bSplit[i])
376- }
377-
378- if x < y {
379- return 1 // b is newer
380- }
381-
382- if x > y {
383- return -1 // a is newer
384- }
385- }
386-
387- return 0 // equal
388-}
389-
390-// New function to check write permissions
391-func checkWritePermission(path string) error {
392- // Try to create a temporary file in the same directory
393- dir := filepath.Dir(path)
394- tmpFile := filepath.Join(dir, ".gordon_update_test")
395- f, err := os.Create(tmpFile)
396- if err != nil {
397- return err
398- }
399- f.Close()
400- os.Remove(tmpFile)
401- return nil
402-}
403-
404-func isRoot() bool {
405- return os.Geteuid() == 0
406-}
407-
408-// extractZip extracts a zip file to a destination directory
409-func extractZip(zipPath, destPath string) error {
410- // Open the zip file
411- reader, err := zip.OpenReader(zipPath)
412- if err != nil {
413- return fmt.Errorf("failed to open zip file: %w", err)
414- }
415- defer reader.Close()
416-
417- // Create destination directory if it doesn't exist
418- if err := os.MkdirAll(destPath, os.ModePerm); err != nil {
419- return fmt.Errorf("failed to create destination directory: %w", err)
420- }
421-
422- // Extract each file
423- for _, file := range reader.File {
424- // Construct the full path for the file
425- path := filepath.Join(destPath, file.Name)
426-
427- // Check for path traversal vulnerabilities
428- if !strings.HasPrefix(path, filepath.Clean(destPath)+string(os.PathSeparator)) {
429- return fmt.Errorf("illegal file path in zip: %s", file.Name)
430- }
431-
432- // Create directory for file
433- if file.FileInfo().IsDir() {
434- if err := os.MkdirAll(path, os.ModePerm); err != nil {
435- return fmt.Errorf("failed to create directory: %w", err)
436- }
437- continue
438- }
439-
440- // Create parent directory if it doesn't exist
441- if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
442- return fmt.Errorf("failed to create directory structure: %w", err)
443- }
444-
445- // Create file
446- destFile, err := os.Create(path)
447- if err != nil {
448- return fmt.Errorf("failed to create file: %w", err)
449- }
450-
451- // Open the file in the zip
452- srcFile, err := file.Open()
453- if err != nil {
454- destFile.Close()
455- return fmt.Errorf("failed to open file in zip: %w", err)
456- }
457-
458- // Copy the contents
459- _, err = io.Copy(destFile, srcFile)
460- srcFile.Close()
461- destFile.Close()
462- if err != nil {
463- return fmt.Errorf("failed to extract file: %w", err)
464- }
465-
466- // Preserve file permissions
467- if err := os.Chmod(path, file.Mode()); err != nil {
468- return fmt.Errorf("failed to set file permissions: %w", err)
469- }
470- }
471-
472- return nil
473-}
D internal/cli/cmd/version.go +0 -39
@@ -1,39 +0,0 @@
1-package cmd
2-
3-import (
4- "github.com/bnema/gordon/internal/cli"
5- "github.com/bnema/gordon/internal/common"
6- "github.com/bnema/gordon/pkg/logger"
7- "github.com/spf13/cobra"
8-)
9-
10-func NewVersionCommand(app *cli.App) *cobra.Command {
11- cmd := &cobra.Command{
12- Use: "version",
13- Short: "Print version information",
14- Run: func(cmd *cobra.Command, args []string) {
15- info := common.GetVersionInfo(
16- app.Config.Build.BuildVersion,
17- app.Config.Build.BuildCommit,
18- app.Config.Build.BuildDate,
19- app.Config.Build.ProxyURL,
20- )
21- logger.Info(info.String())
22-
23- hasUpdate, latestVersion, err := common.CheckForNewVersion(
24- info.Version,
25- info.ProxyURL,
26- )
27- if err != nil {
28- logger.Error("Error checking for updates", "error", err)
29- return
30- }
31-
32- if hasUpdate {
33- logger.Info("A new version is available", "version", latestVersion)
34- logger.Info("You can update using: gordon update")
35- }
36- },
37- }
38- return cmd
39-}
D internal/cli/handler/auth.go +0 -78
@@ -1,78 +0,0 @@
1-package handler
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/gordon/internal/cli"
8- "github.com/bnema/gordon/internal/cli/auth"
9- "github.com/bnema/gordon/internal/common"
10- "github.com/charmbracelet/log"
11-)
12-
13-func CheckAndRefreshAuth(a *cli.App) error {
14- if a.Config.General.JwtToken == "" {
15- log.Info("No authentication token found. Initiating authentication...")
16- return ReAuthenticate(a)
17- }
18-
19- resp, err := SendHTTPRequest(a, &common.RequestPayload{
20- Type: "ping",
21- Payload: common.PingPayload{
22- Message: "ping",
23- },
24- }, "GET", "/ping")
25-
26- if err != nil {
27- log.Debug("Token validation request failed", "error", err)
28- return ReAuthenticate(a)
29- }
30-
31- // If we get a 200 status code, and the resp contains message = pong, we're good
32- if resp.StatusCode != 200 {
33- log.Debug("Token validation failed",
34- "status_code", resp.StatusCode,
35- "response", resp.Body)
36- return ReAuthenticate(a)
37-
38- }
39-
40- return nil
41-}
42-
43-func ReAuthenticate(a *cli.App) error {
44- fmt.Println("Re-authenticating...")
45-
46- // Small delay for visual clarity
47- time.Sleep(100 * time.Millisecond)
48-
49- token, err := auth.DeviceFlowAuth(a)
50-
51- if err != nil {
52- return fmt.Errorf("device flow authentication failed: %w", err)
53- }
54-
55- // if token is the same as the current one, no need to save
56- if token == a.Config.General.JwtToken {
57- fmt.Println("Token is still valid, no changes made.")
58- return nil
59- }
60-
61- // Update the JWTSecret in the config
62- a.Config.General.JwtToken = token
63-
64- // Save the new token to config file
65- err = a.Config.SaveConfig()
66- if err != nil {
67- return fmt.Errorf("failed to save new token to config: %w", err)
68- }
69-
70- // Clear any remaining auth UI elements
71- fmt.Print("\033[2K\r")
72- fmt.Println("Re-authentication successful.")
73-
74- // Add delay before resuming upload display
75- time.Sleep(500 * time.Millisecond)
76-
77- return nil
78-}
D internal/cli/handler/chunked_transfer.go +0 -568
@@ -1,568 +0,0 @@
1-// internal/cli/handler/progress_display.go
2-
3-package handler
4-
5-import (
6- "bytes"
7- "context"
8- "crypto/sha256"
9- "encoding/json"
10- "errors"
11- "fmt"
12- "io"
13- "net"
14- "net/http"
15- "os"
16- "os/signal"
17- "strings"
18- "sync"
19- "syscall"
20- "time"
21-
22- "github.com/bnema/gordon/internal/cli"
23- "github.com/bnema/gordon/internal/common"
24- "github.com/charmbracelet/bubbles/spinner"
25- tea "github.com/charmbracelet/bubbletea"
26- "github.com/charmbracelet/lipgloss"
27- "github.com/charmbracelet/log"
28- "github.com/google/uuid"
29- "golang.org/x/exp/rand"
30-)
31-
32-type DeployResponse = common.DeployResponse
33-
34-// UploadProgress represents the current state of the upload
35-type UploadProgress struct {
36- Completed int
37- Total int
38- Percentage float64
39-}
40-
41-// ProgressDisplay manages the upload progress display
42-type ProgressDisplay struct {
43- mu sync.Mutex
44- model *uploadModel
45- program *tea.Program
46- updateChan chan UploadProgress
47- done chan struct{}
48- paused bool
49- pauseChan chan bool
50-}
51-
52-type uploadModel struct {
53- spinner spinner.Model
54- completed int
55- total int
56- percentage float64
57- done bool
58-}
59-
60-// NewProgressDisplay creates a new progress display
61-func NewProgressDisplay() *ProgressDisplay {
62- return &ProgressDisplay{
63- updateChan: make(chan UploadProgress),
64- done: make(chan struct{}),
65- pauseChan: make(chan bool),
66- }
67-}
68-
69-func (pd *ProgressDisplay) Start() {
70- pd.mu.Lock()
71- defer pd.mu.Unlock()
72-
73- pd.model = &uploadModel{
74- spinner: spinner.New(
75- spinner.WithSpinner(spinner.MiniDot),
76- spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("69"))),
77- ),
78- }
79-
80- pd.program = tea.NewProgram(pd.model)
81-
82- // Run the UI in a goroutine
83- go func() {
84- if _, err := pd.program.Run(); err != nil {
85- fmt.Printf("Error running progress display: %v\n", err)
86- }
87- }()
88-
89- // Handle updates in a separate goroutine
90- go pd.handleUpdates()
91-}
92-
93-func (pd *ProgressDisplay) Pause() {
94- pd.mu.Lock()
95- defer pd.mu.Unlock()
96- pd.paused = true
97- pd.Clear() // Clear the current display
98-}
99-
100-func (pd *ProgressDisplay) Resume() {
101- pd.mu.Lock()
102- defer pd.mu.Unlock()
103- pd.paused = false
104-}
105-
106-func (pd *ProgressDisplay) handleUpdates() {
107- for {
108- select {
109- case progress := <-pd.updateChan:
110- pd.mu.Lock()
111- if !pd.paused {
112- pd.program.Send(progressMsg{
113- completed: progress.Completed,
114- total: progress.Total,
115- percentage: progress.Percentage,
116- })
117- }
118- pd.mu.Unlock()
119- case <-pd.done:
120- pd.program.Send(doneMsg{})
121- return
122- }
123- }
124-}
125-
126-func (pd *ProgressDisplay) Update(completed, total int, percentage float64) {
127- pd.updateChan <- UploadProgress{
128- Completed: completed,
129- Total: total,
130- Percentage: percentage,
131- }
132-}
133-
134-func (pd *ProgressDisplay) Stop() {
135- close(pd.done)
136-}
137-
138-func (pd *ProgressDisplay) Clear() {
139- pd.mu.Lock()
140- defer pd.mu.Unlock()
141-
142- // Send a special clear message
143- pd.program.Send(clearMsg{})
144- // Wait a brief moment for the clear to take effect
145- time.Sleep(50 * time.Millisecond)
146-}
147-
148-type progressMsg struct {
149- completed int
150- total int
151- percentage float64
152-}
153-
154-type doneMsg struct{}
155-
156-type clearMsg struct{}
157-
158-func (m *uploadModel) Init() tea.Cmd {
159- return m.spinner.Tick
160-}
161-
162-func (m *uploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163- switch msg := msg.(type) {
164- case tea.KeyMsg:
165- if msg.Type == tea.KeyCtrlC {
166- return m, tea.Quit
167- }
168- case spinner.TickMsg:
169- var cmd tea.Cmd
170- m.spinner, cmd = m.spinner.Update(msg)
171- return m, cmd
172- case progressMsg:
173- m.completed = msg.completed
174- m.total = msg.total
175- m.percentage = msg.percentage
176- return m, m.spinner.Tick
177- case doneMsg:
178- m.done = true
179- return m, tea.Quit
180- case clearMsg:
181- m.done = true
182- return m, tea.Quit
183- }
184- return m, nil
185-}
186-
187-func (m *uploadModel) View() string {
188- if m.done {
189- return ""
190- }
191-
192- return fmt.Sprintf("\r%s Uploading image... %d/%d chunks (%.2f%%)",
193- m.spinner.View(),
194- m.completed,
195- m.total,
196- m.percentage,
197- )
198-}
199-
200-type ChunkMetadata = common.ChunkMetadata
201-
202-type ChunkedClient struct {
203- client *http.Client
204- baseURL string
205- token string
206- chunkSize int64
207- app *cli.App
208- progress *ProgressDisplay
209- cancelFunc context.CancelFunc
210-}
211-
212-type transferStore struct {
213- mu sync.Mutex
214- chunks map[string]map[int][]byte
215- metadata map[string]*ChunkMetadata
216- started map[string]time.Time
217-}
218-
219-var clientChunkStore = &transferStore{
220- chunks: make(map[string]map[int][]byte),
221- metadata: make(map[string]*ChunkMetadata),
222- started: make(map[string]time.Time),
223-}
224-
225-// NewChunkedClient creates a new chunked client
226-func NewChunkedClient(app *cli.App) *ChunkedClient {
227- baseURL := app.Config.Http.BackendURL + "/api"
228- token := app.Config.General.JwtToken
229-
230- return &ChunkedClient{
231- client: &http.Client{
232- Timeout: 30 * time.Minute,
233- },
234- baseURL: baseURL,
235- token: token,
236- chunkSize: 5 * 1024 * 1024,
237- app: app,
238- progress: NewProgressDisplay(),
239- }
240-}
241-
242-// SendFileAsChunks sends a file as chunks to the specified endpoint
243-func (c *ChunkedClient) SendFileAsChunks(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (*Response, error) {
244- ctx, cancel := context.WithCancel(ctx)
245- c.cancelFunc = cancel
246- defer cancel()
247-
248- // Set up signal handling
249- sigChan := make(chan os.Signal, 1)
250- signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
251- defer signal.Stop(sigChan)
252-
253- // Start progress display
254- c.progress.Start()
255- defer c.progress.Stop()
256-
257- // Handle signals in a goroutine
258- go func() {
259- select {
260- case <-sigChan:
261- log.Info("Received interrupt signal, cancelling upload...")
262- cancel()
263- case <-ctx.Done():
264- return
265- }
266- }()
267-
268- transferID := uuid.New().String()
269- totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize)
270- buf := make([]byte, c.chunkSize)
271-
272- // Initialize transfer tracking
273- clientChunkStore.mu.Lock()
274- clientChunkStore.started[transferID] = time.Now()
275- clientChunkStore.mu.Unlock()
276-
277- defer cleanupClientTransfer(transferID)
278-
279- var lastResponse *Response
280- for chunkNum := 0; chunkNum < totalChunks; chunkNum++ {
281- select {
282- case <-ctx.Done():
283- return nil, fmt.Errorf("upload cancelled")
284- default:
285- n, err := io.ReadFull(reader, buf)
286- if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
287- return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err)
288- }
289-
290- if n == 0 {
291- continue
292- }
293-
294- chunk := buf[:n]
295- metadata := common.ChunkMetadata{
296- ChunkNumber: chunkNum,
297- TotalChunks: totalChunks,
298- ChunkSize: int64(n),
299- TotalSize: totalSize,
300- ImageName: imageName,
301- TransferID: transferID,
302- }
303-
304- resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata)
305- if err != nil {
306- return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err)
307- }
308- lastResponse = resp
309-
310- // Update progress
311- c.progress.Update(chunkNum+1, totalChunks, float64(chunkNum+1)/float64(totalChunks)*100)
312- }
313- }
314-
315- if lastResponse != nil {
316- c.progress.Clear()
317- responseType := determineResponseType(endpoint)
318- if err := handleFinalResponse(lastResponse, responseType); err != nil {
319- log.Error("Failed to handle final response", "error", err)
320- }
321- }
322-
323- return lastResponse, nil
324-}
325-
326-// sendChunkWithRetry sends a single chunk to the server and retries on failure
327-func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
328- maxRetries := 3
329- backoff := time.Second
330-
331- isRetryableError := func(err error) bool {
332- if err == nil {
333- return false
334- }
335-
336- if netErr, ok := err.(net.Error); ok {
337- return netErr.Timeout()
338- }
339-
340- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
341- return false
342- }
343-
344- errorStr := err.Error()
345- retryableErrors := []string{
346- "connection reset by peer",
347- "broken pipe",
348- "connection refused",
349- "no such host",
350- "i/o timeout",
351- "unexpected EOF",
352- "connection timed out",
353- "network is unreachable",
354- }
355-
356- for _, retryableErr := range retryableErrors {
357- if strings.Contains(strings.ToLower(errorStr), retryableErr) {
358- return true
359- }
360- }
361-
362- return false
363- }
364-
365- var lastErr error
366- for attempt := 0; attempt < maxRetries; attempt++ {
367- resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata)
368- if err == nil {
369- return resp, nil
370- }
371-
372- lastErr = err
373-
374- if !isRetryableError(err) {
375- return nil, err
376- }
377-
378- if attempt < maxRetries-1 {
379- retryDelay := backoff * time.Duration(attempt+1)
380- jitter := time.Duration(rand.Int63n(int64(retryDelay) / 2))
381-
382- select {
383- case <-ctx.Done():
384- return nil, ctx.Err()
385- case <-time.After(retryDelay + jitter):
386- continue
387- }
388- }
389- }
390-
391- return nil, fmt.Errorf("max retries exceeded after %d attempts: %w", maxRetries, lastErr)
392-}
393-
394-func (c *ChunkedClient) Cancel() {
395- if c.cancelFunc != nil {
396- c.cancelFunc()
397- }
398-}
399-
400-// Response handling functions
401-func determineResponseType(endpoint string) string {
402- switch {
403- case strings.Contains(endpoint, "/deploy"):
404- return "deploy"
405- case strings.Contains(endpoint, "/push"):
406- return "push"
407- default:
408- return "unknown"
409- }
410-}
411-
412-// handleFinalResponse determines the response type and calls the appropriate handler
413-func handleFinalResponse(resp *Response, responseType string) error {
414- if resp == nil || len(resp.Body) == 0 {
415- return fmt.Errorf("empty response received")
416- }
417-
418- switch responseType {
419- case "deploy":
420- return handleDeployResponse(resp)
421- case "push":
422- return handlePushResponse(resp)
423- default:
424- return fmt.Errorf("unknown response type: %s", responseType)
425- }
426-}
427-
428-// handleDeployResponse handles the response from a deploy operation
429-func handleDeployResponse(resp *Response) error {
430- finalResp := DeployResponse{}
431- if err := json.Unmarshal(resp.Body, &finalResp); err != nil {
432- return fmt.Errorf("failed to unmarshal deploy response: %w", err)
433- }
434-
435- // Always log the success message with available information
436- logFields := []interface{}{}
437-
438- if finalResp.Domain != "" {
439- logFields = append(logFields, "domain", finalResp.Domain)
440- }
441- if finalResp.ContainerID != "" {
442- shortContainerID := finalResp.ContainerID
443- if len(shortContainerID) > 8 {
444- shortContainerID = shortContainerID[:8]
445- }
446- logFields = append(logFields, "containerID", shortContainerID)
447- }
448- if finalResp.ContainerName != "" {
449- logFields = append(logFields, "containerName", finalResp.ContainerName)
450- }
451-
452- // If there's a message, use it; otherwise, use a default success message
453- message := finalResp.Message
454- if message == "" {
455- message = "Deployment completed successfully"
456- }
457-
458- // Ensure we log the result even if there are no additional fields
459- if len(logFields) > 0 {
460- log.Info(message, logFields...)
461- } else {
462- log.Info(message)
463- }
464-
465- return nil
466-}
467-
468-// handlePushResponse handles the response from a push operation
469-func handlePushResponse(resp *Response) error {
470- var finalResp common.PushResponse
471- if err := json.Unmarshal(resp.Body, &finalResp); err != nil {
472- return fmt.Errorf("failed to unmarshal push response: %w", err)
473- }
474- return nil
475-}
476-
477-// sendChunk sends a single chunk to the server
478-func (c *ChunkedClient) sendChunk(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
479- if err := verifyChunkIntegrity(chunk, metadata); err != nil {
480- return nil, fmt.Errorf("chunk integrity check failed: %w", err)
481- }
482- for {
483- select {
484- case <-ctx.Done():
485- return nil, ctx.Err()
486- default:
487- resp, err := c.attemptChunkSend(ctx, endpoint, headers, chunk, metadata)
488- if err != nil {
489- return nil, err
490- }
491- return resp, nil
492- }
493- }
494-}
495-
496-func (c *ChunkedClient) attemptChunkSend(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) {
497- metadataJSON, err := json.Marshal(metadata)
498- if err != nil {
499- return nil, fmt.Errorf("failed to marshal metadata: %w", err)
500- }
501-
502- url := c.baseURL + endpoint
503- req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk))
504- if err != nil {
505- return nil, fmt.Errorf("failed to create request: %w", err)
506- }
507-
508- // Set headers
509- req.Header.Set("Authorization", "Bearer "+c.token)
510- req.Header.Set("Content-Type", "application/octet-stream")
511- req.Header.Set("X-Chunk-Metadata", string(metadataJSON))
512- req.Header.Set("X-Chunk-Hash", fmt.Sprintf("%x", sha256.Sum256(chunk)))
513-
514- for k, v := range headers {
515- req.Header[k] = v
516- }
517-
518- resp, err := c.client.Do(req)
519- if err != nil {
520- return nil, err
521- }
522- defer resp.Body.Close()
523-
524- body, err := io.ReadAll(resp.Body)
525- if err != nil {
526- return nil, fmt.Errorf("failed to read response: %w", err)
527- }
528-
529- if resp.StatusCode != http.StatusOK {
530- return &Response{
531- Http: resp,
532- Body: body,
533- StatusCode: resp.StatusCode,
534- Header: resp.Header,
535- }, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
536- }
537-
538- return &Response{
539- Http: resp,
540- Body: body,
541- StatusCode: resp.StatusCode,
542- Header: resp.Header,
543- }, nil
544-}
545-
546-// verifyChunkIntegrity verifies the integrity of a chunk
547-func verifyChunkIntegrity(chunk []byte, metadata common.ChunkMetadata) error {
548- if len(chunk) == 0 {
549- return fmt.Errorf("empty chunk received")
550- }
551-
552- if int64(len(chunk)) != metadata.ChunkSize {
553- return fmt.Errorf("chunk size mismatch: expected %d, got %d",
554- metadata.ChunkSize, len(chunk))
555- }
556-
557- return nil
558-}
559-
560-// cleanupClientTransfer cleans up a client transfer
561-func cleanupClientTransfer(transferID string) {
562- clientChunkStore.mu.Lock()
563- defer clientChunkStore.mu.Unlock()
564-
565- delete(clientChunkStore.chunks, transferID)
566- delete(clientChunkStore.metadata, transferID)
567- delete(clientChunkStore.started, transferID)
568-}
D internal/cli/handler/deploy_survey.go +0 -116
@@ -1,116 +0,0 @@
1-// gordon/internal/cli/handler/deploy_survey.go
2-
3-package handler
4-
5-import (
6- "encoding/json"
7- "fmt"
8- "time"
9-
10- "github.com/AlecAivazis/survey/v2"
11- "github.com/bnema/gordon/internal/cli"
12- "github.com/bnema/gordon/internal/common"
13- "github.com/charmbracelet/log"
14-)
15-
16-func HandleExistingContainer(app *cli.App, conflictResp *common.DeployResponse) error {
17- // Add a small delay to ensure terminal is clear
18- time.Sleep(100 * time.Millisecond)
19-
20- // Clear the current line
21- fmt.Print("\033[2K\r")
22-
23- containerName := conflictResp.ContainerName
24- shortID := conflictResp.ContainerID
25- if len(shortID) > 12 {
26- shortID = shortID[:12]
27- }
28-
29- var proceed bool
30- prompt := &survey.Confirm{
31- Message: fmt.Sprintf("Container '%s' (ID: %s) is already running for %s. Stop and remove it?",
32- containerName,
33- shortID,
34- conflictResp.RunningTime,
35- ),
36- Default: true,
37- }
38-
39- if err := survey.AskOne(prompt, &proceed); err != nil {
40- return fmt.Errorf("survey failed: %w", err)
41- }
42-
43- if !proceed {
44- return fmt.Errorf("operation cancelled by user")
45- }
46-
47- log.Info("Stopping container...", "name", containerName, "id", shortID)
48-
49- // Stop the container
50- if err := stopContainer(app, conflictResp.ContainerID); err != nil {
51- return fmt.Errorf("failed to stop container '%s': %w", containerName, err)
52- }
53-
54- log.Info("Removing container...", "name", containerName, "id", shortID)
55-
56- // Remove the container
57- if err := removeContainer(app, conflictResp.ContainerID); err != nil {
58- return fmt.Errorf("failed to remove container '%s': %w", containerName, err)
59- }
60-
61- log.Info("Container stopped and removed successfully",
62- "name", containerName,
63- "id", shortID,
64- )
65- return nil
66-}
67-
68-func removeContainer(a *cli.App, containerID string) error {
69- reqPayload := common.RequestPayload{
70- Type: "remove",
71- Payload: common.RemovePayload{
72- ContainerID: containerID,
73- },
74- }
75-
76- resp, err := SendHTTPRequest(a, &reqPayload, "POST", "/remove")
77- if err != nil {
78- return err
79- }
80-
81- var removeResponse common.RemoveResponse
82- if err := json.Unmarshal(resp.Body, &removeResponse); err != nil {
83- return fmt.Errorf("failed to parse remove response: %w", err)
84- }
85-
86- if !removeResponse.Success {
87- return fmt.Errorf("failed to remove container: %s", removeResponse.Message)
88- }
89-
90- return nil
91-}
92-
93-func stopContainer(a *cli.App, containerID string) error {
94- reqPayload := common.RequestPayload{
95- Type: "stop",
96- Payload: common.StopPayload{
97- ContainerID: containerID,
98- },
99- }
100-
101- resp, err := SendHTTPRequest(a, &reqPayload, "POST", "/stop")
102- if err != nil {
103- return err
104- }
105-
106- var stopResponse common.StopResponse
107- if err := json.Unmarshal(resp.Body, &stopResponse); err != nil {
108- return fmt.Errorf("failed to parse stop response: %w", err)
109- }
110-
111- if !stopResponse.Success {
112- return fmt.Errorf("failed to stop container: %s", stopResponse.Message)
113- }
114-
115- return nil
116-}
D internal/cli/handler/docker.go +0 -51
@@ -1,51 +0,0 @@
1-package handler
2-
3-import (
4- "fmt"
5- "io"
6-
7- "github.com/bnema/gordon/pkg/docker"
8-)
9-
10-func ExportDockerImage(imageName string) (io.ReadCloser, int64, error) {
11- // Initialize Docker client
12- if err := docker.CheckIfInitialized(); err != nil {
13- return nil, 0, fmt.Errorf("failed to initialize Docker client: %w", err)
14- }
15-
16- // Get the image ID first
17- imageID, err := docker.GetImageIDByName(imageName)
18- if err != nil {
19- return nil, 0, fmt.Errorf("failed to get image ID: %w", err)
20- }
21-
22- // Create a pipe for streaming
23- pr, pw := io.Pipe()
24-
25- go func() {
26- defer pw.Close()
27-
28- // Export the image using save
29- reader, err := docker.ExportImageFromEngine(imageID)
30- if err != nil {
31- pw.CloseWithError(fmt.Errorf("failed to export image: %w", err))
32- return
33- }
34- defer reader.Close()
35-
36- // Copy the data to the pipe
37- _, err = io.Copy(pw, reader)
38- if err != nil {
39- pw.CloseWithError(fmt.Errorf("failed to copy image data: %w", err))
40- return
41- }
42- }()
43-
44- // Get the actual size
45- size, err := docker.GetImageSize(imageID)
46- if err != nil {
47- return nil, 0, fmt.Errorf("failed to get image size: %w", err)
48- }
49-
50- return pr, size, nil
51-}
D internal/cli/handler/field_check.go +0 -53
@@ -1,53 +0,0 @@
1-package handler
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/gordon/internal/cli"
7- "github.com/bnema/gordon/internal/cli/auth"
8- "github.com/bnema/gordon/internal/common"
9-)
10-
11-// config.Http.BackendURL = readUserInput("Enter the backend URL (e.g. https://gordon.mydomain.com):")
12-// config.General.Token = readUserInput("Enter the token (check your backend config.yml):")
13-// check in the config.yml if BackendURL and Token are set
14-// if not, prompt the user to enter them
15-
16-// Helper function to check, prompt and set a value
17-func checkAndSetField(value *string, promptMessage, errorMessage string) (bool, error) {
18- wasEmpty := false
19- if *value == "" {
20- wasEmpty = true
21- fmt.Println(errorMessage)
22- *value = common.ReadUserInput(promptMessage)
23- }
24- return wasEmpty, nil
25-}
26-
27-func FieldCheck(a *cli.App) error {
28- wasBackendURLEmpty, err := checkAndSetField(&a.Config.Http.BackendURL,
29- "Enter the backend URL (e.g. https://gordon.mydomain.com):",
30- "BackendURL is not set in config.yml")
31- if err != nil {
32- return err
33- }
34-
35- if a.Config.General.JwtToken == "" {
36- fmt.Println("Token is not set. Starting OAuth device flow...")
37- _, err := auth.DeviceFlowAuth(a)
38- if err != nil {
39- fmt.Printf("Device flow authentication error: %v\n", err)
40- return fmt.Errorf("failed to complete device flow authentication: %w", err)
41- }
42- }
43-
44- // Save config if BackendURL was empty or if a new token was set
45- if wasBackendURLEmpty || a.Config.General.JwtToken != "" {
46- err = a.Config.SaveConfig()
47- if err != nil {
48- return err
49- }
50- }
51-
52- return nil
53-}
D internal/cli/handler/http.go +0 -146
@@ -1,146 +0,0 @@
1-package handler
2-
3-import (
4- "bytes"
5- "encoding/json"
6- "fmt"
7- "io"
8- "net/http"
9-
10- "github.com/bnema/gordon/internal/cli"
11- "github.com/bnema/gordon/internal/common"
12-)
13-
14-// PrepareJSONPayload marshals the payload into JSON
15-func PrepareJSONPayload(payload common.Payload) ([]byte, error) {
16- return json.Marshal(payload)
17-}
18-
19-// CreateNewRequest creates a new HTTP request
20-func CreateNewRequest(method, url string, body []byte) (*http.Request, error) {
21-
22- return http.NewRequest(method, url, bytes.NewBuffer(body))
23-}
24-
25-// SetRequestHeaders sets the headers for the request
26-func setAuthRequestHeaders(req *http.Request, token string) {
27- req.Header.Set("Authorization", "Bearer "+token)
28-}
29-
30-// SendRequest sends the HTTP request and returns the response
31-func SendRequest(req *http.Request) (*http.Response, error) {
32- client := &http.Client{}
33- return client.Do(req)
34-}
35-
36-type Response struct {
37- Http *http.Response
38- Header http.Header
39- Body []byte
40- StatusCode int
41-}
42-
43-// SendHTTPRequest sends the HTTP request
44-func SendHTTPRequest(a *cli.App, rp *common.RequestPayload, method string, endpoint string) (*Response, error) {
45- apiUrl := a.Config.Http.BackendURL + "/api"
46- client := &http.Client{}
47-
48- reauthenticated := false
49-
50- for {
51- // Use JWTSecret (which now holds the CLI JWT) for authentication
52- req, err := createRequest(apiUrl, endpoint, method, rp, a.Config.General.JwtToken)
53- if err != nil {
54- return nil, fmt.Errorf("failed to create request: %w", err)
55- }
56-
57- resp, err := client.Do(req)
58- if err != nil {
59- return nil, fmt.Errorf("failed to send request: %w", err)
60- }
61- defer resp.Body.Close()
62-
63- body, err := io.ReadAll(resp.Body)
64- if err != nil {
65- return nil, fmt.Errorf("failed to read response body: %w", err)
66- }
67-
68- if resp.StatusCode != http.StatusOK {
69- var errorResp struct {
70- Success bool `json:"success"`
71- Message string `json:"message"`
72- }
73- if err := json.Unmarshal(body, &errorResp); err == nil {
74- if resp.StatusCode == http.StatusUnauthorized && !reauthenticated {
75- fmt.Println("Token is invalid or expired. Initiating re-authentication...")
76- err := ReAuthenticate(a)
77- if err != nil {
78- return nil, fmt.Errorf("re-authentication failed: %w", err)
79- }
80-
81- reauthenticated = true
82- continue
83- }
84- return nil, fmt.Errorf("failed to send request: %s", errorResp.Message)
85- }
86- return nil, fmt.Errorf("failed to send request: %s", string(body))
87- }
88-
89- return &Response{Http: resp, Body: body, StatusCode: resp.StatusCode}, nil
90- }
91-}
92-
93-func createRequest(apiUrl, endpoint, method string, rp *common.RequestPayload, token string) (*http.Request, error) {
94- var req *http.Request
95- var err error
96-
97- switch rp.Type {
98- case "deploy":
99- deployPayload, ok := rp.Payload.(common.DeployPayload)
100- if !ok {
101- return nil, fmt.Errorf("invalid payload type for deploy")
102- }
103-
104- req, err = http.NewRequest(method, apiUrl+endpoint, deployPayload.Data)
105- if err != nil {
106- return nil, fmt.Errorf("failed to create new streaming request: %w", err)
107- }
108-
109- req.Header.Set("Content-Type", "application/octet-stream")
110- req.Header.Set("X-Ports", deployPayload.Port)
111- req.Header.Set("X-Target-Domain", deployPayload.TargetDomain)
112- req.Header.Set("X-Image-Name", deployPayload.ImageName)
113-
114- case "push":
115- pushPayload, ok := rp.Payload.(common.PushPayload)
116- if !ok {
117- return nil, fmt.Errorf("invalid payload type for push")
118- }
119-
120- req, err = http.NewRequest(method, apiUrl+endpoint, pushPayload.Data)
121- if err != nil {
122- return nil, fmt.Errorf("failed to create new streaming request: %w", err)
123- }
124-
125- req.Header.Set("Content-Type", "application/octet-stream")
126- req.Header.Set("X-Image-Name", pushPayload.ImageName)
127-
128- default:
129- jsonPayload, err := json.Marshal(rp)
130- if err != nil {
131- return nil, fmt.Errorf("failed to marshal payload: %w", err)
132- }
133-
134- req, err = http.NewRequest(method, apiUrl+endpoint, bytes.NewBuffer(jsonPayload))
135- if err != nil {
136- return nil, fmt.Errorf("failed to create new JSON request: %w", err)
137- }
138-
139- req.Header.Set("Content-Type", "application/json")
140- }
141-
142- // Set the authorization header for all request types
143- setAuthRequestHeaders(req, token)
144-
145- return req, nil
146-}
D internal/cli/handler/ping_request.go +0 -46
@@ -1,46 +0,0 @@
1-package handler
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "net/http"
7-
8- "github.com/bnema/gordon/internal/cli"
9- "github.com/bnema/gordon/internal/common"
10-)
11-
12-// Define a struct to match the JSON response structure
13-type PingResponse struct {
14- Uptime string `json:"uptime"`
15- Version string `json:"version"`
16-}
17-
18-func PerformPingRequest(a *cli.App) (PingResponse, error) {
19- var pingResp PingResponse
20-
21- pingPayload := common.PingPayload{Message: "ping"}
22- reqPayload := common.RequestPayload{
23- Type: "ping",
24- Payload: pingPayload,
25- }
26-
27- resp, err := SendHTTPRequest(a, &reqPayload, "GET", "/ping")
28- if err != nil {
29- return pingResp, fmt.Errorf("failed to reach server: %w", err)
30- }
31-
32- defer resp.Http.Body.Close()
33-
34- if resp.StatusCode != http.StatusOK {
35- // Return the error message from the server
36- return PingResponse{}, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(resp.Body))
37- }
38-
39- // Unmarshal the JSON response into the PingResponse struct
40- err = json.Unmarshal(resp.Body, &pingResp)
41- if err != nil {
42- return pingResp, err
43- }
44-
45- return pingResp, nil
46-}
D internal/cli/handler/regex_check.go +0 -40
@@ -1,40 +0,0 @@
1-package handler
2-
3-import (
4- "fmt"
5- "regexp"
6- "strings"
7-)
8-
9-func ValidateImageName(imageName string) error {
10- match, _ := regexp.MatchString("^([a-zA-Z0-9_\\-\\.]+\\/)*[a-zA-Z0-9_\\-\\.]+(:[a-zA-Z0-9_\\-\\.]+)?$", imageName)
11- if !match {
12- return fmt.Errorf("You must specify a valid image name in the form (registry/)image:tag, check your container engine image list")
13- }
14- return nil
15-}
16-
17-func EnsureImageTag(imageName *string) error {
18- // if not tags returns an error
19- if !strings.Contains(*imageName, ":") {
20- return fmt.Errorf("You must specify a tag for the image")
21- }
22-
23- return nil
24-}
25-
26-func ValidatePortMapping(port string) error {
27- match, _ := regexp.MatchString("^[0-9]+:[0-9]+(\\/(tcp|udp))?$", port)
28- if !match {
29- return fmt.Errorf("You must specify a port mapping in the form port:port/proto, if no protocol is specified, TCP is used")
30- }
31- return nil
32-}
33-
34-func ValidateTargetDomain(targetDomain string) error {
35- match, _ := regexp.MatchString("^(https?:\\/\\/)?([a-zA-Z0-9\\-_\\.]+\\.)+[a-zA-Z0-9\\-_\\.]+(:[0-9]+)?$", targetDomain)
36- if !match {
37- return fmt.Errorf("invalid target domain format")
38- }
39- return nil
40-}
D internal/cli/handler/start_server.go +0 -118
@@ -1,118 +0,0 @@
1-package handler
2-
3-import (
4- "context"
5- "fmt"
6- "os"
7- "os/signal"
8- "sync"
9- "syscall"
10- "time"
11-
12- "github.com/bnema/gordon/internal/httpserve"
13- "github.com/bnema/gordon/internal/server"
14- "github.com/bnema/gordon/pkg/logger"
15- "github.com/labstack/echo/v4"
16-)
17-
18-// Global server instance for shutdown handling
19-var (
20- globalServer *server.App
21- globalServerLock sync.Mutex
22-)
23-
24-// SetGlobalServer sets the global server instance for shutdown handling
25-func SetGlobalServer(s *server.App) {
26- globalServerLock.Lock()
27- defer globalServerLock.Unlock()
28- globalServer = s
29-}
30-
31-// GetGlobalServer gets the global server instance
32-func GetGlobalServer() *server.App {
33- globalServerLock.Lock()
34- defer globalServerLock.Unlock()
35- return globalServer
36-}
37-
38-// execute cmd/srv/main.go main function
39-
40-func StartServer(a *server.App, port string) error {
41- // Set the global server instance for shutdown handling
42- SetGlobalServer(a)
43-
44- _, err := server.InitializeDB(a)
45- if err != nil {
46- logger.Fatal("Failed to initialize database:", err)
47- }
48-
49- // Start the session cleaner cron job
50- a.StartSessionCleaner()
51-
52- // Initialize and start the reverse proxy using the already loaded configuration
53- p, err := httpserve.InitializeProxy(a)
54- if err != nil {
55- logger.Error("Failed to initialize reverse proxy", "error", err)
56- // Continue even if proxy fails, as the main server should still work
57- }
58-
59- e := echo.New()
60- e.Server.ReadTimeout = 10 * time.Minute
61- e.Server.WriteTimeout = 10 * time.Minute
62- e.HideBanner = true
63- e.HidePort = true
64- e = httpserve.RegisterRoutes(e, a)
65-
66- // Setup a channel to capture termination signals
67- sigs := make(chan os.Signal, 1)
68- signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
69-
70- // Start server in a goroutine
71- go func() {
72- logger.Info("Starting server", "port", port)
73- if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil {
74- if err.Error() != "http: Server closed" {
75- logger.Error("Server error", "error", err)
76- }
77- }
78- }()
79-
80- // Test admin connections after the server has started (in a separate goroutine)
81- if p != nil {
82- go func() {
83- // Add a delay to ensure the server has fully started
84- time.Sleep(2 * time.Second)
85- p.TestAdminConnectionLater()
86- }()
87- }
88-
89- // Wait for interrupt signal
90- <-sigs
91- logger.Info("Received shutdown signal")
92-
93- // Create a deadline for graceful shutdown
94- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
95- defer cancel()
96-
97- // Gracefully shutdown the server
98- logger.Info("Shutting down server...")
99- if err := e.Shutdown(ctx); err != nil {
100- logger.Error("Server shutdown error", "error", err)
101- }
102-
103- // Perform application shutdown (including database closure)
104- if err := a.Shutdown(); err != nil {
105- logger.Error("Application shutdown error", "error", err)
106- }
107-
108- // Stop the proxy if it was started
109- if p != nil {
110- logger.Info("Stopping reverse proxy...")
111- if err := p.Stop(); err != nil {
112- logger.Error("Failed to stop reverse proxy", "error", err)
113- }
114- }
115-
116- logger.Info("Shutdown complete")
117- return nil
118-}
D internal/cli/new_client.go +0 -27
@@ -1,27 +0,0 @@
1-package cli
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/gordon/internal/common"
7-)
8-
9-type App struct {
10- Config common.Config
11-}
12-
13-// NewClientApp initializes a new App with configuration.
14-func NewClientApp(buildConfig *common.BuildConfig) (*App, error) {
15- // Get global config singleton instead of loading the config again
16- config, err := common.GetGlobalConfig(buildConfig)
17- if err != nil {
18- return nil, fmt.Errorf("failed to load configuration: %w", err)
19- }
20-
21- // Initialize App
22- a := &App{
23- Config: *config,
24- }
25-
26- return a, nil
27-}
D internal/common/init_config.go +0 -860
@@ -1,860 +0,0 @@
1-package common
2-
3-import (
4- "errors"
5- "fmt"
6- "io/fs"
7- "os"
8- "path/filepath"
9- "runtime"
10- "strings"
11- "sync"
12-
13- "github.com/bnema/gordon/pkg/docker"
14- "github.com/bnema/gordon/pkg/logger"
15- "github.com/bnema/gordon/pkg/parser"
16-)
17-
18-var (
19- globalConfig *Config
20- globalConfigOnce sync.Once
21- globalConfigMu sync.RWMutex
22- configLogsPrinted bool
23-)
24-
25-// Initialize package-level logging configuration
26-func init() {
27- // Configure the logger from environment variables
28- logger.GetLogger().ConfigureFromEnv()
29-}
30-
31-type Config struct {
32- General GeneralConfig `yaml:"General"`
33- Http HttpConfig `yaml:"Http"`
34- Admin AdminConfig `yaml:"Admin"`
35- ContainerEngine ContainerEngineConfig `yaml:"ContainerEngine"`
36- ReverseProxy ReverseProxyConfig `yaml:"ReverseProxy"`
37- Build BuildConfig `yaml:"-"`
38-}
39-
40-type BuildConfig struct {
41- RunEnv string `yaml:"-"` // come from env
42- BuildVersion string `yaml:"-"` // come from build ldflags
43- BuildCommit string `yaml:"-"` // come from build ldflags
44- BuildDate string `yaml:"-"` // come from build ldflags
45- ProxyURL string `yaml:"-"`
46-}
47-
48-type AdminConfig struct {
49- Path string `yaml:"path"`
50- AdminWebUI bool `yaml:"-"`
51-}
52-
53-type GeneralConfig struct {
54- StorageDir string `yaml:"storageDir"`
55- LogLevel string `yaml:"logLevel"`
56- GordonContainerID string `yaml:"-"`
57- JwtToken string `yaml:"jwtToken"`
58-}
59-
60-type HttpConfig struct {
61- Port string `yaml:"port"`
62- Domain string `yaml:"domain"`
63- SubDomain string `yaml:"subDomain"`
64- BackendURL string `yaml:"backendURL"`
65- Https bool `yaml:"https"`
66-}
67-
68-type ContainerEngineConfig struct {
69- Sock string `yaml:"dockersock"`
70- PodmanSock string `yaml:"podmansock"`
71- Podman bool `yaml:"podman"`
72- Network string `yaml:"network"`
73-}
74-
75-type ReverseProxyConfig struct {
76- Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled
77- Port string `yaml:"port"` // Port for the reverse proxy to listen on
78- HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS
79- CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates
80- Email string `yaml:"email"` // Email for Let's Encrypt
81- LetsEncryptMode string `yaml:"letsEncryptMode"` // staging or production
82- SkipCertificates bool `yaml:"skipCertificates"` // Skip Let's Encrypt certificate acquisition when behind a TLS terminating proxy
83- GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds
84- RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates
85- EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging
86- EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware
87- DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies
88- BlockDirectIP bool `yaml:"blockDirectIP"` // Whether to block requests made directly to the server's IP (allows ACME)
89- DefaultChallengeType string `yaml:"defaultChallengeType"` // http-01 or dns-01
90- DefaultHttpChallengePort string `yaml:"defaultHttpChallengePort"`
91- DefaultDnsChallengePropagationTimeout int `yaml:"defaultDnsChallengePropagationTimeout"`
92- DefaultDnsChallengePollingInterval int `yaml:"defaultDnsChallengePollingInterval"`
93-}
94-
95-// Default values
96-var (
97- sock = "/var/run/docker.sock"
98- podmansock = "/run/podman/podman.sock"
99- reverseProxyPort = "443" // Changed to 443 for standard HTTPS
100- httpPort = "80" // Standard HTTP port
101- certDir = "/certs"
102- letsEncryptMode = "staging"
103- proxyEnabled = true // Default to enabled for backward compatibility
104- renewBefore = 30 // days
105- gracePeriod = 30 // seconds
106- defaultLogLevel = "info" // Default log level
107- defaultHttpHttps = true // Default to true
108- disableRateLimit = false
109- detectUpstreamProxy = false // Default to disabled
110- skipCertificates = false // Default to disabled
111- enableRateLimit = false // Default to disabled
112- enableHttpLogs = true // Default to enabled
113- defaultChallengeType = "http-01"
114- defaultHttpChallengePort = "80"
115- defaultDnsChallengePropagationTimeout = 60 // seconds
116- defaultDnsChallengePollingInterval = 5 // seconds
117- defaultAdminPath = "/admin"
118- defaultNetwork = "gordon"
119- blockDirectIP = true // Default to enabled
120-)
121-
122-// applyDefaultsToConfig applies default values to any fields that have zero values
123-// Returns true if any defaults were applied
124-func applyDefaultsToConfig(config *Config) bool {
125- defaultsApplied := false
126-
127- // Apply defaults to GeneralConfig
128- if config.General.LogLevel == "" {
129- config.General.LogLevel = defaultLogLevel
130- logger.Debug("Applied default value for General.LogLevel", "value", defaultLogLevel)
131- defaultsApplied = true
132- }
133- if config.General.StorageDir == "" {
134- // When running in container, use /data as the default storage location
135- if docker.IsRunningInContainer() {
136- config.General.StorageDir = "/data"
137- } else {
138- // Otherwise, use ~/.gordon
139- homeDir, err := os.UserHomeDir()
140- if err != nil {
141- homeDir = "/tmp"
142- }
143- config.General.StorageDir = filepath.Join(homeDir, ".gordon")
144- }
145- logger.Debug("Applied default value for General.StorageDir", "value", config.General.StorageDir)
146- defaultsApplied = true
147- }
148-
149- // Apply defaults to HttpConfig
150- // Check if Https is NOT explicitly set to true (i.e., it's the zero value 'false')
151- if !config.Http.Https {
152- config.Http.Https = defaultHttpHttps // Use the defined default variable
153- logger.Debug("Applied default value for Http.Https", "value", defaultHttpHttps)
154- defaultsApplied = true
155- }
156- // No defaults for Http.Port, Http.Domain, Http.SubDomain as they require explicit config
157-
158- // Apply defaults to Admin config
159- if config.Admin.Path == "" {
160- config.Admin.Path = defaultAdminPath
161- logger.Debug("Applied default value for Admin.Path", "value", defaultAdminPath)
162- defaultsApplied = true
163- }
164-
165- // Apply defaults to ContainerEngine config
166- if config.ContainerEngine.Sock == "" {
167- config.ContainerEngine.Sock = sock
168- logger.Debug("Applied default value for ContainerEngine.Sock", "value", sock)
169- defaultsApplied = true
170- }
171- if config.ContainerEngine.PodmanSock == "" {
172- config.ContainerEngine.PodmanSock = podmansock
173- logger.Debug("Applied default value for ContainerEngine.PodmanSock", "value", podmansock)
174- defaultsApplied = true
175- }
176- if config.ContainerEngine.Network == "" {
177- config.ContainerEngine.Network = defaultNetwork
178- logger.Debug("Applied default value for ContainerEngine.Network", "value", defaultNetwork)
179- defaultsApplied = true
180- }
181-
182- // Apply defaults to ReverseProxy config
183- // Set Enabled to true by default if not specified
184- if !config.ReverseProxy.Enabled {
185- config.ReverseProxy.Enabled = proxyEnabled
186- logger.Debug("Applied default value for ReverseProxy.Enabled", "value", proxyEnabled)
187- defaultsApplied = true
188- }
189- if config.ReverseProxy.Port == "" {
190- config.ReverseProxy.Port = reverseProxyPort
191- logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort)
192- defaultsApplied = true
193- }
194- if config.ReverseProxy.HttpPort == "" {
195- config.ReverseProxy.HttpPort = httpPort
196- logger.Debug("Applied default value for ReverseProxy.HttpPort", "value", httpPort)
197- defaultsApplied = true
198- }
199- if config.ReverseProxy.CertDir == "" {
200- config.ReverseProxy.CertDir = certDir
201- logger.Debug("Applied default value for ReverseProxy.CertDir", "value", certDir)
202- defaultsApplied = true
203- }
204- if config.ReverseProxy.RenewBefore == 0 {
205- config.ReverseProxy.RenewBefore = renewBefore
206- logger.Debug("Applied default value for ReverseProxy.RenewBefore", "value", renewBefore)
207- defaultsApplied = true
208- }
209- if config.ReverseProxy.LetsEncryptMode == "" {
210- config.ReverseProxy.LetsEncryptMode = letsEncryptMode
211- logger.Debug("Applied default value for ReverseProxy.LetsEncryptMode", "value", letsEncryptMode)
212- defaultsApplied = true
213- }
214- if config.ReverseProxy.GracePeriod == 0 {
215- config.ReverseProxy.GracePeriod = gracePeriod
216- logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod)
217- defaultsApplied = true
218- }
219- // Check if "enableHttpLogs" is explicitly mentioned in the config file
220- configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml")
221- if fileExists(configFilePath) {
222- yamlContent, err := os.ReadFile(configFilePath)
223- if err == nil {
224- // Only set default if enableHttpLogs not explicitly specified
225- if !strings.Contains(string(yamlContent), "enableHttpLogs:") {
226- config.ReverseProxy.EnableHttpLogs = enableHttpLogs
227- logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", enableHttpLogs)
228- defaultsApplied = true
229- } else {
230- logger.Debug("Keeping explicit value for ReverseProxy.EnableHttpLogs", "value", config.ReverseProxy.EnableHttpLogs)
231- }
232- } else {
233- // If we can't read the file, apply the default only if not set
234- if !config.ReverseProxy.EnableHttpLogs {
235- config.ReverseProxy.EnableHttpLogs = enableHttpLogs
236- logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", enableHttpLogs)
237- defaultsApplied = true
238- }
239- }
240- } else {
241- // If config file doesn't exist yet, set the default
242- config.ReverseProxy.EnableHttpLogs = enableHttpLogs
243- logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", enableHttpLogs)
244- defaultsApplied = true
245- }
246- // Set EnableRateLimit to false by default if not specified
247- if !config.ReverseProxy.EnableRateLimit {
248- config.ReverseProxy.EnableRateLimit = enableRateLimit
249- logger.Debug("Applied default value for ReverseProxy.EnableRateLimit", "value", enableRateLimit)
250- defaultsApplied = true
251- }
252-
253- // Set DetectUpstreamProxy to false by default if not specified
254- if !config.ReverseProxy.DetectUpstreamProxy {
255- config.ReverseProxy.DetectUpstreamProxy = detectUpstreamProxy
256- logger.Debug("Applied default value for ReverseProxy.DetectUpstreamProxy", "value", detectUpstreamProxy)
257- defaultsApplied = true
258- }
259-
260- // Set SkipCertificates to false by default if not specified
261- if !config.ReverseProxy.SkipCertificates {
262- config.ReverseProxy.SkipCertificates = skipCertificates
263- logger.Debug("Applied default value for ReverseProxy.SkipCertificates", "value", skipCertificates)
264- defaultsApplied = true
265- }
266-
267- // Set BlockDirectIP to true by default if not explicitly set to false
268- if !config.ReverseProxy.BlockDirectIP {
269- // Check if the field is explicitly set to false in the config file
270- configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml")
271- isExplicitlyFalse := false
272- if fileExists(configFilePath) {
273- yamlContent, err := os.ReadFile(configFilePath)
274- if err == nil {
275- // Simple check for 'blockDirectIP: false'
276- // Note: This doesn't handle comments or complex YAML structures perfectly
277- if strings.Contains(string(yamlContent), "blockDirectIP: false") {
278- isExplicitlyFalse = true
279- }
280- }
281- }
282-
283- // Apply default 'true' only if it wasn't explicitly set to 'false'
284- if !isExplicitlyFalse {
285- config.ReverseProxy.BlockDirectIP = blockDirectIP
286- logger.Debug("Applied default value for ReverseProxy.BlockDirectIP", "value", blockDirectIP)
287- defaultsApplied = true
288- } else {
289- // It was explicitly set to false in the file, keep it as false (env var can still override)
290- logger.Debug("Keeping explicitly set 'false' for ReverseProxy.BlockDirectIP", "value", config.ReverseProxy.BlockDirectIP)
291- }
292- }
293-
294- // Apply defaults for Let's Encrypt challenge settings
295- if config.ReverseProxy.DefaultChallengeType == "" {
296- config.ReverseProxy.DefaultChallengeType = defaultChallengeType
297- logger.Debug("Applied default value for ReverseProxy.DefaultChallengeType", "value", defaultChallengeType)
298- defaultsApplied = true
299- }
300- if config.ReverseProxy.DefaultHttpChallengePort == "" {
301- config.ReverseProxy.DefaultHttpChallengePort = defaultHttpChallengePort
302- logger.Debug("Applied default value for ReverseProxy.DefaultHttpChallengePort", "value", defaultHttpChallengePort)
303- defaultsApplied = true
304- }
305- if config.ReverseProxy.DefaultDnsChallengePropagationTimeout == 0 {
306- config.ReverseProxy.DefaultDnsChallengePropagationTimeout = defaultDnsChallengePropagationTimeout
307- logger.Debug("Applied default value for ReverseProxy.DefaultDnsChallengePropagationTimeout", "value", defaultDnsChallengePropagationTimeout)
308- defaultsApplied = true
309- }
310- if config.ReverseProxy.DefaultDnsChallengePollingInterval == 0 {
311- config.ReverseProxy.DefaultDnsChallengePollingInterval = defaultDnsChallengePollingInterval
312- logger.Debug("Applied default value for ReverseProxy.DefaultDnsChallengePollingInterval", "value", defaultDnsChallengePollingInterval)
313- defaultsApplied = true
314- }
315-
316- return defaultsApplied
317-}
318-
319-// getConfigDirMustExist returns the config directory and creates it if it doesn't exist
320-// This is a helper function to avoid error handling in applyDefaultsToConfig
321-func getConfigDirMustExist() string {
322- configDir, err := getConfigDir()
323- if err != nil {
324- // Fallback to a reasonable default if there's an error
325- homeDir, err := os.UserHomeDir()
326- if err != nil {
327- return "/tmp/.gordon"
328- }
329- return filepath.Join(homeDir, ".gordon")
330- }
331- return configDir
332-}
333-
334-// GetGlobalConfig returns the singleton config instance, loading it if necessary
335-func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) {
336- logger.Debug("GetGlobalConfig: Starting with buildConfig", "buildConfig", buildConfig != nil)
337-
338- // Try read lock first
339- logger.Debug("GetGlobalConfig: Acquiring read lock")
340- globalConfigMu.RLock()
341- if globalConfig != nil {
342- // If buildConfig is provided, update the Build field
343- if buildConfig != nil {
344- globalConfig.Build = *buildConfig
345- }
346- config := globalConfig
347- logger.Debug("GetGlobalConfig: Config exists, releasing read lock")
348- globalConfigMu.RUnlock()
349- logger.Debug("GetGlobalConfig: Using existing global configuration (already loaded)")
350- return config, nil
351- }
352- logger.Debug("GetGlobalConfig: Config not found, releasing read lock")
353- globalConfigMu.RUnlock()
354-
355- // Need to load the config - acquire write lock
356- logger.Debug("GetGlobalConfig: No existing config, acquiring write lock")
357- globalConfigMu.Lock()
358- defer func() {
359- logger.Debug("GetGlobalConfig: Releasing write lock via defer")
360- globalConfigMu.Unlock()
361- }()
362-
363- // Double-check in case another goroutine loaded the config while we were waiting
364- if globalConfig != nil {
365- // If buildConfig is provided, update the Build field
366- if buildConfig != nil {
367- globalConfig.Build = *buildConfig
368- }
369- logger.Debug("GetGlobalConfig: Using existing global configuration (loaded by another goroutine)")
370- return globalConfig, nil
371- }
372-
373- // Initialize config with build info
374- config := &Config{}
375- if buildConfig != nil {
376- config.Build = *buildConfig
377- }
378-
379- // Load the config once
380- var err error
381- logger.Debug("GetGlobalConfig: About to execute globalConfigOnce.Do")
382- globalConfigOnce.Do(func() {
383- logger.Debug("Inside globalConfigOnce.Do, loading configuration...")
384- var loadedConfig *Config
385- loadedConfig, err = config.LoadConfig()
386- if err == nil {
387- logger.Debug("GetGlobalConfig: LoadConfig successful, setting globalConfig")
388- globalConfig = loadedConfig
389- logger.Info("Global configuration loaded successfully")
390- } else {
391- logger.Error("Error loading global configuration", "error", err)
392- }
393- })
394- logger.Debug("GetGlobalConfig: Completed globalConfigOnce.Do")
395-
396- // Return the global config if it was set, otherwise return the local config
397- if globalConfig != nil {
398- return globalConfig, err
399- }
400-
401- return config, err
402-}
403-
404-func getConfigDir() (string, error) {
405- logger.Debug("getConfigDir: Starting getConfigDir function")
406- var configDir string
407-
408- logger.Debug("getConfigDir: Checking if running in WSL")
409-
410- logger.Debug("getConfigDir: Checking if running in container")
411- if docker.IsRunningInContainer() {
412- logger.Debug("getConfigDir: Running in container, using current directory")
413- return ".", nil
414- }
415-
416- // Check for XDG_CONFIG_HOME first
417- logger.Debug("getConfigDir: Checking XDG_CONFIG_HOME environment variable")
418- if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
419- logger.Debug("getConfigDir: Using XDG_CONFIG_HOME environment variable", "value", xdgConfigHome)
420- configDir = filepath.Join(xdgConfigHome, "Gordon")
421- } else {
422- // If XDG_CONFIG_HOME is not set, fall back to default locations
423- logger.Debug("getConfigDir: XDG_CONFIG_HOME not set, getting user home directory")
424- homeDir, err := os.UserHomeDir()
425- if err != nil {
426- logger.Debug("getConfigDir: Failed to get user home directory", "error", err)
427- return "", fmt.Errorf("could not determine user home directory: %w", err)
428- }
429-
430- // For Windows, use the standard application data directory
431- if runtime.GOOS == "windows" {
432- logger.Debug("getConfigDir: On Windows, using AppData")
433- configDir = filepath.Join(homeDir, "AppData", "Local", "Gordon")
434- } else {
435- // For Unix systems, use the XDG Base Directory Specification
436- logger.Debug("getConfigDir: On Unix system, using XDG pattern")
437- configDir = filepath.Join(homeDir, ".config", "Gordon")
438- }
439- }
440-
441- logger.Debug("getConfigDir: Returning config directory", "dir", configDir)
442- return configDir, nil
443-}
444-
445-// readAndUnmarshalConfig reads and unmarshals a configuration file using the provided filesystem and path.
446-func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error {
447- logger.Debug("readAndUnmarshalConfig: Starting to read and unmarshal config file", "path", filePath)
448- err := parser.ParseYAMLFile(fs, filePath, config)
449- if err != nil {
450- logger.Debug("readAndUnmarshalConfig: Failed to parse YAML file", "error", err)
451- return fmt.Errorf("error reading and unmarshaling configuration file: %w", err)
452- }
453- logger.Debug("readAndUnmarshalConfig: Successfully parsed YAML file")
454- return nil
455-}
456-
457-func (config *Config) LoadConfig() (*Config, error) {
458- // If we're in a non-interactive environment, use simplified config
459- nonInteractiveEnv := os.Getenv("GORDON_NONINTERACTIVE")
460- isNonInteractive := nonInteractiveEnv == "true" || nonInteractiveEnv == "1" || nonInteractiveEnv == "yes"
461- isInContainer := docker.IsRunningInContainer() // Check once
462-
463- // Flag to track if saving is needed at the end
464- shouldSaveConfig := false
465- defaultsApplied := false
466-
467- if isNonInteractive {
468- logger.Info("Non-interactive mode detected, using simplified config without prompts")
469- // Start with an empty config
470- *config = Config{}
471-
472- // Determine StorageDir
473- if isInContainer {
474- config.General.StorageDir = "/data"
475- logger.Debug("Non-interactive: Using default container storage dir", "dir", config.General.StorageDir)
476- } else {
477- homeDir, err := os.UserHomeDir()
478- if err != nil {
479- homeDir = "/tmp"
480- logger.Warn("Could not get user home directory, using /tmp instead")
481- }
482- config.General.StorageDir = filepath.Join(homeDir, ".gordon")
483- logger.Debug("Non-interactive: Using default host storage dir", "dir", config.General.StorageDir)
484- }
485-
486- // Auto-detect Podman and set relevant fields
487- isPodman, podmanSocket := docker.DetectPodman()
488- config.ContainerEngine.Podman = isPodman
489- if isPodman {
490- config.ContainerEngine.PodmanSock = podmanSocket
491- logger.Info("Non-interactive: Podman detected", "socket", podmanSocket)
492- } else {
493- // Explicitly set Docker sock if Podman not detected, applyDefaults might override if empty later
494- config.ContainerEngine.Sock = sock
495- logger.Info("Non-interactive: Assuming Docker")
496- }
497-
498- // Apply remaining defaults
499- defaultsApplied = applyDefaultsToConfig(config)
500- if defaultsApplied {
501- logger.Info("Non-interactive: Applied default configuration values")
502- shouldSaveConfig = true // Mark for saving if defaults were needed
503- }
504-
505- // If in container, apply env vars (potentially overriding defaults)
506- if isInContainer {
507- logger.Info("Non-interactive: Running in container, overriding configuration with environment variables...")
508- // Assuming loadConfigFromEnv modifies config in place
509- loadConfigFromEnv(config, !configLogsPrinted) // Pass flag to control initial logging
510- configLogsPrinted = true // Mark logs as printed
511- shouldSaveConfig = true // Mark for saving if env vars were applied
512- logger.Debug("Non-interactive: Finished loading config from environment")
513- }
514-
515- // Load run env (common step)
516- config.Build.RunEnv = os.Getenv("RUN_ENV")
517- if config.Build.RunEnv == "" {
518- config.Build.RunEnv = "prod"
519- logger.Info("Non-interactive: No RUN_ENV specified, defaulting to 'prod'")
520- } else {
521- logger.Info("Non-interactive: Run environment set", "env", config.Build.RunEnv)
522- }
523-
524- // Check AdminWebUI (common step)
525- if config.Admin.Path == "" {
526- logger.Warn("Non-interactive: Admin path is empty or not set, admin dashboard webui will be disabled")
527- config.Admin.AdminWebUI = false
528- } else {
529- config.Admin.AdminWebUI = true
530- }
531-
532- // Apply log level (common step)
533- if config.General.LogLevel != "" {
534- logger.GetLogger().SetLogLevel(config.General.LogLevel)
535- logger.Debug("Non-interactive: Log level set from configuration", "level", config.General.LogLevel)
536- }
537-
538- // Save config if needed (only in non-interactive mode if defaults/env applied)
539- if shouldSaveConfig {
540- logger.Info("Non-interactive: Saving configuration...")
541- err := config.SaveConfig()
542- if err != nil {
543- // Log error but continue, as config is loaded, just not saved
544- logger.Error("Non-interactive: Failed to save configuration", "error", err)
545- } else {
546- logger.Info("Non-interactive: Successfully saved configuration")
547- }
548- }
549-
550- logger.Debug("LoadConfig: Completed non-interactive setup")
551- return config, nil // Return early for non-interactive mode
552- }
553-
554- // ---- Interactive Mode Logic ----
555- logger.Debug("LoadConfig: Starting interactive LoadConfig function")
556-
557- // Log current execution environment for debugging
558- cwd, _ := os.Getwd()
559- logger.Debug("LoadConfig: Environment information",
560- "isContainer", isInContainer, // Use cached value
561- "currentDir", cwd,
562- "ENV", os.Getenv("ENV"),
563- "GORDON_NONINTERACTIVE", os.Getenv("GORDON_NONINTERACTIVE"))
564-
565- logger.Info("Loading config...")
566-
567- // Enable extra debugging for this function if requested
568- debugLevel := os.Getenv("GORDON_CONFIG_LOG_LEVEL")
569- if debugLevel == "trace" {
570- logger.Debug("LoadConfig: TRACE mode enabled, will log detailed steps")
571- }
572-
573- // Get the config directory
574- logger.Debug("LoadConfig: About to call getConfigDir()")
575- configDir, err := getConfigDir()
576- if err != nil {
577- logger.Error("LoadConfig: Failed to get config directory", "error", err)
578- return nil, fmt.Errorf("error getting configuration directory: %w", err)
579- }
580-
581- logger.Debug("LoadConfig: Got config directory", "dir", configDir)
582- logger.Info("Using configuration directory", "dir", configDir)
583-
584- // Determine config file path
585- var configFilePath string
586- logger.Debug("LoadConfig: Checking config file location")
587- // Check for direct mount first only if in container
588- directMountPath := "./config.yml"
589- useDirectMount := isInContainer && fileExists(directMountPath)
590-
591- if useDirectMount {
592- configFilePath = directMountPath
593- logger.Info("Found directly mounted config.yml in container current directory")
594- } else {
595- configFilePath = filepath.Join(configDir, "config.yml")
596- logger.Info("Using config file path", "path", configFilePath)
597- }
598-
599- // Add an indicator if this is not the first time we're loading config
600- if configLogsPrinted {
601- logger.Info("Reloading configuration (already loaded previously)...")
602- }
603-
604- configExists := true // Assume exists unless stat fails
605-
606- logger.Debug("LoadConfig: Checking if config file exists", "path", configFilePath)
607- _, err = os.Stat(configFilePath)
608- if errors.Is(err, fs.ErrNotExist) {
609- configExists = false
610- shouldSaveConfig = true // Need to save if we create it
611- logger.Info("Config file not found, creating it", "path", configFilePath)
612-
613- // Create config dir if it doesn't exist (only needed if using configDir path)
614- if !useDirectMount {
615- logger.Debug("LoadConfig: Creating config directory", "dir", configDir)
616- err = os.MkdirAll(configDir, 0755)
617- if err != nil {
618- logger.Error("LoadConfig: Failed to create config directory", "error", err)
619- return nil, fmt.Errorf("error creating configuration directory: %w", err)
620- }
621- } else {
622- logger.Debug("LoadConfig: Using direct mount, skipping config directory creation")
623- }
624-
625- // Start with an empty config
626- *config = Config{}
627-
628- // Set specific initial values before applying defaults
629-
630- // Storage Directory
631- if isInContainer {
632- config.General.StorageDir = "/data"
633- logger.Debug("LoadConfig: Setting initial StorageDir for container", "value", config.General.StorageDir)
634- } else {
635- homeDir, err := os.UserHomeDir()
636- if err != nil {
637- logger.Error("LoadConfig: Failed to get user home directory", "error", err)
638- // Setting a temporary default, applyDefaultsToConfig will handle it properly later if needed
639- homeDir = "/tmp"
640- }
641- config.General.StorageDir = filepath.Join(homeDir, ".gordon")
642- logger.Debug("LoadConfig: Setting initial StorageDir for host", "value", config.General.StorageDir)
643- }
644-
645- // Podman Setting
646- var defaultPodman bool
647- if !isInContainer { // Only prompt if interactive and not in a container
648- logger.Info("Not in container, prompting for podman preference...")
649- logger.Debug("LoadConfig: About to prompt for podman preference")
650- defaultPodman = ReadUserInputNonBlocking("Are you using podman ? (y/n)", "n") == "y"
651- logger.Debug("LoadConfig: User responded to podman prompt", "usingPodman", defaultPodman)
652- } else if os.Getenv("GORDON_CONTAINER_PODMAN") == "true" {
653- defaultPodman = true
654- logger.Info("Podman container engine detected via environment variable")
655- } else {
656- // Default to false (Docker) in container if env var not set
657- defaultPodman = false
658- logger.Info("Using Docker as the default container engine in container environment")
659- }
660- config.ContainerEngine.Podman = defaultPodman
661- logger.Debug("LoadConfig: Set initial podman value", "value", defaultPodman)
662-
663- // Now apply all other defaults
664- logger.Info("Applying default values for the new configuration...")
665- defaultsApplied = applyDefaultsToConfig(config)
666- // defaultsApplied is already true implicitly because we created the file
667-
668- } else if err != nil {
669- logger.Error("LoadConfig: Error checking if config file exists", "error", err)
670- return nil, fmt.Errorf("error checking configuration file: %w", err)
671- } else {
672- // If config file exists, read it
673- logger.Info("Reading existing configuration file...")
674- logger.Debug("LoadConfig: About to read and unmarshal existing config file")
675- // Use DirFS based on whether it's a direct mount or in configDir
676- var baseDir string
677- var fileName string
678- if useDirectMount {
679- baseDir = "."
680- fileName = "config.yml"
681- } else {
682- baseDir = configDir
683- fileName = "config.yml"
684- }
685- err = readAndUnmarshalConfig(os.DirFS(baseDir), fileName, config)
686- if err != nil {
687- logger.Error("LoadConfig: Failed to read/unmarshal config file", "error", err)
688- return nil, err
689- }
690- logger.Debug("LoadConfig: Successfully read and unmarshaled config file")
691-
692- // Apply default values to fields that were not specified in the config file
693- logger.Info("Applying default values to missing configuration fields...")
694- defaultsApplied = applyDefaultsToConfig(config)
695- if defaultsApplied {
696- shouldSaveConfig = true // Mark for saving if defaults were applied to existing file
697- }
698- }
699-
700- // --- Common Steps for Interactive Mode (after file read/creation) ---
701-
702- // Load environment variables if in a container (might override file/defaults)
703- envVarsLoaded := false
704- if isInContainer {
705- shouldPrintLogs := !configLogsPrinted
706- logger.Info("Running in container, overriding configuration with environment variables...")
707- logger.Debug("LoadConfig: About to load config from environment")
708- loadConfigFromEnv(config, shouldPrintLogs) // Assuming modification in place
709- envVarsLoaded = true
710- configLogsPrinted = true
711- shouldSaveConfig = true // Mark for saving if env vars were applied
712- logger.Debug("LoadConfig: Finished loading config from environment")
713- }
714-
715- // Load run env
716- logger.Debug("LoadConfig: Setting run environment")
717- config.Build.RunEnv = os.Getenv("RUN_ENV")
718- if config.Build.RunEnv == "" {
719- config.Build.RunEnv = "prod"
720- logger.Info("No RUN_ENV specified, defaulting to 'prod'")
721- } else {
722- logger.Info("Run environment set", "env", config.Build.RunEnv)
723- }
724-
725- // Check AdminWebUI status
726- if config.Admin.Path == "" {
727- logger.Warn("Admin path is empty or not set, admin dashboard webui will be disabled")
728- config.Admin.AdminWebUI = false
729- } else {
730- config.Admin.AdminWebUI = true
731- }
732-
733- // Save config file if it was created, defaults were applied, or env vars loaded in container
734- if shouldSaveConfig {
735- logger.Info("Saving final configuration state...", "path", configFilePath)
736- logger.Debug("LoadConfig: About to save configuration", "reason", fmt.Sprintf("configExists=%v, defaultsApplied=%v, envVarsLoadedInContainer=%v", !configExists, defaultsApplied, envVarsLoaded && isInContainer))
737- err = config.SaveConfig() // SaveConfig determines the correct path internally now
738- if err != nil {
739- // Log error but proceed, config is loaded, just potentially not saved
740- logger.Error("LoadConfig: Failed to save final configuration", "error", err)
741- } else {
742- logger.Info("LoadConfig: Successfully saved final configuration")
743- }
744- }
745-
746- // Apply the log level from the final configuration
747- if config.General.LogLevel != "" {
748- logger.GetLogger().SetLogLevel(config.General.LogLevel)
749- logger.Debug("Log level set from final configuration", "level", config.General.LogLevel)
750- } else {
751- logger.Warn("Final configuration has empty LogLevel")
752- }
753-
754- // Final debug log before returning
755- logger.Debug("LoadConfig: Completed interactive LoadConfig function")
756- return config, nil
757-}
758-
759-func (config *Config) SaveConfig() error {
760- configDir, err := getConfigDir()
761- if err != nil {
762- return fmt.Errorf("error getting configuration directory: %w", err)
763- }
764- isInContainer := docker.IsRunningInContainer()
765-
766- // Determine the correct save path
767- var configFilePath string
768- directMountPath := "/config.yml" // Use root path for direct container mount check
769- if isInContainer && fileExists(directMountPath) {
770- configFilePath = directMountPath
771- logger.Debug("SaveConfig: Using direct mount path", "path", configFilePath)
772- } else if isInContainer && fileExists("./config.yml") { // Check relative path if direct root mount check fails
773- configFilePath = "./config.yml"
774- logger.Debug("SaveConfig: Using relative path in container", "path", configFilePath)
775- } else {
776- // Default to XDG path if not a direct mount or relative path found in container
777- configFilePath = filepath.Join(configDir, "config.yml")
778- logger.Debug("SaveConfig: Using standard config directory path", "path", configFilePath)
779-
780- // Ensure the directory exists before trying to write the file
781- err = os.MkdirAll(filepath.Dir(configFilePath), 0755)
782- if err != nil {
783- return fmt.Errorf("error ensuring configuration directory exists for saving: %w", err)
784- }
785- }
786-
787- logger.Info("Saving configuration to", "path", configFilePath)
788-
789- // Create a temporary config copy excluding Build field for saving
790- saveConfig := *config
791- saveConfig.Build = BuildConfig{} // Exclude build info from saved file
792-
793- err = parser.WriteYAMLFile(configFilePath, &saveConfig) // Pass pointer to the copy
794- if err != nil {
795- return fmt.Errorf("error writing configuration file: %w", err)
796- }
797-
798- logger.Debug("SaveConfig: Configuration saved successfully", "path", configFilePath)
799- return nil
800-}
801-
802-// Helper function to check if a file exists
803-func fileExists(filepath string) bool {
804- _, err := os.Stat(filepath)
805- return err == nil
806-}
807-
808-// GetRunEnv returns the run environment
809-func (config *BuildConfig) GetRunEnv() string {
810- return config.RunEnv
811-}
812-
813-// get storage dir
814-func (config *Config) GetStorageDir() string {
815- return config.General.StorageDir
816-}
817-
818-func (c *Config) GetBackendURL() string {
819- return c.Http.BackendURL
820-}
821-
822-// GetToken returns the token
823-func (c *Config) GetToken() string {
824- return c.General.JwtToken
825-}
826-
827-func (c *HttpConfig) Protocol() string {
828- if c.Https {
829- return "https"
830- }
831- return "http"
832-}
833-
834-func (c *HttpConfig) FullDomain() string {
835- domain := c.Domain
836-
837- // Clean the domain to ensure it doesn't contain path components
838- domain = strings.TrimSuffix(domain, "/")
839- if idx := strings.Index(domain, "/"); idx >= 0 {
840- // If there's a path component, remove it
841- domain = domain[:idx]
842- }
843-
844- if c.SubDomain != "" {
845- // Clean the subdomain too
846- subdomain := strings.TrimSuffix(c.SubDomain, "/")
847- return fmt.Sprintf("%s.%s", subdomain, domain)
848- }
849- return domain
850-}
851-
852-// GetVersion
853-func (c *Config) GetVersion() string {
854- return c.Build.BuildVersion
855-}
856-
857-// IsAdminWebUIEnabled returns whether the admin dashboard is enabled
858-func (c *AdminConfig) IsAdminWebUIEnabled() bool {
859- return c.AdminWebUI && c.Path != ""
860-}
D internal/common/init_dockerclient.go +0 -221
@@ -1,221 +0,0 @@
1-package common
2-
3-import (
4- "fmt"
5- "log"
6- "os"
7- "strings"
8-
9- "github.com/bnema/gordon/pkg/docker"
10-)
11-
12-func DockerInit(cc *ContainerEngineConfig) error {
13- // Auto-detect Podman based on socket path
14- isPodmanSocket := detectPodmanSocket(cc.Sock)
15- if isPodmanSocket && !cc.Podman {
16- fmt.Println("Podman socket detected at", cc.Sock)
17- fmt.Println("Automatically enabling Podman support")
18- cc.Podman = true
19- cc.PodmanSock = cc.Sock
20- }
21-
22- // If socket is empty, try to detect it automatically
23- if cc.Sock == "" {
24- // Try standard paths first
25- standardPaths := []string{
26- "/var/run/docker.sock", // Standard Docker socket in containers
27- "/run/podman/podman.sock", // Standard Podman path
28- "/var/run/podman/podman.sock", // Alternative standard path
29- }
30-
31- // Add home directory based path
32- homePath := os.Getenv("HOME")
33- if homePath != "" {
34- standardPaths = append(standardPaths, homePath+"/.local/podman/podman.sock")
35- }
36-
37- // Check standard paths first
38- for _, path := range standardPaths {
39- if _, err := os.Stat(path); err == nil {
40- log.Printf("Auto-detected container engine socket at %s", path)
41-
42- // Detect if this is a Podman socket
43- isPodmanSocket = detectPodmanSocket(path)
44- if isPodmanSocket {
45- cc.Podman = true
46- cc.PodmanSock = path
47- }
48-
49- cc.Sock = path
50- break
51- } else {
52- log.Printf("Socket not found at: %s", path)
53- }
54- }
55-
56- // If not found in standard paths, try common user IDs
57- if cc.Sock == "" {
58- log.Printf("Scanning for user-specific Podman sockets in ID range 1000-1100...")
59-
60- // Try common user IDs from 1000 to 1100 (typical range for first users on Linux)
61- for uid := 1000; uid <= 1100; uid++ {
62- uidStr := fmt.Sprintf("%d", uid)
63- podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr)
64- dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr)
65-
66- // Check the Podman socket exists
67- if _, err := os.Stat(podmanSocketPath); err == nil {
68- // Check if the socket is accessible for reading/writing
69- if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil {
70- file.Close()
71- log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath)
72- cc.Podman = true
73- cc.PodmanSock = podmanSocketPath
74- cc.Sock = podmanSocketPath
75- break
76- } else {
77- log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err)
78- }
79- }
80-
81- // Check Docker socket for this UID
82- if _, err := os.Stat(dockerSocketPath); err == nil {
83- if file, err := os.OpenFile(dockerSocketPath, os.O_RDWR, 0); err == nil {
84- file.Close()
85- log.Printf("Found accessible Docker socket for user ID %s at: %s", uidStr, dockerSocketPath)
86- cc.Sock = dockerSocketPath
87- break
88- }
89- }
90- }
91- }
92- }
93-
94- // If we found a socket, try to initialize Docker client
95- if cc.Sock != "" {
96- config := NewDockerConfig(cc)
97- err := docker.InitializeClient(config)
98- if err == nil {
99- log.Printf("Successfully initialized Docker client with socket: %s", cc.Sock)
100- return nil
101- }
102-
103- // Log the error but continue with fallback strategy
104- log.Printf("Error initializing Docker client: %s", err)
105- }
106-
107- // If we failed to find or initialize a socket, try a comprehensive fallback scan
108- // across all common socket locations and user IDs
109- socketPaths := []string{
110- "/var/run/docker.sock",
111- "/run/podman/podman.sock",
112- "/var/run/podman/podman.sock",
113- }
114-
115- // Add home directory based path
116- homePath := os.Getenv("HOME")
117- if homePath != "" {
118- socketPaths = append(socketPaths, homePath+"/.local/podman/podman.sock")
119- }
120-
121- // Skip if we already tried this path
122- if cc.Sock != "" {
123- skipPath := cc.Sock
124- for i, path := range socketPaths {
125- if path == skipPath {
126- socketPaths = append(socketPaths[:i], socketPaths[i+1:]...)
127- break
128- }
129- }
130- }
131-
132- // Try common user IDs from 1000 to 1100
133- for uid := 1000; uid <= 1100; uid++ {
134- uidStr := fmt.Sprintf("%d", uid)
135- podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr)
136- dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr)
137-
138- // Check the Podman socket exists
139- if _, err := os.Stat(podmanSocketPath); err == nil {
140- // Check if the socket is accessible for reading/writing
141- if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil {
142- file.Close()
143- log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath)
144- socketPaths = append([]string{podmanSocketPath}, socketPaths...) // Prioritize this path
145- } else {
146- log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err)
147- }
148- }
149-
150- // Also check Docker socket for this UID
151- if _, err := os.Stat(dockerSocketPath); err == nil {
152- socketPaths = append(socketPaths, dockerSocketPath)
153- }
154- }
155-
156- log.Printf("Comprehensive socket scan across %d potential paths...", len(socketPaths))
157-
158- for _, path := range socketPaths {
159- // Skip if it's the path we already tried
160- if path == cc.Sock {
161- continue
162- }
163-
164- // Check if this socket exists
165- if _, err := os.Stat(path); err == nil {
166- log.Printf("Found alternative socket at %s", path)
167-
168- // Detect if this is a Podman socket
169- isPodmanSocket = detectPodmanSocket(path)
170- if isPodmanSocket {
171- cc.Podman = true
172- cc.PodmanSock = path
173- }
174-
175- cc.Sock = path
176-
177- // Try again with the new path
178- newConfig := NewDockerConfig(cc)
179- if err := docker.InitializeClient(newConfig); err == nil {
180- log.Printf("Successfully initialized Docker client with socket: %s", path)
181- return nil
182- } else {
183- log.Printf("Socket found but client initialization failed: %s", err)
184- }
185- }
186- }
187-
188- // If we still haven't found a valid socket, give up but don't fail the application
189- log.Printf("No working container engine socket found after comprehensive scan")
190- log.Printf("Docker/Podman functionality will be limited or unavailable")
191- return fmt.Errorf("could not find or connect to any container engine socket")
192-}
193-
194-// detectPodmanSocket checks if a socket path is likely a Podman socket
195-func detectPodmanSocket(socketPath string) bool {
196- if socketPath == "" {
197- return false
198- }
199-
200- // Check if it's a user-specific Podman socket path
201- if strings.Contains(socketPath, "user") && strings.Contains(socketPath, "podman") {
202- log.Printf("User-specific rootless Podman socket detected: %s", socketPath)
203- return true
204- }
205-
206- return strings.Contains(socketPath, "podman")
207-}
208-
209-// NewDockerConfig creates and returns a new Docker client configuration
210-func NewDockerConfig(cc *ContainerEngineConfig) *docker.Config {
211- if cc.Podman {
212- return &docker.Config{
213- Sock: cc.PodmanSock,
214- PodmanEnable: true,
215- }
216- }
217- return &docker.Config{
218- Sock: cc.Sock,
219- PodmanEnable: false,
220- }
221-}
D internal/common/init_env.go +0 -283
@@ -1,283 +0,0 @@
1-package common
2-
3-import (
4- "os"
5- "strconv"
6- "strings"
7-
8- "github.com/bnema/gordon/pkg/docker"
9- "github.com/bnema/gordon/pkg/logger"
10-)
11-
12-// loadConfigFromEnv loads configuration from environment variables
13-func loadConfigFromEnv(config *Config, printLogs bool) {
14- // General Configuration
15- if val := os.Getenv("GORDON_STORAGE_DIR"); val != "" {
16- config.General.StorageDir = val
17- if printLogs {
18- logger.Info("Using environment variable GORDON_STORAGE_DIR", "value", val)
19- }
20- }
21- // Removed GORDON_TOKEN handling
22- if val := os.Getenv("GORDON_LOG_LEVEL"); val != "" {
23- config.General.LogLevel = val
24- if printLogs {
25- logger.Info("Using environment variable GORDON_LOG_LEVEL", "value", val)
26- }
27- }
28-
29- // HTTP Configuration
30- if val := os.Getenv("GORDON_HTTP_PORT"); val != "" {
31- config.Http.Port = val
32- if printLogs {
33- logger.Info("Using environment variable GORDON_HTTP_PORT", "value", val)
34- }
35- }
36- if val := os.Getenv("GORDON_HTTP_DOMAIN"); val != "" {
37- config.Http.Domain = val
38- if printLogs {
39- logger.Info("Using environment variable GORDON_HTTP_DOMAIN", "value", val)
40- }
41- }
42- if val := os.Getenv("GORDON_HTTP_SUBDOMAIN"); val != "" {
43- config.Http.SubDomain = val
44- if printLogs {
45- logger.Info("Using environment variable GORDON_HTTP_SUBDOMAIN", "value", val)
46- }
47- }
48- if val := os.Getenv("GORDON_HTTP_BACKEND_URL"); val != "" {
49- config.Http.BackendURL = val
50- if printLogs {
51- logger.Info("Using environment variable GORDON_HTTP_BACKEND_URL", "value", val)
52- }
53- }
54- if val := os.Getenv("GORDON_HTTP_HTTPS"); val != "" {
55- config.Http.Https = strings.ToLower(val) == "true"
56- if printLogs {
57- logger.Info("Using environment variable GORDON_HTTP_HTTPS", "value", config.Http.Https)
58- }
59- }
60-
61- // Admin Configuration
62- if val := os.Getenv("GORDON_ADMIN_PATH"); val != "" {
63- config.Admin.Path = val
64- if printLogs {
65- logger.Info("Using environment variable GORDON_ADMIN_PATH", "value", val)
66- }
67- }
68-
69- // Container Engine Configuration
70- if val := os.Getenv("GORDON_CONTAINER_SOCK"); val != "" {
71- config.ContainerEngine.Sock = val
72- if printLogs {
73- logger.Info("Using environment variable GORDON_CONTAINER_SOCK", "value", val)
74- }
75- }
76- if val := os.Getenv("GORDON_CONTAINER_PODMANSOCK"); val != "" {
77- config.ContainerEngine.PodmanSock = val
78- if printLogs {
79- logger.Info("Using environment variable GORDON_CONTAINER_PODMANSOCK", "value", val)
80- }
81- }
82- if val := os.Getenv("GORDON_CONTAINER_PODMAN"); val != "" {
83- config.ContainerEngine.Podman = strings.ToLower(val) == "true"
84- if printLogs {
85- logger.Info("Using environment variable GORDON_CONTAINER_PODMAN", "value", config.ContainerEngine.Podman)
86- }
87- } else {
88- // Auto-detect Podman if not specified in environment
89- isPodman, podmanSocket := docker.DetectPodman()
90- if isPodman {
91- config.ContainerEngine.Podman = true
92- config.ContainerEngine.PodmanSock = podmanSocket
93- if printLogs {
94- logger.Info("Automatically detected Podman installation",
95- "using_podman", true,
96- "socket", podmanSocket)
97- }
98- }
99-
100- // If we're in a container, check for special socket naming patterns
101- if docker.IsRunningInContainer() && fileExists(config.ContainerEngine.Sock) {
102- // Check if socket path contains "podman" which indicates it might be a podman socket
103- if strings.Contains(config.ContainerEngine.Sock, "podman") {
104- config.ContainerEngine.Podman = true
105- if printLogs {
106- logger.Info("Socket path contains 'podman', enabling Podman mode", "path", config.ContainerEngine.Sock)
107- }
108- }
109- }
110- }
111- if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" {
112- config.ContainerEngine.Network = val
113- if printLogs {
114- logger.Info("Using environment variable GORDON_CONTAINER_NETWORK", "value", val)
115- }
116- }
117-
118- // Use Podman socket if Podman is enabled and no Docker socket is specified
119- if config.ContainerEngine.Podman && config.ContainerEngine.Sock == "" && config.ContainerEngine.PodmanSock != "" {
120- config.ContainerEngine.Sock = config.ContainerEngine.PodmanSock
121- if printLogs {
122- logger.Info("Setting ContainerEngine.Sock to PodmanSock value", "value", config.ContainerEngine.Sock)
123- }
124- }
125-
126- // Additional debug info for container sockets
127- if docker.IsRunningInContainer() {
128- logger.Debug("Container socket configuration",
129- "sock", config.ContainerEngine.Sock,
130- "podmansock", config.ContainerEngine.PodmanSock,
131- "podman", config.ContainerEngine.Podman,
132- "sock_exists", fileExists(config.ContainerEngine.Sock),
133- "podmansock_exists", fileExists(config.ContainerEngine.PodmanSock))
134-
135- // Special handling for container environment to ensure sockets are properly set
136- // If no socket is configured but default socket path exists, use it
137- if config.ContainerEngine.Sock == "" && fileExists(sock) {
138- config.ContainerEngine.Sock = sock
139- logger.Info("Found default Docker socket in container, using it", "path", sock)
140- }
141-
142- // If podman socket exists but podman mode is not enabled, check if we should enable it
143- if fileExists(podmansock) && !config.ContainerEngine.Podman {
144- // If the main socket path contains "podman", enable podman mode
145- if strings.Contains(config.ContainerEngine.Sock, "podman") {
146- config.ContainerEngine.Podman = true
147- logger.Info("Socket path suggests Podman usage, enabling Podman mode", "path", config.ContainerEngine.Sock)
148- }
149- }
150- }
151-
152- // Reverse Proxy Configuration
153- if val := os.Getenv("GORDON_PROXY_PORT"); val != "" {
154- config.ReverseProxy.Port = val
155- if printLogs {
156- logger.Info("Using environment variable GORDON_PROXY_PORT", "value", val)
157- }
158- }
159- if val := os.Getenv("GORDON_PROXY_HTTP_PORT"); val != "" {
160- config.ReverseProxy.HttpPort = val
161- if printLogs {
162- logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val)
163- }
164- }
165-
166- // New environment variables for reverse proxy port control
167- if val := os.Getenv("GORDON_USE_FALLBACK_BINDING"); val != "" {
168- // This is handled at runtime in the proxy server but we log it here
169- if printLogs {
170- logger.Info("Found environment variable GORDON_USE_FALLBACK_BINDING", "value", val)
171- }
172- }
173-
174- // Handle GORDON_PROXY_ENABLED environment variable
175- if val := os.Getenv("GORDON_PROXY_ENABLED"); val != "" {
176- enabled, err := strconv.ParseBool(val)
177- if err == nil {
178- config.ReverseProxy.Enabled = enabled
179- }
180- if printLogs {
181- logger.Info("Using environment variable GORDON_PROXY_ENABLED", "value", config.ReverseProxy.Enabled)
182- }
183- }
184- if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" {
185- config.ReverseProxy.CertDir = val
186- if printLogs {
187- logger.Info("Using environment variable GORDON_PROXY_CERT_DIR", "value", val)
188- }
189- }
190- if val := os.Getenv("GORDON_PROXY_RENEW_BEFORE"); val != "" {
191- if i, err := strconv.Atoi(val); err == nil {
192- config.ReverseProxy.RenewBefore = i
193- if printLogs {
194- logger.Info("Using environment variable GORDON_PROXY_RENEW_BEFORE", "value", i)
195- }
196- }
197- }
198- if val := os.Getenv("GORDON_PROXY_LETSENCRYPT_MODE"); val != "" {
199- config.ReverseProxy.LetsEncryptMode = val
200- if printLogs {
201- logger.Info("Using environment variable GORDON_PROXY_LETSENCRYPT_MODE", "value", val)
202- }
203- }
204- if val := os.Getenv("GORDON_PROXY_EMAIL"); val != "" {
205- config.ReverseProxy.Email = val
206- if printLogs {
207- logger.Info("Using environment variable GORDON_PROXY_EMAIL", "value", val)
208- }
209- }
210- if val := os.Getenv("GORDON_PROXY_GRACE_PERIOD"); val != "" {
211- i, err := strconv.Atoi(val)
212- if err == nil {
213- config.ReverseProxy.GracePeriod = i
214- }
215- logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i)
216- }
217-
218- // Handle GORDON_PROXY_ENABLE_HTTP_LOGS environment variable
219- if val := os.Getenv("GORDON_PROXY_ENABLE_HTTP_LOGS"); val != "" {
220- enableLogs, err := strconv.ParseBool(val)
221- if err == nil {
222- config.ReverseProxy.EnableHttpLogs = enableLogs
223- }
224- logger.Info("Using environment variable GORDON_PROXY_ENABLE_HTTP_LOGS", "value", config.ReverseProxy.EnableHttpLogs)
225- } else if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" {
226- // For backward compatibility
227- enableLogs, err := strconv.ParseBool(val)
228- if err == nil {
229- config.ReverseProxy.EnableHttpLogs = enableLogs
230- }
231- logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS (deprecated, use GORDON_PROXY_ENABLE_HTTP_LOGS)", "value", config.ReverseProxy.EnableHttpLogs)
232- }
233-
234- // Handle GORDON_PROXY_ENABLE_RATE_LIMIT environment variable
235- if val := os.Getenv("GORDON_PROXY_ENABLE_RATE_LIMIT"); val != "" {
236- enableRateLimit, err := strconv.ParseBool(val)
237- if err == nil {
238- config.ReverseProxy.EnableRateLimit = enableRateLimit
239- }
240- logger.Info("Using environment variable GORDON_PROXY_ENABLE_RATE_LIMIT", "value", config.ReverseProxy.EnableRateLimit)
241- } else if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" {
242- // For backward compatibility
243- disableRateLimit, err := strconv.ParseBool(val)
244- if err == nil {
245- // Invert the logic
246- config.ReverseProxy.EnableRateLimit = !disableRateLimit
247- }
248- logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT (deprecated, use GORDON_PROXY_ENABLE_RATE_LIMIT)", "value", !config.ReverseProxy.EnableRateLimit)
249- }
250-
251- // Handle GORDON_PROXY_DETECT_UPSTREAM environment variable
252- if val := os.Getenv("GORDON_PROXY_DETECT_UPSTREAM"); val != "" {
253- detectUpstream, err := strconv.ParseBool(val)
254- if err == nil {
255- config.ReverseProxy.DetectUpstreamProxy = detectUpstream
256- }
257- if printLogs {
258- logger.Info("Using environment variable GORDON_PROXY_DETECT_UPSTREAM", "value", config.ReverseProxy.DetectUpstreamProxy)
259- }
260- }
261-
262- // Handle GORDON_PROXY_SKIP_CERTIFICATES environment variable
263- if val := os.Getenv("GORDON_PROXY_SKIP_CERTIFICATES"); val != "" {
264- skipCerts, err := strconv.ParseBool(val)
265- if err == nil {
266- config.ReverseProxy.SkipCertificates = skipCerts
267- }
268- if printLogs {
269- logger.Info("Using environment variable GORDON_PROXY_SKIP_CERTIFICATES", "value", config.ReverseProxy.SkipCertificates)
270- }
271- }
272-
273- // Handle GORDON_PROXY_BLOCK_DIRECT_IP environment variable
274- if val := os.Getenv("GORDON_PROXY_BLOCK_DIRECT_IP"); val != "" {
275- blockDirectIP, err := strconv.ParseBool(val)
276- if err == nil {
277- config.ReverseProxy.BlockDirectIP = blockDirectIP
278- }
279- if printLogs {
280- logger.Info("Using environment variable GORDON_PROXY_BLOCK_DIRECT_IP", "value", config.ReverseProxy.BlockDirectIP)
281- }
282- }
283-}
D internal/common/inputs.go +0 -110
@@ -1,110 +0,0 @@
1-package common
2-
3-import (
4- "bufio"
5- "fmt"
6- "os"
7- "strings"
8-
9- "github.com/bnema/gordon/pkg/logger"
10-)
11-
12-// ReadUserInput reads a string input from the user with a prompt.
13-// It handles multi-word inputs and properly checks for errors.
14-func ReadUserInput(prompt string) string {
15- logger.Debug("ReadUserInput: Prompting user for input", "prompt", prompt)
16- // Make the prompt more visible with newlines and formatting
17- fmt.Printf("\n>>> %s: ", prompt)
18- // Flush to ensure the prompt is displayed
19- os.Stdout.Sync()
20-
21- // Use bufio.NewReader for better input handling
22- reader := bufio.NewReader(os.Stdin)
23- logger.Debug("ReadUserInput: Created reader, waiting for input...")
24-
25- // ReadString reads until newline, handling spaces in input
26- input, err := reader.ReadString('\n')
27- if err != nil {
28- logger.Error("ReadUserInput: Error reading input", "error", err)
29- return ""
30- }
31-
32- // Trim spaces and newline characters
33- result := strings.TrimSpace(input)
34- logger.Debug("ReadUserInput: Received input", "value", result)
35- return result
36-}
37-
38-// ReadUserInputWithValidation reads user input and validates it using a provided function.
39-// It keeps prompting until valid input is received.
40-func ReadUserInputWithValidation(prompt string, validate func(string) bool) string {
41- for {
42- input := ReadUserInput(prompt)
43- if validate(input) {
44- return input
45- }
46- fmt.Println("Invalid input. Please try again.")
47- }
48-}
49-
50-// ReadUserInputWithDefault reads user input with a default value if input is empty.
51-func ReadUserInputWithDefault(prompt string, defaultValue string) string {
52- // Flush to ensure the prompt is displayed
53- os.Stdout.Sync()
54-
55- reader := bufio.NewReader(os.Stdin)
56- input, err := reader.ReadString('\n')
57- if err != nil {
58- logger.Error("Error reading input", "error", err)
59- return defaultValue
60- }
61-
62- input = strings.TrimSpace(input)
63- if input == "" {
64- return defaultValue
65- }
66- return input
67-}
68-
69-// ReadConfirmation reads a yes/no confirmation from the user.
70-func ReadConfirmation(prompt string) bool {
71- input := ReadUserInputWithValidation(
72- prompt+" (y/n)",
73- func(s string) bool {
74- s = strings.ToLower(s)
75- return s == "y" || s == "n" || s == "yes" || s == "no"
76- },
77- )
78-
79- input = strings.ToLower(input)
80- return input == "y" || input == "yes"
81-}
82-
83-// ReadUserInputNonBlocking attempts to read user input but doesn't block in non-interactive environments.
84-// Instead, it returns the provided default value when not in an interactive terminal.
85-func ReadUserInputNonBlocking(prompt string, defaultValue string) string {
86- // Check if we're in an interactive terminal
87- stat, err := os.Stdin.Stat()
88- if err != nil {
89- logger.Error("ReadUserInputNonBlocking: Error checking terminal status", "error", err)
90- logger.Info("Defaulting to non-interactive mode due to error", "default", defaultValue)
91- return defaultValue
92- }
93-
94- isTerminal := (stat.Mode() & os.ModeCharDevice) != 0
95- logger.Debug("ReadUserInputNonBlocking: Terminal status check",
96- "isInteractive", isTerminal,
97- "fileMode", stat.Mode().String())
98-
99- if isTerminal {
100- // In interactive terminal, use normal prompt
101- logger.Debug("ReadUserInputNonBlocking: Using interactive prompt")
102- return ReadUserInput(prompt)
103- } else {
104- // Not in interactive terminal, log and return default
105- logger.Info("Non-interactive environment, using default for prompt",
106- "prompt", prompt,
107- "default", defaultValue)
108- return defaultValue
109- }
110-}
D internal/common/payload.go +0 -208
@@ -1,208 +0,0 @@
1-package common
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "io"
7-)
8-
9-// RequestPayload represents the structure of incoming requests
10-type RequestPayload struct {
11- Type string `json:"type"`
12- Payload Payload `json:"payload"`
13-}
14-
15-// Payload is an interface for different types of payloads
16-type Payload interface {
17- GetType() string
18-}
19-
20-// DeployResponse represents the response structure for deployment requests
21-type DeployResponse struct {
22- Success bool `json:"success"`
23- Message string `json:"message"`
24- StatusCode int `json:"status_code,omitempty"`
25- Domain string `json:"domain,omitempty"`
26- ContainerID string `json:"container_id,omitempty"`
27- ContainerName string `json:"container_name,omitempty"`
28- State string `json:"state,omitempty"`
29- RunningTime string `json:"running_time,omitempty"`
30- Ports string `json:"ports,omitempty"`
31-}
32-
33-// PushResponse represents the response structure for push requests
34-type PushResponse struct {
35- Success bool `json:"success"`
36- Message string `json:"message"`
37- CreateContainerURL string `json:"create_container_url,omitempty"`
38- ImageID string `json:"image_id,omitempty"`
39-}
40-
41-type StopResponse struct {
42- Success bool `json:"success"`
43- Message string `json:"message"`
44-}
45-
46-type RemoveResponse struct {
47- Success bool `json:"success"`
48- Message string `json:"message"`
49-}
50-
51-type StartResponse struct {
52- Success bool `json:"success"`
53- Message string `json:"message"`
54-}
55-
56-// DeployPayload represents the payload for deployment requests
57-type DeployPayload struct {
58- Port string `json:"port"`
59- TargetDomain string `json:"targetdomain"`
60- ImageName string `json:"imagename"`
61- ImageID string `json:"imageid"`
62- SkipProxySetup bool `json:"skip_proxy_setup"`
63- Data io.ReadCloser
64-}
65-
66-// PushPayload represents the payload for push requests
67-type PushPayload struct {
68- ImageName string `json:"imagename"`
69- ImageID string `json:"imageid"`
70- Data io.Reader
71-}
72-
73-type ChunkMetadata struct {
74- ChunkNumber int `json:"chunk_number"`
75- TotalChunks int `json:"total_chunks"`
76- ChunkSize int64 `json:"chunk_size"`
77- TotalSize int64 `json:"total_size"`
78- ImageName string `json:"image_name"`
79- TransferID string `json:"transfer_id"`
80-}
81-
82-type ConflictAction struct {
83- Action string `json:"action"` // "stop", "remove", or "cancel"
84- Force bool `json:"force"`
85-}
86-
87-type DeviceFlowResponse struct {
88- Code string `json:"code"`
89- Message string `json:"message"`
90-}
91-
92-// UnmarshalJSON custom unmarshaler for RequestPayload
93-func (p *RequestPayload) UnmarshalJSON(data []byte) error {
94- var raw map[string]json.RawMessage
95- err := json.Unmarshal(data, &raw)
96- if err != nil {
97- return err
98- }
99-
100- if err := json.Unmarshal(raw["type"], &p.Type); err != nil {
101- return err
102- }
103-
104- // Unmarshal the payload based on the type
105- switch p.Type {
106- case "ping":
107- var payload PingPayload
108- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
109- return err
110- }
111- p.Payload = payload
112- case "deploy":
113- var payload DeployPayload
114- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
115- return err
116- }
117- p.Payload = payload
118- case "push":
119- var payload PushPayload
120- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
121- return err
122- }
123- p.Payload = payload
124- case "stop":
125- var payload StopPayload
126- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
127- return err
128- }
129- p.Payload = payload
130- case "start":
131- var payload StartPayload
132- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
133- return err
134- }
135- p.Payload = payload
136- case "remove":
137- var payload RemovePayload
138- if err := json.Unmarshal(raw["payload"], &payload); err != nil {
139- return err
140- }
141- p.Payload = payload
142- default:
143- return fmt.Errorf("invalid type: %s", p.Type)
144- }
145-
146- return nil
147-}
148-
149-// PingPayload represents the payload for ping requests
150-type PingPayload struct {
151- Message string `json:"message"`
152-}
153-
154-// GetType returns the type of the PingPayload
155-func (p PingPayload) GetType() string {
156- return "ping"
157-}
158-
159-// GetType returns the type of the DeployPayload
160-func (p DeployPayload) GetType() string {
161- return "deploy"
162-}
163-
164-// GetType returns the type of the PushPayload
165-func (p PushPayload) GetType() string {
166- return "push"
167-}
168-
169-// NewPingPayload creates a new PingPayload from a map of data
170-func NewPingPayload(data map[string]interface{}) (PingPayload, error) {
171- message, ok := data["message"].(string)
172- if !ok {
173- return PingPayload{}, fmt.Errorf("invalid data for message")
174- }
175- return PingPayload{Message: message}, nil
176-}
177-
178-// StopPayload represents the payload for stop requests
179-type StopPayload struct {
180- ContainerID string `json:"container_id"`
181- ContainerName string `json:"container_name"`
182-}
183-
184-// GetType returns the type of the StopPayload
185-func (p StopPayload) GetType() string {
186- return "stop"
187-}
188-
189-// StartPayload represents the payload for start requests
190-type StartPayload struct {
191- ContainerID string `json:"container_id"`
192- ContainerName string `json:"container_name"`
193-}
194-
195-// GetType returns the type of the StartPayload
196-func (p StartPayload) GetType() string {
197- return "start"
198-}
199-
200-// RemovePayload represents the payload for remove requests
201-type RemovePayload struct {
202- ContainerID string `json:"container_id"`
203-}
204-
205-// GetType returns the type of the RemovePayload
206-func (p RemovePayload) GetType() string {
207- return "remove"
208-}
D internal/common/version.go +0 -189
@@ -1,189 +0,0 @@
1-// internal/common/version.go
2-
3-package common
4-
5-import (
6- "encoding/json"
7- "fmt"
8- "io"
9- "net/http"
10- "runtime/debug"
11- "strings"
12- "time"
13-
14- "github.com/Masterminds/semver/v3"
15- "github.com/bnema/gordon/pkg/logger"
16-)
17-
18-// Info represents version information
19-type Info struct {
20- Version string
21- Commit string
22- Date string
23- ProxyURL string
24-}
25-
26-type ProxyVersion struct {
27- Version string `json:"version"`
28-}
29-
30-// GetVersionInfo returns the current version information based on BuildConfig
31-func GetVersionInfo(buildVersion, buildCommit, buildDate, proxyURL string) Info {
32- // If version was set in BuildConfig, use that
33- if buildVersion != "" && buildVersion != "dev" {
34- return Info{
35- Version: buildVersion,
36- Commit: buildCommit,
37- Date: buildDate,
38- ProxyURL: proxyURL,
39- }
40- }
41-
42- // Try to get version from runtime build info (for go install)
43- if buildInfo, ok := debug.ReadBuildInfo(); ok {
44- return Info{
45- Version: buildInfo.Main.Version,
46- Commit: "unknown",
47- Date: "unknown",
48- ProxyURL: proxyURL,
49- }
50- }
51-
52- // Fallback for development builds
53- return Info{
54- Version: "dev",
55- Commit: "unknown",
56- Date: "unknown",
57- ProxyURL: proxyURL,
58- }
59-}
60-
61-// CheckForNewVersion checks if a newer version is available via proxy
62-func CheckForNewVersion(currentVersion, proxyURL string) (bool, string, error) {
63- // Don't check for new versions in development builds or dirty builds
64- if currentVersion == "dev" || strings.Contains(currentVersion, "-dirty") {
65- return false, "", nil
66- }
67-
68- // If currentVersion is empty, treat it as v0.0.0
69- if currentVersion == "" {
70- currentVersion = "v0.0.0"
71- }
72-
73- client := &http.Client{
74- Timeout: 10 * time.Second,
75- }
76-
77- resp, err := client.Get(proxyURL + "/version")
78- if err != nil {
79- return false, "", fmt.Errorf("error making request: %w", err)
80- }
81- defer resp.Body.Close()
82-
83- if resp.StatusCode != http.StatusOK {
84- return false, "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
85- }
86-
87- body, err := io.ReadAll(resp.Body)
88- if err != nil {
89- return false, "", fmt.Errorf("error reading response: %w", err)
90- }
91-
92- var proxyVer ProxyVersion
93- if err := json.Unmarshal(body, &proxyVer); err != nil {
94- return false, "", fmt.Errorf("error parsing JSON: %w", err)
95- }
96-
97- // Ensure versions start with 'v'
98- if !strings.HasPrefix(currentVersion, "v") {
99- currentVersion = "v" + currentVersion
100- }
101- if !strings.HasPrefix(proxyVer.Version, "v") {
102- proxyVer.Version = "v" + proxyVer.Version
103- }
104-
105- // Clean up version strings
106- latestVersion := strings.TrimPrefix(proxyVer.Version, "v")
107- currentBase := extractBaseVersion(currentVersion)
108-
109- // Handle development versions
110- if currentBase == "" || currentBase == "dev" {
111- currentBase = "0.0.0"
112- }
113-
114- // Parse versions using semver
115- current, err := semver.NewVersion(currentBase)
116- if err != nil {
117- return false, "", fmt.Errorf("error parsing current version (%s): %w", currentBase, err)
118- }
119-
120- latest, err := semver.NewVersion(latestVersion)
121- if err != nil {
122- return false, "", fmt.Errorf("error parsing latest version (%s): %w", latestVersion, err)
123- }
124-
125- if latest.GreaterThan(current) {
126- return true, proxyVer.Version, nil
127- }
128-
129- return false, "", nil
130-}
131-
132-// CheckVersionPeriodically checks for new versions periodically
133-func CheckVersionPeriodically(info Info, checkInterval time.Duration) {
134- ticker := time.NewTicker(checkInterval)
135- defer ticker.Stop()
136-
137- // Initial check
138- checkVersion(info)
139-
140- // Periodic checks
141- for range ticker.C {
142- checkVersion(info)
143- }
144-}
145-
146-func checkVersion(info Info) {
147- hasUpdate, latestVersion, err := CheckForNewVersion(info.Version, info.ProxyURL)
148- if err != nil {
149- logger.Error("Failed to check for updates", "error", err)
150- return
151- }
152-
153- if hasUpdate {
154- logger.Info("New version available!",
155- "current", info.Version,
156- "latest", latestVersion,
157- "update_url", "https://github.com/bnema/gordon/releases/latest")
158- }
159-}
160-
161-func extractBaseVersion(version string) string {
162- // Remove 'v' prefix if present
163- version = strings.TrimPrefix(version, "v")
164-
165- // Handle empty or invalid versions
166- if version == "" || version == "dev" {
167- return "0.0.0"
168- }
169-
170- // Split on first hyphen to remove dirty/commit info
171- parts := strings.SplitN(version, "-", 2)
172- return parts[0]
173-}
174-
175-// String returns a formatted version string
176-func (i Info) String() string {
177- if i.Version == "dev" {
178- return "Gordon version dev (development build)"
179- }
180-
181- // Check if it's a dirty version
182- if strings.Contains(i.Version, "-dirty") {
183- return fmt.Sprintf("Gordon version %s (commit: %s, built at: %s)",
184- i.Version, i.Commit, i.Date)
185- }
186-
187- return fmt.Sprintf("Gordon version %s (commit: %s, built at: %s)",
188- i.Version, i.Commit, i.Date)
189-}
A internal/config/config.go +132 -0
@@ -0,0 +1,132 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/spf13/viper"
8+)
9+
10+type Config struct {
11+ Server ServerConfig `mapstructure:"server"`
12+ Auth AuthConfig `mapstructure:"auth"`
13+ Routes map[string]string `mapstructure:"routes"`
14+}
15+
16+type ServerConfig struct {
17+ Port int `mapstructure:"port"`
18+ RegistryPort int `mapstructure:"registry_port"`
19+ Runtime string `mapstructure:"runtime"`
20+ SSLEmail string `mapstructure:"ssl_email"`
21+ DataDir string `mapstructure:"data_dir"`
22+}
23+
24+type AuthConfig struct {
25+ Enabled bool `mapstructure:"enabled"`
26+ Method string `mapstructure:"method"` // "jwt", "api_key", or "basic"
27+ JWTSecret string `mapstructure:"jwt_secret"`
28+ APIKey string `mapstructure:"api_key"`
29+ Username string `mapstructure:"username"`
30+ Password string `mapstructure:"password"`
31+ RegistryAuth bool `mapstructure:"registry_auth"`
32+ AllowedIPs []string `mapstructure:"allowed_ips"`
33+}
34+
35+type Route struct {
36+ Domain string
37+ Image string
38+ HTTPS bool
39+}
40+
41+func Load() (*Config, error) {
42+ var cfg Config
43+
44+ // Set defaults
45+ viper.SetDefault("server.port", 8080)
46+ viper.SetDefault("server.registry_port", 5000)
47+ viper.SetDefault("server.runtime", "docker")
48+ viper.SetDefault("server.data_dir", "./data")
49+ viper.SetDefault("auth.enabled", false)
50+ viper.SetDefault("auth.method", "basic")
51+ viper.SetDefault("auth.registry_auth", false)
52+ viper.SetDefault("auth.allowed_ips", []string{"127.0.0.1", "::1"})
53+
54+ // Handle the routes manually since Viper struggles with domain names
55+ cfg.Routes = make(map[string]string)
56+
57+ // Get server config first
58+ if err := viper.UnmarshalKey("server", &cfg.Server); err != nil {
59+ return nil, fmt.Errorf("unable to decode server config: %v", err)
60+ }
61+
62+ // Get auth config
63+ if err := viper.UnmarshalKey("auth", &cfg.Auth); err != nil {
64+ return nil, fmt.Errorf("unable to decode auth config: %v", err)
65+ }
66+
67+ // Get routes manually from the raw config
68+ routesRaw := viper.Get("routes")
69+ if routesRaw != nil {
70+ if routes, ok := routesRaw.(map[string]interface{}); ok {
71+ for domain, image := range routes {
72+ if imageStr, ok := image.(string); ok {
73+ cfg.Routes[domain] = imageStr
74+ }
75+ }
76+ }
77+ }
78+
79+ // Validate required fields
80+ if cfg.Server.SSLEmail == "" {
81+ return nil, fmt.Errorf("server.ssl_email is required")
82+ }
83+
84+ if cfg.Server.Runtime != "docker" && cfg.Server.Runtime != "podman" {
85+ return nil, fmt.Errorf("server.runtime must be 'docker' or 'podman'")
86+ }
87+
88+ // Validate auth config
89+ if cfg.Auth.Enabled {
90+ switch cfg.Auth.Method {
91+ case "jwt":
92+ if cfg.Auth.JWTSecret == "" {
93+ return nil, fmt.Errorf("JWT method selected but no jwt_secret provided")
94+ }
95+ case "api_key":
96+ if cfg.Auth.APIKey == "" {
97+ return nil, fmt.Errorf("API key method selected but no api_key provided")
98+ }
99+ case "basic":
100+ if cfg.Auth.Username == "" || cfg.Auth.Password == "" {
101+ return nil, fmt.Errorf("Basic auth method selected but username/password not provided")
102+ }
103+ default:
104+ return nil, fmt.Errorf("invalid auth method: %s (must be 'jwt', 'api_key', or 'basic')", cfg.Auth.Method)
105+ }
106+ }
107+
108+ return &cfg, nil
109+}
110+
111+func (c *Config) GetRoutes() []Route {
112+ var routes []Route
113+
114+ for domain, image := range c.Routes {
115+ route := Route{
116+ Image: image,
117+ HTTPS: true, // Default to HTTPS
118+ }
119+
120+ // Check if domain has http:// prefix
121+ if strings.HasPrefix(domain, "http://") {
122+ route.Domain = strings.TrimPrefix(domain, "http://")
123+ route.HTTPS = false
124+ } else {
125+ route.Domain = domain
126+ }
127+
128+ routes = append(routes, route)
129+ }
130+
131+ return routes
132+}⏎̸
A internal/container/docker.go +313 -0
@@ -0,0 +1,313 @@
1+package container
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/docker/docker/api/types/container"
11+ "github.com/docker/docker/api/types/image"
12+ "github.com/docker/docker/client"
13+ "github.com/docker/go-connections/nat"
14+ "github.com/rs/zerolog/log"
15+
16+ "gordon/pkg/runtime"
17+)
18+
19+// DockerRuntime implements the Runtime interface using Docker API
20+type DockerRuntime struct {
21+ client *client.Client
22+}
23+
24+// NewDockerRuntime creates a new Docker runtime instance
25+func NewDockerRuntime() (*DockerRuntime, error) {
26+ cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
27+ if err != nil {
28+ return nil, fmt.Errorf("failed to create Docker client: %w", err)
29+ }
30+
31+ return &DockerRuntime{
32+ client: cli,
33+ }, nil
34+}
35+
36+// CreateContainer creates a new container
37+func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) {
38+ // Convert ports to Docker format
39+ exposedPorts := make(nat.PortSet)
40+ portBindings := make(nat.PortMap)
41+
42+ for _, port := range config.Ports {
43+ containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
44+ exposedPorts[containerPort] = struct{}{}
45+
46+ // Bind to random available port on host
47+ portBindings[containerPort] = []nat.PortBinding{
48+ {
49+ HostIP: "0.0.0.0",
50+ HostPort: "0", // Docker will assign a random available port
51+ },
52+ }
53+ }
54+
55+ // Create container configuration
56+ containerConfig := &container.Config{
57+ Image: config.Image,
58+ Env: config.Env,
59+ ExposedPorts: exposedPorts,
60+ WorkingDir: config.WorkingDir,
61+ Cmd: config.Cmd,
62+ Labels: config.Labels,
63+ }
64+
65+ hostConfig := &container.HostConfig{
66+ PortBindings: portBindings,
67+ AutoRemove: config.AutoRemove,
68+ }
69+
70+ // Create the container
71+ resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, config.Name)
72+ if err != nil {
73+ return nil, fmt.Errorf("failed to create container: %w", err)
74+ }
75+
76+ log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Container created")
77+
78+ // Inspect the created container to get full details
79+ return d.InspectContainer(ctx, resp.ID)
80+}
81+
82+// StartContainer starts a container
83+func (d *DockerRuntime) StartContainer(ctx context.Context, containerID string) error {
84+ err := d.client.ContainerStart(ctx, containerID, container.StartOptions{})
85+ if err != nil {
86+ return fmt.Errorf("failed to start container %s: %w", containerID, err)
87+ }
88+
89+ log.Info().Str("id", containerID).Msg("Container started")
90+ return nil
91+}
92+
93+// StopContainer stops a container
94+func (d *DockerRuntime) StopContainer(ctx context.Context, containerID string) error {
95+ timeout := 30 // 30 seconds timeout
96+ err := d.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
97+ if err != nil {
98+ return fmt.Errorf("failed to stop container %s: %w", containerID, err)
99+ }
100+
101+ log.Info().Str("id", containerID).Msg("Container stopped")
102+ return nil
103+}
104+
105+// RestartContainer restarts a container
106+func (d *DockerRuntime) RestartContainer(ctx context.Context, containerID string) error {
107+ timeout := 30 // 30 seconds timeout
108+ err := d.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
109+ if err != nil {
110+ return fmt.Errorf("failed to restart container %s: %w", containerID, err)
111+ }
112+
113+ log.Info().Str("id", containerID).Msg("Container restarted")
114+ return nil
115+}
116+
117+// RemoveContainer removes a container
118+func (d *DockerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
119+ err := d.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
120+ if err != nil {
121+ return fmt.Errorf("failed to remove container %s: %w", containerID, err)
122+ }
123+
124+ log.Info().Str("id", containerID).Bool("force", force).Msg("Container removed")
125+ return nil
126+}
127+
128+// ListContainers lists containers
129+func (d *DockerRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) {
130+ containers, err := d.client.ContainerList(ctx, container.ListOptions{All: all})
131+ if err != nil {
132+ return nil, fmt.Errorf("failed to list containers: %w", err)
133+ }
134+
135+ var result []*runtime.Container
136+ for _, c := range containers {
137+ // Extract ports
138+ var ports []int
139+ for _, port := range c.Ports {
140+ if port.PublicPort > 0 {
141+ ports = append(ports, int(port.PublicPort))
142+ }
143+ }
144+
145+ // Get the primary name (remove leading slash)
146+ name := ""
147+ if len(c.Names) > 0 {
148+ name = strings.TrimPrefix(c.Names[0], "/")
149+ }
150+
151+ result = append(result, &runtime.Container{
152+ ID: c.ID,
153+ Image: c.Image,
154+ Name: name,
155+ Status: c.Status,
156+ Ports: ports,
157+ Labels: c.Labels,
158+ })
159+ }
160+
161+ return result, nil
162+}
163+
164+// InspectContainer inspects a container
165+func (d *DockerRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) {
166+ resp, err := d.client.ContainerInspect(ctx, containerID)
167+ if err != nil {
168+ return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
169+ }
170+
171+ // Extract published ports
172+ var ports []int
173+ if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
174+ for _, bindings := range resp.NetworkSettings.Ports {
175+ for _, binding := range bindings {
176+ if binding.HostPort != "" {
177+ if port, err := strconv.Atoi(binding.HostPort); err == nil {
178+ ports = append(ports, port)
179+ }
180+ }
181+ }
182+ }
183+ }
184+
185+ // Get container name (remove leading slash)
186+ name := strings.TrimPrefix(resp.Name, "/")
187+
188+ return &runtime.Container{
189+ ID: resp.ID,
190+ Image: resp.Config.Image,
191+ Name: name,
192+ Status: resp.State.Status,
193+ Ports: ports,
194+ Labels: resp.Config.Labels,
195+ }, nil
196+}
197+
198+// GetContainerLogs gets container logs
199+func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
200+ logs, err := d.client.ContainerLogs(ctx, containerID, container.LogsOptions{
201+ ShowStdout: true,
202+ ShowStderr: true,
203+ Follow: follow,
204+ Timestamps: true,
205+ })
206+ if err != nil {
207+ return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err)
208+ }
209+
210+ return logs, nil
211+}
212+
213+// PullImage pulls an image
214+func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error {
215+ log.Info().Str("image", imageRef).Msg("Pulling image")
216+
217+ reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{})
218+ if err != nil {
219+ return fmt.Errorf("failed to pull image %s: %w", imageRef, err)
220+ }
221+ defer reader.Close()
222+
223+ // Read the response to completion (this is required for the pull to complete)
224+ _, err = io.Copy(io.Discard, reader)
225+ if err != nil {
226+ return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
227+ }
228+
229+ log.Info().Str("image", imageRef).Msg("Image pulled successfully")
230+ return nil
231+}
232+
233+// RemoveImage removes an image
234+func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
235+ _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
236+ if err != nil {
237+ return fmt.Errorf("failed to remove image %s: %w", imageRef, err)
238+ }
239+
240+ log.Info().Str("image", imageRef).Bool("force", force).Msg("Image removed")
241+ return nil
242+}
243+
244+// ListImages lists images
245+func (d *DockerRuntime) ListImages(ctx context.Context) ([]string, error) {
246+ images, err := d.client.ImageList(ctx, image.ListOptions{})
247+ if err != nil {
248+ return nil, fmt.Errorf("failed to list images: %w", err)
249+ }
250+
251+ var result []string
252+ for _, img := range images {
253+ for _, tag := range img.RepoTags {
254+ if tag != "<none>:<none>" {
255+ result = append(result, tag)
256+ }
257+ }
258+ }
259+
260+ return result, nil
261+}
262+
263+// Ping checks if Docker is responsive
264+func (d *DockerRuntime) Ping(ctx context.Context) error {
265+ _, err := d.client.Ping(ctx)
266+ if err != nil {
267+ return fmt.Errorf("Docker ping failed: %w", err)
268+ }
269+ return nil
270+}
271+
272+// Version returns Docker version
273+func (d *DockerRuntime) Version(ctx context.Context) (string, error) {
274+ version, err := d.client.ServerVersion(ctx)
275+ if err != nil {
276+ return "", fmt.Errorf("failed to get Docker version: %w", err)
277+ }
278+ return version.Version, nil
279+}
280+
281+// IsContainerRunning checks if a container is running
282+func (d *DockerRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
283+ container, err := d.InspectContainer(ctx, containerID)
284+ if err != nil {
285+ return false, err
286+ }
287+ return container.Status == "running", nil
288+}
289+
290+// GetContainerPort gets the host port for a container's internal port
291+func (d *DockerRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
292+ resp, err := d.client.ContainerInspect(ctx, containerID)
293+ if err != nil {
294+ return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
295+ }
296+
297+ if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
298+ return 0, fmt.Errorf("no port mappings found for container %s", containerID)
299+ }
300+
301+ containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
302+ bindings, exists := resp.NetworkSettings.Ports[containerPort]
303+ if !exists || len(bindings) == 0 {
304+ return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
305+ }
306+
307+ hostPort, err := strconv.Atoi(bindings[0].HostPort)
308+ if err != nil {
309+ return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
310+ }
311+
312+ return hostPort, nil
313+}⏎̸
A internal/container/manager.go +254 -0
@@ -0,0 +1,254 @@
1+package container
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+
8+ "github.com/rs/zerolog/log"
9+
10+ "gordon/internal/config"
11+ "gordon/pkg/runtime"
12+)
13+
14+// Manager handles container lifecycle and management
15+type Manager struct {
16+ runtime runtime.Runtime
17+ config *config.Config
18+ containers map[string]*runtime.Container // map[domain] -> container
19+ mu sync.RWMutex
20+}
21+
22+// NewManager creates a new container manager
23+func NewManager(cfg *config.Config) (*Manager, error) {
24+ var rt runtime.Runtime
25+ var err error
26+
27+ switch cfg.Server.Runtime {
28+ case "docker":
29+ rt, err = NewDockerRuntime()
30+ if err != nil {
31+ return nil, fmt.Errorf("failed to create Docker runtime: %w", err)
32+ }
33+ case "podman":
34+ // TODO: Implement Podman runtime
35+ return nil, fmt.Errorf("Podman runtime not yet implemented")
36+ default:
37+ return nil, fmt.Errorf("unsupported runtime: %s", cfg.Server.Runtime)
38+ }
39+
40+ // Test runtime connectivity
41+ ctx := context.Background()
42+ if err := rt.Ping(ctx); err != nil {
43+ return nil, fmt.Errorf("runtime not available: %w", err)
44+ }
45+
46+ version, err := rt.Version(ctx)
47+ if err != nil {
48+ log.Warn().Err(err).Msg("Could not get runtime version")
49+ } else {
50+ log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected")
51+ }
52+
53+ return &Manager{
54+ runtime: rt,
55+ config: cfg,
56+ containers: make(map[string]*runtime.Container),
57+ }, nil
58+}
59+
60+// DeployContainer deploys a container for a specific route
61+func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error) {
62+ m.mu.Lock()
63+ defer m.mu.Unlock()
64+
65+ // Check if container already exists for this domain
66+ if existing, exists := m.containers[route.Domain]; exists {
67+ log.Info().Str("domain", route.Domain).Str("container", existing.ID).Msg("Container already exists, restarting")
68+
69+ // Stop and remove existing container
70+ if err := m.runtime.StopContainer(ctx, existing.ID); err != nil {
71+ log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to stop existing container")
72+ }
73+
74+ if err := m.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
75+ log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to remove existing container")
76+ }
77+
78+ delete(m.containers, route.Domain)
79+ }
80+
81+ // Pull the image first
82+ log.Info().Str("image", route.Image).Msg("Pulling image")
83+ if err := m.runtime.PullImage(ctx, route.Image); err != nil {
84+ return nil, fmt.Errorf("failed to pull image %s: %w", route.Image, err)
85+ }
86+
87+ // Create container configuration
88+ containerConfig := &runtime.ContainerConfig{
89+ Image: route.Image,
90+ Name: fmt.Sprintf("gordon-%s", route.Domain),
91+ Ports: []int{80, 8080, 3000}, // Common web server ports
92+ Labels: map[string]string{
93+ "gordon.domain": route.Domain,
94+ "gordon.image": route.Image,
95+ "gordon.managed": "true",
96+ },
97+ AutoRemove: false, // Keep containers for inspection
98+ }
99+
100+ // Create the container
101+ container, err := m.runtime.CreateContainer(ctx, containerConfig)
102+ if err != nil {
103+ return nil, fmt.Errorf("failed to create container for %s: %w", route.Domain, err)
104+ }
105+
106+ // Start the container
107+ if err := m.runtime.StartContainer(ctx, container.ID); err != nil {
108+ // Clean up the created container
109+ m.runtime.RemoveContainer(ctx, container.ID, true)
110+ return nil, fmt.Errorf("failed to start container for %s: %w", route.Domain, err)
111+ }
112+
113+ // Re-inspect to get updated port information
114+ container, err = m.runtime.InspectContainer(ctx, container.ID)
115+ if err != nil {
116+ return nil, fmt.Errorf("failed to inspect started container: %w", err)
117+ }
118+
119+ // Store the container mapping
120+ m.containers[route.Domain] = container
121+
122+ log.Info().
123+ Str("domain", route.Domain).
124+ Str("image", route.Image).
125+ Str("container", container.ID).
126+ Ints("ports", container.Ports).
127+ Msg("Container deployed successfully")
128+
129+ return container, nil
130+}
131+
132+// GetContainer returns the container for a domain
133+func (m *Manager) GetContainer(domain string) (*runtime.Container, bool) {
134+ m.mu.RLock()
135+ defer m.mu.RUnlock()
136+
137+ container, exists := m.containers[domain]
138+ return container, exists
139+}
140+
141+// GetContainerPort returns the host port for a domain's container
142+func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error) {
143+ m.mu.RLock()
144+ container, exists := m.containers[domain]
145+ m.mu.RUnlock()
146+
147+ if !exists {
148+ return 0, fmt.Errorf("no container found for domain %s", domain)
149+ }
150+
151+ return m.runtime.GetContainerPort(ctx, container.ID, internalPort)
152+}
153+
154+// ListContainers returns all managed containers
155+func (m *Manager) ListContainers() map[string]*runtime.Container {
156+ m.mu.RLock()
157+ defer m.mu.RUnlock()
158+
159+ // Return a copy of the map to avoid race conditions
160+ result := make(map[string]*runtime.Container)
161+ for domain, container := range m.containers {
162+ result[domain] = container
163+ }
164+ return result
165+}
166+
167+// StopContainer stops a container for a domain
168+func (m *Manager) StopContainer(ctx context.Context, domain string) error {
169+ m.mu.Lock()
170+ defer m.mu.Unlock()
171+
172+ container, exists := m.containers[domain]
173+ if !exists {
174+ return fmt.Errorf("no container found for domain %s", domain)
175+ }
176+
177+ if err := m.runtime.StopContainer(ctx, container.ID); err != nil {
178+ return fmt.Errorf("failed to stop container for %s: %w", domain, err)
179+ }
180+
181+ log.Info().Str("domain", domain).Str("container", container.ID).Msg("Container stopped")
182+ return nil
183+}
184+
185+// RemoveContainer removes a container for a domain
186+func (m *Manager) RemoveContainer(ctx context.Context, domain string, force bool) error {
187+ m.mu.Lock()
188+ defer m.mu.Unlock()
189+
190+ container, exists := m.containers[domain]
191+ if !exists {
192+ return fmt.Errorf("no container found for domain %s", domain)
193+ }
194+
195+ if err := m.runtime.RemoveContainer(ctx, container.ID, force); err != nil {
196+ return fmt.Errorf("failed to remove container for %s: %w", domain, err)
197+ }
198+
199+ delete(m.containers, domain)
200+ log.Info().Str("domain", domain).Str("container", container.ID).Msg("Container removed")
201+ return nil
202+}
203+
204+// SyncContainers synchronizes the internal state with the actual running containers
205+func (m *Manager) SyncContainers(ctx context.Context) error {
206+ m.mu.Lock()
207+ defer m.mu.Unlock()
208+
209+ // Get all containers from the runtime
210+ allContainers, err := m.runtime.ListContainers(ctx, false) // only running containers
211+ if err != nil {
212+ return fmt.Errorf("failed to list containers: %w", err)
213+ }
214+
215+ // Find containers managed by Gordon
216+ managedContainers := make(map[string]*runtime.Container)
217+ for _, container := range allContainers {
218+ if container.Labels != nil {
219+ if domain, exists := container.Labels["gordon.domain"]; exists && container.Labels["gordon.managed"] == "true" {
220+ managedContainers[domain] = container
221+ }
222+ }
223+ }
224+
225+ // Update our internal state
226+ m.containers = managedContainers
227+
228+ log.Info().Int("count", len(managedContainers)).Msg("Container state synchronized")
229+ return nil
230+}
231+
232+// HealthCheck checks the health of all managed containers
233+func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {
234+ m.mu.RLock()
235+ defer m.mu.RUnlock()
236+
237+ health := make(map[string]bool)
238+ for domain, container := range m.containers {
239+ running, err := m.runtime.IsContainerRunning(ctx, container.ID)
240+ if err != nil {
241+ log.Warn().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to check container health")
242+ health[domain] = false
243+ } else {
244+ health[domain] = running
245+ }
246+ }
247+
248+ return health
249+}
250+
251+// Runtime returns the underlying runtime interface
252+func (m *Manager) Runtime() runtime.Runtime {
253+ return m.runtime
254+}⏎̸
D internal/db/db_struct.go +0 -105
@@ -1,105 +0,0 @@
1-package db
2-
3-import (
4- "database/sql"
5-)
6-
7-// User struct model
8-type User struct {
9- ID string `sql:"id, primary_key"`
10- Name string `sql:"name"`
11- Email string `sql:"email"`
12- Account Account
13-}
14-
15-type Account struct {
16- ID string `sql:"id, primary_key"`
17- UserID string `sql:"user_id, foreign_key=user.id"`
18- Sessions []Sessions
19- Providers []Provider
20- Clients []Clients
21-}
22-
23-type Sessions struct {
24- ID string `sql:"id, primary_key"`
25- AccountID string `sql:"account_id, foreign_key=account.id"`
26- BrowserInfo string `sql:"browser_info"`
27- AccessToken string `sql:"access_token"`
28- Expires string `sql:"expires"`
29- IsActive bool `sql:"is_active"`
30-}
31-
32-type Provider struct {
33- ID string `sql:"id, primary_key"`
34- AccountID string `sql:"account_id, foreign_key=account.id"`
35- Name string `sql:"name"`
36- Login string `sql:"login"`
37- AvatarURL string `sql:"avatar_url"`
38- ProfileURL string `sql:"profile_url"`
39- Email string `sql:"email"`
40-}
41-
42-type Clients struct {
43- ID string `sql:"id, primary_key"`
44- AccountID string `sql:"account_id, foreign_key=account.id"`
45- OS string `sql:"os"`
46- IP string `sql:"ip"`
47- Hostname string `sql:"hostname"`
48- Expires string `sql:"expires"`
49-}
50-
51-// Domain represents a domain configuration for the proxy
52-type Domain struct {
53- ID string `sql:"id, primary_key"`
54- Name string `sql:"name"` // e.g. example.com
55- AccountID string `sql:"account_id, foreign_key=account.id"`
56- CreatedAt string `sql:"created_at"`
57- UpdatedAt string `sql:"updated_at"`
58- AcmeEnabled bool `sql:"acme_enabled"` // Whether ACME is enabled for this domain
59- AcmeChallengeType sql.NullString `sql:"acme_challenge_type"` // e.g. "http-01", "dns-01"
60- AcmeDnsProvider sql.NullString `sql:"acme_dns_provider"` // DNS provider name if using DNS-01
61- AcmeDnsCredentialsRef sql.NullString `sql:"acme_dns_credentials_ref"` // Reference to credentials (not raw credentials)
62-}
63-
64-// Certificate represents a TLS certificate for a domain
65-type Certificate struct {
66- ID string `sql:"id, primary_key"`
67- DomainID string `sql:"domain_id, foreign_key=domain.id"`
68- CertFile string `sql:"cert_file"` // Path to the certificate file
69- KeyFile string `sql:"key_file"` // Path to the private key file
70- IssuedAt string `sql:"issued_at"`
71- ExpiresAt string `sql:"expires_at"`
72- Issuer string `sql:"issuer"` // e.g. "Let's Encrypt"
73- Status string `sql:"status"` // e.g. valid, expired, revoked
74-}
75-
76-// ProxyRoute represents a mapping from a domain to a container
77-type ProxyRoute struct {
78- ID string `sql:"id, primary_key"`
79- DomainID string `sql:"domain_id, foreign_key=domain.id"`
80- ContainerID string `sql:"container_id"`
81- ContainerIP string `sql:"container_ip"`
82- ContainerPort string `sql:"container_port"`
83- Protocol string `sql:"protocol"` // http or https
84- Path string `sql:"path"` // path prefix to route (e.g., /api)
85- Active bool `sql:"active"`
86- CreatedAt string `sql:"created_at"`
87- UpdatedAt string `sql:"updated_at"`
88-}
89-
90-// AcmeAccount represents the stored ACME account details
91-type AcmeAccount struct {
92- Email string `sql:"email, primary_key"`
93- PrivateKey string `sql:"private_key"` // PEM encoded private key
94- RegistrationInfo sql.NullString `sql:"registration_info"` // JSON marshaled registration resource
95- CreatedAt string `sql:"created_at"`
96- UpdatedAt string `sql:"updated_at"`
97-}
98-
99-// GithubUserInfo holds the essential information for a Github user
100-type GithubUserInfo struct {
101- Login string `json:"login"`
102- AvatarURL string `json:"avatar_url"`
103- ProfileURL string `json:"html_url"`
104- Emails []string
105-}
D internal/db/helpers.go +0 -136
@@ -1,136 +0,0 @@
1-package db
2-
3-import (
4- "database/sql"
5- "fmt"
6- "time"
7-
8- "github.com/bnema/gordon/pkg/logger"
9-)
10-
11-// ExecWithRetry performs a database execution with retry logic for handling locked database
12-func ExecWithRetry(db *sql.DB, query string, args ...interface{}) (sql.Result, error) {
13- var result sql.Result
14- var err error
15- maxRetries := 10
16- retryDelay := 200 * time.Millisecond
17-
18- for attempt := 1; attempt <= maxRetries; attempt++ {
19- result, err = db.Exec(query, args...)
20- if err == nil {
21- return result, nil
22- }
23-
24- // Check if this is a database locked error
25- if err.Error() == "database is locked (5) (SQLITE_BUSY)" {
26- logger.Debug("Database locked, retrying operation",
27- "attempt", attempt,
28- "max_retries", maxRetries,
29- "query", query)
30- time.Sleep(retryDelay)
31- // Increase delay for next retry (exponential backoff)
32- retryDelay *= 2
33- continue
34- }
35-
36- // If it's not a locking error, return immediately
37- return nil, err
38- }
39-
40- return nil, fmt.Errorf("max retries exceeded: %w", err)
41-}
42-
43-// QueryWithRetry performs a database query with retry logic for handling locked database
44-func QueryWithRetry(db *sql.DB, query string, args ...interface{}) (*sql.Rows, error) {
45- var rows *sql.Rows
46- var err error
47- maxRetries := 10
48- retryDelay := 200 * time.Millisecond
49-
50- for attempt := 1; attempt <= maxRetries; attempt++ {
51- rows, err = db.Query(query, args...)
52- if err == nil {
53- return rows, nil
54- }
55-
56- // Check if this is a database locked error
57- if err.Error() == "database is locked (5) (SQLITE_BUSY)" {
58- logger.Debug("Database locked, retrying operation",
59- "attempt", attempt,
60- "max_retries", maxRetries,
61- "query", query)
62- time.Sleep(retryDelay)
63- // Increase delay for next retry (exponential backoff)
64- retryDelay *= 2
65- continue
66- }
67-
68- // If it's not a locking error, return immediately
69- return nil, err
70- }
71-
72- return nil, fmt.Errorf("max retries exceeded: %w", err)
73-}
74-
75-// BeginWithRetry starts a transaction with retry logic for handling locked database
76-func BeginWithRetry(db *sql.DB) (*sql.Tx, error) {
77- var tx *sql.Tx
78- var err error
79- maxRetries := 10
80- retryDelay := 200 * time.Millisecond
81-
82- for attempt := 1; attempt <= maxRetries; attempt++ {
83- tx, err = db.Begin()
84- if err == nil {
85- return tx, nil
86- }
87-
88- // Check if this is a database locked error
89- if err.Error() == "database is locked (5) (SQLITE_BUSY)" {
90- logger.Debug("Database locked, retrying transaction start",
91- "attempt", attempt,
92- "max_retries", maxRetries)
93- time.Sleep(retryDelay)
94- // Increase delay for next retry (exponential backoff)
95- retryDelay *= 2
96- continue
97- }
98-
99- // If it's not a locking error, return immediately
100- return nil, err
101- }
102-
103- return nil, fmt.Errorf("max retries exceeded: %w", err)
104-}
105-
106-// TxExecWithRetry executes a query within a transaction with retry logic
107-func TxExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) {
108- var result sql.Result
109- var err error
110- maxRetries := 10
111- retryDelay := 200 * time.Millisecond
112-
113- for attempt := 1; attempt <= maxRetries; attempt++ {
114- result, err = tx.Exec(query, args...)
115- if err == nil {
116- return result, nil
117- }
118-
119- // Check if this is a database locked error
120- if err.Error() == "database is locked (5) (SQLITE_BUSY)" {
121- logger.Debug("Database locked, retrying transaction operation",
122- "attempt", attempt,
123- "max_retries", maxRetries,
124- "query", query)
125- time.Sleep(retryDelay)
126- // Increase delay for next retry (exponential backoff)
127- retryDelay *= 2
128- continue
129- }
130-
131- // If it's not a locking error, return immediately
132- return nil, err
133- }
134-
135- return nil, fmt.Errorf("max retries exceeded: %w", err)
136-}
D internal/db/queries/account.go +0 -73
@@ -1,73 +0,0 @@
1-package queries
2-
3-import (
4- "database/sql"
5-
6- "github.com/bnema/gordon/internal/db"
7- "github.com/google/uuid"
8-)
9-
10-// AccountQueries contains all SQL queries for account operations
11-type AccountQueries struct {
12- InsertAccount string
13- CheckAccountExists string
14- GetAccountByID string
15-}
16-
17-// NewAccountQueries returns a new instance of AccountQueries
18-func NewAccountQueries() *AccountQueries {
19- return &AccountQueries{
20- InsertAccount: "INSERT INTO account (id, user_id) VALUES (?, ?)",
21- CheckAccountExists: "SELECT EXISTS(SELECT 1 FROM account WHERE id = ?)",
22- GetAccountByID: "SELECT id, user_id FROM account WHERE id = ?",
23- }
24-}
25-
26-// CreateDBAccount creates a new account in the database
27-func CreateDBAccount(database *sql.DB, userID string) (*db.Account, error) {
28- account := &db.Account{
29- ID: uuid.NewString(),
30- UserID: userID,
31- }
32-
33- _, err := database.Exec(
34- NewAccountQueries().InsertAccount,
35- account.ID, userID,
36- )
37-
38- return account, err
39-}
40-
41-// CheckDBAccountExists checks if an account exists in the database
42-func CheckDBAccountExists(database *sql.DB, accountID string) (bool, error) {
43- var exists bool
44-
45- err := database.QueryRow(
46- NewAccountQueries().CheckAccountExists,
47- accountID,
48- ).Scan(&exists)
49-
50- if err != nil {
51- return false, err
52- }
53-
54- return exists, nil
55-}
56-
57-// GetDBAccountByID gets an account from the database by ID
58-func GetDBAccountByID(database *sql.DB, accountID string) (*db.Account, error) {
59- account := &db.Account{
60- ID: accountID,
61- }
62-
63- err := database.QueryRow(
64- NewAccountQueries().GetAccountByID,
65- accountID,
66- ).Scan(&account.ID, &account.UserID)
67-
68- if err != nil {
69- return nil, err
70- }
71-
72- return account, nil
73-}
D internal/db/queries/clients.go +0 -1
@@ -1,1 +0,0 @@
1-package queries
D internal/db/queries/inspectdb.go +0 -74
@@ -1,74 +0,0 @@
1-package queries
2-
3-import (
4- "database/sql"
5- "fmt"
6-)
7-
8-// InspectInMemoryDB inspects the in-memory database. (for debug purpose)
9-func InspectInMemoryDB(database *sql.DB) error {
10- // Query the sqlite_master table to get a list of all tables
11- rows, err := database.Query("SELECT name FROM sqlite_master WHERE type='table'")
12- fmt.Println("rows", rows)
13- if err != nil {
14- return err
15- }
16- defer rows.Close()
17-
18- var tables []string
19- for rows.Next() {
20- var tableName string
21- if err := rows.Scan(&tableName); err != nil {
22- return err
23- }
24- tables = append(tables, tableName)
25- }
26-
27- if err := rows.Err(); err != nil {
28- return err
29- }
30-
31- // Print the tables
32- fmt.Println("Tables in the in-memory database:")
33- for _, table := range tables {
34- fmt.Println(table)
35- }
36- // Print the content of the users table
37- userRows, err := database.Query("SELECT * FROM user")
38- if err != nil {
39- return err
40- }
41- defer userRows.Close()
42-
43- fmt.Println("\nContent of the 'user' table:")
44- for userRows.Next() {
45- // Assuming users table has columns: id, name
46- var id int
47- var name string
48- if err := userRows.Scan(&id, &name); err != nil {
49- return err
50- }
51- fmt.Printf("ID: %d, Name: %s\n", id, name)
52- }
53-
54- // Print the content of accounts table
55- accountRows, err := database.Query("SELECT * FROM account")
56- if err != nil {
57- return err
58- }
59- defer accountRows.Close()
60-
61- fmt.Println("\nContent of the 'account' table:")
62- for accountRows.Next() {
63- // Assuming accounts table has columns: id, name, user_id
64- var id int
65- var name string
66- var userID int
67- if err := accountRows.Scan(&id, &name, &userID); err != nil {
68- return err
69- }
70- fmt.Printf("ID: %d, Name: %s, UserID: %d\n", id, name, userID)
71- }
72-
73- return nil
74-}
D internal/db/queries/provider.go +0 -185
@@ -1,185 +0,0 @@
1-package queries
2-
3-import (
4- "database/sql"
5-
6- "github.com/bnema/gordon/internal/db"
7- "github.com/google/uuid"
8-)
9-
10-// ProviderQueries contains all SQL queries for provider operations
11-type ProviderQueries struct {
12- InsertProvider string
13- PopulateProviderFromDB string
14- CheckProviderExists string
15- GetProviderByAccountLogin string
16- CountProvidersQuery string
17- InsertProviderForAccountQuery string
18- GetUserByProviderLoginQuery string
19-}
20-
21-// NewProviderQueries returns a new instance of ProviderQueries
22-func NewProviderQueries() *ProviderQueries {
23- return &ProviderQueries{
24- InsertProvider: "INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email) VALUES (?, ?, ?, ?, ?, ?, ?)",
25- PopulateProviderFromDB: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE account_id = ?",
26- CheckProviderExists: "SELECT EXISTS(SELECT 1 FROM provider WHERE login = ? AND email = ?)",
27- GetProviderByAccountLogin: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE login = ? AND email = ?",
28- CountProvidersQuery: "SELECT COUNT(*) FROM provider",
29- InsertProviderForAccountQuery: `
30- INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email)
31- VALUES (?, ?, ?, ?, ?, ?, ?)`,
32- GetUserByProviderLoginQuery: `
33- SELECT u.id, u.name, u.email, p.account_id
34- FROM provider p
35- JOIN account a ON p.account_id = a.id
36- JOIN user u ON a.user_id = u.id
37- WHERE p.name = ? AND p.login = ?`,
38- }
39-}
40-
41-// CountProviders returns the total number of provider records in the database.
42-func CountProviders(database *sql.DB) (int, error) {
43- var count int
44- err := database.QueryRow(NewProviderQueries().CountProvidersQuery).Scan(&count)
45- if err != nil {
46- return 0, err
47- }
48- return count, nil
49-}
50-
51-// Inserts a new provider record linked to an existing account.
52-func InsertProviderForAccount(database *sql.DB, accountID string, providerName string, userInfo *db.GithubUserInfo) error {
53- providerID := uuid.NewString()
54- primaryEmail := ""
55- if len(userInfo.Emails) > 0 {
56- primaryEmail = userInfo.Emails[0]
57- }
58-
59- _, err := database.Exec(
60- NewProviderQueries().InsertProviderForAccountQuery,
61- providerID,
62- accountID,
63- providerName,
64- userInfo.Login,
65- userInfo.AvatarURL,
66- userInfo.ProfileURL,
67- primaryEmail,
68- )
69- return err
70-}
71-
72-// Retrieves the User and Account ID associated with a specific provider login.
73-func GetUserByProviderLogin(database *sql.DB, providerName string, providerLogin string) (*db.User, string, error) {
74- user := &db.User{}
75- var accountID string
76-
77- err := database.QueryRow(
78- NewProviderQueries().GetUserByProviderLoginQuery,
79- providerName,
80- providerLogin,
81- ).Scan(
82- &user.ID,
83- &user.Name,
84- &user.Email,
85- &accountID,
86- )
87-
88- if err != nil {
89- return nil, "", err
90- }
91-
92- return user, accountID, nil
93-}
94-
95-// CreateDBGitHubProvider creates a new GitHub provider in the database.
96-func CreateDBGitHubProvider(database *sql.DB, accountID string, userInfo *db.GithubUserInfo) error {
97- providerID := uuid.NewString()
98-
99- primaryEmail := ""
100- if len(userInfo.Emails) > 0 {
101- primaryEmail = userInfo.Emails[0]
102- }
103-
104- _, err := database.Exec(
105- NewProviderQueries().InsertProvider,
106- providerID,
107- accountID,
108- "github",
109- userInfo.Login,
110- userInfo.AvatarURL,
111- userInfo.ProfileURL,
112- primaryEmail,
113- )
114-
115- return err
116-}
117-
118-// PopulateDBProviderFromDB populates provider information from the database.
119-func PopulateDBProviderFromDB(database *sql.DB, accountID string) (*db.Provider, error) {
120- provider := &db.Provider{}
121-
122- err := database.QueryRow(
123- NewProviderQueries().PopulateProviderFromDB,
124- accountID,
125- ).Scan(
126- &provider.ID,
127- &provider.AccountID,
128- &provider.Name,
129- &provider.Login,
130- &provider.AvatarURL,
131- &provider.ProfileURL,
132- &provider.Email,
133- )
134-
135- if err != nil {
136- return nil, err
137- }
138-
139- return provider, nil
140-}
141-
142-// CheckDBProviderExists checks if a provider exists in the database.
143-func CheckDBProviderExists(database *sql.DB, login string, email string) (bool, error) {
144- var exists bool
145-
146- err := database.QueryRow(
147- NewProviderQueries().CheckProviderExists,
148- login,
149- email,
150- ).Scan(&exists)
151-
152- if err != nil {
153- if err == sql.ErrNoRows {
154- return false, nil
155- }
156- return false, err
157- }
158-
159- return exists, nil
160-}
161-
162-// GetProviderByLoginAndEmail returns a provider from the database by login and email.
163-func GetProviderByLoginAndEmail(database *sql.DB, login string, email string) (*db.Provider, error) {
164- provider := &db.Provider{}
165-
166- err := database.QueryRow(
167- NewProviderQueries().GetProviderByAccountLogin,
168- login,
169- email,
170- ).Scan(
171- &provider.ID,
172- &provider.AccountID,
173- &provider.Name,
174- &provider.Login,
175- &provider.AvatarURL,
176- &provider.ProfileURL,
177- &provider.Email,
178- )
179-
180- if err != nil {
181- return nil, err
182- }
183-
184- return provider, nil
185-}
D internal/db/queries/proxy.go +0 -173
@@ -1,173 +0,0 @@
1-package queries
2-
3-// ProxyQueries contains all SQL queries used by the proxy package
4-type ProxyQueries struct {
5- // Route queries
6- GetActiveRoutes string
7- GetRouteByDomain string
8- GetRouteIDAndContainerIDByDomainName string
9- GetRouteIPByID string
10- InsertDomain string
11- GetDomainByName string
12- GetFirstAccount string
13- UpdateRoute string
14- UpdateRecreatedRoute string
15- InsertRoute string
16- DeleteRouteByDomainID string
17- DeleteDomainByID string
18- UpdateRouteIP string
19- GetAllRoutes string
20- MarkRouteInactive string
21-
22- // ACME queries
23- GetDomainAcmeConfig string
24- UpdateDomainAcmeConfig string
25- GetCertificateByDomain string
26- UpdateCertificate string
27-
28- // Admin domain queries
29- CheckDomainExists string
30- CreateAdminDomain string
31- UpdateAdminDomainAcme string
32-
33- // ACME Account queries (NEW)
34- GetAcmeAccountByEmail string
35- InsertOrReplaceAcmeAccount string
36-}
37-
38-// NewProxyQueries returns a new instance of ProxyQueries
39-func NewProxyQueries() *ProxyQueries {
40- return &ProxyQueries{
41- // Route queries
42- GetActiveRoutes: `
43- SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active
44- FROM proxy_route pr
45- JOIN domain d ON pr.domain_id = d.id
46- WHERE pr.active = 1
47- `,
48- GetRouteByDomain: `
49- SELECT id, container_id, container_port
50- FROM proxy_route
51- WHERE domain_id = ?
52- `,
53- GetRouteIDAndContainerIDByDomainName: `
54- SELECT pr.id, pr.container_id
55- FROM proxy_route pr
56- JOIN domain d ON pr.domain_id = d.id
57- WHERE d.name = ?
58- `,
59- GetRouteIPByID: `
60- SELECT container_ip
61- FROM proxy_route
62- WHERE id = ?
63- `,
64- InsertDomain: `
65- INSERT INTO domain (id, name, account_id, created_at, updated_at)
66- VALUES (?, ?, ?, ?, ?)
67- `,
68- GetDomainByName: `
69- SELECT id FROM domain WHERE name = ?
70- `,
71- GetFirstAccount: `
72- SELECT id FROM account LIMIT 1
73- `,
74- UpdateRoute: `
75- UPDATE proxy_route SET
76- container_id = ?,
77- container_ip = ?,
78- container_port = ?,
79- protocol = ?,
80- path = ?,
81- active = ?,
82- updated_at = ?
83- WHERE id = ?
84- `,
85- UpdateRecreatedRoute: `
86- UPDATE proxy_route
87- SET container_id = ?, container_ip = ?, container_port = ?, updated_at = ?
88- WHERE id = ?
89- `,
90- InsertRoute: `
91- INSERT INTO proxy_route (
92- id, domain_id, container_id, container_ip, container_port,
93- protocol, path, active, created_at, updated_at
94- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
95- `,
96- DeleteRouteByDomainID: `
97- DELETE FROM proxy_route WHERE domain_id = ?
98- `,
99- DeleteDomainByID: `
100- DELETE FROM domain WHERE id = ?
101- `,
102- UpdateRouteIP: `
103- UPDATE proxy_route SET container_ip = ?, active = 1, updated_at = ?
104- WHERE domain_id = ? AND container_id = ?
105- `,
106- GetAllRoutes: `
107- SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active
108- FROM proxy_route pr
109- JOIN domain d ON pr.domain_id = d.id
110- `,
111- MarkRouteInactive: `
112- UPDATE proxy_route SET active = 0, updated_at = ? WHERE domain_id = ?
113- `,
114-
115- // ACME queries
116- GetDomainAcmeConfig: `
117- SELECT acme_enabled, acme_challenge_type, acme_dns_provider, acme_dns_credentials_ref
118- FROM domain WHERE name = ?
119- `,
120- UpdateDomainAcmeConfig: `
121- UPDATE domain SET
122- acme_enabled = ?,
123- acme_challenge_type = ?,
124- acme_dns_provider = ?,
125- acme_dns_credentials_ref = ?,
126- updated_at = ?
127- WHERE name = ?
128- `,
129- GetCertificateByDomain: `
130- -- Reverted: Select all original columns as expected by checkExistingCertificate
131- SELECT c.cert_file, c.key_file, c.issued_at, c.expires_at, c.issuer, c.status
132- FROM certificate c
133- JOIN domain d ON c.domain_id = d.id
134- WHERE d.name = ?
135- -- Note: This query might return multiple rows or non-valid certs.
136- -- The calling code (checkExistingCertificate and GetCertificate) needs to handle this.
137- `,
138- UpdateCertificate: `
139- INSERT OR REPLACE INTO certificate (
140- id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status
141- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
142- `,
143-
144- // Admin domain queries
145- CheckDomainExists: `
146- SELECT EXISTS(SELECT 1 FROM domain WHERE name = ?)
147- `,
148- CreateAdminDomain: `
149- INSERT INTO domain (
150- id, name, account_id, created_at, updated_at, acme_enabled, acme_challenge_type
151- ) VALUES (?, ?, ?, ?, ?, ?, ?)
152- `,
153- UpdateAdminDomainAcme: `
154- UPDATE domain SET
155- acme_enabled = 1,
156- acme_challenge_type = 'http-01',
157- updated_at = ?
158- WHERE name = ?
159- `,
160-
161- // ACME Account queries (NEW)
162- GetAcmeAccountByEmail: `
163- SELECT private_key, registration_info
164- FROM acme_account
165- WHERE email = ?
166- `,
167- InsertOrReplaceAcmeAccount: `
168- INSERT OR REPLACE INTO acme_account (
169- email, private_key, registration_info, created_at, updated_at
170- ) VALUES (?, ?, ?, ?, ?)
171- `,
172- }
173-}
D internal/db/queries/sessions.go +0 -326
@@ -1,326 +0,0 @@
1-package queries
2-
3-import (
4- "database/sql"
5- "fmt"
6- "time"
7-
8- "github.com/bnema/gordon/internal/db"
9- "github.com/bnema/gordon/pkg/logger"
10- "github.com/google/uuid"
11-)
12-
13-// SessionQueries contains all SQL queries for session operations
14-type SessionQueries struct {
15- InsertSession string
16- GetSessionByToken string
17- UpdateSessionToken string
18- DeleteSession string
19- GetSessionExpiry string
20- UpdateSessionExpiry string
21- CheckSessionExists string
22- InvalidateSession string
23- MarkSessionActive string
24-}
25-
26-// NewSessionQueries returns a new instance of SessionQueries
27-func NewSessionQueries() *SessionQueries {
28- return &SessionQueries{
29- InsertSession: "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_active) VALUES (?, ?, ?, ?, ?, ?)",
30- GetSessionByToken: "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_active FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?",
31- UpdateSessionToken: "UPDATE sessions SET access_token = ?, expires = ?, is_active = true WHERE id = ?",
32- DeleteSession: "DELETE FROM sessions WHERE account_id = ? AND id = ?",
33- GetSessionExpiry: "SELECT expires FROM sessions WHERE account_id = ? AND id = ?",
34- UpdateSessionExpiry: "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?",
35- CheckSessionExists: "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)",
36- InvalidateSession: "UPDATE sessions SET is_active = ? WHERE id = ?",
37- MarkSessionActive: "UPDATE sessions SET is_active = true WHERE id = ?",
38- }
39-}
40-
41-// CreateDBSession creates a session for the user.
42-func CreateDBSession(database *sql.DB, browserInfo string, accessToken string, accountID string) (*db.Sessions, error) {
43- expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
44- sessionID := uuid.NewString()
45-
46- _, err := database.Exec(
47- NewSessionQueries().InsertSession,
48- sessionID, accountID, accessToken, browserInfo, expireTime, true,
49- )
50-
51- if err != nil {
52- logger.Error("Failed to create session in database", "error", err, "session_id", sessionID, "account_id", accountID)
53- return nil, err
54- }
55-
56- // Create a Sessions struct to return
57- session := &db.Sessions{
58- ID: sessionID,
59- AccountID: accountID,
60- AccessToken: accessToken,
61- BrowserInfo: browserInfo,
62- Expires: expireTime,
63- IsActive: true,
64- }
65-
66- logger.Debug("Session created in database", "session_id", sessionID, "account_id", accountID, "expires", expireTime)
67- return session, nil
68-}
69-
70-// GetDBUserSession gets the user session from the database based on the access token and browser info.
71-func GetDBUserSession(database *sql.DB, accessToken string, browserInfo string) (*db.Sessions, error) {
72- session := &db.Sessions{}
73-
74- err := database.QueryRow(
75- NewSessionQueries().GetSessionByToken,
76- accessToken,
77- browserInfo,
78- ).Scan(
79- &session.ID,
80- &session.AccountID,
81- &session.AccessToken,
82- &session.BrowserInfo,
83- &session.Expires,
84- &session.IsActive,
85- )
86-
87- if err != nil {
88- if err == sql.ErrNoRows {
89- return nil, nil
90- }
91- logger.Warn("Failed to get session", "error", err)
92- return nil, fmt.Errorf("error getting session: %w", err)
93- }
94-
95- logger.Debug("Session retrieved from database", "session", session)
96-
97- return session, nil
98-}
99-
100-// UpdateDBSession updates an existing session with a new access token
101-func UpdateDBSession(database *sql.DB, sessionID string, accessToken string) error {
102- expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
103-
104- _, err := database.Exec(
105- NewSessionQueries().UpdateSessionToken,
106- accessToken,
107- expireTime,
108- sessionID,
109- )
110-
111- if err != nil {
112- logger.Error("Failed to update session in database", "error", err, "session_id", sessionID)
113- return err
114- }
115-
116- logger.Debug("Session updated in database", "sessionID", sessionID, "expires", expireTime)
117- return nil
118-}
119-
120-// CreateOrUpdateSession creates a new session or updates an existing one
121-func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken string, browserInfo string) (*db.Sessions, error) {
122- // Check if a session exists for this account with the same browser info
123- // (Token might change, so check by account/browser)
124- existingSession, err := GetSessionByAccountAndBrowser(database, accountID, browserInfo)
125- if err != nil && err != sql.ErrNoRows {
126- logger.Error("Failed to check for existing session", "error", err, "accountID", accountID)
127- return nil, fmt.Errorf("error checking for existing session: %w", err)
128- }
129-
130- expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339)
131-
132- // If the session exists, update its token and expiry
133- if existingSession != nil {
134- logger.Debug("Existing session found, updating token and expiry", "sessionID", existingSession.ID, "accountID", accountID)
135- updateQuery := NewSessionQueries().UpdateSessionToken
136- updateParams := []interface{}{accessToken, expireTime, existingSession.ID}
137- logger.Debug("Executing session update", "query", updateQuery, "params", updateParams)
138- result, err := database.Exec(
139- updateQuery,
140- updateParams...,
141- )
142- if err != nil {
143- logger.Error("Database Exec failed during session update", "error", err, "session_id", existingSession.ID)
144- } else {
145- rowsAffected, _ := result.RowsAffected()
146- logger.Debug("Database Exec success during session update", "session_id", existingSession.ID, "rows_affected", rowsAffected)
147- }
148- if err != nil {
149- logger.Error("Failed to update existing session", "error", err, "session_id", existingSession.ID)
150- return nil, fmt.Errorf("error updating session: %w", err)
151- }
152- // Update the struct we have in memory
153- existingSession.AccessToken = accessToken
154- existingSession.Expires = expireTime
155- existingSession.IsActive = true // Ensure it's marked active
156- logger.Info("Session updated successfully", "sessionID", existingSession.ID)
157- return existingSession, nil
158- } else {
159- // If the session does not exist, create a new one
160- logger.Debug("No existing session found, creating new session", "accountID", accountID)
161- sessionID := uuid.NewString() // Use uuid directly
162- insertQuery := NewSessionQueries().InsertSession
163- insertParams := []interface{}{sessionID, accountID, accessToken, browserInfo, expireTime, true}
164- logger.Debug("Executing session insert", "query", insertQuery, "params", insertParams)
165- result, err := database.Exec(
166- insertQuery,
167- insertParams...,
168- )
169- if err != nil {
170- logger.Error("Database Exec failed during session insert", "error", err, "accountID", accountID)
171- } else {
172- rowsAffected, _ := result.RowsAffected()
173- lastInsertId, _ := result.LastInsertId() // Might be 0/irrelevant for SQLite UUID PKs, but log anyway
174- logger.Debug("Database Exec success during session insert", "accountID", accountID, "rows_affected", rowsAffected, "last_insert_id", lastInsertId)
175- }
176- if err != nil {
177- logger.Error("Failed to create new session", "error", err, "accountID", accountID)
178- return nil, fmt.Errorf("error creating session: %w", err)
179- }
180- // Create the session struct to return
181- newSession := &db.Sessions{
182- ID: sessionID,
183- AccountID: accountID,
184- AccessToken: accessToken,
185- BrowserInfo: browserInfo,
186- Expires: expireTime,
187- IsActive: true,
188- }
189- logger.Info("New session created successfully", "sessionID", newSession.ID)
190- return newSession, nil
191- }
192-}
193-
194-// GetSessionByAccountAndBrowser finds a session by account ID and browser info
195-func GetSessionByAccountAndBrowser(database *sql.DB, accountID string, browserInfo string) (*db.Sessions, error) {
196- session := &db.Sessions{}
197- // Define the query inline or add to SessionQueries struct
198- query := "SELECT id, account_id, access_token, browser_info, expires, is_active FROM sessions WHERE account_id = ? AND browser_info = ?"
199- err := database.QueryRow(query, accountID, browserInfo).Scan(
200- &session.ID,
201- &session.AccountID,
202- &session.AccessToken,
203- &session.BrowserInfo,
204- &session.Expires,
205- &session.IsActive,
206- )
207- if err != nil {
208- // Return sql.ErrNoRows if not found
209- return nil, err
210- }
211- return session, nil
212-}
213-
214-// DeleteDBSession deletes the session from the database.
215-func DeleteDBSession(database *sql.DB, accountID string, sessionID string) error {
216- _, err := database.Exec(
217- NewSessionQueries().DeleteSession,
218- accountID,
219- sessionID,
220- )
221-
222- if err != nil {
223- logger.Error("Failed to delete session from database", "error", err, "account_id", accountID, "session_id", sessionID)
224- return err
225- }
226-
227- logger.Debug("Session deleted from database", "accountID", accountID, "sessionID", sessionID)
228- return nil
229-}
230-
231-// GetSessionExpiration gets the expiration time for a session
232-func GetSessionExpiration(database *sql.DB, accountID string, sessionID string, currentTime time.Time) (time.Time, error) {
233- var expirationTime string
234-
235- err := database.QueryRow(
236- NewSessionQueries().GetSessionExpiry,
237- accountID,
238- sessionID,
239- ).Scan(&expirationTime)
240-
241- if err != nil {
242- return currentTime, err
243- }
244-
245- // convert the expiration time to a time.Time
246- expirationTimeParsed, err := time.Parse(time.RFC3339, expirationTime)
247- if err != nil {
248- return currentTime, err
249- }
250-
251- logger.Debug("Session expiration time retrieved", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTimeParsed)
252-
253- return expirationTimeParsed, nil
254-}
255-
256-// ExtendSessionExpiration extends the expiration time for a session
257-func ExtendSessionExpiration(database *sql.DB, accountID string, sessionID string, newExpirationTime time.Time) error {
258- _, err := database.Exec(
259- NewSessionQueries().UpdateSessionExpiry,
260- newExpirationTime.Format(time.RFC3339),
261- accountID,
262- sessionID,
263- )
264-
265- if err != nil {
266- logger.Error("Failed to extend session expiration", "error", err, "account_id", accountID, "session_id", sessionID)
267- return err
268- }
269-
270- logger.Debug("Session expiration time extended", "accountID", accountID, "sessionID", sessionID, "newExpirationTime", newExpirationTime)
271- return nil
272-}
273-
274-// CheckDBSessionExists checks if a session exists
275-func CheckDBSessionExists(database *sql.DB, sessionID string) (bool, error) {
276- var sessionExists bool
277- err := database.QueryRow(
278- NewSessionQueries().CheckSessionExists,
279- sessionID,
280- ).Scan(&sessionExists)
281-
282- if err != nil {
283- return false, err
284- }
285-
286- logger.Debug("Session existence checked", "sessionID", sessionID, "exists", sessionExists)
287- return sessionExists, nil
288-}
289-
290-// MarkSessionInactive marks a session as inactive (sets is_active to false)
291-func MarkSessionInactive(database *sql.DB, sessionID string) error {
292- _, err := database.Exec(
293- NewSessionQueries().InvalidateSession,
294- false,
295- sessionID,
296- )
297-
298- if err != nil {
299- logger.Error("Failed to mark session inactive", "error", err, "session_id", sessionID)
300- return err
301- }
302-
303- logger.Debug("Session marked as inactive", "sessionID", sessionID)
304- return nil
305-}
306-
307-// MarkSessionActive sets the is_active flag to true for a given session ID.
308-func MarkSessionActive(database *sql.DB, sessionID string) error {
309- query := NewSessionQueries().MarkSessionActive
310- result, err := database.Exec(query, sessionID)
311-
312- if err != nil {
313- logger.Error("Failed to mark session active in database", "error", err, "session_id", sessionID)
314- return fmt.Errorf("failed to execute MarkSessionActive query: %w", err)
315- }
316-
317- rowsAffected, _ := result.RowsAffected()
318- if rowsAffected == 0 {
319- logger.Warn("MarkSessionActive query executed but did not affect any rows", "sessionID", sessionID)
320- // This might indicate the session ID didn't exist, though the query technically succeeded.
321- // Consider if this should return an error or not. For now, logging a warning.
322- }
323-
324- logger.Debug("Marked session active in database", "sessionID", sessionID, "rows_affected", rowsAffected)
325- return nil
326-}
D internal/db/queries/user.go +0 -195
@@ -1,195 +0,0 @@
1-package queries
2-
3-import (
4- "database/sql"
5- "fmt"
6-
7- "github.com/bnema/gordon/internal/db"
8- "github.com/bnema/gordon/pkg/logger"
9- "github.com/google/uuid"
10-)
11-
12-// UserQueries contains all SQL queries for user operations
13-type UserQueries struct {
14- InsertUser string
15- CheckUserExists string
16- GetUserByProvider string
17- GetAccountCount string
18- GetFirstUserAndAccountQuery string
19- UpdateUserDetailsQuery string
20-}
21-
22-// NewUserQueries returns a new instance of UserQueries
23-func NewUserQueries() *UserQueries {
24- return &UserQueries{
25- InsertUser: "INSERT INTO user (id, name, email) VALUES (?, ?, ?)",
26- CheckUserExists: "SELECT EXISTS(SELECT 1 FROM user LIMIT 1)",
27- GetUserByProvider: "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?",
28- GetAccountCount: "SELECT COUNT(*) FROM account",
29- GetFirstUserAndAccountQuery: `
30- SELECT u.id, u.name, u.email, a.id
31- FROM user u
32- JOIN account a ON u.id = a.user_id
33- ORDER BY u.rowid ASC -- Rely on implicit rowid for 'first'
34- LIMIT 1`,
35- UpdateUserDetailsQuery: "UPDATE user SET name = ?, email = ? WHERE id = ?",
36- }
37-}
38-
39-// GetFirstUserAndAccount retrieves the first user and their associated account ID. Assumes this is the seeded admin.
40-func GetFirstUserAndAccount(database *sql.DB) (*db.User, string, error) {
41- user := &db.User{}
42- var accountID string
43- err := database.QueryRow(NewUserQueries().GetFirstUserAndAccountQuery).Scan(
44- &user.ID,
45- &user.Name,
46- &user.Email,
47- &accountID,
48- )
49- if err != nil {
50- return nil, "", err
51- }
52- return user, accountID, nil
53-}
54-
55-// UpdateUserDetails updates the name and email for a specific user ID.
56-func UpdateUserDetails(database *sql.DB, userID string, newName string, newEmail string) error {
57- _, err := database.Exec(NewUserQueries().UpdateUserDetailsQuery, newName, newEmail, userID)
58- return err
59-}
60-
61-// CreateUser creates a new user along with the associated account, provider, and session.
62-func CreateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
63- // Check if a user already exists. If so, return an error.
64- logger.Debug("Starting CreateUser database function")
65- if exists, err := CheckDBUserExists(database); err != nil || exists {
66- logger.Warn("User already exists or error checking", "error", err, "exists", exists)
67- return nil, "", nil, fmt.Errorf("error checking user or user already exists: %v", err)
68- }
69-
70- logger.Debug("Creating database user", "login", userInfo.Login, "email", userInfo.Emails[0])
71- user, err := CreateDBUser(database, userInfo)
72- if err != nil {
73- logger.Error("Failed to create database user", "error", err)
74- return nil, "", nil, err
75- }
76- logger.Info("Database user created successfully", "user_id", user.ID)
77-
78- logger.Debug("Creating database account for user", "user_id", user.ID)
79- account, err := CreateDBAccount(database, user.ID)
80- if err != nil {
81- logger.Error("Failed to create database account", "error", err)
82- return nil, "", nil, err
83- }
84- logger.Info("Database account created successfully", "account_id", account.ID)
85-
86- logger.Debug("Creating GitHub provider for account", "account_id", account.ID)
87- err = InsertProviderForAccount(database, account.ID, "github", userInfo)
88- if err != nil {
89- logger.Error("Failed to create GitHub provider", "error", err)
90- return nil, "", nil, err
91- }
92- logger.Info("GitHub provider created successfully")
93-
94- logger.Debug("Creating session for account", "account_id", account.ID)
95- session, err := CreateOrUpdateSession(database, account.ID, accessToken, browserInfo)
96- if err != nil {
97- logger.Error("Failed to create session", "error", err)
98- return nil, "", nil, err
99- }
100- logger.Info("Session created successfully")
101-
102- return user, account.ID, session, nil
103-}
104-
105-// CreateDBUser creates a new user in the database
106-func CreateDBUser(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, error) {
107- primaryEmail := ""
108- if len(userInfo.Emails) > 0 {
109- primaryEmail = userInfo.Emails[0]
110- }
111-
112- user := &db.User{
113- ID: uuid.NewString(),
114- Name: userInfo.Login,
115- Email: primaryEmail,
116- }
117-
118- _, err := database.Exec(
119- NewUserQueries().InsertUser,
120- user.ID, user.Name, user.Email,
121- )
122- return user, err
123-}
124-
125-// CheckDBUserExists checks if any user exists in the database
126-func CheckDBUserExists(database *sql.DB) (bool, error) {
127- var exists bool
128- err := database.QueryRow(NewUserQueries().CheckUserExists).Scan(&exists)
129- if err != nil {
130- return false, err
131- }
132- return exists, nil
133-}
134-
135-// CheckDBUserIsGood checks if the user matches the provided GitHub info
136-func CheckDBUserIsGood(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, bool, error) {
137- login := userInfo.Login
138- primaryEmail := ""
139- if len(userInfo.Emails) > 0 {
140- primaryEmail = userInfo.Emails[0]
141- }
142-
143- user := &db.User{}
144-
145- // Check if the user exists based on github login and email
146- err := database.QueryRow(NewUserQueries().GetUserByProvider, login, primaryEmail).Scan(
147- &user.ID, &user.Name, &user.Email,
148- )
149-
150- if err != nil {
151- if err == sql.ErrNoRows {
152- return nil, false, nil
153- }
154- return nil, false, err
155- }
156-
157- if user.ID != "" {
158- return user, true, nil
159- }
160-
161- return nil, false, nil
162-}
163-
164-// UpdateUser updates the user session with new access token
165-func UpdateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
166- // First, get the user info to get their account ID
167- user, accountID, err := GetUserByProviderLogin(database, "github", userInfo.Login)
168- if err != nil {
169- if err == sql.ErrNoRows {
170- return nil, "", nil, fmt.Errorf("user not found in database for provider login '%s'", userInfo.Login)
171- }
172- return nil, "", nil, fmt.Errorf("error checking user by provider login: %w", err)
173- }
174-
175- // If user found, update/create their session
176- session, err := CreateOrUpdateSession(database, accountID, accessToken, browserInfo)
177- if err != nil {
178- return nil, "", nil, fmt.Errorf("error creating/updating session: %w", err)
179- }
180-
181- logger.Debug("Session updated/created in database", "accountID", accountID, "sessionID", session.ID)
182-
183- return user, accountID, session, nil
184-}
185-
186-// GetAccountCount returns the number of accounts in the database
187-func GetAccountCount(database *sql.DB) (int, error) {
188- var count int
189- err := database.QueryRow(NewUserQueries().GetAccountCount).Scan(&count)
190- if err != nil {
191- return 0, err
192- }
193-
194- return count, nil
195-}
D internal/httpserve/handlers/admin_view.go +0 -101
@@ -1,101 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/gordon/internal/server"
7- "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
8- "github.com/bnema/gordon/internal/templating/render"
9- "github.com/bnema/gordon/pkg/docker"
10- "github.com/labstack/echo/v4"
11-)
12-
13-// GetLocalizedData returns the data for the localization
14-func GetLocalizedData(c echo.Context, a *server.App) (map[string]string, error) {
15- lang := c.Get("LangKey")
16- if lang == nil {
17- return nil, fmt.Errorf("LangKey not found in context")
18- }
19-
20- langStr, ok := lang.(string)
21- if !ok {
22- return nil, fmt.Errorf("LangKey is not a string")
23- }
24-
25- yamlDataInterface, err := render.GetLocalization(langStr, a)
26- if err != nil {
27- return nil, fmt.Errorf("failed to get localization: %w", err)
28- }
29-
30- // Convert map[string]interface{} to map[string]string
31- langContent := make(map[string]string)
32- for key, value := range yamlDataInterface {
33- strValue, ok := value.(string)
34- if !ok {
35- // Handle cases where the value is not a string, e.g., log a warning or skip
36- // For now, we'll skip non-string values
37- continue
38- }
39- langContent[key] = strValue
40- }
41-
42- // Return map[string]string directly
43- return langContent, nil
44-}
45-
46-// AdminRoute is the route for the admin panel
47-func AdminRoute(c echo.Context, a *server.App) error {
48- langContent, err := GetLocalizedData(c, a)
49- if err != nil {
50- return sendError(c, err) // Assuming sendError exists
51- }
52-
53- data := admin.IndexPageData{
54- Title: "Admin Dashboard", // Or get from LangContent if available
55- BuildVersion: a.Config.Build.BuildVersion,
56- AdminPath: a.Config.Admin.Path,
57- UserSettings: a.Config.Admin.Path + "/user", // Assuming this path
58- LogoutURL: a.Config.Admin.Path + "/logout",
59- LangContent: langContent,
60- }
61-
62- // Use the Templ renderer
63- renderer := render.NewTemplRenderer(a)
64- component := admin.IndexPage(data)
65-
66- return renderer.RenderTempl(c, component)
67-}
68-
69-// AdminManagerRoute is the route of the manager page
70-func AdminManagerRoute(c echo.Context, a *server.App) error {
71- // Fetch container and image counts
72- containers, err := docker.ListRunningContainers()
73- if err != nil {
74- return sendError(c, fmt.Errorf("failed to list containers: %w", err))
75- }
76- images, err := docker.ListContainerImages()
77- if err != nil {
78- return sendError(c, fmt.Errorf("failed to list images: %w", err))
79- }
80-
81- containerCount := len(containers)
82- imageCount := len(images)
83-
84- title := "Container Manager" // Set a default title
85- userSettingsURL := a.Config.Admin.Path + "/user" // Assuming this path
86- logoutURL := a.Config.Admin.Path + "/logout"
87-
88- // Use the Templ renderer
89- renderer := render.NewTemplRenderer(a)
90- component := admin.ManagerPage(
91- title,
92- a.Config.Build.BuildVersion,
93- a.Config.Admin.Path,
94- userSettingsURL,
95- logoutURL,
96- containerCount,
97- imageCount,
98- )
99-
100- return renderer.RenderTempl(c, component)
101-}
D internal/httpserve/handlers/container_crud.go +0 -246
@@ -1,246 +0,0 @@
1-package handlers
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "strings"
7-
8- "github.com/bnema/gordon/internal/server"
9- "github.com/bnema/gordon/internal/templating/cmdparams"
10- templcomponents "github.com/bnema/gordon/internal/templating/models/templ/components"
11- templadmin "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
12- "github.com/bnema/gordon/internal/templating/render"
13- "github.com/bnema/gordon/pkg/docker"
14- "github.com/bnema/gordon/pkg/sanitize"
15- "github.com/charmbracelet/log"
16- "github.com/labstack/echo/v4"
17-)
18-
19-// FromShortIDToImageID converts a short image ID to a full image ID
20-func FromShortIDToImageID(ShortID string) (string, error) {
21- imageID, exists := safelyInteractWithIDMap(Fetch, ShortID)
22- if !exists {
23- log.Debug("No mapping found for short ID: %s", ShortID)
24- return "", fmt.Errorf("image ID not found")
25- }
26- return imageID, nil
27-}
28-
29-// CreateContainerRoute is the route for creating a new container
30-func CreateContainerGET(c echo.Context, a *server.App) error {
31- log.Debug("Full request URL: %s", c.Request().URL.String())
32- // Retrieve the ShortID of the image from the URL
33- shortID := c.Param("ID")
34- log.Debug("Received ShortID: %s", shortID)
35-
36- fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
37- if !exists {
38- log.Debug("No mapping found for short ID: %s", shortID)
39- return RenderNotFoundPage(c, a)
40- }
41-
42- log.Debug("Found mapping: %s -> %s", shortID, fullID)
43-
44- // Get the image info
45- imageInfo, err := docker.GetImageInfo(fullID)
46- if err != nil {
47- return sendError(c, err)
48- }
49-
50- // Extract image name
51- var imageName string
52- if len(imageInfo.RepoTags) > 0 {
53- imageName = imageInfo.RepoTags[0]
54- } else {
55- imageName = imageInfo.ID
56- }
57-
58- // Setup form data
59- formData := templcomponents.ContainerFormData{
60- ImageName: imageName,
61- ImageID: fullID,
62- ShortID: shortID,
63- ErrorMessage: "",
64- }
65-
66- // Get the renderer
67- renderer := render.NewTemplRenderer(a)
68-
69- // Render the component
70- return renderer.RenderTempl(c, templcomponents.CreateContainerForm(formData))
71-}
72-
73-// CreateContainerFullGET handles the full HTML page for creating a new container
74-func CreateContainerFullGET(c echo.Context, a *server.App) error {
75- // Retrieve the ShortID of the image from the URL
76- shortID := c.Param("ID")
77- log.Debug("Received ShortID: %s", shortID)
78-
79- fullID, exists := safelyInteractWithIDMap(Fetch, shortID)
80- if !exists {
81- log.Debug("No mapping found for short ID: %s", shortID)
82- return RenderNotFoundPage(c, a)
83- }
84-
85- log.Debug("Found mapping: %s -> %s", shortID, fullID)
86-
87- // Get the image info
88- imageInfo, err := docker.GetImageInfo(fullID)
89- if err != nil {
90- return sendError(c, err)
91- }
92-
93- // Extract image name
94- var imageName string
95- if len(imageInfo.RepoTags) > 0 {
96- imageName = imageInfo.RepoTags[0]
97- } else {
98- imageName = imageInfo.ID
99- }
100-
101- // Setup form data
102- formData := templcomponents.ContainerFormData{
103- ImageName: imageName,
104- ImageID: fullID,
105- ShortID: shortID,
106- ErrorMessage: "",
107- }
108-
109- // Get the renderer
110- renderer := render.NewTemplRenderer(a)
111-
112- // Render the createcontainerfull template
113- pageData := templadmin.CreateContainerFullData{
114- Title: "Create Container | Gordon",
115- BuildVersion: a.Config.Build.BuildVersion,
116- AdminPath: a.Config.Admin.Path,
117- UserSettings: "/user",
118- LogoutURL: a.Config.Admin.Path + "/logout",
119- ContainerData: formData,
120- }
121-
122- return renderer.RenderTempl(c, templadmin.CreateContainerFullPage(pageData))
123-}
124-
125-// CreateContainerPOST handles the create container form submission
126-func CreateContainerPOST(c echo.Context, a *server.App) error {
127- log.Debug("CreateContainerPOST handler called")
128-
129- // Retrieve all form parameters
130- formParams, err := c.FormParams()
131- if err != nil {
132- return sendError(c, err)
133- }
134-
135- // Initialize a map to store sanitized values
136- sanitizedInputs := make(map[string]string)
137-
138- // Iterate over all form parameters and sanitize them
139- for k, v := range formParams {
140- if len(v) > 0 {
141- sanitized, err := sanitize.SanitizeHTML(v[0])
142- if err != nil {
143- return sendError(c, err)
144- }
145- sanitizedInputs[k] = sanitized
146- }
147- }
148-
149- // Get the full image ID directly from the sanitized form inputs
150- imageID, ok := sanitizedInputs["image_id"]
151- if !ok || imageID == "" {
152- log.Error("Full image ID not found in form submission")
153- // You might want to return a more specific error message to the user here
154- return sendError(c, fmt.Errorf("missing image ID in form submission"))
155- }
156- log.Debug("Retrieved full image ID from form:", "imageID", imageID)
157-
158- // Get the image info (Optional but good practice: verify the image ID still exists)
159- _, err = docker.GetImageInfo(imageID)
160- if err != nil {
161- log.Error("Error getting image info:", "error", err)
162- return sendError(c, err)
163- }
164-
165- cmdParams, err := cmdparams.FromInputsToCmdParams(sanitizedInputs, a)
166- if err != nil {
167- return sendError(c, err)
168- }
169-
170- // Create the container
171- containerID, err := docker.CreateContainer(cmdParams)
172- if err != nil {
173- return sendError(c, err)
174- }
175-
176- // Start the container
177- err = docker.StartContainer(containerID)
178- if err != nil {
179- // If start fails, try to cleanup the container
180- log.Warn("Failed to start container", "error", err)
181- _ = docker.RemoveContainer(containerID)
182- return sendError(c, fmt.Errorf("failed to start container: %w", err))
183- }
184-
185- // Get the container name for logging and as fallback
186- containerName, err := docker.GetContainerName(containerID)
187- if err != nil {
188- log.Warn("Failed to get container name", "error", err)
189- } else {
190- containerName = strings.TrimPrefix(containerName, "/")
191- }
192-
193- // Check if we should skip proxy setup
194- skipProxySetup := false
195- if skipProxyValue, exists := sanitizedInputs["skip_proxy_setup"]; exists {
196- skipProxySetup = skipProxyValue == "true" || skipProxyValue == "1" || skipProxyValue == "yes"
197- }
198-
199- // Only set up proxy if not skipped
200- if !skipProxySetup {
201- // Get container IP
202- containerIP := GetContainerIP(a, containerID, containerName)
203-
204- // Construct target domain from form inputs
205- var targetDomain string
206- if sanitizedInputs["container_subdomain"] == "" {
207- // If subdomain is empty, use just the domain
208- targetDomain = fmt.Sprintf("%s://%s",
209- sanitizedInputs["container_protocol"],
210- sanitizedInputs["container_domain"])
211- } else {
212- // If subdomain is provided, use subdomain.domain format
213- targetDomain = fmt.Sprintf("%s://%s.%s",
214- sanitizedInputs["container_protocol"],
215- sanitizedInputs["container_subdomain"],
216- sanitizedInputs["container_domain"])
217- }
218-
219- // Add proxy route for the new container
220- err = AddProxyRoute(a, containerID, containerIP, sanitizedInputs["container_port"], targetDomain)
221- if err != nil {
222- log.Error("Failed to add proxy route", "error", err)
223- // Continue anyway, as the container is already created and started
224- }
225- } else {
226- log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)
227- }
228-
229- // Prepare success message payload
230- successPayload := map[string]string{
231- "level": "success",
232- "message": fmt.Sprintf("Container '%s' created successfully.", containerName),
233- }
234- jsonPayload, err := json.Marshal(successPayload)
235- if err != nil {
236- // Log the error, but proceed with redirect as container creation was likely successful
237- log.Error("Failed to marshal success payload for HX-Trigger", "error", err)
238- } else {
239- c.Response().Header().Set("HX-Trigger", fmt.Sprintf(`{"showMessage": %s}`, string(jsonPayload)))
240- }
241-
242- // Render a success message fragment instead of redirecting
243- renderer := render.NewTemplRenderer(a)
244- successMsg := fmt.Sprintf("Container '%s' created successfully.", containerName)
245- return renderer.RenderTempl(c, templcomponents.Success(successMsg)) // Use the correct Success component
246-}
D internal/httpserve/handlers/container_management.go +0 -389
@@ -1,389 +0,0 @@
1-package handlers
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "net/http"
7- "strings"
8- "sync"
9- "time"
10-
11- "github.com/bnema/gordon/internal/server"
12- components "github.com/bnema/gordon/internal/templating/models/templ/components"
13- "github.com/bnema/gordon/internal/templating/render"
14- "github.com/bnema/gordon/pkg/docker"
15- "github.com/bnema/gordon/pkg/humanize"
16- "github.com/bnema/gordon/pkg/logger"
17- "github.com/docker/docker/api/types"
18- "github.com/labstack/echo/v4"
19-)
20-
21-// Shared state for last prune result
22-var lastPruneResult struct {
23- Done bool
24- Success bool
25- Message string
26- mutex sync.Mutex
27-}
28-
29-type MapOperation string
30-
31-var (
32- idMap = make(map[string]string)
33- idMapMutex sync.Mutex
34-)
35-
36-const (
37- Fetch MapOperation = "Fetch"
38- Update MapOperation = "Update"
39- Delete MapOperation = "Delete"
40-)
41-
42-func safelyInteractWithIDMap(op MapOperation, key string, value ...string) (string, bool) {
43- idMapMutex.Lock()
44- defer idMapMutex.Unlock()
45-
46- switch op {
47- case Fetch:
48- val, exists := idMap[key]
49- return val, exists
50- case Update:
51- if len(value) > 0 {
52- idMap[key] = value[0]
53- }
54- return "", true
55- case Delete:
56- delete(idMap, key)
57- return "", true
58- default:
59- return "", false
60- }
61-}
62-
63-type HumanReadableContainerImage struct {
64- ID string
65- Name string
66- ShortID string
67- CreatedStr string
68- SizeStr string
69- RepoDigests []string
70- RepoTags []string
71- Repository string
72- Tag string
73- Size string
74- Created string
75-}
76-
77-type HumanReadableContainer struct {
78- *types.Container
79- Name string
80- Ports []string
81- ShortID string
82- CreatedStr string
83- SizeStr string
84- UpSince string
85- StateColor string
86- ProxyPort string
87-}
88-
89-type ContainerDisplay struct {
90- ID string
91- Name string
92- Ports []docker.PortMapping
93- ProxyPort string
94- CreatedStr string
95- Status string
96- ImageName string
97- IsGordonContainer bool
98-}
99-
100-// ActionSuccess returns the success HTML fragment
101-func ActionSuccess(a *server.App) string {
102-
103- // return just a string
104- return "success"
105-}
106-
107-// ImageManagerComponent handles the /image-manager route (HTMX route)
108-func ImageManagerComponent(c echo.Context, a *server.App) error {
109- // Check last prune status and add trigger header if needed
110- lastPruneResult.mutex.Lock()
111- if lastPruneResult.Done {
112- var level string
113- if lastPruneResult.Success {
114- level = "success"
115- } else {
116- level = "error"
117- }
118- // Prepare JSON payload for HX-Trigger
119- payload := map[string]string{"level": level, "message": lastPruneResult.Message}
120- jsonPayload, _ := json.Marshal(payload) // Error handling omitted for brevity
121- c.Response().Header().Set("HX-Trigger", fmt.Sprintf(`{"showMessage": %s}`, string(jsonPayload)))
122-
123- // Reset the flag
124- lastPruneResult.Done = false
125- }
126- lastPruneResult.mutex.Unlock()
127-
128- images, err := docker.ListContainerImages()
129- if err != nil {
130- return sendError(c, err)
131- }
132-
133- var humanReadableImages []components.HumanReadableImage
134- for _, img := range images {
135- shortID := img.ID[7:19] // Display a shorter version of the ID
136- safelyInteractWithIDMap(Update, shortID, img.ID)
137- createdStr := humanize.TimeAgo(time.Unix(img.Created, 0))
138- sizeStr := humanize.BytesToReadableSize(img.Size)
139-
140- // Parse RepoTags to extract repository and tag
141- var repository, tag string
142- repoTag := "<none>:<none>"
143- if len(img.RepoTags) > 0 {
144- repoTag = img.RepoTags[0]
145- parts := strings.Split(repoTag, ":")
146- if len(parts) == 2 {
147- repository = parts[0]
148- tag = parts[1]
149- } else {
150- repository = repoTag // Handle cases like "image" without a tag
151- tag = "latest"
152- }
153- }
154-
155- // Create instance of components.HumanReadableImage
156- humanReadableImages = append(humanReadableImages, components.HumanReadableImage{
157- ID: img.ID,
158- ShortID: shortID,
159- Created: createdStr,
160- Size: sizeStr,
161- Name: repoTag,
162- RepoDigests: img.RepoDigests,
163- RepoTags: img.RepoTags,
164- Repository: repository,
165- Tag: tag,
166- })
167- }
168-
169- logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images")
170-
171- // Use the Templ renderer
172- renderer := render.NewTemplRenderer(a)
173- component := components.ImageList(humanReadableImages)
174-
175- return renderer.RenderTempl(c, component)
176-}
177-
178-// ImageManagerDelete handles the /image-manager/delete route
179-func ImageManagerDelete(c echo.Context, a *server.App) error {
180- ShortID := c.Param("ID")
181-
182- imageID, exists := safelyInteractWithIDMap(Fetch, ShortID)
183- if !exists {
184- return c.String(http.StatusBadRequest, "Invalid ShortImgID")
185- }
186-
187- err := docker.DeleteContainerImage(imageID)
188- if err != nil {
189- c.Response().Header().Set("X-Error-Type", "image")
190- return c.String(http.StatusInternalServerError, err.Error())
191- }
192-
193- safelyInteractWithIDMap(Delete, ShortID)
194-
195- return c.String(http.StatusOK, ActionSuccess(a))
196-}
197-
198-// ImageManagerPrune handles the /image-manager/prune route
199-func ImageManagerPrune(c echo.Context, a *server.App) error {
200- // Run pruning in a background goroutine
201- go func() {
202- var success bool
203- var message string
204- logger.Info("Starting background image prune...")
205- numPurged, err := docker.PruneImages() // Remove context argument
206- if err != nil {
207- logger.Error("Background image prune failed", "error", err)
208- success = false
209- message = fmt.Sprintf("Image prune failed: %v", err)
210- // Optionally, notify the user or admin about the failure (e.g., via websocket, SSE, or a status endpoint)
211- return
212- } else {
213- logger.Info("Background image prune completed", "purged_count", numPurged)
214- success = true
215- message = fmt.Sprintf("Successfully purged %d images.", numPurged)
216- // Optionally, notify the user about completion (e.g., via websocket, SSE, or updating the UI)
217- }
218-
219- // Update shared state
220- lastPruneResult.mutex.Lock()
221- lastPruneResult.Done = true
222- lastPruneResult.Success = success
223- lastPruneResult.Message = message
224- lastPruneResult.mutex.Unlock()
225- }()
226-
227- // Immediately return a placeholder that initiates polling
228- // Note: We need to modify imagelist.templ to handle this response properly
229- // For now, just return 202 Accepted and indicate work started.
230- // The actual polling trigger will be added in the template.
231- c.Response().WriteHeader(http.StatusAccepted)
232- // We'll return a small snippet that the template swap can use
233- // This snippet should include the polling mechanism itself.
234- // Example (needs refinement in the template):
235- return c.HTML(http.StatusAccepted, `<div id="prune-status-poller" hx-get="/htmx/image-manager/prune-status" hx-trigger="load, every 3s" hx-target="#prune-status-poller" hx-swap="outerHTML">Pruning images in background... Checking status.</div>`)
236-}
237-
238-// ImageManagerPruneStatus checks the status of a background prune operation
239-func ImageManagerPruneStatus(c echo.Context, a *server.App) error {
240- lastPruneResult.mutex.Lock()
241- defer lastPruneResult.mutex.Unlock()
242-
243- // If prune is not done, return an empty response or keep the poller active
244- if !lastPruneResult.Done {
245- // Returning 204 No Content will stop polling if using hx-swap="none"
246- // However, our poller replaces itself, so we just return the same polling div
247- // or an updated status message within it.
248- // Let's keep the existing poller HTML but maybe update the text slightly.
249- return c.HTML(http.StatusOK, `<div id="prune-status-poller" hx-get="/htmx/image-manager/prune-status" hx-trigger="every 3s" hx-target="#prune-status-poller" hx-swap="outerHTML">Still pruning... Checking status.</div>`)
250- }
251-
252- // Prune is done, prepare the final response
253- logger.Info("Prune complete, preparing final response for poller.")
254-
255- // Prepare JSON payload for HX-Trigger toast message
256- var level string
257- if lastPruneResult.Success {
258- level = "success"
259- } else {
260- level = "error"
261- }
262- payload := map[string]string{"level": level, "message": lastPruneResult.Message}
263- jsonPayload, marshalErr := json.Marshal(payload)
264- if marshalErr != nil {
265- logger.Error("Failed to marshal prune result payload for poller", "error", marshalErr)
266- // Handle marshalling error - maybe return an error component
267- } else {
268- c.Response().Header().Set("HX-Trigger", fmt.Sprintf(`{"showMessage": %s}`, string(jsonPayload)))
269- }
270-
271- // Reset the flag *after* preparing the response
272- lastPruneResult.Done = false
273- message := lastPruneResult.Message // Store message before resetting if needed
274- success := lastPruneResult.Success
275- // Reset message/success if needed, or maybe they are only relevant for the toast
276- // lastPruneResult.Message = ""
277- // lastPruneResult.Success = false
278-
279- // Fetch the updated image list (copy logic from ImageManagerComponent)
280- images, listErr := docker.ListContainerImages()
281- if listErr != nil {
282- logger.Error("Failed to list images after prune for poller", "error", listErr)
283- // Return an error FRAGMENT instead of a full page.
284- // HX-Trigger for the prune status itself was already set.
285- pruneStatusMsg := "Prune finished but failed to refresh list."
286- if !success { // Add prune status detail if it also failed
287- pruneStatusMsg = fmt.Sprintf("Prune status: %s. Failed to refresh list.", message)
288- }
289- return c.HTML(http.StatusOK, fmt.Sprintf("<div class='alert alert-error'>%s Error: %v. Please refresh manually.</div>", pruneStatusMsg, listErr))
290- }
291-
292- var humanReadableImages []components.HumanReadableImage
293- for _, img := range images {
294- shortID := img.ID[7:19]
295- safelyInteractWithIDMap(Update, shortID, img.ID)
296- createdStr := humanize.TimeAgo(time.Unix(img.Created, 0))
297- sizeStr := humanize.BytesToReadableSize(img.Size)
298- var repository, tag string
299- repoTag := "<none>:<none>"
300- if len(img.RepoTags) > 0 {
301- repoTag = img.RepoTags[0]
302- parts := strings.Split(repoTag, ":")
303- if len(parts) == 2 {
304- repository = parts[0]
305- tag = parts[1]
306- } else {
307- repository = repoTag
308- tag = "latest"
309- }
310- }
311- humanReadableImages = append(humanReadableImages, components.HumanReadableImage{
312- ID: img.ID,
313- ShortID: shortID,
314- Created: createdStr,
315- Size: sizeStr,
316- Name: repoTag,
317- RepoDigests: img.RepoDigests,
318- RepoTags: img.RepoTags,
319- Repository: repository,
320- Tag: tag,
321- })
322- }
323-
324- // Render the final ImageList component to replace the poller
325- renderer := render.NewTemplRenderer(a)
326- component := components.ImageList(humanReadableImages)
327- logger.Debug("Rendering final ImageList for poller")
328- return renderer.RenderTempl(c, component)
329-}
330-
331-// ContainerManagerComponent handles the /container-manager route
332-func ContainerManagerComponent(c echo.Context, a *server.App) error {
333- logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String())
334-
335- containers, err := docker.ListRunningContainers()
336- if err != nil {
337- logger.Error("Error listing containers", "error", err)
338- return sendError(c, err)
339- }
340-
341- logger.Debug("Containers found", "count", len(containers))
342- for i, container := range containers {
343- logger.Debug("Container details", "index", i, "id", container.ID, "names", container.Names, "state", container.State)
344- }
345-
346- // Use the Templ renderer
347- renderer := render.NewTemplRenderer(a)
348- component := components.ContainerList(containers, len(containers) == 0)
349-
350- return renderer.RenderTempl(c, component)
351-}
352-
353-// ContainerManagerDelete handles the /container-manager/delete route
354-func ContainerManagerDelete(c echo.Context, a *server.App) error {
355- err := docker.RemoveContainer(c.Param("ID"))
356- if err != nil {
357- return sendError(c, err)
358- }
359- return c.HTML(http.StatusOK, ActionSuccess(a))
360-}
361-
362-// ContainerManagerStop handles the /container-manager/stop route
363-func ContainerManagerStop(c echo.Context, a *server.App) error {
364- // Stop the container gracefully with a timeout
365- stopped, err := docker.StopContainerGracefully(c.Param("ID"), 3*time.Second)
366- if err != nil {
367- return sendError(c, err)
368- }
369- // if stopped is true, the container was stopped gracefully
370- if stopped {
371- return c.HTML(http.StatusOK, ActionSuccess(a))
372- }
373- // If stopped is false, the container was not stopped gracefully we force stop it
374- err = docker.StopContainerRagefully(c.Param("ID"))
375- if err != nil {
376- return sendError(c, err)
377- }
378-
379- return c.HTML(http.StatusOK, ActionSuccess(a))
380-}
381-
382-// ContainerManagerStart handles the /container-manager/start route
383-func ContainerManagerStart(c echo.Context, a *server.App) error {
384- err := docker.StartContainer(c.Param("ID"))
385- if err != nil {
386- return sendError(c, err)
387- }
388- return c.HTML(http.StatusOK, ActionSuccess(a))
389-}
D internal/httpserve/handlers/container_transactions.go +0 -292
@@ -1,292 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/bnema/gordon/internal/server"
8- "github.com/bnema/gordon/internal/templating/cmdparams"
9- "github.com/bnema/gordon/internal/templating/models/templ/components"
10- "github.com/bnema/gordon/internal/templating/render"
11- "github.com/bnema/gordon/pkg/docker"
12- "github.com/bnema/gordon/pkg/logger"
13- "github.com/labstack/echo/v4"
14- "gopkg.in/yaml.v3"
15-)
16-
17-func generateOrderedYAML(infoMap map[string]interface{}) (string, error) {
18- orderedKeys := []string{"Name", "Image", "Hostname", "Ports", "Environment", "Labels", "Network", "Volumes", "Restart"}
19-
20- var yamlString string
21- for _, key := range orderedKeys {
22- value, exists := infoMap[key]
23- if !exists {
24- continue
25- }
26-
27- // Special handling for empty Ports and Volumes maps
28- if key == "Ports" || key == "Volumes" {
29- if mapVal, ok := value.(map[string]string); ok && len(mapVal) == 0 {
30- yamlString += key + ": []\n" // Output as empty list instead of map
31- continue // Skip normal marshalling for this key
32- }
33- }
34-
35- // Default marshalling for other keys or non-empty maps
36- yamlBytes, err := yaml.Marshal(map[string]interface{}{key: value})
37- if err != nil {
38- return "", err
39- }
40-
41- yamlString += string(yamlBytes)
42- }
43-
44- return yamlString, nil
45-}
46-
47-// ContainerManagerEditGET displays the edit container view
48-func ContainerManagerEditGET(c echo.Context, a *server.App) error {
49- containerID := c.Param("ID")
50- containerInfo, err := docker.GetContainerInfo(containerID)
51- if err != nil {
52- return sendError(c, err)
53- }
54-
55- portMappings := make(map[string]string)
56- for internalPort, hostBindings := range containerInfo.HostConfig.PortBindings {
57- if len(hostBindings) > 0 {
58- hostBinding := hostBindings[0]
59- portMappings[string(internalPort)] = fmt.Sprintf("%s:%s", hostBinding.HostIP, hostBinding.HostPort)
60- }
61- }
62-
63- volumeMappings := make(map[string]string)
64- for _, mount := range containerInfo.Mounts {
65- volumeMappings[mount.Source] = mount.Destination
66- }
67-
68- networkNames := []string{}
69- for name := range containerInfo.NetworkSettings.Networks {
70- networkNames = append(networkNames, name)
71- }
72-
73- infoMap := map[string]interface{}{
74- "Name": strings.TrimPrefix(containerInfo.Name, "/"),
75- "Image": containerInfo.Config.Image,
76- "CMD": containerInfo.Config.Cmd,
77- "Entrypoint": containerInfo.Config.Entrypoint,
78- }
79- infoMap["Hostname"] = containerInfo.Config.Hostname
80- infoMap["Ports"] = portMappings
81- infoMap["Volumes"] = volumeMappings
82- infoMap["Environment"] = containerInfo.Config.Env
83- infoMap["Labels"] = containerInfo.Config.Labels
84- infoMap["Network"] = networkNames
85- infoMap["Restart"] = containerInfo.HostConfig.RestartPolicy.Name
86-
87- // Convert the infoMap to YAML format
88- yamlString, err := generateOrderedYAML(infoMap)
89- if err != nil {
90- return sendError(c, err)
91- }
92-
93- // Use the Templ renderer
94- renderer := render.NewTemplRenderer(a)
95- component := components.EditContainer(containerID, yamlString)
96-
97- return renderer.RenderTempl(c, component)
98-}
99-
100-type TransactionStep func() error
101-type RollbackStep func() error
102-
103-type TransactionQueue struct {
104- NewContainerID string
105- NewContainerName string
106- TempContainerName string
107- OldContainerID string
108- OldContainerName string
109- steps []TransactionStep
110- rollbacks []RollbackStep
111-}
112-
113-func (tq *TransactionQueue) Add(step TransactionStep, rollback RollbackStep) {
114- tq.steps = append(tq.steps, step)
115- tq.rollbacks = append(tq.rollbacks, rollback)
116-}
117-
118-func (tq *TransactionQueue) Execute() error {
119- var transactionErrors []error
120- var rollbackErrors []error
121- for i, step := range tq.steps {
122- err := step()
123- if err != nil {
124- logger.Error(fmt.Sprintf("Error occurred at step index %d: %v", i, err))
125- transactionErrors = append(transactionErrors, err)
126- for j := i - 1; j >= 0; j-- {
127- rollback := tq.rollbacks[j]
128- if rollback != nil {
129- if rollbackErr := rollback(); rollbackErr != nil {
130- logger.Error(fmt.Sprintf("Failed to execute rollback at index %d: %v", j, rollbackErr))
131- rollbackErrors = append(rollbackErrors, rollbackErr)
132- }
133- }
134- }
135- return fmt.Errorf("transaction failed: %v; rollback errors: %v", transactionErrors, rollbackErrors)
136- }
137- }
138- return nil
139-}
140-
141-// ContainerManagerEditPOST handles the edit container form submission
142-func ContainerManagerEditPOST(c echo.Context, a *server.App) error {
143- tq := &TransactionQueue{}
144- oldContainerID := c.Param("ID")
145-
146- oldContainerInfo, err := docker.GetContainerInfo(oldContainerID)
147- if err != nil {
148- return sendError(c, fmt.Errorf("getting old container info failed: %w", err))
149- }
150-
151- containerConfig := c.FormValue("container_config")
152- var containerParams cmdparams.YAMLContainerParams
153- err = yaml.Unmarshal([]byte(containerConfig), &containerParams)
154- if err != nil {
155- return sendError(c, fmt.Errorf("YAML unmarshal failed: %w", err))
156- }
157-
158- // Use render.FromYAMLStructToCmdParams to convert YAMLContainerParams to ContainerCommandParams
159- cmdParams, err := cmdparams.FromYAMLStructToCmdParams(containerParams)
160- if err != nil {
161- return fmt.Errorf("failed to convert YAML: %w", err)
162- }
163-
164- tq.OldContainerID = oldContainerID
165- tq.OldContainerName = strings.TrimPrefix(oldContainerInfo.Name, "/")
166- tq.NewContainerName = cmdParams.ContainerName
167- tq.TempContainerName = fmt.Sprintf("%s-temp", tq.NewContainerName)
168-
169- cmdParams.ContainerName = tq.TempContainerName
170-
171- // 1. Stop the old container
172- tq.Add(StopOldContainerStep(tq), StartOldContainerRollback(tq))
173-
174- // 2. Create the new container
175- tq.Add(CreateNewContainerStep(tq, cmdParams), RemoveNewContainerRollback(tq))
176-
177- // 3. Start the new container
178- tq.Add(StartNewContainerStep(tq), StopNewContainerRollback(tq))
179-
180- // 4. Rename the new container to the original name
181- tq.Add(RemoveOldContainerStep(tq), nil)
182-
183- // 5. Remove the old container
184- tq.Add(RenameNewContainerStep(tq), RenameNewContainerRollback(tq))
185-
186- err = tq.Execute()
187- if err != nil {
188- return sendError(c, fmt.Errorf("transaction failed: %w", err))
189- }
190-
191- return c.HTML(200, ActionSuccess(a))
192-}
193-
194-// CreateNewContainerStep creates a new container with the given parameters
195-func CreateNewContainerStep(tq *TransactionQueue, cmdParams docker.ContainerCommandParams) TransactionStep {
196- return func() error {
197- var err error
198- tq.NewContainerID, err = docker.CreateContainer(cmdParams)
199- if err != nil {
200- return fmt.Errorf("creating new container failed: %w", err)
201- }
202- return nil
203- }
204-}
205-
206-// StartNewContainerStep starts the new container
207-func StartNewContainerStep(tq *TransactionQueue) TransactionStep {
208- return func() error {
209- err := docker.StartContainer(tq.NewContainerID)
210- if err != nil {
211- return fmt.Errorf("starting new container failed: %w", err)
212- }
213- return nil
214- }
215-}
216-
217-// StopOldContainerStep stops the old container
218-func StopOldContainerStep(tq *TransactionQueue) TransactionStep {
219- return func() error {
220- err := docker.StopContainer(tq.OldContainerID)
221- if err != nil {
222- return fmt.Errorf("stopping old container failed: %w", err)
223- }
224- return nil
225- }
226-}
227-
228-// RemoveOldContainerStep removes the old container
229-func RemoveOldContainerStep(tq *TransactionQueue) TransactionStep {
230- return func() error {
231- err := docker.RemoveContainer(tq.OldContainerID)
232- if err != nil {
233- return fmt.Errorf("removing old container failed: %w", err)
234- }
235- return nil
236- }
237-}
238-
239-// RemoveNewContainerRollback removes the new container if the transaction fails
240-func RemoveNewContainerRollback(tq *TransactionQueue) RollbackStep {
241- return func() error {
242- err := docker.RemoveContainer(tq.NewContainerID)
243- if err != nil {
244- return fmt.Errorf("rollback: removing new container failed: %w", err)
245- }
246- return nil
247- }
248-}
249-
250-// StopNewContainerRollback stops the new container if the transaction fails
251-func StopNewContainerRollback(tq *TransactionQueue) RollbackStep {
252- return func() error {
253- err := docker.StopContainer(tq.NewContainerID)
254- if err != nil {
255- return fmt.Errorf("rollback: stopping new container failed: %w", err)
256- }
257- return nil
258- }
259-}
260-
261-// StartOldContainerRollback starts the old container if the transaction fails
262-func StartOldContainerRollback(tq *TransactionQueue) RollbackStep {
263- return func() error {
264- err := docker.StartContainer(tq.OldContainerID)
265- if err != nil {
266- return fmt.Errorf("rollback: starting old container failed: %w", err)
267- }
268- return nil
269- }
270-}
271-
272-// RenameNewContainerStep renames the new temporary container to the original name
273-func RenameNewContainerStep(tq *TransactionQueue) TransactionStep {
274- return func() error {
275- err := docker.RenameContainer(tq.NewContainerID, tq.NewContainerName)
276- if err != nil {
277- return fmt.Errorf("renaming new container failed: %w", err)
278- }
279- return nil
280- }
281-}
282-
283-// RenameNewContainerRollback renames the new container back to the temporary name if it fails
284-func RenameNewContainerRollback(tq *TransactionQueue) RollbackStep {
285- return func() error {
286- err := docker.RenameContainer(tq.NewContainerID, tq.TempContainerName)
287- if err != nil {
288- return fmt.Errorf("rollback: renaming new container back failed: %w", err)
289- }
290- return nil
291- }
292-}
D internal/httpserve/handlers/endpoint_deploy.go +0 -630
@@ -1,630 +0,0 @@
1-// endpoint_deploy.go
2-package handlers
3-
4-import (
5- "database/sql"
6- "encoding/json"
7- "fmt"
8- "io"
9- "net/http"
10- "os"
11- "path/filepath"
12- "strings"
13- "time"
14-
15- authToken "github.com/bnema/gordon/internal/cli/auth"
16- "github.com/bnema/gordon/internal/common"
17- "github.com/bnema/gordon/internal/proxy"
18- "github.com/bnema/gordon/internal/server"
19- "github.com/bnema/gordon/pkg/docker"
20- "github.com/bnema/gordon/pkg/logger"
21- "github.com/labstack/echo/v4"
22-)
23-
24-// PostDeploy handles the container deployment request
25-func PostDeploy(c echo.Context, a *server.App) error {
26- payload, err := validateAndPrepareDeployPayload(c)
27- if err != nil {
28- // Debug
29- logger.Error("Failed to validate and prepare payload", "error", err)
30- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
31- Success: false,
32- Message: fmt.Sprintf("Invalid payload: %v", err),
33- StatusCode: http.StatusBadRequest,
34- })
35- }
36-
37- _, err = saveAndImportImage(c, a, payload)
38- if err != nil {
39- // Debug
40- logger.Error("Failed to save and import image", "error", err)
41- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
42- Success: false,
43- Message: fmt.Sprintf("Failed to save or import image: %v", err),
44- StatusCode: http.StatusInternalServerError,
45- })
46- }
47-
48- containerID, containerName, err := createAndStartContainer(a, payload)
49- if err != nil {
50- if strings.Contains(err.Error(), "is already in use") {
51- logger.Warn("Container already exists", "error", err)
52- existingContainerID := extractContainerID(err.Error())
53- existingContainerName, _ := docker.GetContainerName(existingContainerID)
54- // Remove the / from the container name
55- existingContainerName = strings.TrimLeft(existingContainerName, "/")
56-
57- if existingContainerID == "" || existingContainerName == "" {
58- logger.Error("Failed to extract container ID or Name from error message")
59- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
60- Success: false,
61- Message: "A container for this deploy already exists but could not be identified.",
62- StatusCode: http.StatusConflict,
63- })
64- }
65-
66- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
67- Success: false,
68- Message: "A container for this deploy already exists.",
69- ContainerID: existingContainerID,
70- ContainerName: existingContainerName,
71- StatusCode: http.StatusConflict,
72- })
73- }
74- logger.Error("Failed to create or start container", "error", err)
75- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
76- Success: false,
77- Message: fmt.Sprintf("Failed to create or start container: %v", err),
78- StatusCode: http.StatusInternalServerError,
79- })
80- }
81-
82- // Check if we should skip proxy setup
83- skipProxySetup := false
84- if payload.SkipProxySetup {
85- skipProxySetup = true
86- }
87-
88- // After successfully deploying a container, add a proxy route if not skipped
89- if !skipProxySetup {
90- containerIP := GetContainerIP(a, containerID, containerName)
91- err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain)
92- if err != nil {
93- logger.Error("Failed to add proxy route", "error", err)
94- return sendDeployErrorResponse(c, "Failed to add proxy route", err)
95- }
96- } else {
97- logger.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName)
98- }
99-
100- // Arrived here means deployment was successful
101- return sendJSONResponse(c, http.StatusOK, DeployResponse{
102- Success: true,
103- Message: "Deployment successful",
104- Domain: payload.TargetDomain,
105- ContainerID: containerID,
106- ContainerName: containerName,
107- })
108-}
109-
110-// PostDeployChunked handles the chunked container deployment request
111-func PostDeployChunked(c echo.Context, a *server.App) error {
112- return handleChunkedDeploy(c, a, c.Request().Header.Get("X-Chunk-Metadata"))
113-}
114-
115-func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) error {
116- var metadata ChunkMetadata
117- if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil {
118- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
119- Success: false,
120- Message: "Invalid chunk metadata",
121- StatusCode: http.StatusBadRequest,
122- })
123- }
124-
125- payload, err := validateAndPrepareDeployPayload(c)
126- if err != nil {
127- logger.Error("Failed to validate and prepare payload", "error", err)
128- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
129- Success: false,
130- Message: fmt.Sprintf("Invalid payload: %v", err),
131- StatusCode: http.StatusBadRequest,
132- })
133- }
134-
135- // Auth verification steps
136-
137- auth := c.Request().Header.Get("Authorization")
138- if auth == "" {
139- return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{
140- Success: false,
141- Message: "Authentication required",
142- StatusCode: http.StatusUnauthorized,
143- })
144- }
145-
146- // Validate the token through GitHub API
147- token := strings.TrimPrefix(auth, "Bearer ")
148- if !authToken.IsValidGitHubToken(token) {
149- return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{
150- Success: false,
151- Message: "Invalid or expired token",
152- StatusCode: http.StatusUnauthorized,
153- })
154- }
155-
156- // TODO: Here we should probably check that the github user is the same as the one in DB
157-
158- if metadata.TotalChunks == 1 {
159- chunkData, err := io.ReadAll(c.Request().Body)
160- if err != nil {
161- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
162- Success: false,
163- Message: "Failed to read chunk data",
164- StatusCode: http.StatusInternalServerError,
165- })
166- }
167-
168- // Store the data directly
169- chunkStore.mu.Lock()
170- chunkStore.chunks[metadata.TransferID] = make(map[int][]byte)
171- chunkStore.chunks[metadata.TransferID][0] = chunkData
172- chunkStore.metadata[metadata.TransferID] = &metadata
173- chunkStore.mu.Unlock()
174-
175- // Process the complete transfer immediately
176- return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID)
177- }
178-
179- // Store chunk data
180- chunkStore.mu.Lock()
181- if chunkStore.chunks[metadata.TransferID] == nil {
182- chunkStore.chunks[metadata.TransferID] = make(map[int][]byte)
183- chunkStore.metadata[metadata.TransferID] = &metadata
184- }
185-
186- chunkData, err := io.ReadAll(c.Request().Body)
187- if err != nil {
188- chunkStore.mu.Unlock()
189- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
190- Success: false,
191- Message: "Failed to read chunk data",
192- StatusCode: http.StatusInternalServerError,
193- })
194- }
195-
196- // Chunks validation steps
197- //
198- if len(chunkData) == 0 {
199- logger.Error("Received empty chunk")
200- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
201- Success: false,
202- Message: "Received empty chunk",
203- StatusCode: http.StatusBadRequest,
204- })
205- }
206-
207- // Verify chunk size matches metadata
208- if int64(len(chunkData)) != metadata.ChunkSize {
209- logger.Error("Chunk size mismatch",
210- "expected", metadata.ChunkSize,
211- "received", len(chunkData))
212- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
213- Success: false,
214- Message: fmt.Sprintf("Chunk size mismatch: expected %d, got %d",
215- metadata.ChunkSize, len(chunkData)),
216- StatusCode: http.StatusBadRequest,
217- })
218- }
219-
220- // Store the complete chunk into our store
221- chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData
222- chunkStore.mu.Unlock()
223-
224- // If this is the last chunk, process the complete transfer
225- if isTransferComplete(metadata.TransferID) {
226- return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID)
227- }
228-
229- // Include domain information in intermediate chunk responses
230- return sendJSONResponse(c, http.StatusOK, DeployResponse{
231- Success: true,
232- Message: fmt.Sprintf("Chunk %d/%d received", metadata.ChunkNumber+1, metadata.TotalChunks),
233- Domain: payload.TargetDomain,
234- StatusCode: http.StatusOK,
235- })
236-}
237-
238-func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error {
239- logger.Info("Starting complete deploy transfer processing",
240- "transferID", transferID)
241-
242- // Get transfer data
243- metadata, chunks, err := getTransferData(transferID)
244- if err != nil {
245- return sendDeployErrorResponse(c, "Failed to get transfer data", err)
246- }
247-
248- // Create temporary directory for processing
249- tmpDir, err := os.MkdirTemp("", "docker-deploy-*")
250- if err != nil {
251- return sendDeployErrorResponse(c, "Failed to create temp directory", err)
252- }
253- defer os.RemoveAll(tmpDir)
254-
255- tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar"))
256- if err != nil {
257- return sendDeployErrorResponse(c, "Failed to create temp file", err)
258- }
259- defer tmpFile.Close()
260-
261- // Write chunks to file
262- if err := writeChunksToFile(tmpFile, chunks, metadata.TotalChunks); err != nil {
263- return sendDeployErrorResponse(c, "Failed to write chunks", err)
264- }
265-
266- // Get deployment-specific headers
267- port := normalizePort(c.Request().Header.Get("X-Ports"))
268- targetDomain := normalizeTargetDomain(c.Request().Header.Get("X-Target-Domain"))
269- imageName := c.Request().Header.Get("X-Image-Name")
270-
271- if port == "" || targetDomain == "" || imageName == "" {
272- return sendDeployErrorResponse(c, "Missing required deployment parameters",
273- fmt.Errorf("port: %s, domain: %s, image: %s", port, targetDomain, imageName))
274- }
275-
276- // Extract container name from domain and perform safety checks
277- cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://")
278-
279- // Check if the domain has a subdomain
280- domainParts := strings.Split(cleanDomain, ".")
281- var containerName string
282-
283- if len(domainParts) >= 2 {
284- // If there's a subdomain (e.g., sub.domain.tld), use it as the container name
285- containerName = domainParts[0]
286- } else {
287- // If there's no subdomain (e.g., domain.tld), use the domain as the container name
288- containerName = strings.ReplaceAll(cleanDomain, ".", "-")
289- }
290-
291- if containerName == "" {
292- return sendDeployErrorResponse(c, "Invalid target domain",
293- fmt.Errorf("couldn't extract container name from domain: %s", targetDomain))
294- }
295-
296- // Check for existing container
297- existingContainerID := docker.GetContainerIDByName(containerName)
298- if existingContainerID != "" {
299- logger.Warn("Container already exists",
300- "name", containerName,
301- "id", existingContainerID)
302- return sendDeployErrorResponse(c, "Container already exists", nil)
303- }
304-
305- // Import the image
306- logger.Info("Importing image from file",
307- "path", tmpFile.Name(),
308- "imageName", imageName)
309-
310- imageID, err := docker.ImportImageToEngine(tmpFile.Name())
311- if err != nil {
312- if strings.Contains(err.Error(), "platform compatibility check failed") {
313- logger.Error("Platform compatibility check failed",
314- "error", err,
315- "imageName", imageName)
316- return sendDeployErrorResponse(c, "Platform compatibility check failed", err)
317- }
318- return sendDeployErrorResponse(c, "Failed to import image", err)
319- }
320-
321- // Prepare deployment payload
322- deployPayload := &common.DeployPayload{
323- Port: port,
324- TargetDomain: targetDomain,
325- ImageName: imageName,
326- ImageID: imageID,
327- }
328-
329- // Create and start the container
330- containerID, containerName, err := createAndStartContainer(a, deployPayload)
331- if err != nil {
332- if strings.Contains(err.Error(), "is already in use") {
333- existingContainerID := extractContainerID(err.Error())
334- existingContainerName, _ := docker.GetContainerName(existingContainerID)
335- existingContainerName = strings.TrimPrefix(existingContainerName, "/")
336-
337- return sendDeployErrorResponse(c, "Container already in use", err)
338- }
339-
340- return sendDeployErrorResponse(c, "Failed to create or start container", err)
341- }
342-
343- // Cleanup transfer data
344- cleanupTransfer(transferID)
345-
346- logger.Info("Deployment completed successfully",
347- "containerID", containerID,
348- "containerName", containerName,
349- "domain", targetDomain)
350-
351- // After successfully deploying a container, add a proxy route
352- containerIP := GetContainerIP(a, containerID, containerName)
353- err = AddProxyRoute(a, containerID, containerIP, port, targetDomain)
354- if err != nil {
355- logger.Error("Failed to add proxy route", "error", err)
356- return sendDeployErrorResponse(c, "Failed to add proxy route", err)
357- }
358-
359- return sendJSONResponse(c, http.StatusOK, DeployResponse{
360- Success: true,
361- Message: "Deployment successful",
362- Domain: targetDomain,
363- ContainerID: containerID,
364- ContainerName: strings.TrimPrefix(containerName, "/"),
365- })
366-}
367-
368-func sendDeployErrorResponse(c echo.Context, message string, err error) error {
369- // The error becomes the message
370- errorMsg := fmt.Sprintf("%s: %s", message, err.Error())
371- return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{
372- Success: false,
373- Message: errorMsg,
374- StatusCode: http.StatusInternalServerError,
375- })
376-}
377-
378-// func waitForDeployment(domain string, containerID string) error {
379-// client := &http.Client{Timeout: 10 * time.Second}
380-// maxRetries := 10
381-// retryInterval := time.Second
382-
383-// var shortContainerID string
384-// if containerID != "" {
385-// shortContainerID = containerID[:12]
386-// }
387-
388-// log.Info("Waiting for deployment to be reachable",
389-// "domain", domain,
390-// "container_id", shortContainerID)
391-
392-// for i := 0; i < maxRetries; i++ {
393-// resp, err := client.Get(domain)
394-// if err == nil {
395-// defer resp.Body.Close()
396-// if resp.StatusCode == http.StatusOK {
397-// log.Info("Deployment successful",
398-// "domain", domain,
399-// "container_id", shortContainerID)
400-// return nil
401-// }
402-// body, _ := io.ReadAll(resp.Body)
403-// if strings.Contains(string(body), "failed to create container:") {
404-// return fmt.Errorf("deployment failed: %s", string(body))
405-// }
406-// }
407-// log.Warn("Deployment not ready yet, retrying",
408-// "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries))
409-// time.Sleep(retryInterval)
410-// }
411-
412-// return fmt.Errorf("deployment not ready after %d attempts", maxRetries)
413-// }
414-
415-// After successfully deploying a container, add a proxy route
416-func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targetDomain string) error {
417- // Create a new proxy instance
418- p, err := proxy.NewProxy(a)
419- if err != nil {
420- return fmt.Errorf("failed to create proxy: %w", err)
421- }
422-
423- // Double check that we have a valid container IP - critical for recreated containers
424- if containerIP == "" || containerIP == containerID || containerIP == "localhost" {
425- logger.Warn("Invalid container IP detected, attempting to get correct IP",
426- "containerID", containerID,
427- "invalid_ip", containerIP)
428-
429- // Try to get the container name for better logging
430- containerName, err := docker.GetContainerName(containerID)
431- if err != nil {
432- containerName = containerID
433- } else {
434- containerName = strings.TrimPrefix(containerName, "/")
435- }
436-
437- // Get the correct container IP with retry logic
438- newContainerIP := GetContainerIP(a, containerID, containerName)
439- if newContainerIP != containerIP && newContainerIP != containerName {
440- logger.Info("Updated container IP for proxy route",
441- "containerID", containerID,
442- "old_ip", containerIP,
443- "new_ip", newContainerIP)
444- containerIP = newContainerIP
445- }
446- }
447-
448- // Get container info to check labels
449- containerInfo, err := docker.GetContainerInfo(containerID)
450- if err != nil {
451- logger.Warn("Failed to get container info", "error", err)
452- } else {
453- // Check if the container has a gordon.proxy.port label that might override containerPort
454- if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" {
455- if portLabel != containerPort {
456- logger.Info("Using container label port instead of provided port",
457- "label_port", portLabel,
458- "provided_port", containerPort)
459- containerPort = portLabel
460- }
461- }
462- }
463-
464- // Extract the protocol from the target domain
465- protocol := "http"
466-
467- // Check if the target domain specifies HTTPS
468- if strings.HasPrefix(strings.ToLower(targetDomain), "https://") {
469- protocol = "https"
470- logger.Debug("Using HTTPS protocol for proxy route based on target domain",
471- "domain", targetDomain,
472- "protocol", protocol)
473- } else {
474- // If target domain doesn't specify protocol, check the container labels
475- if err == nil { // Only check if we have containerInfo
476- // Check if gordon.proxy.ssl label is set to true
477- if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists &&
478- (sslValue == "true" || sslValue == "1" || sslValue == "yes") {
479- protocol = "https"
480- logger.Debug("Using HTTPS protocol for proxy route based on container labels",
481- "domain", targetDomain,
482- "containerID", containerID,
483- "label_value", sslValue,
484- "protocol", protocol)
485- }
486- }
487- }
488-
489- // Clean domain if it includes protocol
490- cleanDomain := targetDomain
491- if strings.Contains(cleanDomain, "://") {
492- cleanDomain = strings.Split(cleanDomain, "://")[1]
493- }
494-
495- // Remove any leading dot that might have been added
496- cleanDomain = strings.TrimPrefix(cleanDomain, ".")
497-
498- // Log that we're adding the domain for Let's Encrypt
499- logger.Debug("Adding domain for Let's Encrypt verification",
500- "domain", cleanDomain)
501-
502- // Get container name for better logging
503- containerName, err := docker.GetContainerName(containerID)
504- if err != nil {
505- logger.Warn("Unable to get container name", "containerID", containerID, "error", err)
506- } else {
507- containerName = strings.TrimPrefix(containerName, "/")
508- logger.Debug("Container name for proxy route", "name", containerName)
509- }
510-
511- // Check the database for any existing routes with this domain, regardless of container ID
512- // This handles the case where a container is recreated with the same name but a different ID
513- tx, err := a.GetDB().Begin()
514- if err != nil {
515- return fmt.Errorf("failed to begin transaction: %w", err)
516- }
517- defer tx.Rollback()
518-
519- var existingRouteID string
520- var existingContainerID string
521- err = tx.QueryRow(`
522- SELECT pr.id, pr.container_id
523- FROM proxy_route pr
524- JOIN domain d ON pr.domain_id = d.id
525- WHERE d.name = ?`, cleanDomain).Scan(&existingRouteID, &existingContainerID)
526-
527- if err == nil {
528- // Domain exists in database, check if this is a recreated container
529- logger.Info("Found existing proxy route for domain",
530- "domain", cleanDomain,
531- "old_container_id", existingContainerID,
532- "new_container_id", containerID)
533-
534- // Verify container IP one last time before updating the route
535- containerNetworkInfo, err := docker.GetContainerInfo(containerID)
536- if err == nil && containerNetworkInfo.NetworkSettings != nil {
537- // Check the container's network settings for the configured network
538- networkName := a.Config.ContainerEngine.Network
539- if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists &&
540- networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP {
541- logger.Info("Updating container IP before database update",
542- "domain", cleanDomain,
543- "old_ip", containerIP,
544- "new_ip", networkSettings.IPAddress)
545- containerIP = networkSettings.IPAddress
546- }
547- }
548-
549- // If the container IDs are different, this is likely a recreated container
550- // Update the route directly in the database
551- now := time.Now().Format(time.RFC3339)
552- _, err = tx.Exec(`
553- UPDATE proxy_route
554- SET container_id = ?, container_ip = ?, container_port = ?, updated_at = ?
555- WHERE id = ?`,
556- containerID, containerIP, containerPort, now, existingRouteID)
557-
558- if err != nil {
559- return fmt.Errorf("failed to update proxy route: %w", err)
560- }
561-
562- logger.Info("Updated proxy route for recreated container",
563- "domain", cleanDomain,
564- "old_container_id", existingContainerID,
565- "new_container_id", containerID,
566- "container_ip", containerIP,
567- "container_port", containerPort)
568-
569- // Commit transaction
570- if err := tx.Commit(); err != nil {
571- return fmt.Errorf("failed to commit transaction: %w", err)
572- }
573-
574- // Verify the database update with a direct query
575- var updatedContainerIP string
576- err = a.GetDB().QueryRow(p.Queries.GetRouteIPByID, existingRouteID).Scan(&updatedContainerIP)
577-
578- if err == nil && updatedContainerIP != containerIP {
579- logger.Warn("Database verification failed: container IP mismatch",
580- "expected", containerIP,
581- "actual", updatedContainerIP)
582- } else {
583- logger.Debug("Database verification passed: container IP updated correctly",
584- "container_ip", containerIP)
585- }
586-
587- // Reload the routes
588- if err := p.Reload(); err != nil {
589- return fmt.Errorf("failed to reload routes: %w", err)
590- }
591-
592- // Final verification: check that the route was loaded with the correct IP
593- routes := p.GetRoutes()
594- if route, exists := routes[cleanDomain]; exists {
595- if route.ContainerIP != containerIP {
596- logger.Warn("Route reload verification failed: container IP mismatch",
597- "domain", cleanDomain,
598- "expected", containerIP,
599- "actual", route.ContainerIP)
600- } else {
601- logger.Debug("Route reload verification passed: container IP loaded correctly",
602- "domain", cleanDomain,
603- "container_ip", containerIP)
604- }
605- }
606-
607- return nil
608- } else if err != sql.ErrNoRows {
609- // A database error other than "not found"
610- return fmt.Errorf("failed to query database for existing route: %w", err)
611- }
612-
613- // Route doesn't exist or we had ErrNoRows, continue with normal AddRoute
614- // Add the route - this will also add to the domain database for Let's Encrypt
615- if err := p.AddRoute(cleanDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil {
616- return fmt.Errorf("failed to add proxy route: %w", err)
617- }
618-
619- // Register this container to prevent immediate recreation logic
620- // and avoid database locking conflicts
621- p.RegisterNewlyCreatedContainer(containerID)
622-
623- logger.Debug("Added proxy route",
624- "domain", targetDomain,
625- "containerIP", containerIP,
626- "containerPort", containerPort,
627- "protocol", protocol,
628- )
629- return nil
630-}
D internal/httpserve/handlers/endpoint_deploy_check.go +0 -75
@@ -1,75 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "net/http"
6- "strings"
7-
8- "github.com/bnema/gordon/internal/server"
9- "github.com/bnema/gordon/pkg/docker"
10- "github.com/charmbracelet/log"
11- "github.com/labstack/echo/v4"
12-)
13-
14-func CheckDeployConflict(c echo.Context, a *server.App) error {
15- targetDomain := c.QueryParam("domain")
16- if targetDomain == "" {
17- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
18- Success: false,
19- Message: "Target domain is required",
20- StatusCode: http.StatusBadRequest,
21- })
22- }
23-
24- // Extract container name from the target domain
25- cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://")
26- containerName := strings.Split(cleanDomain, ".")[0]
27-
28- if containerName == "" {
29- return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{
30- Success: false,
31- Message: "Invalid target domain format",
32- StatusCode: http.StatusBadRequest,
33- })
34- }
35-
36- // Check if container exists
37- containerID := docker.GetContainerIDByName(containerName)
38- if containerID == "" {
39- return sendJSONResponse(c, http.StatusOK, DeployResponse{
40- Success: true,
41- Message: "No conflicts found",
42- StatusCode: http.StatusOK,
43- })
44- }
45-
46- // Get container details
47- state, err := docker.GetContainerState(containerID)
48- if err != nil {
49- log.Error("Failed to get container state", "error", err)
50- state = "unknown"
51- }
52-
53- ports, err := docker.GetContainerPorts(containerID)
54- if err != nil {
55- log.Error("Failed to get container ports", "error", err)
56- ports = "unknown"
57- }
58-
59- uptime, err := docker.GetContainerUptime(containerID)
60- if err != nil {
61- log.Error("Failed to get container uptime", "error", err)
62- uptime = "unknown"
63- }
64-
65- return sendJSONResponse(c, http.StatusConflict, DeployResponse{
66- Success: false,
67- Message: fmt.Sprintf("Container with name '%s' already exists", containerName),
68- ContainerID: containerID,
69- ContainerName: containerName,
70- State: state,
71- RunningTime: uptime,
72- Ports: ports,
73- StatusCode: http.StatusConflict,
74- })
75-}
D internal/httpserve/handlers/endpoint_helpers.go +0 -560
@@ -1,560 +0,0 @@
1-// endpoint_helpers.go
2-package handlers
3-
4-import (
5- "bytes"
6- "errors"
7- "fmt"
8- "io"
9- "net/http"
10- "os"
11- "regexp"
12- "strings"
13- "sync"
14- "time"
15-
16- "github.com/bnema/gordon/internal/common"
17- "github.com/bnema/gordon/internal/server"
18- "github.com/bnema/gordon/internal/templating/cmdparams"
19- "github.com/bnema/gordon/pkg/docker"
20- "github.com/bnema/gordon/pkg/filestore"
21- "github.com/bnema/gordon/pkg/logger"
22- "github.com/labstack/echo/v4"
23-)
24-
25-type ChunkMetadata common.ChunkMetadata
26-
27-type ChunkStore struct {
28- mu sync.Mutex
29- chunks map[string]map[int][]byte
30- metadata map[string]*ChunkMetadata
31- started map[string]time.Time
32-}
33-
34-type InfoResponse struct {
35- Uptime string `json:"uptime"`
36- Version string `json:"version"`
37-}
38-
39-type DeployResponse = common.DeployResponse
40-type PushResponse = common.PushResponse
41-type StopResponse = common.StopResponse
42-type RemoveResponse = common.RemoveResponse
43-
44-var chunkStore = &ChunkStore{
45- chunks: make(map[string]map[int][]byte),
46- metadata: make(map[string]*ChunkMetadata),
47- started: make(map[string]time.Time),
48-}
49-
50-const (
51- cleanupInterval = 5 * time.Minute
52- transferTimeout = 30 * time.Minute
53-)
54-
55-func init() {
56- // Start a cleanup goroutine
57- go func() {
58- ticker := time.NewTicker(5 * time.Minute)
59- for range ticker.C {
60- cleanupOldTransfers()
61- }
62- }()
63-}
64-
65-// Populate fills the InfoResponse struct with data from the App
66-func (info *InfoResponse) Populate(a *server.App) {
67- info.Uptime = a.GetUptime()
68- info.Version = a.GetVersionstring()
69-}
70-
71-func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error {
72- err := c.JSON(statusCode, response)
73- if err != nil {
74- logger.Error("Failed to send JSON response",
75- "error", err,
76- "statusCode", statusCode)
77- }
78- return err
79-}
80-
81-// GetInfos handles GET requests on /api/ping endpoint
82-func GetInfos(c echo.Context, a *server.App) error {
83- payload := new(common.RequestPayload)
84- if err := c.Bind(payload); err != nil {
85- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
86- }
87-
88- if payload.Type != "ping" {
89- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
90- }
91-
92- pingPayload, ok := payload.Payload.(common.PingPayload)
93- if !ok {
94- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
95- }
96-
97- if pingPayload.Message != "ping" {
98- return sendJSONResponse(c, http.StatusBadRequest, "Invalid ping message")
99- }
100-
101- info := &InfoResponse{}
102- info.Populate(a)
103-
104- return sendJSONResponse(c, http.StatusOK, info)
105-}
106-
107-// validateAndPrepareDeployPayload validates and prepares the deploy payload
108-func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) {
109- payload := &common.DeployPayload{
110- Port: c.Request().Header.Get("X-Ports"),
111- ImageName: c.Request().Header.Get("X-Image-Name"),
112- TargetDomain: c.Request().Header.Get("X-Target-Domain"),
113- SkipProxySetup: c.Request().Header.Get("X-Skip-Proxy-Setup") == "true",
114- }
115-
116- if payload.ImageName == "" {
117- return nil, errors.New("invalid image name")
118- }
119-
120- payload.Port = normalizePort(payload.Port)
121- payload.TargetDomain = normalizeTargetDomain(payload.TargetDomain)
122-
123- return payload, nil
124-}
125-
126-// ssaveAndImportImage saves and imports an image from the request body
127-func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (string, error) {
128- var imageName, imageID string
129-
130- // Determine payload type and extract image name
131- switch p := payload.(type) {
132- case *common.PushPayload:
133- imageName = p.ImageName
134- defer func() { p.ImageID = imageID }()
135- case *common.DeployPayload:
136- imageName = p.ImageName
137- defer func() { p.ImageID = imageID }()
138- default:
139- return "", fmt.Errorf("unsupported payload type: %T", payload)
140- }
141-
142- imageReader := c.Request().Body
143- defer imageReader.Close()
144-
145- imageFileName := sanitizeImageFileName(imageName)
146-
147- imagePath, err := filestore.SaveImageToStorage(&a.Config, imageFileName, imageReader)
148- if err != nil {
149- return "", fmt.Errorf("failed to save image: %v", err)
150- }
151-
152- imageID, err = docker.ImportImageToEngine(imagePath)
153- if err != nil {
154- if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {
155- logger.Error("Failed to remove temporary image file after import failure",
156- "error", removeErr,
157- "path", imagePath)
158- }
159- return "", fmt.Errorf("failed to import image: %v", err)
160- }
161-
162- if imageID == "" {
163- if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil {
164- logger.Error("Failed to remove temporary image file after empty ID",
165- "error", removeErr,
166- "path", imagePath)
167- }
168- return "", errors.New("imported image ID is empty")
169- }
170-
171- err = filestore.RemoveFromStorage(imagePath)
172- if err != nil {
173- logger.Error("Failed to remove temporary image file after successful import",
174- "error", err,
175- "path", imagePath)
176- return "", fmt.Errorf("failed to remove temporary image file: %v", err)
177- }
178-
179- return imagePath, nil
180-}
181-
182-// createAndStartContainer creates a container from the image, starts it and returns the container ID
183-func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) {
184- logger.Info("Starting container creation process",
185- "imageName", payload.ImageName,
186- "imageID", payload.ImageID,
187- "port", payload.Port)
188-
189- params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID)
190- if err != nil {
191- return "", "", fmt.Errorf("failed to create command parameters: %v", err)
192- }
193-
194- logger.Debug("Container parameters prepared", "params", params)
195-
196- containerID, err := docker.CreateContainer(params)
197- if err != nil {
198- return "", "", fmt.Errorf("failed to create container: %v", err)
199- }
200-
201- logger.Info("Container created successfully", "containerID", containerID)
202-
203- containerName, err := docker.GetContainerName(containerID)
204- if err != nil {
205- return "", "", fmt.Errorf("failed to get container name: %v", err)
206- }
207-
208- logger.Info("Starting container",
209- "containerID", containerID,
210- "name", containerName)
211-
212- // Use regular StartContainer instead of StartContainerWithContext
213- err = docker.StartContainer(containerID)
214- if err != nil {
215- // Get container logs if start failed
216- logs, logErr := docker.GetContainerLogs(containerID)
217- if logErr == nil {
218- logger.Error("Container start failed - container logs",
219- "logs", logs,
220- "containerID", containerID)
221- }
222-
223- // Cleanup the failed container
224- cleanupErr := docker.RemoveContainer(containerID)
225- if cleanupErr != nil {
226- logger.Error("Failed to cleanup container after start failure",
227- "containerID", containerID,
228- "error", cleanupErr)
229- }
230- return "", "", fmt.Errorf("failed to start container: %v", err)
231- }
232-
233- logger.Info("Container started successfully",
234- "containerID", containerID,
235- "name", containerName)
236-
237- return containerID, containerName, nil
238-}
239-
240-// Helpers for sanitizing and normalizing input
241-func sanitizeImageFileName(imageName string) string {
242- imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "")
243- imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "")
244- return imageName + ".tar"
245-}
246-
247-// normalizePort ensures that the port is in the format of "port/protocol"
248-func normalizePort(port string) string {
249- if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) {
250- return port + "/tcp"
251- }
252- return port
253-}
254-
255-// normalizeTargetDomain ensures that the target domain is in the format of "https://domain" or "http://domain"
256-func normalizeTargetDomain(domain string) string {
257- // First, remove any leading dot from the domain
258- domain = strings.TrimPrefix(domain, ".")
259-
260- // If domain contains a dot prefix in the middle (like "example.com/.domain.tld"), fix it
261- domain = strings.ReplaceAll(domain, "/.", "/")
262-
263- // Then check if we need to add the protocol
264- if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) {
265- return "https://" + domain
266- }
267- return domain
268-}
269-
270-// generateShortID generates a short ID from the full image ID
271-func generateShortID(imageID string) string {
272- shortID := imageID[7:]
273- if len(shortID) > 12 {
274- shortID = shortID[:12]
275- }
276- return shortID
277-}
278-
279-// generateCreateContainerURL generates the URL for creating a container
280-func generateCreateContainerURL(a *server.App, shortID string) string {
281- return fmt.Sprintf("%s://%s%s/cc/%s",
282- a.Config.Http.Protocol(),
283- a.Config.Http.FullDomain(),
284- a.Config.Admin.Path,
285- shortID)
286-}
287-
288-// generateContainerLogsURL generates the URL for viewing container logs
289-func isTransferComplete(transferID string) bool {
290- chunkStore.mu.Lock()
291- defer chunkStore.mu.Unlock()
292-
293- metadata := chunkStore.metadata[transferID]
294- if metadata == nil {
295- logger.Warn("No metadata found for transfer", "transferID", transferID)
296- return false
297- }
298-
299- chunksReceived := len(chunkStore.chunks[transferID])
300- isComplete := chunksReceived == metadata.TotalChunks
301-
302- logger.Debug("Transfer completion check",
303- "transferID", transferID,
304- "chunksReceived", chunksReceived,
305- "totalExpected", metadata.TotalChunks,
306- "isComplete", isComplete)
307-
308- return isComplete
309-}
310-
311-// Cleanup helpers for old transfers
312-func cleanupTransfer(transferID string) {
313- chunkStore.mu.Lock()
314- defer chunkStore.mu.Unlock()
315-
316- delete(chunkStore.chunks, transferID)
317- delete(chunkStore.metadata, transferID)
318- delete(chunkStore.started, transferID)
319-}
320-
321-// cleanupOldTransfers periodically checks for and cleans up old transfers
322-func cleanupOldTransfers() {
323- ticker := time.NewTicker(cleanupInterval)
324- defer ticker.Stop() // Good practice to clean up the ticker
325-
326- for range ticker.C {
327- now := time.Now()
328- chunkStore.mu.Lock()
329- for id, startTime := range chunkStore.started {
330- if now.Sub(startTime) > transferTimeout {
331- logger.Debug("Cleaning up stale transfer",
332- "transferID", id,
333- "age", now.Sub(startTime))
334- cleanupTransfer(id)
335- }
336- }
337- chunkStore.mu.Unlock()
338- }
339-}
340-
341-// extractContainerID extracts the container ID from an error message
342-func extractContainerID(errorMessage string) string {
343- re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`)
344- match := re.FindStringSubmatch(errorMessage)
345- if len(match) > 1 {
346- return match[1]
347- }
348- return ""
349-}
350-
351-// storeIDMapping stores a mapping between a short ID and a full ID
352-func storeIDMapping(shortID, fullID string) {
353- safelyInteractWithIDMap(Update, shortID, fullID)
354-}
355-
356-func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) error {
357- // Validate input parameters
358- if file == nil {
359- return errors.New("nil file handle provided")
360- }
361- if len(chunks) == 0 {
362- return errors.New("no chunks provided")
363- }
364- if totalChunks <= 0 {
365- return fmt.Errorf("invalid total chunks count: %d", totalChunks)
366- }
367-
368- // Verify we have all chunks before starting
369- if len(chunks) != totalChunks {
370- return fmt.Errorf("chunk count mismatch: got %d, expected %d", len(chunks), totalChunks)
371- }
372-
373- // Calculate total expected size
374- var totalSize int64
375- for _, chunk := range chunks {
376- totalSize += int64(len(chunk))
377- }
378-
379- logger.Debug("Starting chunk assembly",
380- "totalChunks", totalChunks,
381- "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024)))
382-
383- // First, seek to the beginning of the file
384- if _, err := file.Seek(0, 0); err != nil {
385- return fmt.Errorf("failed to seek to beginning: %w", err)
386- }
387-
388- // Create a temporary buffer with capacity hint
389- tempBuffer := bytes.NewBuffer(make([]byte, 0, totalSize))
390-
391- // Write chunks in order with validation
392- for i := 0; i < totalChunks; i++ {
393- chunk, exists := chunks[i]
394- if !exists {
395- return fmt.Errorf("missing chunk %d", i)
396- }
397-
398- if len(chunk) == 0 {
399- return fmt.Errorf("empty chunk found at index %d", i)
400- }
401-
402- n, err := tempBuffer.Write(chunk)
403- if err != nil {
404- return fmt.Errorf("failed to write chunk %d to buffer: %w", i, err)
405- }
406- if n != len(chunk) {
407- return fmt.Errorf("incomplete chunk write: wrote %d of %d bytes for chunk %d",
408- n, len(chunk), i)
409- }
410- }
411-
412- // Verify buffer size matches expected total
413- if int64(tempBuffer.Len()) != totalSize {
414- return fmt.Errorf("buffer size mismatch: got %d, expected %d",
415- tempBuffer.Len(), totalSize)
416- }
417-
418- // Write the complete buffer to file
419- written, err := io.Copy(file, tempBuffer)
420- if err != nil {
421- return fmt.Errorf("failed to write buffer to file: %w", err)
422- }
423- if written != totalSize {
424- return fmt.Errorf("file write size mismatch: wrote %d, expected %d",
425- written, totalSize)
426- }
427-
428- // Ensure all data is written to disk
429- if err := file.Sync(); err != nil {
430- return fmt.Errorf("failed to sync file: %w", err)
431- }
432-
433- // Reset file pointer to beginning
434- if _, err := file.Seek(0, 0); err != nil {
435- return fmt.Errorf("failed to reset file pointer: %w", err)
436- }
437-
438- logger.Debug("Chunk assembly completed successfully",
439- "totalBytesWritten", written,
440- "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024)))
441-
442- return nil
443-}
444-
445-func getTransferData(transferID string) (*ChunkMetadata, map[int][]byte, error) {
446- chunkStore.mu.Lock()
447- defer chunkStore.mu.Unlock()
448-
449- metadata := chunkStore.metadata[transferID]
450- if metadata == nil {
451- return nil, nil, fmt.Errorf("transfer metadata not found")
452- }
453- return metadata, chunkStore.chunks[transferID], nil
454-}
455-
456-// GetContainerIP gets the IP address of a container within its Docker network
457-func GetContainerIP(a *server.App, containerID, containerName string) string {
458- // Add retry logic with exponential backoff to ensure container network is ready
459- maxRetries := 3
460- var containerIP string
461- var lastErr error
462-
463- for retry := 0; retry < maxRetries; retry++ {
464- // If this is a retry, add an increasing delay
465- if retry > 0 {
466- sleepTime := time.Duration(500*retry) * time.Millisecond
467- logger.Debug("Retrying to get container IP",
468- "attempt", retry+1,
469- "containerID", containerID,
470- "delay", sleepTime)
471- time.Sleep(sleepTime)
472- }
473-
474- // Try to get container info
475- containerInfo, err := docker.GetContainerInfo(containerID)
476- if err != nil {
477- lastErr = err
478- continue // Try again
479- }
480-
481- // Make sure we have the most up-to-date container info by refreshing if needed
482- // This is crucial for recreated containers
483- if containerInfo.State == nil || !containerInfo.State.Running {
484- // Container might still be starting, wait a bit longer
485- time.Sleep(500 * time.Millisecond)
486- refreshedInfo, err := docker.GetContainerInfo(containerID)
487- if err == nil {
488- containerInfo = refreshedInfo
489- logger.Debug("Refreshed container info for IP retrieval",
490- "containerName", containerName,
491- "containerID", containerID)
492- } else {
493- lastErr = err
494- continue // Try again
495- }
496- }
497-
498- // Extract IP address from container info
499- if containerInfo.NetworkSettings != nil {
500- // Look for the network specified in the config
501- networkName := a.Config.ContainerEngine.Network
502- if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" {
503- containerIP = networkSettings.IPAddress
504- logger.Debug("Found container IP",
505- "containerName", containerName,
506- "containerID", containerID,
507- "containerIP", containerIP,
508- "network", networkName)
509- return containerIP
510- }
511-
512- // Look for any network if the specified one isn't found
513- for networkName, networkSettings := range containerInfo.NetworkSettings.Networks {
514- if networkSettings.IPAddress != "" {
515- containerIP = networkSettings.IPAddress
516- logger.Debug("Found container IP in alternative network",
517- "containerName", containerName,
518- "containerID", containerID,
519- "containerIP", containerIP,
520- "network", networkName)
521- return containerIP
522- }
523- }
524- }
525-
526- // If we've gotten this far, let's try inspecting the network directly
527- networkName := a.Config.ContainerEngine.Network
528- networkInfo, err := docker.GetNetworkInfo(networkName)
529- if err == nil {
530- // Search for this container in the network
531- for contID, endpoint := range networkInfo.Containers {
532- if contID == containerID && endpoint.IPv4Address != "" {
533- // Extract just the IP address from the CIDR format
534- ipCIDR := endpoint.IPv4Address
535- containerIP = strings.Split(ipCIDR, "/")[0]
536- logger.Debug("Found container IP via network inspection",
537- "containerName", containerName,
538- "containerID", containerID,
539- "containerIP", containerIP,
540- "network", networkName)
541- return containerIP
542- }
543- }
544- }
545- }
546-
547- // If we've tried multiple times and couldn't get an IP
548- if lastErr != nil {
549- logger.Warn("Failed to get container info after multiple attempts",
550- "error", lastErr,
551- "containerID", containerID)
552- }
553-
554- logger.Warn("Could not find container IP, using container name as fallback",
555- "containerName", containerName,
556- "containerID", containerID)
557-
558- // Use container name as a fallback
559- return containerName
560-}
D internal/httpserve/handlers/endpoint_push.go +0 -249
@@ -1,249 +0,0 @@
1-// endpoint_push.go
2-package handlers
3-
4-import (
5- "encoding/json"
6- "errors"
7- "fmt"
8- "io"
9- "net/http"
10- "os"
11- "path/filepath"
12- "strings"
13-
14- "github.com/bnema/gordon/internal/common"
15- "github.com/bnema/gordon/internal/server"
16- "github.com/bnema/gordon/pkg/docker"
17- "github.com/charmbracelet/log"
18- "github.com/labstack/echo/v4"
19-)
20-
21-// PostPush handles the image push request
22-func PostPush(c echo.Context, a *server.App) error {
23- requestID := c.Response().Header().Get(echo.HeaderXRequestID)
24- log.Info("PostPush started",
25- "requestID", requestID,
26- "path", c.Request().URL.Path)
27- payload, err := validateAndPreparePushPayload(c)
28- if err != nil {
29- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
30- Success: false,
31- Message: err.Error(),
32- })
33- }
34-
35- _, err = saveAndImportImage(c, a, payload)
36- if err != nil {
37- return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
38- Success: false,
39- Message: err.Error(),
40- })
41- }
42-
43- shortID := generateShortID(payload.ImageID)
44- storeIDMapping(shortID, payload.ImageID)
45-
46- createContainerURL := generateCreateContainerURL(a, shortID)
47-
48- return sendJSONResponse(c, http.StatusOK, common.PushResponse{
49- Success: true,
50- Message: "Image pushed and imported successfully",
51- CreateContainerURL: createContainerURL,
52- })
53-}
54-
55-// PostPushChunked handles chunked (large) image pushes
56-func PostPushChunked(c echo.Context, a *server.App) error {
57- return handleChunkedPush(c, a, c.Request().Header.Get("X-Chunk-Metadata"))
58-}
59-
60-func handleChunkedPush(c echo.Context, a *server.App, metadataStr string) error {
61- var metadata ChunkMetadata
62- if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil {
63- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
64- Success: false,
65- Message: "Invalid chunk metadata",
66- })
67- }
68-
69- // Initialize chunk storage first
70- chunkStore.mu.Lock()
71- if chunkStore.chunks[metadata.TransferID] == nil {
72- chunkStore.chunks[metadata.TransferID] = make(map[int][]byte)
73- chunkStore.metadata[metadata.TransferID] = &metadata
74- }
75- chunkStore.mu.Unlock()
76-
77- chunkData, err := io.ReadAll(c.Request().Body)
78- if err != nil {
79- return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
80- Success: false,
81- Message: "Failed to read chunk data",
82- })
83- }
84-
85- // Validate chunk data
86- if err := validatePushChunk(chunkData, &metadata); err != nil {
87- log.Error("Chunk validation failed",
88- "error", err,
89- "chunkNumber", metadata.ChunkNumber,
90- "transferID", metadata.TransferID)
91- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
92- Success: false,
93- Message: fmt.Sprintf("Invalid chunk: %v", err),
94- })
95- }
96-
97- // Store chunk data
98- chunkStore.mu.Lock()
99- chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData
100- chunkStore.mu.Unlock()
101-
102- // Process complete transfer if this was the last chunk
103- if isTransferComplete(metadata.TransferID) {
104- return processCompleteChunkedPushTransfer(c, a, metadata.TransferID)
105- }
106-
107- // Acknowledge intermediate chunk
108- return sendJSONResponse(c, http.StatusOK, PushResponse{
109- Success: true,
110- Message: fmt.Sprintf("Chunk %d/%d received successfully",
111- metadata.ChunkNumber+1, metadata.TotalChunks),
112- })
113-}
114-
115-func processCompleteChunkedPushTransfer(c echo.Context, a *server.App, transferID string) error {
116- log.Info("Starting complete push transfer processing",
117- "transferID", transferID)
118-
119- // Get transfer data
120- metadata, chunks, err := getTransferData(transferID)
121- if err != nil {
122- return sendPushErrorResponse(c, "Failed to get transfer data", err)
123- }
124-
125- // Validate we have all chunks
126- if len(chunks) != metadata.TotalChunks {
127- return sendPushErrorResponse(c, "Incomplete transfer",
128- fmt.Errorf("expected %d chunks, got %d", metadata.TotalChunks, len(chunks)))
129- }
130-
131- // Create temporary directory with specific naming
132- tmpDir, err := os.MkdirTemp("", "docker-push-*")
133- if err != nil {
134- return sendPushErrorResponse(c, "Failed to create temp directory", err)
135- }
136- defer os.RemoveAll(tmpDir)
137-
138- // Create temporary file with .tar extension
139- tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar"))
140- if err != nil {
141- return sendPushErrorResponse(c, "Failed to create temp file", err)
142- }
143- defer tmpFile.Close()
144-
145- // Write chunks in order to ensure proper tar assembly
146- for i := 0; i < metadata.TotalChunks; i++ {
147- chunk, exists := chunks[i]
148- if !exists {
149- return sendPushErrorResponse(c, "Missing chunk",
150- fmt.Errorf("chunk %d not found", i))
151- }
152- if _, err := tmpFile.Write(chunk); err != nil {
153- return sendPushErrorResponse(c, "Failed to write chunk",
154- fmt.Errorf("error writing chunk %d: %v", i, err))
155- }
156- }
157-
158- // Ensure all data is written to disk
159- if err := tmpFile.Sync(); err != nil {
160- return sendPushErrorResponse(c, "Failed to sync file", err)
161- }
162-
163- // Reset file pointer to beginning
164- if _, err := tmpFile.Seek(0, 0); err != nil {
165- return sendPushErrorResponse(c, "Failed to reset file pointer", err)
166- }
167-
168- // Verify file size
169- fileInfo, err := tmpFile.Stat()
170- if err != nil {
171- return sendPushErrorResponse(c, "Failed to get file info", err)
172- }
173-
174- log.Info("Image file created",
175- "size", fmt.Sprintf("%.2f MB", float64(fileInfo.Size())/(1024*1024)),
176- "path", tmpFile.Name())
177-
178- imageName := c.Request().Header.Get("X-Image-Name")
179- if imageName == "" {
180- return sendPushErrorResponse(c, "Missing image name", fmt.Errorf("X-Image-Name header is required"))
181- }
182-
183- log.Info("Importing image",
184- "path", tmpFile.Name(),
185- "imageName", imageName)
186-
187- // Import the image
188- imageID, err := docker.ImportImageToEngine(tmpFile.Name())
189- if err != nil {
190- if strings.Contains(err.Error(), "platform compatibility check failed") {
191- return sendJSONResponse(c, http.StatusBadRequest, PushResponse{
192- Success: false,
193- Message: fmt.Sprintf("Image platform not compatible with server: %v", err),
194- })
195- }
196- return sendPushErrorResponse(c, "Failed to import image", err)
197- }
198-
199- // Cleanup and prepare response
200- cleanupTransfer(transferID)
201- shortID := generateShortID(imageID)
202- storeIDMapping(shortID, imageID)
203- createContainerURL := generateCreateContainerURL(a, shortID)
204-
205- return sendJSONResponse(c, http.StatusOK, PushResponse{
206- Success: true,
207- Message: "Image pushed and imported successfully",
208- CreateContainerURL: createContainerURL,
209- ImageID: imageID,
210- })
211-}
212-
213-func validatePushChunk(chunk []byte, metadata *ChunkMetadata) error {
214- if len(chunk) == 0 {
215- return errors.New("empty chunk received")
216- }
217-
218- if metadata == nil {
219- return errors.New("missing chunk metadata")
220- }
221-
222- if int64(len(chunk)) != metadata.ChunkSize {
223- return fmt.Errorf("chunk size mismatch: expected %d, got %d",
224- metadata.ChunkSize, len(chunk))
225- }
226-
227- return nil
228-}
229-
230-func sendPushErrorResponse(c echo.Context, message string, err error) error {
231- log.Error(message, "error", err)
232- return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{
233- Success: false,
234- Message: message,
235- })
236-}
237-
238-// validateAndPreparePushPayload validates and prepares the push payload
239-func validateAndPreparePushPayload(c echo.Context) (*common.PushPayload, error) {
240- payload := &common.PushPayload{
241- ImageName: c.Request().Header.Get("X-Image-Name"),
242- }
243-
244- if payload.ImageName == "" {
245- return nil, errors.New("invalid image name")
246- }
247-
248- return payload, nil
249-}
D internal/httpserve/handlers/endpoint_remove.go +0 -44
@@ -1,44 +0,0 @@
1-package handlers
2-
3-import (
4- "net/http"
5-
6- "github.com/bnema/gordon/internal/common"
7- "github.com/bnema/gordon/internal/server"
8- "github.com/bnema/gordon/pkg/docker"
9- "github.com/labstack/echo/v4"
10-)
11-
12-// PostContainerRemove handles the container remove request
13-func PostContainerRemove(c echo.Context, a *server.App) error {
14- payload := new(common.RequestPayload)
15- if err := c.Bind(payload); err != nil {
16- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
17- }
18-
19- if payload.Type != "remove" {
20- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
21- }
22-
23- removePayload, ok := payload.Payload.(common.RemovePayload)
24- if !ok {
25- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
26- }
27-
28- if removePayload.ContainerID == "" {
29- return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
30- }
31-
32- err := docker.RemoveContainer(removePayload.ContainerID)
33- if err != nil {
34- return sendJSONResponse(c, http.StatusInternalServerError, RemoveResponse{
35- Success: false,
36- Message: err.Error(),
37- })
38- }
39-
40- return sendJSONResponse(c, http.StatusOK, RemoveResponse{
41- Success: true,
42- Message: "Container removed successfully",
43- })
44-}
D internal/httpserve/handlers/endpoint_stop.go +0 -44
@@ -1,44 +0,0 @@
1-package handlers
2-
3-import (
4- "net/http"
5-
6- "github.com/bnema/gordon/internal/common"
7- "github.com/bnema/gordon/internal/server"
8- "github.com/bnema/gordon/pkg/docker"
9- "github.com/labstack/echo/v4"
10-)
11-
12-// PostContainerStop handles the container stop request
13-func PostContainerStop(c echo.Context, a *server.App) error {
14- payload := new(common.RequestPayload)
15- if err := c.Bind(payload); err != nil {
16- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error())
17- }
18-
19- if payload.Type != "stop" {
20- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type")
21- }
22-
23- stopPayload, ok := payload.Payload.(common.StopPayload)
24- if !ok {
25- return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure")
26- }
27-
28- if stopPayload.ContainerID == "" {
29- return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID")
30- }
31-
32- err := docker.StopContainer(stopPayload.ContainerID)
33- if err != nil {
34- return sendJSONResponse(c, http.StatusInternalServerError, StopResponse{
35- Success: false,
36- Message: err.Error(),
37- })
38- }
39-
40- return sendJSONResponse(c, http.StatusOK, StopResponse{
41- Success: true,
42- Message: "Container stopped successfully",
43- })
44-}
D internal/httpserve/handlers/error_handlers.go +0 -36
@@ -1,36 +0,0 @@
1-package handlers
2-
3-import (
4- "net/http"
5-
6- "github.com/bnema/gordon/internal/server"
7- "github.com/bnema/gordon/internal/templating/models/templ/pages/errors"
8- "github.com/bnema/gordon/internal/templating/render"
9- "github.com/labstack/echo/v4"
10-)
11-
12-// RenderNotFoundPage renders a 404 error page using templ
13-func RenderNotFoundPage(c echo.Context, a *server.App) error {
14- data := errors.ErrorPageData{
15- Title: "Page Not Found | Gordon",
16- AdminPath: a.Config.Admin.Path,
17- StatusCode: http.StatusNotFound,
18- Message: "Sorry, the page you are looking for does not exist.",
19- }
20-
21- renderer := render.NewTemplRenderer(a)
22- return renderer.RenderTempl(c, errors.NotFoundPage(data))
23-}
24-
25-// RenderForbiddenPage renders a 403 error page using templ
26-func RenderForbiddenPage(c echo.Context, a *server.App) error {
27- data := errors.ErrorPageData{
28- Title: "Access Forbidden | Gordon",
29- AdminPath: a.Config.Admin.Path,
30- StatusCode: http.StatusForbidden,
31- Message: "You don't have permission to access this resource.",
32- }
33-
34- renderer := render.NewTemplRenderer(a)
35- return renderer.RenderTempl(c, errors.ForbiddenPage(data))
36-} ⏎̸
D internal/httpserve/handlers/login.go +0 -595
@@ -1,595 +0,0 @@
1-package handlers
2-
3-import (
4- "database/sql"
5- "encoding/base64"
6- "encoding/json"
7- "errors"
8- "fmt"
9- "io"
10- "net/http"
11- "net/url"
12- "strings"
13- "time"
14-
15- "github.com/bnema/gordon/internal/db"
16- "github.com/bnema/gordon/internal/db/queries"
17- "github.com/bnema/gordon/internal/server"
18- "github.com/bnema/gordon/pkg/logger"
19- "github.com/golang-jwt/jwt/v5"
20- "github.com/gorilla/sessions"
21- _ "github.com/joho/godotenv/autoload"
22- "github.com/labstack/echo-contrib/session"
23- "github.com/labstack/echo/v4"
24-)
25-
26-// --- JWT Claims ---
27-type JwtCustomClaims struct {
28- AccountID string `json:"account_id"`
29- jwt.RegisteredClaims
30-}
31-
32-// StartOAuthGithub starts the Github OAuth flow.
33-func StartOAuthGithub(c echo.Context, a *server.App) error {
34- logger.Info("Attempting to get an existing session")
35- sess, err := getSession(c)
36- if err != nil || sess == nil || sess.Values == nil {
37- logger.Info("Session retrieval failed, initiating new GitHub OAuth flow")
38- return initiateGithubOAuthFlow(c, a)
39- }
40-
41- logger.Info("Attempting to assert session values")
42- sessionID, ok1 := sess.Values["sessionID"].(string)
43- accountID, ok2 := sess.Values["accountID"].(string)
44- authenticated, ok3 := sess.Values["authenticated"].(bool)
45-
46- if !ok1 || !ok2 || !ok3 || !authenticated {
47- logger.Info("Session assertion failed, or user not authenticated. Initiating new OAuth flow")
48- return initiateGithubOAuthFlow(c, a)
49- }
50-
51- logger.Info("Checking if session exists in DB")
52- sessionExists, err := queries.CheckDBSessionExists(a.DB, sessionID)
53- if err != nil {
54- logger.Info("Error checking if session exists", "error", err)
55- return fmt.Errorf("could not check if session exists: %w", err)
56- }
57-
58- if !sessionExists {
59- logger.Info("Session does not exist in DB, initiating new OAuth flow")
60- return initiateGithubOAuthFlow(c, a)
61- }
62-
63- logger.Info("Getting the expiration time of the session")
64- currentTime := time.Now()
65- sessionExpiration, err := queries.GetSessionExpiration(a.DB, accountID, sessionID, currentTime)
66- if err != nil {
67- logger.Info("Error getting session expiration", "error", err)
68- return fmt.Errorf("could not get session expiration: %w", err)
69- }
70-
71- if currentTime.After(sessionExpiration) {
72- logger.Info("Current time is after session expiration, initiating new OAuth flow")
73- return initiateGithubOAuthFlow(c, a)
74- }
75-
76- logger.Info("Extending session expiration time by 24 hours")
77- newExpirationTime := currentTime.Add(time.Hour * 24)
78- err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, newExpirationTime)
79- if err != nil {
80- logger.Info("Error extending session expiration", "error", err)
81- return fmt.Errorf("could not extend session expiration: %w", err)
82- }
83-
84- // Explicitly mark the session as active since we've validated it
85- logger.Info("Marking existing session as active", "sessionID", sessionID)
86- err = queries.MarkSessionActive(a.DB, sessionID)
87- if err != nil {
88- logger.Error("Failed to mark session as active", "error", err, "sessionID", sessionID)
89- return fmt.Errorf("failed to mark session active: %w", err)
90- }
91-
92- logger.Info("Session is valid and extended. Redirecting to admin panel")
93- return c.Redirect(http.StatusFound, a.Config.Admin.Path)
94-}
95-
96-// initiateGithubOAuthFlow starts a new GitHub OAuth flow
97-func initiateGithubOAuthFlow(c echo.Context, a *server.App) error {
98- clientID := "" // IDK why it needs that even empty, but it does
99- // Use the corrected generation function
100- redirectDomain := a.GenerateOauthCallbackURL()
101-
102- encodedState := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain))
103-
104- // Redirect to GitHub's OAuth endpoint via the proxy
105- proxyURL := a.Config.Build.ProxyURL
106- oauthURL := fmt.Sprintf(
107- "%s/github/authorize?client_id=%s&redirect_uri=%s&state=%s",
108- proxyURL,
109- clientID,
110- redirectDomain, // Pass the correctly generated public URL to the proxy
111- encodedState,
112- )
113- logger.Debug("Initiating OAuth flow, redirecting to proxy", "proxy_url", oauthURL)
114- return c.Redirect(http.StatusFound, oauthURL)
115-}
116-
117-type Sessions struct {
118- *db.Sessions
119-}
120-
121-// OAuthCallback handles the callback response from Github OAuth
122-func OAuthCallback(c echo.Context, a *server.App) error {
123- logger.Info("Starting OAuth callback handler")
124- redirectPath := a.Config.Admin.Path
125-
126- accessToken, encodedState, err := parseQueryParams(c)
127- if err != nil {
128- logger.Error("Failed to parse query params from OAuth callback", "error", err)
129- return c.String(http.StatusBadRequest, err.Error())
130- }
131- logger.Debug("Parsed access token and state from query params", "token_length", len(accessToken), "encoded_state", encodedState)
132-
133- // update the struct with the new access token
134- a.DBTables.Sessions.AccessToken = accessToken
135-
136- // Compare the state parameter with the correctly generated redirect domain
137- redirectDomain := a.GenerateOauthCallbackURL()
138- encodedRedirectDomain := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain))
139- if encodedState != encodedRedirectDomain {
140- logger.Error("Invalid state parameter", "expected", encodedRedirectDomain, "received", encodedState)
141- return c.String(http.StatusBadRequest, "invalid state parameter")
142- }
143- logger.Debug("State parameter validation successful")
144-
145- sess, err := getSession(c)
146- if err != nil {
147- logger.Error("Failed to get session", "error", err)
148- return err
149- }
150- logger.Debug("Session retrieved successfully")
151-
152- browserInfo := c.Request().UserAgent()
153- userInfo, err := githubGetUserDetails(c)
154- if err != nil {
155- logger.Error("Failed to get GitHub user details", "error", err)
156- return err
157- }
158- logger.Debug("Retrieved GitHub user details", "login", userInfo.Login, "email_count", len(userInfo.Emails))
159-
160- user, accountID, session, err := handleUser(a, accessToken, browserInfo, userInfo)
161- if err != nil {
162- logger.Error("Failed to handle user during OAuth callback", "error", err)
163-
164- // --- MODIFIED: Redirect to login with specific error based on err type ---
165- loginPath := a.Config.Admin.Path + "/login"
166- errorParam := "error=unknown_error" // Default error
167-
168- var httpErr *echo.HTTPError
169- if errors.As(err, &httpErr) {
170- switch httpErr.Code {
171- case http.StatusUnauthorized:
172- // Check the message to differentiate unauthorized reasons
173- if strings.Contains(httpErr.Message.(string), "does not match the allowed email") {
174- errorParam = "error=email_mismatch"
175- } else if strings.Contains(httpErr.Message.(string), "no public email address") {
176- errorParam = "error=no_email"
177- } else {
178- errorParam = "error=unauthorized"
179- }
180- case http.StatusInternalServerError:
181- // Check for specific internal errors if needed, e.g., config issue
182- if strings.Contains(httpErr.Message.(string), "Admin email not set") {
183- errorParam = "error=config_email_missing"
184- } else {
185- errorParam = "error=server_error"
186- }
187- default:
188- errorParam = "error=server_error" // Fallback for other HTTP errors
189- }
190- } else {
191- // Handle non-HTTP errors (like DB connection issues, etc.)
192- errorParam = "error=server_error"
193- }
194-
195- redirectURL := loginPath + "?" + errorParam
196- logger.Debug("Redirecting user to login page with error", "url", redirectURL)
197- return c.Redirect(http.StatusSeeOther, redirectURL)
198- // --- END MODIFICATION ---
199- }
200- logger.Info("User handled successfully", "user_id", user.ID, "account_id", accountID)
201-
202- logger.Debug("Before setting session values", "account_id", accountID, "session", session)
203- err = setSessionValues(c, sess, accountID, session)
204- if err != nil {
205- // Error is already logged within setSessionValues
206- // Redirect to login page with a server error message
207- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=server_error")
208- }
209- // Log success ONLY after setSessionValues returns without error
210- logger.Info("Session values set and saved successfully")
211-
212- logger.Info("OAuth callback successful, redirecting to", "path", redirectPath)
213- return c.Redirect(http.StatusFound, redirectPath)
214-}
215-
216-// parseQueryParams parses the query parameters from the callback URL
217-func parseQueryParams(c echo.Context) (string, string, error) {
218- accessToken := c.QueryParam("access_token")
219- encodedState := c.QueryParam("state")
220-
221- _, err := base64.StdEncoding.DecodeString(encodedState)
222- if err != nil {
223- return "", "", fmt.Errorf("invalid state parameter")
224- }
225-
226- return accessToken, encodedState, nil
227-}
228-
229-// getSession gets the session from the context
230-func getSession(c echo.Context) (*sessions.Session, error) {
231- sess, err := session.Get("session", c)
232- if err != nil {
233- return nil, echo.NewHTTPError(http.StatusInternalServerError, "Could not get session")
234- }
235- return sess, nil
236-}
237-
238-// handleUser handles the user creation or update
239-func handleUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
240- logger.Debug("Starting handleUser function", "github_login", userInfo.Login)
241-
242- // --- Check provider count ---
243- providerCount, err := queries.CountProviders(a.DB)
244- if err != nil {
245- logger.Error("Error checking provider count", "error", err)
246- return nil, "", nil, fmt.Errorf("could not check provider count: %w", err)
247- }
248- logger.Debug("Provider count check result", "count", providerCount)
249-
250- // --- Case 1: First ever GitHub login ---
251- if providerCount == 0 {
252- logger.Info("First GitHub login detected. Linking to seeded admin account.")
253-
254- // --- Link to Seeded Admin ---
255- // No token check needed anymore, proceed directly to linking.
256- seededUser, seededAccountID, session, err := linkProviderToSeededAdmin(a, accessToken, browserInfo, userInfo)
257- if err != nil {
258- logger.Error("Failed to link provider to seeded admin", "error", err)
259- return nil, "", nil, err
260- }
261- logger.Info("Successfully linked GitHub provider to seeded admin account", "user_id", seededUser.ID, "account_id", seededAccountID)
262- return seededUser, seededAccountID, session, nil
263- }
264-
265- // --- Case 2: Subsequent GitHub logins ---
266- if providerCount > 0 {
267- logger.Debug("Existing provider(s) found. Checking if incoming GitHub user matches.")
268-
269- // --- ADDED: Check if incoming GitHub user email matches configured email ---
270- configuredEmail := a.Config.ReverseProxy.Email
271- if configuredEmail == "" {
272- logger.Error("Configuration Error: ReverseProxy.Email is not set in config.yml. Cannot validate user email.")
273- // Prevent login if the email isn't configured, as it's required for validation.
274- return nil, "", nil, echo.NewHTTPError(http.StatusInternalServerError, "Server configuration error: Admin email not set.")
275- }
276-
277- githubEmail := ""
278- if len(userInfo.Emails) > 0 {
279- githubEmail = userInfo.Emails[0] // Assuming the first email is primary
280- }
281-
282- if githubEmail == "" {
283- logger.Warn("Unauthorized GitHub login attempt: User has no public email address on GitHub.", "github_login", userInfo.Login)
284- return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "GitHub account must have a public primary email address.")
285- }
286-
287- if !strings.EqualFold(githubEmail, configuredEmail) {
288- logger.Warn("Unauthorized GitHub login attempt: Email mismatch.",
289- "github_login", userInfo.Login,
290- "github_email", githubEmail,
291- "configured_email", configuredEmail)
292- return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("This GitHub account's email (%s) does not match the allowed email.", githubEmail))
293- }
294- logger.Debug("GitHub user email matches configured admin email.", "email", githubEmail)
295- // --- END: Email Check ---
296-
297- // --- Check if incoming GitHub user matches an existing provider ---
298- existingUser, existingAccountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login)
299- if err != nil {
300- // Handle sql.ErrNoRows specifically: means this GitHub user is not linked yet.
301- if err == sql.ErrNoRows {
302- logger.Warn("Unauthorized GitHub user trying to log in (no matching provider found).", "github_login", userInfo.Login)
303- // For now, deny access. Future: could allow multiple users if desired.
304- return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "This GitHub account is not authorized.")
305- }
306- // Handle other potential database errors
307- logger.Error("Error checking for existing provider by login", "error", err)
308- return nil, "", nil, fmt.Errorf("could not check existing provider: %w", err)
309- }
310- logger.Debug("Incoming GitHub login matches existing user.", "github_login", userInfo.Login, "user_id", existingUser.ID, "account_id", existingAccountID)
311-
312- // --- Update Session for Existing User ---
313- // The user is valid and matches the existing provider, just update their session.
314- session, err := queries.CreateOrUpdateSession(a.DB, existingAccountID, accessToken, browserInfo)
315- if err != nil {
316- logger.Error("Could not create/update session for existing user", "error", err)
317- return nil, "", nil, fmt.Errorf("could not update session: %w", err)
318- }
319- logger.Info("Successfully created/updated session for existing user", "user_id", existingUser.ID, "account_id", existingAccountID)
320- return existingUser, existingAccountID, session, nil
321- }
322-
323- // Should not be reached if logic is correct
324- logger.Error("Could not handle user - reached end of function unexpectedly", "provider_count", providerCount)
325- return nil, "", nil, fmt.Errorf("could not handle user")
326-}
327-
328-// --- NEW FUNCTION: linkProviderToSeededAdmin ---
329-// Finds the seeded admin, updates its details, creates the provider and session.
330-func linkProviderToSeededAdmin(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) {
331- logger.Debug("Starting linkProviderToSeededAdmin", "github_login", userInfo.Login)
332-
333- // 1. Get the seeded user and account ID (assuming it's the only one)
334- seededUser, seededAccountID, err := queries.GetFirstUserAndAccount(a.DB)
335- if err != nil {
336- logger.Error("Failed to get seeded user/account", "error", err)
337- return nil, "", nil, fmt.Errorf("critical: could not find seeded admin account: %w", err)
338- }
339- logger.Debug("Found seeded admin", "user_id", seededUser.ID, "account_id", seededAccountID, "original_email", seededUser.Email)
340-
341- // 2. Update the seeded user's details with GitHub info
342- newEmail := seededUser.Email // Keep original unless GitHub provides one
343- if len(userInfo.Emails) > 0 && userInfo.Emails[0] != "" {
344- newEmail = userInfo.Emails[0]
345- }
346- newName := userInfo.Login
347-
348- err = queries.UpdateUserDetails(a.DB, seededUser.ID, newName, newEmail)
349- if err != nil {
350- logger.Error("Failed to update seeded user details", "user_id", seededUser.ID, "error", err)
351- return nil, "", nil, fmt.Errorf("could not update seeded user details: %w", err)
352- }
353- logger.Info("Updated seeded user details", "user_id", seededUser.ID, "new_name", newName, "new_email", newEmail)
354- seededUser.Name = newName
355- seededUser.Email = newEmail
356-
357- // 3. Create the Provider record linked to the seeded account
358- err = queries.InsertProviderForAccount(a.DB, seededAccountID, "github", userInfo)
359- if err != nil {
360- logger.Error("Failed to insert provider record for seeded account", "account_id", seededAccountID, "error", err)
361- return nil, "", nil, fmt.Errorf("could not insert provider record: %w", err)
362- }
363- logger.Info("Inserted provider record", "account_id", seededAccountID, "provider", "github", "login", userInfo.Login)
364-
365- // 4. Create/Update the Session record linked to the seeded account
366- session, err := queries.CreateOrUpdateSession(a.DB, seededAccountID, accessToken, browserInfo)
367- if err != nil {
368- logger.Error("Failed to create/update session for seeded account", "account_id", seededAccountID, "error", err)
369- return nil, "", nil, fmt.Errorf("could not create/update session: %w", err)
370- }
371- logger.Info("Created/Updated session record", "account_id", seededAccountID, "session_id", session.ID)
372-
373- return seededUser, seededAccountID, session, nil
374-}
375-
376-// setSessionValues sets the session values for the user
377-func setSessionValues(c echo.Context, sess *sessions.Session, accountID string, session *db.Sessions) error {
378- sess.Values["authenticated"] = true
379- sess.Values["accountID"] = accountID
380- sess.Values["expires"] = session.Expires
381- sess.Values["sessionID"] = session.ID
382- sess.Values["isActive"] = session.IsActive
383-
384- if err := sess.Save(c.Request(), c.Response()); err != nil {
385- logger.Error("Failed to save session cookie", "error", err, "sessionID", session.ID)
386- return fmt.Errorf("could not save session: %w", err)
387- }
388- return nil
389-}
390-
391-// fetchGithubAPI fetches the Github API
392-func fetchGithubAPI(url string, authHeader string, result interface{}) error {
393- req, err := http.NewRequest("GET", url, nil)
394- if err != nil {
395- return err
396- }
397-
398- req.Header.Set("Authorization", authHeader)
399-
400- resp, err := http.DefaultClient.Do(req)
401- if err != nil {
402- return err
403- }
404- defer resp.Body.Close()
405-
406- if resp.StatusCode != http.StatusOK {
407- return fmt.Errorf("got status code %d", resp.StatusCode)
408- }
409-
410- body, err := io.ReadAll(resp.Body)
411- if err != nil {
412- return err
413- }
414-
415- err = json.Unmarshal(body, &result)
416- if err != nil {
417- return err
418- }
419-
420- return nil
421-}
422-
423-// githubGetUserDetails gets the user details from the Github API using the access token
424-func githubGetUserDetails(c echo.Context) (userInfo *db.GithubUserInfo, err error) {
425- accessToken := c.QueryParam("access_token")
426-
427- // Fetch user info
428- err = fetchGithubAPI("https://api.github.com/user", "Bearer "+accessToken, &userInfo)
429- if err != nil {
430- return nil, err
431- }
432- // Fetch user emails
433- var emailObjects []struct {
434- Email string `json:"email"`
435- }
436- err = fetchGithubAPI("https://api.github.com/user/emails", "Bearer "+accessToken, &emailObjects)
437- if err != nil {
438- return nil, err
439- }
440-
441- // Extract emails and populate the Emails field in userInfo
442- for _, emailObj := range emailObjects {
443- userInfo.Emails = append(userInfo.Emails, emailObj.Email)
444- }
445-
446- return userInfo, nil
447-}
448-
449-func DeviceCodeRequest(c echo.Context, a *server.App) error {
450- proxyURL := a.Config.Build.ProxyURL
451- resp, err := http.Post(proxyURL+"/github/device/code", "application/json", nil)
452- if err != nil {
453- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
454- }
455- defer resp.Body.Close()
456-
457- if resp.StatusCode != http.StatusOK {
458- body, _ := io.ReadAll(resp.Body)
459- return c.JSON(resp.StatusCode, map[string]string{"error": string(body)})
460- }
461-
462- var deviceCode map[string]interface{}
463- if err := json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil {
464- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"})
465- }
466-
467- return c.JSON(http.StatusOK, deviceCode)
468-}
469-
470-func DeviceTokenRequest(c echo.Context, a *server.App) error {
471- proxyURL := a.Config.Build.ProxyURL
472- deviceCode := c.FormValue("device_code")
473-
474- resp, err := http.PostForm(proxyURL+"/github/device/token", url.Values{
475- "device_code": {deviceCode},
476- })
477- if err != nil {
478- return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
479- }
480- defer resp.Body.Close()
481-
482- if resp.StatusCode != http.StatusOK {
483- body, _ := io.ReadAll(resp.Body)
484- return c.JSON(resp.StatusCode, map[string]string{"error": string(body)})
485- }
486-
487- var tokenResp map[string]interface{}
488- if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
489- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"})
490- }
491-
492- // --- Start JWT Generation ---
493- // Extract the access token from the response
494- githubAccessToken, ok := tokenResp["access_token"].(string)
495- if !ok || githubAccessToken == "" {
496- logger.Error("GitHub access token not found or invalid in device token response", "response", tokenResp)
497- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to retrieve GitHub token"})
498- }
499- logger.Debug("Retrieved GitHub access token via device flow")
500-
501- // Get GitHub user details using the access token
502- userInfo, err := githubGetUserDetailsWithToken(githubAccessToken)
503- if err != nil {
504- logger.Error("Failed to get GitHub user details using device flow token", "error", err)
505- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch GitHub user details"})
506- }
507- logger.Debug("Retrieved GitHub user details via device flow", "login", userInfo.Login)
508-
509- // Find the user in the database based on GitHub login
510- _, accountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login)
511- if err != nil {
512- if err == sql.ErrNoRows {
513- logger.Warn("Unauthorized GitHub user trying to get CLI token via device flow.", "github_login", userInfo.Login)
514- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "This GitHub account is not authorized."})
515- }
516- logger.Error("Error checking for existing provider by login during device flow", "error", err)
517- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Database error during user lookup"})
518- }
519- logger.Info("Found matching user for device flow", "account_id", accountID, "github_login", userInfo.Login)
520-
521- // Generate JWT
522- jwtToken, err := generateJWT(accountID, a.Config.General.JwtToken)
523- if err != nil {
524- logger.Error("Failed to generate JWT for device flow", "error", err, "account_id", accountID)
525- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate authentication token"})
526- }
527- logger.Info("Generated JWT for CLI device flow", "account_id", accountID)
528-
529- // Return the JWT
530- return c.JSON(http.StatusOK, map[string]string{"jwt_token": jwtToken})
531- // --- End JWT Generation ---
532-}
533-
534-// githubGetUserDetailsWithToken gets user details using a provided token
535-// (Similar to githubGetUserDetails but takes token as argument)
536-func githubGetUserDetailsWithToken(accessToken string) (*db.GithubUserInfo, error) {
537- var userInfo *db.GithubUserInfo // Initialize userInfo
538-
539- // Fetch user info
540- err := fetchGithubAPI("https://api.github.com/user", "Bearer "+accessToken, &userInfo)
541- if err != nil {
542- return nil, fmt.Errorf("failed to fetch user info: %w", err)
543- }
544- if userInfo == nil {
545- return nil, fmt.Errorf("received nil user info from GitHub API")
546- }
547-
548- // Fetch user emails
549- var emailObjects []struct {
550- Email string `json:"email"`
551- }
552- err = fetchGithubAPI("https://api.github.com/user/emails", "Bearer "+accessToken, &emailObjects)
553- if err != nil {
554- // Log email fetch error but continue, email might not be needed or primary
555- logger.Warn("Failed to fetch user emails from GitHub API", "error", err)
556- } else {
557- // Extract emails and populate the Emails field in userInfo
558- userInfo.Emails = []string{} // Ensure Emails slice is initialized
559- for _, emailObj := range emailObjects {
560- userInfo.Emails = append(userInfo.Emails, emailObj.Email)
561- }
562- }
563-
564- return userInfo, nil
565-}
566-
567-// generateJWT creates a new JWT token for the given account ID
568-func generateJWT(accountID string, secret string) (string, error) {
569- if secret == "" {
570- return "", fmt.Errorf("JWT secret is not configured")
571- }
572- // Set custom claims
573- claims := &JwtCustomClaims{
574- AccountID: accountID,
575- RegisteredClaims: jwt.RegisteredClaims{
576- // Set token expiration (e.g., 24 hours)
577- ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 30)), // 30 days validity for CLI token
578- IssuedAt: jwt.NewNumericDate(time.Now()),
579- NotBefore: jwt.NewNumericDate(time.Now()),
580- Issuer: "gordon",
581- Subject: "cli_auth",
582- },
583- }
584-
585- // Create token with claims
586- token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
587-
588- // Generate encoded token and return it
589- t, err := token.SignedString([]byte(secret))
590- if err != nil {
591- return "", fmt.Errorf("failed to sign JWT token: %w", err)
592- }
593-
594- return t, nil
595-}
D internal/httpserve/handlers/logout.go +0 -52
@@ -1,52 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "net/http"
6- "time"
7-
8- "github.com/bnema/gordon/internal/db/queries"
9- "github.com/bnema/gordon/internal/server"
10- "github.com/gorilla/sessions"
11- "github.com/labstack/echo/v4"
12-)
13-
14-func Logout(c echo.Context, a *server.App) error {
15- // Retrieve the session
16- sess, err := getSession(c)
17- if err != nil || sess == nil {
18- // if there is no session, redirect to the login page
19- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login")
20-
21- }
22-
23- // Invalidate the session in the database
24- sessionID, ok := sess.Values["sessionID"].(string)
25- if ok {
26- err := queries.MarkSessionInactive(a.DB, sessionID)
27- if err != nil {
28- return fmt.Errorf("failed to invalidate session: %w", err)
29- }
30- }
31-
32- // Delete the session
33- sess.Options = &sessions.Options{
34- Path: "/",
35- MaxAge: -1,
36- HttpOnly: true,
37- }
38-
39- err = sess.Save(c.Request(), c.Response())
40- if err != nil {
41- return fmt.Errorf("failed to save session: %w", err)
42- }
43-
44- // Drop the cookie
45- cookie := new(http.Cookie)
46- cookie.Name = "session"
47- cookie.Value = ""
48- cookie.Expires = time.Now().Add(-1 * time.Hour)
49- c.SetCookie(cookie)
50-
51- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login")
52-}
D internal/httpserve/handlers/send_error.go +0 -17
@@ -1,17 +0,0 @@
1-package handlers
2-
3-import (
4- "net/http"
5-
6- "github.com/bnema/gordon/pkg/sanitize"
7- "github.com/labstack/echo/v4"
8-)
9-
10-func sendError(c echo.Context, err error) error {
11- rawErrHTML := `<div>Error: ` + err.Error() + `</div>`
12- sanitizedHTML, err := sanitize.SanitizeHTML(rawErrHTML)
13- if err != nil {
14- return c.String(http.StatusInternalServerError, "An error occurred during sanitization")
15- }
16- return c.HTML(http.StatusInternalServerError, sanitizedHTML)
17-}
D internal/httpserve/handlers/session_db.go +0 -146
@@ -1,146 +0,0 @@
1-package handlers
2-
3-import (
4- "database/sql"
5- "fmt"
6- "time"
7-
8- "github.com/bnema/gordon/internal/db/queries"
9- "github.com/bnema/gordon/internal/server"
10- "github.com/bnema/gordon/pkg/logger"
11- "github.com/labstack/echo-contrib/session"
12- "github.com/labstack/echo/v4"
13-)
14-
15-func ValidateSessionAndUser(c echo.Context, a *server.App) error {
16- sessionExpiryTime := 30 * time.Minute
17-
18- sess, err := session.Get("session", c)
19- if err != nil {
20- logger.Warn("Failed to get session from cookie store", "error", err)
21- return fmt.Errorf("failed to get session: %w", err)
22- }
23-
24- accountID, ok := sess.Values["accountID"].(string)
25- if !ok || accountID == "" {
26- logger.Warn("Account ID not found in session or is empty")
27- return fmt.Errorf("accountID not found or invalid")
28- }
29-
30- sessionID, ok := sess.Values["sessionID"].(string)
31- if !ok || sessionID == "" {
32- logger.Warn("Session ID not found in session or is empty")
33- return fmt.Errorf("sessionID not found or invalid")
34- }
35-
36- authenticated, ok := sess.Values["authenticated"].(bool)
37- if !ok || !authenticated {
38- logger.Warn("User not authenticated according to session cookie", "ok", ok, "authenticated_val", sess.Values["authenticated"])
39- return fmt.Errorf("user not authenticated")
40- }
41-
42- accountExists, err := IsAccountIDInDB(a, accountID)
43- if err != nil {
44- logger.Error("Failed to check account existence", "error", err, "accountID", accountID)
45- return fmt.Errorf("failed checking account existence: %w", err)
46- }
47- if !accountExists {
48- logger.Warn("Account associated with session not found in DB", "accountID", accountID)
49- return fmt.Errorf("account does not exist")
50- }
51-
52- isValidInDB, err := IsSessionActiveInDB(a, sessionID)
53- if err != nil {
54- logger.Error("Failed to check session validity in DB", "error", err, "sessionID", sessionID)
55- return fmt.Errorf("failed checking session validity in DB: %w", err)
56- }
57- if !isValidInDB {
58- logger.Warn("Session is invalid in DB (expired or not active)", "sessionID", sessionID)
59- return fmt.Errorf("session invalid or expired")
60- }
61-
62- err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, time.Now().Add(sessionExpiryTime))
63- if err != nil {
64- logger.Error("Failed to extend session in DB", "error", err, "accountID", accountID, "sessionID", sessionID)
65- return fmt.Errorf("failed to extend session: %w", err)
66- }
67- sess.Values["expires"] = time.Now().Add(sessionExpiryTime).Format(time.RFC3339)
68- if err := sess.Save(c.Request(), c.Response()); err != nil {
69- logger.Error("Failed to save updated expiry to cookie session", "error", err, "sessionID", sessionID)
70- }
71-
72- logger.Debug("Session validated and extended successfully", "accountID", accountID, "sessionID", sessionID)
73- return nil
74-}
75-
76-func IsSessionActiveInDB(a *server.App, sessionID string) (bool, error) {
77- var dbExpiresStr string
78- var isActive bool
79-
80- query := "SELECT expires, is_active FROM sessions WHERE id = ?"
81- err := a.DB.QueryRow(query, sessionID).Scan(&dbExpiresStr, &isActive)
82-
83- if err != nil {
84- if err == sql.ErrNoRows {
85- logger.Warn("Session not found in DB", "sessionID", sessionID)
86- return false, nil
87- }
88- logger.Error("Failed to query session validity from DB", "error", err, "sessionID", sessionID)
89- return false, err
90- }
91-
92- logger.Debug("Session validity check: Read DB values", "sessionID", sessionID, "isActive_read", isActive, "expires_read", dbExpiresStr)
93-
94- if !isActive {
95- logger.Warn("Session found in DB but marked as not active", "sessionID", sessionID)
96- return false, nil
97- }
98-
99- expirationTime, err := time.Parse(time.RFC3339, dbExpiresStr)
100- if err != nil {
101- logger.Error("Failed to parse session expiration time from DB", "error", err, "sessionID", sessionID, "expires_str", dbExpiresStr)
102- return false, fmt.Errorf("invalid expiry format in DB: %w", err)
103- }
104-
105- if time.Now().After(expirationTime) {
106- logger.Warn("Session found in DB but expired", "sessionID", sessionID, "expires_at", expirationTime)
107- return false, nil
108- }
109-
110- return true, nil
111-}
112-
113-func IsAccountIDInDB(a *server.App, accountID string) (bool, error) {
114- accountExists, err := queries.CheckDBAccountExists(a.DB, accountID)
115- if err != nil {
116- logger.Error("Failed to check account existence", "error", err, "accountID", accountID)
117- return false, err
118- }
119- logger.Debug("Account existence check", "accountID", accountID, "exists", accountExists)
120- return accountExists, nil
121-}
122-
123-// ResetSession resets the session cookie and session data
124-// func ResetSession(c echo.Context) error {
125-// // Manually expire the cookie
126-// cookie := new(http.Cookie)
127-// cookie.Name = "session"
128-// cookie.Value = ""
129-// cookie.Path = "/"
130-// cookie.Expires = time.Unix(0, 0)
131-// cookie.MaxAge = -1 // Clear immediately
132-// c.SetCookie(cookie)
133-// // Invalidate the existing session
134-// sess, err := session.Get("session", c)
135-// if err != nil {
136-// log.Println(err)
137-// return echo.NewHTTPError(http.StatusInternalServerError, "Could not get session")
138-// }
139-// sess.Options.MaxAge = -1
140-// if err := sess.Save(c.Request(), c.Response()); err != nil {
141-// log.Println("Could not delete session:", err)
142-// return echo.NewHTTPError(http.StatusInternalServerError, "Could not delete session")
143-// }
144-
145-// return nil
146-// }
D internal/httpserve/handlers/static.go +0 -37
@@ -1,37 +0,0 @@
1-package handlers
2-
3-import (
4- "io/fs"
5- "net/http"
6-)
7-
8-// NoDirFileSys restricts directory listing
9-type NoDirFileSys struct {
10- fs http.FileSystem
11-}
12-
13-// NewNoDirFileSys creates a new NoDirFileSys
14-func NewNoDirFileSys(fs http.FileSystem) NoDirFileSys {
15- return NoDirFileSys{fs: fs}
16-}
17-
18-func (nfs NoDirFileSys) Open(name string) (http.File, error) {
19- f, err := nfs.fs.Open(name)
20- if err != nil {
21- return nil, err
22- }
23-
24- return NoDirFile{f}, nil
25-}
26-
27-// NoDirFile restricts directory listing
28-type NoDirFile struct {
29- http.File
30-}
31-
32-func (f NoDirFile) Readdir(count int) ([]fs.FileInfo, error) {
33- // Disable directory listing
34- return nil, nil
35-}
36-
37-// StaticRoute serves static files from the embedded filesystem - REMOVED
D internal/httpserve/handlers/templ_handlers.go +0 -212
@@ -1,212 +0,0 @@
1-package handlers
2-
3-import (
4- "github.com/bnema/gordon/internal/server"
5- "github.com/bnema/gordon/internal/templating/models/templ/components"
6- "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
7- "github.com/bnema/gordon/internal/templating/models/templ/pages/login"
8- "github.com/bnema/gordon/internal/templating/render"
9- "github.com/bnema/gordon/pkg/docker"
10- "github.com/docker/docker/api/types"
11- "github.com/labstack/echo/v4"
12- "strings"
13- "time"
14- "github.com/bnema/gordon/pkg/humanize"
15- "net/http"
16-)
17-
18-// RenderTemplLoginPage renders the login page using templ
19-func RenderTemplLoginPage(c echo.Context, a *server.App) error {
20- // Check for token in query param for backward compatibility
21- urlToken := c.QueryParam("token")
22- _ = urlToken // Not using this yet, but kept for compatibility
23-
24- // Check for error message
25- errorMsg := c.QueryParam("error")
26-
27- // Create a templ renderer
28- renderer := render.NewTemplRenderer(a)
29-
30- // Render the login page
31- return renderer.RenderTempl(c, login.LoginPage("Login", a.Config.Build.BuildVersion, errorMsg))
32-}
33-
34-// RenderTemplDashboardPage renders the dashboard page using templ
35-func RenderTemplDashboardPage(c echo.Context, a *server.App) error {
36- // Create a templ renderer
37- renderer := render.NewTemplRenderer(a)
38-
39- // Get user-specific URLs
40- adminPath := a.Config.Admin.Path
41- userSettingsURL := adminPath + "/settings"
42- logoutURL := adminPath + "/logout"
43-
44- // Render the dashboard page
45- return renderer.RenderTempl(c, admin.DashboardPage(
46- "Dashboard",
47- a.Config.Build.BuildVersion,
48- adminPath,
49- userSettingsURL,
50- logoutURL,
51- ))
52-}
53-
54-// RenderTemplManagerPage renders the manager page using templ
55-func RenderTemplManagerPage(c echo.Context, a *server.App) error {
56- // Create a templ renderer
57- renderer := render.NewTemplRenderer(a)
58-
59- // Get container and image counts
60- containerCount, _ := GetContainerCount(a)
61- imageCount, _ := GetImageCount(a)
62-
63- // Get user-specific URLs
64- adminPath := a.Config.Admin.Path
65- userSettingsURL := adminPath + "/settings"
66- logoutURL := adminPath + "/logout"
67-
68- // Render the manager page
69- return renderer.RenderTempl(c, admin.ManagerPage(
70- "Container Manager",
71- a.Config.Build.BuildVersion,
72- adminPath,
73- userSettingsURL,
74- logoutURL,
75- containerCount,
76- imageCount,
77- ))
78-}
79-
80-// RenderTemplContainerList renders the container list component
81-func RenderTemplContainerList(c echo.Context, a *server.App) error {
82- // Create a templ renderer
83- renderer := render.NewTemplRenderer(a)
84-
85- // Get the containers
86- containers, noContainer, err := GetContainers(a)
87- if err != nil {
88- return err
89- }
90-
91- // Render the container list component
92- return renderer.RenderTempl(c, components.ContainerList(containers, noContainer))
93-}
94-
95-// RenderTemplImageList renders the image list component
96-func RenderTemplImageList(c echo.Context, a *server.App) error {
97- // Create a templ renderer
98- renderer := render.NewTemplRenderer(a)
99-
100- // Get the images
101- images, err := docker.ListContainerImages()
102- if err != nil {
103- return err
104- }
105-
106- // Convert to human-readable format
107- var humanReadableImages []components.HumanReadableImage
108- for _, img := range images {
109- shortID := img.ID[7:19]
110-
111- for _, repoTag := range img.RepoTags {
112- // Split the repo tag into repository and tag
113- parts := strings.Split(repoTag, ":")
114- repository := parts[0]
115- tag := "latest"
116- if len(parts) > 1 {
117- tag = parts[1]
118- }
119-
120- humanReadableImages = append(humanReadableImages, components.HumanReadableImage{
121- ID: img.ID,
122- ShortID: shortID,
123- Name: repoTag,
124- Repository: repository,
125- Tag: tag,
126- Size: humanize.BytesToReadableSize(img.Size),
127- Created: humanize.TimeAgo(time.Unix(img.Created, 0)),
128- RepoDigests: img.RepoDigests,
129- RepoTags: img.RepoTags,
130- })
131- }
132- }
133-
134- // Render the image list component
135- return renderer.RenderTempl(c, components.ImageList(humanReadableImages))
136-}
137-
138-// RenderTemplUploadImageForm renders the upload image form component
139-func RenderTemplUploadImageForm(c echo.Context, a *server.App) error {
140- // Create a templ renderer
141- renderer := render.NewTemplRenderer(a)
142-
143- // Render the upload image form component
144- return renderer.RenderTempl(c, components.UploadImageForm())
145-}
146-
147-// RenderTemplCreateContainerForm renders the create container form component
148-func RenderTemplCreateContainerForm(c echo.Context, a *server.App) error {
149- // Create a templ renderer
150- renderer := render.NewTemplRenderer(a)
151-
152- // Get the image ID from the URL parameter
153- shortID := c.Param("ID")
154-
155- // Try to fetch the image info
156- imageID, exists := safelyInteractWithIDMap(Fetch, shortID)
157- if !exists {
158- return c.String(http.StatusBadRequest, "Invalid image ID")
159- }
160-
161- // Get image info
162- image, err := docker.GetImageInfo(imageID)
163- if err != nil {
164- return err
165- }
166-
167- // Prepare image name from repo tags
168- imageName := "unknown"
169- if len(image.RepoTags) > 0 {
170- imageName = image.RepoTags[0]
171- }
172-
173- // Prepare form data
174- formData := components.ContainerFormData{
175- ImageName: imageName,
176- ImageID: imageID,
177- ShortID: shortID,
178- // Default values can be set here if needed
179- ProxyPort: "80", // Default proxy port
180- }
181-
182- // Render the create container form component
183- return renderer.RenderTempl(c, components.CreateContainerForm(formData))
184-}
185-
186-// GetContainerCount returns the count of containers
187-func GetContainerCount(a *server.App) (int, error) {
188- containers, err := docker.ListRunningContainers()
189- if err != nil {
190- return 0, err
191- }
192- return len(containers), nil
193-}
194-
195-// GetImageCount returns the count of images
196-func GetImageCount(a *server.App) (int, error) {
197- images, err := docker.ListContainerImages()
198- if err != nil {
199- return 0, err
200- }
201- return len(images), nil
202-}
203-
204-// GetContainers returns the list of containers
205-func GetContainers(a *server.App) ([]types.Container, bool, error) {
206- containers, err := docker.ListRunningContainers()
207- if err != nil {
208- return nil, false, err
209- }
210- noContainer := len(containers) == 0
211- return containers, noContainer, nil
212-}⏎̸
D internal/httpserve/handlers/upload_image.go +0 -92
@@ -1,92 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "io"
6- "net/http"
7- "os"
8-
9- "github.com/bnema/gordon/internal/server"
10- "github.com/bnema/gordon/internal/templating/models/templ/components"
11- "github.com/bnema/gordon/internal/templating/render"
12- "github.com/bnema/gordon/pkg/docker"
13- "github.com/bnema/gordon/pkg/filestore"
14- "github.com/labstack/echo/v4"
15-)
16-
17-// 10G
18-const MaxUploadSize = 10 * 1024 * 1024 * 1024 // 10GB
19-
20-// UploadImageHandler handles the /upload-image route to show the form
21-func UploadImageGETHandler(c echo.Context, a *server.App) error {
22- // Use the Templ renderer
23- renderer := render.NewTemplRenderer(a)
24- component := components.UploadImageForm()
25-
26- return renderer.RenderTempl(c, component)
27-}
28-
29-// UploadImageHandler handles the /upload-image
30-func UploadImagePOSTHandler(c echo.Context, a *server.App) error {
31- // Set upload size limit
32- c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, MaxUploadSize)
33-
34- // Parse the multipart form
35- form, err := c.MultipartForm()
36- if err != nil {
37- return sendError(c, err)
38- }
39-
40- // Get the file from the form
41- files := form.File["file"]
42- if len(files) == 0 {
43- return c.String(http.StatusBadRequest, "No file uploaded")
44- }
45- file := files[0]
46-
47- // Open the file
48- src, err := file.Open()
49- if err != nil {
50- return sendError(c, err)
51- }
52- defer src.Close()
53-
54- // Create a temporary file to store the uploaded chunks
55- tempFile, err := os.CreateTemp("", "upload-*")
56- if err != nil {
57- return sendError(c, err)
58- }
59- defer tempFile.Close()
60-
61- // Copy the file chunks to the temporary file
62- _, err = io.Copy(tempFile, src)
63- if err != nil {
64- return sendError(c, err)
65- }
66-
67- // Reset the temporary file pointer
68- _, err = tempFile.Seek(0, io.SeekStart)
69- if err != nil {
70- return sendError(c, err)
71- }
72-
73- // Save the image to the storage directory
74- saveInPath, err := filestore.SaveImageToStorage(&a.Config, file.Filename, tempFile)
75- if err != nil {
76- return sendError(c, err)
77- }
78-
79- // Import the image into Docker
80- _, err = docker.ImportImageToEngine(saveInPath)
81- if err != nil {
82- return fmt.Errorf("failed to import image to Docker: %v", err)
83- }
84-
85- // Remove the image from the storage directory
86- err = filestore.RemoveFromStorage(saveInPath)
87- if err != nil {
88- return sendError(c, err)
89- }
90-
91- return c.HTML(http.StatusOK, ActionSuccess(a))
92-}
D internal/httpserve/middleware/colorschemedetect.go +0 -35
@@ -1,35 +0,0 @@
1-package middleware
2-
3-import (
4- "github.com/labstack/echo-contrib/session"
5- "github.com/labstack/echo/v4"
6-)
7-
8-// ColorSchemeDetection detects the current color scheme and sets it in the context based on the "Sec-CH-Prefers-Color-Scheme" header
9-func ColorSchemeDetection(next echo.HandlerFunc) echo.HandlerFunc {
10- ColorSchemeKey := "CurrentColorScheme"
11- return func(c echo.Context) error {
12- colorScheme := detectCurrentColorScheme(c)
13- c.Set(ColorSchemeKey, colorScheme)
14- return next(c)
15- }
16-}
17-
18-func detectCurrentColorScheme(c echo.Context) string {
19- // Check if the color scheme key is set in the session storage
20- sess, err := session.Get("session", c)
21- if err != nil {
22- return "light"
23- }
24-
25- if colorSchemeValue, ok := sess.Values["colorScheme"]; ok && colorSchemeValue != nil {
26- return colorSchemeValue.(string) // Return the color scheme value from the session storage
27- }
28-
29- // If color scheme key is not set, proceed with "Sec-CH-Prefers-Color-Scheme" header detection
30- header := c.Request().Header.Get("Sec-CH-Prefers-Color-Scheme")
31- if header == "dark" {
32- return "dark"
33- }
34- return "light" // Default to light mode
35-}
D internal/httpserve/middleware/commondata.go +0 -15
@@ -1,15 +0,0 @@
1-package middleware
2-
3-import (
4- "github.com/bnema/gordon/internal/server"
5- "github.com/labstack/echo/v4"
6-)
7-
8-func SetCommonDataMiddleware(a *server.App) echo.MiddlewareFunc {
9- return func(next echo.HandlerFunc) echo.HandlerFunc {
10- return func(c echo.Context) error {
11- // TODO: set common data
12- return next(c)
13- }
14- }
15-}
D internal/httpserve/middleware/error.go +0 -20
@@ -1,20 +0,0 @@
1-package middleware
2-
3-import (
4- "github.com/bnema/gordon/pkg/logger"
5- "github.com/labstack/echo/v4"
6-)
7-
8-func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc {
9- return func(c echo.Context) error {
10- err := next(c)
11- if err != nil {
12- logger.Error("Caught http error",
13- "path", c.Request().URL.Path,
14- "method", c.Request().Method,
15- "error", err,
16- "status", c.Response().Status)
17- }
18- return err
19- }
20-}
D internal/httpserve/middleware/error_middleware.go +0 -36
@@ -1,36 +0,0 @@
1-package middleware
2-
3-import (
4- "net/http"
5-
6- "github.com/bnema/gordon/internal/httpserve/handlers"
7- "github.com/bnema/gordon/internal/server"
8- "github.com/labstack/echo/v4"
9-)
10-
11-// CustomHTTPErrorHandler is a custom error handler that uses templ to render error pages
12-func CustomHTTPErrorHandler(a *server.App) echo.HTTPErrorHandler {
13- return func(err error, c echo.Context) {
14- code := http.StatusInternalServerError
15- if he, ok := err.(*echo.HTTPError); ok {
16- code = he.Code
17- }
18-
19- // Handle specific error codes with our templates
20- switch code {
21- case http.StatusNotFound:
22- if err := handlers.RenderNotFoundPage(c, a); err != nil {
23- c.Logger().Error(err)
24- }
25- return
26- case http.StatusForbidden:
27- if err := handlers.RenderForbiddenPage(c, a); err != nil {
28- c.Logger().Error(err)
29- }
30- return
31- default:
32- // For other errors, use Echo's default error handler
33- c.Echo().DefaultHTTPErrorHandler(err, c)
34- }
35- }
36-} ⏎̸
D internal/httpserve/middleware/langdetect.go +0 -25
@@ -1,25 +0,0 @@
1-package middleware
2-
3-import (
4- "strings"
5-
6- "github.com/labstack/echo/v4"
7-)
8-
9-// LanguageDetectiondetects the current language and sets it in the context
10-func LanguageDetection(next echo.HandlerFunc) echo.HandlerFunc {
11- return func(c echo.Context) error {
12- lang := detectCurrentLanguage(c)
13- c.Set("LangKey", lang)
14- return next(c)
15- }
16-}
17-
18-func detectCurrentLanguage(c echo.Context) string {
19- // If lang key is not set, proceed with "Accept-Language" header detection
20- header := c.Request().Header.Get("Accept-Language")
21- if strings.Contains(header, "fr") {
22- return "fr"
23- }
24- return "en" // Default to English
25-}
D internal/httpserve/middleware/requirelogin.go +0 -47
@@ -1,47 +0,0 @@
1-package middleware
2-
3-import (
4- "net/http"
5- "time"
6-
7- "github.com/bnema/gordon/internal/httpserve/handlers"
8- "github.com/bnema/gordon/internal/server"
9-
10- "github.com/labstack/echo/v4"
11-)
12-
13-func redirectToLogin(c echo.Context, a *server.App) error {
14- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login")
15-}
16-
17-func RequireLogin(a *server.App) echo.MiddlewareFunc {
18- return func(next echo.HandlerFunc) echo.HandlerFunc {
19- return func(c echo.Context) error {
20- const maxRetries = 3
21- var err error
22- for i := 0; i < maxRetries; i++ {
23- err = handlers.ValidateSessionAndUser(c, a)
24- if err == nil {
25- return next(c)
26- }
27- if i < maxRetries-1 {
28- time.Sleep(time.Duration(i+1) * 500 * time.Millisecond)
29- }
30- }
31- // propose the redirect auth url as httpcode
32- return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login")
33- }
34-
35- }
36-}
37-
38-// func DeleteCookieAndRedirectToLogin(c echo.Context, a *server.App) error {
39-// // Delete session cookie
40-// cookie := new(http.Cookie)
41-// cookie.Name = "session"
42-// cookie.Value = ""
43-// cookie.Expires = time.Now().Add(-1 * time.Hour)
44-// c.SetCookie(cookie)
45-
46-// return redirectToLogin(c, a)
47-// }
D internal/httpserve/middleware/requiretoken.go +0 -96
@@ -1,96 +0,0 @@
1-package middleware
2-
3-import (
4- "fmt"
5- "net/http"
6- "strings"
7-
8- "errors"
9-
10- "github.com/bnema/gordon/internal/db/queries"
11- "github.com/bnema/gordon/internal/server"
12- "github.com/bnema/gordon/pkg/logger"
13- "github.com/golang-jwt/jwt/v5"
14- "github.com/labstack/echo/v4"
15-)
16-
17-// --- JWT Claims (copied from handlers/login.go for middleware use) ---
18-// Consider moving this to a shared location if used in more places.
19-type JwtCustomClaims struct {
20- AccountID string `json:"account_id"`
21- jwt.RegisteredClaims
22-}
23-
24-// RequireToken validates JWT tokens for API requests
25-func RequireToken(a *server.App) echo.MiddlewareFunc {
26- return func(next echo.HandlerFunc) echo.HandlerFunc {
27- return func(c echo.Context) error {
28- authHeader := c.Request().Header.Get("Authorization")
29- if authHeader == "" {
30- logger.Warn("API request missing Authorization header")
31- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Missing authorization token"})
32- }
33-
34- tokenString := strings.TrimPrefix(authHeader, "Bearer ")
35- if tokenString == authHeader { // No "Bearer " prefix found
36- logger.Warn("API request Authorization header malformed", "header", authHeader)
37- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Malformed authorization token"})
38- }
39-
40- if a.Config.General.JwtToken == "" {
41- logger.Error("JWT Secret is not configured, cannot validate token")
42- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Server configuration error"})
43- }
44-
45- // Parse and validate the token
46- token, err := jwt.ParseWithClaims(tokenString, &JwtCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
47- // Validate the alg is what we expect:
48- if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
49- return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
50- }
51- return []byte(a.Config.General.JwtToken), nil
52- })
53-
54- if err != nil {
55- logger.Warn("Invalid JWT token received", "error", err)
56- if errors.Is(err, jwt.ErrTokenMalformed) {
57- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Malformed token"})
58- } else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
59- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired or not yet valid"})
60- } else {
61- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
62- }
63- }
64-
65- // Check if claims are valid and extract AccountID
66- if claims, ok := token.Claims.(*JwtCustomClaims); ok && token.Valid {
67- if claims.AccountID == "" {
68- logger.Warn("JWT token is valid but missing AccountID claim")
69- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token claims"})
70- }
71-
72- // --- Optional: Check if AccountID exists and is active in DB ---
73- accountExists, err := queries.CheckDBAccountExists(a.DB, claims.AccountID) // Corrected function name
74- if err != nil {
75- logger.Error("Database error checking account existence during token validation", "account_id", claims.AccountID, "error", err)
76- return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Internal server error"})
77- }
78- if !accountExists {
79- logger.Warn("JWT token validated for non-existent account", "account_id", claims.AccountID)
80- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Account not found"})
81- }
82- // --- End Optional DB Check ---
83-
84- logger.Debug("JWT token validated successfully", "account_id", claims.AccountID)
85- // Set account ID in context for downstream handlers
86- c.Set("accountID", claims.AccountID)
87- return next(c)
88- }
89-
90- logger.Warn("Invalid JWT token claims or token invalid")
91- return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
92- }
93- }
94-}
95-
96-// Removed unused functions: validateGitHubToken, fetchGitHubUserEmails, shouldUpdateSession, sessionUpdateCache
D internal/httpserve/middleware/secure.go +0 -81
@@ -1,81 +0,0 @@
1-package middleware
2-
3-import (
4- "fmt"
5- "log"
6- "net/http"
7- "os"
8-
9- "github.com/bnema/gordon/internal/server"
10- "github.com/gorilla/sessions"
11- _ "github.com/joho/godotenv/autoload"
12- "github.com/labstack/echo-contrib/session"
13- "github.com/labstack/echo/v4"
14- "github.com/labstack/echo/v4/middleware"
15-)
16-
17-func SecureRoutes(a *server.App) echo.MiddlewareFunc {
18- proxyURL := a.Config.Build.ProxyURL
19- urlCheckVersion := a.Config.Build.ProxyURL + "/version"
20-
21- // Base directives
22- defaultSrc := "'self'"
23- styleSrc := "'self' 'unsafe-inline'"
24- fontSrc := "'self' data:"
25- imgSrc := "'self' data:"
26- connectSrc := fmt.Sprintf("'self' %s %s", proxyURL, urlCheckVersion)
27-
28- // Start building script-src
29- scriptSrc := "'self' 'unsafe-inline' 'unsafe-eval'"
30-
31- // Conditionally add Cloudflare Insights if SkipCertificates is true
32- if a.Config.ReverseProxy.SkipCertificates {
33- scriptSrc += " https://static.cloudflareinsights.com"
34- }
35-
36- // Construct the final CSP string
37- csp := fmt.Sprintf(
38- "default-src %s; style-src %s; font-src %s; img-src %s; script-src %s; connect-src %s",
39- defaultSrc, styleSrc, fontSrc, imgSrc, scriptSrc, connectSrc,
40- )
41-
42- return middleware.SecureWithConfig(middleware.SecureConfig{
43- XSSProtection: "1; mode=block",
44- ContentTypeNosniff: "nosniff",
45- XFrameOptions: "SAMEORIGIN",
46- HSTSMaxAge: 3600,
47- HSTSExcludeSubdomains: false,
48- ContentSecurityPolicy: csp, // Use the dynamically constructed CSP
49- })
50-}
51-
52-// InitSessionMiddleware initializes the session middleware with secure options
53-func InitSessionMiddleware(a *server.App) echo.MiddlewareFunc {
54- isHttps := a.Config.Http.Https
55- secret := os.Getenv("SESSION_SECRET")
56- if secret == "" {
57- log.Fatal("Environment variable SESSION_SECRET is not set or cannot be read")
58- }
59- store := sessions.NewCookieStore([]byte(secret))
60- store.Options = &sessions.Options{
61- Path: "/",
62- HttpOnly: true,
63- Secure: isHttps,
64- MaxAge: 86400,
65- SameSite: http.SameSiteLaxMode,
66- }
67- return func(next echo.HandlerFunc) echo.HandlerFunc {
68- return func(c echo.Context) error {
69- err := session.Middleware(store)(next)(c)
70- if err != nil {
71- log.Printf("Error in session middleware: %v", err)
72- return err
73- }
74- _, err = session.Get("session", c)
75- if err != nil {
76- log.Printf("Could not retrieve session: %v", err)
77- }
78- return nil
79- }
80- }
81-}
D internal/httpserve/proxy_init.go +0 -26
@@ -1,26 +0,0 @@
1-package httpserve
2-
3-import (
4- "github.com/bnema/gordon/internal/proxy"
5- "github.com/bnema/gordon/internal/server"
6- "github.com/charmbracelet/log"
7-)
8-
9-// InitializeProxy initializes and starts the reverse proxy
10-func InitializeProxy(a *server.App) (*proxy.Proxy, error) {
11- log.Info("Initializing reverse proxy")
12-
13- // Create a new proxy
14- p, err := proxy.NewProxy(a)
15- if err != nil {
16- return nil, err
17- }
18-
19- // Start the proxy
20- if err := p.Start(); err != nil {
21- return nil, err
22- }
23-
24- log.Info("Reverse proxy initialized and started")
25- return p, nil
26-}
D internal/httpserve/router.go +0 -294
@@ -1,294 +0,0 @@
1-package httpserve
2-
3-import (
4- "io/fs"
5- "net/http"
6- "os"
7-
8- "github.com/bnema/gordon/internal/httpserve/handlers"
9- "github.com/bnema/gordon/internal/httpserve/middleware"
10- "github.com/bnema/gordon/internal/server"
11- "github.com/bnema/gordon/internal/webui" // Import the webui package
12- log "github.com/bnema/gordon/pkg/logger"
13- _ "github.com/joho/godotenv/autoload"
14- "github.com/labstack/echo-contrib/session"
15- "github.com/labstack/echo/v4"
16- echomid "github.com/labstack/echo/v4/middleware"
17-)
18-
19-// RegisterRoutes registers all routes and middlewares
20-func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo {
21- e.Use(echomid.Recover())
22-
23- // Determine if running in live UI mode
24- useOS := os.Getenv("GORDON_LIVE_UI") == "true"
25- staticFileSystem := getStaticFileSystem(useOS)
26- assetHandler := http.FileServer(staticFileSystem)
27-
28- // Serve static files using the asset handler
29- // We wrap the handler to ensure it works correctly with Echo
30- // We also need to strip the prefix if accessing assets directly (e.g. /assets/css/...)
31- // The root path "/" will serve index.html or equivalent by default if present.
32- e.GET("/*", echo.WrapHandler(assetHandler))
33-
34- // Log admin status
35- log.Info("Admin WebUI status",
36- "enabled", a.Config.Admin.IsAdminWebUIEnabled(),
37- "path", a.Config.Admin.Path)
38-
39- // Initiate the session middleware
40- sessionMiddleware := middleware.InitSessionMiddleware(a)
41- // Apply session middleware *after* the static file handler setup
42- // It will only run for routes not handled by the static file server
43- e.Use(sessionMiddleware)
44-
45- // Language detection
46- e.Use(middleware.LanguageDetection)
47-
48- AdminPath := a.Config.Admin.Path
49- log.Debug("Binding login routes with AdminPath", "path", AdminPath)
50- bindLoginRoute(e, a, AdminPath)
51-
52- log.Debug("Binding API endpoints")
53- bindAPIEndpoints(e, a)
54-
55- // SetCommonDataMiddleware will pass data to the renderer
56- e.Use(middleware.SetCommonDataMiddleware(a))
57-
58- // Error handler middleware
59- e.Use(middleware.ErrorHandler)
60-
61- // Use middlewares
62- e.Use(middleware.SecureRoutes(a))
63-
64- // Color scheme detection for dark/light mode
65- e.Use(middleware.ColorSchemeDetection)
66-
67- // Set custom error handler
68- e.HTTPErrorHandler = middleware.CustomHTTPErrorHandler(a)
69-
70- log.Debug("Binding admin routes with AdminPath", "path", AdminPath)
71- bindAdminRoute(e, a, AdminPath)
72-
73- // Only bind HTMX endpoints if Admin WebUI is enabled
74- if a.Config.Admin.IsAdminWebUIEnabled() {
75- log.Info("Admin WebUI is enabled, binding HTMX endpoints")
76- bindHTMXEndpoints(e, a)
77- } else {
78- log.Info("Admin WebUI is disabled, skipping HTMX endpoints")
79- }
80-
81- // Protect the root path with a 403
82- e.GET("/", func(c echo.Context) error {
83- return handlers.RenderForbiddenPage(c, a)
84- })
85-
86- log.Info("All routes registered successfully")
87- return e
88-}
89-
90-// bindAPIEndpoints expose /api endpoints and speaks only JSON
91-func bindAPIEndpoints(e *echo.Echo, a *server.App) {
92- apiGroup := e.Group("/api")
93-
94- // Device flow endpoints (without RequireToken middleware)
95- apiGroup.POST("/device/code", func(c echo.Context) error {
96- return handlers.DeviceCodeRequest(c, a)
97- })
98- apiGroup.POST("/device/token", func(c echo.Context) error {
99- return handlers.DeviceTokenRequest(c, a)
100- })
101-
102- // Other API endpoints that require a token
103- protectedApiGroup := apiGroup.Group("", middleware.RequireToken(a))
104- protectedApiGroup.GET("/ping", func(c echo.Context) error {
105- return handlers.GetInfos(c, a)
106- })
107- protectedApiGroup.POST("/deploy", func(c echo.Context) error {
108- return handlers.PostDeploy(c, a)
109- })
110- protectedApiGroup.POST("/deploy/chunked", func(c echo.Context) error {
111- return handlers.PostDeployChunked(c, a)
112- })
113- protectedApiGroup.GET("/deploy/check-conflict", func(c echo.Context) error {
114- return handlers.CheckDeployConflict(c, a)
115- })
116- // Regular push endpoint for small images
117- protectedApiGroup.POST("/push", func(c echo.Context) error {
118- return handlers.PostPush(c, a)
119- })
120-
121- // Chunked push endpoint for large images
122- protectedApiGroup.POST("/push/chunked", func(c echo.Context) error {
123- return handlers.PostPushChunked(c, a)
124- })
125- protectedApiGroup.POST("/stop", func(c echo.Context) error {
126- return handlers.PostContainerStop(c, a)
127- })
128- protectedApiGroup.POST("/remove", func(c echo.Context) error {
129- return handlers.PostContainerRemove(c, a)
130- })
131-}
132-
133-// bindLoginRoute binds all login routes
134-func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) {
135- // Skip binding admin login routes if admin webUI is disabled
136- if !a.Config.Admin.IsAdminWebUIEnabled() {
137- log.Debug("Admin WebUI is disabled, skipping admin login routes")
138- return
139- }
140-
141- e.GET(adminPath+"/login", func(c echo.Context) error {
142- // Check if already logged in based on cookie
143- sess, _ := session.Get("session", c) // Ignore error for initial check
144- if sess != nil && sess.Values != nil {
145- if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated {
146- // Cookie claims authenticated. NOW, validate against the DB too.
147- err := handlers.ValidateSessionAndUser(c, a) // Use the same validation function as the middleware
148- if err == nil {
149- // Session is valid in BOTH cookie AND DB. Proceed to admin panel.
150- log.Debug("Login page check: Valid session found (cookie & DB), redirecting to admin panel")
151- return c.Redirect(http.StatusFound, a.Config.Admin.Path)
152- }
153- // If DB validation failed, log it and fall through to render login page
154- log.Warn("Login page check: Cookie session present but DB validation failed", "error", err)
155- // NOTE: Consider invalidating the inconsistent cookie here?
156- // handlers.InvalidateSessionCookie(c, sess) // Optional: Force cookie cleanup
157- }
158- }
159- // If no session cookie, not authenticated in cookie, or DB validation failed: render login page.
160- log.Debug("Login page check: No valid session found or DB validation failed, rendering login page")
161- // Always use the Templ-based login page renderer
162- return handlers.RenderTemplLoginPage(c, a)
163- })
164- // Removed POST /login/submit-token route
165- e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error {
166- return handlers.StartOAuthGithub(c, a)
167- })
168- e.GET("/callback", func(c echo.Context) error {
169- log.Debug("OAuth callback endpoint hit", "path", c.Path(), "query", c.QueryString())
170- return handlers.OAuthCallback(c, a)
171- })
172- e.GET(adminPath+"/logout", func(c echo.Context) error {
173- return handlers.Logout(c, a)
174- })
175-}
176-
177-// bindAdminRoute binds all admin routes
178-func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) {
179- // Skip binding admin routes if admin webUI is disabled
180- if !a.Config.Admin.IsAdminWebUIEnabled() {
181- log.Debug("Admin WebUI is disabled, skipping admin routes")
182- return
183- }
184-
185- adminGroup := e.Group(adminPath)
186- // Since login is behind the admin path, we cannot use group middleware
187- adminGroup.GET("", func(c echo.Context) error {
188- return c.Redirect(302, adminPath+"/manager")
189- }, middleware.RequireLogin(a))
190-
191- adminGroup.GET("/manager", func(c echo.Context) error {
192- return handlers.RenderTemplManagerPage(c, a)
193- }, middleware.RequireLogin(a))
194- adminGroup.GET("/cc/:ID", func(c echo.Context) error {
195- return handlers.CreateContainerFullGET(c, a)
196- }, middleware.RequireLogin(a))
197-
198- // Add a ping endpoint for health checks and connection testing
199- // This endpoint doesn't require authentication since it's used by the proxy for connection testing
200- adminGroup.GET("/ping", func(c echo.Context) error {
201- log.Debug("Ping endpoint hit from:", "ip", c.RealIP())
202- return c.JSON(200, map[string]string{
203- "status": "ok",
204- "message": "Gordon admin server is running",
205- })
206- })
207-}
208-
209-// bindHTMXEndpoints binds all HTMX endpoints
210-func bindHTMXEndpoints(e *echo.Echo, a *server.App) {
211- // Skip if AdminWebUI is disabled
212- if !a.Config.Admin.IsAdminWebUIEnabled() {
213- log.Debug("Admin WebUI is disabled, skipping HTMX endpoints")
214- return
215- }
216-
217- // Create a group for /htmx endpoints
218- htmxGroup := e.Group("/htmx")
219-
220- // Apply middleware to the group, so all endpoints are protected
221- htmxGroup.Use(middleware.RequireLogin(a))
222-
223- // List all images component
224- htmxGroup.GET("/image-manager", func(c echo.Context) error {
225- return handlers.ImageManagerComponent(c, a)
226- })
227- // Delete an image
228- htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error {
229- return handlers.ImageManagerDelete(c, a)
230- })
231- // Purge unused images
232- htmxGroup.POST("/image-manager/prune", func(c echo.Context) error {
233- return handlers.ImageManagerPrune(c, a)
234- })
235-
236- // List all containers
237- htmxGroup.GET("/container-manager", func(c echo.Context) error {
238- return handlers.RenderTemplContainerList(c, a)
239- })
240- // Stop a container
241- htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error {
242- return handlers.ContainerManagerStop(c, a)
243- })
244- // Delete a container
245- htmxGroup.DELETE("/container-manager/delete/:ID", func(c echo.Context) error {
246- return handlers.ContainerManagerDelete(c, a)
247- })
248- // Start a container
249- htmxGroup.POST("/container-manager/start/:ID", func(c echo.Context) error {
250- return handlers.ContainerManagerStart(c, a)
251- })
252- // Edit a container view
253- htmxGroup.GET("/container-manager/edit/:ID", func(c echo.Context) error {
254- return handlers.ContainerManagerEditGET(c, a)
255- })
256- // Edit a container action
257- htmxGroup.POST("/container-manager/edit/:ID", func(c echo.Context) error {
258- return handlers.ContainerManagerEditPOST(c, a)
259- })
260-
261- // Display upload-image component
262- htmxGroup.GET("/upload-image", func(c echo.Context) error {
263- return handlers.UploadImageGETHandler(c, a)
264- })
265- // Upload image
266- htmxGroup.POST("/upload-image", func(c echo.Context) error {
267- return handlers.UploadImagePOSTHandler(c, a)
268- })
269- // Display create-container component
270- htmxGroup.GET("/create-container/:ID", func(c echo.Context) error {
271- return handlers.CreateContainerGET(c, a)
272- })
273- // Handle create-container form submission
274- htmxGroup.POST("/create-container/:ID", func(c echo.Context) error {
275- return handlers.CreateContainerPOST(c, a)
276- })
277-}
278-
279-// getStaticFileSystem returns the appropriate http.FileSystem based on the GORDON_LIVE_UI env var.
280-func getStaticFileSystem(useOS bool) http.FileSystem {
281- if useOS {
282- log.Info("Serving static files from OS filesystem (live mode)")
283- return http.FS(os.DirFS("internal/webui/public"))
284- }
285-
286- log.Info("Serving static files from embedded filesystem")
287- // Use the PublicFS from the webui package
288- fsys, err := fs.Sub(webui.PublicFS, "public")
289- if err != nil {
290- panic(err) // Should not happen with embedded files
291- }
292-
293- return http.FS(fsys)
294-}
D internal/interfaces/app_interface.go +0 -21
@@ -1,21 +0,0 @@
1-package interfaces
2-
3-import (
4- "database/sql"
5-
6- "github.com/bnema/gordon/internal/common"
7-)
8-
9-// AppInterface defines the interface that the proxy package needs from the App struct
10-type AppInterface interface {
11- // Configuration access
12- GetConfig() *common.Config
13-
14- // Database access
15- GetDB() *sql.DB
16-
17- // Environment and status
18- IsDevEnvironment() bool
19- GetUptime() string
20- GetVersionstring() string
21-}
A internal/middleware/auth.go +246 -0
@@ -0,0 +1,246 @@
1+package middleware
2+
3+import (
4+ "crypto/rand"
5+ "crypto/subtle"
6+ "encoding/base64"
7+ "fmt"
8+ "net/http"
9+ "strings"
10+ "time"
11+
12+ "github.com/golang-jwt/jwt/v5"
13+ "github.com/rs/zerolog/log"
14+)
15+
16+// BasicAuth middleware provides HTTP Basic Authentication
17+func BasicAuth(username, password string) func(http.Handler) http.Handler {
18+ return func(next http.Handler) http.Handler {
19+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ if !isAuthenticated(r, username, password) {
21+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Management API"`)
22+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
23+
24+ log.Warn().
25+ Str("method", r.Method).
26+ Str("path", r.URL.Path).
27+ Str("remote_addr", r.RemoteAddr).
28+ Msg("Unauthorized API access attempt")
29+ return
30+ }
31+
32+ next.ServeHTTP(w, r)
33+ })
34+ }
35+}
36+
37+// APIKeyAuth middleware provides API key authentication
38+func APIKeyAuth(validAPIKey string) func(http.Handler) http.Handler {
39+ return func(next http.Handler) http.Handler {
40+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+ apiKey := r.Header.Get("X-API-Key")
42+ if apiKey == "" {
43+ apiKey = r.URL.Query().Get("api_key")
44+ }
45+
46+ if !isValidAPIKey(apiKey, validAPIKey) {
47+ http.Error(w, "Forbidden - Invalid API Key", http.StatusForbidden)
48+
49+ log.Warn().
50+ Str("method", r.Method).
51+ Str("path", r.URL.Path).
52+ Str("remote_addr", r.RemoteAddr).
53+ Msg("Invalid API key access attempt")
54+ return
55+ }
56+
57+ next.ServeHTTP(w, r)
58+ })
59+ }
60+}
61+
62+// RegistryAuth middleware provides Docker Registry authentication
63+func RegistryAuth(username, password string) func(http.Handler) http.Handler {
64+ return func(next http.Handler) http.Handler {
65+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+ // Skip auth for registry info endpoint
67+ if r.URL.Path == "/v2/" && r.Method == "GET" {
68+ next.ServeHTTP(w, r)
69+ return
70+ }
71+
72+ // Require auth for all other registry operations
73+ if !isAuthenticated(r, username, password) {
74+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
75+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
76+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
77+
78+ log.Warn().
79+ Str("method", r.Method).
80+ Str("path", r.URL.Path).
81+ Str("remote_addr", r.RemoteAddr).
82+ Msg("Unauthorized registry access attempt")
83+ return
84+ }
85+
86+ next.ServeHTTP(w, r)
87+ })
88+ }
89+}
90+
91+// IPWhitelist middleware restricts access to specific IP addresses
92+func IPWhitelist(allowedIPs []string) func(http.Handler) http.Handler {
93+ return func(next http.Handler) http.Handler {
94+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ clientIP := getClientIP(r)
96+
97+ // Remove port from IP if present
98+ if colonIndex := strings.LastIndex(clientIP, ":"); colonIndex != -1 {
99+ clientIP = clientIP[:colonIndex]
100+ }
101+
102+ allowed := false
103+ for _, allowedIP := range allowedIPs {
104+ if clientIP == allowedIP || allowedIP == "127.0.0.1" && (clientIP == "::1" || clientIP == "localhost") {
105+ allowed = true
106+ break
107+ }
108+ }
109+
110+ if !allowed {
111+ http.Error(w, "Forbidden - IP not allowed", http.StatusForbidden)
112+
113+ log.Warn().
114+ Str("method", r.Method).
115+ Str("path", r.URL.Path).
116+ Str("client_ip", clientIP).
117+ Msg("IP not in whitelist")
118+ return
119+ }
120+
121+ next.ServeHTTP(w, r)
122+ })
123+ }
124+}
125+
126+// Helper functions
127+
128+func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string) bool {
129+ username, password, ok := r.BasicAuth()
130+ if !ok {
131+ return false
132+ }
133+
134+ // Use constant-time comparison to prevent timing attacks
135+ usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
136+ passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
137+
138+ return usernameMatch && passwordMatch
139+}
140+
141+func isValidAPIKey(provided, expected string) bool {
142+ if provided == "" || expected == "" {
143+ return false
144+ }
145+
146+ // Use constant-time comparison to prevent timing attacks
147+ return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
148+}
149+
150+// GordonClaims represents the JWT claims for Gordon tokens
151+type GordonClaims struct {
152+ Username string `json:"username"`
153+ Roles []string `json:"roles"`
154+ jwt.RegisteredClaims
155+}
156+
157+// JWTAuth middleware provides JWT token authentication
158+func JWTAuth(jwtSecret string) func(http.Handler) http.Handler {
159+ return func(next http.Handler) http.Handler {
160+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
161+ // Extract token from Authorization header
162+ authHeader := r.Header.Get("Authorization")
163+ if authHeader == "" {
164+ http.Error(w, "Authorization header required", http.StatusUnauthorized)
165+ return
166+ }
167+
168+ // Check for Bearer token
169+ tokenString := ""
170+ if strings.HasPrefix(authHeader, "Bearer ") {
171+ tokenString = strings.TrimPrefix(authHeader, "Bearer ")
172+ } else {
173+ http.Error(w, "Bearer token required", http.StatusUnauthorized)
174+ return
175+ }
176+
177+ // Parse and validate token
178+ token, err := jwt.ParseWithClaims(tokenString, &GordonClaims{}, func(token *jwt.Token) (interface{}, error) {
179+ // Validate signing method
180+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
181+ return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
182+ }
183+ return []byte(jwtSecret), nil
184+ })
185+
186+ if err != nil {
187+ log.Warn().Err(err).
188+ Str("method", r.Method).
189+ Str("path", r.URL.Path).
190+ Str("remote_addr", r.RemoteAddr).
191+ Msg("Invalid JWT token")
192+ http.Error(w, "Invalid token", http.StatusUnauthorized)
193+ return
194+ }
195+
196+ if !token.Valid {
197+ http.Error(w, "Invalid token", http.StatusUnauthorized)
198+ return
199+ }
200+
201+ // Token is valid, continue
202+ next.ServeHTTP(w, r)
203+ })
204+ }
205+}
206+
207+// GenerateJWTSecret generates a secure random secret for JWT signing
208+func GenerateJWTSecret() (string, error) {
209+ bytes := make([]byte, 32) // 256 bits
210+ if _, err := rand.Read(bytes); err != nil {
211+ return "", fmt.Errorf("failed to generate JWT secret: %w", err)
212+ }
213+ return base64.URLEncoding.EncodeToString(bytes), nil
214+}
215+
216+// GenerateJWTToken generates a JWT token for a user
217+func GenerateJWTToken(username string, roles []string, jwtSecret string, validFor time.Duration) (string, error) {
218+ claims := GordonClaims{
219+ Username: username,
220+ Roles: roles,
221+ RegisteredClaims: jwt.RegisteredClaims{
222+ Issuer: "gordon",
223+ Subject: username,
224+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(validFor)),
225+ IssuedAt: jwt.NewNumericDate(time.Now()),
226+ NotBefore: jwt.NewNumericDate(time.Now()),
227+ },
228+ }
229+
230+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
231+ tokenString, err := token.SignedString([]byte(jwtSecret))
232+ if err != nil {
233+ return "", fmt.Errorf("failed to sign JWT token: %w", err)
234+ }
235+
236+ return tokenString, nil
237+}
238+
239+// GenerateSecureAPIKey generates a secure random API key
240+func GenerateSecureAPIKey() (string, error) {
241+ bytes := make([]byte, 24) // 192 bits, base64 encoded will be ~32 chars
242+ if _, err := rand.Read(bytes); err != nil {
243+ return "", fmt.Errorf("failed to generate API key: %w", err)
244+ }
245+ return base64.URLEncoding.EncodeToString(bytes), nil
246+}⏎̸
A internal/middleware/logging.go +187 -0
@@ -0,0 +1,187 @@
1+package middleware
2+
3+import (
4+ "net/http"
5+ "time"
6+
7+ "github.com/rs/zerolog/log"
8+)
9+
10+// ResponseWriter wraps http.ResponseWriter to capture status code and bytes written
11+type ResponseWriter struct {
12+ http.ResponseWriter
13+ statusCode int
14+ bytes int
15+}
16+
17+func NewResponseWriter(w http.ResponseWriter) *ResponseWriter {
18+ return &ResponseWriter{
19+ ResponseWriter: w,
20+ statusCode: http.StatusOK, // Default status code
21+ }
22+}
23+
24+func (rw *ResponseWriter) WriteHeader(code int) {
25+ rw.statusCode = code
26+ rw.ResponseWriter.WriteHeader(code)
27+}
28+
29+func (rw *ResponseWriter) Write(b []byte) (int, error) {
30+ n, err := rw.ResponseWriter.Write(b)
31+ rw.bytes += n
32+ return n, err
33+}
34+
35+func (rw *ResponseWriter) StatusCode() int {
36+ return rw.statusCode
37+}
38+
39+func (rw *ResponseWriter) BytesWritten() int {
40+ return rw.bytes
41+}
42+
43+// RequestLogger is a middleware that logs HTTP requests
44+func RequestLogger(next http.Handler) http.Handler {
45+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46+ start := time.Now()
47+
48+ // Wrap the response writer to capture status and bytes
49+ rw := NewResponseWriter(w)
50+
51+ // Get client IP (handle X-Forwarded-For and X-Real-IP headers)
52+ clientIP := getClientIP(r)
53+
54+ // Call the next handler
55+ next.ServeHTTP(rw, r)
56+
57+ // Calculate duration
58+ duration := time.Since(start)
59+
60+ // Log the request
61+ log.Info().
62+ Str("method", r.Method).
63+ Str("path", r.URL.Path).
64+ Str("query", r.URL.RawQuery).
65+ Str("host", r.Host).
66+ Str("user_agent", r.UserAgent()).
67+ Str("referer", r.Referer()).
68+ Str("client_ip", clientIP).
69+ Int("status", rw.StatusCode()).
70+ Int("bytes", rw.BytesWritten()).
71+ Dur("duration", duration).
72+ Str("proto", r.Proto).
73+ Msg("HTTP Request")
74+ })
75+}
76+
77+// getClientIP extracts the client IP from various headers
78+func getClientIP(r *http.Request) string {
79+ // Check X-Forwarded-For header (comma-separated list, first is original client)
80+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
81+ // Take the first IP (original client)
82+ for idx := 0; idx < len(xff); idx++ {
83+ if xff[idx] == ',' {
84+ return xff[:idx]
85+ }
86+ }
87+ return xff
88+ }
89+
90+ // Check X-Real-IP header
91+ if xri := r.Header.Get("X-Real-IP"); xri != "" {
92+ return xri
93+ }
94+
95+ // Check X-Forwarded header (less common)
96+ if xf := r.Header.Get("X-Forwarded"); xf != "" {
97+ return xf
98+ }
99+
100+ // Fallback to RemoteAddr
101+ return r.RemoteAddr
102+}
103+
104+// ContainerRequestLogger logs requests with container-specific information
105+func ContainerRequestLogger(containerID string, domain string) func(http.Handler) http.Handler {
106+ return func(next http.Handler) http.Handler {
107+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
108+ start := time.Now()
109+
110+ // Wrap the response writer to capture status and bytes
111+ rw := NewResponseWriter(w)
112+
113+ // Get client IP
114+ clientIP := getClientIP(r)
115+
116+ // Call the next handler
117+ next.ServeHTTP(rw, r)
118+
119+ // Calculate duration
120+ duration := time.Since(start)
121+
122+ // Log the request with container information
123+ log.Info().
124+ Str("method", r.Method).
125+ Str("path", r.URL.Path).
126+ Str("query", r.URL.RawQuery).
127+ Str("host", r.Host).
128+ Str("domain", domain).
129+ Str("container_id", containerID).
130+ Str("user_agent", r.UserAgent()).
131+ Str("referer", r.Referer()).
132+ Str("client_ip", clientIP).
133+ Int("status", rw.StatusCode()).
134+ Int("bytes", rw.BytesWritten()).
135+ Dur("duration", duration).
136+ Str("proto", r.Proto).
137+ Msg("Container Request")
138+ })
139+ }
140+}
141+
142+// PanicRecovery middleware recovers from panics and logs them
143+func PanicRecovery(next http.Handler) http.Handler {
144+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
145+ defer func() {
146+ if err := recover(); err != nil {
147+ log.Error().
148+ Interface("panic", err).
149+ Str("method", r.Method).
150+ Str("path", r.URL.Path).
151+ Str("host", r.Host).
152+ Msg("Panic recovered")
153+
154+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
155+ }
156+ }()
157+
158+ next.ServeHTTP(w, r)
159+ })
160+}
161+
162+// CORS middleware adds CORS headers
163+func CORS(next http.Handler) http.Handler {
164+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
165+ w.Header().Set("Access-Control-Allow-Origin", "*")
166+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
167+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
168+
169+ // Handle preflight OPTIONS request
170+ if r.Method == "OPTIONS" {
171+ w.WriteHeader(http.StatusOK)
172+ return
173+ }
174+
175+ next.ServeHTTP(w, r)
176+ })
177+}
178+
179+// Chain combines multiple middleware functions
180+func Chain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
181+ return func(final http.Handler) http.Handler {
182+ for i := len(middlewares) - 1; i >= 0; i-- {
183+ final = middlewares[i](final)
184+ }
185+ return final
186+ }
187+}⏎̸
D internal/proxy/acme.go +0 -606
@@ -1,606 +0,0 @@
1-package proxy
2-
3-import (
4- "crypto"
5- "crypto/tls"
6- "database/sql"
7- "encoding/json"
8- "fmt"
9- "os"
10- "strings"
11- "time"
12-
13- "github.com/bnema/gordon/internal/db/queries"
14- "github.com/bnema/gordon/internal/interfaces"
15- "github.com/bnema/gordon/pkg/logger"
16- "github.com/go-acme/lego/v4/certcrypto"
17- "github.com/go-acme/lego/v4/certificate"
18- "github.com/go-acme/lego/v4/challenge/http01"
19- "github.com/go-acme/lego/v4/lego"
20- "github.com/go-acme/lego/v4/providers/dns"
21- "github.com/go-acme/lego/v4/registration"
22-)
23-
24-// CertManagerConfig holds configuration for the certificate manager
25-type CertManagerConfig struct {
26- CertDir string
27- Email string
28- Mode string
29- SkipCertificates bool
30- AdminDomain string
31- RootDomain string
32- HttpPort string
33-}
34-
35-// CertificateManager handles ACME certificate operations
36-type CertificateManager struct {
37- config CertManagerConfig
38- client *lego.Client
39- user *AcmeUser
40- db *sql.DB
41- queries *queries.ProxyQueries
42-}
43-
44-// AcmeUser implements the registration.User interface
45-type AcmeUser struct {
46- Email string `json:"email"`
47- Registration *registration.Resource `json:"registration"`
48- key crypto.PrivateKey `json:"-"` // Exclude key from JSON marshal
49-}
50-
51-// GetEmail returns the user's email
52-func (u *AcmeUser) GetEmail() string {
53- return u.Email
54-}
55-
56-// GetRegistration returns the user's registration
57-func (u *AcmeUser) GetRegistration() *registration.Resource {
58- return u.Registration
59-}
60-
61-// GetPrivateKey returns the user's private key
62-func (u *AcmeUser) GetPrivateKey() crypto.PrivateKey {
63- return u.key
64-}
65-
66-// NewCertificateManager creates a new certificate manager
67-func NewCertificateManager(config CertManagerConfig, app interfaces.AppInterface, queries *queries.ProxyQueries) (*CertificateManager, error) {
68- logger.Debug("Initializing certificate manager", "config", config)
69-
70- db := app.GetDB() // Get DB connection
71-
72- // --- User Loading/Creation ---
73- user, err := loadAcmeUserFromDB(config.Email, db, queries)
74-
75- if err != nil {
76- // Check if the error is "user not found"
77- if err == sql.ErrNoRows {
78- logger.Info("No existing ACME user found in DB, creating new one.", "email", config.Email)
79-
80- // Create a new key
81- privateKey, keyErr := certcrypto.GeneratePrivateKey(certcrypto.RSA2048)
82- if keyErr != nil {
83- return nil, fmt.Errorf("failed to generate private key: %w", keyErr)
84- }
85-
86- user = &AcmeUser{
87- Email: config.Email,
88- key: privateKey,
89- // Registration will be nil initially
90- }
91-
92- // Save the new user (key only for now, registration later)
93- if saveErr := saveAcmeUserToDB(user, db, queries); saveErr != nil {
94- // Log the error but proceed, registration might fix it or fail later
95- logger.Error("Failed to save newly generated ACME user key to DB", "error", saveErr)
96- // Depending on policy, might want to return error here:
97- // return nil, fmt.Errorf("failed to save initial ACME user: %w", saveErr)
98- } else {
99- logger.Debug("Saved ACME user details to DB", "email", user.Email)
100- }
101- } else {
102- // Handle other DB errors during load
103- return nil, fmt.Errorf("failed to load ACME user from DB: %w", err)
104- }
105- } else {
106- logger.Debug("Loaded existing ACME user from DB", "email", user.Email)
107- }
108- // --- End User Loading/Creation ---
109-
110- // Create the lego config using the loaded/created user
111- legoConfig := lego.NewConfig(user)
112- legoConfig.Certificate.KeyType = certcrypto.RSA2048 // Match key type used
113-
114- // Set the server based on mode
115- if config.Mode == "staging" {
116- logger.Info("Using Let's Encrypt staging environment")
117- legoConfig.CADirURL = lego.LEDirectoryStaging
118- } else {
119- logger.Info("Using Let's Encrypt production environment")
120- legoConfig.CADirURL = lego.LEDirectoryProduction
121- }
122-
123- // Create the lego client
124- client, err := lego.NewClient(legoConfig)
125- if err != nil {
126- return nil, fmt.Errorf("failed to create lego client: %w", err)
127- }
128-
129- // --- ACME User Registration ---
130- if user.Registration == nil {
131- logger.Info("ACME user registration not found, attempting to register", "email", user.Email)
132- reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
133- if err != nil {
134- // Check if it's an "already registered" error, which is fine
135- // Note: Lego might handle this implicitly, but explicit check is safer.
136- // The exact error type/message might vary depending on lego version and ACME server.
137- // We might need to refine this error check based on observed behavior.
138- if strings.Contains(err.Error(), "urn:ietf:params:acme:error:accountDoesNotExist") {
139- // This might indicate a mismatch between loaded key and LE's record.
140- // We might need to re-create the key and re-register, or query the account by key.
141- // For now, just log and return error.
142- logger.Error("ACME account registration failed: Account does not exist (key mismatch?)", "error", err)
143- return nil, fmt.Errorf("failed to register ACME user (account not found): %w", err)
144- } else if strings.Contains(err.Error(), "user already exists") { // Example, adjust as needed
145- logger.Info("ACME user is already registered with the server.")
146- // Try to resolve the account details if possible
147- // reg, err = client.Registration.ResolveAccountByKey() // Requires lego client supporting this
148- // if err != nil {
149- // logger.Error("Failed to resolve existing ACME account by key", "error", err)
150- // return nil, fmt.Errorf("failed to resolve existing ACME account: %w", err)
151- // }
152- // For now, let's assume if registration failed with "exists", we might need manual intervention
153- // or a more sophisticated recovery mechanism. Returning error for safety.
154- // If Obtain works despite this, we can reconsider.
155- logger.Error("ACME user registration failed, but server indicates user exists. Manual check might be needed.", "error", err)
156- return nil, fmt.Errorf("failed to register ACME user (already exists conflict): %w", err)
157- } else {
158- // Handle other registration errors
159- logger.Error("ACME user registration failed", "error", err)
160- return nil, fmt.Errorf("failed to register ACME user: %w", err)
161- }
162- } else {
163- logger.Info("ACME user registered successfully", "uri", reg.URI)
164- user.Registration = reg // Store the registration resource
165-
166- // Save the user again, now with registration info
167- if saveErr := saveAcmeUserToDB(user, db, queries); saveErr != nil {
168- logger.Error("Failed to save ACME user registration info to DB", "error", saveErr)
169- // Continue, as registration itself succeeded, but log the failure
170- } else {
171- logger.Debug("Saved ACME user registration info to DB")
172- }
173- }
174- } else {
175- logger.Debug("Existing ACME user registration loaded from DB", "uri", user.Registration.URI)
176- // Optional: Could add a check here to ensure the registration is still valid
177- }
178- // --- End ACME User Registration ---
179-
180- // Create the certificate manager struct
181- cm := &CertificateManager{
182- config: config,
183- user: user,
184- client: client,
185- db: db,
186- queries: queries,
187- }
188-
189- return cm, nil
190-}
191-
192-// loadAcmeUserFromDB loads the user's private key and registration info from the DB
193-func loadAcmeUserFromDB(email string, db *sql.DB, queries *queries.ProxyQueries) (*AcmeUser, error) {
194- var privateKeyPEM string
195- var registrationJSON sql.NullString
196-
197- err := db.QueryRow(queries.GetAcmeAccountByEmail, email).Scan(&privateKeyPEM, &registrationJSON)
198- if err != nil {
199- if err == sql.ErrNoRows {
200- logger.Debug("ACME user not found in DB", "email", email)
201- return nil, sql.ErrNoRows // Return specific error
202- }
203- return nil, fmt.Errorf("failed to query ACME user: %w", err)
204- }
205-
206- // Add detailed logging for the loaded registration info
207- logger.Debug("Raw registration_info from DB", "email", email, "Valid", registrationJSON.Valid, "String", registrationJSON.String)
208-
209- // Parse the private key
210- privateKey, err := certcrypto.ParsePEMPrivateKey([]byte(privateKeyPEM))
211- if err != nil {
212- return nil, fmt.Errorf("failed to parse private key from DB: %w", err)
213- }
214-
215- user := &AcmeUser{
216- Email: email,
217- key: privateKey,
218- }
219-
220- // Load the registration info if it exists
221- if registrationJSON.Valid && registrationJSON.String != "" {
222- // Need to unmarshal into a registration.Resource pointer
223- reg := &registration.Resource{}
224- err = json.Unmarshal([]byte(registrationJSON.String), reg)
225- if err != nil {
226- // Log the error but potentially continue without registration? Or return error?
227- // Returning error is safer.
228- logger.Error("Failed to unmarshal ACME registration info from DB", "email", email, "error", err)
229- return nil, fmt.Errorf("failed to unmarshal registration info: %w", err)
230- }
231- user.Registration = reg // Assign the pointer
232- logger.Debug("Loaded ACME user registration info from DB", "email", email, "uri", user.Registration.URI)
233- } else {
234- logger.Debug("ACME user registration info not found or null in DB", "email", email)
235- }
236-
237- return user, nil
238-}
239-
240-// saveAcmeUserToDB saves the user's private key and registration info to the DB
241-func saveAcmeUserToDB(user *AcmeUser, db *sql.DB, queries *queries.ProxyQueries) error {
242- if user.key == nil {
243- return fmt.Errorf("cannot save user without a private key")
244- }
245-
246- // Encode private key to PEM
247- privateKeyPEM := certcrypto.PEMEncode(user.key)
248-
249- // Marshal registration info to JSON (handle nil)
250- var registrationJSON sql.NullString
251- if user.Registration != nil {
252- regBytes, err := json.Marshal(user.Registration)
253- if err != nil {
254- return fmt.Errorf("failed to marshal registration info: %w", err)
255- }
256- registrationJSON = sql.NullString{String: string(regBytes), Valid: true}
257- }
258-
259- now := time.Now().Format(time.RFC3339)
260-
261- // Use InsertOrReplace query
262- _, err := db.Exec(queries.InsertOrReplaceAcmeAccount,
263- user.Email,
264- string(privateKeyPEM),
265- registrationJSON, // Pass the NullString directly
266- now, // created_at (will be updated on replace)
267- now, // updated_at
268- )
269-
270- if err != nil {
271- return fmt.Errorf("failed to save ACME user to DB: %w", err)
272- }
273-
274- logger.Debug("Saved ACME user details to DB", "email", user.Email)
275- return nil
276-}
277-
278-// SetupChallengeProvider sets up the appropriate challenge provider for a domain
279-func (cm *CertificateManager) SetupChallengeProvider(domainName string, proxy *Proxy) error {
280- // Get domain configuration from database
281- var domain struct {
282- AcmeEnabled bool
283- AcmeChallengeType sql.NullString
284- AcmeDnsProvider sql.NullString
285- AcmeDnsCredentialsRef sql.NullString
286- }
287-
288- err := proxy.app.GetDB().QueryRow(`
289- SELECT acme_enabled, acme_challenge_type, acme_dns_provider, acme_dns_credentials_ref
290- FROM domain WHERE name = ?
291- `, domainName).Scan(&domain.AcmeEnabled, &domain.AcmeChallengeType, &domain.AcmeDnsProvider, &domain.AcmeDnsCredentialsRef)
292-
293- if err != nil {
294- return fmt.Errorf("failed to get domain configuration: %w", err)
295- }
296-
297- // Determine challenge type
298- challengeType := domain.AcmeChallengeType.String
299- if !domain.AcmeChallengeType.Valid {
300- challengeType = proxy.config.DefaultChallengeType
301- }
302-
303- // Set up the appropriate challenge provider
304- switch challengeType {
305- case "http-01":
306- // Use the configured HTTP challenge port
307- port := proxy.config.DefaultHttpChallengePort
308- if port == "" {
309- port = "80"
310- }
311- provider := http01.NewProviderServer("", port)
312- if err := cm.client.Challenge.SetHTTP01Provider(provider); err != nil {
313- return fmt.Errorf("failed to set HTTP-01 provider: %w", err)
314- }
315-
316- case "dns-01":
317- if domain.AcmeDnsProvider.String == "" {
318- return fmt.Errorf("DNS provider not configured for domain %s", domainName)
319- }
320-
321- // Get DNS provider credentials from environment
322- creds := cm.getDNSProviderCredentials(domain.AcmeDnsCredentialsRef.String)
323- if creds == nil {
324- return fmt.Errorf("DNS provider credentials not found for domain %s", domainName)
325- }
326-
327- // Create and configure the DNS provider
328- provider, err := dns.NewDNSChallengeProviderByName(domain.AcmeDnsProvider.String)
329- if err != nil {
330- return fmt.Errorf("failed to create DNS provider: %w", err)
331- }
332-
333- // Configure the provider with credentials
334- if err := provider.Present(domainName, "", ""); err != nil {
335- return fmt.Errorf("failed to configure DNS provider: %w", err)
336- }
337-
338- // Set DNS timeouts
339- if dnsProvider, ok := provider.(interface {
340- SetPropagationTimeout(time.Duration)
341- SetPollingInterval(time.Duration)
342- }); ok {
343- dnsProvider.SetPropagationTimeout(
344- time.Duration(proxy.config.DefaultDnsChallengePropagationTimeout) * time.Second,
345- )
346- dnsProvider.SetPollingInterval(
347- time.Duration(proxy.config.DefaultDnsChallengePollingInterval) * time.Second,
348- )
349- }
350-
351- if err := cm.client.Challenge.SetDNS01Provider(provider); err != nil {
352- return fmt.Errorf("failed to set DNS-01 provider: %w", err)
353- }
354-
355- default:
356- return fmt.Errorf("unsupported challenge type: %s", challengeType)
357- }
358-
359- return nil
360-}
361-
362-// GetKeyAuthorization returns the key authorization for a token
363-func (cm *CertificateManager) GetKeyAuthorization(token string) (string, error) {
364- // Create a new HTTP-01 provider server
365- provider := http01.NewProviderServer("", cm.config.HttpPort)
366-
367- // Present the challenge
368- err := provider.Present("", token, "")
369- if err != nil {
370- return "", fmt.Errorf("failed to present challenge: %w", err)
371- }
372-
373- // Get the key authorization from the provider
374- path := http01.ChallengePath(token)
375- return path, nil
376-}
377-
378-// ObtainCertificate obtains a new certificate for a domain
379-func (cm *CertificateManager) ObtainCertificate(domainName string, proxy *Proxy) (*certificate.Resource, error) {
380- logger.Debug("Obtaining certificate for domain", "domain", domainName)
381-
382- // Set up the challenge provider
383- if err := cm.SetupChallengeProvider(domainName, proxy); err != nil {
384- return nil, fmt.Errorf("failed to set up challenge provider: %w", err)
385- }
386-
387- // Request the certificate
388- request := certificate.ObtainRequest{
389- Domains: []string{domainName},
390- Bundle: true,
391- }
392-
393- cert, err := cm.client.Certificate.Obtain(request)
394- if err != nil {
395- return nil, fmt.Errorf("failed to obtain certificate: %w", err)
396- }
397-
398- // Update the certificate in the database
399- if err := cm.updateCertificateInDB(domainName, cert, proxy); err != nil {
400- logger.Error("Failed to update certificate in database", "error", err)
401- }
402-
403- return cert, nil
404-}
405-
406-// updateCertificateInDB updates the certificate information in the database
407-func (cm *CertificateManager) updateCertificateInDB(domainName string, cert *certificate.Resource, proxy *Proxy) error {
408- // Get the domain ID
409- var domainID string
410- err := proxy.app.GetDB().QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID)
411- if err != nil {
412- return fmt.Errorf("failed to get domain ID: %w", err)
413- }
414-
415- // Parse the certificate to get expiry date
416- x509Cert, err := certcrypto.ParsePEMCertificate(cert.Certificate)
417- if err != nil {
418- return fmt.Errorf("failed to parse certificate: %w", err)
419- }
420-
421- // Update or insert the certificate
422- _, err = proxy.app.GetDB().Exec(`
423- INSERT OR REPLACE INTO certificate (
424- id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status
425- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
426- `,
427- cert.Domain,
428- domainID,
429- string(cert.Certificate),
430- string(cert.PrivateKey),
431- time.Now().Format(time.RFC3339),
432- x509Cert.NotAfter.Format(time.RFC3339),
433- "Let's Encrypt",
434- "valid",
435- )
436-
437- if err != nil {
438- return fmt.Errorf("failed to update certificate in database: %w", err)
439- }
440-
441- return nil
442-}
443-
444-// RenewCertificate renews an existing certificate
445-func (cm *CertificateManager) RenewCertificate(domainName string, proxy *Proxy) (*certificate.Resource, error) {
446- logger.Debug("Renewing certificate for domain", "domain", domainName)
447-
448- // Set up the challenge provider
449- if err := cm.SetupChallengeProvider(domainName, proxy); err != nil {
450- return nil, fmt.Errorf("failed to set up challenge provider: %w", err)
451- }
452-
453- // Get the existing certificate from the database
454- var certFile, keyFile string
455- err := proxy.app.GetDB().QueryRow(`
456- SELECT cert_file, key_file FROM certificate c
457- JOIN domain d ON c.domain_id = d.id
458- WHERE d.name = ?
459- `, domainName).Scan(&certFile, &keyFile)
460- if err != nil {
461- return nil, fmt.Errorf("failed to get existing certificate: %w", err)
462- }
463-
464- // Create certificate resource for renewal
465- cert := &certificate.Resource{
466- Domain: domainName,
467- Certificate: []byte(certFile),
468- PrivateKey: []byte(keyFile),
469- }
470-
471- // Renew the certificate
472- _, err = cm.client.Certificate.Renew(*cert, true, false, "")
473- if err != nil {
474- return nil, fmt.Errorf("failed to renew certificate: %w", err)
475- }
476-
477- // Update the certificate in the database
478- if err := cm.updateCertificateInDB(domainName, cert, proxy); err != nil {
479- logger.Error("Failed to update certificate in database", "error", err)
480- }
481-
482- return cert, nil
483-}
484-
485-// GetCertificateForTLS returns a certificate for TLS handshake
486-func (cm *CertificateManager) GetCertificateForTLS(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
487- logger.Debug("Getting certificate for TLS handshake", "server_name", hello.ServerName)
488-
489- // Get the certificate resource from the database
490- certResource, err := cm.GetCertificate(hello.ServerName)
491- if err != nil {
492- // If the certificate is specifically not found in the DB,
493- // return nil, nil to indicate no certificate is available for this ServerName.
494- // This allows the TLS handshake to potentially use other certificates or fail gracefully.
495- if err == sql.ErrNoRows {
496- logger.Debug("Certificate not found in DB during TLS handshake", "server_name", hello.ServerName)
497- return nil, nil // Indicate no certificate available for this name
498- }
499- // For other errors (DB connection issues, etc.), return the error.
500- logger.Error("Error getting certificate during TLS handshake", "server_name", hello.ServerName, "error", err)
501- return nil, fmt.Errorf("failed to get certificate for %s: %w", hello.ServerName, err)
502- }
503-
504- // Convert the retrieved certificate resource to tls.Certificate
505- tlsCert, err := tls.X509KeyPair(certResource.Certificate, certResource.PrivateKey)
506- if err != nil {
507- logger.Error("Failed to create key pair from retrieved certificate", "server_name", hello.ServerName, "error", err)
508- return nil, fmt.Errorf("failed to parse retrieved certificate for %s: %w", hello.ServerName, err)
509- }
510-
511- return &tlsCert, nil
512-}
513-
514-// GetCertificate retrieves a certificate for a domain from the database
515-func (cm *CertificateManager) GetCertificate(domainName string) (*certificate.Resource, error) {
516- logger.Debug("Attempting to retrieve certificate from DB", "domain", domainName)
517- var certPEM, keyPEM string
518- // Add dummy variables to scan the extra columns we don't need in this function
519- var issuedAt, expiresAt, issuer, status sql.NullString // Use sql.NullString or appropriate types
520-
521- // Query the database using the predefined query (which now returns 6 columns)
522- err := cm.db.QueryRow(cm.queries.GetCertificateByDomain, domainName).Scan(
523- &certPEM, &keyPEM, // The columns we need
524- &issuedAt, &expiresAt, &issuer, &status, // Dummy scans for the rest
525- )
526-
527- if err != nil {
528- // Check for ErrNoRows *after* attempting the scan
529- if err == sql.ErrNoRows {
530- logger.Warn("Certificate not found in DB for domain, attempting to obtain.", "domain", domainName)
531- // Certificate not found, try obtaining a new one.
532- // Note: Need the 'proxy' instance here. This function signature might need adjustment,
533- // or the obtaining logic should be handled higher up (e.g., in GetCertificateForTLS or the proxy core).
534- // For now, returning an error to highlight this dependency.
535- // Alternatively, we could pass the proxy instance or necessary config down.
536- // Let's return a specific error indicating it needs obtaining.
537- return nil, fmt.Errorf("certificate not found in DB for domain %s, obtain needed", domainName)
538-
539- // --- Alternative: Obtain directly (Requires Proxy instance or refactor) ---
540- // proxyInstance := ??? // How to get the proxy instance here?
541- // if proxyInstance == nil {
542- // return nil, fmt.Errorf("cannot obtain certificate without proxy instance in GetCertificate")
543- // }
544- // obtainedCert, obtainErr := cm.ObtainCertificate(domainName, proxyInstance)
545- // if obtainErr != nil {
546- // logger.Error("Failed to obtain certificate after not finding in DB", "domain", domainName, "error", obtainErr)
547- // return nil, fmt.Errorf("certificate not found and obtain failed for %s: %w", domainName, obtainErr)
548- // }
549- // logger.Info("Successfully obtained certificate after not finding in DB", "domain", domainName)
550- // return obtainedCert, nil
551- // --- End Alternative ---
552-
553- }
554- logger.Error("Failed to query certificate from DB", "domain", domainName, "error", err)
555- return nil, fmt.Errorf("database error retrieving certificate for %s: %w", domainName, err)
556- }
557-
558- logger.Debug("Successfully retrieved certificate from DB", "domain", domainName)
559-
560- // Construct the certificate resource
561- cert := &certificate.Resource{
562- Domain: domainName,
563- CertURL: "", // Not stored/retrieved from DB in this example
564- CertStableURL: "", // Not stored/retrieved from DB
565- PrivateKey: []byte(keyPEM),
566- Certificate: []byte(certPEM),
567- IssuerCertificate: nil, // Not typically stored/needed for serving
568- CSR: nil, // Not stored
569- }
570-
571- return cert, nil
572-}
573-
574-// GetAutocertManager returns the autocert manager
575-func (cm *CertificateManager) GetAutocertManager() *tls.Config {
576- return &tls.Config{
577- GetCertificate: cm.GetCertificateForTLS,
578- MinVersion: tls.VersionTLS12,
579- }
580-}
581-
582-// getDNSProviderCredentials retrieves DNS provider credentials from environment variables
583-func (cm *CertificateManager) getDNSProviderCredentials(ref string) map[string]string {
584- if ref == "" {
585- return nil
586- }
587-
588- creds := make(map[string]string)
589- prefix := fmt.Sprintf("GORDON_DNS_CRED_%s_", ref)
590-
591- for _, env := range os.Environ() {
592- if strings.HasPrefix(env, prefix) {
593- parts := strings.SplitN(env, "=", 2)
594- if len(parts) == 2 {
595- key := strings.TrimPrefix(parts[0], prefix)
596- creds[key] = parts[1]
597- }
598- }
599- }
600-
601- if len(creds) == 0 {
602- return nil
603- }
604-
605- return creds
606-}
D internal/proxy/containers.go +0 -158
@@ -1,158 +0,0 @@
1-package proxy
2-
3-import (
4- "os"
5- "strings"
6-
7- "github.com/bnema/gordon/pkg/docker"
8- "github.com/bnema/gordon/pkg/logger"
9-)
10-
11-// scanContainersAndCheckCertificates scans all containers from the Gordon network
12-// and checks if we have certificates for their domains and subdomains
13-func (p *Proxy) scanContainersAndCheckCertificates() error {
14- networkName := p.app.GetConfig().ContainerEngine.Network
15- if networkName == "" {
16- // If no network is defined, skip container scanning
17- logger.Info("No container network defined, skipping certificate checks")
18- return nil
19- }
20-
21- logger.Info("Scanning containers in network for certificate checks",
22- "network", networkName)
23-
24- // Try to get network info using the docker client
25- networkInfo, err := docker.GetNetworkInfo(networkName)
26- if err != nil {
27- logger.Warn("Failed to get network info, skipping certificate checks",
28- "error", err,
29- "network", networkName)
30- return nil // Return nil instead of error to continue execution
31- }
32-
33- // Skip admin cert check if environment variable is set
34- skipAdminCertCheck := os.Getenv("GORDON_SKIP_ADMIN_CERT_CHECK") == "true"
35- if skipAdminCertCheck {
36- logger.Info("Skipping admin certificate check as GORDON_SKIP_ADMIN_CERT_CHECK=true")
37- }
38-
39- // Get all containers in the network
40- for containerID, containerEndpoint := range networkInfo.Containers {
41- containerName := strings.TrimPrefix(containerEndpoint.Name, "/")
42-
43- // Get container info to check for relevant domains
44- containerInfo, err := docker.GetContainerInfo(containerID)
45- if err != nil {
46- logger.Warn("Failed to get container info",
47- "containerID", containerID,
48- "containerName", containerName,
49- "error", err)
50- continue
51- }
52-
53- // Extract domains from container
54- // First check if there are labels with domain information
55- domains := []string{}
56-
57- // Check common domain-related labels
58- if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" {
59- // Remove any leading dot from domain label
60- domainLabel = strings.TrimPrefix(domainLabel, ".")
61-
62- // Always add the base domain
63- domains = append(domains, domainLabel)
64-
65- // Also check if there's a service label to use as a subdomain
66- if serviceLabel, serviceExists := containerInfo.Config.Labels["gordon.service"]; serviceExists && serviceLabel != "" {
67- // Combine service name with domain to create service.domain format
68- serviceSubdomain := serviceLabel + "." + domainLabel
69- domains = append(domains, serviceSubdomain)
70- logger.Debug("Created additional domain from service label",
71- "domain", serviceSubdomain,
72- "service", serviceLabel,
73- "base_domain", domainLabel)
74- }
75- }
76-
77- // Check environment variables for domain information
78- for _, env := range containerInfo.Config.Env {
79- if strings.HasPrefix(env, "DOMAIN=") || strings.HasPrefix(env, "VIRTUAL_HOST=") {
80- parts := strings.SplitN(env, "=", 2)
81- if len(parts) == 2 && parts[1] != "" {
82- // Multiple domains might be comma-separated
83- for _, domain := range strings.Split(parts[1], ",") {
84- // Remove any leading dot before adding the domain
85- cleanDomain := strings.TrimPrefix(strings.TrimSpace(domain), ".")
86- domains = append(domains, cleanDomain)
87- }
88- }
89- }
90- }
91-
92- // If no domains were found, use the container name
93- if len(domains) == 0 {
94- // Check if there is a proxy route for this container
95- var found bool
96- for domain, routeInfo := range p.routes {
97- if routeInfo.ContainerID == containerID {
98- domains = append(domains, domain)
99- found = true
100- }
101- }
102-
103- // If no route was found, use container name as fallback
104- if !found {
105- // Use container name as subdomain of the default domain
106- if defaultDomain := p.app.GetConfig().Http.Domain; defaultDomain != "" {
107- domains = append(domains, containerName+"."+defaultDomain)
108- }
109- }
110- }
111-
112- // Check certificates for each domain
113- for _, domain := range domains {
114- // With the refactored CertificateManager, we don't need to manually check
115- // the cache here. We can just attempt to ensure a certificate exists
116- // by calling requestDomainCertificate, which delegates to GetCertificate.
117- // GetCertificate will use autocert's cache and request a new cert only if needed.
118-
119- logger.Debug("Ensuring certificate exists for domain",
120- "domain", domain,
121- "container", containerName)
122-
123- // Request certificate (non-blocking).
124- // GetCertificate handles checking cache/validity and requesting if necessary.
125- go p.requestDomainCertificate(domain) // Simplified call
126-
127- // If this is a subdomain, ensure the parent domain cert exists too
128- parts := strings.Split(domain, ".")
129- if len(parts) >= 3 {
130- parentDomain := strings.Join(parts[1:], ".")
131-
132- // Only check parent domain if it's not an ordinary TLD
133- // and not the same as the root domain (to avoid redundant checks)
134- if len(parts) > 2 && !isCommonTLD(parts[len(parts)-1]) && parentDomain != p.app.GetConfig().Http.Domain {
135- logger.Debug("Ensuring certificate exists for parent domain",
136- "parent_domain", parentDomain,
137- "original_domain", domain,
138- "container", containerName)
139-
140- // Request certificate for parent domain also
141- go p.requestDomainCertificate(parentDomain) // Simplified call
142- }
143- }
144- }
145- }
146-
147- return nil
148-}
149-
150-// isCommonTLD checks if a string is a common top-level domain
151-func isCommonTLD(tld string) bool {
152- commonTLDs := map[string]bool{
153- "com": true, "org": true, "net": true, "io": true, "co": true,
154- "ai": true, "app": true, "dev": true, "edu": true, "gov": true,
155- "info": true, "name": true, "biz": true, "me": true,
156- }
157- return commonTLDs[tld]
158-}
D internal/proxy/core.go +0 -357
@@ -1,357 +0,0 @@
1-package proxy
2-
3-import (
4- "context"
5- "fmt"
6- "net"
7- "sync"
8- "time"
9-
10- "crypto/tls"
11- "database/sql"
12- "net/http"
13-
14- "github.com/bnema/gordon/internal/common"
15- "github.com/bnema/gordon/internal/db/queries"
16- "github.com/bnema/gordon/internal/interfaces"
17- "github.com/bnema/gordon/pkg/docker"
18- "github.com/bnema/gordon/pkg/logger"
19- "github.com/labstack/echo/v4"
20-)
21-
22-// - core.go - Core proxy structure and constructor
23-// - certificates.go - Certificate management functions
24-// - routes.go - Route management functions
25-// - middleware.go - HTTP middleware functions
26-// - server.go - Server startup and shutdown
27-// - utils.go - Utility functions
28-
29-// Proxy represents the reverse proxy server
30-type Proxy struct {
31- config common.ReverseProxyConfig
32- app interfaces.AppInterface
33- httpsServer *echo.Echo
34- routes map[string]*ProxyRouteInfo
35- mu sync.RWMutex
36- serverStarted bool
37- ctx context.Context // Context for storing shared data
38-
39- // Flag to track specific domain certificate operations (May need review if CertManager handles this)
40- processingSpecificDomain bool
41-
42- // Store Gordon's own container ID at startup for reliable identification
43- gordonContainerID string
44-
45- reverseProxyClient *http.Client
46- dbMaxRetries int
47- dbRetryBaseDelay time.Duration
48- shutdown chan struct{} // Channel for signaling shutdown to background goroutines
49-
50- // Track recently created containers to prevent recreation logic
51- recentContainers map[string]time.Time
52- recentContainersMu sync.RWMutex
53- containerCooldownPeriod time.Duration
54-
55- // Certificate Management
56- certificateManager *CertificateManager // New refactored manager
57-
58- // SQL Queries
59- Queries *queries.ProxyQueries
60-}
61-
62-// NewProxy creates a new instance of the reverse proxy
63-func NewProxy(app interfaces.AppInterface) (*Proxy, error) {
64- logger.Debug("Initializing reverse proxy")
65-
66- // Set up the config
67- config := app.GetConfig().ReverseProxy
68-
69- // Check if proxy is disabled through configuration
70- if !config.Enabled {
71- logger.Info("Reverse proxy is disabled via configuration")
72- // Return a non-active proxy instance
73- return &Proxy{
74- config: config,
75- app: app,
76- serverStarted: false,
77- shutdown: make(chan struct{}),
78- ctx: context.Background(),
79- }, nil
80- }
81-
82- // Check if running inside a container
83- isContainer := docker.IsRunningInContainer()
84- logger.Debug("Container environment check", "is_running_in_container", isContainer)
85-
86- if !isContainer {
87- logger.Info("Gordon is not running in a container, proxy will be disabled automatically")
88- // Override the enabled setting to false
89- config.Enabled = false
90- return &Proxy{
91- config: config,
92- app: app,
93- serverStarted: false,
94- shutdown: make(chan struct{}),
95- ctx: context.Background(),
96- }, nil
97- }
98-
99- // Set up the echo server for HTTPS traffic
100- httpsServer := echo.New()
101- httpsServer.HideBanner = true
102-
103- // Initialize container storage and cooldown settings
104- recentContainers := make(map[string]time.Time)
105- containerCooldownPeriod := time.Duration(10) * time.Second
106- // Use a fixed cooldown period of 10 seconds (currently no config option for this)
107-
108- // Set up DB retry configuration
109- dbMaxRetries := 3
110- dbRetryBaseDelay := time.Duration(500) * time.Millisecond
111-
112- // Initialize the reverseProxyClient with reasonable timeouts
113- reverseProxyClient := &http.Client{
114- Timeout: 30 * time.Second,
115- Transport: &http.Transport{
116- TLSClientConfig: &tls.Config{
117- InsecureSkipVerify: true,
118- },
119- // Add more detailed timeout settings
120- TLSHandshakeTimeout: 10 * time.Second,
121- ResponseHeaderTimeout: 10 * time.Second,
122- ExpectContinueTimeout: 5 * time.Second,
123- // Add a custom dialer with explicit timeouts
124- DialContext: (&net.Dialer{
125- Timeout: 10 * time.Second,
126- KeepAlive: 30 * time.Second,
127- DualStack: true,
128- }).DialContext,
129- MaxIdleConns: 100,
130- MaxIdleConnsPerHost: 10,
131- IdleConnTimeout: 90 * time.Second,
132- ForceAttemptHTTP2: true,
133- },
134- }
135-
136- // Initialize the proxy queries
137- proxyQueries := queries.NewProxyQueries()
138-
139- // Create the proxy instance
140- p := &Proxy{
141- config: config,
142- app: app,
143- httpsServer: httpsServer,
144- // httpServer: httpServer, // Removed assignment
145- routes: make(map[string]*ProxyRouteInfo),
146- reverseProxyClient: reverseProxyClient,
147- dbMaxRetries: dbMaxRetries,
148- dbRetryBaseDelay: dbRetryBaseDelay,
149- recentContainers: recentContainers,
150- containerCooldownPeriod: containerCooldownPeriod,
151- shutdown: make(chan struct{}),
152- Queries: proxyQueries,
153- ctx: context.Background(),
154- }
155-
156- // --- Certificate Manager Setup ---
157- // Determine cert directory
158- certDir := config.CertDir
159- if certDir == "" {
160- certDir = app.GetConfig().General.StorageDir + "/certs"
161- }
162-
163- // Create Certificate Manager Config
164- certManagerConfig := CertManagerConfig{
165- CertDir: certDir,
166- Email: config.Email,
167- Mode: config.LetsEncryptMode, // staging or production
168- SkipCertificates: config.SkipCertificates,
169- AdminDomain: app.GetConfig().Http.FullDomain(),
170- RootDomain: app.GetConfig().Http.Domain,
171- HttpPort: config.DefaultHttpChallengePort,
172- // RouteValidator: p.isHostInRoutes, // TODO: Pass a function if needed by hostPolicy
173- }
174-
175- // Only initialize Certificate Manager if SkipCertificates is false
176- if !config.SkipCertificates {
177- // Create the Certificate Manager
178- certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries)
179- if err != nil {
180- logger.Error("Failed to initialize Certificate Manager", "error", err)
181- // Return an error to prevent starting a broken proxy
182- return nil, fmt.Errorf("failed to initialize certificate manager: %w", err)
183- } else {
184- logger.Debug("Certificate manager successfully initialized")
185- p.certificateManager = certMgr // Store the new manager
186- }
187-
188- // Ensure admin domain has proper ACME configuration only if manager was created
189- if err := p.ensureAdminDomainConfig(); err != nil {
190- logger.Error("Failed to ensure admin domain configuration", "error", err)
191- return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err)
192- }
193- } else {
194- logger.Info("Skipping certificate manager initialization because SkipCertificates is true")
195- }
196-
197- // --- End Certificate Manager Setup ---
198-
199- // Detect our container ID for later use
200- p.detectGordonContainer()
201-
202- return p, nil
203-}
204-
205-// Close cleans up resources used by the proxy
206-func (p *Proxy) Close() {
207- // Signal all background goroutines to stop
208- close(p.shutdown)
209-
210- if p.httpsServer != nil {
211- if err := p.httpsServer.Close(); err != nil {
212- logger.Error("Error closing HTTPS server", "error", err)
213- }
214- }
215-
216- logger.Info("Proxy resources cleaned up")
217-}
218-
219-// requestDomainCertificate requests a certificate for a domain
220-func (p *Proxy) requestDomainCertificate(domainName string) error {
221- // If certificate acquisition is globally skipped, do nothing.
222- if p.config.SkipCertificates {
223- logger.Debug("Skipping certificate request because SkipCertificates is true", "domain", domainName)
224- return nil
225- }
226-
227- logger.Debug("Requesting certificate for domain", "domain", domainName)
228-
229- // Check if we're already processing this domain
230- if p.processingSpecificDomain {
231- logger.Debug("Already processing a specific domain, skipping", "domain", domainName)
232- return nil
233- }
234-
235- // Get domain configuration
236- var acmeEnabled bool
237- // Use sql.NullString for potentially nullable string columns
238- var acmeChallengeType, acmeDnsProvider, acmeDnsCredentialsRef sql.NullString
239- err := p.app.GetDB().QueryRow(p.Queries.GetDomainAcmeConfig, domainName).Scan(
240- &acmeEnabled, &acmeChallengeType, &acmeDnsProvider, &acmeDnsCredentialsRef,
241- )
242- if err != nil {
243- // Handle case where the domain might not exist in the acme_configs table yet
244- if err == sql.ErrNoRows {
245- logger.Debug("No ACME configuration found for domain, assuming ACME is disabled", "domain", domainName)
246- return nil // Treat as ACME disabled if no config exists
247- }
248- logger.Error("Failed to get domain ACME configuration", "domain", domainName, "error", err)
249- return err
250- }
251-
252- if !acmeEnabled {
253- logger.Debug("ACME not enabled for domain", "domain", domainName)
254- return nil
255- }
256-
257- // Check if certificate exists and is valid
258- var certFile, keyFile string
259- var issuedAtStr, expiresAtStr string // Read timestamps as strings first
260- var issuer, status string
261- var issuedAt, expiresAt time.Time // Keep these for use after parsing
262-
263- // Scan from DB, reading timestamps as strings
264- err = p.app.GetDB().QueryRow(p.Queries.GetCertificateByDomain, domainName).Scan(
265- &certFile, &keyFile, &issuedAtStr, &expiresAtStr, &issuer, &status,
266- )
267-
268- // Handle potential sql.ErrNoRows if the certificate doesn't exist yet
269- if err != nil && err != sql.ErrNoRows {
270- logger.Error("Failed to check existing certificate", "domain", domainName, "error", err)
271- return err
272- }
273-
274- // If a certificate was found (no error or ErrNoRows), parse the timestamps
275- if err == nil {
276- issuedAt, err = time.Parse(time.RFC3339, issuedAtStr)
277- if err != nil {
278- logger.Error("Failed to parse issued_at timestamp from DB", "domain", domainName, "value", issuedAtStr, "error", err)
279- // Return the parse error as it indicates data corruption or an unexpected format
280- return fmt.Errorf("failed to parse issued_at for domain %s: %w", domainName, err)
281- }
282- expiresAt, err = time.Parse(time.RFC3339, expiresAtStr)
283- if err != nil {
284- logger.Error("Failed to parse expires_at timestamp from DB", "domain", domainName, "value", expiresAtStr, "error", err)
285- // Return the parse error
286- return fmt.Errorf("failed to parse expires_at for domain %s: %w", domainName, err)
287- }
288- logger.Debug("Successfully scanned and parsed certificate timestamps", "domain", domainName, "issued", issuedAt, "expires", expiresAt)
289- }
290- // Note: If err was sql.ErrNoRows, issuedAt/expiresAt will remain zero time.Time, which is handled correctly later.
291-
292- // If certificate exists and is valid
293- if err == nil && status == "valid" {
294- // Check if certificate needs renewal
295- renewBefore := time.Duration(p.config.RenewBefore) * 24 * time.Hour
296- if time.Now().Add(renewBefore).Before(expiresAt) {
297- logger.Debug("Certificate is still valid and does not need renewal",
298- "domain", domainName,
299- "expires_at", expiresAt,
300- "renew_before_days", p.config.RenewBefore)
301- return nil // Certificate is valid, no action needed
302- }
303- logger.Debug("Certificate requires renewal", "domain", domainName, "expires_at", expiresAt)
304- } else if err == sql.ErrNoRows {
305- logger.Debug("No existing certificate found for domain", "domain", domainName)
306- } else {
307- logger.Debug("Existing certificate is not valid", "domain", domainName, "status", status, "scan_error", err)
308- }
309-
310- // --- Proceed with obtaining/renewing certificate ---
311-
312- // Check if required ACME config fields are present now that we know ACME is enabled
313- if !acmeChallengeType.Valid || acmeChallengeType.String == "" {
314- err := fmt.Errorf("ACME challenge type is missing for domain %s", domainName)
315- logger.Error(err.Error())
316- return err
317- }
318- // If DNS challenge, provider and credentials ref are required
319- if acmeChallengeType.String == "dns-01" {
320- if !acmeDnsProvider.Valid || acmeDnsProvider.String == "" {
321- err := fmt.Errorf("ACME DNS provider is missing for domain %s using dns-01 challenge", domainName)
322- logger.Error(err.Error())
323- return err
324- }
325- if !acmeDnsCredentialsRef.Valid || acmeDnsCredentialsRef.String == "" {
326- err := fmt.Errorf("ACME DNS credentials reference is missing for domain %s using dns-01 challenge", domainName)
327- logger.Error(err.Error())
328- return err
329- }
330- // Log the DNS provider being used (optional, but helpful for debugging)
331- logger.Debug("Using DNS provider for ACME challenge", "domain", domainName, "provider", acmeDnsProvider.String)
332- }
333-
334- // Set processing flag
335- p.processingSpecificDomain = true
336- defer func() {
337- p.processingSpecificDomain = false
338- }()
339-
340- // Request new certificate using the CertificateManager
341- cert, err := p.certificateManager.ObtainCertificate(domainName, p) // Pass 'p' (Proxy instance)
342- if err != nil {
343- logger.Error("Failed to obtain/renew certificate", "domain", domainName, "error", err)
344- // Update certificate status to 'failed' in DB?
345- // _, dbErr := p.app.GetDB().Exec(p.Queries.UpdateCertificateStatus, "failed", domainName)
346- // if dbErr != nil {
347- // logger.Error("Failed to update certificate status after obtain failure", "domain", domainName, "db_error", dbErr)
348- // }
349- return err // Return the obtain error
350- }
351-
352- logger.Info("Successfully obtained certificate",
353- "domain", domainName,
354- "cert_url", cert.CertURL)
355-
356- return nil
357-}
D internal/proxy/db_helpers.go +0 -87
@@ -1,87 +0,0 @@
1-package proxy
2-
3-import (
4- "database/sql"
5- "fmt"
6- "time"
7-
8- "github.com/bnema/gordon/internal/db"
9- "github.com/bnema/gordon/pkg/logger"
10-)
11-
12-// dbExecWithRetry performs a database execution with retry logic for handling locked database
13-func (p *Proxy) dbExecWithRetry(query string, args ...interface{}) (sql.Result, error) {
14- return db.ExecWithRetry(p.app.GetDB(), query, args...)
15-}
16-
17-// dbQueryWithRetry performs a database query with retry logic for handling locked database
18-func (p *Proxy) dbQueryWithRetry(query string, args ...interface{}) (*sql.Rows, error) {
19- return db.QueryWithRetry(p.app.GetDB(), query, args...)
20-}
21-
22-// dbBeginWithRetry starts a transaction with retry logic for handling locked database
23-func (p *Proxy) dbBeginWithRetry() (*sql.Tx, error) {
24- return db.BeginWithRetry(p.app.GetDB())
25-}
26-
27-// txExecWithRetry executes a query within a transaction with retry logic
28-func txExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) {
29- return db.TxExecWithRetry(tx, query, args...)
30-}
31-
32-// ensureAdminDomainConfig ensures the admin domain has proper ACME configuration in the database
33-func (p *Proxy) ensureAdminDomainConfig() error {
34- adminDomain := p.app.GetConfig().Http.FullDomain()
35- adminAccountID := "" // Variable to store the admin account ID
36-
37- // Check if domain exists
38- var exists bool
39- err := p.app.GetDB().QueryRow(p.Queries.CheckDomainExists, adminDomain).Scan(&exists)
40- if err != nil {
41- return fmt.Errorf("failed to check admin domain existence: %w", err)
42- }
43-
44- // Get the first account ID (assumed to be the admin)
45- err = p.app.GetDB().QueryRow(p.Queries.GetFirstAccount).Scan(&adminAccountID)
46- if err != nil {
47- if err == sql.ErrNoRows {
48- // This case should ideally not happen if account setup is done first
49- logger.Error("No accounts found in the database. Cannot associate admin domain.")
50- return fmt.Errorf("no accounts found in database")
51- }
52- return fmt.Errorf("failed to get admin account ID: %w", err)
53- }
54- if adminAccountID == "" {
55- // Sanity check
56- logger.Error("Admin account ID is empty. Cannot associate admin domain.")
57- return fmt.Errorf("admin account ID is empty")
58- }
59-
60- if !exists {
61- // Create the admin domain with proper ACME config and account ID
62- now := time.Now().Format(time.RFC3339)
63- _, err = p.app.GetDB().Exec(p.Queries.CreateAdminDomain,
64- adminDomain, // ID (assuming domain name is unique ID here?)
65- adminDomain, // Name
66- adminAccountID, // Account ID (NEW)
67- now, // Created At
68- now, // Updated At
69- true, // ACME Enabled
70- "http-01", // ACME Challenge Type
71- )
72- if err != nil {
73- return fmt.Errorf("failed to create admin domain: %w", err)
74- }
75- logger.Info("Created admin domain with ACME configuration", "domain", adminDomain, "account_id", adminAccountID)
76- } else {
77- // Update existing domain to ensure ACME is enabled (no need to update account_id here, assume it's set correctly)
78- now := time.Now().Format(time.RFC3339)
79- _, err = p.app.GetDB().Exec(p.Queries.UpdateAdminDomainAcme, now, adminDomain)
80- if err != nil {
81- return fmt.Errorf("failed to update admin domain ACME config: %w", err)
82- }
83- logger.Info("Updated admin domain ACME configuration", "domain", adminDomain)
84- }
85-
86- return nil
87-}
D internal/proxy/middleware.go +0 -265
@@ -1,265 +0,0 @@
1-package proxy
2-
3-import (
4- "net"
5- "net/http"
6- "strings"
7- "sync"
8- "time"
9-
10- "github.com/bnema/gordon/pkg/logger"
11- "github.com/google/uuid"
12- "github.com/labstack/echo/v4"
13- "github.com/labstack/echo/v4/middleware"
14- "golang.org/x/time/rate"
15-
16- "github.com/bnema/gordon/pkg/kv" // Correct import path based on the module name
17-)
18-
19-// rateLimiterCleanup holds the cleanup functions for rate limiters
20-var rateLimiterCleanup []func()
21-
22-// RequestIDKey is the key used to store the request ID in the context
23-const RequestIDKey = "request_id"
24-
25-// Cooldown period for logging direct IP access warnings
26-const logCooldown = 1 * time.Minute
27-
28-// Map to store the last log time for each IP address attempting direct access
29-var ipLogTimes = make(map[string]time.Time)
30-var ipLogMutex sync.Mutex // Mutex to protect access to ipLogTimes
31-
32-// isHostIPAddress checks if the host part of a string (like "1.2.3.4:80" or "1.2.3.4") is a valid IP address.
33-func isHostIPAddress(host string) bool {
34- // Split host and port
35- h, _, err := net.SplitHostPort(host)
36- if err != nil {
37- // If splitting fails, assume it might be just an IP without a port
38- h = host
39- }
40- // Try parsing the host part as an IP address
41- ip := net.ParseIP(h)
42- return ip != nil
43-}
44-
45-// blockDirectIPMiddleware rejects requests where the Host header is an IP address,
46-// unless it's an ACME challenge request. It includes a log cooldown mechanism.
47-func blockDirectIPMiddleware() echo.MiddlewareFunc {
48- return func(next echo.HandlerFunc) echo.HandlerFunc {
49- return func(c echo.Context) error {
50- host := c.Request().Host
51- path := c.Request().URL.Path
52- realIP := c.RealIP() // Get the real IP once
53-
54- if isHostIPAddress(host) {
55- // Allow ACME HTTP-01 challenges to proceed even via IP
56- if strings.HasPrefix(path, "/.well-known/acme-challenge/") {
57- logger.Debug("Allowing direct IP access for ACME challenge", "host", host, "path", path, "ip", realIP)
58- return next(c)
59- }
60-
61- // --- Log Cooldown Logic ---
62- now := time.Now()
63- logAllowed := false
64-
65- ipLogMutex.Lock()
66- lastLogTime, exists := ipLogTimes[realIP]
67- if !exists || now.Sub(lastLogTime) > logCooldown {
68- // It's a new IP or the cooldown has passed
69- ipLogTimes[realIP] = now // Update the last log time
70- logAllowed = true
71- }
72- ipLogMutex.Unlock()
73-
74- // Log only if allowed by the cooldown
75- if logAllowed {
76- logger.Warn("Blocking direct IP access attempt (log cooldown active)",
77- "host", host,
78- "path", path,
79- "ip", realIP,
80- "user_agent", c.Request().UserAgent())
81- } else {
82- // Optionally log at DEBUG level that we suppressed a log, useful for verifying
83- // logger.Debug("Suppressed duplicate direct IP access log due to cooldown", "ip", realIP, "host", host)
84- }
85- // --- End Log Cooldown Logic ---
86-
87- // Block other direct IP access attempts regardless of logging
88- return echo.NewHTTPError(http.StatusForbidden, "Direct IP access is not permitted.")
89- }
90-
91- // If host is not an IP, proceed normally
92- return next(c)
93- }
94- }
95-}
96-
97-// setupMiddleware configures middleware for both HTTP and HTTPS servers
98-func (p *Proxy) setupMiddleware() {
99- // Configure Echo IP extraction. Since the proxy is edge-facing (directly exposed),
100- // ignore X-Forwarded-For and use the direct remote address.
101- p.httpsServer.IPExtractor = echo.ExtractIPDirect()
102-
103- // Add UUID generator middleware
104- p.httpsServer.Use(p.createRequestIDMiddleware())
105-
106- // --- Block Direct IP Access Middleware (Conditional) ---
107- if p.config.BlockDirectIP {
108- // This should run early, before logging or rate limiting potentially.
109- p.httpsServer.Use(blockDirectIPMiddleware())
110- logger.Info("Direct IP blocking middleware is ENABLED (allows ACME)")
111- } else {
112- logger.Info("Direct IP blocking middleware is DISABLED")
113- }
114-
115- // Add middleware to log incoming headers (for debugging Cloudflare detection)
116- // p.httpsServer.Use(logHeadersMiddleware())
117-
118- // --- Rate Limiting Setup (Domain Only Now) ---
119- if p.config.EnableRateLimit {
120- logger.Debug("Rate limiting enabled. Setting up limiter for domain requests only.")
121-
122- // --- REMOVED Rate Limiter for Direct IP Access ---
123-
124- // --- Rate Limiter for Domain Access (Existing Logic) ---
125- // Use Starskey or fallback for requests coming through a domain name.
126- domainRateLimiterStore, err := kv.NewStarskeyRateLimiterStore(
127- "./data/ratelimiter/domain", // Path for domain limiter data
128- 10, // Rate: 10 requests per second (default)
129- 30, // Burst: 30 requests (default)
130- 3*time.Minute, // Expires in 3 minutes
131- )
132-
133- if err != nil {
134- logger.Error("Failed to create Domain rate limiter store (Starskey), falling back to memory store", "error", err)
135- // Fallback to memory store
136- p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
137- Store: middleware.NewRateLimiterMemoryStoreWithConfig(
138- middleware.RateLimiterMemoryStoreConfig{
139- Rate: rate.Limit(10),
140- Burst: 30,
141- ExpiresIn: 3 * time.Minute,
142- },
143- ),
144- // Apply this limiter ONLY if the host is NOT an IP address (redundant now but safe)
145- Skipper: func(c echo.Context) bool {
146- host := c.Request().Host
147- isIP := isHostIPAddress(host)
148- if !isIP {
149- // Log only if applying the limiter
150- logger.Debug("Applying domain rate limiter (Memory Fallback)", "host", host, "ip", c.RealIP())
151- }
152- return isIP // Skip if it IS an IP
153- },
154- IdentifierExtractor: func(ctx echo.Context) (string, error) {
155- return ctx.RealIP(), nil
156- },
157- DenyHandler: func(context echo.Context, identifier string, err error) error {
158- logger.Warn("Domain rate limit exceeded (Memory Fallback)",
159- "ip", identifier,
160- "host", context.Request().Host,
161- "path", context.Request().URL.Path,
162- "method", context.Request().Method,
163- "error", err.Error())
164- return context.String(http.StatusTooManyRequests, "Too many requests")
165- },
166- }))
167- } else {
168- logger.Debug("Using Starskey rate limiter for domain requests")
169- // Use the Starskey store
170- p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
171- Store: domainRateLimiterStore,
172- // Apply this limiter ONLY if the host is NOT an IP address (redundant now but safe)
173- Skipper: func(c echo.Context) bool {
174- host := c.Request().Host
175- isIP := isHostIPAddress(host)
176- if !isIP {
177- // Log only if applying the limiter
178- logger.Debug("Applying domain rate limiter (Starskey)", "host", host, "ip", c.RealIP())
179- }
180- return isIP // Skip if it IS an IP
181- },
182- IdentifierExtractor: func(ctx echo.Context) (string, error) {
183- id := ctx.RealIP()
184- ctx.Response().Header().Set("X-Rate-Limit-Domain-IP", id) // Keep debug header
185- return id, nil
186- },
187- DenyHandler: func(context echo.Context, identifier string, err error) error {
188- logger.Warn("Domain rate limit exceeded (Starskey)",
189- "ip", identifier,
190- "host", context.Request().Host,
191- "path", context.Request().URL.Path,
192- "method", context.Request().Method,
193- "error", err.Error())
194- return context.String(http.StatusTooManyRequests, "Too many requests")
195- },
196- }))
197-
198- // Add cleanup function for the Starskey domain limiter
199- rateLimiterCleanup = append(rateLimiterCleanup, func() {
200- if err := domainRateLimiterStore.Close(); err != nil {
201- logger.Error("Failed to close Domain rate limiter store (Starskey)", "error", err)
202- }
203- })
204- }
205- logger.Debug("Configured rate limiter for domain requests")
206-
207- } else {
208- logger.Info("Rate limiting is disabled globally via configuration")
209- }
210-
211- // --- Logging Middleware (Existing) ---
212- if p.config.EnableHttpLogs {
213- p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
214- // Use a standard string with escaped quotes for the JSON format
215- Format: "{\"time\":\"${time_rfc3339_nano}\",\"id\":\"${id}\",\"remote_ip\":\"${remote_ip}\",\"host\":\"${host}\",\"method\":\"${method}\",\"uri\":\"${uri}\",\"user_agent\":\"${user_agent}\",\"status\":${status},\"error\":\"${error}\",\"latency\":${latency},\"latency_human\":\"${latency_human}\",\"bytes_in\":${bytes_in},\"bytes_out\":${bytes_out}}\n",
216- CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00",
217- Skipper: func(c echo.Context) bool {
218- // Skip logging if disabled in config OR if it's a successful ACME challenge
219- // (to avoid polluting logs during certificate issuance)
220- isAcme := strings.HasPrefix(c.Request().URL.Path, "/.well-known/acme-challenge/")
221- return !p.config.EnableHttpLogs || (isAcme && c.Response().Status == http.StatusOK)
222- },
223- }))
224- logger.Debug("HTTP request logging enabled for proxy server")
225- } else {
226- logger.Debug("HTTP request logging disabled for proxy server")
227- }
228-
229- // REMOVED the duplicated rate limiter and logger blocks that seemed intended for a separate HTTP server
230-}
231-
232-// createRequestIDMiddleware returns a middleware function that generates a UUID for each request
233-func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc {
234- return func(next echo.HandlerFunc) echo.HandlerFunc {
235- return func(c echo.Context) error {
236- // Generate a UUID for this request
237- requestID := uuid.New().String()
238-
239- // Store it in the context
240- c.Set(RequestIDKey, requestID)
241-
242- // Add it as a response header
243- c.Response().Header().Set(echo.HeaderXRequestID, requestID)
244-
245- // Continue processing
246- return next(c)
247- }
248- }
249-}
250-
251-// logHeadersMiddleware creates a middleware function to log request headers
252-// func logHeadersMiddleware() echo.MiddlewareFunc {
253-// return func(next echo.HandlerFunc) echo.HandlerFunc {
254-// return func(c echo.Context) error {
255-// // Log all headers at DEBUG level
256-// headerStrings := []string{}
257-// for k, v := range c.Request().Header {
258-// headerStrings = append(headerStrings, fmt.Sprintf("%s: %s", k, strings.Join(v, ",")))
259-// }
260-// logger.Debug("Incoming Request Headers", "request_id", c.Get(RequestIDKey), "headers", strings.Join(headerStrings, " | "))
261-
262-// return next(c)
263-// }
264-// }
265-// }
D internal/proxy/routes.go +0 -2094 large diff (2094 lines changed): collapsed
M internal/proxy/server.go +239 -555
@@ -2,601 +2,285 @@ package proxy
22
33 import (
44 "context"
5- "crypto/tls"
6- "errors"
75 "fmt"
86 "net/http"
9- "strings"
10- "time"
11-
12- "github.com/bnema/gordon/pkg/docker"
13- "github.com/bnema/gordon/pkg/logger"
7+ "net/http/httputil"
8+ "net/url"
9+ "strconv"
10+
11+ "github.com/rs/zerolog/log"
12+ "gordon/internal/config"
13+ "gordon/internal/container"
14+ "gordon/internal/middleware"
1415 )
1516
16-// This file contains server operations functions for the proxy.
17-// These methods may need to be deleted from proxy.go after validation.
18-
19-// Start loads the routes from the database and starts the proxy server
20-func (p *Proxy) Start() error {
21- // First, load routes from the database
22- if err := p.loadRoutes(); err != nil {
23- return fmt.Errorf("failed to load routes: %w", err)
24- }
25-
26- // Now log Gordon container identity for debugging (after routes are loaded)
27- p.LogGordonIdentity()
28-
29- // Scan Gordon network containers and check for certificates
30- if err := p.scanContainersAndCheckCertificates(); err != nil {
31- logger.Warn("Failed to scan containers for certificate checks", "error", err)
32- }
33-
34- // Run an immediate route verification to ensure all routes are valid on startup
35- logger.Info("Performing initial route verification on startup")
36-
37- // Discover any containers that should have routes but don't
38- p.DiscoverMissingRoutes()
39-
40- // Log all active containers and their routes for visibility
41- p.LogActiveContainersAndRoutes()
42-
43- // Start the container event listener for real-time updates
44- if err := p.StartContainerEventListener(); err != nil {
45- logger.Warn("Failed to start container event listener", "error", err)
46- // Continue anyway, this is non-critical
47- }
48-
49- // Check if there might be port conflicts with the main server
50- mainServerPort := p.app.GetConfig().Http.Port
51-
52- // Check HTTP port conflict
53- if p.config.HttpPort == mainServerPort {
54- logger.Warn("HTTP port for reverse proxy conflicts with main server port",
55- "port", p.config.HttpPort,
56- "solution", "reverse proxy HTTP server will be disabled")
57- return nil
58- }
59-
60- // Check HTTPS port conflict (less common, but still possible)
61- if p.config.Port == mainServerPort {
62- logger.Warn("HTTPS port for reverse proxy conflicts with main server port",
63- "port", p.config.Port,
64- "solution", "reverse proxy HTTPS server will be disabled")
65- return nil
66- }
67-
68- // Add a special route for the admin domain (Gordon itself)
69- adminDomain := p.app.GetConfig().Http.FullDomain()
70-
71- // Auto-detect the Gordon container name and ID
72- containerName := p.detectGordonContainer()
73-
74- // Get the network name from config
75- networkName := p.app.GetConfig().ContainerEngine.Network
76-
77- // Initialize container IP
78- var containerIP string
79-
80- // Try to get the actual container IP from the network
81- if p.gordonContainerID == "" {
82- return fmt.Errorf("failed to detect Gordon container ID, cannot add admin route")
83- }
84-
85- // Get the IP address from the container's network
86- ip, err := docker.GetContainerIPFromNetwork(p.gordonContainerID, networkName)
87- if err != nil || ip == "" {
88- logger.Error("Failed to get Gordon container IP from network",
89- "container_id", p.gordonContainerID,
90- "network", networkName,
91- "error", err)
92- return fmt.Errorf("failed to get Gordon container IP from network %s: %w", networkName, err)
93- }
94-
95- containerIP = ip
96- logger.Info("Using Gordon container IP from network",
97- "container_id", p.gordonContainerID,
98- "network", networkName,
99- "ip", containerIP)
100-
101- // Check if admin route exists in database and create/update it
102- err = p.AddRoute(
103- adminDomain,
104- containerName,
105- containerIP,
106- p.app.GetConfig().Http.Port,
107- "http", // Gordon server uses HTTP internally
108- "/",
109- )
17+type Server struct {
18+ config *config.Config
19+ mux *http.ServeMux
20+ routes []config.Route
21+ manager *container.Manager
22+}
11023
111- if err != nil {
112- logger.Error("Failed to add admin route to database",
113- "error", err,
114- "domain", adminDomain)
115- } else {
116- logger.Info("Ensured admin domain route is in database",
117- "domain", adminDomain,
118- "container", containerName,
119- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
24+func NewServer(cfg *config.Config, manager *container.Manager) *Server {
25+ s := &Server{
26+ config: cfg,
27+ mux: http.NewServeMux(),
28+ routes: cfg.GetRoutes(),
29+ manager: manager,
12030 }
31+ s.setupRoutes()
32+ return s
33+}
12134
122- p.mu.Lock()
123- // Only add if it doesn't already exist in memory
124- if _, exists := p.routes[adminDomain]; !exists {
125- p.routes[adminDomain] = &ProxyRouteInfo{
126- Domain: adminDomain,
127- ContainerIP: containerIP,
128- ContainerPort: p.app.GetConfig().Http.Port,
129- ContainerID: containerName, // Use auto-detected container name
130- Protocol: "http", // Gordon server uses HTTP internally
131- Path: "/",
132- Active: true,
35+func (s *Server) setupRoutes() {
36+ // Protected Management API endpoints
37+ if s.config.Auth.Enabled {
38+ // Create protected subrouter for API endpoints
39+ apiMux := http.NewServeMux()
40+ apiMux.HandleFunc("GET /api/status", s.handleStatus)
41+ apiMux.HandleFunc("POST /api/reload", s.handleReload)
42+ apiMux.HandleFunc("GET /api/containers", s.handleListContainers)
43+ apiMux.HandleFunc("POST /api/deploy/{route}", s.handleDeploy)
44+
45+ // Apply authentication middleware to API routes
46+ var authHandler http.Handler = apiMux
47+
48+ switch s.config.Auth.Method {
49+ case "jwt":
50+ authHandler = middleware.JWTAuth(s.config.Auth.JWTSecret)(authHandler)
51+ case "api_key":
52+ authHandler = middleware.APIKeyAuth(s.config.Auth.APIKey)(authHandler)
53+ case "basic":
54+ authHandler = middleware.BasicAuth(s.config.Auth.Username, s.config.Auth.Password)(authHandler)
13355 }
134- logger.Info("Added special route for admin domain",
135- "domain", adminDomain,
136- "container", containerName,
137- "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port))
56+
57+ // Add IP whitelist if configured
58+ if len(s.config.Auth.AllowedIPs) > 0 {
59+ authHandler = middleware.IPWhitelist(s.config.Auth.AllowedIPs)(authHandler)
60+ }
61+
62+ s.mux.Handle("/api/", authHandler)
13863 } else {
139- logger.Debug("Admin domain route already exists",
140- "domain", adminDomain,
141- "route", fmt.Sprintf("%s://%s:%s",
142- p.routes[adminDomain].Protocol,
143- p.routes[adminDomain].ContainerIP,
144- p.routes[adminDomain].ContainerPort))
145- }
146- p.mu.Unlock()
147-
148- // Explicitly request certificate for admin domain after ensuring route exists
149- if err := p.requestDomainCertificate(adminDomain); err != nil {
150- logger.Error("Failed to request initial certificate for admin domain", "domain", adminDomain, "error", err)
151- // Don't return an error here, let the server start and retry later
152- }
153-
154- // Set up middleware
155- p.setupMiddleware()
156-
157- // Configure routes
158- p.configureRoutes()
159-
160- // Configure HTTPS and HTTP servers using our refactored method
161- if err := p.StartHTTPSServer(); err != nil {
162- logger.Error("Failed to start HTTPS server", "error", err)
163- return err
64+ // Unprotected API endpoints (not recommended for production)
65+ s.mux.HandleFunc("GET /api/status", s.handleStatus)
66+ s.mux.HandleFunc("POST /api/reload", s.handleReload)
67+ s.mux.HandleFunc("GET /api/containers", s.handleListContainers)
68+ s.mux.HandleFunc("POST /api/deploy/{route}", s.handleDeploy)
16469 }
165-
166- return nil
70+
71+ // Catch-all handler for domain-based routing
72+ // Since Go's ServeMux doesn't support Host matching, we'll handle it in the handler
73+ s.mux.HandleFunc("/", s.handleDomainRouting)
16774 }
16875
169-// Stop gracefully shuts down the proxy server
170-func (p *Proxy) Stop() error {
171- logger.Info("Stopping reverse proxy")
172-
173- // Run any rate limiter cleanup functions
174- for _, cleanup := range rateLimiterCleanup {
175- cleanup()
176- }
177-
178- // Stop the container event listener
179- docker.StopContainerEventListener()
180-
181- // Skip the rest if the server was never started
182- if !p.serverStarted {
183- logger.Debug("Proxy server was never started, nothing to stop")
184- return nil
185- }
186-
187- // Create a context with timeout for shutdown
188- timeout := p.config.GracePeriod
189- if timeout <= 0 {
190- timeout = 10 // Default to 10 seconds if not set
191- }
192- ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
193- defer cancel()
194-
195- // Removed HTTP server shutdown logic
196- // if p.httpServer != nil {
197- // if err := p.httpServer.Shutdown(ctx); err != nil {
198- // logger.Error("Failed to gracefully shutdown HTTP server", "error", err)
199- // }
200- // }
201-
202- // Shutdown HTTPS server if it's running
203- if p.httpsServer != nil {
204- if err := p.httpsServer.Shutdown(ctx); err != nil {
205- logger.Error("Failed to gracefully shutdown HTTPS server", "error", err)
76+func (s *Server) handleDomainRouting(w http.ResponseWriter, r *http.Request) {
77+ host := r.Host
78+
79+ // Find matching route for this domain
80+ for _, route := range s.routes {
81+ if route.Domain == host {
82+ log.Info().Str("domain", host).Str("image", route.Image).Msg("Routing request")
83+ s.proxyToContainer(w, r, route)
84+ return
20685 }
20786 }
208-
209- logger.Info("Reverse proxy stopped")
210- return nil
87+
88+ // No route found
89+ log.Warn().Str("domain", host).Msg("No route found for domain")
90+ http.NotFound(w, r)
21191 }
21292
213-// testAdminConnection attempts to find a working connection to the Gordon admin ping endpoint
214-func (p *Proxy) testAdminConnection(defaultHost string, port string) string {
215- adminPath := p.app.GetConfig().Admin.Path // Get the base admin path
216- pingPath := adminPath + "/ping" // Construct the full ping path
217- url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, pingPath)
218-
219- logger.Debug("Testing admin ping connection", "url", url) // Update log message
220-
221- // Try connecting using the provided default host
222- client := &http.Client{
223- // Use a simple TLS config instead of getting it from the app
224- Transport: &http.Transport{
225- TLSClientConfig: &tls.Config{
226- InsecureSkipVerify: true,
227- },
228- },
229- Timeout: 5 * time.Second,
230- }
231-
232- resp, err := client.Get(url)
233- if err == nil {
234- resp.Body.Close()
235- // Check if the response status is OK
236- if resp.StatusCode == http.StatusOK {
237- logger.Info("Admin ping connection successful", "host", defaultHost) // Update log message
238- return defaultHost
239- } else {
240- logger.Warn("Admin ping connection returned non-OK status", "host", defaultHost, "status_code", resp.StatusCode)
93+func (s *Server) proxyToContainer(w http.ResponseWriter, r *http.Request, route config.Route) {
94+ ctx := r.Context()
95+
96+ // Check if container exists and is running
97+ container, exists := s.manager.GetContainer(route.Domain)
98+ if !exists {
99+ log.Info().Str("domain", route.Domain).Msg("Container not found, deploying")
100+
101+ // Deploy the container
102+ var err error
103+ container, err = s.manager.DeployContainer(ctx, route)
104+ if err != nil {
105+ log.Error().Err(err).Str("domain", route.Domain).Msg("Failed to deploy container")
106+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
107+ return
241108 }
242- } else {
243- logger.Warn("Admin ping connection failed", "host", defaultHost, "error", err)
244- }
245-
246- // If connection failed or status not OK, try other common hostnames
247- hosts := []string{
248- "localhost",
249- "127.0.0.1",
250- "host.docker.internal",
251- "host.containers.internal",
252109 }
253110
254- for _, host := range hosts {
255- if host == defaultHost {
256- continue // Already tried this one
257- }
258-
259- url = fmt.Sprintf("http://%s:%s%s", host, port, pingPath) // Use full pingPath here too
260- logger.Debug("Trying alternative host for admin ping", "url", url) // Update log message
261-
262- resp, err := client.Get(url)
111+ // Try to get container port (try common web ports)
112+ var targetPort int
113+ var err error
114+
115+ // Try common web ports in order: 80, 8080, 3000
116+ for _, port := range []int{80, 8080, 3000} {
117+ targetPort, err = s.manager.GetContainerPort(ctx, route.Domain, port)
263118 if err == nil {
264- resp.Body.Close()
265- if resp.StatusCode == http.StatusOK {
266- logger.Info("Found working admin ping connection", "host", host) // Update log message
267- return host
268- } else {
269- logger.Warn("Admin ping connection on alternative host returned non-OK status", "host", host, "status_code", resp.StatusCode)
270- }
271- } else {
272- logger.Warn("Admin ping connection failed on alternative host", "host", host, "error", err)
119+ break
273120 }
274121 }
275-
276- logger.Warn("Could not establish admin ping connection on any tested host", "fallback", defaultHost) // Update log message
277- return defaultHost
278-}
279-
280-// TestAdminConnectionLater tests the admin connection after a short delay
281-func (p *Proxy) TestAdminConnectionLater() {
282- // Wait for servers to start
283- time.Sleep(2 * time.Second)
284-
285- logger.Debug("Testing admin connections after server startup")
286-
287- // Get domain and current route
288- adminDomain := p.app.GetConfig().Http.FullDomain()
289-
290- p.mu.RLock()
291- route, exists := p.routes[adminDomain]
292- p.mu.RUnlock()
293-
294- if !exists {
295- logger.Warn("Admin route not found")
122+
123+ if err != nil {
124+ log.Error().Err(err).Str("domain", route.Domain).Msg("No accessible port found for container")
125+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
296126 return
297127 }
298-
299- // Test connection and update if needed
300- testedIP := p.testAdminConnection(route.ContainerIP, p.app.GetConfig().Http.Port)
301- if testedIP != route.ContainerIP {
302- p.mu.Lock()
303- if r, ok := p.routes[adminDomain]; ok {
304- r.ContainerIP = testedIP
305- logger.Info("Updated admin route with working connection",
306- "domain", adminDomain,
307- "ip", testedIP)
308- }
309- p.mu.Unlock()
310- }
311-
312- // Check external port access
313- p.checkExternalPortAccess()
314-}
315-
316-// checkExternalPortAccess verifies if ports 80 and 443 are accessible
317-func (p *Proxy) checkExternalPortAccess() {
318- adminDomain := p.app.GetConfig().Http.FullDomain()
319- if adminDomain == "" {
128+
129+ target, err := url.Parse(fmt.Sprintf("http://localhost:%d", targetPort))
130+ if err != nil {
131+ log.Error().Err(err).Msg("Failed to parse target URL")
132+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
320133 return
321134 }
322-
323- // Test HTTP port 80
324- /* // Commenting out HTTP check due to potential hairpin NAT issues
325- client := &http.Client{
326- Timeout: 5 * time.Second,
327- CheckRedirect: func(req *http.Request, via []*http.Request) error {
328- return http.ErrUseLastResponse // Don't follow redirects
329- },
330- }
331-
332- httpURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test", adminDomain)
333- _, httpErr := client.Get(httpURL)
334- if httpErr != nil {
335- logger.Warn("External HTTP port 80 might not be accessible",
336- "error", httpErr.Error())
337- } else {
338- logger.Info("External HTTP port 80 is accessible")
135+
136+ proxy := httputil.NewSingleHostReverseProxy(target)
137+
138+ // Customize the proxy to handle errors
139+ proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
140+ log.Error().Err(err).Str("domain", route.Domain).Int("port", targetPort).Msg("Proxy error")
141+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
339142 }
340- */
341-
342- // Test HTTPS port 443
343- /* // Commenting out httpsClient as it's no longer used
344- httpsClient := &http.Client{
345- Timeout: 10 * time.Second, // Increased timeout for more reliable testing
346- Transport: &http.Transport{
347- TLSClientConfig: &tls.Config{
348- InsecureSkipVerify: true,
349- },
350- // Add more detailed timeout settings
351- TLSHandshakeTimeout: 5 * time.Second,
352- ResponseHeaderTimeout: 5 * time.Second,
353- ExpectContinueTimeout: 1 * time.Second,
354- // Add a custom dialer with explicit timeouts
355- DialContext: (&net.Dialer{
356- Timeout: 5 * time.Second,
357- KeepAlive: 30 * time.Second,
358- DualStack: true,
359- }).DialContext,
360- },
361- CheckRedirect: func(req *http.Request, via []*http.Request) error {
362- return http.ErrUseLastResponse
363- },
364- }
365- */
366-
367- // Verify TLS settings are consistent
368- if p.certificateManager == nil { // Updated check
369- logger.Error("Certificate manager is nil - TLS will not work correctly",
370- "solution", "Check NewProxy implementation and logs")
143+
144+ // Add custom headers
145+ proxy.ModifyResponse = func(resp *http.Response) error {
146+ resp.Header.Set("X-Proxied-By", "Gordon")
147+ resp.Header.Set("X-Container-ID", container.ID)
148+ return nil
371149 }
150+
151+ log.Debug().
152+ Str("method", r.Method).
153+ Str("path", r.URL.Path).
154+ Str("domain", route.Domain).
155+ Str("target", target.String()).
156+ Str("container", container.ID).
157+ Msg("Proxying request")
158+ proxy.ServeHTTP(w, r)
159+}
372160
373- /* // Commenting out port 443 checks due to potential hairpin NAT issues
374- // First try a basic TCP connection to port 443 to test if it's open
375- logger.Info("Testing TCP connectivity to HTTPS port 443...")
376- conn, tcpErr := net.DialTimeout("tcp", adminDomain+":443", 5*time.Second)
377- if tcpErr != nil {
378- logger.Warn("TCP connection to port 443 failed - HTTPS will not work",
379- "domain", adminDomain,
380- "error", tcpErr.Error())
381- } else {
382- conn.Close()
383- logger.Info("TCP connection to port 443 successful")
161+func (s *Server) Start(ctx context.Context) error {
162+ addr := ":" + strconv.Itoa(s.config.Server.Port)
163+
164+ // Wrap the mux with middleware
165+ handler := middleware.Chain(
166+ middleware.PanicRecovery,
167+ middleware.RequestLogger,
168+ middleware.CORS,
169+ )(s.mux)
170+
171+ // TODO: Implement HTTPS support with Let's Encrypt
172+ server := &http.Server{
173+ Addr: addr,
174+ Handler: handler,
384175 }
385176
386- // Then try the full HTTPS request
387- httpsURL := fmt.Sprintf("https://%s/", adminDomain)
388- logger.Info("Testing HTTPS connection with SNI...", "url", httpsURL)
389- _, httpsErr := httpsClient.Get(httpsURL)
390- if httpsErr != nil {
391- logger.Warn("External HTTPS port 443 might not be accessible",
392- "error", httpsErr.Error())
177+ log.Info().Str("address", addr).Msg("Proxy server starting")
393178
394- // Provide more detailed troubleshooting info
395- if strings.Contains(httpsErr.Error(), "certificate") {
396- logger.Warn("TLS certificate issue detected",
397- "hint", "Certificate verification failed but connection succeeded")
398- } else if strings.Contains(httpsErr.Error(), "connection refused") {
399- logger.Warn("Connection refused - firewall may be blocking port 443")
400- } else if strings.Contains(httpsErr.Error(), "i/o timeout") || strings.Contains(httpsErr.Error(), "deadline exceeded") {
401- logger.Warn("Connection timeout - port 443 may be filtered or not properly forwarded")
179+ // Start server in a goroutine
180+ errChan := make(chan error, 1)
181+ go func() {
182+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
183+ errChan <- err
402184 }
403- } else {
404- logger.Info("External HTTPS port 443 is accessible - TLS handshake successful")
185+ }()
186+
187+ // Wait for context cancellation or server error
188+ select {
189+ case err := <-errChan:
190+ return err
191+ case <-ctx.Done():
192+ log.Info().Msg("Proxy server shutting down...")
193+ return server.Shutdown(context.Background())
405194 }
406- */
407195 }
408196
409-// // detectGordonContainer attempts to find the Gordon container automatically
410-// func (p *Proxy) detectGordonContainer() string {
411-// // Check if we're running inside a container
412-// if docker.IsRunningInContainer() {
413-// logger.Debug("Detected we're running in a container")
414-
415-// // Get our hostname which should be the container ID in Docker/Podman
416-// hostname := os.Getenv("HOSTNAME")
417-// if hostname != "" {
418-// // Check if there's a container with this hostname
419-// containers, err := docker.ListRunningContainers()
420-// if err == nil {
421-// for _, container := range containers {
422-// // Check if this container is us by comparing hostname with container ID
423-// if strings.HasPrefix(hostname, container.ID) {
424-// // This is our container
425-// containerName := strings.TrimLeft(container.Names[0], "/")
426-// logger.Debug("Auto-detected Gordon container name from hostname", "container_name", containerName)
427-// return containerName
428-// }
429-// }
430-// } else {
431-// logger.Debug("Could not list containers when checking hostname", "error", err)
432-// }
433-// }
434-// }
435-
436-// // Try to find Gordon by examining container processes
437-// containers, err := docker.ListRunningContainers()
438-// if err == nil {
439-// for _, container := range containers {
440-// containerID := container.ID
441-
442-// // Check container processes for Gordon
443-// containerLogs, err := docker.GetContainerLogs(containerID)
444-// if err == nil && (strings.Contains(containerLogs, "/gordon") || strings.Contains(containerLogs, "serve")) {
445-// containerName := strings.TrimLeft(container.Names[0], "/")
446-// logger.Debug("Auto-detected Gordon container by process", "container_name", containerName)
447-// return containerName
448-// } else if err != nil {
449-// logger.Debug("Could not get container logs", "container_id", containerID, "error", err)
450-// }
451-
452-// // Check container command for Gordon
453-// if len(container.Command) > 0 && strings.Contains(container.Command, "gordon") {
454-// containerName := strings.TrimLeft(container.Names[0], "/")
455-// logger.Debug("Auto-detected Gordon container by command", "container_name", containerName)
456-// return containerName
457-// }
458-// }
459-// } else {
460-// logger.Debug("Could not list containers when checking processes", "error", err)
461-// }
462-
463-// // If we couldn't detect from processes, try to find a container with gordon in the name or image
464-// if err == nil {
465-// for _, container := range containers {
466-// // Check container names for "gordon"
467-// for _, name := range container.Names {
468-// if strings.Contains(strings.ToLower(name), "gordon") {
469-// containerName := strings.TrimLeft(name, "/")
470-// logger.Debug("Auto-detected Gordon container by name", "container_name", containerName)
471-// return containerName
472-// }
473-// }
474-
475-// // Check image name for "gordon"
476-// if strings.Contains(strings.ToLower(container.Image), "gordon") {
477-// containerName := strings.TrimLeft(container.Names[0], "/")
478-// logger.Debug("Auto-detected Gordon container by image", "container_name", containerName, "image", container.Image)
479-// return containerName
480-// }
481-// }
482-// }
483-
484-// // If all else fails, default to "gordon"
485-// logger.Debug("Could not auto-detect Gordon container, using default name", "container_name", "gordon")
486-// return "gordon"
487-// }
488-
489-// StartHTTPSServer starts the HTTPS server
490-func (p *Proxy) StartHTTPSServer() error {
491- // Setup main Echo instance for routing (used by both HTTP/HTTPS handlers)
492- e := p.httpsServer // Use the existing Echo instance
493-
494- // If SkipCertificates is true, start a plain HTTP listener instead of HTTPS
495- if p.config.SkipCertificates {
496- logger.Info("Starting plain HTTP listener because SkipCertificates is true",
497- "port", p.config.DefaultHttpChallengePort)
498-
499- // Create a standard http.Server for the plain HTTP listener
500- httpServer := &http.Server{
501- Addr: fmt.Sprintf(":%s", p.config.DefaultHttpChallengePort), // Add colon prefix
502- Handler: e, // Use the main Echo instance
503- ReadTimeout: 5 * time.Second,
504- WriteTimeout: 10 * time.Second,
505- IdleTimeout: 120 * time.Second,
506- }
507-
508- // Start plain HTTP listener using http.Server's ListenAndServe
509- go func() {
510- logger.Info("Plain HTTP listener starting", "address", httpServer.Addr)
511- if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
512- // Log specific errors if possible
513- if strings.Contains(err.Error(), "bind: address already in use") {
514- logger.Error("Plain HTTP port is already in use", "port", p.config.DefaultHttpChallengePort, "error", err)
515- } else if strings.Contains(err.Error(), "permission denied") {
516- logger.Error("Permission denied binding to plain HTTP port", "port", p.config.DefaultHttpChallengePort, "error", err)
517- } else {
518- logger.Error("Plain HTTP listener failed", "error", err, "address", httpServer.Addr)
519- }
520- }
521- }()
522- p.serverStarted = true // Mark server as started
523-
524- } else {
525- // --- Start HTTPS Server (only if SkipCertificates is false) ---
526- logger.Info("Starting HTTPS server",
527- "port", p.config.Port,
528- "mode", p.config.LetsEncryptMode)
529-
530- // Verify certificate manager is initialized *only* if we need it for HTTPS
531- if p.certificateManager == nil {
532- logger.Error("Certificate manager is nil, cannot start HTTPS server")
533- return fmt.Errorf("certificate manager not initialized for HTTPS")
534- }
535- logger.Info("Using certificate management system for HTTPS")
197+func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
198+ ctx := r.Context()
199+ w.Header().Set("Content-Type", "application/json")
200+
201+ // Get container health status
202+ health := s.manager.HealthCheck(ctx)
203+ containers := s.manager.ListContainers()
204+
205+ status := map[string]interface{}{
206+ "status": "running",
207+ "routes": len(s.routes),
208+ "runtime": s.config.Server.Runtime,
209+ "containers": len(containers),
210+ "health": health,
211+ }
212+
213+ w.WriteHeader(http.StatusOK)
214+ fmt.Fprintf(w, `{"status":"%s","routes":%d,"runtime":"%s","containers":%d}`,
215+ status["status"], status["routes"], status["runtime"], status["containers"])
216+}
536217
537- // Setup TLS configuration
538- tlsConfig := &tls.Config{
539- GetCertificate: p.getTLSCertificateFunc(),
540- MinVersion: tls.VersionTLS12,
541- CipherSuites: []uint16{
542- tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
543- tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
544- tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
545- tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
546- tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
547- tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
548- },
549- PreferServerCipherSuites: true,
550- NextProtos: []string{"h2", "http/1.1"},
551- ServerName: p.app.GetConfig().Http.FullDomain(),
552- }
218+func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
219+ log.Info().Msg("Configuration reload requested")
220+
221+ // TODO: Implement configuration reload
222+ // This should reload the config file and update routes
223+
224+ w.WriteHeader(http.StatusOK)
225+ fmt.Fprint(w, `{"message":"Configuration reloaded"}`)
226+}
553227
554- // Create a custom HTTPS server with proper TLS config
555- httpsServer := &http.Server{
556- Addr: fmt.Sprintf(":%s", p.config.Port), // Use the configured HTTPS port (e.g., :443)
557- Handler: e, // Use the main Echo instance
558- TLSConfig: tlsConfig,
559- ReadTimeout: 60 * time.Second,
560- WriteTimeout: 60 * time.Second,
561- IdleTimeout: 120 * time.Second,
562- ReadHeaderTimeout: 30 * time.Second,
228+func (s *Server) handleListContainers(w http.ResponseWriter, r *http.Request) {
229+ log.Info().Msg("Container list requested")
230+
231+ containers := s.manager.ListContainers()
232+
233+ w.Header().Set("Content-Type", "application/json")
234+ w.WriteHeader(http.StatusOK)
235+
236+ // Simple JSON response - in production would use proper JSON marshaling
237+ fmt.Fprint(w, `{"containers":[`)
238+ first := true
239+ for domain, container := range containers {
240+ if !first {
241+ fmt.Fprint(w, ",")
563242 }
564-
565- // Store the HTTPS server for later use in Stop()
566- p.httpsServer.Server = httpsServer // Store the http.Server within Echo
567-
568- // Start HTTPS server in a goroutine
569- go func() {
570- logger.Info("HTTPS server starting", "address", httpsServer.Addr)
571- // Using empty strings for cert and key files since GetCertificate is used
572- if err := httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
573- // Log specific errors if possible
574- if strings.Contains(err.Error(), "bind: address already in use") {
575- logger.Error("HTTPS port is already in use", "port", p.config.Port, "error", err)
576- } else if strings.Contains(err.Error(), "permission denied") {
577- logger.Error("Permission denied binding to HTTPS port", "port", p.config.Port, "error", err)
578- } else if strings.Contains(err.Error(), "certificate") {
579- logger.Error("Certificate-related error starting HTTPS server", "error", err)
580- } else {
581- logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr)
582- }
583- }
584- }()
585- p.serverStarted = true // Mark server as started
243+ fmt.Fprintf(w, `{"domain":"%s","id":"%s","image":"%s","status":"%s"}`,
244+ domain, container.ID, container.Image, container.Status)
245+ first = false
586246 }
587-
588- // Test admin connection after attempting to start the appropriate server
589- go p.TestAdminConnectionLater()
590-
591- // Remove the old/duplicate HTTP listener logic that was here
592-
593- return nil // Return nil indicating server start was initiated
247+ fmt.Fprint(w, `]}`)
594248 }
595249
596-// getTLSCertificateFunc returns a function that gets certificates for TLS handshakes
597-func (p *Proxy) getTLSCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
598- return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
599- logger.Debug("Getting certificate for TLS handshake", "server_name", hello.ServerName)
600- return p.certificateManager.GetCertificateForTLS(hello)
250+func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) {
251+ routeName := r.PathValue("route")
252+ if routeName == "" {
253+ http.Error(w, "Route name required", http.StatusBadRequest)
254+ return
601255 }
602-}
256+
257+ log.Info().Str("route", routeName).Msg("Manual deployment requested")
258+
259+ // Find the route
260+ var targetRoute *config.Route
261+ for _, route := range s.routes {
262+ if route.Domain == routeName {
263+ targetRoute = &route
264+ break
265+ }
266+ }
267+
268+ if targetRoute == nil {
269+ http.Error(w, "Route not found", http.StatusNotFound)
270+ return
271+ }
272+
273+ // Deploy the container
274+ ctx := r.Context()
275+ container, err := s.manager.DeployContainer(ctx, *targetRoute)
276+ if err != nil {
277+ log.Error().Err(err).Str("route", routeName).Msg("Deployment failed")
278+ http.Error(w, "Deployment failed", http.StatusInternalServerError)
279+ return
280+ }
281+
282+ w.Header().Set("Content-Type", "application/json")
283+ w.WriteHeader(http.StatusOK)
284+ fmt.Fprintf(w, `{"message":"Deployed successfully","container":"%s","domain":"%s","image":"%s"}`,
285+ container.ID, targetRoute.Domain, targetRoute.Image)
286+}⏎̸
A internal/registry/server.go +360 -0
@@ -0,0 +1,360 @@
1+package registry
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+
12+ "github.com/rs/zerolog/log"
13+ "gordon/internal/config"
14+ "gordon/internal/middleware"
15+)
16+
17+type Server struct {
18+ config *config.Config
19+ mux *http.ServeMux
20+ storage Storage
21+}
22+
23+func NewServer(cfg *config.Config) (*Server, error) {
24+ // Initialize storage
25+ storage, err := NewFilesystemStorage(cfg.Server.DataDir + "/registry")
26+ if err != nil {
27+ return nil, fmt.Errorf("failed to initialize storage: %w", err)
28+ }
29+
30+ s := &Server{
31+ config: cfg,
32+ mux: http.NewServeMux(),
33+ storage: storage,
34+ }
35+ s.setupRoutes()
36+ return s, nil
37+}
38+
39+func (s *Server) setupRoutes() {
40+ // Docker Registry API v2 endpoints using Go 1.22+ ServeMux patterns
41+
42+ // Registry info endpoint
43+ s.mux.HandleFunc("GET /v2/", s.handleRegistryInfo)
44+
45+ // Since Go's ServeMux requires wildcards to be at the end, we need a different approach
46+ // Use a catch-all handler for /v2/ paths and route internally
47+ s.mux.HandleFunc("/v2/", s.handleV2Routes)
48+}
49+
50+func (s *Server) handleV2Routes(w http.ResponseWriter, r *http.Request) {
51+ path := r.URL.Path
52+ method := r.Method
53+
54+ // Route based on path pattern and method
55+ switch {
56+ case path == "/v2/" && method == "GET":
57+ s.handleRegistryInfo(w, r)
58+ case method == "GET" && matches(path, "/v2/.*/manifests/.*"):
59+ s.handleGetManifest(w, r)
60+ case method == "PUT" && matches(path, "/v2/.*/manifests/.*"):
61+ s.handlePutManifest(w, r)
62+ case method == "GET" && matches(path, "/v2/.*/blobs/.*"):
63+ s.handleGetBlob(w, r)
64+ case method == "POST" && matches(path, "/v2/.*/blobs/uploads/"):
65+ s.handleStartBlobUpload(w, r)
66+ case (method == "PUT" || method == "PATCH") && matches(path, "/v2/.*/blobs/uploads/.*"):
67+ s.handleBlobUpload(w, r)
68+ case method == "GET" && matches(path, "/v2/.*/tags/list"):
69+ s.handleListTags(w, r)
70+ default:
71+ http.NotFound(w, r)
72+ }
73+}
74+
75+// Simple pattern matching helper
76+func matches(path, pattern string) bool {
77+ // Convert pattern to regex-like matching
78+ // For now, just check if the pattern structure matches
79+ if pattern == "/v2/.*/manifests/.*" {
80+ return len(path) > 4 && path[:4] == "/v2/" &&
81+ len(path) > 14 && path[len(path)-10:] != "/manifests/" &&
82+ contains(path, "/manifests/")
83+ }
84+ if pattern == "/v2/.*/blobs/.*" {
85+ return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/")
86+ }
87+ if pattern == "/v2/.*/blobs/uploads/" {
88+ return len(path) > 4 && path[:4] == "/v2/" &&
89+ contains(path, "/blobs/uploads/") && path[len(path)-1:] == "/"
90+ }
91+ if pattern == "/v2/.*/blobs/uploads/.*" {
92+ return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/uploads/")
93+ }
94+ if pattern == "/v2/.*/tags/list" {
95+ return len(path) > 4 && path[:4] == "/v2/" &&
96+ len(path) > 10 && path[len(path)-10:] == "/tags/list"
97+ }
98+ return false
99+}
100+
101+func contains(s, substr string) bool {
102+ for i := 0; i <= len(s)-len(substr); i++ {
103+ if s[i:i+len(substr)] == substr {
104+ return true
105+ }
106+ }
107+ return false
108+}
109+
110+// Path parsing helpers
111+func (s *Server) parseManifestPath(path string) (name, reference string) {
112+ // Expected format: /v2/{name}/manifests/{reference}
113+ parts := strings.Split(path, "/")
114+ if len(parts) >= 5 && parts[3] == "manifests" {
115+ name = parts[2]
116+ reference = parts[4]
117+ }
118+ return
119+}
120+
121+func (s *Server) parseBlobPath(path string) (name, digest string) {
122+ // Expected format: /v2/{name}/blobs/{digest}
123+ parts := strings.Split(path, "/")
124+ if len(parts) >= 5 && parts[3] == "blobs" {
125+ name = parts[2]
126+ digest = parts[4]
127+ }
128+ return
129+}
130+
131+func (s *Server) parseUploadPath(path string) (name, uuid string) {
132+ // Expected format: /v2/{name}/blobs/uploads/{uuid}
133+ parts := strings.Split(path, "/")
134+ if len(parts) >= 6 && parts[3] == "blobs" && parts[4] == "uploads" {
135+ name = parts[2]
136+ if len(parts) > 5 {
137+ uuid = parts[5]
138+ }
139+ }
140+ return
141+}
142+
143+func (s *Server) parseTagsPath(path string) string {
144+ // Expected format: /v2/{name}/tags/list
145+ parts := strings.Split(path, "/")
146+ if len(parts) >= 4 && parts[3] == "tags" {
147+ return parts[2]
148+ }
149+ return ""
150+}
151+
152+func (s *Server) Start(ctx context.Context) error {
153+ addr := ":" + strconv.Itoa(s.config.Server.RegistryPort)
154+
155+ // Build middleware chain
156+ middlewares := []func(http.Handler) http.Handler{
157+ middleware.PanicRecovery,
158+ middleware.RequestLogger,
159+ }
160+
161+ // Add registry authentication if enabled
162+ if s.config.Auth.Enabled && s.config.Auth.RegistryAuth {
163+ if s.config.Auth.Username != "" && s.config.Auth.Password != "" {
164+ middlewares = append(middlewares, middleware.RegistryAuth(s.config.Auth.Username, s.config.Auth.Password))
165+ }
166+ }
167+
168+ // Wrap the mux with middleware
169+ handler := middleware.Chain(middlewares...)(s.mux)
170+
171+ server := &http.Server{
172+ Addr: addr,
173+ Handler: handler,
174+ }
175+
176+ log.Info().Str("address", addr).Msg("Registry server starting")
177+
178+ // Start server in a goroutine
179+ errChan := make(chan error, 1)
180+ go func() {
181+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
182+ errChan <- err
183+ }
184+ }()
185+
186+ // Wait for context cancellation or server error
187+ select {
188+ case err := <-errChan:
189+ return err
190+ case <-ctx.Done():
191+ log.Info().Msg("Registry server shutting down...")
192+ return server.Shutdown(context.Background())
193+ }
194+}
195+
196+func (s *Server) handleRegistryInfo(w http.ResponseWriter, r *http.Request) {
197+ w.Header().Set("Content-Type", "application/json")
198+ w.WriteHeader(http.StatusOK)
199+ fmt.Fprint(w, `{"name":"gordon-registry"}`)
200+}
201+
202+func (s *Server) handleGetManifest(w http.ResponseWriter, r *http.Request) {
203+ name, reference := s.parseManifestPath(r.URL.Path)
204+
205+ log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
206+
207+ manifest, err := s.storage.GetManifest(name, reference)
208+ if err != nil {
209+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Manifest not found")
210+ w.WriteHeader(http.StatusNotFound)
211+ return
212+ }
213+
214+ w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
215+ w.Header().Set("Content-Length", strconv.Itoa(len(manifest)))
216+ w.WriteHeader(http.StatusOK)
217+ w.Write(manifest)
218+}
219+
220+func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
221+ name, reference := s.parseManifestPath(r.URL.Path)
222+
223+ log.Debug().Str("name", name).Str("reference", reference).Msg("PUT manifest")
224+
225+ // Read manifest data
226+ data, err := io.ReadAll(r.Body)
227+ if err != nil {
228+ log.Error().Err(err).Msg("Failed to read manifest data")
229+ http.Error(w, "Bad Request", http.StatusBadRequest)
230+ return
231+ }
232+
233+ // Store manifest
234+ if err := s.storage.PutManifest(name, reference, data); err != nil {
235+ log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest")
236+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
237+ return
238+ }
239+
240+ // Calculate digest for Docker-Content-Digest header
241+ digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
242+ w.Header().Set("Docker-Content-Digest", digest)
243+ w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
244+ w.WriteHeader(http.StatusCreated)
245+
246+ // TODO: Trigger deployment webhook
247+ log.Info().Str("name", name).Str("reference", reference).Msg("Manifest stored - deployment should be triggered")
248+}
249+
250+func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
251+ name, digest := s.parseBlobPath(r.URL.Path)
252+
253+ log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
254+
255+ reader, err := s.storage.GetBlob(digest)
256+ if err != nil {
257+ log.Warn().Err(err).Str("digest", digest).Msg("Blob not found")
258+ w.WriteHeader(http.StatusNotFound)
259+ return
260+ }
261+ defer reader.Close()
262+
263+ w.Header().Set("Content-Type", "application/octet-stream")
264+ w.WriteHeader(http.StatusOK)
265+
266+ _, err = io.Copy(w, reader)
267+ if err != nil {
268+ log.Error().Err(err).Str("digest", digest).Msg("Failed to serve blob")
269+ }
270+}
271+
272+func (s *Server) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
273+ name, _ := s.parseUploadPath(r.URL.Path)
274+
275+ log.Debug().Str("name", name).Msg("Start blob upload")
276+
277+ uuid, err := s.storage.StartBlobUpload(name)
278+ if err != nil {
279+ log.Error().Err(err).Str("name", name).Msg("Failed to start blob upload")
280+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
281+ return
282+ }
283+
284+ w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
285+ w.Header().Set("Range", "0-0")
286+ w.WriteHeader(http.StatusAccepted)
287+}
288+
289+func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
290+ name, uuid := s.parseUploadPath(r.URL.Path)
291+
292+ log.Debug().Str("name", name).Str("uuid", uuid).Msg("Blob upload")
293+
294+ // Check if this is a completion request (has digest parameter)
295+ digest := r.URL.Query().Get("digest")
296+
297+ if r.Method == "PUT" && digest != "" {
298+ // This is a completion request
299+ if err := s.storage.FinishBlobUpload(uuid, digest); err != nil {
300+ log.Error().Err(err).Str("uuid", uuid).Str("digest", digest).Msg("Failed to finish blob upload")
301+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
302+ return
303+ }
304+
305+ w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
306+ w.Header().Set("Docker-Content-Digest", digest)
307+ w.WriteHeader(http.StatusCreated)
308+
309+ log.Info().Str("name", name).Str("uuid", uuid).Str("digest", digest).Msg("Blob upload completed")
310+ return
311+ }
312+
313+ // This is a chunk upload (PATCH)
314+ writer, err := s.storage.GetBlobUpload(uuid)
315+ if err != nil {
316+ log.Error().Err(err).Str("uuid", uuid).Msg("Failed to get blob upload")
317+ http.Error(w, "Not Found", http.StatusNotFound)
318+ return
319+ }
320+ defer writer.Close()
321+
322+ written, err := io.Copy(writer, r.Body)
323+ if err != nil {
324+ log.Error().Err(err).Str("uuid", uuid).Msg("Failed to write blob chunk")
325+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
326+ return
327+ }
328+
329+ w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
330+ w.Header().Set("Range", fmt.Sprintf("0-%d", written-1))
331+ w.WriteHeader(http.StatusAccepted)
332+
333+ log.Debug().Str("uuid", uuid).Int64("bytes", written).Msg("Blob chunk uploaded")
334+}
335+
336+func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) {
337+ name := s.parseTagsPath(r.URL.Path)
338+
339+ log.Debug().Str("name", name).Msg("List tags")
340+
341+ tags, err := s.storage.ListTags(name)
342+ if err != nil {
343+ log.Error().Err(err).Str("name", name).Msg("Failed to list tags")
344+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
345+ return
346+ }
347+
348+ w.Header().Set("Content-Type", "application/json")
349+ w.WriteHeader(http.StatusOK)
350+
351+ // Build JSON response
352+ fmt.Fprintf(w, `{"name":"%s","tags":[`, name)
353+ for i, tag := range tags {
354+ if i > 0 {
355+ fmt.Fprint(w, ",")
356+ }
357+ fmt.Fprintf(w, `"%s"`, tag)
358+ }
359+ fmt.Fprint(w, "]}")
360+}⏎̸
A internal/registry/storage.go +413 -0
@@ -0,0 +1,413 @@
1+package registry
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/rs/zerolog/log"
13+)
14+
15+// Storage interface defines the contract for registry storage backends
16+type Storage interface {
17+ // Manifest operations
18+ GetManifest(name, reference string) ([]byte, error)
19+ PutManifest(name, reference string, data []byte) error
20+ DeleteManifest(name, reference string) error
21+
22+ // Blob operations
23+ GetBlob(digest string) (io.ReadCloser, error)
24+ PutBlob(digest string, data io.Reader, size int64) error
25+ DeleteBlob(digest string) error
26+ BlobExists(digest string) bool
27+
28+ // Upload operations
29+ StartBlobUpload(name string) (string, error) // returns upload UUID
30+ GetBlobUpload(uuid string) (io.WriteCloser, error)
31+ FinishBlobUpload(uuid, digest string) error
32+ CancelBlobUpload(uuid string) error
33+
34+ // Tag operations
35+ ListTags(name string) ([]string, error)
36+
37+ // Repository operations
38+ ListRepositories() ([]string, error)
39+}
40+
41+// FilesystemStorage implements Storage using the local filesystem
42+type FilesystemStorage struct {
43+ rootDir string
44+}
45+
46+// NewFilesystemStorage creates a new filesystem storage instance
47+func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) {
48+ // Create directory structure if it doesn't exist
49+ dirs := []string{
50+ filepath.Join(rootDir, "repositories"),
51+ filepath.Join(rootDir, "blobs"),
52+ filepath.Join(rootDir, "uploads"),
53+ }
54+
55+ for _, dir := range dirs {
56+ if err := os.MkdirAll(dir, 0755); err != nil {
57+ return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
58+ }
59+ }
60+
61+ log.Info().Str("root_dir", rootDir).Msg("Filesystem storage initialized")
62+
63+ return &FilesystemStorage{
64+ rootDir: rootDir,
65+ }, nil
66+}
67+
68+// Manifest operations
69+
70+func (fs *FilesystemStorage) GetManifest(name, reference string) ([]byte, error) {
71+ manifestPath := fs.getManifestPath(name, reference)
72+
73+ data, err := os.ReadFile(manifestPath)
74+ if err != nil {
75+ if os.IsNotExist(err) {
76+ return nil, fmt.Errorf("manifest not found: %s/%s", name, reference)
77+ }
78+ return nil, fmt.Errorf("failed to read manifest: %w", err)
79+ }
80+
81+ return data, nil
82+}
83+
84+func (fs *FilesystemStorage) PutManifest(name, reference string, data []byte) error {
85+ manifestPath := fs.getManifestPath(name, reference)
86+
87+ // Create directory if it doesn't exist
88+ if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil {
89+ return fmt.Errorf("failed to create manifest directory: %w", err)
90+ }
91+
92+ // Write manifest file
93+ if err := os.WriteFile(manifestPath, data, 0644); err != nil {
94+ return fmt.Errorf("failed to write manifest: %w", err)
95+ }
96+
97+ // Update tags list
98+ if err := fs.updateTagsList(name, reference); err != nil {
99+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")
100+ }
101+
102+ log.Info().Str("name", name).Str("reference", reference).Msg("Manifest stored")
103+ return nil
104+}
105+
106+func (fs *FilesystemStorage) DeleteManifest(name, reference string) error {
107+ manifestPath := fs.getManifestPath(name, reference)
108+
109+ if err := os.Remove(manifestPath); err != nil {
110+ if os.IsNotExist(err) {
111+ return fmt.Errorf("manifest not found: %s/%s", name, reference)
112+ }
113+ return fmt.Errorf("failed to delete manifest: %w", err)
114+ }
115+
116+ // Remove from tags list
117+ if err := fs.removeFromTagsList(name, reference); err != nil {
118+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")
119+ }
120+
121+ log.Info().Str("name", name).Str("reference", reference).Msg("Manifest deleted")
122+ return nil
123+}
124+
125+// Blob operations
126+
127+func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) {
128+ blobPath := fs.getBlobPath(digest)
129+
130+ file, err := os.Open(blobPath)
131+ if err != nil {
132+ if os.IsNotExist(err) {
133+ return nil, fmt.Errorf("blob not found: %s", digest)
134+ }
135+ return nil, fmt.Errorf("failed to open blob: %w", err)
136+ }
137+
138+ return file, nil
139+}
140+
141+func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64) error {
142+ blobPath := fs.getBlobPath(digest)
143+
144+ // Create directory if it doesn't exist
145+ if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
146+ return fmt.Errorf("failed to create blob directory: %w", err)
147+ }
148+
149+ // Create temporary file first
150+ tmpPath := blobPath + ".tmp"
151+ file, err := os.Create(tmpPath)
152+ if err != nil {
153+ return fmt.Errorf("failed to create temporary blob file: %w", err)
154+ }
155+ defer file.Close()
156+
157+ // Copy data to file
158+ written, err := io.Copy(file, data)
159+ if err != nil {
160+ os.Remove(tmpPath)
161+ return fmt.Errorf("failed to write blob data: %w", err)
162+ }
163+
164+ // Verify size if provided
165+ if size > 0 && written != size {
166+ os.Remove(tmpPath)
167+ return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written)
168+ }
169+
170+ // Move to final location
171+ if err := os.Rename(tmpPath, blobPath); err != nil {
172+ os.Remove(tmpPath)
173+ return fmt.Errorf("failed to move blob to final location: %w", err)
174+ }
175+
176+ log.Info().Str("digest", digest).Int64("size", written).Msg("Blob stored")
177+ return nil
178+}
179+
180+func (fs *FilesystemStorage) DeleteBlob(digest string) error {
181+ blobPath := fs.getBlobPath(digest)
182+
183+ if err := os.Remove(blobPath); err != nil {
184+ if os.IsNotExist(err) {
185+ return fmt.Errorf("blob not found: %s", digest)
186+ }
187+ return fmt.Errorf("failed to delete blob: %w", err)
188+ }
189+
190+ log.Info().Str("digest", digest).Msg("Blob deleted")
191+ return nil
192+}
193+
194+func (fs *FilesystemStorage) BlobExists(digest string) bool {
195+ blobPath := fs.getBlobPath(digest)
196+ _, err := os.Stat(blobPath)
197+ return err == nil
198+}
199+
200+// Upload operations
201+
202+func (fs *FilesystemStorage) StartBlobUpload(name string) (string, error) {
203+ // Generate UUID-like upload ID using timestamp
204+ uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_"))
205+
206+ uploadPath := fs.getUploadPath(uuid)
207+
208+ // Create directory if it doesn't exist
209+ if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil {
210+ return "", fmt.Errorf("failed to create upload directory: %w", err)
211+ }
212+
213+ // Create empty upload file
214+ file, err := os.Create(uploadPath)
215+ if err != nil {
216+ return "", fmt.Errorf("failed to create upload file: %w", err)
217+ }
218+ file.Close()
219+
220+ log.Info().Str("uuid", uuid).Str("name", name).Msg("Blob upload started")
221+ return uuid, nil
222+}
223+
224+func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
225+ uploadPath := fs.getUploadPath(uuid)
226+
227+ file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644)
228+ if err != nil {
229+ if os.IsNotExist(err) {
230+ return nil, fmt.Errorf("upload not found: %s", uuid)
231+ }
232+ return nil, fmt.Errorf("failed to open upload file: %w", err)
233+ }
234+
235+ return file, nil
236+}
237+
238+func (fs *FilesystemStorage) FinishBlobUpload(uuid, digest string) error {
239+ uploadPath := fs.getUploadPath(uuid)
240+ blobPath := fs.getBlobPath(digest)
241+
242+ // Create blob directory if it doesn't exist
243+ if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
244+ return fmt.Errorf("failed to create blob directory: %w", err)
245+ }
246+
247+ // Move upload file to blob location
248+ if err := os.Rename(uploadPath, blobPath); err != nil {
249+ return fmt.Errorf("failed to move upload to blob location: %w", err)
250+ }
251+
252+ log.Info().Str("uuid", uuid).Str("digest", digest).Msg("Blob upload finished")
253+ return nil
254+}
255+
256+func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error {
257+ uploadPath := fs.getUploadPath(uuid)
258+
259+ if err := os.Remove(uploadPath); err != nil {
260+ if os.IsNotExist(err) {
261+ return fmt.Errorf("upload not found: %s", uuid)
262+ }
263+ return fmt.Errorf("failed to cancel upload: %w", err)
264+ }
265+
266+ log.Info().Str("uuid", uuid).Msg("Blob upload cancelled")
267+ return nil
268+}
269+
270+// Tag operations
271+
272+func (fs *FilesystemStorage) ListTags(name string) ([]string, error) {
273+ tagsPath := fs.getTagsPath(name)
274+
275+ data, err := os.ReadFile(tagsPath)
276+ if err != nil {
277+ if os.IsNotExist(err) {
278+ return []string{}, nil // No tags file means no tags
279+ }
280+ return nil, fmt.Errorf("failed to read tags file: %w", err)
281+ }
282+
283+ var tags []string
284+ if err := json.Unmarshal(data, &tags); err != nil {
285+ return nil, fmt.Errorf("failed to parse tags file: %w", err)
286+ }
287+
288+ return tags, nil
289+}
290+
291+// Repository operations
292+
293+func (fs *FilesystemStorage) ListRepositories() ([]string, error) {
294+ reposDir := filepath.Join(fs.rootDir, "repositories")
295+
296+ var repositories []string
297+ err := filepath.Walk(reposDir, func(path string, info os.FileInfo, err error) error {
298+ if err != nil {
299+ return err
300+ }
301+
302+ if info.IsDir() && path != reposDir {
303+ // Get relative path from repositories directory
304+ relPath, err := filepath.Rel(reposDir, path)
305+ if err != nil {
306+ return err
307+ }
308+
309+ // Check if this directory contains manifests
310+ manifestsDir := filepath.Join(path, "manifests")
311+ if _, err := os.Stat(manifestsDir); err == nil {
312+ repositories = append(repositories, relPath)
313+ }
314+ }
315+
316+ return nil
317+ })
318+
319+ if err != nil {
320+ return nil, fmt.Errorf("failed to list repositories: %w", err)
321+ }
322+
323+ return repositories, nil
324+}
325+
326+// Helper methods for path generation
327+
328+func (fs *FilesystemStorage) getManifestPath(name, reference string) string {
329+ return filepath.Join(fs.rootDir, "repositories", name, "manifests", reference)
330+}
331+
332+func (fs *FilesystemStorage) getBlobPath(digest string) string {
333+ // Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...)
334+ parts := strings.SplitN(digest, ":", 2)
335+ if len(parts) != 2 {
336+ // Fallback if digest format is unexpected
337+ return filepath.Join(fs.rootDir, "blobs", digest)
338+ }
339+
340+ algorithm := parts[0]
341+ hash := parts[1]
342+
343+ // Create two-level directory structure for better performance
344+ if len(hash) >= 2 {
345+ return filepath.Join(fs.rootDir, "blobs", algorithm, hash[:2], hash)
346+ }
347+
348+ return filepath.Join(fs.rootDir, "blobs", algorithm, hash)
349+}
350+
351+func (fs *FilesystemStorage) getUploadPath(uuid string) string {
352+ return filepath.Join(fs.rootDir, "uploads", uuid)
353+}
354+
355+func (fs *FilesystemStorage) getTagsPath(name string) string {
356+ return filepath.Join(fs.rootDir, "repositories", name, "tags.json")
357+}
358+
359+// Helper methods for tags management
360+
361+func (fs *FilesystemStorage) updateTagsList(name, reference string) error {
362+ tags, err := fs.ListTags(name)
363+ if err != nil {
364+ return err
365+ }
366+
367+ // Add tag if not already present
368+ for _, tag := range tags {
369+ if tag == reference {
370+ return nil // Already exists
371+ }
372+ }
373+
374+ tags = append(tags, reference)
375+ return fs.saveTagsList(name, tags)
376+}
377+
378+func (fs *FilesystemStorage) removeFromTagsList(name, reference string) error {
379+ tags, err := fs.ListTags(name)
380+ if err != nil {
381+ return err
382+ }
383+
384+ // Remove tag if present
385+ var newTags []string
386+ for _, tag := range tags {
387+ if tag != reference {
388+ newTags = append(newTags, tag)
389+ }
390+ }
391+
392+ return fs.saveTagsList(name, newTags)
393+}
394+
395+func (fs *FilesystemStorage) saveTagsList(name string, tags []string) error {
396+ tagsPath := fs.getTagsPath(name)
397+
398+ // Create directory if it doesn't exist
399+ if err := os.MkdirAll(filepath.Dir(tagsPath), 0755); err != nil {
400+ return fmt.Errorf("failed to create tags directory: %w", err)
401+ }
402+
403+ data, err := json.Marshal(tags)
404+ if err != nil {
405+ return fmt.Errorf("failed to marshal tags: %w", err)
406+ }
407+
408+ if err := os.WriteFile(tagsPath, data, 0644); err != nil {
409+ return fmt.Errorf("failed to write tags file: %w", err)
410+ }
411+
412+ return nil
413+}⏎̸
D internal/server/app_server.go +0 -103
@@ -1,103 +0,0 @@
1-package server
2-
3-import (
4- "context"
5- "database/sql"
6- "fmt"
7- "time"
8-
9- "github.com/bnema/gordon/internal/common"
10- "github.com/bnema/gordon/internal/db"
11- "github.com/charmbracelet/log"
12- _ "github.com/joho/godotenv/autoload"
13-)
14-
15-type App struct {
16- // TemplateFS fs.FS // No longer needed
17- // PublicFS fs.FS // Removed as we now serve assets directly
18- // LocYML []byte // Replaced by parsed Strings map
19- Strings map[string]interface{} // Parsed localization strings
20- DBDir string
21- DBFilename string
22- DBPath string
23- InitialChecksum string
24- Config common.Config
25- DB *sql.DB
26- DBTables DBTables
27- DBSaveCtx context.Context // Context for the auto-save routine
28- DBSaveCancel context.CancelFunc // Cancel function for the auto-save routine
29- StartTime time.Time
30-}
31-
32-type DBTables struct {
33- User db.User `sql:"user"`
34- Account db.Account `sql:"account"`
35- Sessions db.Sessions `sql:"sessions"`
36- Provider db.Provider `sql:"provider"`
37- Clients db.Clients `sql:"clients"`
38- Domain db.Domain `sql:"domain"`
39- Certificate db.Certificate `sql:"certificate"`
40- ProxyRoute db.ProxyRoute `sql:"proxy_route"`
41- AcmeAccount db.AcmeAccount `sql:"acme_account"`
42-}
43-
44-// GetConfig returns the configuration
45-func (a *App) GetConfig() *common.Config {
46- return &a.Config
47-}
48-
49-// GetDB returns the database connection
50-func (a *App) GetDB() *sql.DB {
51- return a.DB
52-}
53-
54-// GenerateOauthCallbackURL generates the OAuth callback URL
55-func (a *App) GenerateOauthCallbackURL() string {
56- var scheme, port string
57- config := a.Config
58-
59- if config.Http.Https {
60- scheme = "https"
61- port = ""
62- } else {
63- scheme = "http"
64- port = fmt.Sprintf(":%s", a.Config.Http.Port)
65- }
66-
67- domain := config.Http.Domain
68- if config.Http.SubDomain != "" {
69- domain = fmt.Sprintf("%s.%s", config.Http.SubDomain, config.Http.Domain)
70- }
71-
72- callbackURL := fmt.Sprintf("%s://%s%s/callback", scheme, domain, port)
73- log.Debug("Generated OAuth Callback URL", "url", callbackURL)
74- return callbackURL
75-}
76-
77-func (a *App) IsDevEnvironment() bool {
78- return a.Config.Build.RunEnv == "dev"
79-}
80-
81-func (a *App) GetUptime() string {
82- uptime := time.Since(a.StartTime)
83- return uptime.String()
84-}
85-
86-// GetVersion returns the version of the app
87-func (a *App) GetVersionstring() string {
88- return fmt.Sprint(a.Config.Build.BuildVersion)
89-}
90-
91-// Shutdown performs a clean shutdown of the application
92-func (a *App) Shutdown() error {
93- log.Info("Initiating application shutdown")
94-
95- // Close the database connection
96- if err := CloseDB(a); err != nil {
97- log.Error("Error during database shutdown", "error", err)
98- return err
99- }
100-
101- log.Info("Application shutdown completed successfully")
102- return nil
103-}
D internal/server/cronjobs.go +0 -30
@@ -1,30 +0,0 @@
1-package server
2-
3-import (
4- "log"
5- "time"
6-)
7-
8-// SessionCleaner, time as a string
9-func SessionCleaner(a *App, currentTime string) error {
10- query := "DELETE FROM sessions WHERE expires < ?"
11- _, err := a.DB.Exec(query, currentTime)
12- if err != nil {
13- return err
14- }
15-
16- return nil
17-}
18-
19-// StartSessionCleaner initializes the session cleaner to run at regular intervals.
20-func (a *App) StartSessionCleaner() {
21- go func() {
22- for {
23- currentTime := time.Now().Format(time.RFC3339)
24- if err := SessionCleaner(a, currentTime); err != nil {
25- log.Printf("Failed to clean up sessions: %v", err)
26- }
27- time.Sleep(1 * time.Hour)
28- }
29- }()
30-}
D internal/server/new_server.go +0 -88
@@ -1,88 +0,0 @@
1-package server
2-
3-import (
4- "os"
5- "path/filepath"
6- "time"
7-
8- "github.com/bnema/gordon/internal/common"
9- "github.com/bnema/gordon/internal/proxy"
10- "github.com/bnema/gordon/internal/templating"
11- "github.com/bnema/gordon/pkg/logger"
12- "gopkg.in/yaml.v3"
13-)
14-
15-func NewServerApp(buildConfig *common.BuildConfig) (*App, error) {
16- // Get global config singleton instead of loading the config again
17- config, err := common.GetGlobalConfig(buildConfig)
18- if err != nil {
19- logger.Fatalf("Failed to load configuration: %v", err)
20- }
21-
22- logger.Info("Starting Gordon server", "version", config.Build.BuildVersion)
23-
24- // For development environment, use the project root directory
25- if config.Build.RunEnv == "dev" {
26- // Get the current working directory as the project root
27- projectRoot, err := os.Getwd()
28- if err != nil {
29- logger.Fatalf("Failed to get current working directory: %v", err)
30- }
31-
32- // Set storage directory to project root + /data instead of ~/.gordon
33- config.General.StorageDir = filepath.Join(projectRoot, "data")
34- logger.Debug("Dev environment detected, using local storage path", "path", config.General.StorageDir)
35-
36- // Create the directory if it doesn't exist
37- if err := os.MkdirAll(config.General.StorageDir, 0755); err != nil {
38- logger.Fatalf("Failed to create storage directory: %v", err)
39- }
40- }
41-
42- // Read the embedded localization file
43- locBytes, err := templating.LocFS.ReadFile("models/txt/locstrings.yml")
44- if err != nil {
45- logger.Fatalf("Failed to read embedded locstrings.yml: %v", err)
46- }
47-
48- // Parse the YAML content
49- var stringsMap map[string]interface{}
50- if err := yaml.Unmarshal(locBytes, &stringsMap); err != nil {
51- logger.Fatalf("Failed to parse locstrings.yml: %v", err)
52- }
53-
54- // Initialize DB
55- DBDir := config.General.StorageDir + "/db"
56-
57- // Initialize App
58- a := &App{
59- Strings: stringsMap,
60- DBDir: DBDir,
61- DBFilename: DBFilename,
62- Config: *config,
63- StartTime: time.Now(),
64- }
65-
66- a.GenerateOauthCallbackURL()
67-
68- return a, nil
69-}
70-
71-// InitializeProxy initializes and starts the reverse proxy
72-func (a *App) InitializeProxy() (*proxy.Proxy, error) {
73- logger.Info("Initializing reverse proxy")
74-
75- // Create a new proxy
76- p, err := proxy.NewProxy(a)
77- if err != nil {
78- return nil, err
79- }
80-
81- // Start the proxy
82- if err := p.Start(); err != nil {
83- return nil, err
84- }
85-
86- logger.Info("Reverse proxy initialized and started")
87- return p, nil
88-}
D internal/server/sqlite_close.go +0 -62
@@ -1,62 +0,0 @@
1-package server
2-
3-import (
4- "fmt"
5-
6- "github.com/charmbracelet/log"
7-)
8-
9-// CloseDB saves the in-memory database to disk and then closes the database connection.
10-func CloseDB(a *App) error {
11- log.Debug("CloseDB called, checking if database connection exists")
12-
13- if a.DB != nil {
14- log.Debug("Database connection exists, proceeding with cleanup")
15-
16- // Cancel the auto-save routine
17- if a.DBSaveCancel != nil {
18- log.Debug("Canceling auto-save routine")
19- a.DBSaveCancel()
20- } else {
21- log.Debug("No auto-save routine to cancel")
22- }
23-
24- log.Info("Saving in-memory database to disk before shutdown")
25-
26- // Save the in-memory database to disk
27- if err := saveMemDBToDisk(a.DB, a.DBPath); err != nil {
28- log.Error("Failed to save in-memory database to disk during shutdown", "error", err)
29- // Continue with closing even if saving fails
30- } else {
31- log.Info("Successfully saved in-memory database to disk")
32- }
33-
34- // Close the database connection
35- log.Debug("Closing database connection")
36- if err := a.DB.Close(); err != nil {
37- log.Error("Failed to close database connection", "error", err)
38- return fmt.Errorf("failed to close database: %w", err)
39- }
40-
41- log.Info("Database connection closed successfully")
42- // Set DB to nil to prevent double-close attempts
43- a.DB = nil
44- } else {
45- log.Debug("No database connection to close")
46- }
47-
48- return nil
49-}
50-
51-// func saveDB(a *App) error {
52-
53-// return nil
54-// }
55-
56-// // TODO
57-// func backupDBFile(a *App) error {
58-// // Create a backup of the database file at the same location as the database file.
59-// // The backup file name is the database file name with a timestamp appended to it.
60-// // If the backup file already exists, overwrite it.
61-// return nil
62-// }
D internal/server/sqlite_init.go +0 -713
@@ -1,713 +0,0 @@
1-package server
2-
3-import (
4- "context"
5- "database/sql"
6- "fmt"
7- "os"
8- "path/filepath"
9- "reflect"
10- "strings"
11- "time"
12-
13- "github.com/bnema/gordon/internal/db"
14- pkgsqlite "github.com/bnema/gordon/pkg/sqlite"
15- "github.com/charmbracelet/log"
16- "github.com/google/uuid"
17- _ "modernc.org/sqlite"
18-)
19-
20-const (
21- DBFilename = "sqlite.db"
22- // Auto-save interval in minutes
23- AutoSaveInterval = 5
24-)
25-
26-func (a *App) getDiskDBFilePath() string {
27- DiskDBfilepath := filepath.Join(a.DBDir, a.DBFilename)
28- // if a.DBDir is empty return log "Set a path using storageDir in the config file"
29- log.Debug("DB file path", "path", DiskDBfilepath)
30- return DiskDBfilepath
31-}
32-
33-// ensureDBDir ensures that the database directory exists.
34-func (a *App) ensureDBDir() error {
35- if _, err := os.Stat(a.DBDir); os.IsNotExist(err) {
36- log.Debug("Creating database directory", "dir", a.DBDir)
37- if err := os.MkdirAll(a.DBDir, 0755); err != nil {
38- return err
39- }
40- }
41- return nil
42-}
43-
44-// InitializeDB initializes the database in memory and loads data from disk if available
45-func InitializeDB(a *App) (*sql.DB, error) {
46- log.Debug("Initializing database")
47- if err := a.ensureDBDir(); err != nil {
48- return nil, fmt.Errorf("failed to ensure DB directory: %w", err)
49- }
50-
51- diskDBPath := a.getDiskDBFilePath()
52- a.DBPath = diskDBPath
53-
54- // --- Start Refactored Schema Handling ---
55- log.Debug("Ensuring database schema is up-to-date on disk", "path", diskDBPath)
56- // UpdateDatabase will create the file and tables if they don't exist,
57- // or update the schema if they do. It also handles backups.
58- if err := pkgsqlite.UpdateDatabase(diskDBPath, a.DBTables); err != nil {
59- return nil, fmt.Errorf("failed to create/update database schema on disk: %w", err)
60- }
61- log.Debug("Database schema on disk is up-to-date.")
62- // --- End Refactored Schema Handling ---
63-
64- // --- BEGIN Seed Admin Account if Necessary ---
65- log.Debug("Checking if admin account seeding is necessary", "path", diskDBPath)
66- // Need to temporarily open the disk DB to check and potentially write
67- seedDB, err := sql.Open("sqlite", diskDBPath)
68- if err != nil {
69- return nil, fmt.Errorf("failed to open disk DB for seeding check: %w", err)
70- }
71-
72- var accountCount int
73- err = seedDB.QueryRow("SELECT COUNT(*) FROM account").Scan(&accountCount)
74- if err != nil {
75- seedDB.Close()
76- // If the table doesn't exist yet (shouldn't happen after UpdateDatabase), log it
77- if pkgsqlite.IsNoSuchTableError(err) {
78- log.Warn("Account table not found during seeding check, skipping seed.", "error", err)
79- // Proceed without seeding, assuming UpdateDatabase handled creation correctly
80- } else {
81- return nil, fmt.Errorf("failed to query account count from disk DB: %w", err)
82- }
83- } else if accountCount == 0 {
84- log.Info("No accounts found, seeding default admin user and account.")
85- // Begin transaction for seeding
86- seedTx, txErr := seedDB.Begin()
87- if txErr != nil {
88- seedDB.Close()
89- return nil, fmt.Errorf("failed to begin transaction for seeding: %w", txErr)
90- }
91-
92- adminUserID := uuid.NewString()
93- adminAccountID := uuid.NewString()
94- adminEmail := "admin@local.host" // Or some other placeholder
95-
96- // Insert User
97- _, txErr = seedTx.Exec("INSERT INTO user (id, name, email) VALUES (?, ?, ?)",
98- adminUserID, "Admin User", adminEmail)
99- if txErr != nil {
100- seedTx.Rollback()
101- seedDB.Close()
102- return nil, fmt.Errorf("failed to insert default admin user: %w", txErr)
103- }
104-
105- // Insert Account
106- _, txErr = seedTx.Exec("INSERT INTO account (id, user_id) VALUES (?, ?)",
107- adminAccountID, adminUserID)
108- if txErr != nil {
109- seedTx.Rollback()
110- seedDB.Close()
111- return nil, fmt.Errorf("failed to insert default admin account: %w", txErr)
112- }
113-
114- // Commit transaction
115- txErr = seedTx.Commit()
116- if txErr != nil {
117- seedDB.Close()
118- return nil, fmt.Errorf("failed to commit seeding transaction: %w", txErr)
119- }
120- log.Info("Default admin user and account seeded successfully", "user_id", adminUserID, "account_id", adminAccountID)
121- } else {
122- log.Debug("Accounts already exist, skipping admin seeding.", "count", accountCount)
123- }
124-
125- // Close the temporary connection used for seeding
126- if err := seedDB.Close(); err != nil {
127- // Log error but don't necessarily fail initialization at this point
128- log.Error("Error closing temporary seed DB connection", "error", err)
129- }
130- // --- END Seed Admin Account if Necessary ---
131-
132- // Create in-memory database connection (remains the same)
133- log.Debug("Opening in-memory SQLite database")
134- memDB, err := sql.Open("sqlite", "file::memory:?cache=shared")
135- if err != nil {
136- return nil, fmt.Errorf("failed to open in-memory DB: %w", err)
137- }
138-
139- // Test that the in-memory database is working (remains the same)
140- log.Debug("Testing in-memory database connection")
141- if err := memDB.Ping(); err != nil {
142- memDB.Close() // Close memDB if ping fails
143- return nil, fmt.Errorf("failed to ping in-memory database: %w", err)
144- }
145-
146- // --- Load from Disk to Memory ---
147- // Now that the disk schema is guaranteed to be correct, load it into memory.
148- // We need to check if the file actually contains data before loading.
149- // A simple way is to check if the file size is > 0 after UpdateDatabase.
150- fileInfo, err := os.Stat(diskDBPath)
151- loadNeeded := true
152- if err != nil {
153- if os.IsNotExist(err) {
154- log.Debug("Database file still doesn't exist after schema update (likely first run with no tables?), skipping load from disk.")
155- loadNeeded = false // Nothing to load
156- } else {
157- memDB.Close()
158- return nil, fmt.Errorf("failed to stat disk DB file after schema update: %w", err)
159- }
160- } else if fileInfo.Size() == 0 {
161- log.Debug("Database file is empty after schema update, skipping load from disk.")
162- loadNeeded = false // Nothing to load
163- }
164-
165- if loadNeeded {
166- log.Debug("Loading database from disk into memory", "path", diskDBPath)
167- if err := loadDiskDBToMemory(diskDBPath, memDB); err != nil {
168- memDB.Close()
169- return nil, fmt.Errorf("failed to load disk DB into memory: %w", err)
170- }
171- log.Debug("Successfully loaded disk database into memory")
172- } else {
173- // If we didn't load, the memory DB is empty but has the correct schema
174- // implicitly because UpdateDatabase already ran on the (possibly new) disk file.
175- // However, loadDiskDBToMemory *also* creates the schema in memory based on the disk file.
176- // If the disk file was *just* created by UpdateDatabase and is empty,
177- // loadDiskDBToMemory won't run, and memDB won't have the tables yet.
178- // We need to ensure the schema exists in memory even if the disk file was initially empty.
179- // Easiest way: re-apply the schema creation logic to memDB.
180- log.Debug("Applying schema to empty in-memory database")
181- memTx, err := memDB.Begin()
182- if err != nil {
183- memDB.Close()
184- return nil, fmt.Errorf("failed to begin transaction on memory DB for schema creation: %w", err)
185- }
186- schemaCreated := false
187- // Iterate through DBTables fields using reflection to get correct table names
188- dbTablesType := reflect.TypeOf(a.DBTables)
189- dbTablesValue := reflect.ValueOf(a.DBTables)
190- if dbTablesType.Kind() == reflect.Ptr {
191- dbTablesType = dbTablesType.Elem()
192- dbTablesValue = dbTablesValue.Elem()
193- }
194-
195- if dbTablesType.Kind() == reflect.Struct {
196- log.Debug("Reflecting DBTables struct for in-memory schema creation", "type", dbTablesType.Name())
197- for i := 0; i < dbTablesType.NumField(); i++ {
198- field := dbTablesType.Field(i)
199- fieldValue := dbTablesValue.Field(i).Interface() // e.g., db.ProxyRoute instance
200- sqlTag := field.Tag.Get("sql")
201-
202- if sqlTag == "" || sqlTag == "-" {
203- log.Debug("Skipping field without sql tag in memory schema creation", "field", field.Name)
204- continue // Skip fields without a sql tag or marked to be ignored
205- }
206-
207- tableName := sqlTag // Use the tag directly as the table name
208- log.Debug("Attempting to create table in memory", "tableName", tableName, "structType", reflect.TypeOf(fieldValue).Name())
209-
210- if err := pkgsqlite.CreateTableInTx(memTx, fieldValue, tableName); err != nil {
211- // Ignore "table already exists" errors if somehow they occur
212- if !strings.Contains(strings.ToLower(err.Error()), "already exists") {
213- log.Error("Failed to create table in memory", "table", tableName, "error", err)
214- memTx.Rollback()
215- memDB.Close()
216- return nil, fmt.Errorf("failed to create table %s in memory: %w", tableName, err)
217- } else {
218- log.Debug("Table already exists in memory, skipping creation", "table", tableName)
219- }
220- } else {
221- log.Debug("Successfully created table in memory", "table", tableName)
222- schemaCreated = true
223- }
224- }
225- } else {
226- log.Error("a.DBTables is not a struct, cannot create schema in memory")
227- memDB.Close()
228- return nil, fmt.Errorf("internal error: DBTables is not a struct type (%T)", a.DBTables)
229- }
230-
231- if err := memTx.Commit(); err != nil {
232- memDB.Close()
233- return nil, fmt.Errorf("failed to commit schema creation transaction to memory DB: %w", err)
234- }
235- if schemaCreated {
236- log.Debug("Successfully applied schema to in-memory database")
237- } else {
238- log.Debug("In-memory schema already existed or no tables defined.")
239- }
240-
241- }
242- // --- End Load from Disk to Memory ---
243-
244- a.DB = memDB
245-
246- // Populate struct tables if data was loaded
247- if loadNeeded {
248- log.Debug("Populating struct tables from loaded database")
249- if err := PopulateStructWithTables(a); err != nil {
250- // Don't fail initialization for this, maybe just log an error
251- log.Error("Failed to populate struct tables after loading DB", "error", err)
252- // Depending on severity, you might want to return nil, err here
253- }
254- } else {
255- log.Debug("Skipping struct table population as no data was loaded from disk.")
256- }
257-
258- // Create context for auto-save (remains the same)
259- a.DBSaveCtx, a.DBSaveCancel = context.WithCancel(context.Background())
260-
261- // Start auto-save routine (remains the same)
262- log.Debug("Starting auto-save routine with interval", "minutes", AutoSaveInterval)
263- go autoSaveDB(a.DBSaveCtx, a, AutoSaveInterval)
264-
265- log.Info("Database initialization completed successfully")
266- return memDB, nil
267-}
268-
269-// loadDiskDBToMemory loads the database from disk into memory
270-func loadDiskDBToMemory(diskDBPath string, memDB *sql.DB) error {
271- log.Debug("Opening disk database for loading", "path", diskDBPath)
272- // Open the disk database
273- diskDB, err := sql.Open("sqlite", diskDBPath)
274- if err != nil {
275- return fmt.Errorf("failed to open disk DB for loading: %w", err)
276- }
277- defer diskDB.Close()
278-
279- // Backup disk database to memory
280- // First, get all table names
281- log.Debug("Retrieving table names from disk database")
282- rows, err := diskDB.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
283- if err != nil {
284- return fmt.Errorf("failed to get table names: %w", err)
285- }
286- defer rows.Close()
287-
288- // Begin transaction for memory DB
289- log.Debug("Beginning transaction on memory database")
290- memTx, err := memDB.Begin()
291- if err != nil {
292- return fmt.Errorf("failed to begin transaction on memory DB: %w", err)
293- }
294- defer func() {
295- if err != nil {
296- log.Debug("Rolling back memory database transaction due to error")
297- memTx.Rollback()
298- }
299- }()
300-
301- tableCount := 0
302- // For each table, create it in memory and copy data
303- for rows.Next() {
304- var tableName string
305- if err := rows.Scan(&tableName); err != nil {
306- return fmt.Errorf("failed to scan table name: %w", err)
307- }
308- tableCount++
309-
310- log.Debug("Processing table", "name", tableName)
311-
312- // Get table schema
313- var createStmt string
314- err := diskDB.QueryRow(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND name='%s'", tableName)).Scan(&createStmt)
315- if err != nil {
316- return fmt.Errorf("failed to get create statement for table %s: %w", tableName, err)
317- }
318-
319- // Create table in memory
320- log.Debug("Creating table in memory", "name", tableName)
321- _, err = memTx.Exec(createStmt)
322- if err != nil {
323- return fmt.Errorf("failed to create table %s in memory: %w", tableName, err)
324- }
325-
326- // Get all data from disk table
327- log.Debug("Retrieving data from disk table", "name", tableName)
328- dataRows, err := diskDB.Query(fmt.Sprintf("SELECT * FROM %s", tableName))
329- if err != nil {
330- return fmt.Errorf("failed to query data from table %s: %w", tableName, err)
331- }
332- defer dataRows.Close()
333-
334- // Get column names
335- columns, err := dataRows.Columns()
336- if err != nil {
337- return fmt.Errorf("failed to get columns for table %s: %w", tableName, err)
338- }
339- log.Debug("Table columns", "name", tableName, "columns", columns)
340-
341- // Prepare insert statement for memory DB
342- placeholders := make([]string, len(columns))
343- for i := range placeholders {
344- placeholders[i] = "?"
345- }
346- insertStmt, err := memTx.Prepare(fmt.Sprintf("INSERT INTO %s VALUES (%s)", tableName, joinStrings(placeholders, ",")))
347- if err != nil {
348- return fmt.Errorf("failed to prepare insert statement for table %s: %w", tableName, err)
349- }
350- defer insertStmt.Close()
351-
352- // Copy data row by row
353- rowCount := 0
354- for dataRows.Next() {
355- // Create a slice of interface{} to hold the values
356- values := make([]interface{}, len(columns))
357- valuePtrs := make([]interface{}, len(columns))
358- for i := range values {
359- valuePtrs[i] = &values[i]
360- }
361-
362- // Scan the row into the slice
363- if err := dataRows.Scan(valuePtrs...); err != nil {
364- return fmt.Errorf("failed to scan row from table %s: %w", tableName, err)
365- }
366-
367- // Insert into memory DB
368- _, err = insertStmt.Exec(values...)
369- if err != nil {
370- return fmt.Errorf("failed to insert row into memory table %s: %w", tableName, err)
371- }
372- rowCount++
373- }
374- log.Debug("Copied rows to memory", "table", tableName, "count", rowCount)
375-
376- if err := dataRows.Err(); err != nil {
377- return fmt.Errorf("error iterating rows for table %s: %w", tableName, err)
378- }
379- }
380-
381- if err := rows.Err(); err != nil {
382- return fmt.Errorf("error iterating table names: %w", err)
383- }
384-
385- // Commit transaction
386- log.Debug("Committing transaction to memory database")
387- if err := memTx.Commit(); err != nil {
388- return fmt.Errorf("failed to commit transaction to memory DB: %w", err)
389- }
390-
391- log.Debug("Successfully loaded database from disk to memory", "tables", tableCount)
392- return nil
393-}
394-
395-// saveMemDBToDisk saves the in-memory database to disk
396-func saveMemDBToDisk(memDB *sql.DB, diskDBPath string) error {
397- log.Debug("Saving in-memory database to disk", "path", diskDBPath)
398-
399- // Create a backup of the existing file if it exists
400- if _, err := os.Stat(diskDBPath); err == nil {
401- backupPath := diskDBPath + ".bak"
402- log.Debug("Creating backup of existing database file", "backup", backupPath)
403- if err := os.Rename(diskDBPath, backupPath); err != nil {
404- log.Warn("Failed to create backup of database file", "error", err)
405- // Continue anyway, as this is just a backup
406- } else {
407- log.Debug("Created backup of database file", "backup", backupPath)
408- }
409- }
410-
411- // Open the disk database
412- log.Debug("Opening disk database for saving")
413- diskDB, err := sql.Open("sqlite", diskDBPath)
414- if err != nil {
415- return fmt.Errorf("failed to open disk DB for saving: %w", err)
416- }
417- defer diskDB.Close()
418-
419- // Get all table names from memory DB
420- log.Debug("Retrieving table names from memory database")
421- rows, err := memDB.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
422- if err != nil {
423- return fmt.Errorf("failed to get table names from memory: %w", err)
424- }
425- defer rows.Close()
426-
427- // Begin transaction for disk DB
428- log.Debug("Beginning transaction on disk database")
429- diskTx, err := diskDB.Begin()
430- if err != nil {
431- return fmt.Errorf("failed to begin transaction on disk DB: %w", err)
432- }
433- defer func() {
434- if err != nil {
435- log.Debug("Rolling back disk database transaction due to error")
436- diskTx.Rollback()
437- }
438- }()
439-
440- tableCount := 0
441- // For each table, create it on disk and copy data
442- for rows.Next() {
443- var tableName string
444- if err := rows.Scan(&tableName); err != nil {
445- return fmt.Errorf("failed to scan table name: %w", err)
446- }
447- tableCount++
448-
449- log.Debug("Processing table for saving", "name", tableName)
450-
451- // Get table schema
452- var createStmt string
453- err := memDB.QueryRow(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND name='%s'", tableName)).Scan(&createStmt)
454- if err != nil {
455- return fmt.Errorf("failed to get create statement for table %s: %w", tableName, err)
456- }
457-
458- // Create table on disk
459- log.Debug("Creating table on disk", "name", tableName)
460- _, err = diskTx.Exec(createStmt)
461- if err != nil {
462- return fmt.Errorf("failed to create table %s on disk: %w", tableName, err)
463- }
464-
465- // Get all data from memory table
466- log.Debug("Retrieving data from memory table", "name", tableName)
467- dataRows, err := memDB.Query(fmt.Sprintf("SELECT * FROM %s", tableName))
468- if err != nil {
469- return fmt.Errorf("failed to query data from memory table %s: %w", tableName, err)
470- }
471- defer dataRows.Close()
472-
473- // Get column names
474- columns, err := dataRows.Columns()
475- if err != nil {
476- return fmt.Errorf("failed to get columns for table %s: %w", tableName, err)
477- }
478-
479- // Prepare insert statement for disk DB
480- placeholders := make([]string, len(columns))
481- for i := range placeholders {
482- placeholders[i] = "?"
483- }
484- insertStmt, err := diskTx.Prepare(fmt.Sprintf("INSERT INTO %s VALUES (%s)", tableName, joinStrings(placeholders, ",")))
485- if err != nil {
486- return fmt.Errorf("failed to prepare insert statement for table %s: %w", tableName, err)
487- }
488- defer insertStmt.Close()
489-
490- // Copy data row by row
491- rowCount := 0
492- for dataRows.Next() {
493- // Create a slice of interface{} to hold the values
494- values := make([]interface{}, len(columns))
495- valuePtrs := make([]interface{}, len(columns))
496- for i := range values {
497- valuePtrs[i] = &values[i]
498- }
499-
500- // Scan the row into the slice
501- if err := dataRows.Scan(valuePtrs...); err != nil {
502- return fmt.Errorf("failed to scan row from memory table %s: %w", tableName, err)
503- }
504-
505- // Insert into disk DB
506- _, err = insertStmt.Exec(values...)
507- if err != nil {
508- return fmt.Errorf("failed to insert row into disk table %s: %w", tableName, err)
509- }
510- rowCount++
511- }
512- log.Debug("Copied rows to disk", "table", tableName, "count", rowCount)
513-
514- if err := dataRows.Err(); err != nil {
515- return fmt.Errorf("error iterating rows for table %s: %w", tableName, err)
516- }
517- }
518-
519- if err := rows.Err(); err != nil {
520- return fmt.Errorf("error iterating table names: %w", err)
521- }
522-
523- // Commit transaction
524- log.Debug("Committing transaction to disk database")
525- if err := diskTx.Commit(); err != nil {
526- return fmt.Errorf("failed to commit transaction to disk DB: %w", err)
527- }
528-
529- log.Debug("Successfully saved in-memory database to disk", "tables", tableCount)
530- return nil
531-}
532-
533-// autoSaveDB periodically saves the in-memory database to disk
534-func autoSaveDB(ctx context.Context, a *App, intervalMinutes int) {
535- ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute)
536- defer ticker.Stop()
537-
538- log.Debug("Auto-save routine started", "interval_minutes", intervalMinutes)
539-
540- for {
541- select {
542- case <-ticker.C:
543- log.Debug("Auto-saving in-memory database to disk")
544- if err := saveMemDBToDisk(a.DB, a.DBPath); err != nil {
545- log.Error("Failed to auto-save in-memory database to disk", "error", err)
546- } else {
547- log.Debug("Auto-save completed successfully")
548- }
549- case <-ctx.Done():
550- log.Debug("Auto-save routine stopping due to context cancellation")
551- return
552- }
553- }
554-}
555-
556-// Helper function to join strings with a separator
557-func joinStrings(strings []string, separator string) string {
558- if len(strings) == 0 {
559- return ""
560- }
561- result := strings[0]
562- for i := 1; i < len(strings); i++ {
563- result += separator + strings[i]
564- }
565- return result
566-}
567-
568-type DBTable interface {
569- ScanFromRows(rows *sql.Rows) error
570-}
571-
572-type UserWrapper struct {
573- *db.User
574-}
575-
576-type AccountWrapper struct {
577- *db.Account
578-}
579-
580-type ProviderWrapper struct {
581- *db.Provider
582-}
583-
584-type SessionsWrapper struct {
585- *db.Sessions
586-}
587-
588-type ClientsWrapper struct {
589- *db.Clients
590-}
591-
592-type DomainWrapper struct {
593- *db.Domain
594-}
595-
596-type CertificateWrapper struct {
597- *db.Certificate
598-}
599-
600-type ProxyRouteWrapper struct {
601- *db.ProxyRoute
602-}
603-
604-func populateTableFromDB(a *App, query string, table DBTable) error {
605- rows, err := a.DB.Query(query)
606- if err != nil {
607- return err
608- }
609- defer rows.Close()
610-
611- for rows.Next() {
612- if err := table.ScanFromRows(rows); err != nil {
613- return err
614- }
615- }
616- return rows.Err()
617-}
618-
619-// Implementation of DBTable for User, Account, Provider, Sessions
620-func (u *UserWrapper) ScanFromRows(rows *sql.Rows) error {
621- return rows.Scan(&u.ID, &u.Name, &u.Email)
622-}
623-
624-func (a *AccountWrapper) ScanFromRows(rows *sql.Rows) error {
625- return rows.Scan(&a.ID, &a.UserID)
626-}
627-
628-func (p *ProviderWrapper) ScanFromRows(rows *sql.Rows) error {
629- return rows.Scan(&p.ID, &p.AccountID, &p.Name, &p.Login, &p.AvatarURL, &p.ProfileURL, &p.Email)
630-}
631-
632-func (s *SessionsWrapper) ScanFromRows(rows *sql.Rows) error {
633- return rows.Scan(&s.ID, &s.AccountID, &s.AccessToken, &s.BrowserInfo, &s.Expires, &s.IsActive)
634-}
635-
636-func (c *ClientsWrapper) ScanFromRows(rows *sql.Rows) error {
637- return rows.Scan(&c.ID, &c.AccountID, &c.OS, &c.IP, &c.Hostname, &c.Expires)
638-}
639-
640-func (d *DomainWrapper) ScanFromRows(rows *sql.Rows) error {
641- return rows.Scan(
642- &d.ID,
643- &d.Name,
644- &d.AccountID,
645- &d.CreatedAt,
646- &d.UpdatedAt,
647- &d.AcmeEnabled,
648- &d.AcmeChallengeType,
649- &d.AcmeDnsProvider,
650- &d.AcmeDnsCredentialsRef,
651- )
652-}
653-
654-func (c *CertificateWrapper) ScanFromRows(rows *sql.Rows) error {
655- return rows.Scan(&c.ID, &c.DomainID, &c.CertFile, &c.KeyFile, &c.IssuedAt, &c.ExpiresAt, &c.Issuer, &c.Status)
656-}
657-
658-func (p *ProxyRouteWrapper) ScanFromRows(rows *sql.Rows) error {
659- return rows.Scan(&p.ID, &p.DomainID, &p.ContainerID, &p.ContainerIP, &p.ContainerPort, &p.Protocol, &p.Path, &p.Active)
660-}
661-
662-// PopulateStructWithTables updates db struct with tables
663-func PopulateStructWithTables(a *App) error {
664- // Populate the User table
665- userWrapper := &UserWrapper{User: &a.DBTables.User}
666- if err := populateTableFromDB(a, "SELECT id, name, email FROM user", userWrapper); err != nil {
667- return err
668- }
669-
670- // Populate the Account table
671- accountWrapper := &AccountWrapper{Account: &a.DBTables.Account}
672- if err := populateTableFromDB(a, "SELECT id, user_id FROM account", accountWrapper); err != nil {
673- return err
674- }
675-
676- // Populate the Provider table
677- providerWrapper := &ProviderWrapper{Provider: &a.DBTables.Provider}
678- if err := populateTableFromDB(a, "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider", providerWrapper); err != nil {
679- return err
680- }
681-
682- // Populate the Sessions table
683- sessionsWrapper := &SessionsWrapper{Sessions: &a.DBTables.Sessions}
684- if err := populateTableFromDB(a, "SELECT id, account_id, browser_info, access_token, expires, is_active FROM sessions", sessionsWrapper); err != nil {
685- return err
686- }
687-
688- // Populate the Clients table
689- clientsWrapper := &ClientsWrapper{Clients: &a.DBTables.Clients}
690- if err := populateTableFromDB(a, "SELECT id, account_id, os, ip, hostname, expires FROM clients", clientsWrapper); err != nil {
691- return err
692- }
693-
694- // Populate the Domain table
695- domainWrapper := &DomainWrapper{Domain: &a.DBTables.Domain}
696- if err := populateTableFromDB(a, "SELECT id, name, account_id, created_at, updated_at, acme_enabled, acme_challenge_type, acme_dns_provider, acme_dns_credentials_ref FROM domain", domainWrapper); err != nil {
697- return err
698- }
699-
700- // Populate the Certificate table
701- certificateWrapper := &CertificateWrapper{Certificate: &a.DBTables.Certificate}
702- if err := populateTableFromDB(a, "SELECT id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status FROM certificate", certificateWrapper); err != nil {
703- return err
704- }
705-
706- // Populate the ProxyRoute table
707- proxyRouteWrapper := &ProxyRouteWrapper{ProxyRoute: &a.DBTables.ProxyRoute}
708- if err := populateTableFromDB(a, "SELECT id, domain_id, container_id, container_ip, container_port, protocol, path, active FROM proxy_route", proxyRouteWrapper); err != nil {
709- return err
710- }
711-
712- return nil
713-}
D internal/templating/cmdparams/from_cli.go +0 -76
@@ -1,76 +0,0 @@
1-package cmdparams
2-
3-import (
4- "fmt"
5- "net/url"
6- "strings"
7-
8- "github.com/bnema/gordon/internal/common"
9- "github.com/bnema/gordon/internal/server"
10- "github.com/bnema/gordon/pkg/docker"
11-)
12-
13-// FromYAMLStructToCmdParams converts a YAMLContainerParams struct to a ContainerCommandParams struct
14-func FromPayloadStructToCmdParams(ppl *common.DeployPayload, a *server.App, imageID string) (docker.ContainerCommandParams, error) {
15-
16- // volume empty for now
17- volumeSlice := ParseVolumeSlice("")
18-
19- environmentSlice := ParseEnvironmentSlice("")
20-
21- // from ppl.TargetDomain, (eg. "https://test.example.com") get container_name (eg. "test"), get container_subdomain (eg. "test"), get container_domain (eg. "example.com") and get container_protocol (eg. "https")
22- // Parse TargetDomain to get the various components
23- parsedURL, err := url.Parse(ppl.TargetDomain)
24- if err != nil {
25- return docker.ContainerCommandParams{}, err
26- }
27-
28- // Extract the subdomain and domain from the host
29- hostParts := strings.Split(parsedURL.Host, ".")
30- if len(hostParts) < 2 {
31- return docker.ContainerCommandParams{}, fmt.Errorf("invalid domain: %s", parsedURL.Host)
32- }
33-
34- // Protocol (http/https), Container name, Container subdomain, and Container domain
35- protocol := parsedURL.Scheme
36-
37- var containerSubdomain string
38- var containerDomain string
39-
40- if len(hostParts) == 2 {
41- // No subdomain (e.g., domain.tld)
42- containerSubdomain = ""
43- containerDomain = parsedURL.Host
44- } else {
45- // Has subdomain (e.g., sub.domain.tld)
46- containerSubdomain = hostParts[0]
47- containerDomain = strings.Join(hostParts[1:], ".")
48- }
49-
50- // Use subdomain as container name, or domain with hyphens if no subdomain
51- containerName := containerSubdomain
52- if containerName == "" {
53- containerName = strings.ReplaceAll(containerDomain, ".", "-")
54- }
55-
56- params := docker.ContainerCommandParams{
57- IsSSL: protocol == "https",
58- ContainerName: containerName,
59- ServiceName: containerName,
60- Domain: containerDomain,
61- ImageName: ppl.ImageName,
62- ImageID: imageID,
63- Restart: "always",
64- Volumes: volumeSlice,
65- Environment: environmentSlice,
66- Network: a.Config.ContainerEngine.Network,
67- ProxyPort: ppl.Port,
68- }
69-
70- err = SetupProxyRoute(&params, params.ProxyPort, a)
71- if err != nil {
72- return docker.ContainerCommandParams{}, fmt.Errorf("error setting up proxy route: %w", err)
73- }
74-
75- return params, nil
76-}
D internal/templating/cmdparams/from_inputs.go +0 -66
@@ -1,66 +0,0 @@
1-package cmdparams
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/gordon/internal/server"
7- "github.com/bnema/gordon/pkg/docker"
8-)
9-
10-// FromInputsToCmdParams transforms the inputs map into a ContainerCommandParams struct
11-func FromInputsToCmdParams(inputs map[string]string, a *server.App) (docker.ContainerCommandParams, error) {
12- volumeSlice := ParseVolumeSlice(inputs["volumes"])
13- environmentSlice := ParseEnvironmentSlice(inputs["environment_variables"])
14-
15- var portMappings []docker.PortMapping
16- var err error
17-
18- // Parse port mappings if provided
19- if inputs["ports"] != "" {
20- portMappings, err = ParsePortMappingsSlice(inputs["ports"])
21- if err != nil {
22- return docker.ContainerCommandParams{}, err
23- }
24- }
25-
26- // Retrieve proxy port from the correct input key
27- proxyPortValue := inputs["container_port"] // Changed from proxy_port
28-
29- params := docker.ContainerCommandParams{
30- IsSSL: inputs["container_protocol"] == "https",
31- ContainerName: inputs["container_name"],
32- ServiceName: inputs["container_subdomain"],
33- Domain: inputs["container_domain"],
34- ImageName: inputs["image_name"],
35- ImageID: inputs["image_id"],
36- Restart: inputs["restart"],
37- ProxyPort: proxyPortValue, // Assign the retrieved value
38- Volumes: volumeSlice,
39- Environment: environmentSlice,
40- Network: a.Config.ContainerEngine.Network,
41- PortMappings: portMappings,
42- // Labels will be populated below
43- }
44-
45- // Initialize Labels slice if nil
46- if params.Labels == nil {
47- params.Labels = []string{}
48- }
49-
50- // Add standard labels and the proxy port label
51- params.Labels = append(params.Labels,
52- fmt.Sprintf("gordon.managed=true"),
53- fmt.Sprintf("gordon.domain=%s.%s", params.ServiceName, params.Domain),
54- fmt.Sprintf("gordon.proxy.ssl=%t", params.IsSSL),
55- fmt.Sprintf("gordon.proxy.port=%s", proxyPortValue), // Add the proxy port label
56- )
57-
58- // SetupProxyRoute might need adjustment if it relies on params.ProxyPort directly
59- // Assuming SetupProxyRoute uses the passed proxyPortValue or reads labels
60- err = SetupProxyRoute(&params, proxyPortValue, a) // Pass the correct port value
61- if err != nil {
62- return docker.ContainerCommandParams{}, fmt.Errorf("error setting up proxy route: %w", err)
63- }
64-
65- return params, nil
66-}
D internal/templating/cmdparams/from_yaml.go +0 -58
@@ -1,58 +0,0 @@
1-package cmdparams
2-
3-import (
4- "fmt"
5- "strings"
6-
7- "github.com/bnema/gordon/pkg/docker"
8-)
9-
10-type YAMLContainerParams struct {
11- Name string `yaml:"Name"`
12- Image string `yaml:"Image"`
13- Hostname string `yaml:"Hostname"`
14- Ports []string `yaml:"Ports"`
15- Volumes []string `yaml:"Volumes"`
16- Environment []string `yaml:"Environment"`
17- Labels map[string]string `yaml:"Labels"`
18- Network []string `yaml:"Network"`
19- Restart string `yaml:"Restart"`
20-}
21-
22-// FromYAMLStructToCmdParams converts a YAMLContainerParams struct to a ContainerCommandParams struct
23-func FromYAMLStructToCmdParams(yamlParams YAMLContainerParams) (docker.ContainerCommandParams, error) {
24- // with the image name find the image id
25- imageID, err := docker.GetImageIDByName(yamlParams.Image)
26- if err != nil {
27- return docker.ContainerCommandParams{}, err
28- }
29-
30- // Convert the map of labels to a slice of strings
31- var labels []string
32- for k, v := range yamlParams.Labels {
33- labels = append(labels, fmt.Sprintf("%s=%s", k, v))
34- }
35-
36- // Ports input parsing
37- portMappings, err := docker.ParsePortsSpecs(yamlParams.Ports)
38- if err != nil {
39- return docker.ContainerCommandParams{}, err
40- }
41-
42- // Convert the slice of strings to a comma separated string
43- network := strings.Join(yamlParams.Network, ",")
44-
45- params := docker.ContainerCommandParams{
46- ContainerName: yamlParams.Name,
47- ImageName: yamlParams.Image,
48- ImageID: imageID,
49- Volumes: yamlParams.Volumes,
50- Environment: yamlParams.Environment,
51- Labels: labels,
52- Network: network,
53- Restart: yamlParams.Restart,
54- PortMappings: portMappings,
55- }
56-
57- return params, nil
58-}
D internal/templating/cmdparams/parsing.go +0 -39
@@ -1,39 +0,0 @@
1-package cmdparams
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/gordon/pkg/docker"
7-)
8-
9-// ParseVolumeSlice parses the volumes string into a slice.
10-func ParseVolumeSlice(volumeStr string) []string {
11- volumeSlice := strings.Split(volumeStr, ",")
12- if len(volumeSlice) == 1 && volumeSlice[0] == "" {
13- return []string{}
14- }
15- return volumeSlice
16-}
17-
18-// ParseEnvironmentSlice parses the environment variables string into a slice.
19-func ParseEnvironmentSlice(environmentStr string) []string {
20- environmentSlice := strings.Split(environmentStr, "\n")
21- if len(environmentSlice) == 1 && environmentSlice[0] == "" {
22- return []string{}
23- }
24- return environmentSlice
25-}
26-
27-// ParsePortMappingsSlice parses the port mappings string into a slice and a struct.
28-func ParsePortMappingsSlice(portMappingsStr string) ([]docker.PortMapping, error) {
29- if portMappingsStr == "" {
30- return []docker.PortMapping{}, nil
31- }
32-
33- portMappingsSliceRaw := strings.Split(portMappingsStr, ",")
34- portMappingsSlice := make([]string, len(portMappingsSliceRaw))
35- for i, spec := range portMappingsSliceRaw {
36- portMappingsSlice[i] = strings.TrimSpace(spec)
37- }
38- return docker.ParsePortsSpecs(portMappingsSlice)
39-}
D internal/templating/cmdparams/proxy.go +0 -39
@@ -1,39 +0,0 @@
1-package cmdparams
2-
3-import (
4- "github.com/bnema/gordon/internal/interfaces"
5- "github.com/bnema/gordon/pkg/docker"
6- "github.com/charmbracelet/log"
7-)
8-
9-// SetupProxyRoute configures the container for our reverse proxy
10-func SetupProxyRoute(params *docker.ContainerCommandParams, containerPort string, a interfaces.AppInterface) error {
11- // Set the container port to be used by the reverse proxy
12- params.ProxyPort = containerPort
13-
14- // Create the full domain (service.domain)
15- fullDomain := params.ServiceName + "." + params.Domain
16-
17- // Add custom labels to identify this container as manageable by Gordon
18- params.Labels = []string{
19- "gordon.managed=true",
20- "gordon.domain=" + fullDomain,
21- "gordon.proxy.port=" + containerPort,
22- "gordon.proxy.ssl=" + boolToString(params.IsSSL),
23- }
24-
25- log.Debug("Container proxy configuration set up",
26- "domain", fullDomain,
27- "proxyPort", containerPort,
28- "ssl", params.IsSSL)
29-
30- return nil
31-}
32-
33-// boolToString converts a boolean to a string "true" or "false"
34-func boolToString(b bool) string {
35- if b {
36- return "true"
37- }
38- return "false"
39-}
D internal/templating/embed.go +0 -13
@@ -1,13 +0,0 @@
1-package templating
2-
3-import (
4- "embed"
5-)
6-
7-// Embedding localization file
8-
9-//go:embed models/txt/locstrings.yml
10-var locFS embed.FS
11-
12-// LocFS provides access to the embedded localization file.
13-var LocFS = locFS
D internal/templating/models/templ/README.md +0 -48
@@ -1,48 +0,0 @@
1-# Templ Templates for Gordon
2-
3-This directory contains [templ](https://github.com/a-h/templ) templates for the Gordon application. We're migrating from Go's html/template to templ for improved type safety and maintainability.
4-
5-## Directory Structure
6-
7-- `components/` - Reusable UI components
8-- `layouts/` - Page layouts and base templates
9-- `pages/` - Full page templates
10- - `admin/` - Admin pages
11- - `login/` - Login pages
12-
13-## Working with Templates
14-
15-To create or modify templates:
16-
17-1. Edit the `.templ` files in the appropriate directory
18-2. Run `templ generate` or `make build-templ` to generate Go code
19-3. Use the templates in your handlers with the `TemplRenderer`
20-
21-## Converting from gohtml
22-
23-When converting from gohtml:
24-
25-1. Create a new `.templ` file
26-2. Copy HTML structure from gohtml
27-3. Replace `{{ .varName }}` with `{ varName }`
28-4. Use templ's control flow syntax like `if`, `for`, etc.
29-5. Use `@` to invoke other components
30-
31-## Example:
32-
33-```templ
34-package components
35-
36-templ Button(label string, classes string) {
37- <button class={classes}>{ label }</button>
38-}
39-```
40-
41-## Usage in Handlers
42-
43-```go
44-func RenderMyPage(c echo.Context, a *server.App) error {
45- renderer := render.NewTemplRenderer(a)
46- return renderer.RenderTempl(c, mypage.PageTemplate("Page Title", someData))
47-}
48-```⏎̸
D internal/templating/models/templ/components/containerlist.templ +0 -132
@@ -1,132 +0,0 @@
1-package components
2-
3-import (
4- "github.com/docker/docker/api/types/container"
5- "strconv"
6-)
7-
8-// ContainerList displays a list of containers
9-templ ContainerList(containers []container.Summary, noContainer bool) {
10- <!-- Container list using daisyUI components -->
11- <div class="card w-full bg-base-100">
12- <div class="card-body p-4">
13-
14- <!-- Action buttons -->
15- <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
16- <h3 class="font-semibold text-base-content">Available Containers: { strconv.Itoa(len(containers)) }</h3>
17- <div class="btn-group">
18- <button
19- class="btn btn-sm btn-primary"
20- hx-get="/htmx/container-manager"
21- hx-target="#container-manager"
22- hx-swap="innerHTML">
23- <span class="iconf">󰑓</span> Refresh
24- </button>
25- </div>
26- </div>
27-
28- <!-- Container list -->
29- <table class="table">
30- <!-- head -->
31- <thead>
32- <tr>
33- <th>Name</th>
34- <th>Image</th>
35- <th>Status</th>
36- <th>Created</th>
37- <th>Actions</th>
38- </tr>
39- </thead>
40- <tbody>
41- if noContainer || len(containers) == 0 {
42- <tr>
43- <td colspan="5" class="text-center py-4">
44- <div class="alert alert-info">
45- <span class="iconf">ℹ️</span>
46- <span>No containers found. Upload an image first, then create a container.</span>
47- </div>
48- </td>
49- </tr>
50- } else {
51- for _, container := range containers {
52- <tr class="hover">
53- <td>
54- <div class="flex items-center gap-3">
55- <div>
56- <div class="font-bold">{ container.Names[0] }</div>
57- <div class="text-sm opacity-50">{ container.ID }</div>
58- </div>
59- </div>
60- </td>
61- <td>{ container.Image }</td>
62- <td>
63- if container.State == "running" {
64- <div class="badge badge-success badge-sm">Running</div>
65- } else if container.State == "exited" {
66- <div class="badge badge-warning badge-sm">Stopped</div>
67- } else {
68- <div class="badge badge-ghost badge-sm">{ container.State }</div>
69- }
70- </td>
71- <td>{ strconv.FormatInt(container.Created, 10) }</td>
72- <th>
73- <div class="dropdown dropdown-end">
74- <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
75- <span class="iconf">󰅀</span>
76- </div>
77- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40">
78- if container.State == "running" {
79- <li>
80- <button
81- class="text-warning flex items-center"
82- hx-post={ "/htmx/container-manager/stop/" + container.ID }
83- hx-target="#container-manager"
84- hx-swap="innerHTML">
85- <span class="iconf mr-2">󰓛</span> Stop
86- </button>
87- </li>
88- } else if container.State == "exited" {
89- <li>
90- <button
91- class="text-success flex items-center"
92- hx-post={ "/htmx/container-manager/start/" + container.ID }
93- hx-target="#container-manager"
94- hx-swap="innerHTML">
95- <span class="iconf mr-2">󰐊</span> Start
96- </button>
97- </li>
98- }
99- <li>
100- <button
101- class="text-info flex items-center"
102- hx-get={ "/htmx/container-manager/edit/" + container.ID }
103- hx-target="#container-manager"
104- hx-swap="innerHTML">
105- <span class="iconf mr-2">󰏫</span> Edit
106- </button>
107- </li>
108- <li>
109- <button
110- class="text-error flex items-center"
111- hx-delete={ "/htmx/container-manager/delete/" + container.ID }
112- hx-target="#container-manager"
113- hx-swap="innerHTML"
114- hx-confirm="Are you sure you want to delete this container?">
115- <span class="iconf mr-2">󰆴</span> Delete
116- </button>
117- </li>
118- </ul>
119- </div>
120- </th>
121- </tr>
122- }
123- }
124- </tbody>
125- </table>
126- </div>
127- </div>
128-
129- <script>
130- console.debug('Container list rendered');
131- </script>
132-}⏎̸
D internal/templating/models/templ/components/containerlist_templ.go +0 -228 generated file: collapsed
D internal/templating/models/templ/components/createcontainer.templ +0 -163
@@ -1,163 +0,0 @@
1-package components
2-
3-import "fmt"
4-
5-// ContainerFormData holds the data needed for the container creation form
6-type ContainerFormData struct {
7- ImageName string
8- ImageID string
9- ShortID string
10- ContainerName string
11- ContainerSubdomain string
12- ContainerDomain string
13- Ports string
14- ProxyPort string
15- Volumes string
16- EnvironmentVariables string
17- ErrorMessage string
18-}
19-
20-// CreateContainerForm displays a form for creating a new container
21-templ CreateContainerForm(data ContainerFormData) {
22- <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
23- <!-- Left Side: Docker Run Options -->
24- <div class="flex-grow card p-6 bg-base-100">
25- <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2>
26- if data.ErrorMessage != "" {
27- <div class="alert alert-error">
28- <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
29- <span>{ data.ErrorMessage }</span>
30- </div>
31- }
32- <form id={ "create-container-form-" + data.ShortID }
33- action={ templ.URL(fmt.Sprintf("/create-container/%s", data.ShortID)) }
34- method="post"
35- hx-post={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", data.ShortID))) }
36- hx-swap="innerHTML"
37- hx-target={ "#create-form-target-" + data.ShortID }>
38- <!-- Image Name -->
39- <input type="hidden" name="image_id" value={ data.ImageID } />
40- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4">
41- <div class="md:col-span-1">
42- <label class="label">
43- <span class="label-text">Image name</span>
44- </label>
45- <input type="text" name="image_name" class="input input-bordered input-disabled w-full"
46- value={ data.ImageName } readonly />
47- </div>
48-
49- <div class="md:col-span-2">
50- <label class="label">
51- <span class="label-text">Image ID</span>
52- </label>
53- <input type="text" name="image_id" class="input input-bordered input-disabled w-full"
54- value={ data.ImageID } readonly />
55- </div>
56- </div>
57-
58- <!-- Container Name -->
59- <div class="form-control pb-4">
60- <label class="label">
61- <span class="label-text">Container name</span>
62- </label>
63- <input type="text" name="container_name" class="input input-bordered w-full" value={ data.ContainerName } />
64- </div>
65-
66- <!-- Http(s) / Subdomain / Domain -->
67- <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4">
68- <!-- Protocol -->
69- <div class="md:col-span-3">
70- <label class="label">
71- <span class="label-text">Protocol</span>
72- </label>
73- <select name="container_protocol" class="select select-bordered w-full">
74- <option value="https">https</option>
75- <option value="http">http</option>
76- </select>
77- </div>
78-
79- <!-- Subdomain -->
80- <div class="md:col-span-4">
81- <label class="label">
82- <span class="label-text">Subdomain</span>
83- </label>
84- <input type="text" name="container_subdomain" class="input input-bordered w-full" value={ data.ContainerSubdomain } />
85- </div>
86-
87- <!-- Domain -->
88- <div class="md:col-span-5">
89- <label class="label">
90- <span class="label-text">Domain</span>
91- </label>
92- <input type="text" name="container_domain" class="input input-bordered w-full" value={ data.ContainerDomain } />
93- </div>
94- </div>
95-
96- <!-- Exposed Ports -->
97- <div class="form-control pb-4">
98- <label class="label">
99- <span class="label-text">Exposed Ports (Optional)</span>
100- <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span>
101- </label>
102- <input type="text" name="ports" class="input input-bordered w-full" value={ data.Ports } />
103- </div>
104-
105- <!-- Proxy Port -->
106- <div class="form-control pb-4">
107- <label class="label">
108- <span class="label-text">Container Proxy Port (Required)</span>
109- <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span>
110- </label>
111- <input type="text" name="container_port" class="input input-bordered w-full" value={ data.ProxyPort } required />
112- </div>
113-
114- <!-- Volumes -->
115- <div class="form-control pb-4">
116- <label class="label">
117- <span class="label-text">Volumes</span>
118- <span class="label-text-alt">e.g. ./data:/container/data</span>
119- </label>
120- <input type="text" name="volumes" class="input input-bordered w-full" value={ data.Volumes } />
121- </div>
122-
123- <div class="form-control pb-4">
124- <label class="label">
125- <span class="label-text">Environment variables</span>
126- <span class="label-text-alt">e.g. KEY=VALUE</span>
127- </label>
128- <textarea name="environment_variables" class="textarea textarea-bordered w-full"
129- rows="4">{ data.EnvironmentVariables }</textarea>
130- </div>
131-
132- <!-- Restart: always/on-failure/only when stopped/never -->
133- <div class="form-control pb-4">
134- <label class="label">
135- <span class="label-text">Restart</span>
136- </label>
137- <select name="restart" class="select select-bordered w-full">
138- <option value="always">Always</option>
139- <option value="on-failure">On failure</option>
140- <option value="unless-stopped">Unless stopped</option>
141- <option value="never">Never</option>
142- </select>
143- </div>
144-
145- <!-- Skip Proxy Setup Option -->
146- <div class="form-control pb-4">
147- <label class="label cursor-pointer justify-start gap-4">
148- <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" />
149- <span>Skip proxy setup (create container only, don't set up domain/certificates)</span>
150- </label>
151- <div class="text-sm opacity-70 ml-10">
152- Use this option if you only want to create the container without triggering proxy setup,
153- certificate generation, and other related processes.
154- </div>
155- </div>
156-
157- <button type="submit" id="create-container-submit" class="btn btn-primary">
158- Create Container
159- </button>
160- </form>
161- </div>
162- </div>
163-} ⏎̸
D internal/templating/models/templ/components/createcontainer_templ.go +0 -272 generated file: collapsed
D internal/templating/models/templ/components/editcontainer.templ +0 -39
@@ -1,39 +0,0 @@
1-package components
2-
3-import t "github.com/a-h/templ"
4-
5-// EditContainer replaces editcontainer.gohtml
6-// It renders a form to edit a container's configuration
7-templ EditContainer(id string, containerInfo string) {
8- <div
9- id={ "edit-container-" + id }
10- class="card bg-base-100 shadow-xl"
11- hx-ext="response-targets"
12- >
13- <div id="editor" class="card-body">
14- <form
15- method="post"
16- action={ t.URL("/htmx/container-manager/edit/" + id) }
17- hx-post={ string(t.URL("/htmx/container-manager/edit/" + id)) }
18- hx-swap="innerHTML"
19- hx-target-*={ "#error-message-edit-container-" + id }
20- >
21- <textarea
22- name="container_config"
23- id="container_config"
24- class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF"
25- rows="20"
26- >{ containerInfo }</textarea>
27- <div class="card-actions justify-end mt-4">
28- <input
29- type="submit"
30- value="Save"
31- id={ "save-button-" + id }
32- class="btn btn-primary"
33- />
34- </div>
35- </form>
36- <div id={ "error-message-edit-container-" + id } class="text-error mt-2"></div>
37- </div>
38- </div>
39-} ⏎̸
D internal/templating/models/templ/components/editcontainer_templ.go +0 -131 generated file: collapsed
D internal/templating/models/templ/components/footer.templ +0 -17
@@ -1,17 +0,0 @@
1-package components
2-
3-// Footer is a templ component for the page footer
4-// It replaces footer.gohtml
5-templ Footer() {
6- <footer class="footer items-center p-4 bg-base-300 text-base-content">
7- <div class="items-center grid-flow-col">
8- <span class="iconfb text-xl">󰊢</span>
9- <p class="IBMPlexSans text-sm">Gordon Container Manager © 2024</p>
10- </div>
11- <div class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
12- <a class="link link-hover text-sm" href="https://github.com/bnema/gordon" target="_blank">GitHub</a>
13- // Add other essential links here if needed, e.g., Documentation
14- // <a class="link link-hover text-sm">Documentation</a>
15- </div>
16- </footer>
17-}⏎̸
D internal/templating/models/templ/components/footer_templ.go +0 -42 generated file: collapsed
D internal/templating/models/templ/components/header.templ +0 -31
@@ -1,31 +0,0 @@
1-package components
2-
3-// Header is a templ component for the page header
4-// It replaces header.gohtml
5-templ Header(title string) {
6- <head>
7- <meta charset="UTF-8" />
8- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9- <title>{ title }</title>
10- <link rel="stylesheet" href="/assets/css/tailwind.css" />
11-
12- <script src="/libs/jquery.min.js"></script>
13- <script src="/libs/htmx.min.js"></script>
14- <script src="/libs/htmx-response-targets.js"></script>
15- <script src="/libs/htmx-debug.js"></script>
16- <script src="/libs/htmx-sse.js"></script>
17- <script src="/libs/daisyui.js" type="module"></script>
18- <script defer src="/libs/hyperscript.min.js"></script>
19- <script defer src="/assets/js/custom.js"></script>
20-
21- <!-- daisyUI theme configuration -->
22- <script>
23- // Check for saved theme preference or use preferred color scheme
24- const savedTheme = localStorage.getItem('theme') || 'gordon-dark';
25- document.documentElement.setAttribute('data-theme', savedTheme);
26-
27- // Log theme loading for debugging
28- console.debug('Theme loaded:', savedTheme);
29- </script>
30- </head>
31-}⏎̸
D internal/templating/models/templ/components/header_templ.go +0 -55 generated file: collapsed
D internal/templating/models/templ/components/htmx_components.templ +0 -80
@@ -1,80 +0,0 @@
1-package components
2-
3-// HTMXHeader creates the hero header with HTMX attributes
4-templ HTMXHeader(title string, subtitle string) {
5- <div id="header" class="hero bg-base-200">
6- <div class="hero-content text-center">
7- <div class="max-w-md">
8- <h1 class="text-5xl font-bold gordon-title">
9- { title }
10- </h1>
11- <p class="py-6 IBMPlexSans">{ subtitle }</p>
12- <button class="btn btn-primary">Get Started</button>
13- </div>
14- </div>
15- </div>
16-}
17-
18-// HTMXAlert creates an alert box that can be loaded with HTMX
19-templ HTMXAlert(message string) {
20- <div id="hello" class="alert alert-info shadow-lg mt-4 mb-4">
21- <div>
22- <span class="iconfb">󰋽</span>
23- <span class="IBMPlexSans">{ message }</span>
24- </div>
25- </div>
26-}
27-
28-// HTMXHeaderContainer creates a container to load the HTMX header
29-templ HTMXHeaderContainer() {
30- <div
31- id="header"
32- hx-get="/htmx"
33- hx-headers='{"HX-Fragment": "header"}'
34- hx-trigger="load"
35- hx-swap="outerHTML"
36- ></div>
37-}
38-
39-// HTMXAppContainer creates a container to load the HTMX app
40-templ HTMXAppContainer() {
41- <div
42- id="app"
43- hx-get="/htmx"
44- hx-headers='{"HX-Fragment": "hello"}'
45- hx-trigger="load"
46- hx-swap="outerHTML"
47- ></div>
48-}
49-
50-// StatsCard creates individual stat cards for the dashboard
51-templ StatsCard(title string, value string, description string, icon string) {
52- <div class="stat bg-base-100 shadow">
53- <div class="stat-figure text-primary">
54- <span class="iconfb text-3xl">{ icon }</span>
55- </div>
56- <div class="stat-title">{ title }</div>
57- <div class="stat-value">{ value }</div>
58- <div class="stat-desc">{ description }</div>
59- </div>
60-}
61-
62-// StatsCards creates a set of stats cards
63-templ StatsCards() {
64- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
65- @StatsCard("Containers", "31", "Running containers", "󰡨")
66- @StatsCard("Images", "42", "Available images", "󱧕")
67- @StatsCard("Networks", "12", "Container networks", "󰙅")
68- </div>
69-}
70-
71-// TabsNav creates a navigation tabs component
72-templ TabsNav(activeTab string) {
73- <div class="tabs tabs-boxed my-4">
74- <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Overview")}>Overview</a>
75- <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Containers")}>Containers</a>
76- <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Images")}>Images</a>
77- <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Networks")}>Networks</a>
78- <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Volumes")}>Volumes</a>
79- </div>
80-}⏎̸
D internal/templating/models/templ/components/htmx_components_templ.go +0 -438 generated file: collapsed
D internal/templating/models/templ/components/imagelist.templ +0 -189
@@ -1,189 +0,0 @@
1-package components
2-
3-import (
4- "strconv"
5- "fmt"
6-)
7-
8-// HumanReadableImage represents an image with human-readable attributes
9-type HumanReadableImage struct {
10- ID string
11- ShortID string
12- Name string
13- Repository string
14- Tag string
15- Size string
16- Created string
17- RepoDigests []string
18- RepoTags []string
19-}
20-
21-// ImageList displays a list of images
22-templ ImageList(images []HumanReadableImage) {
23- <!-- Image list using daisyUI components -->
24- <div class="card w-full bg-base-100">
25- <div class="card-body p-4">
26-
27- <!-- Action buttons -->
28- <div class="flex flex-col sm:flex-row sm:justify-start items-start sm:items-center gap-4 flex-wrap">
29- <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3>
30- <div class="flex gap-2">
31- <button
32- class="btn btn-sm btn-secondary"
33- hx-get="/htmx/upload-image"
34- hx-target="#image-manager"
35- hx-swap="innerHTML">
36- <span class="iconf">󰦒</span> Upload
37- </button>
38- <button
39- class="btn btn-sm btn-primary"
40- hx-get="/htmx/image-manager"
41- hx-target="#image-manager"
42- hx-swap="innerHTML">
43- <span class="iconf">󰑓</span> Refresh
44- </button>
45- <button
46- id="purge-button"
47- class="btn btn-sm btn-warning"
48- hx-post="/htmx/image-manager/prune"
49- hx-target="#image-manager"
50- hx-swap="innerHTML"
51- hx-indicator="#purge-spinner"
52- hx-confirm="Are you sure you want to delete all unused (dangling and unreferenced) images? This cannot be undone.">
53- <span class="btn-content flex items-center">
54- <span class="iconf mr-1">󰩹</span> Purge Unused
55- </span>
56- <span class="loading loading-spinner loading-sm hidden" id="purge-spinner"></span>
57- </button>
58- </div>
59- </div>
60-
61- <!-- Image list -->
62- <table class="table">
63- <!-- head -->
64- <thead>
65- <tr>
66- <th>Repository</th>
67- <th>Tag</th>
68- <th>Size</th>
69- <th>Created</th>
70- <th>Actions</th>
71- </tr>
72- </thead>
73- <tbody>
74- /* Check if there are no images */
75- if len(images) == 0 {
76- <tr>
77- <td colspan="5" class="text-center py-4">
78- <div class="alert alert-info">
79- <span class="iconf">ℹ️</span>
80- <span>No images found. Upload an image first.</span>
81- </div>
82- </td>
83- </tr>
84- } else {
85- /* List all available images */
86- for _, img := range images {
87- <tr class="hover">
88- <td>
89- <div class="flex items-center gap-3">
90- <div>
91- <div class="font-bold">{ img.Repository }</div>
92- <div class="text-sm opacity-50">{ img.ID }</div>
93- </div>
94- </div>
95- </td>
96- <td>{ img.Tag }</td>
97- <td>{ img.Size }</td>
98- <td>{ img.Created }</td>
99- <th>
100- <div class="dropdown dropdown-end">
101- <div tabindex="0" role="button" class="btn btn-ghost btn-xs">
102- <span class="iconf">󰅀</span>
103- </div>
104- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
105- <li>
106- <a
107- href="#"
108- hx-get={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", img.ShortID))) }
109- hx-target={ "#create-form-target-" + img.ShortID }
110- hx-swap="innerHTML"
111- class="text-success flex items-center">
112- <span class="iconf mr-2">󰐕</span> Create Container
113- </a>
114- </li>
115- <li>
116- <button
117- class="text-error flex items-center"
118- hx-delete={ string(templ.URL(fmt.Sprintf("/htmx/image-manager/delete/%s", img.ShortID))) }
119- hx-target="#image-manager"
120- hx-swap="innerHTML"
121- hx-confirm="Are you sure you want to delete this image?">
122- <span class="iconf mr-2">󰆴</span> Delete
123- </button>
124- </li>
125- </ul>
126- </div>
127- </th>
128- </tr>
129- <tr>
130- <td colspan="5" id={ "create-form-target-" + img.ShortID }>
131- <!-- Form will be loaded here by HTMX -->
132- </td>
133- </tr>
134- }
135- }
136- </tbody>
137- </table>
138- </div>
139- </div>
140-
141- <!-- Notification Area -->
142- <div id="image-prune-notifications" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
143-
144- <style>
145- #purge-button.htmx-request .btn-content {
146- display: none;
147- }
148- #purge-button.htmx-request #purge-spinner {
149- display: inline-block;
150- }
151- </style>
152-
153- <script>
154- console.debug('Image list rendered');
155-
156- document.body.addEventListener('showMessage', function(evt) {
157- console.debug('showMessage event received:', evt.detail);
158- const detail = evt.detail;
159- const notificationArea = document.getElementById('image-prune-notifications');
160- if (!notificationArea || !detail || !detail.message) return;
161-
162- const alertDiv = document.createElement('div');
163- let alertClass = 'alert-info'; // Default
164- let iconClass = 'ℹ️'; // Default icon
165-
166- if (detail.level === 'success') {
167- alertClass = 'alert-success';
168- iconClass = '󰕬'; // Checkmark icon
169- } else if (detail.level === 'error') {
170- alertClass = 'alert-error';
171- iconClass = '󰅚'; // Error icon
172- }
173-
174- alertDiv.className = `alert ${alertClass} shadow-lg flex`;
175- alertDiv.innerHTML = `
176- <span class="iconf text-xl">${iconClass}</span>
177- <span>${detail.message}</span>
178- <button class="btn btn-sm btn-ghost ml-auto" onclick="this.parentElement.remove()">✕</button>
179- `;
180-
181- notificationArea.appendChild(alertDiv);
182-
183- // Automatically remove the alert after 5 seconds
184- setTimeout(() => {
185- alertDiv.remove();
186- }, 5000);
187- });
188- </script>
189-} ⏎̸
D internal/templating/models/templ/components/imagelist_templ.go +0 -206 generated file: collapsed
D internal/templating/models/templ/components/navbar.templ +0 -72
@@ -1,72 +0,0 @@
1-package components
2-
3-// Navbar is a menu component for navigation
4-templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
5- <!-- DaisyUI Navbar -->
6- <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50">
7- <div class="navbar-start">
8- <div class="flex items-center">
9- <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4">
10- Gordon
11- </h1>
12- <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70">
13- { buildVersion }
14- </span>
15- <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title="">
16- <a href="https://github.com/bnema/gordon/releases" class="link">󰨦</a>
17- </span>
18- </div>
19- </div>
20- <div class="navbar-center hidden lg:flex">
21- <ul class="menu menu-horizontal px-1">
22- <li>
23- <a href={ templ.URL(adminPath + "/manager") } class="font-semibold">
24- <span class="JetBrainsMonoBoldNF pr-1">󰘦</span> Manager
25- </a>
26- </li>
27- </ul>
28- </div>
29- <div class="navbar-end">
30- <!-- Theme toggle -->
31- <div class="dropdown dropdown-end">
32- <label tabindex="0" class="btn btn-ghost btn-circle">
33- <span class="iconf text-lg md:text-xl">󰔎</span>
34- </label>
35- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
36- <li><button onclick="setTheme('gordon')">Gordon Light</button></li>
37- <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li>
38- <li><button onclick="setTheme('light')">Light</button></li>
39- <li><button onclick="setTheme('dark')">Dark</button></li>
40- </ul>
41- </div>
42-
43- <!-- User menu -->
44- <div class="dropdown dropdown-end">
45- <label tabindex="0" class="btn btn-ghost btn-circle">
46- <span class="iconf text-lg md:text-xl">󰀉</span>
47- </label>
48- <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
49- <li>
50- <a href={ templ.URL(userSettingsURL) } title="User settings">
51- <span class="iconf">󰒓</span> Settings
52- </a>
53- </li>
54- <li>
55- <a href={ templ.URL(logoutURL) } title="Logout">
56- <span class="iconf">󰍃</span> Logout
57- </a>
58- </li>
59- </ul>
60- </div>
61- </div>
62- </div>
63-
64- <!-- Theme toggle script -->
65- <script>
66- function setTheme(theme) {
67- document.documentElement.setAttribute('data-theme', theme);
68- localStorage.setItem('theme', theme);
69- console.debug('Theme changed to:', theme);
70- }
71- </script>
72-}⏎̸
D internal/templating/models/templ/components/navbar_templ.go +0 -81 generated file: collapsed
D internal/templating/models/templ/components/success.templ +0 -16
@@ -1,16 +0,0 @@
1-package components
2-
3-// Success displays a success message
4-templ Success(message string) {
5- <div
6- id="success"
7- class="flex text-success content-center justify-center items-center w-full h-full">
8- <p class="text-2xl text-center">
9- if message != "" {
10- { message }
11- } else {
12- Success!
13- }
14- </p>
15- </div>
16-}⏎̸
D internal/templating/models/templ/components/success_templ.go +0 -61 generated file: collapsed
D internal/templating/models/templ/components/uploadimage.templ +0 -66
@@ -1,66 +0,0 @@
1-package components
2-
3-// UploadImageForm displays a form for uploading Docker images
4-templ UploadImageForm() {
5- <!-- Upload image form using DaisyUI components -->
6- <div class="card bg-base-100 shadow">
7- <div class="card-body">
8- <div class="flex justify-between items-center">
9- <h2 class="card-title text-primary">Upload Image</h2>
10- <button
11- class="btn btn-sm btn-circle btn-ghost"
12- hx-get="/htmx/image-manager"
13- hx-target="#image-manager"
14- hx-swap="innerHTML">
15- <span class="iconf text-lg">✕</span>
16- </button>
17- </div>
18- <div class="divider"></div>
19-
20- <form
21- id="upload-form"
22- hx-encoding="multipart/form-data"
23- hx-post="/htmx/upload-image"
24- hx-target="#image-manager"
25- hx-swap="innerHTML"
26- class="space-y-4">
27-
28- <div class="alert alert-info shadow-lg mb-4">
29- <span class="iconf">ℹ️</span>
30- <div>
31- <p>This form allows you to upload a container image (.tar file) to Gordon.</p>
32- <p>After uploading, you can create and manage containers from this image.</p>
33- </div>
34- </div>
35-
36- <div class="form-control w-full">
37- <label class="label">
38- <span class="label-text">Select a container image file (.tar)</span>
39- </label>
40- <input
41- type="file"
42- name="imageFile"
43- class="file-input file-input-bordered w-full"
44- accept=".tar"
45- required />
46- </div>
47-
48- <div class="card-actions justify-end mt-4">
49- <button
50- class="btn btn-ghost"
51- hx-get="/htmx/image-manager"
52- hx-target="#image-manager"
53- hx-swap="innerHTML"
54- type="button">
55- Cancel
56- </button>
57- <button
58- class="btn btn-primary"
59- type="submit">
60- <span class="iconf mr-1">󰦒</span> Upload
61- </button>
62- </div>
63- </form>
64- </div>
65- </div>
66-} ⏎̸
D internal/templating/models/templ/components/uploadimage_templ.go +0 -41 generated file: collapsed
D internal/templating/models/templ/layouts/admin_layout.templ +0 -21
@@ -1,21 +0,0 @@
1-package layouts
2-
3-import "github.com/bnema/gordon/internal/templating/models/templ/components"
4-
5-// AdminLayout provides the base layout for admin pages
6-templ AdminLayout(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
7- <!DOCTYPE html>
8- <html lang="en" data-theme="gordon-dark">
9- @components.Header(title)
10- <body class="min-h-screen bg-base-250">
11- @components.Navbar(buildVersion, adminPath, userSettingsURL, logoutURL)
12-
13- <!-- Main content starts after navbar height -->
14- <div class="container mx-auto pt-20 px-4">
15- { children... }
16- </div>
17-
18- @components.Footer()
19- </body>
20- </html>
21-}⏎̸
D internal/templating/models/templ/layouts/admin_layout_templ.go +0 -75 generated file: collapsed
D internal/templating/models/templ/layouts/base.templ +0 -16
@@ -1,16 +0,0 @@
1-package layouts
2-
3-import "github.com/bnema/gordon/internal/templating/models/templ/components"
4-
5-// Base layout template that provides the HTML structure
6-// All pages will use this layout template
7-templ Base(title string) {
8- <!DOCTYPE html>
9- <html lang="en">
10- @components.Header(title)
11- <body class="min-h-screen bg-base-200">
12- { children... }
13- @components.Footer()
14- </body>
15- </html>
16-}⏎̸
D internal/templating/models/templ/layouts/base_templ.go +0 -64 generated file: collapsed
D internal/templating/models/templ/pages/admin/createcontainerfull.templ +0 -30
@@ -1,30 +0,0 @@
1-package admin
2-
3-import (
4- "github.com/bnema/gordon/internal/templating/models/templ/layouts"
5- "github.com/bnema/gordon/internal/templating/models/templ/components"
6-)
7-
8-// CreateContainerFullData represents the data needed for the create container full page
9-type CreateContainerFullData struct {
10- Title string
11- BuildVersion string
12- AdminPath string
13- UserSettings string
14- LogoutURL string
15- ContainerData components.ContainerFormData
16-}
17-
18-// CreateContainerFullPage replaces createcontainerfull.gohtml
19-// It renders the full page for creating a container
20-templ CreateContainerFullPage(data CreateContainerFullData) {
21- @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
22- <div class="flex flex-col md:flex-row w-full flex-grow">
23- <div class="card flex-grow">
24- <h1 class="card-header mb-4">Create Container</h1>
25- <!-- Include the container creation form component -->
26- @components.CreateContainerForm(data.ContainerData)
27- </div>
28- </div>
29- }
30-} ⏎̸
D internal/templating/models/templ/pages/admin/createcontainerfull_templ.go +0 -83 generated file: collapsed
D internal/templating/models/templ/pages/admin/dashboard.templ +0 -18
@@ -1,18 +0,0 @@
1-package admin
2-
3-import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4-
5-// DashboardPage implements the admin dashboard
6-templ DashboardPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) {
7- @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) {
8- <div id="dashboard">
9- <h1
10- class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s"
11- >
12- Hello World from dashboard.html
13- </h1>
14- <!-- We load Chart.js here to avoid loading it in every page end when all the rest is loaded -->
15- <script src="libs/chart.js"></script>
16- </div>
17- }
18-}⏎̸
D internal/templating/models/templ/pages/admin/dashboard_templ.go +0 -61 generated file: collapsed
D internal/templating/models/templ/pages/admin/index.templ +0 -36
@@ -1,36 +0,0 @@
1-package admin
2-
3-import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4-
5-// IndexPageData represents data needed for the index page
6-type IndexPageData struct {
7- Title string
8- BuildVersion string
9- AdminPath string
10- UserSettings string
11- LogoutURL string
12- LangContent map[string]string // For storing language translations
13-}
14-
15-// IndexPage replaces admin/index.gohtml
16-// It renders the admin dashboard index page
17-templ IndexPage(data IndexPageData) {
18- @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
19- <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow">
20- <div class="card flex-grow">
21- <h2 class="card-header">{ data.LangContent["title"] }</h2>
22- </div>
23- <div class="w-full md:w-2/6 flex flex-col space-y-4">
24- <div class="card">
25- <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
26- </div>
27- <div class="card">
28- <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
29- </div>
30- <div class="card">
31- <div class="w-full h-44 rounded-sm bg-indigo-100"></div>
32- </div>
33- </div>
34- </div>
35- }
36-} ⏎̸
D internal/templating/models/templ/pages/admin/index_templ.go +0 -85 generated file: collapsed
D internal/templating/models/templ/pages/admin/install.templ +0 -59
@@ -1,59 +0,0 @@
1-package admin
2-
3-import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4-
5-// InstallPage replaces install.gohtml
6-// It renders the installation page
7-templ InstallPage(title string, version string) {
8- @layouts.Base(title) {
9- <!-- Logo and Title -->
10- <div
11- id="logo-mascot"
12- class="flex flex-col justify-center items-center p-4 mb-4"
13- >
14- <div class="circle-shadow flex justify-center w-100">
15- <img
16- src="/assets/imgs/gordon-mascot-mq-trsp.png"
17- alt="logo"
18- class="sm:h-24 h-32 xl:h-48"
19- />
20- </div>
21- <div class="flex justify-center mt-4">
22- <h1
23- class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter"
24- >
25- Gordon
26- </h1>
27- <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2">
28- v{ version }
29- </span>
30- </div>
31- </div>
32-
33- <!-- Main Content -->
34- <div class="card w-2/3">
35- <!-- Built-in Proxy -->
36- <div id="built-in-proxy" class="mt-8">
37- <div class="flex items-center mb-4">
38- <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
39- <span class="text-white font-bold">✓</span>
40- </div>
41- <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2>
42- </div>
43-
44- <div class="bg-indigo-50 p-4 rounded-md">
45- <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p>
46- <p>The proxy automatically:</p>
47- <ul class="list-disc ml-6 mb-4">
48- <li>Manages Let's Encrypt certificates</li>
49- <li>Routes domains to the appropriate containers</li>
50- <li>Handles HTTP to HTTPS redirects</li>
51- <li>Provides proper headers for forwarded requests</li>
52- </ul>
53- <p>No additional configuration is required.</p>
54- </div>
55- </div>
56- <!-- End Built-in Proxy -->
57- </div>
58- }
59-} ⏎̸
D internal/templating/models/templ/pages/admin/install_templ.go +0 -75 generated file: collapsed
D internal/templating/models/templ/pages/admin/manager.templ +0 -73
@@ -1,73 +0,0 @@
1-package admin
2-
3-import (
4- "github.com/bnema/gordon/internal/templating/models/templ/layouts"
5- "strconv"
6-)
7-
8-// ManagerPage implements the container and image manager page
9-templ ManagerPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string, containerCount int, imageCount int) {
10- @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) {
11- <!-- Main content -->
12- <div class="text-sm breadcrumbs mb-4">
13- <ul>
14- <li><a href={ templ.URL(adminPath) }>Gordon</a></li>
15- <li>Manager</li>
16- </ul>
17- </div>
18-
19- <!-- Stats overview -->
20- <div class="stats shadow mb-6 w-full bg-base-100">
21- <div class="stat">
22- <div class="stat-figure text-primary">
23- <span class="iconf text-3xl">󰡨</span>
24- </div>
25- <div class="stat-title">Containers</div>
26- <div class="stat-value" id="container-count">{ strconv.Itoa(containerCount) }</div>
27- <div class="stat-desc">Docker containers managed by Gordon</div>
28- </div>
29-
30- <div class="stat">
31- <div class="stat-figure text-secondary">
32- <span class="iconf text-3xl">󰋊</span>
33- </div>
34- <div class="stat-title">Images</div>
35- <div class="stat-value" id="image-count">{ strconv.Itoa(imageCount) }</div>
36- <div class="stat-desc">Docker images available</div>
37- </div>
38- </div>
39-
40- <!-- Tabs for containers and images -->
41- <div role="tablist" class="tabs tabs-border mb-4">
42- <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked />
43- <div role="tabpanel" class="tab-content p-0 bg-base-300">
44- <!-- Container manager section -->
45- <div id="container-manager"
46- hx-get="/htmx/container-manager"
47- hx-trigger="load"
48- hx-swap="innerHTML">
49- <!-- Container content loaded via HTMX -->
50- <div class="text-center p-4">
51- <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
52- Loading containers...
53- </div>
54- </div>
55- </div>
56-
57- <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" />
58- <div role="tabpanel" class="tab-content p-0 bg-base-300">
59- <!-- Image manager section -->
60- <div id="image-manager"
61- hx-get="/htmx/image-manager"
62- hx-trigger="load"
63- hx-swap="innerHTML">
64- <!-- Image content loaded via HTMX -->
65- <div class="text-center p-4">
66- <span class="iconf text-3xl animate-spin inline-block">󰑐</span>
67- Loading images...
68- </div>
69- </div>
70- </div>
71- </div>
72- }
73-}⏎̸
D internal/templating/models/templ/pages/admin/manager_templ.go +0 -99 generated file: collapsed
D internal/templating/models/templ/pages/admin/user.templ +0 -58
@@ -1,58 +0,0 @@
1-package admin
2-
3-import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4-
5-// UserPageData represents data needed for the user page
6-type UserPageData struct {
7- Title string
8- BuildVersion string
9- AdminPath string
10- UserSettings string
11- LogoutURL string
12- Username string
13- Email string
14- Role string
15-}
16-
17-// UserPage replaces admin/user.gohtml
18-// It renders the user profile page
19-templ UserPage(data UserPageData) {
20- @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) {
21- <div class="text-sm breadcrumbs mb-4">
22- <ul>
23- <li><a href={ templ.URL(data.AdminPath) }>Gordon</a></li>
24- <li>User Profile</li>
25- </ul>
26- </div>
27-
28- <div class="card bg-base-100 shadow-xl">
29- <div class="card-body">
30- <h2 class="card-title">User Profile</h2>
31- <div class="divider"></div>
32-
33- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
34- <div class="form-control">
35- <label class="label">
36- <span class="label-text">Username</span>
37- </label>
38- <input type="text" class="input input-bordered" value={ data.Username } readonly />
39- </div>
40-
41- <div class="form-control">
42- <label class="label">
43- <span class="label-text">Email</span>
44- </label>
45- <input type="email" class="input input-bordered" value={ data.Email } readonly />
46- </div>
47-
48- <div class="form-control">
49- <label class="label">
50- <span class="label-text">Role</span>
51- </label>
52- <input type="text" class="input input-bordered" value={ data.Role } readonly />
53- </div>
54- </div>
55- </div>
56- </div>
57- }
58-} ⏎̸
D internal/templating/models/templ/pages/admin/user_templ.go +0 -122 generated file: collapsed
D internal/templating/models/templ/pages/errors/errors.templ +0 -43
@@ -1,43 +0,0 @@
1-package errors
2-
3-import "github.com/bnema/gordon/internal/templating/models/templ/layouts"
4-
5-// ErrorPageData represents data needed for error pages
6-type ErrorPageData struct {
7- Title string
8- BuildVersion string
9- AdminPath string
10- StatusCode int
11- Message string
12-}
13-
14-// NotFoundPage renders a 404 error page
15-templ NotFoundPage(data ErrorPageData) {
16- @layouts.Base(data.Title) {
17- <div class="flex flex-col items-center justify-center min-h-screen p-4">
18- <div class="flex flex-col items-center mb-8">
19- <img src="/assets/imgs/gordon-mascot-mq-trsp.png" alt="Gordon Mascot" class="w-64 mb-4" />
20- <h1 class="text-4xl font-bold text-error">404</h1>
21- <h2 class="text-2xl mb-2">Page Not Found</h2>
22- <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p>
23- </div>
24- </div>
25- }
26-}
27-
28-// ForbiddenPage renders a 403 error page
29-templ ForbiddenPage(data ErrorPageData) {
30- @layouts.Base(data.Title) {
31- <div class="flex flex-col items-center justify-center min-h-screen p-4">
32- <div class="flex flex-col items-center mb-8">
33- <img src="/assets/imgs/gordon-mascot-mq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" />
34- <h1 class="text-4xl font-bold text-error">403</h1>
35- <h2 class="text-2xl mb-2">Access Forbidden</h2>
36- <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p>
37- <a href={ templ.URL(data.AdminPath) } class="btn btn-primary">
38- <span class="iconf mr-2">🏠</span> Go Home
39- </a>
40- </div>
41- </div>
42- }
43-} ⏎̸
D internal/templating/models/templ/pages/errors/errors_templ.go +0 -153 generated file: collapsed
D internal/templating/models/templ/pages/login/index.templ +0 -115
@@ -1,115 +0,0 @@
1-package login
2-
3-// LoginPage implements the login page with token and GitHub authentication
4-templ LoginPage(title string, buildVersion string, errorMsg string) {
5- <!doctype html>
6- <html lang="en" data-theme="gordon-dark">
7- <head>
8- <meta charset="UTF-8" />
9- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10- <title>{ title }</title>
11- <meta name="description" content="Gordon Container Management System" />
12- <meta name="keywords" content="container, management, docker, podman" />
13- <!-- Custom CSS + Tailwind -->
14- <link rel="stylesheet" href="/assets/css/tailwind.css" />
15- <!-- HTMX min js -->
16- <script src="/libs/htmx.min.js"></script>
17- <!-- HTMX SSE -->
18- <script src="/libs/htmx-sse.js"></script>
19- <!-- DaisyUI -->
20- <script src="/libs/daisyui.js" type="module"></script>
21- <!-- jQuery -->
22- <script src="/libs/jquery.min.js"></script>
23- <!-- Custom JS from assets/js -->
24- <script src="/assets/js/custom.js" defer></script>
25-
26- <!-- daisyUI theme configuration -->
27- <script>
28- // Check for saved theme preference or use preferred color scheme
29- const savedTheme = localStorage.getItem('theme') || 'gordon-dark';
30- document.documentElement.setAttribute('data-theme', savedTheme);
31- console.debug('Theme loaded:', savedTheme);
32- </script>
33- </head>
34-
35- <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4">
36- <!-- Logo and Title -->
37- <div
38- id="logo-mascot"
39- class="flex flex-col justify-center items-center p-4 mb-4">
40- <div class="flex justify-center w-100">
41- <img
42- src="/assets/imgs/gordon-mascot-mq-trsp.png"
43- alt="logo gordon"
44- class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" />
45- </div>
46- <div class="flex justify-center mt-4">
47- <h1
48- class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter">
49- Gordon
50- </h1>
51- <span
52- class="gordon-v-subtitle text-secondary text-sm ml-2"
53- >{ buildVersion }</span>
54- </div>
55- </div>
56-
57- <!-- Main Content -->
58- <div class="card bg-base-100 shadow-xl w-full max-w-md">
59- <div class="card-body">
60- <!-- Login Section -->
61- <div id="login-section">
62- <div class="flex flex-col items-center">
63- <h2 class="card-title text-center mb-4">Login</h2>
64-
65- <!-- Error Message Display -->
66- if errorMsg != "" {
67- <div class="alert alert-error mb-4">
68- <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
69- <span>
70- // --- MODIFIED: Handle new error messages ---
71- switch errorMsg {
72- case "server_error":
73- A server error occurred during login. Please try again later or contact support.
74- case "github_error": // Kept for potential future use
75- An error occurred during GitHub authentication. Please try again.
76- case "unauthorized":
77- This GitHub account is not authorized to access Gordon.
78- case "email_mismatch":
79- Login failed: Your GitHub email does not match the configured administrator email.
80- case "no_email":
81- Login failed: Your GitHub account must have a public primary email address configured.
82- case "config_email_missing":
83- Server Configuration Error: The administrator email is not set up. Please contact support.
84- case "session_invalid_or_expired": // Added from login page handler
85- Your session is invalid or has expired. Please log in again.
86- default:
87- An unknown login error occurred: { errorMsg }
88- }
89- // --- END MODIFICATION ---
90- </span>
91- </div>
92- }
93-
94- <!-- Token Input Form Removed -->
95-
96- <!-- GitHub OAuth -->
97- <div class="w-full">
98- <a
99- href="/admin/login/oauth/github"
100- class="btn btn-secondary w-full"
101- >
102- <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
103- <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" />
104- </svg>
105- Sign in with GitHub
106- </a>
107- </div>
108- </div>
109- </div>
110- <!-- End Login Section -->
111- </div>
112- </div>
113- </body>
114- </html>
115-}⏎̸
D internal/templating/models/templ/pages/login/index.templ (lines 84-87) +0 -5
@@ -1,5 +0,0 @@
1- <div class="w-full">
2- <a
3- href="login/oauth/github"
4- class="btn btn-secondary w-full"
5- > ⏎̸
D internal/templating/models/templ/pages/login/index_templ.go +0 -132 generated file: collapsed
D internal/templating/models/txt/locstrings.yml +0 -149
@@ -1,149 +0,0 @@
1-en:
2- common:
3- meta:
4- description: Admin interface for Gordon
5- keywords: gordon, admin, interface
6- header:
7- someKey: Hello World!
8- update_available: Update available
9- navmenu:
10- dashboard: Dashboard
11- manager: Manager
12- settings: Settings
13- logout: Logout
14- content:
15- update_available: "Update available"
16- upload: "Upload"
17- login:
18- meta:
19- title: Login to Gordon
20- content:
21- title: Login
22- github_login: Login with GitHub
23- dashboard:
24- meta:
25- title: Gordon - Admin Dashboard
26- content:
27- title: Dashboard
28- manager:
29- meta:
30- title: Gordon - Container Engine Manager
31- content:
32- title: Container Engine Manager
33- h1-containers: Containers
34- h1-images: Images
35- button-upload: Upload
36- confirm-delete: Are you sure you want to delete this?
37- confirm-container-stop: Are you sure you want to stop this container?
38- no-containers: No available containers.
39- thName: Name
40- thPorts: Exposed Ports
41- thStatus: Status
42- thState: State
43- thActions: Actions
44- thEntryPoint: "Entry Point"
45- images: Images
46- manually_upload: Manually upload a .tar container image
47- image_name: Image name
48- image_id: Image ID
49- container_name: Container name
50- protocol: Protocol
51- subdomain: Subdomain
52- domain: Domain
53- exposed_ports: Exposed Ports
54- exposed_ports_hint: "eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)"
55- proxy_port: Proxy Port
56- proxy_port_hint: Specify the internal container port that the proxy will route traffic to
57- volumes: Volumes
58- volumes_hint: "e.g. ./data:/container/data"
59- environment_variables: Environment Variables
60- environment_variables_hint: One per line, in the format KEY=VALUE
61- deploy: Deploy
62- edit_container: Edit Container
63- save: Save
64- settings:
65- meta:
66- title: Gordon - Settings
67- content:
68- title: Settings
69- error:
70- meta:
71- title: Error
72- content:
73- not_found: Page not found
74- internal_server: Something went really wrong o/
75-
76-fr:
77- common:
78- meta:
79- description: Interface d'administration pour Gordon
80- keywords: gordon, admin, interface
81- header:
82- someKey: Bonjour le monde !
83- update_available: Mise à jour disponible
84- navmenu:
85- dashboard: Tableau de bord
86- manager: Gestionnaire
87- settings: Paramètres
88- logout: Déconnexion
89- content:
90- update_available: "Mise à jour disponible"
91- upload: "Télécharger"
92- login:
93- meta:
94- title: Connexion à Gordon
95- content:
96- title: Connexion
97- github_login: Se connecter avec GitHub
98- dashboard:
99- meta:
100- title: Gordon - Tableau de bord
101- content:
102- title: Tableau de bord
103- manager:
104- meta:
105- title: Gordon - Gestionnaire de moteur de conteneur
106- content:
107- title: Gestionnaire de moteur de conteneur
108- h1-containers: Conteneurs
109- h1-images: Images
110- button-upload: Télécharger
111- confirm-delete: Êtes-vous sûr de vouloir supprimer ceci?
112- confirm-container-stop: Êtes-vous sûr de vouloir arrêter ce conteneur?
113- no-containers: Aucun conteneur disponible.
114- thName: Nom
115- thPorts: Ports exposés
116- thStatus: Statut
117- thState: État
118- thActions: Actions
119- thEntryPoint: "Point d'entrée"
120- images: Images
121- manually_upload: Télécharger manuellement une image de conteneur .tar
122- image_name: Nom de l'image
123- image_id: ID de l'image
124- container_name: Nom du conteneur
125- protocol: Protocole
126- subdomain: Sous-domaine
127- domain: Domaine
128- exposed_ports: Ports exposés
129- exposed_ports_hint: "ex: 8080:1887/tcp (Vous pouvez spécifier plusieurs ports en les séparant par une virgule)"
130- proxy_port: Proxy Port
131- proxy_port_hint: Spécifiez le port interne du conteneur que le proxy va router le trafic vers
132- volumes: Volumes
133- volumes_hint: "ex. ./data:/container/data"
134- environment_variables: Variables d'environnement
135- environment_variables_hint: Une par ligne, au format CLÉ=VALEUR
136- deploy: Déployer
137- edit_container: Modifier le conteneur
138- save: Enregistrer
139- settings:
140- meta:
141- title: Gordon - Paramètres
142- content:
143- title: Paramètres
144- error:
145- meta:
146- title: Erreur
147- content:
148- not_found: Page non trouvée
149- internal_server: Quelque chose s'est vraiment mal passé o/⏎̸
D internal/templating/render/html.go +0 -77
@@ -1,77 +0,0 @@
1-package render
2-
3-import (
4- "bytes"
5- "fmt"
6- "html/template"
7- "io/fs"
8- "path"
9-
10- "github.com/bnema/gordon/internal/server"
11-)
12-
13-type Renderer struct {
14- Template *template.Template
15- ParseError error
16- BuildVersion string
17- LatestVersion string
18-}
19-
20-// Render function renders the template with the given data
21-func (r *Renderer) Render(data interface{}, a *server.App) (string, error) {
22- if r.ParseError != nil {
23- return "", fmt.Errorf("failed to parse template: %w", r.ParseError)
24-
25- }
26- if r.Template == nil {
27- return "", fmt.Errorf("template is nil")
28- }
29- // Type assert data to map[string]interface{} to add BuildVersion
30- dataMap, ok := data.(map[string]interface{})
31- if !ok {
32- return "", fmt.Errorf("data is not a map[string]interface{}")
33- }
34-
35- // Automatically add BuildVersion to the data map
36- dataMap["BuildVersion"] = r.BuildVersion
37- buf := new(bytes.Buffer)
38- err := r.Template.Execute(buf, dataMap)
39- if err != nil {
40- return "", fmt.Errorf("failed to execute template: %w", err)
41- }
42-
43- return buf.String(), nil
44-}
45-
46-// GetHTMLRenderer function returns a new Renderer instance
47-func GetHTMLRenderer(mainPath string, filename string, fs fs.FS, a *server.App, fragmentsPath ...string) (*Renderer, error) {
48- // Full path to the main template
49- fullPath := path.Join(mainPath, filename)
50- // Check if the file exists in the provided fs.FS using fs.Open
51- file, err := fs.Open(fullPath)
52- if err != nil {
53- return nil, fmt.Errorf("failed to open %s: %w", filename, err)
54- }
55- file.Close()
56-
57- // Parse the main template
58- tmpl, err := template.New(filename).ParseFS(fs, fullPath)
59- if err != nil {
60- return nil, fmt.Errorf("failed to parse %s: %w", filename, err)
61- }
62-
63- // If a fragments path is provided, parse the fragment templates
64- if len(fragmentsPath) > 0 && fragmentsPath[0] != "" {
65- fragmentsGlob := path.Join(fragmentsPath[0], "*.gohtml")
66- _, err = tmpl.ParseFS(fs, fragmentsGlob)
67- if err != nil {
68- return nil, fmt.Errorf("failed to parse fragments: %w", err)
69- }
70- }
71-
72- // Return the Renderer instance
73- return &Renderer{
74- Template: tmpl,
75- BuildVersion: a.Config.Build.BuildVersion,
76- }, nil
77-}
D internal/templating/render/localization.go +0 -36
@@ -1,36 +0,0 @@
1-package render
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/gordon/internal/server"
7-)
8-
9-// GetLocalization function returns the localization map for the given language
10-func GetLocalization(lang string, a *server.App) (map[string]interface{}, error) {
11- // 1. Check if the language exists in the pre-parsed map
12- langData, langExists := a.Strings[lang]
13- if !langExists {
14- // Fallback to default language (e.g., "en") if primary is not found
15- defaultLang := "en"
16- langData, langExists = a.Strings[defaultLang]
17- if !langExists {
18- return nil, fmt.Errorf("default language '%s' not found in localization data", defaultLang)
19- }
20- // Use default language if primary was not found
21- lang = defaultLang
22- fmt.Printf("Warning: Language '%s' not found, falling back to '%s'\n", lang, defaultLang)
23- }
24-
25- // 2. Assert that the language data is a map
26- localizationMap, ok := langData.(map[string]interface{})
27- if !ok {
28- return nil, fmt.Errorf("localization data for language '%s' is not in the expected map format", lang)
29- }
30-
31- // 3. Return the localization map for the requested (or fallback) language
32- return localizationMap, nil
33-}
34-
35-// Helper function to check if a slice contains a string - No longer needed as we check map keys
36-// func contains(slice []string, str string) bool { ... }
D internal/templating/render/templ.go +0 -42
@@ -1,42 +0,0 @@
1-package render
2-
3-import (
4- "context"
5- "fmt"
6- "net/http"
7-
8- "github.com/a-h/templ"
9- "github.com/bnema/gordon/internal/server"
10- "github.com/labstack/echo/v4"
11-)
12-
13-// Renderer for templ templates
14-type TemplRenderer struct {
15- BuildVersion string
16- LatestVersion string
17-}
18-
19-// RenderTempl renders a templ component
20-func (r *TemplRenderer) RenderTempl(ctx echo.Context, component templ.Component) error {
21- // Set the content type and status code
22- ctx.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML)
23- ctx.Response().WriteHeader(http.StatusOK)
24-
25- // Create a render context
26- renderCtx := context.WithValue(ctx.Request().Context(), "BuildVersion", r.BuildVersion)
27-
28- // Render the component
29- err := component.Render(renderCtx, ctx.Response().Writer)
30- if err != nil {
31- return fmt.Errorf("failed to render templ component: %w", err)
32- }
33-
34- return nil
35-}
36-
37-// NewTemplRenderer creates a new templ renderer
38-func NewTemplRenderer(a *server.App) *TemplRenderer {
39- return &TemplRenderer{
40- BuildVersion: a.Config.Build.BuildVersion,
41- }
42-}⏎̸
D internal/templating/templ_embed.go +0 -13
@@ -1,13 +0,0 @@
1-package templating
2-
3-// This file contains templ components embeddings
4-// As we migrate from traditional Go templates to templ,
5-// we'll add the appropriate code here to handle templ templates.
6-
7-import (
8- // Import templ generated templates, which will make them available to the application
9- _ "github.com/bnema/gordon/internal/templating/models/templ/components"
10- _ "github.com/bnema/gordon/internal/templating/models/templ/layouts"
11- _ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin"
12- _ "github.com/bnema/gordon/internal/templating/models/templ/pages/login"
13-)⏎̸
D internal/webui/embed.go +0 -9
@@ -1,9 +0,0 @@
1-package webui
2-
3-import "embed"
4-
5-// PublicFS holds the embedded static file system for the web UI.
6-// The path "public" is relative to this package directory (internal/webui).
7-//
8-//go:embed public
9-var PublicFS embed.FS
D internal/webui/public/404.gohtml +0 -12
@@ -1,12 +0,0 @@
1-<!DOCTYPE html>
2-<html lang="en">
3- <head>
4- <meta charset="UTF-8" />
5- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6- <title>Error 404</title>
7- </head>
8- <body>
9- <h1>Error 404</h1>
10- <p>Page not found</p>
11- </body>
12-</html>
D internal/webui/public/500.gohtml +0 -12
@@ -1,12 +0,0 @@
1-<!DOCTYPE html>
2-<html lang="en">
3- <head>
4- <meta charset="UTF-8" />
5- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6- <title>Error 500</title>
7- </head>
8- <body>
9- <h1>500 Internal Server Error</h1>
10- <p>Something went really wrong o/</p>
11- </body>
12-</html>
D internal/webui/public/android-chrome-192x192.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/android-chrome-512x512.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/apple-touch-icon.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/css/custom.css +0 -155
@@ -1,155 +0,0 @@
1-@import "tailwindcss";
2-/* Custom DaisyUI theme - Defined in tailwind.config.js */
3-@plugin "daisyui/theme" {
4- name: "gordon-dark";
5- default: true;
6- prefersdark: true;
7- color-scheme: "dark";
8- --color-base-100: oklch(14% 0 0);
9- --color-base-200: oklch(21% 0.006 285.885);
10- --color-base-250: oklch(21% 0.006 285.885);
11- --color-base-300: oklch(26% 0 0);
12- --color-base-content: oklch(97% 0.001 106.424);
13- --color-primary: oklch(44% 0.043 257.281);
14- --color-primary-content: oklch(98% 0.003 247.858);
15- --color-secondary: oklch(66% 0.179 58.318);
16- --color-secondary-content: oklch(98% 0.022 95.277);
17- --color-accent: oklch(60% 0.118 184.704);
18- --color-accent-content: oklch(98% 0.014 180.72);
19- --color-neutral: oklch(42% 0.199 265.638);
20- --color-neutral-content: oklch(98% 0.001 106.423);
21- --color-info: oklch(68% 0.169 237.323);
22- --color-info-content: oklch(97% 0.013 236.62);
23- --color-success: oklch(69% 0.17 162.48);
24- --color-success-content: oklch(97% 0.021 166.113);
25- --color-warning: oklch(70% 0.213 47.604);
26- --color-warning-content: oklch(98% 0.016 73.684);
27- --color-error: oklch(64% 0.246 16.439);
28- --color-error-content: oklch(96% 0.015 12.422);
29- --radius-selector: 1rem;
30- --radius-field: 0.5rem;
31- --radius-box: 1rem;
32- --size-selector: 0.25rem;
33- --size-field: 0.21875rem;
34- --border: 1px;
35- --depth: 0;
36- --noise: 0;
37-}
38-
39-
40-/* Import IBM Plex Fonts */
41-@font-face {
42- font-family: "IBMPlexSans-Bold";
43- src: url("/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2") format("woff2");
44- font-weight: bold;
45- font-style: normal;
46-}
47-
48-@font-face {
49- font-family: "IBMPlexSans-Regular";
50- src: url("/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2") format("woff2");
51- font-weight: normal;
52- font-style: normal;
53-}
54-
55-/* Import Jetbrains Mono Nerd Font */
56-@font-face {
57- font-family: "JetBrainsMono-Nerd-Font-Regular";
58- src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2")
59- format("woff2");
60- font-weight: normal;
61- font-style: normal;
62-}
63-
64-/* Import Jetbrains Mono Nerd Font Bold */
65-@font-face {
66- font-family: "JetBrainsMono-Nerd-Font-Bold";
67- src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2")
68- format("woff2");
69- font-weight: bold;
70- font-style: normal;
71-}
72-
73-@font-face {
74- font-family: "LilitaOne-Regular";
75- src: url("/assets/fonts/LilitaOne/LilitaOne-Regular.ttf") format("truetype");
76- font-weight: normal;
77- font-style: normal;
78-}
79-
80-.fade-me-out.htmx-swapping {
81- opacity: 0;
82- transition: opacity 1s ease-out;
83-}
84-
85-/* View transition */
86-@keyframes fade-in {
87- from {
88- opacity: 0;
89- }
90-}
91-
92-@keyframes fade-out {
93- to {
94- opacity: 0;
95- }
96-}
97-
98-@keyframes slide-from-right {
99- from {
100- transform: translateX(90px);
101- }
102-}
103-
104-@keyframes slide-to-left {
105- to {
106- transform: translateX(-90px);
107- }
108-}
109-
110-@keyframes wiggle {
111-
112- 0%,
113- 100% {
114- transform: rotate(-3deg);
115- }
116-
117- 50% {
118- transform: rotate(3deg);
119- }
120-}
121-
122-#update-available {
123- animation: wiggle 1s ease-in-out infinite;
124-}
125-
126-.slide-it {
127- view-transition-name: slide-it;
128-}
129-
130-::view-transition-old(slide-it) {
131- animation:
132- 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
133- 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
134-}
135-::view-transition-new(slide-it) {
136- animation:
137- 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
138- 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
139-}
140-
141-.gordon-title {
142- font-family: "LilitaOne-Regular", sans-serif;
143-}
144-
145-.IBMPlexSans {
146- font-family: "IBMPlexSans-Regular", sans-serif;
147-}
148-
149-.JetBrainsMonoNF, .iconf{
150- font-family: "JetBrainsMono-Nerd-Font-Regular", sans-serif;
151-}
152-
153-.JetBrainsMonoBoldNF, .iconfb{
154- font-family: "JetBrainsMono-Nerd-Font-Bold", sans-serif;
155-}
D internal/webui/public/assets/css/tailwind.css +0 -1321 collapsed to keep the page light
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Light.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Medium.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Thin.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFontMono-Regular.ttf +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/LilitaOne/LilitaOne-Regular.ttf +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/fonts/LilitaOne/LilitaOne-Regular.woff2 +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/imgs/github/github-mark-white.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/imgs/github/github-mark-white.svg +0 -1
@@ -1,1 +0,0 @@
1-<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>⏎̸
D internal/webui/public/assets/imgs/github/github-mark.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/imgs/github/github-mark.svg +0 -1
@@ -1,1 +0,0 @@
1-<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>⏎̸
D internal/webui/public/assets/imgs/gordon-mascot-mq-trsp.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/assets/js/custom.js +0 -402
@@ -1,402 +0,0 @@
1-function initializeVersionCheck() {
2- // Dummy data for the remote version (as if fetched from a server)
3- const updateStrTemplate =
4- "New version %VERSION% available, consider pulling the latest image";
5-
6- const checkVersion = (versionData, currentVersion) => {
7- const fetchedVersionNumber =
8- versionData?.amd64?.name.match(/\d+\.\d+\.\d+/)?.[0];
9-
10- if (currentVersion !== fetchedVersionNumber) {
11- const updateStr = updateStrTemplate.replace(
12- "%VERSION%",
13- fetchedVersionNumber,
14- );
15- console.debug("Update available:", updateStr);
16- const updateElement = document.getElementById("update-available");
17- updateElement.title = updateStr;
18- updateElement.toggleAttribute("hidden", false);
19- updateElement.classList.remove("hidden"); // Remove the Tailwind `hidden` class
20- updateElement.classList.add("block"); // Add the Tailwind `block` class
21- } else {
22- console.debug(
23- "No new version. Current version is up-to-date:",
24- currentVersion,
25- );
26- }
27- };
28-
29- const fetchVersionInfo = (currentVersion) => {
30- fetch(`https://gordon-proxy.bamen.dev/version`)
31- .then((response) => response.json())
32- .then((versionData) => checkVersion(versionData, currentVersion))
33- .catch((error) => console.error("Error fetching version:", error));
34- };
35-
36- const init = () => {
37- const currentVersionElement = document.getElementById("actual-version");
38- if (!currentVersionElement) {
39- console.debug("Error: #actual-version element not found. Assuming not on admin page.");
40- return false; // Indicate element not found
41- }
42- const currentVersion = currentVersionElement.textContent.trim();
43- if (!currentVersion) {
44- console.debug("Dev mode detected, skipping version check.");
45- return true; // Element found, but dev mode
46- }
47- fetchVersionInfo(currentVersion);
48- return true; // Indicate element found
49- };
50-
51- return init(); // Return the result of init
52-}
53-
54-// Initialize jQuery functionality for the admin dashboard
55-function initializeAdminDashboard() {
56- console.debug('Initializing admin dashboard with jQuery');
57-
58- // Check if jQuery is properly loaded
59- if (typeof $ === 'undefined') {
60- console.error('jQuery is not defined! Make sure jQuery is loaded before this script.');
61- return;
62- }
63-
64- // Handle edit button clicks to toggle action response visibility
65- $(document).on('click', '[id^="edit-button-"]', function() {
66- const id = $(this).attr('id').replace('edit-button-', '');
67- const actionResponse = $(`#actionResponse-${id}`);
68-
69- console.debug(`Toggle visibility for actionResponse-${id}`);
70- actionResponse.toggleClass('visible hidden');
71-
72- // Update the active state
73- const isActive = $(this).data('active') === true;
74- $(this).data('active', !isActive);
75- });
76-
77- // Handle upload button click
78- $(document).on('click', '#upload-button', function() {
79- console.debug('Upload button clicked');
80- $('#upload-image').toggleClass('visible hidden');
81- $('.create-container').addClass('hidden');
82- });
83-
84- // Handle add button click
85- $(document).on('click', '[id^="add-button-img-"]', function() {
86- const id = $(this).attr('id').replace('add-button-img-', '');
87- console.debug(`Add button clicked for ${id}`);
88- $(`#create-container-${id}`).toggleClass('visible hidden');
89- $('#upload-image').addClass('hidden');
90-
91- // Add event listener for form submission
92- $(document).on('submit', `#create-container-form-${id}`, function(e) {
93- console.debug(`Form submission for container ${id}`);
94- // Don't prevent default as HTMX will handle the submission
95- });
96- });
97-
98- // Handle upload submit button
99- $(document).on('click', '#upload-submit-button', function() {
100- console.debug('Upload submit button clicked');
101- $(this).prop('disabled', true);
102-
103- // Re-enable the button after the HTMX request completes
104- document.body.addEventListener('htmx:afterOnLoad', function() {
105- $('#upload-submit-button').prop('disabled', false);
106- }, { once: true });
107- });
108-
109- // Handle success message without page reload
110- $(document).on('htmx:afterSwap', function(event) {
111- if ($(event.detail.target).find('#success').length > 0) {
112- console.debug('Success message detected');
113-
114- // Instead of reloading the page, refresh the container list
115- refreshContainerList();
116-
117- // Animate and remove the success message
118- setTimeout(function() {
119- $('#success').animate({ opacity: 0 }, 1000, function() {
120- $(this).remove();
121- });
122- }, 1000);
123- }
124- });
125-
126- // Handle refresh buttons
127- $(document).on('click', '#refresh-containers-button', function() {
128- console.debug('Refreshing container list manually');
129- refreshContainerList();
130- });
131-
132- $(document).on('click', '#refresh-images-button', function() {
133- console.debug('Refreshing image list manually');
134- refreshImageList();
135- });
136-
137- // Setup HTMX event listeners to avoid page reloads
138- setupHtmxEventListeners();
139-
140- // Initialize notification system
141- initializeNotifications();
142-
143- console.debug("Admin dashboard jQuery functionality initialized");
144-}
145-
146-// Function to initialize notification system
147-function initializeNotifications() {
148- // Create notification container if it doesn't exist
149- if ($('#notification-container').length === 0) {
150- $('body').append('<div id="notification-container" class="fixed top-4 right-4 z-50 flex flex-col space-y-2"></div>');
151- }
152-
153- console.debug("Notification system initialized");
154-}
155-
156-// Function to show a notification
157-function showNotification(message, type = 'success', duration = 3000) {
158- const notificationId = 'notification-' + Date.now();
159- const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500';
160-
161- const notification = $(`
162- <div id="${notificationId}" class="${bgColor} text-white p-3 rounded-md shadow-md opacity-0 transition-opacity duration-300">
163- ${message}
164- </div>
165- `);
166-
167- $('#notification-container').append(notification);
168-
169- // Fade in
170- setTimeout(() => {
171- $(`#${notificationId}`).css('opacity', '1');
172- }, 10);
173-
174- // Fade out and remove after duration
175- setTimeout(() => {
176- $(`#${notificationId}`).css('opacity', '0');
177- setTimeout(() => {
178- $(`#${notificationId}`).remove();
179- }, 300);
180- }, duration);
181-
182- console.debug(`Notification shown: ${message}`);
183-}
184-
185-// Function to refresh container list without page reload
186-function refreshContainerList() {
187- console.debug("Refreshing container list");
188-
189- // Show loading indicator
190- const containerManager = $('#container-manager');
191- if (containerManager.length === 0) {
192- console.error("Container manager element not found in DOM");
193- return;
194- }
195-
196- console.debug("Container manager element found:", containerManager[0]);
197- console.debug("Container manager classes:", containerManager.attr('class'));
198-
199- const originalContent = containerManager.html();
200- containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading containers...</div>');
201-
202- // Ensure the container is visible
203- containerManager.removeClass('hidden');
204-
205- $.ajax({
206- url: '/htmx/container-manager',
207- type: 'GET',
208- success: function(data) {
209- console.debug("Container data received, length:", data.length);
210- $('#container-manager').html(data);
211-
212- // Force visibility
213- $('#container-manager').removeClass('hidden');
214-
215- console.debug("Container list refreshed successfully");
216- },
217- error: function(xhr, status, error) {
218- console.error("Error refreshing container list:", error);
219- console.error("Status:", status);
220- console.error("Response:", xhr.responseText);
221- // Restore original content on error
222- containerManager.html(originalContent);
223- }
224- });
225-}
226-
227-// Function to refresh image list without page reload
228-function refreshImageList() {
229- console.debug("Refreshing image list");
230-
231- // Show loading indicator
232- const imageManager = $('#image-manager');
233- if (imageManager.length === 0) {
234- console.error("Image manager element not found in DOM");
235- return;
236- }
237-
238- console.debug("Image manager element found:", imageManager[0]);
239- console.debug("Image manager classes:", imageManager.attr('class'));
240-
241- const originalContent = imageManager.html();
242- imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block">󰑐</span> Loading images...</div>');
243-
244- $.ajax({
245- url: '/htmx/image-manager',
246- type: 'GET',
247- success: function(data) {
248- console.debug("Image data received, length:", data.length);
249- $('#image-manager').html(data);
250- console.debug("Image list refreshed successfully");
251- },
252- error: function(xhr, status, error) {
253- console.error("Error refreshing image list:", error);
254- console.error("Status:", status);
255- console.error("Response:", xhr.responseText);
256- // Restore original content on error
257- imageManager.html(originalContent);
258- }
259- });
260-}
261-
262-// Setup HTMX event listeners to avoid page reloads
263-function setupHtmxEventListeners() {
264- // Listen for container actions (start, stop, edit, delete)
265- document.body.addEventListener('htmx:afterOnLoad', function(event) {
266- const targetId = event.detail.target.id;
267- console.debug(`HTMX afterOnLoad event for target: ${targetId}`);
268-
269- // Remove the tab visibility fix which was interfering with DaisyUI
270- // DaisyUI handles tab visibility automatically with the input[type="radio"] approach
271-
272- // Check if the target is an action response
273- if (targetId && targetId.startsWith('actionResponse-')) {
274- console.debug(`Action completed for ${targetId}`);
275-
276- // Show notification instead of reloading
277- const responseText = $(event.detail.target).text().trim();
278- if (responseText.includes('Success')) {
279- showNotification('Action completed successfully', 'success');
280- } else if (responseText) {
281- showNotification(responseText, 'info');
282- }
283-
284- // Refresh container list after a short delay
285- setTimeout(refreshContainerList, 500);
286- }
287-
288- // Check if the target is the image manager (after upload)
289- if (targetId === 'image-manager') {
290- console.debug('Image manager updated');
291-
292- // Show notification for successful upload
293- if (event.detail.xhr && event.detail.xhr.responseText && event.detail.xhr.responseText.includes('Success')) {
294- showNotification('Image uploaded successfully', 'success');
295- }
296-
297- // Hide the upload form after successful upload
298- $('#upload-image').addClass('hidden');
299- }
300-
301- // Check if this is a container creation form response
302- if (targetId && targetId.includes('create-container-')) {
303- console.debug(`Container creation form response for ${targetId}`);
304- const responseText = $(event.detail.target).text().trim();
305-
306- // If the response contains success message
307- if (responseText.includes('Success') || responseText.includes('Container created successfully')) {
308- showNotification('Container created successfully', 'success');
309-
310- // Hide the container creation form
311- $(event.detail.target).closest('.create-container').addClass('hidden');
312-
313- // Refresh container list to show the new container
314- setTimeout(refreshContainerList, 500);
315- }
316- }
317- });
318-
319- // Listen for form submissions
320- $(document).on('submit', 'form[hx-post^="/htmx/create-container/"]', function(event) {
321- console.debug('Container creation form submitted');
322-
323- // Disable the submit button to prevent double submission
324- $('#create-container-submit').prop('disabled', true);
325-
326- // We don't prevent default because HTMX needs to handle the form submission
327- });
328-
329- // Listen for container creation
330- $(document).on('htmx:afterRequest', function(event) {
331- const path = event.detail.pathInfo.requestPath;
332- console.debug(`HTMX afterRequest for path: ${path}`);
333-
334- // If this was a container creation request
335- if (path && path.includes('/htmx/create-container/')) {
336- console.debug('Container creation request completed');
337-
338- // Re-enable the submit button
339- $('#create-container-submit').prop('disabled', false);
340-
341- // Check if the response indicates success
342- const responseText = event.detail.xhr.responseText;
343- if (responseText && (responseText.includes('Success') || responseText.includes('Container created successfully'))) {
344- showNotification('Container created successfully', 'success');
345-
346- // Refresh container list after creation
347- setTimeout(refreshContainerList, 500);
348- } else if (responseText && responseText.includes('Error')) {
349- showNotification('Error creating container: ' + responseText, 'error');
350- }
351- }
352-
353- // If this was an image deletion request
354- if (path && path.includes('/htmx/image-manager/delete/')) {
355- console.debug('Image deletion request completed');
356-
357- // Show notification
358- showNotification('Image deleted successfully', 'success');
359-
360- // Refresh image list after deletion
361- setTimeout(refreshImageList, 500);
362- }
363- });
364-
365- // Handle errors
366- $(document).on('htmx:responseError', function(event) {
367- console.error('HTMX response error:', event.detail.error);
368- showNotification('Error: ' + (event.detail.error || 'Unknown error occurred'), 'error');
369- });
370-
371- // Add HTMX event listeners for debugging
372- document.addEventListener('htmx:beforeRequest', function(event) {
373- console.debug('HTMX before request:', event.detail);
374- });
375-
376- document.addEventListener('htmx:afterRequest', function(event) {
377- console.debug('HTMX after request:', event.detail);
378- });
379-
380- document.addEventListener('htmx:responseError', function(event) {
381- console.error('HTMX response error:', event.detail);
382- });
383-}
384-
385-document.addEventListener("DOMContentLoaded", function() {
386- console.debug('DOM fully loaded');
387-
388- // Check if jQuery is available
389- if (typeof $ === 'undefined') {
390- console.error('jQuery is not loaded! Some features may not work properly.');
391- } else {
392- console.debug('jQuery is loaded successfully');
393- }
394-
395- // Run version check (which also checks if we're on an admin page)
396- const isAdminPage = initializeVersionCheck();
397-
398- // Only initialize admin dashboard functions if jQuery is loaded AND we are on an admin page
399- if (typeof $ !== 'undefined' && isAdminPage) {
400- initializeAdminDashboard();
401- }
402-});
D internal/webui/public/favicon-16x16.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/favicon-32x32.png +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/favicon.ico +0 -0 binary file: no text diff

binary file: no text diff

D internal/webui/public/libs/chart.js +0 -20 too large to show in the browser

too large to show in the browser

D internal/webui/public/libs/daisyui.js +0 -8 too large to show in the browser

too large to show in the browser

D internal/webui/public/libs/htmx-debug.js +0 -11
@@ -1,11 +0,0 @@
1-htmx.defineExtension('debug', {
2- onEvent: function (name, evt) {
3- if (console.debug) {
4- console.debug(name, evt);
5- } else if (console) {
6- console.log("DEBUG:", name, evt);
7- } else {
8- throw "NO CONSOLE SUPPORTED"
9- }
10- }
11-});
D internal/webui/public/libs/htmx-response-targets.js +0 -135
@@ -1,135 +0,0 @@
1-(function(){
2-
3- if (htmx.version && !htmx.version.startsWith("1.")) {
4- console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version +
5- ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions")
6- }
7-
8- /** @type {import("../htmx").HtmxInternalApi} */
9- var api;
10-
11- var attrPrefix = 'hx-target-';
12-
13- // IE11 doesn't support string.startsWith
14- function startsWith(str, prefix) {
15- return str.substring(0, prefix.length) === prefix
16- }
17-
18- /**
19- * @param {HTMLElement} elt
20- * @param {number} respCode
21- * @returns {HTMLElement | null}
22- */
23- function getRespCodeTarget(elt, respCodeNumber) {
24- if (!elt || !respCodeNumber) return null;
25-
26- var respCode = respCodeNumber.toString();
27-
28- // '*' is the original syntax, as the obvious character for a wildcard.
29- // The 'x' alternative was added for maximum compatibility with HTML
30- // templating engines, due to ambiguity around which characters are
31- // supported in HTML attributes.
32- //
33- // Start with the most specific possible attribute and generalize from
34- // there.
35- var attrPossibilities = [
36- respCode,
37-
38- respCode.substr(0, 2) + '*',
39- respCode.substr(0, 2) + 'x',
40-
41- respCode.substr(0, 1) + '*',
42- respCode.substr(0, 1) + 'x',
43- respCode.substr(0, 1) + '**',
44- respCode.substr(0, 1) + 'xx',
45-
46- '*',
47- 'x',
48- '***',
49- 'xxx',
50- ];
51- if (startsWith(respCode, '4') || startsWith(respCode, '5')) {
52- attrPossibilities.push('error');
53- }
54-
55- for (var i = 0; i < attrPossibilities.length; i++) {
56- var attr = attrPrefix + attrPossibilities[i];
57- var attrValue = api.getClosestAttributeValue(elt, attr);
58- if (attrValue) {
59- if (attrValue === "this") {
60- return api.findThisElement(elt, attr);
61- } else {
62- return api.querySelectorExt(elt, attrValue);
63- }
64- }
65- }
66-
67- return null;
68- }
69-
70- /** @param {Event} evt */
71- function handleErrorFlag(evt) {
72- if (evt.detail.isError) {
73- if (htmx.config.responseTargetUnsetsError) {
74- evt.detail.isError = false;
75- }
76- } else if (htmx.config.responseTargetSetsError) {
77- evt.detail.isError = true;
78- }
79- }
80-
81- htmx.defineExtension('response-targets', {
82-
83- /** @param {import("../htmx").HtmxInternalApi} apiRef */
84- init: function (apiRef) {
85- api = apiRef;
86-
87- if (htmx.config.responseTargetUnsetsError === undefined) {
88- htmx.config.responseTargetUnsetsError = true;
89- }
90- if (htmx.config.responseTargetSetsError === undefined) {
91- htmx.config.responseTargetSetsError = false;
92- }
93- if (htmx.config.responseTargetPrefersExisting === undefined) {
94- htmx.config.responseTargetPrefersExisting = false;
95- }
96- if (htmx.config.responseTargetPrefersRetargetHeader === undefined) {
97- htmx.config.responseTargetPrefersRetargetHeader = true;
98- }
99- },
100-
101- /**
102- * @param {string} name
103- * @param {Event} evt
104- */
105- onEvent: function (name, evt) {
106- if (name === "htmx:beforeSwap" &&
107- evt.detail.xhr &&
108- evt.detail.xhr.status !== 200) {
109- if (evt.detail.target) {
110- if (htmx.config.responseTargetPrefersExisting) {
111- evt.detail.shouldSwap = true;
112- handleErrorFlag(evt);
113- return true;
114- }
115- if (htmx.config.responseTargetPrefersRetargetHeader &&
116- evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) {
117- evt.detail.shouldSwap = true;
118- handleErrorFlag(evt);
119- return true;
120- }
121- }
122- if (!evt.detail.requestConfig) {
123- return true;
124- }
125- var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status);
126- if (target) {
127- handleErrorFlag(evt);
128- evt.detail.shouldSwap = true;
129- evt.detail.target = target;
130- }
131- return true;
132- }
133- }
134- });
135-})();
D internal/webui/public/libs/htmx-sse.js +0 -374
@@ -1,374 +0,0 @@
1-/*
2-Server Sent Events Extension
3-============================
4-This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
5-
6-*/
7-
8-(function() {
9-
10- if (htmx.version && !htmx.version.startsWith("1.")) {
11- console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version +
12- ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions")
13- }
14-
15- /** @type {import("../htmx").HtmxInternalApi} */
16- var api;
17-
18- htmx.defineExtension("sse", {
19-
20- /**
21- * Init saves the provided reference to the internal HTMX API.
22- *
23- * @param {import("../htmx").HtmxInternalApi} api
24- * @returns void
25- */
26- init: function(apiRef) {
27- // store a reference to the internal API.
28- api = apiRef;
29-
30- // set a function in the public API for creating new EventSource objects
31- if (htmx.createEventSource == undefined) {
32- htmx.createEventSource = createEventSource;
33- }
34- },
35-
36- /**
37- * onEvent handles all events passed to this extension.
38- *
39- * @param {string} name
40- * @param {Event} evt
41- * @returns void
42- */
43- onEvent: function(name, evt) {
44-
45- var parent = evt.target || evt.detail.elt;
46- switch (name) {
47-
48- case "htmx:beforeCleanupElement":
49- var internalData = api.getInternalData(parent)
50- // Try to remove remove an EventSource when elements are removed
51- if (internalData.sseEventSource) {
52- internalData.sseEventSource.close();
53- }
54-
55- return;
56-
57- // Try to create EventSources when elements are processed
58- case "htmx:afterProcessNode":
59- ensureEventSourceOnElement(parent);
60- }
61- }
62- });
63-
64- ///////////////////////////////////////////////
65- // HELPER FUNCTIONS
66- ///////////////////////////////////////////////
67-
68-
69- /**
70- * createEventSource is the default method for creating new EventSource objects.
71- * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
72- *
73- * @param {string} url
74- * @returns EventSource
75- */
76- function createEventSource(url) {
77- return new EventSource(url, { withCredentials: true });
78- }
79-
80- function splitOnWhitespace(trigger) {
81- return trigger.trim().split(/\s+/);
82- }
83-
84- function getLegacySSEURL(elt) {
85- var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
86- if (legacySSEValue) {
87- var values = splitOnWhitespace(legacySSEValue);
88- for (var i = 0; i < values.length; i++) {
89- var value = values[i].split(/:(.+)/);
90- if (value[0] === "connect") {
91- return value[1];
92- }
93- }
94- }
95- }
96-
97- function getLegacySSESwaps(elt) {
98- var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
99- var returnArr = [];
100- if (legacySSEValue != null) {
101- var values = splitOnWhitespace(legacySSEValue);
102- for (var i = 0; i < values.length; i++) {
103- var value = values[i].split(/:(.+)/);
104- if (value[0] === "swap") {
105- returnArr.push(value[1]);
106- }
107- }
108- }
109- return returnArr;
110- }
111-
112- /**
113- * registerSSE looks for attributes that can contain sse events, right
114- * now hx-trigger and sse-swap and adds listeners based on these attributes too
115- * the closest event source
116- *
117- * @param {HTMLElement} elt
118- */
119- function registerSSE(elt) {
120- // Add message handlers for every `sse-swap` attribute
121- queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function (child) {
122- // Find closest existing event source
123- var sourceElement = api.getClosestMatch(child, hasEventSource);
124- if (sourceElement == null) {
125- // api.triggerErrorEvent(elt, "htmx:noSSESourceError")
126- return null; // no eventsource in parentage, orphaned element
127- }
128-
129- // Set internalData and source
130- var internalData = api.getInternalData(sourceElement);
131- var source = internalData.sseEventSource;
132-
133- var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
134- if (sseSwapAttr) {
135- var sseEventNames = sseSwapAttr.split(",");
136- } else {
137- var sseEventNames = getLegacySSESwaps(child);
138- }
139-
140- for (var i = 0; i < sseEventNames.length; i++) {
141- var sseEventName = sseEventNames[i].trim();
142- var listener = function(event) {
143-
144- // If the source is missing then close SSE
145- if (maybeCloseSSESource(sourceElement)) {
146- return;
147- }
148-
149- // If the body no longer contains the element, remove the listener
150- if (!api.bodyContains(child)) {
151- source.removeEventListener(sseEventName, listener);
152- return;
153- }
154-
155- // swap the response into the DOM and trigger a notification
156- if(!api.triggerEvent(elt, "htmx:sseBeforeMessage", event)) {
157- return;
158- }
159- swap(child, event.data);
160- api.triggerEvent(elt, "htmx:sseMessage", event);
161- };
162-
163- // Register the new listener
164- api.getInternalData(child).sseEventListener = listener;
165- source.addEventListener(sseEventName, listener);
166- }
167- });
168-
169- // Add message handlers for every `hx-trigger="sse:*"` attribute
170- queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
171- // Find closest existing event source
172- var sourceElement = api.getClosestMatch(child, hasEventSource);
173- if (sourceElement == null) {
174- // api.triggerErrorEvent(elt, "htmx:noSSESourceError")
175- return null; // no eventsource in parentage, orphaned element
176- }
177-
178- // Set internalData and source
179- var internalData = api.getInternalData(sourceElement);
180- var source = internalData.sseEventSource;
181-
182- var sseEventName = api.getAttributeValue(child, "hx-trigger");
183- if (sseEventName == null) {
184- return;
185- }
186-
187- // Only process hx-triggers for events with the "sse:" prefix
188- if (sseEventName.slice(0, 4) != "sse:") {
189- return;
190- }
191-
192- // remove the sse: prefix from here on out
193- sseEventName = sseEventName.substr(4);
194-
195- var listener = function() {
196- if (maybeCloseSSESource(sourceElement)) {
197- return
198- }
199-
200- if (!api.bodyContains(child)) {
201- source.removeEventListener(sseEventName, listener);
202- }
203- }
204- });
205- }
206-
207- /**
208- * ensureEventSourceOnElement creates a new EventSource connection on the provided element.
209- * If a usable EventSource already exists, then it is returned. If not, then a new EventSource
210- * is created and stored in the element's internalData.
211- * @param {HTMLElement} elt
212- * @param {number} retryCount
213- * @returns {EventSource | null}
214- */
215- function ensureEventSourceOnElement(elt, retryCount) {
216-
217- if (elt == null) {
218- return null;
219- }
220-
221- // handle extension source creation attribute
222- queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
223- var sseURL = api.getAttributeValue(child, "sse-connect");
224- if (sseURL == null) {
225- return;
226- }
227-
228- ensureEventSource(child, sseURL, retryCount);
229- });
230-
231- // handle legacy sse, remove for HTMX2
232- queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
233- var sseURL = getLegacySSEURL(child);
234- if (sseURL == null) {
235- return;
236- }
237-
238- ensureEventSource(child, sseURL, retryCount);
239- });
240-
241- registerSSE(elt);
242- }
243-
244- function ensureEventSource(elt, url, retryCount) {
245- var source = htmx.createEventSource(url);
246-
247- source.onerror = function(err) {
248-
249- // Log an error event
250- api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
251-
252- // If parent no longer exists in the document, then clean up this EventSource
253- if (maybeCloseSSESource(elt)) {
254- return;
255- }
256-
257- // Otherwise, try to reconnect the EventSource
258- if (source.readyState === EventSource.CLOSED) {
259- retryCount = retryCount || 0;
260- var timeout = Math.random() * (2 ^ retryCount) * 500;
261- window.setTimeout(function() {
262- ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
263- }, timeout);
264- }
265- };
266-
267- source.onopen = function(evt) {
268- api.triggerEvent(elt, "htmx:sseOpen", { source: source });
269- }
270-
271- api.getInternalData(elt).sseEventSource = source;
272- }
273-
274- /**
275- * maybeCloseSSESource confirms that the parent element still exists.
276- * If not, then any associated SSE source is closed and the function returns true.
277- *
278- * @param {HTMLElement} elt
279- * @returns boolean
280- */
281- function maybeCloseSSESource(elt) {
282- if (!api.bodyContains(elt)) {
283- var source = api.getInternalData(elt).sseEventSource;
284- if (source != undefined) {
285- source.close();
286- // source = null
287- return true;
288- }
289- }
290- return false;
291- }
292-
293- /**
294- * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
295- *
296- * @param {HTMLElement} elt
297- * @param {string} attributeName
298- */
299- function queryAttributeOnThisOrChildren(elt, attributeName) {
300-
301- var result = [];
302-
303- // If the parent element also contains the requested attribute, then add it to the results too.
304- if (api.hasAttribute(elt, attributeName)) {
305- result.push(elt);
306- }
307-
308- // Search all child nodes that match the requested attribute
309- elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
310- result.push(node);
311- });
312-
313- return result;
314- }
315-
316- /**
317- * @param {HTMLElement} elt
318- * @param {string} content
319- */
320- function swap(elt, content) {
321-
322- api.withExtensions(elt, function(extension) {
323- content = extension.transformResponse(content, null, elt);
324- });
325-
326- var swapSpec = api.getSwapSpecification(elt);
327- var target = api.getTarget(elt);
328- var settleInfo = api.makeSettleInfo(elt);
329-
330- api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
331-
332- settleInfo.elts.forEach(function(elt) {
333- if (elt.classList) {
334- elt.classList.add(htmx.config.settlingClass);
335- }
336- api.triggerEvent(elt, 'htmx:beforeSettle');
337- });
338-
339- // Handle settle tasks (with delay if requested)
340- if (swapSpec.settleDelay > 0) {
341- setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
342- } else {
343- doSettle(settleInfo)();
344- }
345- }
346-
347- /**
348- * doSettle mirrors much of the functionality in htmx that
349- * settles elements after their content has been swapped.
350- * TODO: this should be published by htmx, and not duplicated here
351- * @param {import("../htmx").HtmxSettleInfo} settleInfo
352- * @returns () => void
353- */
354- function doSettle(settleInfo) {
355-
356- return function() {
357- settleInfo.tasks.forEach(function(task) {
358- task.call();
359- });
360-
361- settleInfo.elts.forEach(function(elt) {
362- if (elt.classList) {
363- elt.classList.remove(htmx.config.settlingClass);
364- }
365- api.triggerEvent(elt, 'htmx:afterSettle');
366- });
367- }
368- }
369-
370- function hasEventSource(node) {
371- return api.getInternalData(node).sseEventSource != null;
372- }
373-
374-})();
D internal/webui/public/libs/htmx.min.js +0 -1 generated file: collapsed
D internal/webui/public/libs/hyperscript.min.js +0 -1 generated file: collapsed
D internal/webui/public/libs/jquery.min.js +0 -2 generated file: collapsed
D internal/webui/public/site.webmanifest +0 -1
@@ -1,1 +0,0 @@
1-{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}⏎̸
M main.go +6 -8
@@ -1,15 +1,13 @@
11 package main
22
33 import (
4- "github.com/bnema/gordon/cmd"
5-)
4+ "os"
65
7-var (
8- version string
9- commit string
10- date string
6+ "gordon/cmd"
117 )
128
139 func main() {
14- cmd.ExecuteCLI(version, commit, date)
15-}
10+ if err := cmd.Execute(); err != nil {
11+ os.Exit(1)
12+ }
13+}⏎̸
D node_modules/.bin/cssesc +0 -1 generated file: collapsed
D node_modules/.bin/glob +0 -1 generated file: collapsed
D node_modules/.bin/jiti +0 -1 generated file: collapsed
D node_modules/.bin/nanoid +0 -1 generated file: collapsed
D node_modules/.bin/node-which +0 -1 generated file: collapsed
D node_modules/.bin/prettier +0 -1 generated file: collapsed
D node_modules/.bin/resolve +0 -1 generated file: collapsed
D node_modules/.bin/sucrase +0 -1 generated file: collapsed
D node_modules/.bin/sucrase-node +0 -1 generated file: collapsed
D node_modules/.bin/tailwind +0 -1 generated file: collapsed
D node_modules/.bin/yaml +0 -1 generated file: collapsed
D node_modules/.svelte2tsx-language-server-files/svelte-native-jsx.d.ts +0 -32 generated file: collapsed
D node_modules/.svelte2tsx-language-server-files/svelte-shims-v4.d.ts +0 -287 generated file: collapsed
D node_modules/@alloc/quick-lru/index.d.ts +0 -128 generated file: collapsed
D node_modules/@alloc/quick-lru/index.js +0 -263 generated file: collapsed
D node_modules/@alloc/quick-lru/license +0 -9 generated file: collapsed
D node_modules/@alloc/quick-lru/package.json +0 -43 generated file: collapsed
D node_modules/@alloc/quick-lru/readme.md +0 -139 generated file: collapsed
D node_modules/@isaacs/cliui/README.md +0 -143 generated file: collapsed
D node_modules/@isaacs/cliui/build/index.cjs +0 -317 generated file: collapsed
D node_modules/@isaacs/cliui/build/index.d.cts +0 -43 generated file: collapsed
D node_modules/@isaacs/cliui/build/lib/index.js +0 -302 generated file: collapsed
D node_modules/@isaacs/cliui/index.mjs +0 -14 generated file: collapsed
D node_modules/@isaacs/cliui/package.json +0 -86 generated file: collapsed
D node_modules/@jridgewell/gen-mapping/LICENSE +0 -19 generated file: collapsed
D node_modules/@jridgewell/gen-mapping/README.md +0 -227 generated file: collapsed
D node_modules/@jridgewell/gen-mapping/package.json +0 -76 generated file: collapsed
D node_modules/@jridgewell/resolve-uri/LICENSE +0 -19 generated file: collapsed
D node_modules/@jridgewell/resolve-uri/README.md +0 -40 generated file: collapsed
D node_modules/@jridgewell/resolve-uri/package.json +0 -69 generated file: collapsed
D node_modules/@jridgewell/set-array/LICENSE +0 -19 generated file: collapsed
D node_modules/@jridgewell/set-array/README.md +0 -37 generated file: collapsed
D node_modules/@jridgewell/set-array/package.json +0 -65 generated file: collapsed
D node_modules/@jridgewell/sourcemap-codec/LICENSE +0 -21 generated file: collapsed
D node_modules/@jridgewell/sourcemap-codec/README.md +0 -264 generated file: collapsed
D node_modules/@jridgewell/sourcemap-codec/package.json +0 -75 generated file: collapsed
D node_modules/@jridgewell/trace-mapping/LICENSE +0 -19 generated file: collapsed
D node_modules/@jridgewell/trace-mapping/README.md +0 -257 generated file: collapsed
D node_modules/@jridgewell/trace-mapping/package.json +0 -77 generated file: collapsed
D node_modules/@nodelib/fs.scandir/LICENSE +0 -21 generated file: collapsed
D node_modules/@nodelib/fs.scandir/README.md +0 -171 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/adapters/fs.d.ts +0 -20 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/adapters/fs.js +0 -19 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/constants.d.ts +0 -4 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/constants.js +0 -17 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/index.d.ts +0 -12 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/index.js +0 -26 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/async.d.ts +0 -7 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/async.js +0 -104 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/common.d.ts +0 -1 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/common.js +0 -13 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/sync.d.ts +0 -5 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/sync.js +0 -54 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/settings.d.ts +0 -20 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/settings.js +0 -24 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/types/index.d.ts +0 -20 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/types/index.js +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/utils/fs.d.ts +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/utils/fs.js +0 -19 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/utils/index.d.ts +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/utils/index.js +0 -5 generated file: collapsed
D node_modules/@nodelib/fs.scandir/package.json +0 -44 generated file: collapsed
D node_modules/@nodelib/fs.stat/LICENSE +0 -21 generated file: collapsed
D node_modules/@nodelib/fs.stat/README.md +0 -126 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/adapters/fs.d.ts +0 -13 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/adapters/fs.js +0 -17 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/index.d.ts +0 -12 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/index.js +0 -26 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/providers/async.d.ts +0 -4 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/providers/async.js +0 -36 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/providers/sync.d.ts +0 -3 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/providers/sync.js +0 -23 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/settings.d.ts +0 -16 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/settings.js +0 -16 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/types/index.d.ts +0 -4 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/types/index.js +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.stat/package.json +0 -37 generated file: collapsed
D node_modules/@nodelib/fs.walk/LICENSE +0 -21 generated file: collapsed
D node_modules/@nodelib/fs.walk/README.md +0 -215 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/index.d.ts +0 -14 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/index.js +0 -34 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/async.d.ts +0 -12 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/async.js +0 -30 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/index.d.ts +0 -4 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/index.js +0 -9 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/stream.d.ts +0 -12 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/stream.js +0 -34 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/sync.d.ts +0 -10 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/sync.js +0 -14 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/async.d.ts +0 -30 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/async.js +0 -97 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/common.d.ts +0 -7 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/common.js +0 -31 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/reader.d.ts +0 -6 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/reader.js +0 -11 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/sync.d.ts +0 -15 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/sync.js +0 -59 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/settings.d.ts +0 -30 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/settings.js +0 -26 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/types/index.d.ts +0 -8 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/types/index.js +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.walk/package.json +0 -44 generated file: collapsed
D node_modules/@parcel/watcher-linux-x64-glibc/LICENSE +0 -21 generated file: collapsed
D node_modules/@parcel/watcher-linux-x64-glibc/README.md +0 -1 generated file: collapsed
D node_modules/@parcel/watcher-linux-x64-glibc/package.json +0 -33 generated file: collapsed
D node_modules/@parcel/watcher-linux-x64-glibc/watcher.node +0 -0 binary file: no text diff

binary file: no text diff

D node_modules/@parcel/watcher-linux-x64-musl/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@parcel/watcher-linux-x64-musl/README.md +0 -1 collapsed to keep the page light
D node_modules/@parcel/watcher-linux-x64-musl/package.json +0 -33 collapsed to keep the page light
D node_modules/@parcel/watcher-linux-x64-musl/watcher.node +0 -0 binary file: no text diff

binary file: no text diff

D node_modules/@parcel/watcher/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@parcel/watcher/README.md +0 -135 collapsed to keep the page light
D node_modules/@parcel/watcher/binding.gyp +0 -93 collapsed to keep the page light
D node_modules/@parcel/watcher/index.d.ts +0 -49 collapsed to keep the page light
D node_modules/@parcel/watcher/index.js +0 -41 collapsed to keep the page light
D node_modules/@parcel/watcher/index.js.flow +0 -48 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/.bin/detect-libc +0 -1 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/.npmignore +0 -7 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/LICENSE +0 -201 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/README.md +0 -78 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/bin/detect-libc.js +0 -18 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/lib/detect-libc.js +0 -92 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/package.json +0 -35 collapsed to keep the page light
D node_modules/@parcel/watcher/package.json +0 -88 collapsed to keep the page light
D node_modules/@parcel/watcher/scripts/build-from-source.js +0 -13 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Backend.cc +0 -182 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Backend.hh +0 -37 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Debounce.cc +0 -113 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Debounce.hh +0 -49 collapsed to keep the page light
D node_modules/@parcel/watcher/src/DirTree.cc +0 -152 collapsed to keep the page light
D node_modules/@parcel/watcher/src/DirTree.hh +0 -50 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Event.hh +0 -109 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Glob.cc +0 -22 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Glob.hh +0 -34 collapsed to keep the page light
D node_modules/@parcel/watcher/src/PromiseRunner.hh +0 -101 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Signal.hh +0 -46 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Watcher.cc +0 -237 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Watcher.hh +0 -73 collapsed to keep the page light
D node_modules/@parcel/watcher/src/binding.cc +0 -268 collapsed to keep the page light
D node_modules/@parcel/watcher/src/kqueue/KqueueBackend.cc +0 -306 collapsed to keep the page light
D node_modules/@parcel/watcher/src/kqueue/KqueueBackend.hh +0 -35 collapsed to keep the page light
D node_modules/@parcel/watcher/src/linux/InotifyBackend.cc +0 -232 collapsed to keep the page light
D node_modules/@parcel/watcher/src/linux/InotifyBackend.hh +0 -34 collapsed to keep the page light
D node_modules/@parcel/watcher/src/macos/FSEventsBackend.cc +0 -338 collapsed to keep the page light
D node_modules/@parcel/watcher/src/macos/FSEventsBackend.hh +0 -20 collapsed to keep the page light
D node_modules/@parcel/watcher/src/shared/BruteForceBackend.cc +0 -41 collapsed to keep the page light
D node_modules/@parcel/watcher/src/shared/BruteForceBackend.hh +0 -25 collapsed to keep the page light
D node_modules/@parcel/watcher/src/unix/fts.cc +0 -50 collapsed to keep the page light
D node_modules/@parcel/watcher/src/unix/legacy.cc +0 -77 collapsed to keep the page light
D node_modules/@parcel/watcher/src/wasm/WasmBackend.cc +0 -132 collapsed to keep the page light
D node_modules/@parcel/watcher/src/wasm/WasmBackend.hh +0 -34 collapsed to keep the page light
D node_modules/@parcel/watcher/src/wasm/include.h +0 -74 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/BSER.cc +0 -302 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/BSER.hh +0 -69 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/IPC.hh +0 -175 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/WatchmanBackend.cc +0 -338 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/WatchmanBackend.hh +0 -35 collapsed to keep the page light
D node_modules/@parcel/watcher/src/windows/WindowsBackend.cc +0 -282 collapsed to keep the page light
D node_modules/@parcel/watcher/src/windows/WindowsBackend.hh +0 -18 collapsed to keep the page light
D node_modules/@parcel/watcher/src/windows/win_utils.cc +0 -44 collapsed to keep the page light
D node_modules/@parcel/watcher/src/windows/win_utils.hh +0 -11 collapsed to keep the page light
D node_modules/@parcel/watcher/wrapper.js +0 -77 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/.editorconfig +0 -14 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/CHANGELOG.md +0 -147 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/LICENSE +0 -201 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/README.md +0 -413 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/is-default-value.js +0 -25 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/limit-long-syntax.js +0 -35 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/negate.js +0 -43 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/no-repeated-options.js +0 -31 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/ordered-options.mjs +0 -41 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/simple-hard-coded.js +0 -26 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/index.js +0 -396 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/internal/errors.js +0 -47 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/internal/primordials.js +0 -393 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/internal/util.js +0 -14 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/internal/validators.js +0 -89 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/package.json +0 -36 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/utils.js +0 -198 collapsed to keep the page light
D node_modules/@tailwindcss/cli/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/cli/README.md +0 -40 collapsed to keep the page light
D node_modules/@tailwindcss/cli/package.json +0 -41 collapsed to keep the page light
D node_modules/@tailwindcss/node/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/node/README.md +0 -40 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/.bin/jiti +0 -1 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/README.md +0 -235 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-cli.mjs +0 -34 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-hooks.mjs +0 -117 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-native.mjs +0 -121 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-register.d.mts +0 -1 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-register.mjs +0 -4 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti.cjs +0 -23 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti.d.cts +0 -8 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti.d.mts +0 -8 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti.mjs +0 -22 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/types.d.ts +0 -260 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/package.json +0 -134 collapsed to keep the page light
D node_modules/@tailwindcss/node/package.json +0 -44 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-gnu/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-gnu/README.md +0 -3 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-gnu/package.json +0 -30 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-gnu/tailwindcss-oxide.linux-x64-gnu.node +0 -0 binary file: no text diff

binary file: no text diff

D node_modules/@tailwindcss/oxide-linux-x64-musl/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-musl/README.md +0 -3 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-musl/package.json +0 -30 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-musl/tailwindcss-oxide.linux-x64-musl.node +0 -0 binary file: no text diff

binary file: no text diff

D node_modules/@tailwindcss/oxide/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/oxide/index.d.ts +0 -37 collapsed to keep the page light
D node_modules/@tailwindcss/oxide/index.js +0 -315 collapsed to keep the page light
D node_modules/@tailwindcss/oxide/package.json +0 -63 collapsed to keep the page light
D node_modules/@tailwindcss/postcss/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/postcss/README.md +0 -40 collapsed to keep the page light
D node_modules/@tailwindcss/postcss/package.json +0 -47 collapsed to keep the page light
D node_modules/ansi-regex/index.d.ts +0 -33 collapsed to keep the page light
D node_modules/ansi-regex/index.js +0 -10 collapsed to keep the page light
D node_modules/ansi-regex/license +0 -9 collapsed to keep the page light
D node_modules/ansi-regex/package.json +0 -61 collapsed to keep the page light
D node_modules/ansi-regex/readme.md +0 -60 collapsed to keep the page light
D node_modules/ansi-styles/index.d.ts +0 -236 collapsed to keep the page light
D node_modules/ansi-styles/index.js +0 -223 collapsed to keep the page light
D node_modules/ansi-styles/license +0 -9 collapsed to keep the page light
D node_modules/ansi-styles/package.json +0 -54 collapsed to keep the page light
D node_modules/ansi-styles/readme.md +0 -173 collapsed to keep the page light
D node_modules/any-promise/.jshintrc +0 -4 collapsed to keep the page light
D node_modules/any-promise/.npmignore +0 -7 collapsed to keep the page light
D node_modules/any-promise/LICENSE +0 -19 collapsed to keep the page light
D node_modules/any-promise/README.md +0 -161 collapsed to keep the page light
D node_modules/any-promise/implementation.d.ts +0 -3 collapsed to keep the page light
D node_modules/any-promise/implementation.js +0 -1 collapsed to keep the page light
D node_modules/any-promise/index.d.ts +0 -73 collapsed to keep the page light
D node_modules/any-promise/index.js +0 -1 collapsed to keep the page light
D node_modules/any-promise/loader.js +0 -78 collapsed to keep the page light
D node_modules/any-promise/optional.js +0 -6 collapsed to keep the page light
D node_modules/any-promise/package.json +0 -45 collapsed to keep the page light
D node_modules/any-promise/register-shim.js +0 -18 collapsed to keep the page light
D node_modules/any-promise/register.d.ts +0 -17 collapsed to keep the page light
D node_modules/any-promise/register.js +0 -94 collapsed to keep the page light
D node_modules/any-promise/register/bluebird.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/bluebird.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/es6-promise.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/es6-promise.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/lie.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/lie.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/native-promise-only.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/native-promise-only.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/pinkie.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/pinkie.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/promise.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/promise.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/q.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/q.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/rsvp.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/rsvp.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/vow.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/vow.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/when.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/when.js +0 -2 collapsed to keep the page light
D node_modules/anymatch/LICENSE +0 -15 collapsed to keep the page light
D node_modules/anymatch/README.md +0 -87 collapsed to keep the page light
D node_modules/anymatch/index.d.ts +0 -20 collapsed to keep the page light
D node_modules/anymatch/index.js +0 -104 collapsed to keep the page light
D node_modules/anymatch/package.json +0 -48 collapsed to keep the page light
D node_modules/arg/LICENSE.md +0 -21 collapsed to keep the page light
D node_modules/arg/README.md +0 -317 collapsed to keep the page light
D node_modules/arg/index.d.ts +0 -44 collapsed to keep the page light
D node_modules/arg/index.js +0 -195 collapsed to keep the page light
D node_modules/arg/package.json +0 -28 collapsed to keep the page light
D node_modules/balanced-match/.github/FUNDING.yml +0 -2 collapsed to keep the page light
D node_modules/balanced-match/LICENSE.md +0 -21 collapsed to keep the page light
D node_modules/balanced-match/README.md +0 -97 collapsed to keep the page light
D node_modules/balanced-match/index.js +0 -62 collapsed to keep the page light
D node_modules/balanced-match/package.json +0 -48 collapsed to keep the page light
D node_modules/binary-extensions/binary-extensions.json +0 -263 collapsed to keep the page light
D node_modules/binary-extensions/binary-extensions.json.d.ts +0 -3 collapsed to keep the page light
D node_modules/binary-extensions/index.d.ts +0 -14 collapsed to keep the page light
D node_modules/binary-extensions/index.js +0 -1 collapsed to keep the page light
D node_modules/binary-extensions/license +0 -10 collapsed to keep the page light
D node_modules/binary-extensions/package.json +0 -40 collapsed to keep the page light
D node_modules/binary-extensions/readme.md +0 -25 collapsed to keep the page light
D node_modules/brace-expansion/.github/FUNDING.yml +0 -2 collapsed to keep the page light
D node_modules/brace-expansion/LICENSE +0 -21 collapsed to keep the page light
D node_modules/brace-expansion/README.md +0 -135 collapsed to keep the page light
D node_modules/brace-expansion/index.js +0 -203 collapsed to keep the page light
D node_modules/brace-expansion/package.json +0 -46 collapsed to keep the page light
D node_modules/braces/LICENSE +0 -21 collapsed to keep the page light
D node_modules/braces/README.md +0 -586 collapsed to keep the page light
D node_modules/braces/index.js +0 -170 collapsed to keep the page light
D node_modules/braces/lib/compile.js +0 -60 collapsed to keep the page light
D node_modules/braces/lib/constants.js +0 -57 collapsed to keep the page light
D node_modules/braces/lib/expand.js +0 -113 collapsed to keep the page light
D node_modules/braces/lib/parse.js +0 -331 collapsed to keep the page light
D node_modules/braces/lib/stringify.js +0 -32 collapsed to keep the page light
D node_modules/braces/lib/utils.js +0 -122 collapsed to keep the page light
D node_modules/braces/package.json +0 -77 collapsed to keep the page light
D node_modules/camelcase-css/README.md +0 -27 collapsed to keep the page light
D node_modules/camelcase-css/index-es5.js +0 -24 collapsed to keep the page light
D node_modules/camelcase-css/index.js +0 -30 collapsed to keep the page light
D node_modules/camelcase-css/license +0 -21 collapsed to keep the page light
D node_modules/camelcase-css/package.json +0 -34 collapsed to keep the page light
D node_modules/caniuse-lite/LICENSE +0 -395 collapsed to keep the page light
D node_modules/caniuse-lite/README.md +0 -6 collapsed to keep the page light
D node_modules/caniuse-lite/package.json +0 -34 collapsed to keep the page light
D node_modules/chokidar/LICENSE +0 -21 collapsed to keep the page light
D node_modules/chokidar/README.md +0 -308 collapsed to keep the page light
D node_modules/chokidar/index.js +0 -973 collapsed to keep the page light
D node_modules/chokidar/lib/constants.js +0 -66 collapsed to keep the page light
D node_modules/chokidar/lib/fsevents-handler.js +0 -526 collapsed to keep the page light
D node_modules/chokidar/lib/nodefs-handler.js +0 -654 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/CHANGELOG.md +0 -110 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/LICENSE +0 -15 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/README.md +0 -137 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/index.js +0 -42 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/package.json +0 -48 collapsed to keep the page light
D node_modules/chokidar/package.json +0 -70 collapsed to keep the page light
D node_modules/chokidar/types/index.d.ts +0 -192 collapsed to keep the page light
D node_modules/color-convert/CHANGELOG.md +0 -54 collapsed to keep the page light
D node_modules/color-convert/LICENSE +0 -21 collapsed to keep the page light
D node_modules/color-convert/README.md +0 -68 collapsed to keep the page light
D node_modules/color-convert/conversions.js +0 -839 collapsed to keep the page light
D node_modules/color-convert/index.js +0 -81 collapsed to keep the page light
D node_modules/color-convert/package.json +0 -48 collapsed to keep the page light
D node_modules/color-convert/route.js +0 -97 collapsed to keep the page light
D node_modules/color-name/LICENSE +0 -8 collapsed to keep the page light
D node_modules/color-name/README.md +0 -11 collapsed to keep the page light
D node_modules/color-name/index.js +0 -152 collapsed to keep the page light
D node_modules/color-name/package.json +0 -28 collapsed to keep the page light
D node_modules/commander/CHANGELOG.md +0 -436 collapsed to keep the page light
D node_modules/commander/LICENSE +0 -22 collapsed to keep the page light
D node_modules/commander/Readme.md +0 -713 collapsed to keep the page light
D node_modules/commander/index.js +0 -1649 collapsed to keep the page light
D node_modules/commander/package.json +0 -41 collapsed to keep the page light
D node_modules/commander/typings/index.d.ts +0 -311 collapsed to keep the page light
D node_modules/cross-spawn/CHANGELOG.md +0 -130 collapsed to keep the page light
D node_modules/cross-spawn/LICENSE +0 -21 collapsed to keep the page light
D node_modules/cross-spawn/README.md +0 -96 collapsed to keep the page light
D node_modules/cross-spawn/index.js +0 -39 collapsed to keep the page light
D node_modules/cross-spawn/lib/enoent.js +0 -59 collapsed to keep the page light
D node_modules/cross-spawn/lib/parse.js +0 -91 collapsed to keep the page light
D node_modules/cross-spawn/lib/util/escape.js +0 -45 collapsed to keep the page light
D node_modules/cross-spawn/lib/util/readShebang.js +0 -23 collapsed to keep the page light
D node_modules/cross-spawn/lib/util/resolveCommand.js +0 -52 collapsed to keep the page light
D node_modules/cross-spawn/package.json +0 -73 collapsed to keep the page light
D node_modules/cssesc/README.md +0 -201 collapsed to keep the page light
D node_modules/cssesc/bin/cssesc +0 -116 collapsed to keep the page light
D node_modules/cssesc/cssesc.js +0 -110 collapsed to keep the page light
D node_modules/cssesc/man/cssesc.1 +0 -70 collapsed to keep the page light
D node_modules/cssesc/package.json +0 -51 collapsed to keep the page light
D node_modules/daisyui/LICENSE +0 -21 collapsed to keep the page light
D node_modules/daisyui/README.md +0 -37 collapsed to keep the page light
D node_modules/daisyui/base/properties.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/properties/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/properties/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/reset.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootcolor.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootcolor/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/rootcolor/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootscrollgutter.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootscrollgutter/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/rootscrollgutter/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootscrolllock.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootscrolllock/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/rootscrolllock/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/scrollbar.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/scrollbar/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/scrollbar/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/svg.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/svg/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/svg/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/chunks.css +0 -73 collapsed to keep the page light
D node_modules/daisyui/colors/properties-extended.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/properties.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/responsive-extended.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/responsive.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/states-extended.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/states.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/alert.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/alert/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/alert/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/avatar.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/avatar/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/avatar/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/badge.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/badge/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/badge/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/breadcrumbs.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/breadcrumbs/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/breadcrumbs/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/button.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/button/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/button/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/calendar.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/calendar/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/calendar/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/card.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/card/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/card/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/carousel.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/carousel/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/carousel/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/chat.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/chat/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/chat/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/checkbox.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/checkbox/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/checkbox/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/collapse.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/collapse/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/collapse/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/countdown.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/countdown/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/countdown/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/diff.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/diff/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/diff/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/divider.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/divider/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/divider/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/dock.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/dock/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/dock/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/drawer.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/drawer/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/drawer/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/dropdown.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/dropdown/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/dropdown/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/fieldset.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/fieldset/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/fieldset/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/fileinput.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/fileinput/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/fileinput/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/filter.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/filter/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/filter/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/footer.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/footer/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/footer/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/hero.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/hero/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/hero/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/indicator.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/indicator/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/indicator/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/input.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/input/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/input/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/kbd.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/kbd/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/kbd/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/label.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/label/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/label/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/link.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/link/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/link/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/list.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/list/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/list/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/loading.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/loading/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/loading/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/mask.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/mask/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/mask/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/menu.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/menu/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/menu/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/mockup.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/mockup/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/mockup/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/modal.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/modal/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/modal/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/navbar.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/navbar/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/navbar/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/progress.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/progress/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/progress/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/radialprogress.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/radialprogress/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/radialprogress/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/radio.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/radio/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/radio/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/range.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/range/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/range/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/rating.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/rating/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/rating/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/select.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/select/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/select/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/skeleton.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/skeleton/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/skeleton/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/stack.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/stack/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/stack/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/stat.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/stat/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/stat/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/status.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/status/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/status/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/steps.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/steps/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/steps/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/swap.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/swap/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/swap/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/tab.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/tab/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/tab/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/table.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/table/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/table/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/textarea.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/textarea/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/textarea/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/timeline.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/timeline/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/timeline/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/toast.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/toast/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/toast/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/toggle.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/toggle/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/toggle/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/tooltip.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/tooltip/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/tooltip/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/validator.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/validator/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/validator/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/daisyui.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/functions/addPrefix.js +0 -156 collapsed to keep the page light
D node_modules/daisyui/functions/plugin.js +0 -11 collapsed to keep the page light
D node_modules/daisyui/functions/pluginOptionsHandler.js +0 -86 collapsed to keep the page light
D node_modules/daisyui/functions/themeOrder.js +0 -37 collapsed to keep the page light
D node_modules/daisyui/functions/variables.js +0 -29 collapsed to keep the page light
D node_modules/daisyui/imports.js +0 -68 collapsed to keep the page light
D node_modules/daisyui/index.js +0 -51 collapsed to keep the page light
D node_modules/daisyui/package.json +0 -72 collapsed to keep the page light
D node_modules/daisyui/theme/abyss.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/abyss/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/abyss/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/acid.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/acid/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/acid/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/aqua.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/aqua/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/aqua/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/autumn.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/autumn/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/autumn/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/black.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/black/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/black/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/bumblebee.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/bumblebee/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/bumblebee/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/business.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/business/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/business/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/caramellatte.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/caramellatte/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/caramellatte/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/cmyk.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/cmyk/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/cmyk/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/coffee.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/coffee/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/coffee/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/corporate.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/corporate/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/corporate/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/cupcake.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/cupcake/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/cupcake/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/cyberpunk.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/cyberpunk/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/cyberpunk/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/dark.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/dark/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/dark/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/dim.css +0 -31
@@ -1,31 +0,0 @@
1-:root:has(input.theme-controller[value=dim]:checked),[data-theme="dim"] {
2-color-scheme: dark;
3---color-base-100: oklch(30.857% 0.023 264.149);
4---color-base-200: oklch(28.036% 0.019 264.182);
5---color-base-300: oklch(26.346% 0.018 262.177);
6---color-base-content: oklch(82.901% 0.031 222.959);
7---color-primary: oklch(86.133% 0.141 139.549);
8---color-primary-content: oklch(17.226% 0.028 139.549);
9---color-secondary: oklch(73.375% 0.165 35.353);
10---color-secondary-content: oklch(14.675% 0.033 35.353);
11---color-accent: oklch(74.229% 0.133 311.379);
12---color-accent-content: oklch(14.845% 0.026 311.379);
13---color-neutral: oklch(24.731% 0.02 264.094);
14---color-neutral-content: oklch(82.901% 0.031 222.959);
15---color-info: oklch(86.078% 0.142 206.182);
16---color-info-content: oklch(17.215% 0.028 206.182);
17---color-success: oklch(86.171% 0.142 166.534);
18---color-success-content: oklch(17.234% 0.028 166.534);
19---color-warning: oklch(86.163% 0.142 94.818);
20---color-warning-content: oklch(17.232% 0.028 94.818);
21---color-error: oklch(82.418% 0.099 33.756);
22---color-error-content: oklch(16.483% 0.019 33.756);
23---radius-selector: 1rem;
24---radius-field: 0.5rem;
25---radius-box: 1rem;
26---size-selector: 0.25rem;
27---size-field: 0.25rem;
28---border: 1px;
29---depth: 0;
30---noise: 0;
31-}
D node_modules/daisyui/theme/dim/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/dim/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/dracula.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/dracula/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/dracula/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/emerald.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/emerald/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/emerald/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/fantasy.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/fantasy/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/fantasy/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/forest.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/forest/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/forest/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/garden.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/garden/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/garden/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/halloween.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/halloween/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/halloween/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/index.js +0 -48 collapsed to keep the page light
D node_modules/daisyui/theme/lemonade.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/lemonade/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/lemonade/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/light.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/light/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/light/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/lofi.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/lofi/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/lofi/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/luxury.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/luxury/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/luxury/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/night.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/night/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/night/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/nord.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/nord/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/nord/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/object.d.ts +0 -74 collapsed to keep the page light
D node_modules/daisyui/theme/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/pastel.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/pastel/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/pastel/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/retro.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/retro/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/retro/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/silk.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/silk/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/silk/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/sunset.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/sunset/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/sunset/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/synthwave.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/synthwave/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/synthwave/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/valentine.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/valentine/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/valentine/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/winter.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/winter/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/winter/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/wireframe.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/wireframe/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/wireframe/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/themes.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/glass.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/glass/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/utilities/glass/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/join.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/join/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/utilities/join/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/radius.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/radius/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/utilities/radius/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/typography.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/typography/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/utilities/typography/object.js +0 -1 collapsed to keep the page light
D node_modules/detect-libc/LICENSE +0 -201 collapsed to keep the page light
D node_modules/detect-libc/README.md +0 -163 collapsed to keep the page light
D node_modules/detect-libc/index.d.ts +0 -14 collapsed to keep the page light
D node_modules/detect-libc/lib/detect-libc.js +0 -267 collapsed to keep the page light
D node_modules/detect-libc/lib/filesystem.js +0 -41 collapsed to keep the page light
D node_modules/detect-libc/lib/process.js +0 -24 collapsed to keep the page light
D node_modules/detect-libc/package.json +0 -40 collapsed to keep the page light
D node_modules/didyoumean/LICENSE +0 -14 collapsed to keep the page light
D node_modules/didyoumean/README.md +0 -134 collapsed to keep the page light
D node_modules/didyoumean/didYouMean-1.2.1.js +0 -274 collapsed to keep the page light
D node_modules/didyoumean/didYouMean-1.2.1.min.js +0 -17 collapsed to keep the page light
D node_modules/didyoumean/package.json +0 -27 collapsed to keep the page light
D node_modules/dlv/README.md +0 -76 collapsed to keep the page light
D node_modules/dlv/index.js +0 -7 collapsed to keep the page light
D node_modules/dlv/package.json +0 -30 collapsed to keep the page light
D node_modules/dotenv/CHANGELOG.md +0 -488 collapsed to keep the page light
D node_modules/dotenv/LICENSE +0 -23 collapsed to keep the page light
D node_modules/dotenv/README-es.md +0 -448 collapsed to keep the page light
D node_modules/dotenv/README.md +0 -651 collapsed to keep the page light
D node_modules/dotenv/config.d.ts +0 -1 collapsed to keep the page light
D node_modules/dotenv/config.js +0 -9 collapsed to keep the page light
D node_modules/dotenv/lib/cli-options.js +0 -11 collapsed to keep the page light
D node_modules/dotenv/lib/env-options.js +0 -24 collapsed to keep the page light
D node_modules/dotenv/lib/main.d.ts +0 -153 collapsed to keep the page light
D node_modules/dotenv/lib/main.js +0 -361 collapsed to keep the page light
D node_modules/dotenv/package.json +0 -61 collapsed to keep the page light
D node_modules/eastasianwidth/README.md +0 -32 collapsed to keep the page light
D node_modules/eastasianwidth/eastasianwidth.js +0 -311 collapsed to keep the page light
D node_modules/eastasianwidth/package.json +0 -18 collapsed to keep the page light
D node_modules/emoji-regex/README.md +0 -137 collapsed to keep the page light
D node_modules/emoji-regex/RGI_Emoji.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/RGI_Emoji.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/es2015/RGI_Emoji.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/es2015/RGI_Emoji.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/es2015/index.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/es2015/index.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/es2015/text.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/es2015/text.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/index.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/index.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/package.json +0 -52 collapsed to keep the page light
D node_modules/emoji-regex/text.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/text.js +0 -6 collapsed to keep the page light
D node_modules/enhanced-resolve/LICENSE +0 -20 collapsed to keep the page light
D node_modules/enhanced-resolve/README.md +0 -183 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/AliasFieldPlugin.js +0 -108 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/AliasPlugin.js +0 -183 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/AppendPlugin.js +0 -49 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/CachedInputFileSystem.js +0 -664 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/CloneBasenamePlugin.js +0 -53 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ConditionalPlugin.js +0 -59 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/DescriptionFilePlugin.js +0 -98 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/DescriptionFileUtils.js +0 -201 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/DirectoryExistsPlugin.js +0 -63 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ExportsFieldPlugin.js +0 -200 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ExtensionAliasPlugin.js +0 -101 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/FileExistsPlugin.js +0 -58 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ImportsFieldPlugin.js +0 -223 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/JoinRequestPartPlugin.js +0 -75 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/JoinRequestPlugin.js +0 -45 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/LogInfoPlugin.js +0 -54 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/MainFieldPlugin.js +0 -90 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ModulesInHierachicDirectoriesPlugin.js +0 -9 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ModulesInHierarchicalDirectoriesPlugin.js +0 -87 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ModulesInRootPlugin.js +0 -49 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/NextPlugin.js +0 -33 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ParsePlugin.js +0 -76 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/PnpPlugin.js +0 -133 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/Resolver.js +0 -800 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ResolverFactory.js +0 -728 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/RestrictionsPlugin.js +0 -70 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ResultPlugin.js +0 -42 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/RootsPlugin.js +0 -69 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/SelfReferencePlugin.js +0 -82 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/SymlinkPlugin.js +0 -100 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/SyncAsyncFileSystemDecorator.js +0 -220 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/TryNextPlugin.js +0 -41 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/UnsafeCachePlugin.js +0 -112 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/UseFilePlugin.js +0 -55 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/createInnerContext.js +0 -46 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/forEachBail.js +0 -50 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/getInnerRequest.js +0 -36 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/getPaths.js +0 -45 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/index.js +0 -203 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/entrypoints.js +0 -573 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/identifier.js +0 -69 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/module-browser.js +0 -8 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/path.js +0 -203 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/process-browser.js +0 -22 collapsed to keep the page light
D node_modules/enhanced-resolve/package.json +0 -72 collapsed to keep the page light
D node_modules/enhanced-resolve/types.d.ts +0 -1135 collapsed to keep the page light
D node_modules/fast-glob/LICENSE +0 -21 collapsed to keep the page light
D node_modules/fast-glob/README.md +0 -830 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/CHANGELOG.md +0 -110 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/LICENSE +0 -15 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/README.md +0 -137 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/index.js +0 -42 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/package.json +0 -48 collapsed to keep the page light
D node_modules/fast-glob/out/index.d.ts +0 -40 collapsed to keep the page light
D node_modules/fast-glob/out/index.js +0 -102 collapsed to keep the page light
D node_modules/fast-glob/out/managers/tasks.d.ts +0 -22 collapsed to keep the page light
D node_modules/fast-glob/out/managers/tasks.js +0 -110 collapsed to keep the page light
D node_modules/fast-glob/out/providers/async.d.ts +0 -9 collapsed to keep the page light
D node_modules/fast-glob/out/providers/async.js +0 -23 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/deep.d.ts +0 -16 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/deep.js +0 -62 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/entry.d.ts +0 -16 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/entry.js +0 -63 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/error.d.ts +0 -8 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/error.js +0 -15 collapsed to keep the page light
D node_modules/fast-glob/out/providers/matchers/matcher.d.ts +0 -33 collapsed to keep the page light
D node_modules/fast-glob/out/providers/matchers/matcher.js +0 -45 collapsed to keep the page light
D node_modules/fast-glob/out/providers/matchers/partial.d.ts +0 -4 collapsed to keep the page light
D node_modules/fast-glob/out/providers/matchers/partial.js +0 -38 collapsed to keep the page light
D node_modules/fast-glob/out/providers/provider.d.ts +0 -19 collapsed to keep the page light
D node_modules/fast-glob/out/providers/provider.js +0 -48 collapsed to keep the page light
D node_modules/fast-glob/out/providers/stream.d.ts +0 -11 collapsed to keep the page light
D node_modules/fast-glob/out/providers/stream.js +0 -31 collapsed to keep the page light
D node_modules/fast-glob/out/providers/sync.d.ts +0 -9 collapsed to keep the page light
D node_modules/fast-glob/out/providers/sync.js +0 -23 collapsed to keep the page light
D node_modules/fast-glob/out/providers/transformers/entry.d.ts +0 -8 collapsed to keep the page light
D node_modules/fast-glob/out/providers/transformers/entry.js +0 -26 collapsed to keep the page light
D node_modules/fast-glob/out/readers/async.d.ts +0 -10 collapsed to keep the page light
D node_modules/fast-glob/out/readers/async.js +0 -35 collapsed to keep the page light
D node_modules/fast-glob/out/readers/reader.d.ts +0 -15 collapsed to keep the page light
D node_modules/fast-glob/out/readers/reader.js +0 -33 collapsed to keep the page light
D node_modules/fast-glob/out/readers/stream.d.ts +0 -14 collapsed to keep the page light
D node_modules/fast-glob/out/readers/stream.js +0 -55 collapsed to keep the page light
D node_modules/fast-glob/out/readers/sync.d.ts +0 -12 collapsed to keep the page light
D node_modules/fast-glob/out/readers/sync.js +0 -43 collapsed to keep the page light
D node_modules/fast-glob/out/settings.d.ts +0 -164 collapsed to keep the page light
D node_modules/fast-glob/out/settings.js +0 -59 collapsed to keep the page light
D node_modules/fast-glob/out/types/index.d.ts +0 -31 collapsed to keep the page light
D node_modules/fast-glob/out/types/index.js +0 -2 collapsed to keep the page light
D node_modules/fast-glob/out/utils/array.d.ts +0 -2 collapsed to keep the page light
D node_modules/fast-glob/out/utils/array.js +0 -22 collapsed to keep the page light
D node_modules/fast-glob/out/utils/errno.d.ts +0 -2 collapsed to keep the page light
D node_modules/fast-glob/out/utils/errno.js +0 -7 collapsed to keep the page light
D node_modules/fast-glob/out/utils/fs.d.ts +0 -4 collapsed to keep the page light
D node_modules/fast-glob/out/utils/fs.js +0 -19 collapsed to keep the page light
D node_modules/fast-glob/out/utils/index.d.ts +0 -8 collapsed to keep the page light
D node_modules/fast-glob/out/utils/index.js +0 -17 collapsed to keep the page light
D node_modules/fast-glob/out/utils/path.d.ts +0 -13 collapsed to keep the page light
D node_modules/fast-glob/out/utils/path.js +0 -68 collapsed to keep the page light
D node_modules/fast-glob/out/utils/pattern.d.ts +0 -47 collapsed to keep the page light
D node_modules/fast-glob/out/utils/pattern.js +0 -188 collapsed to keep the page light
D node_modules/fast-glob/out/utils/stream.d.ts +0 -4 collapsed to keep the page light
D node_modules/fast-glob/out/utils/stream.js +0 -17 collapsed to keep the page light
D node_modules/fast-glob/out/utils/string.d.ts +0 -2 collapsed to keep the page light
D node_modules/fast-glob/out/utils/string.js +0 -11 collapsed to keep the page light
D node_modules/fast-glob/package.json +0 -81 collapsed to keep the page light
D node_modules/fastq/.github/dependabot.yml +0 -11 collapsed to keep the page light
D node_modules/fastq/.github/workflows/ci.yml +0 -75 collapsed to keep the page light
D node_modules/fastq/LICENSE +0 -13 collapsed to keep the page light
D node_modules/fastq/README.md +0 -306 collapsed to keep the page light
D node_modules/fastq/bench.js +0 -66 collapsed to keep the page light
D node_modules/fastq/example.js +0 -14 collapsed to keep the page light
D node_modules/fastq/example.mjs +0 -11 collapsed to keep the page light
D node_modules/fastq/index.d.ts +0 -38 collapsed to keep the page light
D node_modules/fastq/package.json +0 -53 collapsed to keep the page light
D node_modules/fastq/queue.js +0 -311 collapsed to keep the page light
D node_modules/fastq/test/example.ts +0 -83 collapsed to keep the page light
D node_modules/fastq/test/promise.js +0 -248 collapsed to keep the page light
D node_modules/fastq/test/test.js +0 -642 collapsed to keep the page light
D node_modules/fastq/test/tsconfig.json +0 -11 collapsed to keep the page light
D node_modules/fill-range/LICENSE +0 -21 collapsed to keep the page light
D node_modules/fill-range/README.md +0 -237 collapsed to keep the page light
D node_modules/fill-range/index.js +0 -248 collapsed to keep the page light
D node_modules/fill-range/package.json +0 -74 collapsed to keep the page light
D node_modules/foreground-child/LICENSE +0 -15 collapsed to keep the page light
D node_modules/foreground-child/README.md +0 -128 collapsed to keep the page light
D node_modules/foreground-child/package.json +0 -111 collapsed to keep the page light
D node_modules/function-bind/.eslintrc +0 -21 collapsed to keep the page light
D node_modules/function-bind/.github/FUNDING.yml +0 -12 collapsed to keep the page light
D node_modules/function-bind/.github/SECURITY.md +0 -3 collapsed to keep the page light
D node_modules/function-bind/.nycrc +0 -13 collapsed to keep the page light
D node_modules/function-bind/CHANGELOG.md +0 -136 collapsed to keep the page light
D node_modules/function-bind/LICENSE +0 -20 collapsed to keep the page light
D node_modules/function-bind/README.md +0 -46 collapsed to keep the page light
D node_modules/function-bind/implementation.js +0 -84 collapsed to keep the page light
D node_modules/function-bind/index.js +0 -5 collapsed to keep the page light
D node_modules/function-bind/package.json +0 -87 collapsed to keep the page light
D node_modules/function-bind/test/.eslintrc +0 -9 collapsed to keep the page light
D node_modules/function-bind/test/index.js +0 -252 collapsed to keep the page light
D node_modules/glob-parent/LICENSE +0 -15 collapsed to keep the page light
D node_modules/glob-parent/README.md +0 -134 collapsed to keep the page light
D node_modules/glob-parent/index.js +0 -75 collapsed to keep the page light
D node_modules/glob-parent/package.json +0 -54 collapsed to keep the page light
D node_modules/glob/LICENSE +0 -15 collapsed to keep the page light
D node_modules/glob/README.md +0 -1265 collapsed to keep the page light
D node_modules/glob/package.json +0 -99 collapsed to keep the page light
D node_modules/graceful-fs/LICENSE +0 -15 collapsed to keep the page light
D node_modules/graceful-fs/README.md +0 -143 collapsed to keep the page light
D node_modules/graceful-fs/clone.js +0 -23 collapsed to keep the page light
D node_modules/graceful-fs/graceful-fs.js +0 -448 collapsed to keep the page light
D node_modules/graceful-fs/legacy-streams.js +0 -118 collapsed to keep the page light
D node_modules/graceful-fs/package.json +0 -53 collapsed to keep the page light
D node_modules/graceful-fs/polyfills.js +0 -355 collapsed to keep the page light
D node_modules/hasown/.eslintrc +0 -5 collapsed to keep the page light
D node_modules/hasown/.github/FUNDING.yml +0 -12 collapsed to keep the page light
D node_modules/hasown/.nycrc +0 -13 collapsed to keep the page light
D node_modules/hasown/CHANGELOG.md +0 -40 collapsed to keep the page light
D node_modules/hasown/LICENSE +0 -21 collapsed to keep the page light
D node_modules/hasown/README.md +0 -40 collapsed to keep the page light
D node_modules/hasown/index.d.ts +0 -3 collapsed to keep the page light
D node_modules/hasown/index.js +0 -8 collapsed to keep the page light
D node_modules/hasown/package.json +0 -92 collapsed to keep the page light
D node_modules/hasown/tsconfig.json +0 -6 collapsed to keep the page light
D node_modules/is-binary-path/index.d.ts +0 -17 collapsed to keep the page light
D node_modules/is-binary-path/index.js +0 -7 collapsed to keep the page light
D node_modules/is-binary-path/license +0 -9 collapsed to keep the page light
D node_modules/is-binary-path/package.json +0 -40 collapsed to keep the page light
D node_modules/is-binary-path/readme.md +0 -34 collapsed to keep the page light
D node_modules/is-core-module/.eslintrc +0 -18 collapsed to keep the page light
D node_modules/is-core-module/.nycrc +0 -9 collapsed to keep the page light
D node_modules/is-core-module/CHANGELOG.md +0 -205 collapsed to keep the page light
D node_modules/is-core-module/LICENSE +0 -20 collapsed to keep the page light
D node_modules/is-core-module/README.md +0 -40 collapsed to keep the page light
D node_modules/is-core-module/core.json +0 -161 collapsed to keep the page light
D node_modules/is-core-module/index.js +0 -69 collapsed to keep the page light
D node_modules/is-core-module/package.json +0 -76 collapsed to keep the page light
D node_modules/is-core-module/test/index.js +0 -154 collapsed to keep the page light
D node_modules/is-extglob/LICENSE +0 -21 collapsed to keep the page light
D node_modules/is-extglob/README.md +0 -107 collapsed to keep the page light
D node_modules/is-extglob/index.js +0 -20 collapsed to keep the page light
D node_modules/is-extglob/package.json +0 -69 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/index.d.ts +0 -17 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/index.js +0 -50 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/license +0 -9 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/package.json +0 -42 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/readme.md +0 -39 collapsed to keep the page light
D node_modules/is-glob/LICENSE +0 -21 collapsed to keep the page light
D node_modules/is-glob/README.md +0 -206 collapsed to keep the page light
D node_modules/is-glob/index.js +0 -150 collapsed to keep the page light
D node_modules/is-glob/package.json +0 -81 collapsed to keep the page light
D node_modules/is-number/LICENSE +0 -21 collapsed to keep the page light
D node_modules/is-number/README.md +0 -187 collapsed to keep the page light
D node_modules/is-number/index.js +0 -18 collapsed to keep the page light
D node_modules/is-number/package.json +0 -82 collapsed to keep the page light
D node_modules/isexe/.npmignore +0 -2 collapsed to keep the page light
D node_modules/isexe/LICENSE +0 -15 collapsed to keep the page light
D node_modules/isexe/README.md +0 -51 collapsed to keep the page light
D node_modules/isexe/index.js +0 -57 collapsed to keep the page light
D node_modules/isexe/mode.js +0 -41 collapsed to keep the page light
D node_modules/isexe/package.json +0 -31 collapsed to keep the page light
D node_modules/isexe/test/basic.js +0 -221 collapsed to keep the page light
D node_modules/isexe/windows.js +0 -42 collapsed to keep the page light
D node_modules/jackspeak/LICENSE.md +0 -55 collapsed to keep the page light
D node_modules/jackspeak/README.md +0 -357 collapsed to keep the page light
D node_modules/jackspeak/package.json +0 -95 collapsed to keep the page light
D node_modules/jiti/LICENSE +0 -21 collapsed to keep the page light
D node_modules/jiti/README.md +0 -235 collapsed to keep the page light
D node_modules/jiti/lib/jiti-cli.mjs +0 -34 collapsed to keep the page light
D node_modules/jiti/lib/jiti-hooks.mjs +0 -117 collapsed to keep the page light
D node_modules/jiti/lib/jiti-native.mjs +0 -121 collapsed to keep the page light
D node_modules/jiti/lib/jiti-register.d.mts +0 -1 collapsed to keep the page light
D node_modules/jiti/lib/jiti-register.mjs +0 -4 collapsed to keep the page light
D node_modules/jiti/lib/jiti.cjs +0 -23 collapsed to keep the page light
D node_modules/jiti/lib/jiti.d.cts +0 -8 collapsed to keep the page light
D node_modules/jiti/lib/jiti.d.mts +0 -8 collapsed to keep the page light
D node_modules/jiti/lib/jiti.mjs +0 -22 collapsed to keep the page light
D node_modules/jiti/lib/types.d.ts +0 -260 collapsed to keep the page light
D node_modules/jiti/package.json +0 -134 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-gnu/LICENSE +0 -373 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-gnu/README.md +0 -1 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-gnu/lightningcss.linux-x64-gnu.node +0 -0 binary file: no text diff

binary file: no text diff

D node_modules/lightningcss-linux-x64-gnu/package.json +0 -37 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-musl/LICENSE +0 -373 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-musl/README.md +0 -1 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-musl/lightningcss.linux-x64-musl.node +0 -0 binary file: no text diff

binary file: no text diff

D node_modules/lightningcss-linux-x64-musl/package.json +0 -37 collapsed to keep the page light
D node_modules/lightningcss/LICENSE +0 -373 collapsed to keep the page light
D node_modules/lightningcss/README.md +0 -105 collapsed to keep the page light
D node_modules/lightningcss/node/ast.d.ts +0 -9716 collapsed to keep the page light
D node_modules/lightningcss/node/ast.js.flow +0 -10346 collapsed to keep the page light
D node_modules/lightningcss/node/browserslistToTargets.js +0 -48 collapsed to keep the page light
D node_modules/lightningcss/node/composeVisitors.js +0 -442 collapsed to keep the page light
D node_modules/lightningcss/node/flags.js +0 -28 collapsed to keep the page light
D node_modules/lightningcss/node/index.d.ts +0 -477 collapsed to keep the page light
D node_modules/lightningcss/node/index.js +0 -28 collapsed to keep the page light
D node_modules/lightningcss/node/index.js.flow +0 -824 collapsed to keep the page light
D node_modules/lightningcss/node/index.mjs +0 -4 collapsed to keep the page light
D node_modules/lightningcss/node/targets.d.ts +0 -40 collapsed to keep the page light
D node_modules/lightningcss/node/targets.js.flow +0 -39 collapsed to keep the page light
D node_modules/lightningcss/package.json +0 -109 collapsed to keep the page light
D node_modules/lilconfig/LICENSE +0 -21 collapsed to keep the page light
D node_modules/lilconfig/package.json +0 -42 collapsed to keep the page light
D node_modules/lilconfig/readme.md +0 -98 collapsed to keep the page light
D node_modules/lilconfig/src/index.d.ts +0 -54 collapsed to keep the page light
D node_modules/lilconfig/src/index.js +0 -460 collapsed to keep the page light
D node_modules/lines-and-columns/LICENSE +0 -21 collapsed to keep the page light
D node_modules/lines-and-columns/README.md +0 -33 collapsed to keep the page light
D node_modules/lines-and-columns/build/index.d.ts +0 -13 collapsed to keep the page light
D node_modules/lines-and-columns/build/index.js +0 -62 collapsed to keep the page light
D node_modules/lines-and-columns/package.json +0 -49 collapsed to keep the page light
D node_modules/lru-cache/LICENSE +0 -15 collapsed to keep the page light
D node_modules/lru-cache/README.md +0 -331 collapsed to keep the page light
D node_modules/lru-cache/package.json +0 -116 collapsed to keep the page light
D node_modules/merge2/LICENSE +0 -21 collapsed to keep the page light
D node_modules/merge2/README.md +0 -144 collapsed to keep the page light
D node_modules/merge2/index.js +0 -144 collapsed to keep the page light
D node_modules/merge2/package.json +0 -43 collapsed to keep the page light
D node_modules/micromatch/LICENSE +0 -21 collapsed to keep the page light
D node_modules/micromatch/README.md +0 -1024 collapsed to keep the page light
D node_modules/micromatch/index.js +0 -474 collapsed to keep the page light
D node_modules/micromatch/package.json +0 -119 collapsed to keep the page light
D node_modules/minimatch/LICENSE +0 -15 collapsed to keep the page light
D node_modules/minimatch/README.md +0 -454 collapsed to keep the page light
D node_modules/minimatch/package.json +0 -82 collapsed to keep the page light
D node_modules/minipass/LICENSE +0 -15 collapsed to keep the page light
D node_modules/minipass/README.md +0 -825 collapsed to keep the page light
D node_modules/minipass/package.json +0 -82 collapsed to keep the page light
D node_modules/mri/index.d.ts +0 -21 collapsed to keep the page light
D node_modules/mri/lib/index.js +0 -119 collapsed to keep the page light
D node_modules/mri/lib/index.mjs +0 -119 collapsed to keep the page light
D node_modules/mri/license.md +0 -21 collapsed to keep the page light
D node_modules/mri/package.json +0 -43 collapsed to keep the page light
D node_modules/mri/readme.md +0 -166 collapsed to keep the page light
D node_modules/mz/HISTORY.md +0 -66 collapsed to keep the page light
D node_modules/mz/LICENSE +0 -22 collapsed to keep the page light
D node_modules/mz/README.md +0 -106 collapsed to keep the page light
D node_modules/mz/child_process.js +0 -8 collapsed to keep the page light
D node_modules/mz/crypto.js +0 -9 collapsed to keep the page light
D node_modules/mz/dns.js +0 -16 collapsed to keep the page light
D node_modules/mz/fs.js +0 -62 collapsed to keep the page light
D node_modules/mz/index.js +0 -8 collapsed to keep the page light
D node_modules/mz/package.json +0 -44 collapsed to keep the page light
D node_modules/mz/readline.js +0 -64 collapsed to keep the page light
D node_modules/mz/zlib.js +0 -13 collapsed to keep the page light
D node_modules/nanoid/LICENSE +0 -20 collapsed to keep the page light
D node_modules/nanoid/README.md +0 -554 collapsed to keep the page light
D node_modules/nanoid/async/index.browser.cjs +0 -69 collapsed to keep the page light
D node_modules/nanoid/async/index.browser.js +0 -69 collapsed to keep the page light
D node_modules/nanoid/async/index.cjs +0 -71 collapsed to keep the page light
D node_modules/nanoid/async/index.d.ts +0 -56 collapsed to keep the page light
D node_modules/nanoid/async/index.js +0 -71 collapsed to keep the page light
D node_modules/nanoid/async/index.native.js +0 -57 collapsed to keep the page light
D node_modules/nanoid/async/package.json +0 -12 collapsed to keep the page light
D node_modules/nanoid/bin/nanoid.cjs +0 -55 collapsed to keep the page light
D node_modules/nanoid/index.browser.cjs +0 -72 collapsed to keep the page light
D node_modules/nanoid/index.browser.js +0 -72 collapsed to keep the page light
D node_modules/nanoid/index.cjs +0 -85 collapsed to keep the page light
D node_modules/nanoid/index.d.cts +0 -91 collapsed to keep the page light
D node_modules/nanoid/index.d.ts +0 -91 collapsed to keep the page light
D node_modules/nanoid/index.js +0 -85 collapsed to keep the page light
D node_modules/nanoid/nanoid.js +0 -1 collapsed to keep the page light
D node_modules/nanoid/non-secure/index.cjs +0 -34 collapsed to keep the page light
D node_modules/nanoid/non-secure/index.d.ts +0 -33 collapsed to keep the page light
D node_modules/nanoid/non-secure/index.js +0 -34 collapsed to keep the page light
D node_modules/nanoid/non-secure/package.json +0 -6 collapsed to keep the page light
D node_modules/nanoid/package.json +0 -88 collapsed to keep the page light
D node_modules/nanoid/url-alphabet/index.cjs +0 -7 collapsed to keep the page light
D node_modules/nanoid/url-alphabet/index.js +0 -7 collapsed to keep the page light
D node_modules/nanoid/url-alphabet/package.json +0 -6 collapsed to keep the page light
D node_modules/node-addon-api/LICENSE.md +0 -9 collapsed to keep the page light
D node_modules/node-addon-api/README.md +0 -319 collapsed to keep the page light
D node_modules/node-addon-api/common.gypi +0 -20 collapsed to keep the page light
D node_modules/node-addon-api/except.gypi +0 -25 collapsed to keep the page light
D node_modules/node-addon-api/index.js +0 -12 collapsed to keep the page light
D node_modules/node-addon-api/napi-inl.deprecated.h +0 -186 collapsed to keep the page light
D node_modules/node-addon-api/napi-inl.h +0 -6607 collapsed to keep the page light
D node_modules/node-addon-api/napi.h +0 -3201 collapsed to keep the page light
D node_modules/node-addon-api/node_addon_api.gyp +0 -32 collapsed to keep the page light
D node_modules/node-addon-api/node_api.gyp +0 -9 collapsed to keep the page light
D node_modules/node-addon-api/noexcept.gypi +0 -26 collapsed to keep the page light
D node_modules/node-addon-api/nothing.c +0 -0

no textual change.

D node_modules/node-addon-api/package-support.json +0 -21 collapsed to keep the page light
D node_modules/node-addon-api/package.json +0 -480 collapsed to keep the page light
D node_modules/node-addon-api/tools/README.md +0 -73 collapsed to keep the page light
D node_modules/node-addon-api/tools/check-napi.js +0 -99 collapsed to keep the page light
D node_modules/node-addon-api/tools/clang-format.js +0 -71 collapsed to keep the page light
D node_modules/node-addon-api/tools/conversion.js +0 -301 collapsed to keep the page light
D node_modules/node-addon-api/tools/eslint-format.js +0 -79 collapsed to keep the page light
D node_modules/normalize-path/LICENSE +0 -21 collapsed to keep the page light
D node_modules/normalize-path/README.md +0 -127 collapsed to keep the page light
D node_modules/normalize-path/index.js +0 -35 collapsed to keep the page light
D node_modules/normalize-path/package.json +0 -77 collapsed to keep the page light
D node_modules/object-assign/index.js +0 -90 collapsed to keep the page light
D node_modules/object-assign/license +0 -21 collapsed to keep the page light
D node_modules/object-assign/package.json +0 -42 collapsed to keep the page light
D node_modules/object-assign/readme.md +0 -61 collapsed to keep the page light
D node_modules/object-hash/LICENSE +0 -22 collapsed to keep the page light
D node_modules/object-hash/index.js +0 -453 collapsed to keep the page light
D node_modules/object-hash/package.json +0 -53 collapsed to keep the page light
D node_modules/object-hash/readme.markdown +0 -198 collapsed to keep the page light
D node_modules/package-json-from-dist/LICENSE.md +0 -63 collapsed to keep the page light
D node_modules/package-json-from-dist/README.md +0 -110 collapsed to keep the page light
D node_modules/package-json-from-dist/package.json +0 -68 collapsed to keep the page light
D node_modules/path-key/index.d.ts +0 -40 collapsed to keep the page light
D node_modules/path-key/index.js +0 -16 collapsed to keep the page light
D node_modules/path-key/license +0 -9 collapsed to keep the page light
D node_modules/path-key/package.json +0 -39 collapsed to keep the page light
D node_modules/path-key/readme.md +0 -61 collapsed to keep the page light
D node_modules/path-parse/LICENSE +0 -21 collapsed to keep the page light
D node_modules/path-parse/README.md +0 -42 collapsed to keep the page light
D node_modules/path-parse/index.js +0 -75 collapsed to keep the page light
D node_modules/path-parse/package.json +0 -33 collapsed to keep the page light
D node_modules/path-scurry/LICENSE.md +0 -55 collapsed to keep the page light
D node_modules/path-scurry/README.md +0 -636 collapsed to keep the page light
D node_modules/path-scurry/package.json +0 -89 collapsed to keep the page light
D node_modules/picocolors/LICENSE +0 -15 collapsed to keep the page light
D node_modules/picocolors/README.md +0 -21 collapsed to keep the page light
D node_modules/picocolors/package.json +0 -25 collapsed to keep the page light
D node_modules/picocolors/picocolors.browser.js +0 -4 collapsed to keep the page light
D node_modules/picocolors/picocolors.d.ts +0 -5 collapsed to keep the page light
D node_modules/picocolors/picocolors.js +0 -75 collapsed to keep the page light
D node_modules/picocolors/types.d.ts +0 -51 collapsed to keep the page light
D node_modules/picomatch/CHANGELOG.md +0 -136 collapsed to keep the page light
D node_modules/picomatch/LICENSE +0 -21 collapsed to keep the page light
D node_modules/picomatch/README.md +0 -708 collapsed to keep the page light
D node_modules/picomatch/index.js +0 -3 collapsed to keep the page light
D node_modules/picomatch/lib/constants.js +0 -179 collapsed to keep the page light
D node_modules/picomatch/lib/parse.js +0 -1091 collapsed to keep the page light
D node_modules/picomatch/lib/picomatch.js +0 -342 collapsed to keep the page light
D node_modules/picomatch/lib/scan.js +0 -391 collapsed to keep the page light
D node_modules/picomatch/lib/utils.js +0 -64 collapsed to keep the page light
D node_modules/picomatch/package.json +0 -81 collapsed to keep the page light
D node_modules/pify/index.js +0 -68 collapsed to keep the page light
D node_modules/pify/license +0 -21 collapsed to keep the page light
D node_modules/pify/package.json +0 -48 collapsed to keep the page light
D node_modules/pify/readme.md +0 -119 collapsed to keep the page light
D node_modules/pirates/LICENSE +0 -21 collapsed to keep the page light
D node_modules/pirates/README.md +0 -69 collapsed to keep the page light
D node_modules/pirates/index.d.ts +0 -82 collapsed to keep the page light
D node_modules/pirates/lib/index.js +0 -139 collapsed to keep the page light
D node_modules/pirates/package.json +0 -74 collapsed to keep the page light
D node_modules/postcss-import/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss-import/README.md +0 -244 collapsed to keep the page light
D node_modules/postcss-import/index.js +0 -420 collapsed to keep the page light
D node_modules/postcss-import/lib/assign-layer-names.js +0 -17 collapsed to keep the page light
D node_modules/postcss-import/lib/data-url.js +0 -17 collapsed to keep the page light
D node_modules/postcss-import/lib/join-layer.js +0 -9 collapsed to keep the page light
D node_modules/postcss-import/lib/join-media.js +0 -28 collapsed to keep the page light
D node_modules/postcss-import/lib/load-content.js +0 -12 collapsed to keep the page light
D node_modules/postcss-import/lib/parse-statements.js +0 -172 collapsed to keep the page light
D node_modules/postcss-import/lib/process-content.js +0 -59 collapsed to keep the page light
D node_modules/postcss-import/lib/resolve-id.js +0 -42 collapsed to keep the page light
D node_modules/postcss-import/package.json +0 -65 collapsed to keep the page light
D node_modules/postcss-js/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss-js/README.md +0 -22 collapsed to keep the page light
D node_modules/postcss-js/async.js +0 -15 collapsed to keep the page light
D node_modules/postcss-js/index.js +0 -11 collapsed to keep the page light
D node_modules/postcss-js/index.mjs +0 -8 collapsed to keep the page light
D node_modules/postcss-js/objectifier.js +0 -85 collapsed to keep the page light
D node_modules/postcss-js/package.json +0 -42 collapsed to keep the page light
D node_modules/postcss-js/parser.js +0 -104 collapsed to keep the page light
D node_modules/postcss-js/process-result.js +0 -11 collapsed to keep the page light
D node_modules/postcss-js/sync.js +0 -12 collapsed to keep the page light
D node_modules/postcss-load-config/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss-load-config/README.md +0 -466 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/LICENSE +0 -21 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/package.json +0 -42 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/readme.md +0 -98 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/src/index.d.ts +0 -54 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/src/index.js +0 -457 collapsed to keep the page light
D node_modules/postcss-load-config/package.json +0 -54 collapsed to keep the page light
D node_modules/postcss-load-config/src/index.d.ts +0 -65 collapsed to keep the page light
D node_modules/postcss-load-config/src/index.js +0 -185 collapsed to keep the page light
D node_modules/postcss-load-config/src/options.js +0 -47 collapsed to keep the page light
D node_modules/postcss-load-config/src/plugins.js +0 -85 collapsed to keep the page light
D node_modules/postcss-load-config/src/req.js +0 -10 collapsed to keep the page light
D node_modules/postcss-nested/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss-nested/README.md +0 -85 collapsed to keep the page light
D node_modules/postcss-nested/index.d.ts +0 -41 collapsed to keep the page light
D node_modules/postcss-nested/index.js +0 -361 collapsed to keep the page light
D node_modules/postcss-nested/package.json +0 -34 collapsed to keep the page light
D node_modules/postcss-selector-parser/API.md +0 -872 collapsed to keep the page light
D node_modules/postcss-selector-parser/CHANGELOG.md +0 -549 collapsed to keep the page light
D node_modules/postcss-selector-parser/LICENSE-MIT +0 -22 collapsed to keep the page light
D node_modules/postcss-selector-parser/README.md +0 -49 collapsed to keep the page light
D node_modules/postcss-selector-parser/package.json +0 -80 collapsed to keep the page light
D node_modules/postcss-selector-parser/postcss-selector-parser.d.ts +0 -555 collapsed to keep the page light
D node_modules/postcss-value-parser/LICENSE +0 -22 collapsed to keep the page light
D node_modules/postcss-value-parser/README.md +0 -263 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/index.d.ts +0 -177 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/index.js +0 -28 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/parse.js +0 -321 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/stringify.js +0 -48 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/unit.js +0 -120 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/walk.js +0 -22 collapsed to keep the page light
D node_modules/postcss-value-parser/package.json +0 -58 collapsed to keep the page light
D node_modules/postcss/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss/README.md +0 -28 collapsed to keep the page light
D node_modules/postcss/lib/at-rule.d.ts +0 -140 collapsed to keep the page light
D node_modules/postcss/lib/at-rule.js +0 -25 collapsed to keep the page light
D node_modules/postcss/lib/comment.d.ts +0 -68 collapsed to keep the page light
D node_modules/postcss/lib/comment.js +0 -13 collapsed to keep the page light
D node_modules/postcss/lib/container.d.ts +0 -480 collapsed to keep the page light
D node_modules/postcss/lib/container.js +0 -447 collapsed to keep the page light
D node_modules/postcss/lib/css-syntax-error.d.ts +0 -248 collapsed to keep the page light
D node_modules/postcss/lib/css-syntax-error.js +0 -133 collapsed to keep the page light
D node_modules/postcss/lib/declaration.d.ts +0 -151 collapsed to keep the page light
D node_modules/postcss/lib/declaration.js +0 -24 collapsed to keep the page light
D node_modules/postcss/lib/document.d.ts +0 -69 collapsed to keep the page light
D node_modules/postcss/lib/document.js +0 -33 collapsed to keep the page light
D node_modules/postcss/lib/fromJSON.d.ts +0 -9 collapsed to keep the page light
D node_modules/postcss/lib/fromJSON.js +0 -54 collapsed to keep the page light
D node_modules/postcss/lib/input.d.ts +0 -206 collapsed to keep the page light
D node_modules/postcss/lib/input.js +0 -251 collapsed to keep the page light
D node_modules/postcss/lib/lazy-result.d.ts +0 -190 collapsed to keep the page light
D node_modules/postcss/lib/lazy-result.js +0 -550 collapsed to keep the page light
D node_modules/postcss/lib/list.d.ts +0 -60 collapsed to keep the page light
D node_modules/postcss/lib/list.js +0 -58 collapsed to keep the page light
D node_modules/postcss/lib/map-generator.js +0 -368 collapsed to keep the page light
D node_modules/postcss/lib/no-work-result.d.ts +0 -46 collapsed to keep the page light
D node_modules/postcss/lib/no-work-result.js +0 -138 collapsed to keep the page light
D node_modules/postcss/lib/node.d.ts +0 -541 collapsed to keep the page light
D node_modules/postcss/lib/node.js +0 -434 collapsed to keep the page light
D node_modules/postcss/lib/parse.d.ts +0 -9 collapsed to keep the page light
D node_modules/postcss/lib/parse.js +0 -42 collapsed to keep the page light
D node_modules/postcss/lib/parser.js +0 -611 collapsed to keep the page light
D node_modules/postcss/lib/postcss.d.mts +0 -69 collapsed to keep the page light
D node_modules/postcss/lib/postcss.d.ts +0 -458 collapsed to keep the page light
D node_modules/postcss/lib/postcss.js +0 -101 collapsed to keep the page light
D node_modules/postcss/lib/postcss.mjs +0 -30 collapsed to keep the page light
D node_modules/postcss/lib/previous-map.d.ts +0 -81 collapsed to keep the page light
D node_modules/postcss/lib/previous-map.js +0 -144 collapsed to keep the page light
D node_modules/postcss/lib/processor.d.ts +0 -115 collapsed to keep the page light
D node_modules/postcss/lib/processor.js +0 -67 collapsed to keep the page light
D node_modules/postcss/lib/result.d.ts +0 -205 collapsed to keep the page light
D node_modules/postcss/lib/result.js +0 -42 collapsed to keep the page light
D node_modules/postcss/lib/root.d.ts +0 -87 collapsed to keep the page light
D node_modules/postcss/lib/root.js +0 -61 collapsed to keep the page light
D node_modules/postcss/lib/rule.d.ts +0 -126 collapsed to keep the page light
D node_modules/postcss/lib/rule.js +0 -27 collapsed to keep the page light
D node_modules/postcss/lib/stringifier.d.ts +0 -46 collapsed to keep the page light
D node_modules/postcss/lib/stringifier.js +0 -353 collapsed to keep the page light
D node_modules/postcss/lib/stringify.d.ts +0 -9 collapsed to keep the page light
D node_modules/postcss/lib/stringify.js +0 -11 collapsed to keep the page light
D node_modules/postcss/lib/symbols.js +0 -5 collapsed to keep the page light
D node_modules/postcss/lib/terminal-highlight.js +0 -70 collapsed to keep the page light
D node_modules/postcss/lib/tokenize.js +0 -266 collapsed to keep the page light
D node_modules/postcss/lib/warn-once.js +0 -13 collapsed to keep the page light
D node_modules/postcss/lib/warning.d.ts +0 -147 collapsed to keep the page light
D node_modules/postcss/lib/warning.js +0 -37 collapsed to keep the page light
D node_modules/postcss/package.json +0 -88 collapsed to keep the page light
D node_modules/prettier-plugin-tailwindcss/LICENSE +0 -21 collapsed to keep the page light
D node_modules/prettier-plugin-tailwindcss/README.md +0 -174 collapsed to keep the page light
D node_modules/prettier-plugin-tailwindcss/package.json +0 -144 collapsed to keep the page light
D node_modules/prettier/LICENSE +0 -7 collapsed to keep the page light
D node_modules/prettier/README.md +0 -108 collapsed to keep the page light
D node_modules/prettier/THIRD-PARTY-NOTICES.md +0 -4416 collapsed to keep the page light
D node_modules/prettier/bin/prettier.cjs +0 -68 collapsed to keep the page light
D node_modules/prettier/doc.d.ts +0 -254 collapsed to keep the page light
D node_modules/prettier/doc.js +0 -1261 collapsed to keep the page light
D node_modules/prettier/doc.mjs +0 -1233 collapsed to keep the page light
D node_modules/prettier/index.cjs +0 -682 collapsed to keep the page light
D node_modules/prettier/index.d.ts +0 -956 collapsed to keep the page light
D node_modules/prettier/index.mjs +0 -22242 collapsed to keep the page light
D node_modules/prettier/internal/cli.mjs +0 -4873 collapsed to keep the page light
D node_modules/prettier/package.json +0 -200 collapsed to keep the page light
D node_modules/prettier/plugins/acorn.d.ts +0 -6 collapsed to keep the page light
D node_modules/prettier/plugins/acorn.js +0 -15 collapsed to keep the page light
D node_modules/prettier/plugins/acorn.mjs +0 -15 collapsed to keep the page light
D node_modules/prettier/plugins/angular.d.ts +0 -8 collapsed to keep the page light
D node_modules/prettier/plugins/angular.js +0 -2 collapsed to keep the page light
D node_modules/prettier/plugins/angular.mjs +0 -2 collapsed to keep the page light
D node_modules/prettier/plugins/babel.d.ts +0 -18 collapsed to keep the page light
D node_modules/prettier/plugins/babel.js +0 -15 collapsed to keep the page light
D node_modules/prettier/plugins/babel.mjs +0 -15 collapsed to keep the page light
D node_modules/prettier/plugins/estree.d.ts +0 -1 collapsed to keep the page light
D node_modules/prettier/plugins/estree.js +0 -36 collapsed to keep the page light
D node_modules/prettier/plugins/estree.mjs +0 -36 collapsed to keep the page light
D node_modules/prettier/plugins/flow.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/flow.js +0 -19 collapsed to keep the page light
D node_modules/prettier/plugins/flow.mjs +0 -19 collapsed to keep the page light
D node_modules/prettier/plugins/glimmer.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/glimmer.js +0 -30 collapsed to keep the page light
D node_modules/prettier/plugins/glimmer.mjs +0 -30 collapsed to keep the page light
D node_modules/prettier/plugins/graphql.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/graphql.js +0 -29 collapsed to keep the page light
D node_modules/prettier/plugins/graphql.mjs +0 -29 collapsed to keep the page light
D node_modules/prettier/plugins/html.d.ts +0 -8 collapsed to keep the page light
D node_modules/prettier/plugins/html.js +0 -22 collapsed to keep the page light
D node_modules/prettier/plugins/html.mjs +0 -22 collapsed to keep the page light
D node_modules/prettier/plugins/markdown.d.ts +0 -7 collapsed to keep the page light
D node_modules/prettier/plugins/markdown.js +0 -63 collapsed to keep the page light
D node_modules/prettier/plugins/markdown.mjs +0 -63 collapsed to keep the page light
D node_modules/prettier/plugins/meriyah.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/meriyah.js +0 -4 collapsed to keep the page light
D node_modules/prettier/plugins/meriyah.mjs +0 -4 collapsed to keep the page light
D node_modules/prettier/plugins/postcss.d.ts +0 -7 collapsed to keep the page light
D node_modules/prettier/plugins/postcss.js +0 -54 collapsed to keep the page light
D node_modules/prettier/plugins/postcss.mjs +0 -54 collapsed to keep the page light
D node_modules/prettier/plugins/typescript.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/typescript.js +0 -20 collapsed to keep the page light
D node_modules/prettier/plugins/typescript.mjs +0 -20 collapsed to keep the page light
D node_modules/prettier/plugins/yaml.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/yaml.js +0 -161 collapsed to keep the page light
D node_modules/prettier/plugins/yaml.mjs +0 -161 collapsed to keep the page light
D node_modules/prettier/standalone.d.ts +0 -33 collapsed to keep the page light
D node_modules/prettier/standalone.js +0 -39 collapsed to keep the page light
D node_modules/prettier/standalone.mjs +0 -39 collapsed to keep the page light
D node_modules/queue-microtask/LICENSE +0 -20 collapsed to keep the page light
D node_modules/queue-microtask/README.md +0 -90 collapsed to keep the page light
D node_modules/queue-microtask/index.d.ts +0 -2 collapsed to keep the page light
D node_modules/queue-microtask/index.js +0 -9 collapsed to keep the page light
D node_modules/queue-microtask/package.json +0 -55 collapsed to keep the page light
D node_modules/read-cache/LICENSE +0 -20 collapsed to keep the page light
D node_modules/read-cache/README.md +0 -46 collapsed to keep the page light
D node_modules/read-cache/index.js +0 -78 collapsed to keep the page light
D node_modules/read-cache/package.json +0 -34 collapsed to keep the page light
D node_modules/readdirp/LICENSE +0 -21 collapsed to keep the page light
D node_modules/readdirp/README.md +0 -122 collapsed to keep the page light
D node_modules/readdirp/index.d.ts +0 -43 collapsed to keep the page light
D node_modules/readdirp/index.js +0 -287 collapsed to keep the page light
D node_modules/readdirp/package.json +0 -122 collapsed to keep the page light
D node_modules/resolve/.editorconfig +0 -37 collapsed to keep the page light
D node_modules/resolve/.eslintrc +0 -65 collapsed to keep the page light
D node_modules/resolve/.github/FUNDING.yml +0 -12 collapsed to keep the page light
D node_modules/resolve/LICENSE +0 -21 collapsed to keep the page light
D node_modules/resolve/SECURITY.md +0 -3 collapsed to keep the page light
D node_modules/resolve/async.js +0 -3 collapsed to keep the page light
D node_modules/resolve/bin/resolve +0 -50 collapsed to keep the page light
D node_modules/resolve/example/async.js +0 -5 collapsed to keep the page light
D node_modules/resolve/example/sync.js +0 -3 collapsed to keep the page light
D node_modules/resolve/index.js +0 -6 collapsed to keep the page light
D node_modules/resolve/lib/async.js +0 -329 collapsed to keep the page light
D node_modules/resolve/lib/caller.js +0 -8 collapsed to keep the page light
D node_modules/resolve/lib/core.js +0 -12 collapsed to keep the page light
D node_modules/resolve/lib/core.json +0 -158 collapsed to keep the page light
D node_modules/resolve/lib/homedir.js +0 -24 collapsed to keep the page light
D node_modules/resolve/lib/is-core.js +0 -5 collapsed to keep the page light
D node_modules/resolve/lib/node-modules-paths.js +0 -42 collapsed to keep the page light
D node_modules/resolve/lib/normalize-options.js +0 -10 collapsed to keep the page light
D node_modules/resolve/lib/sync.js +0 -208 collapsed to keep the page light
D node_modules/resolve/package.json +0 -72 collapsed to keep the page light
D node_modules/resolve/readme.markdown +0 -301 collapsed to keep the page light
D node_modules/resolve/sync.js +0 -3 collapsed to keep the page light
D node_modules/resolve/test/core.js +0 -88 collapsed to keep the page light
D node_modules/resolve/test/dotdot.js +0 -29 collapsed to keep the page light
D node_modules/resolve/test/dotdot/abc/index.js +0 -2 collapsed to keep the page light
D node_modules/resolve/test/dotdot/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/faulty_basedir.js +0 -29 collapsed to keep the page light
D node_modules/resolve/test/filter.js +0 -34 collapsed to keep the page light
D node_modules/resolve/test/filter_sync.js +0 -33 collapsed to keep the page light
D node_modules/resolve/test/home_paths.js +0 -127 collapsed to keep the page light
D node_modules/resolve/test/home_paths_sync.js +0 -114 collapsed to keep the page light
D node_modules/resolve/test/mock.js +0 -315 collapsed to keep the page light
D node_modules/resolve/test/mock_sync.js +0 -214 collapsed to keep the page light
D node_modules/resolve/test/module_dir.js +0 -56 collapsed to keep the page light
D node_modules/resolve/test/module_dir/xmodules/aaa/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/module_dir/ymodules/aaa/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/module_dir/zmodules/bbb/main.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/module_dir/zmodules/bbb/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/node-modules-paths.js +0 -143 collapsed to keep the page light
D node_modules/resolve/test/node_path.js +0 -70 collapsed to keep the page light
D node_modules/resolve/test/node_path/x/aaa/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/node_path/x/ccc/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/node_path/y/bbb/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/node_path/y/ccc/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/nonstring.js +0 -9 collapsed to keep the page light
D node_modules/resolve/test/pathfilter.js +0 -75 collapsed to keep the page light
D node_modules/resolve/test/pathfilter/deep_ref/main.js +0 -0

no textual change.

D node_modules/resolve/test/precedence.js +0 -23 collapsed to keep the page light
D node_modules/resolve/test/precedence/aaa.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/precedence/aaa/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/precedence/aaa/main.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/precedence/bbb.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/precedence/bbb/main.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver.js +0 -597 collapsed to keep the page light
D node_modules/resolve/test/resolver/baz/doom.js +0 -0

no textual change.

D node_modules/resolve/test/resolver/baz/package.json +0 -4 collapsed to keep the page light
D node_modules/resolve/test/resolver/baz/quux.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/browser_field/a.js +0 -0

no textual change.

D node_modules/resolve/test/resolver/browser_field/b.js +0 -0

no textual change.

D node_modules/resolve/test/resolver/browser_field/package.json +0 -5 collapsed to keep the page light
D node_modules/resolve/test/resolver/cup.coffee +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/dot_main/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/dot_main/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/resolver/dot_slash_main/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/dot_slash_main/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/resolver/false_main/index.js +0 -0

no textual change.

D node_modules/resolve/test/resolver/false_main/package.json +0 -4 collapsed to keep the page light
D node_modules/resolve/test/resolver/foo.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/incorrect_main/index.js +0 -2 collapsed to keep the page light
D node_modules/resolve/test/resolver/incorrect_main/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/resolver/invalid_main/package.json +0 -7 collapsed to keep the page light
D node_modules/resolve/test/resolver/mug.coffee +0 -0

no textual change.

D node_modules/resolve/test/resolver/mug.js +0 -0

no textual change.

D node_modules/resolve/test/resolver/multirepo/lerna.json +0 -6 collapsed to keep the page light
D node_modules/resolve/test/resolver/multirepo/package.json +0 -20 collapsed to keep the page light
D node_modules/resolve/test/resolver/multirepo/packages/package-a/index.js +0 -35 collapsed to keep the page light
D node_modules/resolve/test/resolver/multirepo/packages/package-a/package.json +0 -14 collapsed to keep the page light
D node_modules/resolve/test/resolver/multirepo/packages/package-b/index.js +0 -0

no textual change.

D node_modules/resolve/test/resolver/multirepo/packages/package-b/package.json +0 -14 collapsed to keep the page light
D node_modules/resolve/test/resolver/nested_symlinks/mylib/async.js +0 -26 collapsed to keep the page light
D node_modules/resolve/test/resolver/nested_symlinks/mylib/package.json +0 -15 collapsed to keep the page light
D node_modules/resolve/test/resolver/nested_symlinks/mylib/sync.js +0 -12 collapsed to keep the page light
D node_modules/resolve/test/resolver/other_path/lib/other-lib.js +0 -0

no textual change.

D node_modules/resolve/test/resolver/other_path/root.js +0 -0

no textual change.

D node_modules/resolve/test/resolver/quux/foo/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/same_names/foo.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/same_names/foo/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/symlinked/_/node_modules/foo.js +0 -0

no textual change.

D node_modules/resolve/test/resolver/symlinked/_/symlink_target/.gitkeep +0 -0

no textual change.

D node_modules/resolve/test/resolver/symlinked/package/bar.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/symlinked/package/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/resolver/without_basedir/main.js +0 -5 collapsed to keep the page light
D node_modules/resolve/test/resolver_sync.js +0 -730 collapsed to keep the page light
D node_modules/resolve/test/shadowed_core.js +0 -54 collapsed to keep the page light
D node_modules/resolve/test/shadowed_core/node_modules/util/index.js +0 -0

no textual change.

D node_modules/resolve/test/subdirs.js +0 -13 collapsed to keep the page light
D node_modules/resolve/test/symlinks.js +0 -176 collapsed to keep the page light
D node_modules/reusify/.coveralls.yml +0 -1 collapsed to keep the page light
D node_modules/reusify/.travis.yml +0 -28 collapsed to keep the page light
D node_modules/reusify/LICENSE +0 -22 collapsed to keep the page light
D node_modules/reusify/README.md +0 -145 collapsed to keep the page light
D node_modules/reusify/benchmarks/createNoCodeFunction.js +0 -30 collapsed to keep the page light
D node_modules/reusify/benchmarks/fib.js +0 -13 collapsed to keep the page light
D node_modules/reusify/benchmarks/reuseNoCodeFunction.js +0 -38 collapsed to keep the page light
D node_modules/reusify/package.json +0 -45 collapsed to keep the page light
D node_modules/reusify/reusify.js +0 -33 collapsed to keep the page light
D node_modules/reusify/test.js +0 -66 collapsed to keep the page light
D node_modules/run-parallel/LICENSE +0 -20 collapsed to keep the page light
D node_modules/run-parallel/README.md +0 -85 collapsed to keep the page light
D node_modules/run-parallel/index.js +0 -51 collapsed to keep the page light
D node_modules/run-parallel/package.json +0 -58 collapsed to keep the page light
D node_modules/shebang-command/index.js +0 -19 collapsed to keep the page light
D node_modules/shebang-command/license +0 -9 collapsed to keep the page light
D node_modules/shebang-command/package.json +0 -34 collapsed to keep the page light
D node_modules/shebang-command/readme.md +0 -34 collapsed to keep the page light
D node_modules/shebang-regex/index.d.ts +0 -22 collapsed to keep the page light
D node_modules/shebang-regex/index.js +0 -2 collapsed to keep the page light
D node_modules/shebang-regex/license +0 -9 collapsed to keep the page light
D node_modules/shebang-regex/package.json +0 -35 collapsed to keep the page light
D node_modules/shebang-regex/readme.md +0 -33 collapsed to keep the page light
D node_modules/signal-exit/README.md +0 -74 collapsed to keep the page light
D node_modules/signal-exit/package.json +0 -106 collapsed to keep the page light
D node_modules/source-map-js/LICENSE +0 -28 collapsed to keep the page light
D node_modules/source-map-js/README.md +0 -765 collapsed to keep the page light
D node_modules/source-map-js/lib/array-set.js +0 -121 collapsed to keep the page light
D node_modules/source-map-js/lib/base64-vlq.js +0 -140 collapsed to keep the page light
D node_modules/source-map-js/lib/base64.js +0 -67 collapsed to keep the page light
D node_modules/source-map-js/lib/binary-search.js +0 -111 collapsed to keep the page light
D node_modules/source-map-js/lib/mapping-list.js +0 -79 collapsed to keep the page light
D node_modules/source-map-js/lib/quick-sort.js +0 -132 collapsed to keep the page light
D node_modules/source-map-js/lib/source-map-consumer.d.ts +0 -1 collapsed to keep the page light
D node_modules/source-map-js/lib/source-map-consumer.js +0 -1188 collapsed to keep the page light
D node_modules/source-map-js/lib/source-map-generator.d.ts +0 -1 collapsed to keep the page light
D node_modules/source-map-js/lib/source-map-generator.js +0 -444 collapsed to keep the page light
D node_modules/source-map-js/lib/source-node.d.ts +0 -1 collapsed to keep the page light
D node_modules/source-map-js/lib/source-node.js +0 -413 collapsed to keep the page light
D node_modules/source-map-js/lib/util.js +0 -594 collapsed to keep the page light
D node_modules/source-map-js/package.json +0 -71 collapsed to keep the page light
D node_modules/source-map-js/source-map.d.ts +0 -104 collapsed to keep the page light
D node_modules/source-map-js/source-map.js +0 -8 collapsed to keep the page light
D node_modules/string-width-cjs/index.d.ts +0 -29 collapsed to keep the page light
D node_modules/string-width-cjs/index.js +0 -47 collapsed to keep the page light
D node_modules/string-width-cjs/license +0 -9 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/emoji-regex/README.md +0 -73 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/emoji-regex/es2015/index.js +0 -6 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/emoji-regex/es2015/text.js +0 -6 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/emoji-regex/index.d.ts +0 -23 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/emoji-regex/index.js +0 -6 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/emoji-regex/package.json +0 -50 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/emoji-regex/text.js +0 -6 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/index.d.ts +0 -17 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/index.js +0 -4 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/license +0 -9 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/node_modules/ansi-regex/index.d.ts +0 -37 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/node_modules/ansi-regex/index.js +0 -10 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/node_modules/ansi-regex/license +0 -9 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/node_modules/ansi-regex/package.json +0 -55 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/node_modules/ansi-regex/readme.md +0 -78 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/package.json +0 -54 collapsed to keep the page light
D node_modules/string-width-cjs/node_modules/strip-ansi/readme.md +0 -46 collapsed to keep the page light
D node_modules/string-width-cjs/package.json +0 -56 collapsed to keep the page light
D node_modules/string-width-cjs/readme.md +0 -50 collapsed to keep the page light
D node_modules/string-width/index.d.ts +0 -29 collapsed to keep the page light
D node_modules/string-width/index.js +0 -54 collapsed to keep the page light
D node_modules/string-width/license +0 -9 collapsed to keep the page light
D node_modules/string-width/package.json +0 -59 collapsed to keep the page light
D node_modules/string-width/readme.md +0 -67 collapsed to keep the page light
D node_modules/strip-ansi-cjs/index.d.ts +0 -17 collapsed to keep the page light
D node_modules/strip-ansi-cjs/index.js +0 -4 collapsed to keep the page light
D node_modules/strip-ansi-cjs/license +0 -9 collapsed to keep the page light
D node_modules/strip-ansi-cjs/node_modules/ansi-regex/index.d.ts +0 -37 collapsed to keep the page light
D node_modules/strip-ansi-cjs/node_modules/ansi-regex/index.js +0 -10 collapsed to keep the page light
D node_modules/strip-ansi-cjs/node_modules/ansi-regex/license +0 -9 collapsed to keep the page light
D node_modules/strip-ansi-cjs/node_modules/ansi-regex/package.json +0 -55 collapsed to keep the page light
D node_modules/strip-ansi-cjs/node_modules/ansi-regex/readme.md +0 -78 collapsed to keep the page light
D node_modules/strip-ansi-cjs/package.json +0 -54 collapsed to keep the page light
D node_modules/strip-ansi-cjs/readme.md +0 -46 collapsed to keep the page light
D node_modules/strip-ansi/index.d.ts +0 -15 collapsed to keep the page light
D node_modules/strip-ansi/index.js +0 -14 collapsed to keep the page light
D node_modules/strip-ansi/license +0 -9 collapsed to keep the page light
D node_modules/strip-ansi/package.json +0 -57 collapsed to keep the page light
D node_modules/strip-ansi/readme.md +0 -41 collapsed to keep the page light
D node_modules/sucrase/LICENSE +0 -21 collapsed to keep the page light
D node_modules/sucrase/README.md +0 -295 collapsed to keep the page light
D node_modules/sucrase/bin/sucrase +0 -3 collapsed to keep the page light
D node_modules/sucrase/bin/sucrase-node +0 -18 collapsed to keep the page light
D node_modules/sucrase/package.json +0 -88 collapsed to keep the page light
D node_modules/sucrase/register/index.js +0 -1 collapsed to keep the page light
D node_modules/sucrase/register/js.js +0 -1 collapsed to keep the page light
D node_modules/sucrase/register/jsx.js +0 -1 collapsed to keep the page light
D node_modules/sucrase/register/ts-legacy-module-interop.js +0 -1 collapsed to keep the page light
D node_modules/sucrase/register/ts.js +0 -1 collapsed to keep the page light
D node_modules/sucrase/register/tsx-legacy-module-interop.js +0 -1 collapsed to keep the page light
D node_modules/sucrase/register/tsx.js +0 -1 collapsed to keep the page light
D node_modules/sucrase/ts-node-plugin/index.js +0 -83 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/.eslintrc +0 -14 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/.github/FUNDING.yml +0 -12 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/.nycrc +0 -9 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/CHANGELOG.md +0 -22 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/LICENSE +0 -21 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/README.md +0 -42 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/browser.js +0 -3 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/index.js +0 -9 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/package.json +0 -70 collapsed to keep the page light
D node_modules/supports-preserve-symlinks-flag/test/index.js +0 -29 collapsed to keep the page light
D node_modules/tapable/LICENSE +0 -21 collapsed to keep the page light
D node_modules/tapable/README.md +0 -296 collapsed to keep the page light
D node_modules/tapable/lib/AsyncParallelBailHook.js +0 -85 collapsed to keep the page light
D node_modules/tapable/lib/AsyncParallelHook.js +0 -37 collapsed to keep the page light
D node_modules/tapable/lib/AsyncSeriesBailHook.js +0 -42 collapsed to keep the page light
D node_modules/tapable/lib/AsyncSeriesHook.js +0 -37 collapsed to keep the page light
D node_modules/tapable/lib/AsyncSeriesLoopHook.js +0 -37 collapsed to keep the page light
D node_modules/tapable/lib/AsyncSeriesWaterfallHook.js +0 -47 collapsed to keep the page light
D node_modules/tapable/lib/Hook.js +0 -175 collapsed to keep the page light
D node_modules/tapable/lib/HookCodeFactory.js +0 -468 collapsed to keep the page light
D node_modules/tapable/lib/HookMap.js +0 -61 collapsed to keep the page light
D node_modules/tapable/lib/MultiHook.js +0 -54 collapsed to keep the page light
D node_modules/tapable/lib/SyncBailHook.js +0 -51 collapsed to keep the page light
D node_modules/tapable/lib/SyncHook.js +0 -46 collapsed to keep the page light
D node_modules/tapable/lib/SyncLoopHook.js +0 -46 collapsed to keep the page light
D node_modules/tapable/lib/SyncWaterfallHook.js +0 -57 collapsed to keep the page light
D node_modules/tapable/lib/index.js +0 -19 collapsed to keep the page light
D node_modules/tapable/lib/util-browser.js +0 -16 collapsed to keep the page light
D node_modules/tapable/package.json +0 -44 collapsed to keep the page light
D node_modules/tapable/tapable.d.ts +0 -116 collapsed to keep the page light
D node_modules/thenify-all/History.md +0 -11 collapsed to keep the page light
D node_modules/thenify-all/LICENSE +0 -22 collapsed to keep the page light
D node_modules/thenify-all/README.md +0 -66 collapsed to keep the page light
D node_modules/thenify-all/index.js +0 -73 collapsed to keep the page light
D node_modules/thenify-all/package.json +0 -34 collapsed to keep the page light
D node_modules/thenify/History.md +0 -11 collapsed to keep the page light
D node_modules/thenify/LICENSE +0 -22 collapsed to keep the page light
D node_modules/thenify/README.md +0 -120 collapsed to keep the page light
D node_modules/thenify/index.js +0 -77 collapsed to keep the page light
D node_modules/thenify/package.json +0 -31 collapsed to keep the page light
D node_modules/to-regex-range/LICENSE +0 -21 collapsed to keep the page light
D node_modules/to-regex-range/README.md +0 -305 collapsed to keep the page light
D node_modules/to-regex-range/index.js +0 -288 collapsed to keep the page light
D node_modules/to-regex-range/package.json +0 -88 collapsed to keep the page light
D node_modules/ts-interface-checker/LICENSE +0 -201 collapsed to keep the page light
D node_modules/ts-interface-checker/README.md +0 -185 collapsed to keep the page light
D node_modules/ts-interface-checker/package.json +0 -60 collapsed to keep the page light
D node_modules/util-deprecate/History.md +0 -16 collapsed to keep the page light
D node_modules/util-deprecate/LICENSE +0 -24 collapsed to keep the page light
D node_modules/util-deprecate/README.md +0 -53 collapsed to keep the page light
D node_modules/util-deprecate/browser.js +0 -67 collapsed to keep the page light
D node_modules/util-deprecate/node.js +0 -6 collapsed to keep the page light
D node_modules/util-deprecate/package.json +0 -27 collapsed to keep the page light
D node_modules/which/CHANGELOG.md +0 -166 collapsed to keep the page light
D node_modules/which/LICENSE +0 -15 collapsed to keep the page light
D node_modules/which/README.md +0 -54 collapsed to keep the page light
D node_modules/which/bin/node-which +0 -52 collapsed to keep the page light
D node_modules/which/package.json +0 -43 collapsed to keep the page light
D node_modules/which/which.js +0 -125 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/index.js +0 -216 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/license +0 -9 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/ansi-styles/index.d.ts +0 -345 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/ansi-styles/index.js +0 -163 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/ansi-styles/license +0 -9 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/ansi-styles/package.json +0 -56 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/ansi-styles/readme.md +0 -152 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/index.d.ts +0 -29 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/index.js +0 -47 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/license +0 -9 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/node_modules/emoji-regex/README.md +0 -73 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/node_modules/emoji-regex/es2015/index.js +0 -6 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/node_modules/emoji-regex/es2015/text.js +0 -6 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/node_modules/emoji-regex/index.d.ts +0 -23 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/node_modules/emoji-regex/index.js +0 -6 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/node_modules/emoji-regex/package.json +0 -50 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/node_modules/emoji-regex/text.js +0 -6 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/package.json +0 -56 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/string-width/readme.md +0 -50 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/index.d.ts +0 -17 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/index.js +0 -4 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/license +0 -9 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/node_modules/ansi-regex/index.d.ts +0 -37 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/node_modules/ansi-regex/index.js +0 -10 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/node_modules/ansi-regex/license +0 -9 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/node_modules/ansi-regex/package.json +0 -55 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/node_modules/ansi-regex/readme.md +0 -78 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/package.json +0 -54 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/node_modules/strip-ansi/readme.md +0 -46 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/package.json +0 -62 collapsed to keep the page light
D node_modules/wrap-ansi-cjs/readme.md +0 -91 collapsed to keep the page light
D node_modules/wrap-ansi/index.d.ts +0 -41 collapsed to keep the page light
D node_modules/wrap-ansi/index.js +0 -214 collapsed to keep the page light
D node_modules/wrap-ansi/license +0 -9 collapsed to keep the page light
D node_modules/wrap-ansi/package.json +0 -69 collapsed to keep the page light
D node_modules/wrap-ansi/readme.md +0 -91 collapsed to keep the page light
D node_modules/yaml/LICENSE +0 -13 collapsed to keep the page light
D node_modules/yaml/README.md +0 -169 collapsed to keep the page light
D node_modules/yaml/bin.mjs +0 -11 collapsed to keep the page light
D node_modules/yaml/browser/index.js +0 -5 collapsed to keep the page light
D node_modules/yaml/browser/package.json +0 -3 collapsed to keep the page light
D node_modules/yaml/package.json +0 -96 collapsed to keep the page light
D node_modules/yaml/util.js +0 -2 collapsed to keep the page light
D pkg/docker/client.go +0 -117 collapsed to keep the page light
D pkg/docker/containers.go +0 -1168 collapsed to keep the page light
D pkg/docker/detect.go +0 -98 collapsed to keep the page light
D pkg/docker/images.go +0 -360 collapsed to keep the page light
D pkg/filestore/container_images.go +0 -71 collapsed to keep the page light
D pkg/humanize/humanize.go +0 -56 collapsed to keep the page light
D pkg/kv/ratelimiter.go +0 -277 collapsed to keep the page light
D pkg/logger/logger.go +0 -101 collapsed to keep the page light
D pkg/parser/yaml.go +0 -55 collapsed to keep the page light
A pkg/runtime/interface.go +56 -0 collapsed to keep the page light
D pkg/sanitize/html_sanitizer.go +0 -22 collapsed to keep the page light
D pkg/scripts/rate_limit_test/main.go +0 -229 collapsed to keep the page light
D pkg/sqlite/db_create.go +0 -75 collapsed to keep the page light
D pkg/sqlite/db_update.go +0 -328 collapsed to keep the page light
D pkg/verify/tar.go +0 -31 collapsed to keep the page light
D tailwind.config.js +0 -12 collapsed to keep the page light
D tools.go +0 -7 collapsed to keep the page light

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close