mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat: owner accounts and in-app platform settings
- First registered account becomes admin (users.is_admin, backfilled to earliest account); better-auth mirror path does the same - Public registration closes after bootstrap; admin can reopen it via GET/PUT /api/v1/settings (admin-gated) or SIGNUP_ENABLED env - In-app settings override env vars; secrets stored in app_settings, token values never returned by the API - Saving a Cloudflare Tunnel token pulls and runs a managed cloudflared sidecar on the backend's networks; clearing removes it; startup restores it from saved settings - group_add DOCKER_GID grants the non-root backend user Docker socket access; install.sh probes the in-container socket gid - Settings page gains an owner-only Platform section (registration toggle, tunnel token, link to Cloudflare dashboard); sign-up page stays reachable while registration is open - OpenAPI spec + generated types updated; manual user creation now requires admin Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c3959d13e2
commit
c16b32f56a
18 files changed
+802
-34
No files matched your search
@@ -46,6 +46,7 @@ type User struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -63,10 +64,10 @@ func handleLogin(c *gin.Context) {
|
||||
var user User
|
||||
var hashedPassword string
|
||||
err := db.QueryRow(`
|
||||
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
||||
FROM users
|
||||
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||
@@ -128,10 +129,11 @@ func handleAuthBootstrap(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"has_users": count > 0,
|
||||
"user_count": count,
|
||||
"mode": mode,
|
||||
"providers": providers,
|
||||
"has_users": count > 0,
|
||||
"user_count": count,
|
||||
"mode": mode,
|
||||
"providers": providers,
|
||||
"signup_enabled": signupEnabled(db),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,24 +284,26 @@ func handleRegister(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
total, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// Closed once the first account exists unless the owner reopens registration.
|
||||
if total > 0 && !signupEnabled(db) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
return
|
||||
}
|
||||
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
|
||||
var existing int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
if existing > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
@@ -311,13 +315,13 @@ func handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
// First account owns the platform.
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO users (email, password_hash, name, is_admin)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
@@ -338,13 +342,16 @@ func handleRegister(c *gin.Context) {
|
||||
}
|
||||
|
||||
func handleCreateUser(c *gin.Context) {
|
||||
db, ok := requireAdmin(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req ManualUserCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Email == "" || req.Name == "" {
|
||||
@@ -477,10 +484,10 @@ func handleGetProfile(c *gin.Context) {
|
||||
|
||||
var user User
|
||||
err := db.QueryRow(`
|
||||
SELECT id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
FROM users
|
||||
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
|
||||
@@ -67,7 +67,9 @@ func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *d
|
||||
return true
|
||||
}
|
||||
|
||||
return count > 0
|
||||
// Registration is closed once the first account exists unless the owner
|
||||
// reopens it in Settings (signup_enabled).
|
||||
return count > 0 && !signupEnabled(db)
|
||||
}
|
||||
|
||||
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||
|
||||
@@ -35,6 +35,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
dockerClient = client
|
||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
||||
// Restore managed sidecars (e.g. cloudflared) from saved settings.
|
||||
syncManagedContainers(dockerClient, db)
|
||||
}
|
||||
|
||||
// Initialize build handler
|
||||
@@ -184,6 +186,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.PUT("/user/profile", handleUpdateProfile)
|
||||
protected.POST("/users", handleCreateUser)
|
||||
|
||||
// Platform settings (admin-gated inside handlers)
|
||||
protected.GET("/settings", handleGetSettings)
|
||||
protected.PUT("/settings", handleUpdateSettings)
|
||||
|
||||
// Project routes
|
||||
protected.GET("/projects", handleGetProjects)
|
||||
protected.POST("/projects", handleCreateProject)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 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"
|
||||
|
||||
cloudflaredContainerName = "containr-cloudflared"
|
||||
cloudflaredImage = "cloudflare/cloudflared:latest"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
var value string
|
||||
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
|
||||
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 {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO app_settings (key, value, is_secret, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
|
||||
`, key, value, secret)
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteSetting(db *database.DB, key string) error {
|
||||
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var isAdmin bool
|
||||
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
|
||||
return false
|
||||
}
|
||||
return 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),
|
||||
})
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
var value string
|
||||
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); 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 reader, err := client.PullImage(ctx, cloudflaredImage, registry.AuthConfig{}); err == nil {
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
_ = reader.Close()
|
||||
} else {
|
||||
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
|
||||
}
|
||||
@@ -248,11 +248,18 @@ func ensureLocalUserRecord(c *gin.Context, user betterAuthSessionUser) (string,
|
||||
return "", hashErr
|
||||
}
|
||||
|
||||
// The first mirrored account owns the platform.
|
||||
var userCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||
return "", err
|
||||
}
|
||||
isFirstUser := userCount == 0
|
||||
|
||||
if err := db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||
RETURNING id
|
||||
`, email, string(hashedPassword), name, avatarURL).Scan(&localUserID); err != nil {
|
||||
`, email, string(hashedPassword), name, avatarURL, isFirstUser).Scan(&localUserID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user