package api import ( "context" "database/sql" "log" "net/http" "os" "strings" "time" "containr/internal/database" "containr/internal/database/sqlcdb" "containr/internal/docker" "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/registry" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // Platform setting keys. Values stored in app_settings take precedence over // environment variables; env remains the fallback for optional overrides. const ( settingSignupEnabled = "signup_enabled" settingCloudflareTunnelToken = "cloudflare_tunnel_token" // White-label branding keys — unset fields fall back to Containr // defaults. Self-hosters rebrand their instance via these. settingBrandProductName = "branding.product_name" settingBrandLogoURL = "branding.logo_url" settingBrandFaviconURL = "branding.favicon_url" settingBrandAccent = "branding.accent_color" settingBrandDocsURL = "branding.docs_url" settingBrandSupportURL = "branding.support_url" cloudflaredContainerName = "containr-cloudflared" cloudflaredImage = "cloudflare/cloudflared:latest" ) // brandingConfig is the public white-label payload. Fields are always // populated — defaults keep the instance Containr-branded. type brandingConfig struct { ProductName string `json:"product_name"` LogoURL string `json:"logo_url"` FaviconURL string `json:"favicon_url"` AccentColor string `json:"accent_color"` DocsURL string `json:"docs_url"` SupportURL string `json:"support_url"` } func resolveBranding(db *database.DB) brandingConfig { return brandingConfig{ ProductName: settingValue(db, settingBrandProductName, "BRAND_NAME", "Containr"), LogoURL: settingValue(db, settingBrandLogoURL, "BRAND_LOGO_URL", ""), FaviconURL: settingValue(db, settingBrandFaviconURL, "BRAND_FAVICON_URL", ""), AccentColor: settingValue(db, settingBrandAccent, "BRAND_ACCENT_COLOR", ""), DocsURL: settingValue(db, settingBrandDocsURL, "BRAND_DOCS_URL", ""), SupportURL: settingValue(db, settingBrandSupportURL, "BRAND_SUPPORT_URL", ""), } } // GET /branding — public so the login page and shell can brand themselves // before a session exists. func handleGetBranding(c *gin.Context) { db, _ := c.Get("db") database, _ := db.(*database.DB) c.JSON(http.StatusOK, resolveBranding(database)) } // settingValue resolves a setting: app_settings row first, env fallback, // then the caller's default. func settingValue(db *database.DB, key, envKey, fallback string) string { if db != nil && db.DB != nil { value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key) if err == nil { return value } if err != sql.ErrNoRows { log.Printf("Failed to read setting %s: %v", key, err) } } if envKey != "" { if value := strings.TrimSpace(os.Getenv(envKey)); value != "" { return value } } return fallback } func setSetting(db *database.DB, key, value string, secret bool) error { return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{ Key: key, Value: value, IsSecret: secret, }) } func deleteSetting(db *database.DB, key string) error { return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key) } // signupEnabled controls whether public registration stays open after the // first account exists. Defaults to closed; the platform owner can reopen it // in Settings or via SIGNUP_ENABLED=true. func signupEnabled(db *database.DB) bool { return strings.EqualFold(settingValue(db, settingSignupEnabled, "SIGNUP_ENABLED", "false"), "true") } func isAdminUser(db *database.DB, userID string) bool { parsed, err := uuid.Parse(userID) if err != nil { return false } isAdmin, err := sqlcdb.New(db.DB).GetUserIsAdmin(context.Background(), parsed) return err == nil && isAdmin } func requireAdmin(c *gin.Context) (*database.DB, bool) { userID, ok := requireAuthenticatedUserID(c) if !ok { return nil, false } db, _ := c.Get("db") database, _ := db.(*database.DB) if database == nil || database.DB == nil || !isAdminUser(database, userID) { c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) return nil, false } return database, true } type cloudflareTunnelStatus struct { TokenSet bool `json:"token_set"` Source string `json:"source"` // "app" | "env" | "none" Container string `json:"container"` } func handleGetSettings(c *gin.Context) { db, ok := requireAdmin(c) if !ok { return } dockerClient, _ := c.Get("docker_client") client, _ := dockerClient.(*docker.Client) c.JSON(http.StatusOK, gin.H{ "signup_enabled": signupEnabled(db), "cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client), "branding": resolveBranding(db), }) } func handleUpdateSettings(c *gin.Context) { db, ok := requireAdmin(c) if !ok { return } var req struct { SignupEnabled *bool `json:"signup_enabled"` CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"` Branding *struct { ProductName *string `json:"product_name"` LogoURL *string `json:"logo_url"` FaviconURL *string `json:"favicon_url"` AccentColor *string `json:"accent_color"` DocsURL *string `json:"docs_url"` SupportURL *string `json:"support_url"` } `json:"branding"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.SignupEnabled != nil { value := "false" if *req.SignupEnabled { value = "true" } if err := setSetting(db, settingSignupEnabled, value, false); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update signup setting"}) return } } if req.CloudflareTunnelToken != nil { token := strings.TrimSpace(*req.CloudflareTunnelToken) if token == "" { if err := deleteSetting(db, settingCloudflareTunnelToken); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tunnel token"}) return } } else if err := setSetting(db, settingCloudflareTunnelToken, token, true); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tunnel token"}) return } if dockerClient, exists := c.Get("docker_client"); exists { if client, ok := dockerClient.(*docker.Client); ok && client != nil { go applyCloudflaredTunnel(client, token) } } } if req.Branding != nil { fields := []struct { key string value *string }{ {settingBrandProductName, req.Branding.ProductName}, {settingBrandLogoURL, req.Branding.LogoURL}, {settingBrandFaviconURL, req.Branding.FaviconURL}, {settingBrandAccent, req.Branding.AccentColor}, {settingBrandDocsURL, req.Branding.DocsURL}, {settingBrandSupportURL, req.Branding.SupportURL}, } for _, f := range fields { if f.value == nil { continue } v := strings.TrimSpace(*f.value) var err error if v == "" { err = deleteSetting(db, f.key) } else { err = setSetting(db, f.key, v, false) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save branding"}) return } } } handleGetSettings(c) } // resolvedTunnelToken prefers the in-app value and falls back to the env. func resolvedTunnelToken(db *database.DB) (string, string) { if db != nil && db.DB != nil { if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" { return strings.TrimSpace(value), "app" } } if value := strings.TrimSpace(os.Getenv("CLOUDFLARED_TOKEN")); value != "" && value != "your_cloudflare_tunnel_token_here" { return value, "env" } return "", "none" } func cloudflareTunnelState(ctx context.Context, db *database.DB, client *docker.Client) cloudflareTunnelStatus { token, source := resolvedTunnelToken(db) state := cloudflareTunnelStatus{TokenSet: token != "", Source: source, Container: "missing"} if client == nil { state.Container = "unavailable" return state } ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() container, err := findCloudflaredContainer(ctx, client) if err != nil || container == "" { return state } if inspect, err := client.GetContainer(ctx, container); err == nil && inspect.State != nil { state.Container = inspect.State.Status } return state } // syncManagedContainers reconciles containers the platform manages from // settings - currently only the optional Cloudflare Tunnel sidecar. func syncManagedContainers(client *docker.Client, db *database.DB) { token, _ := resolvedTunnelToken(db) if token == "" { return } go applyCloudflaredTunnel(client, token) } func findCloudflaredContainer(ctx context.Context, client *docker.Client) (string, error) { containers, err := client.ListContainers(ctx, true) if err != nil { return "", err } for _, item := range containers { for _, name := range item.Names { if strings.TrimPrefix(name, "/") == cloudflaredContainerName { return item.ID, nil } } } return "", nil } // applyCloudflaredTunnel ensures a cloudflared container mirrors the stored // token: created/recreated when set, removed when cleared. It joins the same // networks as the backend so tunnel targets can use compose service names. func applyCloudflaredTunnel(client *docker.Client, token string) { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() if existing, err := findCloudflaredContainer(ctx, client); err == nil && existing != "" { if err := client.RemoveContainer(ctx, existing, true); err != nil { log.Printf("Failed to remove existing cloudflared container: %v", err) return } } if token == "" { return } if err := client.PullImageWait(ctx, cloudflaredImage, registry.AuthConfig{}); err != nil { log.Printf("Failed to pull %s: %v", cloudflaredImage, err) } networks, err := backendNetworks(ctx, client) if err != nil { log.Printf("Failed to resolve backend networks for cloudflared: %v", err) return } id, err := client.CreateContainer(ctx, docker.ContainerConfig{ Name: cloudflaredContainerName, Image: cloudflaredImage, Cmd: []string{"tunnel", "--no-autoupdate", "run", "--token", token}, RestartPolicy: "unless-stopped", Labels: map[string]string{"containr.managed": "cloudflared"}, Networks: networks, }) if err != nil { log.Printf("Failed to create cloudflared container: %v", err) return } if err := client.StartContainer(ctx, id); err != nil { log.Printf("Failed to start cloudflared container: %v", err) } } // backendNetworks returns the networks this backend container sits on, so the // managed cloudflared container can reach the frontend/backend by name. func backendNetworks(ctx context.Context, client *docker.Client) (map[string]*network.EndpointSettings, error) { hostname, err := os.Hostname() if err != nil { return nil, err } self, err := client.GetContainer(ctx, hostname) if err != nil { return nil, err } networks := map[string]*network.EndpointSettings{} for name := range self.NetworkSettings.Networks { networks[name] = &network.EndpointSettings{} } if len(networks) == 0 { return nil, sql.ErrNoRows } return networks, nil }