feat: public platform, admin console, templates, landing docs

Projects become publicly browsable after admin approval; management and
secret-adjacent reads (logs, variables, exec, connection URLs) require
owner/member/admin auth, and platform controls sit behind RequireAdmin.

- Backend: OptionalAuth middleware, read/authed/admin route groups,
  projects.is_approved + service_templates.owner_id migration,
  visibility-aware sqlc queries, admin overview/users/approval endpoints,
  EnsureAdminAccount bootstrap (ADMIN_* env), 30 seeded templates with
  owner CRUD, managed-database connection URLs gated owner+admin, fix
  DB.Stats() infinite recursion.
- Frontend: expandable persisted sidebar + clickable user menu, public
  landing page with searchable docs browser (GitHub sync, bundled
  snapshot + localStorage fallback), DocsBrowser shared with /docs,
  template create/edit/JSON-upload for owned templates, database create
  dialog, admin console (stats, approvals, project edit/delete, user
  admin toggles), HA/Security/Usage polish with dead CSS tokens mapped
  to real theme vars, anonymous-safe gating on every mutation control.
- Tests: safe window.localStorage access fixes happy-dom crashes in
  PlatformShell and canvas storage.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-22 19:58:59 +02:00
1 parent 8f5789bf93
commit 8b6214a0f5
62 files changed
+4986 -1133

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"strings"
"time"
"containr/internal/config"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// ---------------------------------------------------------------------------
// Admin account bootstrap
// ---------------------------------------------------------------------------
// EnsureAdminAccount provisions the platform owner account from
// ADMIN_EMAIL/ADMIN_PASSWORD (optional ADMIN_NAME). It is idempotent: existing
// users are simply promoted to admin. Both the local users row (legacy JWT
// login) and the Better Auth account (cookie sessions) are created so either
// sign-in path works. Failures are logged, never fatal — the platform must
// boot even when auth is misconfigured.
func EnsureAdminAccount(ctx context.Context, db *database.DB, cfg *config.Config) {
email := strings.ToLower(strings.TrimSpace(cfg.AdminEmail))
password := cfg.AdminPassword
if email == "" || password == "" {
return
}
if db == nil || db.DB == nil {
log.Println("Admin bootstrap skipped: database unavailable")
return
}
name := strings.TrimSpace(cfg.AdminName)
if name == "" {
name = "Platform Admin"
}
// Local users row (owns resources, is_admin flag, legacy login hash).
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Admin bootstrap failed: %v", err)
return
}
var userID string
err = db.QueryRowContext(ctx, `
INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, true)
ON CONFLICT (email) DO UPDATE
SET is_admin = true, updated_at = NOW()
RETURNING id
`, email, string(hashed), name).Scan(&userID)
if err != nil {
log.Printf("Admin bootstrap failed to upsert user %s: %v", email, err)
return
}
// Better Auth account (cookie-session sign-in). Duplicate sign-up returns
// an error which we treat as "already provisioned".
if err := ensureBetterAuthUser(ctx, name, email, password); err != nil {
if errors.Is(err, errBetterAuthUserExists) {
log.Printf("Admin account %s already exists in auth service; ensured admin flag", email)
} else {
log.Printf("Warning: admin bootstrap could not create auth user for %s: %v", email, err)
}
return
}
log.Printf("Admin account provisioned: %s", email)
}
// ensureBetterAuthUser calls the Better Auth sign-up endpoint directly. Unlike
// createBetterAuthUser it runs outside a request context.
func ensureBetterAuthUser(ctx context.Context, name, email, password string) error {
endpoint, err := betterAuthSignUpURL()
if err != nil {
return err
}
body, err := json.Marshal(gin.H{"name": name, "email": email, "password": password})
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
request.Header.Set("X-Containr-Auth-Internal", token)
}
response, err := (&http.Client{Timeout: 10 * time.Second}).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
return nil
}
var payload map[string]interface{}
_ = json.NewDecoder(response.Body).Decode(&payload)
message := ""
if raw, ok := payload["message"].(string); ok {
message = strings.ToLower(raw)
} else if raw, ok := payload["error"].(string); ok {
message = strings.ToLower(raw)
}
if strings.Contains(message, "exist") || strings.Contains(message, "already") {
return errBetterAuthUserExists
}
if message == "" {
message = http.StatusText(response.StatusCode)
}
return errors.New(message)
}
// ---------------------------------------------------------------------------
// Admin API
// ---------------------------------------------------------------------------
type adminUserRow struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
CreatedAt string `json:"created_at"`
}
func handleAdminOverview(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ctx := c.Request.Context()
count := func(query string) int {
var n int
if err := db.QueryRowContext(ctx, query).Scan(&n); err != nil {
return 0
}
return n
}
pendingRows, err := queries.ListPendingProjects(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list pending projects"})
return
}
pending := make([]Project, 0, len(pendingRows))
for _, row := range pendingRows {
pending = append(pending, mapSQLCProject(row))
}
c.JSON(http.StatusOK, gin.H{
"stats": gin.H{
"users": count(`SELECT COUNT(*) FROM users`),
"projects": count(`SELECT COUNT(*) FROM projects`),
"pending_projects": count(`SELECT COUNT(*) FROM projects WHERE is_approved = false`),
"services": count(`SELECT COUNT(*) FROM services`),
"running_services": count(`SELECT COUNT(*) FROM services WHERE status = 'running'`),
"databases": count(`SELECT COUNT(*) FROM database_services`),
"agents": count(`SELECT COUNT(*) FROM node_agents`),
"templates": count(`SELECT COUNT(*) FROM service_templates`),
"user_templates": count(`SELECT COUNT(*) FROM service_templates WHERE is_official = false`),
"cron_jobs": count(`SELECT COUNT(*) FROM cron_jobs`),
"deployments_total": count(`SELECT COUNT(*) FROM deployments`),
},
"pending_projects": pending,
})
}
func handleAdminListUsers(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
rows, err := db.QueryContext(c.Request.Context(), `
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, COALESCE(to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), '')
FROM users
ORDER BY created_at ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list users"})
return
}
defer rows.Close()
users := make([]adminUserRow, 0)
for rows.Next() {
var u adminUserRow
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.AvatarURL, &u.IsAdmin, &u.CreatedAt); err != nil {
continue
}
users = append(users, u)
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
func handleAdminSetUserAdmin(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
targetID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(targetID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
var req struct {
IsAdmin bool `json:"is_admin"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
callerID := optionalUserID(c)
if callerID == targetID && !req.IsAdmin {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot revoke your own admin access"})
return
}
result, err := db.ExecContext(c.Request.Context(),
`UPDATE users SET is_admin = $1, updated_at = NOW() WHERE id = $2`,
req.IsAdmin, targetID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}
if n, _ := result.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
LogAudit(callerID, "user", targetID, "set_admin", map[string]interface{}{"is_admin": req.IsAdmin})
c.JSON(http.StatusOK, gin.H{"message": "User updated"})
}
func handleAdminSetProjectApproval(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
projectID, ok := parseProjectIDParam(c)
if !ok {
return
}
var req struct {
IsApproved bool `json:"is_approved"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rows, err := queries.SetProjectApproved(c.Request.Context(), sqlcdb.SetProjectApprovedParams{
IsApproved: req.IsApproved,
ProjectID: projectID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
action := "unapprove"
if req.IsApproved {
action = "approve"
}
LogAudit(optionalUserID(c), "project", projectID.String(), action, nil)
row, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: optionalUserUUID(c),
IsAdmin: true,
})
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Project updated"})
return
}
c.JSON(http.StatusOK, gin.H{"project": mapSQLCProject(row)})
}
+4 -2
View File
@@ -1217,8 +1217,10 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
}
}
// SetupRoutes registers authenticated dashboard/control agent routes.
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
// SetupAdminRoutes registers dashboard/control agent routes. Command
// history and container details can leak sensitive output, so these stay
// admin-only rather than joining the public read group.
func (h *NodeAgentHandler) SetupAdminRoutes(router *gin.RouterGroup) {
tokens := router.Group("/agent-tokens")
{
tokens.POST("", h.CreateAgentToken)
+107
View File
@@ -3,7 +3,10 @@ package api
import (
"net/http"
"containr/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
@@ -21,3 +24,107 @@ func requireAuthenticatedUserID(c *gin.Context) (string, bool) {
return userID, true
}
// optionalUserID returns the resolved user id or "" for anonymous callers on
// OptionalAuth routes. Never writes a response.
func optionalUserID(c *gin.Context) string {
userIDValue, exists := c.Get("user_id")
if !exists {
return ""
}
userID, _ := userIDValue.(string)
return userID
}
// optionalUserUUID is the uuid variant of optionalUserID; anonymous callers
// get uuid.Nil, which matches no owner_id and therefore only sees approved
// public resources.
func optionalUserUUID(c *gin.Context) uuid.UUID {
userID := optionalUserID(c)
parsed, err := uuid.Parse(userID)
if err != nil {
return uuid.Nil
}
return parsed
}
// contextIsAdmin reports whether the resolved caller is a platform admin.
func contextIsAdmin(c *gin.Context) bool {
return c.GetBool("is_admin")
}
// projectReadAccess reports (exists, allowedToRead). Approved projects are
// public; unapproved ones are visible to the owner, project members, and
// platform admins only.
func projectReadAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var isApproved bool
var ownerID string
err := db.QueryRow(
`SELECT is_approved, owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&isApproved, &ownerID)
if err != nil {
return false, false
}
if isApproved || contextIsAdmin(c) {
return true, true
}
userID := optionalUserID(c)
if userID == "" {
return true, false
}
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
return true, true
}
return true, false
}
// projectManageAccess reports (exists, allowedToOperate). Unlike
// projectReadAccess, approval does NOT grant access — only the owner,
// project members, and platform admins may reach secret-adjacent data
// such as variables and runtime logs.
func projectManageAccess(c *gin.Context, db *database.DB, projectID uuid.UUID) (bool, bool) {
var ownerID string
err := db.QueryRow(
`SELECT owner_id::text FROM projects WHERE id = $1`,
projectID,
).Scan(&ownerID)
if err != nil {
return false, false
}
if contextIsAdmin(c) {
return true, true
}
userID := optionalUserID(c)
if userID == "" {
return true, false
}
if ownerID == userID {
return true, true
}
var member bool
if err := db.QueryRow(
`SELECT EXISTS(SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)`,
projectID, userID,
).Scan(&member); err == nil && member {
return true, true
}
return true, false
}
// serviceProjectID resolves the project that owns a service.
func serviceProjectID(db *database.DB, serviceID uuid.UUID) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(`SELECT project_id FROM services WHERE id = $1`, serviceID).Scan(&projectID)
if err != nil {
return uuid.Nil, false
}
return projectID, true
}
+42 -29
View File
@@ -65,19 +65,34 @@ type UpdateCronJobRequest struct {
Retention int `json:"retention"`
}
// cronJobProjectID resolves the project a cron job belongs to.
func cronJobProjectID(db *database.DB, jobID string) (uuid.UUID, bool) {
var projectID uuid.UUID
err := db.QueryRow(
`SELECT cj.project_id FROM cron_jobs cj WHERE cj.id = $1`,
jobID,
).Scan(&projectID)
if err != nil {
return uuid.Nil, false
}
return projectID, true
}
func handleGetCronJobs(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
projectID := c.Query("project_id")
serviceID := c.Query("service_id")
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE p.owner_id = $1`
args := []interface{}{userID}
WHERE (p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1))`
args := []interface{}{userID, isAdmin}
if projectID != "" {
args = append(args, projectID)
@@ -130,7 +145,7 @@ func handleCreateCronJob(c *gin.Context) {
req.ServiceID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -184,30 +199,32 @@ func handleCreateCronJob(c *gin.Context) {
func handleGetCronJob(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var job CronJob
var ownerCheck string
err := db.QueryRow(
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at, p.owner_id
`SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone,
cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output,
cj.retention, cj.created_at, cj.updated_at
FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&job.ID, &job.ProjectID, &job.ServiceID, &job.Name, &job.Schedule, &job.Timezone,
&job.Enabled, &job.LastRunAt, &job.NextRunAt, &job.LastStatus, &job.LastOutput,
&job.Retention, &job.CreatedAt, &job.UpdatedAt, &ownerCheck)
&job.Retention, &job.CreatedAt, &job.UpdatedAt)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
projectID, found := cronJobProjectID(db, jobID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
@@ -233,7 +250,7 @@ func handleUpdateCronJob(c *gin.Context) {
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -303,7 +320,7 @@ func handleDeleteCronJob(c *gin.Context) {
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -321,19 +338,15 @@ func handleDeleteCronJob(c *gin.Context) {
func handleGetCronExecutions(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
userID := c.MustGet("user_id").(string)
jobID := c.Param("id")
var ownerCheck string
err := db.QueryRow(
`SELECT p.owner_id FROM cron_jobs cj
JOIN projects p ON cj.project_id = p.id
WHERE cj.id = $1`,
jobID,
).Scan(&ownerCheck)
if err != nil || ownerCheck != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
projectID, found := cronJobProjectID(db, jobID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Cron job not found"})
return
}
@@ -380,7 +393,7 @@ func handleTriggerCronJob(c *gin.Context) {
jobID,
).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck)
if err != nil || ownerCheck != userID {
if err != nil || (ownerCheck != userID && !contextIsAdmin(c)) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
+57 -73
View File
@@ -41,6 +41,7 @@ const (
// DatabaseService represents a managed database service
type DatabaseService struct {
ID string `json:"id" db:"id"`
OwnerID string `json:"-" db:"user_id"`
Name string `json:"name" db:"name"`
Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly
Status string `json:"status" db:"status"` // running, stopped, building, error
@@ -168,13 +169,14 @@ func NewDatabaseHandler(db *sql.DB, dockerClient *docker.Client) *DatabaseHandle
}
}
// GetDatabases returns all database services for a user
// GetDatabases returns database services. The platform is public-read: every
// caller sees the fleet, but connection URLs (which may embed credentials)
// are only exposed to admins.
func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
rows, err := h.queries.ListDatabaseServicesByUser(c.Request.Context(), userID)
userID := optionalUserID(c)
isAdmin := contextIsAdmin(c)
rows, err := h.queries.ListAllDatabaseServices(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch databases"})
return
@@ -182,17 +184,23 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
databases := make([]DatabaseService, 0, len(rows))
for _, row := range rows {
db := mapDatabaseServiceListRow(row)
db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
db.Backups = backupConfig
privileged := isAdmin || (userID != "" && db.OwnerID == userID)
if privileged {
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
db.Backups = backupConfig
} else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
} else {
db.Backups = h.generateMockBackupConfig()
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = ""
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
databases = append(databases, db)
}
@@ -202,15 +210,11 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
// GetDatabase returns a specific database service
func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
userID := optionalUserID(c)
isAdmin := contextIsAdmin(c)
databaseID := c.Param("id")
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
UserID: userID,
})
row, err := h.queries.GetDatabaseServiceByID(c.Request.Context(), databaseID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Database not found"})
@@ -219,21 +223,40 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database"})
return
}
db := mapDatabaseServiceGetRow(row)
db := mapDatabaseServiceRow(row)
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
db.Backups = backupConfig
privileged := isAdmin || (userID != "" && db.OwnerID == userID)
if privileged {
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), db.OwnerID, db); err == nil {
db.Backups = backupConfig
} else {
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
} else {
db.Backups = h.generateMockBackupConfig()
db.Backups = DatabaseBackupConfig{Backups: []DatabaseBackup{}}
db.ConnectionURL = ""
}
db.Settings = h.generateMockSettings()
db.ConnectionURL = h.resolveConnectionURL(db)
c.JSON(http.StatusOK, db)
}
// effectiveUserID returns the user scope for row-level queries. Admins act
// on behalf of the row's owner so management works across accounts.
func (h *DatabaseHandler) effectiveUserID(c *gin.Context, userID, databaseID string) string {
if !contextIsAdmin(c) || databaseID == "" {
return userID
}
owner, err := h.queries.GetDatabaseServiceOwnerID(c.Request.Context(), databaseID)
if err == nil && owner != "" {
return owner
}
return userID
}
// CreateDatabase creates a new database service
func (h *DatabaseHandler) CreateDatabase(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
@@ -357,6 +380,7 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -440,6 +464,7 @@ func (h *DatabaseHandler) DeleteDatabase(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
exists, err := h.queries.DatabaseServiceExistsByIDAndUser(c.Request.Context(), sqlcdb.DatabaseServiceExistsByIDAndUserParams{
ID: databaseID,
@@ -481,6 +506,7 @@ func (h *DatabaseHandler) PerformDatabaseAction(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseActionRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -574,6 +600,7 @@ func (h *DatabaseHandler) CreateBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
row, err := h.queries.GetDatabaseServiceByIDAndUser(c.Request.Context(), sqlcdb.GetDatabaseServiceByIDAndUserParams{
ID: databaseID,
@@ -623,6 +650,7 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
var req DatabaseRestoreRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -684,6 +712,7 @@ func (h *DatabaseHandler) DownloadBackup(c *gin.Context) {
return
}
databaseID := c.Param("id")
userID = h.effectiveUserID(c, userID, databaseID)
backupID := c.Param("bid")
backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{
@@ -904,35 +933,6 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string
}, nil
}
func (h *DatabaseHandler) generateMockBackupConfig() DatabaseBackupConfig {
return DatabaseBackupConfig{
Enabled: true,
LastBackup: &time.Time{},
Retention: 30,
NextBackup: &time.Time{},
Backups: []DatabaseBackup{
{
ID: "backup_1",
CreatedAt: time.Now().Add(-6 * time.Hour),
Size: "245 MB",
Status: "completed",
},
{
ID: "backup_2",
CreatedAt: time.Now().Add(-24 * time.Hour),
Size: "238 MB",
Status: "completed",
},
{
ID: "backup_3",
CreatedAt: time.Now().Add(-48 * time.Hour),
Size: "241 MB",
Status: "completed",
},
},
}
}
func (h *DatabaseHandler) generateMockSettings() DatabaseSettings {
return DatabaseSettings{
MaxConnections: 100,
@@ -1038,26 +1038,10 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
}
}
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
return DatabaseService{
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
}
}
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
func mapDatabaseServiceRow(row sqlcdb.DatabaseService) DatabaseService {
return DatabaseService{
ID: row.ID,
OwnerID: row.UserID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
+22 -44
View File
@@ -64,36 +64,22 @@ func handleGetDeployments(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
rows, err := db.(*database.DB).Query(
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
`SELECT id, service_id, commit_hash, status, image_name, image_tag,
build_log, runtime_log, error, started_at, completed_at, created_at, updated_at
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
FROM deployments
WHERE service_id = $1
ORDER BY created_at DESC
LIMIT 50`,
serviceID,
)
@@ -143,11 +129,8 @@ func handleGetRecentDeployments(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
limit := 10
if v := c.Query("limit"); v != "" {
@@ -162,10 +145,11 @@ func handleGetRecentDeployments(c *gin.Context) {
FROM deployments d
JOIN services s ON s.id = d.service_id
JOIN projects p ON p.id = s.project_id
WHERE p.owner_id = $1
WHERE p.is_approved OR p.owner_id = $1 OR $2::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)
ORDER BY d.created_at DESC
LIMIT $2`,
userID.(string), limit,
LIMIT $3`,
userID, isAdmin, limit,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"})
@@ -557,18 +541,12 @@ func handleGetDeployment(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var d DeploymentModel
var ownerCheck string
var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.owner_id
`SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag,
d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at,
d.created_at, d.updated_at, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
@@ -577,7 +555,7 @@ func handleGetDeployment(c *gin.Context) {
).Scan(
&d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag,
&d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt,
&d.CreatedAt, &d.UpdatedAt, &ownerCheck,
&d.CreatedAt, &d.UpdatedAt, &projectID,
)
if err != nil {
@@ -585,8 +563,8 @@ func handleGetDeployment(c *gin.Context) {
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
@@ -639,7 +617,7 @@ func handleRollbackDeployment(c *gin.Context) {
return
}
if ownerCheck != userID.(string) {
if ownerCheck != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
+12 -9
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"containr/internal/database"
"containr/internal/docker"
@@ -17,7 +18,6 @@ import (
// commands run as the container's default user with a 30s ceiling.
func handleExecInService(c *gin.Context) {
serviceID := c.Param("id")
userID := c.MustGet("user_id").(string)
db := c.MustGet("db").(*database.DB)
var req struct {
@@ -33,17 +33,20 @@ func handleExecInService(c *gin.Context) {
return
}
var ownerID string
err := db.QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON p.id = s.project_id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerID)
if err != nil || ownerID != userID {
serviceUUID, err := uuid.Parse(serviceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return
}
projectID, found := serviceProjectID(db, serviceUUID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if _, allowed := projectManageAccess(c, db, projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
dockerClient, _ := c.Get("docker_client")
dockerCli, _ := dockerClient.(*docker.Client)
+27 -12
View File
@@ -25,43 +25,58 @@ func NewHAManager(haManager *ha.HighAvailabilityManager) *HAManager {
}
}
// RegisterRoutes registers HA routes
func (h *HAManager) RegisterRoutes(router *gin.RouterGroup) {
// RegisterReadRoutes registers public HA read endpoints.
func (h *HAManager) RegisterReadRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.GET("/status", h.GetHAStatus)
// Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
// Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
// Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
}
}
// RegisterAdminRoutes registers HA mutation endpoints.
func (h *HAManager) RegisterAdminRoutes(router *gin.RouterGroup) {
ha := router.Group("/ha")
{
ha.POST("/enable", h.EnableHA)
ha.POST("/disable", h.DisableHA)
ha.POST("/failover", h.TriggerFailover)
// Failover policies
ha.GET("/failover/policies", h.GetFailoverPolicies)
ha.POST("/failover/policies", h.SetFailoverPolicy)
ha.GET("/failover/policies/:serviceId", h.GetFailoverPolicy)
ha.PUT("/failover/policies/:serviceId", h.UpdateFailoverPolicy)
ha.DELETE("/failover/policies/:serviceId", h.DeleteFailoverPolicy)
// Health checks
ha.GET("/health/checks", h.GetHealthChecks)
ha.POST("/health/checks", h.AddHealthCheck)
ha.GET("/health/checks/:checkId", h.GetHealthCheck)
ha.PUT("/health/checks/:checkId", h.UpdateHealthCheck)
ha.DELETE("/health/checks/:checkId", h.DeleteHealthCheck)
ha.GET("/health/results", h.GetHealthResults)
// Alerts
ha.GET("/alerts/rules", h.GetAlertRules)
ha.POST("/alerts/rules", h.AddAlertRule)
ha.GET("/alerts/rules/:ruleId", h.GetAlertRule)
ha.PUT("/alerts/rules/:ruleId", h.UpdateAlertRule)
ha.DELETE("/alerts/rules/:ruleId", h.DeleteAlertRule)
ha.GET("/alerts/active", h.GetActiveAlerts)
ha.POST("/alerts/:alertId/resolve", h.ResolveAlert)
// Notifiers
ha.GET("/notifiers", h.GetNotifiers)
ha.POST("/notifiers", h.AddNotifier)
ha.GET("/notifiers/:notifierId", h.GetNotifier)
ha.DELETE("/notifiers/:notifierId", h.DeleteNotifier)
}
}
+9 -29
View File
@@ -37,27 +37,13 @@ func handleGetLogs(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
@@ -209,30 +195,24 @@ func handleGetDeploymentLogs(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var buildLog, runtimeLog string
var ownerCheck string
var projectID uuid.UUID
err = db.(*database.DB).QueryRow(
`SELECT d.build_log, d.runtime_log, p.owner_id
`SELECT d.build_log, d.runtime_log, p.id
FROM deployments d
JOIN services s ON d.service_id = s.id
JOIN projects p ON s.project_id = p.id
WHERE d.id = $1`,
deploymentID,
).Scan(&buildLog, &runtimeLog, &ownerCheck)
).Scan(&buildLog, &runtimeLog, &projectID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
if ownerCheck != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"})
return
}
+6 -13
View File
@@ -60,26 +60,19 @@ func handleGetServiceMetrics(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerID string
var projectID uuid.UUID
var status string
err = db.QueryRow(
`SELECT p.owner_id, COALESCE(s.status, '')
FROM services s JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
`SELECT s.project_id, COALESCE(s.status, '')
FROM services s WHERE s.id = $1`,
serviceID,
).Scan(&ownerID, &status)
).Scan(&projectID, &status)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
@@ -69,31 +69,13 @@ func handleGetPreviewEnvironments(c *gin.Context) {
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
// Approved projects are public; unapproved ones need owner/member/admin.
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
if !projectExists || !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Get preview environments for the project with service info
rows, err := db.(*database.DB).Query(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
@@ -193,7 +175,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -332,27 +314,22 @@ func handleGetPreviewEnvironment(c *gin.Context) {
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get preview environment with project ownership check
// Public when the parent project is approved; otherwise owner/member/admin.
userID := optionalUserUUID(c)
var env PreviewEnvironment
var serviceID sql.NullString
var serviceName sql.NullString
var serviceType sql.NullString
err = db.(*database.DB).QueryRow(
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
s.id as service_id, s.name as service_name, s.type as service_type
FROM preview_environments pe
LEFT JOIN services s ON pe.service_id = s.id
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
@@ -414,8 +391,8 @@ func handleUpdatePreviewEnvironment(c *gin.Context) {
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName,
&existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL,
@@ -498,7 +475,7 @@ func handleDeletePreviewEnvironment(c *gin.Context) {
}
// Check if user owns the project
if projectOwnerID != userID.(string) {
if projectOwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -568,8 +545,8 @@ func handlePromotePreviewEnvironment(c *gin.Context) {
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE pe.id = $1 AND p.owner_id = $2`,
envID, userID,
WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
envID, userID, contextIsAdmin(c),
).Scan(
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status,
)
@@ -665,8 +642,8 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) {
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment
FROM preview_environments pe
JOIN projects p ON pe.project_id = p.id
WHERE p.owner_id = $1 AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID,
WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`,
userID, contextIsAdmin(c),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"})
@@ -6,8 +6,6 @@ import (
"net/http/httptest"
"testing"
"containr/internal/database"
"github.com/gin-gonic/gin"
)
@@ -89,7 +87,7 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) {
}
}
func TestRecentDeploymentsRequireAuthentication(t *testing.T) {
func TestRecentDeploymentsAllowAnonymousRead(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/v1/deployments", handleGetRecentDeployments)
@@ -98,20 +96,9 @@ func TestRecentDeploymentsRequireAuthentication(t *testing.T) {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// No db in context → dependency failure surfaces before auth.
// Deployments are publicly readable; with no db in context the handler
// fails at the dependency boundary, never at an auth check.
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code)
}
// With db present but no user context → 401.
router2 := gin.New()
router2.GET("/api/v1/deployments", func(c *gin.Context) {
c.Set("db", &database.DB{})
handleGetRecentDeployments(c)
})
rec2 := httptest.NewRecorder()
router2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil))
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec2.Code)
}
}
+16 -14
View File
@@ -21,6 +21,7 @@ type Project struct {
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
IsApproved bool `json:"is_approved"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -48,10 +49,8 @@ type UpdateProjectRequest struct {
}
func handleGetProjects(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
userID := optionalUserUUID(c)
isAdmin := contextIsAdmin(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
@@ -75,6 +74,7 @@ func handleGetProjects(c *gin.Context) {
rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{
UserID: userID,
IsAdmin: isAdmin,
Search: searchParam,
LimitCount: int32(limit),
OffsetCount: int32(offset),
@@ -90,8 +90,9 @@ func handleGetProjects(c *gin.Context) {
}
total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{
UserID: userID,
Search: searchParam,
UserID: userID,
IsAdmin: isAdmin,
Search: searchParam,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"})
@@ -171,10 +172,7 @@ func handleCreateProject(c *gin.Context) {
}
func handleGetProject(c *gin.Context) {
userID, ok := requireAuthenticatedUserUUID(c)
if !ok {
return
}
userID := optionalUserUUID(c)
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
@@ -187,6 +185,7 @@ func handleGetProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -217,6 +216,7 @@ func handleUpdateProject(c *gin.Context) {
role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
@@ -270,6 +270,7 @@ func handleUpdateProject(c *gin.Context) {
projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{
ProjectID: projectID,
UserID: userID,
IsAdmin: contextIsAdmin(c),
})
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
@@ -307,7 +308,7 @@ func handleDeleteProject(c *gin.Context) {
return
}
if ownerID != userID {
if ownerID != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"})
return
}
@@ -361,7 +362,7 @@ func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) {
func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats {
result := ProjectWithStats{
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.CreatedAt, row.UpdatedAt),
Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt),
Stats: ProjectStats{
ServiceCount: int(row.ServiceCount),
DeploymentCount: int(row.DeploymentCount),
@@ -373,15 +374,16 @@ func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWi
}
func mapSQLCProject(project sqlcdb.Project) Project {
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.CreatedAt, project.UpdatedAt)
return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt)
}
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, createdAt, updatedAt sql.NullTime) Project {
func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project {
return Project{
ID: id.String(),
Name: name,
Description: nullableStringValue(description),
OwnerID: ownerID.String(),
IsApproved: isApproved,
CreatedAt: nullableTimestampString(createdAt),
UpdatedAt: nullableTimestampString(updatedAt),
}
+157 -135
View File
@@ -96,6 +96,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Next()
})
// Seed the official service template catalog (idempotent upserts).
go SeedOfficialTemplates(context.Background(), db)
go func() {
if err := haManager.Start(context.Background()); err != nil {
log.Printf("HA manager exited: %v", err)
@@ -136,7 +139,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
if redis == nil {
redisStatus = "unhealthy"
checks["redis"] = redisStatus
checks["redis"] = "unhealthy"
checks["redisError"] = "redis client not initialized"
overallStatus = "degraded"
statusCode = http.StatusServiceUnavailable
@@ -177,163 +180,182 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/register", handleRegister)
}
// Protected routes (authentication required)
protected := v1.Group("/")
protected.Use(middleware.Auth(cfg.JWTSecret))
// Read routes — public browsing. OptionalAuth resolves a session when
// present so handlers can widen visibility for owners/admins; anonymous
// callers see approved resources only.
read := v1.Group("/")
read.Use(middleware.OptionalAuth(cfg.JWTSecret))
{
// User routes
protected.GET("/user/profile", handleGetProfile)
protected.PUT("/user/profile", handleUpdateProfile)
protected.POST("/users", handleCreateUser)
read.GET("/projects", handleGetProjects)
read.GET("/projects/:id", handleGetProject)
read.GET("/projects/:id/services", handleGetServices)
read.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
read.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
read.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
read.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
// Platform settings (admin-gated inside handlers)
protected.GET("/settings", handleGetSettings)
protected.PUT("/settings", handleUpdateSettings)
read.GET("/services/:id", handleGetService)
read.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/services/:id/runtime", handleGetServiceRuntime)
read.GET("/services/:id/deployments", handleGetDeployments)
read.GET("/deployments", handleGetRecentDeployments)
read.GET("/deployments/:id", handleGetDeployment)
// Project routes
protected.GET("/projects", handleGetProjects)
protected.POST("/projects", handleCreateProject)
read.GET("/databases", databaseHandler.GetDatabases)
read.GET("/databases/:id", databaseHandler.GetDatabase)
// Service routes (nested under projects)
protected.GET("/projects/:id/services", handleGetServices)
protected.POST("/projects/:id/services", handleCreateService)
read.GET("/templates", handleGetTemplates)
read.GET("/templates/:id", handleGetTemplate)
// Generic project routes
protected.GET("/projects/:id", handleGetProject)
protected.PUT("/projects/:id", handleUpdateProject)
protected.DELETE("/projects/:id", handleDeleteProject)
read.GET("/cron-jobs", handleGetCronJobs)
read.GET("/cron-jobs/:id", handleGetCronJob)
read.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
// Service routes
protected.GET("/services/:id", handleGetService)
protected.PUT("/services/:id", handleUpdateService)
protected.DELETE("/services/:id", handleDeleteService)
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
read.GET("/security/scans/:id", securityHandler.GetSecurityScan)
read.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
read.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
// Runtime lifecycle (Railway-style)
protected.GET("/services/:id/runtime", handleGetServiceRuntime)
protected.POST("/services/:id/start", handleServiceStart)
protected.POST("/services/:id/stop", handleServiceStop)
protected.POST("/services/:id/restart", handleServiceRestart)
protected.POST("/services/:id/redeploy", handleServiceRedeploy)
read.GET("/preview-environments/:id", handleGetPreviewEnvironment)
// Deployment routes
protected.GET("/services/:id/deployments", handleGetDeployments)
protected.POST("/services/:id/deployments", handleCreateDeployment)
protected.GET("/deployments", handleGetRecentDeployments)
protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
read.GET("/system/upgrade/status", handleGetUpgradeStatus)
read.GET("/system/host", handleGetHostMonitoring)
// Environment variables routes
protected.GET("/services/:id/variables", handleGetVariables)
protected.PUT("/services/:id/variables", handleUpdateVariables)
// HA + scaling read endpoints (status, policies, health, alerts)
haAPIManager.RegisterReadRoutes(read)
scalingHandler.RegisterReadRoutes(read)
}
// Logs routes
protected.GET("/services/:id/logs", handleGetLogs)
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// Authenticated routes — a valid session is required and handlers
// enforce ownership; platform admins additionally pass every check.
authed := v1.Group("/")
authed.Use(middleware.Auth(cfg.JWTSecret))
{
authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile)
// One-off exec console (docker exec, 30s ceiling)
protected.POST("/services/:id/exec", handleExecInService)
// Git integration routes
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
protected.GET("/git/providers", handleGetGitProviders)
protected.POST("/git/providers", handleCreateGitProvider)
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
protected.POST("/git/repositories/connect", handleConnectGitRepository)
protected.GET("/git/repositories", handleGetConnectedRepositories)
protected.POST("/git/webhooks", handleCreateWebhook)
// Build routes
protected.POST("/builds", buildHandler.StartBuild)
protected.GET("/builds", buildHandler.ListBuilds)
protected.GET("/builds/:id", buildHandler.GetBuildStatus)
protected.POST("/builds/:id/cancel", buildHandler.CancelBuild)
protected.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
protected.GET("/builds/detect", buildHandler.DetectBuildType)
// System routes
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
protected.GET("/system/host", handleGetHostMonitoring)
// Scaling routes
scalingHandler.RegisterRoutes(protected)
haAPIManager.RegisterRoutes(protected)
// Database routes
protected.GET("/databases", databaseHandler.GetDatabases)
protected.POST("/databases", databaseHandler.CreateDatabase)
protected.GET("/databases/:id", databaseHandler.GetDatabase)
protected.PUT("/databases/:id", databaseHandler.UpdateDatabase)
protected.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
protected.POST("/databases/:id/backup", databaseHandler.CreateBackup)
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
// Notification routes
protected.GET("/notifications", handleListNotifications)
protected.POST("/notifications/:id/read", handleMarkNotificationRead)
protected.POST("/notifications/read-all", handleMarkAllNotificationsRead)
// Node Agent routes
agentHandler.SetupRoutes(protected)
// Preview Environments routes
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
protected.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
protected.GET("/preview-environments/:id", handleGetPreviewEnvironment)
protected.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
protected.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
protected.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
protected.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// Security routes
protected.POST("/security/scans", securityHandler.StartSecurityScan)
protected.GET("/security/scans/:id", securityHandler.GetSecurityScan)
protected.GET("/projects/:id/security/history", securityHandler.GetProjectSecurityHistory)
protected.GET("/projects/:id/vulnerabilities", securityHandler.GetVulnerabilities)
protected.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
protected.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
protected.GET("/security/compliance/reports/:id", securityHandler.GetComplianceReport)
protected.GET("/security/compliance/frameworks", securityHandler.GetComplianceFrameworks)
protected.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
protected.GET("/projects/:id/security/metrics", securityHandler.GetSecurityMetrics)
protected.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
// WebSocket endpoint
protected.GET("/ws", handleWebSocket)
authed.GET("/ws", handleWebSocket)
// Templates routes
protected.GET("/templates", handleGetTemplates)
protected.GET("/templates/:id", handleGetTemplate)
protected.POST("/templates/:id/deploy", handleCreateFromTemplate)
// Owned-resource mutations — handlers scope to the caller and
// admit admins. New projects land unapproved until an admin
// publishes them.
authed.POST("/projects", handleCreateProject)
authed.POST("/projects/:id/services", handleCreateService)
authed.PUT("/projects/:id", handleUpdateProject)
authed.DELETE("/projects/:id", handleDeleteProject)
// Cron Jobs routes
protected.GET("/cron-jobs", handleGetCronJobs)
protected.POST("/cron-jobs", handleCreateCronJob)
protected.GET("/cron-jobs/:id", handleGetCronJob)
protected.PUT("/cron-jobs/:id", handleUpdateCronJob)
protected.DELETE("/cron-jobs/:id", handleDeleteCronJob)
protected.GET("/cron-jobs/:id/executions", handleGetCronExecutions)
protected.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
authed.PUT("/services/:id", handleUpdateService)
authed.DELETE("/services/:id", handleDeleteService)
authed.POST("/services/:id/start", handleServiceStart)
authed.POST("/services/:id/stop", handleServiceStop)
authed.POST("/services/:id/restart", handleServiceRestart)
authed.POST("/services/:id/redeploy", handleServiceRedeploy)
authed.POST("/services/:id/deployments", handleCreateDeployment)
authed.POST("/deployments/:id/rollback", handleRollbackDeployment)
// Audit Logs routes
protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
// Environment variables — secrets, owner/admin only.
authed.GET("/services/:id/variables", handleGetVariables)
authed.PUT("/services/:id/variables", handleUpdateVariables)
// Runtime logs — container stdout routinely echoes secrets,
// so these stay behind authentication like build logs.
authed.GET("/services/:id/logs", handleGetLogs)
authed.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// One-off exec console (docker exec, 30s ceiling)
authed.POST("/services/:id/exec", handleExecInService)
// Git integration — providers carry per-user credentials.
authed.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
authed.POST("/git/github-app/connect", handleConnectGitHubApp)
authed.GET("/git/providers", handleGetGitProviders)
authed.POST("/git/providers", handleCreateGitProvider)
authed.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
authed.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
authed.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
authed.POST("/git/repositories/connect", handleConnectGitRepository)
authed.GET("/git/repositories", handleGetConnectedRepositories)
authed.POST("/git/webhooks", handleCreateWebhook)
// Builds have no per-user scoping and logs may carry secrets —
// keep every build endpoint behind authentication.
authed.POST("/builds", buildHandler.StartBuild)
authed.GET("/builds", buildHandler.ListBuilds)
authed.GET("/builds/:id", buildHandler.GetBuildStatus)
authed.GET("/builds/:id/logs", buildHandler.GetBuildLogs)
authed.GET("/builds/detect", buildHandler.DetectBuildType)
authed.POST("/builds/:id/cancel", buildHandler.CancelBuild)
authed.POST("/builds/plan", buildHandler.GetBuildPlan)
authed.POST("/databases", databaseHandler.CreateDatabase)
authed.PUT("/databases/:id", databaseHandler.UpdateDatabase)
authed.DELETE("/databases/:id", databaseHandler.DeleteDatabase)
authed.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction)
authed.POST("/databases/:id/backup", databaseHandler.CreateBackup)
authed.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
authed.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup)
authed.POST("/projects/:id/preview-environments", handleCreatePreviewEnvironment)
authed.PUT("/preview-environments/:id", handleUpdatePreviewEnvironment)
authed.DELETE("/preview-environments/:id", handleDeletePreviewEnvironment)
authed.POST("/preview-environments/:id/promote", handlePromotePreviewEnvironment)
authed.POST("/security/scans", securityHandler.StartSecurityScan)
authed.PUT("/vulnerabilities/:id", securityHandler.UpdateVulnerability)
authed.POST("/security/compliance/assess", securityHandler.StartComplianceAssessment)
authed.POST("/security/compliance/gdpr/init", securityHandler.InitializeGDPRFramework)
authed.GET("/projects/:id/security/audit-logs", securityHandler.GetAuditLogs)
// User templates — ownership enforced inside the handlers.
authed.POST("/templates", handleCreateTemplate)
authed.PUT("/templates/:id", handleUpdateTemplate)
authed.DELETE("/templates/:id", handleDeleteTemplate)
authed.POST("/templates/:id/deploy", handleCreateFromTemplate)
authed.POST("/cron-jobs", handleCreateCronJob)
authed.PUT("/cron-jobs/:id", handleUpdateCronJob)
authed.DELETE("/cron-jobs/:id", handleDeleteCronJob)
authed.POST("/cron-jobs/:id/trigger", handleTriggerCronJob)
}
// Admin routes — platform-wide controls and cross-user management.
admin := v1.Group("/")
admin.Use(middleware.Auth(cfg.JWTSecret))
admin.Use(middleware.RequireAdmin())
{
admin.GET("/admin/overview", handleAdminOverview)
admin.GET("/admin/users", handleAdminListUsers)
admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin)
admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval)
admin.POST("/users", handleCreateUser)
admin.GET("/settings", handleGetSettings)
admin.PUT("/settings", handleUpdateSettings)
admin.POST("/system/upgrade/pull", handlePullUpgradeImage)
admin.POST("/preview-environments/cleanup-expired", handleCleanupExpiredPreviewEnvironments)
// HA + scaling + agent mutations — platform-level controls.
haAPIManager.RegisterAdminRoutes(admin)
scalingHandler.RegisterAdminRoutes(admin)
agentHandler.SetupAdminRoutes(admin)
admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
// API Gateway routes (merged APwhy) - namespaced to avoid
// colliding with Containr's own /services paths.
gateway := protected.Group("/gateway")
gateway := admin.Group("/gateway")
{
gateway.GET("/services", handleAPwhyServicesList)
gateway.POST("/services", handleAPwhyServicesCreate)
gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
gateway.GET("/services/:id", handleAPwhyServiceValidate)
gateway.GET("/keys", handleAPwhyKeysList)
gateway.POST("/keys", handleAPwhyKeysCreate)
+44 -1
View File
@@ -225,13 +225,56 @@ func loadOwnedService(c *gin.Context, db *database.DB) (Service, bool) {
return service, true
}
// loadReadableService loads a service when the caller may read its project:
// approved projects are public, unapproved ones need owner/member/admin.
func loadReadableService(c *gin.Context, db *database.DB) (Service, bool) {
serviceID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
return Service{}, false
}
var service Service
var projectID uuid.UUID
err = db.QueryRow(
`SELECT s.id, s.project_id, s.name,
COALESCE(s.type, s.service_type, ''), COALESCE(s.status, ''),
COALESCE(s.image, s.image_name, ''), COALESCE(s.command, s.start_command, ''),
COALESCE(s.environment, ''), COALESCE(s.git_repo, s.source_url, ''),
COALESCE(s.git_branch, ''), COALESCE(s.build_path, ''),
COALESCE(s.cpu, ''), COALESCE(s.memory, ''),
COALESCE(s.replicas, 1), COALESCE(s.port, 0),
COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''),
COALESCE(s.restart_policy, 'unless-stopped'),
s.created_at, s.updated_at, s.project_id
FROM services s
WHERE s.id = $1`,
serviceID,
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
&service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory,
&service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath,
&service.RestartPolicy, &service.CreatedAt, &service.UpdatedAt, &projectID,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
if _, allowed := projectReadAccess(c, db, projectID); !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return Service{}, false
}
return service, true
}
// handleServiceRuntime returns live container state for a service.
func handleGetServiceRuntime(c *gin.Context) {
engine, db, ok := getRuntimeEngine(c)
if !ok {
return
}
service, ok := loadOwnedService(c, db)
service, ok := loadReadableService(c, db)
if !ok {
return
}
+17 -9
View File
@@ -24,31 +24,39 @@ func NewScalingHandler(autoScaler *scaling.AutoScaler) *ScalingHandler {
}
}
// RegisterRoutes registers scaling routes
func (h *ScalingHandler) RegisterRoutes(router *gin.RouterGroup) {
// RegisterReadRoutes registers public scaling read endpoints.
func (h *ScalingHandler) RegisterReadRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.GET("/policies", h.GetScalingPolicies)
scaling.POST("/policies", h.SetScalingPolicy)
scaling.GET("/policies/:serviceId", h.GetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.GET("/services", h.GetServiceStates)
scaling.GET("/services/:serviceId", h.GetServiceState)
scaling.GET("/services/:serviceId/history", h.GetScalingHistory)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.GET("/status", h.GetScalingStatus)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
scaling.GET("/metrics", h.GetScalingMetrics)
scaling.GET("/events", h.GetScalingEvents)
}
}
// RegisterAdminRoutes registers scaling mutation endpoints.
func (h *ScalingHandler) RegisterAdminRoutes(router *gin.RouterGroup) {
scaling := router.Group("/scaling")
{
scaling.POST("/policies", h.SetScalingPolicy)
scaling.PUT("/policies/:serviceId", h.UpdateScalingPolicy)
scaling.DELETE("/policies/:serviceId", h.DeleteScalingPolicy)
scaling.POST("/services/:serviceId/scale", h.ManualScale)
scaling.POST("/enable", h.EnableAutoScaler)
scaling.POST("/disable", h.DisableAutoScaler)
}
}
// GetScalingPolicies returns all scaling policies
func (h *ScalingHandler) GetScalingPolicies(c *gin.Context) {
states := h.autoScaler.GetAllServiceStates()
+3 -1
View File
@@ -49,7 +49,9 @@ func setupScalingTestRouter(t *testing.T) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewScalingHandler(autoScaler)
handler.RegisterRoutes(router.Group("/api/v1"))
group := router.Group("/api/v1")
handler.RegisterReadRoutes(group)
handler.RegisterAdminRoutes(group)
return router
}
+5 -12
View File
@@ -589,16 +589,7 @@ func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
}
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
userIDValue, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return "", false
}
userID, ok := userIDValue.(string)
if !ok || userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return "", false
}
userID := optionalUserID(c)
if _, err := uuid.Parse(projectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
@@ -611,12 +602,14 @@ func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string
SELECT 1
FROM projects p
WHERE p.id = $1
AND (p.owner_id = $2 OR EXISTS (
AND (p.is_approved
OR $3::bool
OR p.owner_id = $2 OR EXISTS (
SELECT 1 FROM project_members pm
WHERE pm.project_id = p.id AND pm.user_id = $2
))
)`,
projectID, userID,
projectID, optionalUserUUID(c), contextIsAdmin(c),
).Scan(&hasAccess)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
+14 -32
View File
@@ -91,28 +91,14 @@ func handleGetServices(c *gin.Context) {
return
}
// Check if project exists and user has access
var project Project
err = db.(*database.DB).QueryRow(
"SELECT id, name, owner_id FROM projects WHERE id = $1",
projectID,
).Scan(&project.ID, &project.Name, &project.OwnerID)
if err != nil {
// Approved projects are public; unapproved ones need owner/member/admin.
projectExists, allowed := projectReadAccess(c, db.(*database.DB), projectID)
if !projectExists {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
// Get user ID from JWT token (set by auth middleware)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
if !allowed {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
@@ -217,7 +203,7 @@ func handleCreateService(c *gin.Context) {
}
// Check if user owns the project
if project.OwnerID != userID.(string) {
if project.OwnerID != userID.(string) && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -344,14 +330,9 @@ func handleGetService(c *gin.Context) {
return
}
// Get user ID from JWT token
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
// Get service with project ownership check
// Get service — public when its project is approved, otherwise
// owner/member/admin only.
userID := optionalUserUUID(c)
var service Service
err = db.(*database.DB).QueryRow(
`SELECT s.id, s.project_id, s.name,
@@ -371,8 +352,9 @@ func handleGetService(c *gin.Context) {
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
WHERE s.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool
OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`,
serviceID, userID, contextIsAdmin(c),
).Scan(
&service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status,
&service.Image, &service.Command, &service.Environment, &service.GitRepo,
@@ -438,8 +420,8 @@ func handleUpdateService(c *gin.Context) {
s.created_at, s.updated_at
FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1 AND p.owner_id = $2`,
serviceID, userID,
WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool)`,
serviceID, userID, contextIsAdmin(c),
).Scan(
&existingService.ID, &existingService.ProjectID, &existingService.Name, &existingService.Type,
&existingService.Status, &existingService.Image, &existingService.Command,
+433 -6
View File
@@ -3,16 +3,19 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
)
type ServiceTemplate struct {
@@ -24,6 +27,7 @@ type ServiceTemplate struct {
Config string `json:"config" db:"config"`
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
@@ -67,12 +71,18 @@ func handleGetTemplates(c *gin.Context) {
ctx := c.Request.Context()
queries := sqlcdb.New(db.DB)
userID := optionalUserUUID(c)
owner := uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil}
var templateRows []sqlcdb.ServiceTemplate
var err error
if category != "" {
templateRows, err = queries.ListServiceTemplatesByCategory(ctx, category)
templateRows, err = queries.ListServiceTemplatesByCategoryForUser(ctx, sqlcdb.ListServiceTemplatesByCategoryForUserParams{
Category: category,
OwnerID: owner,
})
} else {
templateRows, err = queries.ListServiceTemplates(ctx)
templateRows, err = queries.ListServiceTemplatesForUser(ctx, owner)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
@@ -103,7 +113,7 @@ func handleGetTemplate(c *gin.Context) {
}
t := mapSQLCTemplate(row)
if t.ID == "" {
if t.ID == "" || !templateVisibleTo(c, t) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
@@ -169,7 +179,7 @@ func handleCreateFromTemplate(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch project"})
return
}
if ownerID.String() != userID {
if ownerID.String() != userID && !contextIsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -185,8 +195,8 @@ func handleCreateFromTemplate(c *gin.Context) {
}
template := mapSQLCTemplate(templateRow)
if template.ID == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
if template.ID == "" || !templateVisibleTo(c, template) {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
@@ -363,6 +373,243 @@ func handleCreateFromTemplate(c *gin.Context) {
})
}
// templateWriteRequest is the admin payload for creating/updating a user
// template. config must decode into TemplateConfig; variables into
// []TemplateVariable.
type templateWriteRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
Logo string `json:"logo"`
Config json.RawMessage `json:"config" binding:"required"`
Variables json.RawMessage `json:"variables"`
}
var templateCategories = map[string]bool{
"web": true, "frontend": true, "api": true, "database": true,
"worker": true, "cron": true, "app": true, "custom": true,
}
func normalizeTemplateCategory(category string) string {
normalized := strings.ToLower(strings.TrimSpace(category))
if templateCategories[normalized] {
return normalized
}
return "custom"
}
func validateTemplateWrite(c *gin.Context, req *templateWriteRequest) bool {
req.Name = strings.TrimSpace(req.Name)
if len(req.Name) < 2 || len(req.Name) > 120 {
c.JSON(http.StatusBadRequest, gin.H{"error": "name must be 2-120 characters"})
return false
}
req.Category = normalizeTemplateCategory(req.Category)
req.Logo = strings.TrimSpace(req.Logo)
if len(req.Logo) > 500 || (req.Logo != "" && !strings.HasPrefix(req.Logo, "https://") && !strings.HasPrefix(req.Logo, "http://")) {
c.JSON(http.StatusBadRequest, gin.H{"error": "logo must be an http(s) URL up to 500 chars"})
return false
}
if len(req.Config) == 0 || len(req.Config) > 64*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "config is required and must be under 64KB"})
return false
}
var config TemplateConfig
if err := json.Unmarshal(req.Config, &config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "config must be a valid template config object"})
return false
}
if _, err := normalizeTemplateServiceType(config.Type); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return false
}
if len(req.Variables) > 0 {
if len(req.Variables) > 64*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be under 64KB"})
return false
}
var variables []TemplateVariable
if err := json.Unmarshal(req.Variables, &variables); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "variables must be an array"})
return false
}
for _, v := range variables {
if strings.TrimSpace(v.Key) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "every variable needs a key"})
return false
}
}
}
return true
}
func templateVariablesParam(raw json.RawMessage) pqtype.NullRawMessage {
if len(raw) == 0 {
return pqtype.NullRawMessage{}
}
return pqtype.NullRawMessage{RawMessage: raw, Valid: true}
}
func handleCreateTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req templateWriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validateTemplateWrite(c, &req) {
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
id := "usr-" + uuid.NewString()
if err := queries.CreateUserTemplate(c.Request.Context(), sqlcdb.CreateUserTemplateParams{
ID: id,
Name: req.Name,
Description: nullableText(strings.TrimSpace(req.Description)),
Category: req.Category,
Logo: nullableText(req.Logo),
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return
}
LogAudit(userID, "template", id, "create", map[string]interface{}{"name": req.Name})
row, err := queries.GetServiceTemplateByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusCreated, gin.H{"template": gin.H{"id": id, "name": req.Name}})
return
}
c.JSON(http.StatusCreated, gin.H{"template": mapSQLCTemplate(row)})
}
func handleUpdateTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
templateID := c.Param("id")
var req templateWriteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validateTemplateWrite(c, &req) {
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.UpdateUserTemplate(c.Request.Context(), sqlcdb.UpdateUserTemplateParams{
Name: req.Name,
Description: nullableText(strings.TrimSpace(req.Description)),
Category: req.Category,
Logo: nullableText(req.Logo),
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not editable"})
return
}
LogAudit(userID, "template", templateID, "update", map[string]interface{}{"name": req.Name})
row, err := queries.GetServiceTemplateByID(c.Request.Context(), templateID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Template updated"})
return
}
c.JSON(http.StatusOK, gin.H{"template": mapSQLCTemplate(row)})
}
func handleDeleteTemplate(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
templateID := c.Param("id")
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.DeleteUserTemplate(c.Request.Context(), sqlcdb.DeleteUserTemplateParams{
ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
return
}
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found or not deletable"})
return
}
LogAudit(userID, "template", templateID, "delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Template deleted"})
}
// SeedOfficialTemplates upserts the built-in catalog on startup so new
// templates ship with releases without needing a migration per entry.
func SeedOfficialTemplates(ctx context.Context, db *database.DB) {
if db == nil || db.DB == nil {
return
}
queries := sqlcdb.New(db.DB)
for _, t := range SeedTemplates() {
variables := pqtype.NullRawMessage{}
if strings.TrimSpace(t.Variables) != "" {
variables = pqtype.NullRawMessage{RawMessage: json.RawMessage(t.Variables), Valid: true}
}
if err := queries.UpsertServiceTemplate(ctx, sqlcdb.UpsertServiceTemplateParams{
ID: t.ID,
Name: t.Name,
Description: nullableText(t.Description),
Category: t.Category,
Logo: nullableText(t.Logo),
Config: json.RawMessage(t.Config),
Variables: variables,
IsOfficial: sql.NullBool{Bool: true, Valid: true},
}); err != nil {
log.Printf("Failed to seed template %s: %v", t.ID, err)
}
}
}
func SeedTemplates() []ServiceTemplate {
templates := []ServiceTemplate{
{
@@ -505,6 +752,167 @@ func SeedTemplates() []ServiceTemplate {
Variables: `[{"key":"IMAGE","label":"Docker Image","default":"","required":true,"secret":false},{"key":"TAG","label":"Image Tag","default":"latest","required":false,"secret":false}]`,
IsOfficial: true,
},
// Self-hosted applications — runtime doubles as the image reference.
{
ID: "tpl-nextcloud",
Name: "Nextcloud",
Description: "Self-hosted file sync, share and collaboration platform",
Category: "app",
Logo: "https://cdn.simpleicons.org/nextcloud",
Config: `{"type":"web","runtime":"nextcloud:30-apache","port":80,"health_check":"/status.php"}`,
Variables: `[{"key":"NEXTCLOUD_ADMIN_USER","label":"Admin User","default":"admin","required":false,"secret":false},{"key":"NEXTCLOUD_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"NEXTCLOUD_TRUSTED_DOMAINS","label":"Trusted Domains","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-gitea",
Name: "Gitea",
Description: "Lightweight self-hosted Git service",
Category: "app",
Logo: "https://cdn.simpleicons.org/gitea",
Config: `{"type":"web","runtime":"gitea/gitea:1","port":3000,"health_check":"/api/healthz"}`,
Variables: `[{"key":"GITEA__server__DOMAIN","label":"Server Domain","default":"localhost","required":false,"secret":false},{"key":"GITEA__server__ROOT_URL","label":"Root URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-ghost",
Name: "Ghost",
Description: "Independent publishing platform for blogs and newsletters",
Category: "app",
Logo: "https://cdn.simpleicons.org/ghost",
Config: `{"type":"web","runtime":"ghost:5-alpine","port":2368,"health_check":"/ghost/api/admin/site/"}`,
Variables: `[{"key":"url","label":"Public URL","default":"","required":true,"secret":false},{"key":"database__client","label":"Database Client","default":"sqlite3","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-uptime-kuma",
Name: "Uptime Kuma",
Description: "Self-hosted monitoring tool for services and websites",
Category: "app",
Logo: "https://cdn.simpleicons.org/uptimerobot",
Config: `{"type":"web","runtime":"louislam/uptime-kuma:1","port":3001,"health_check":"/"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-vaultwarden",
Name: "Vaultwarden",
Description: "Lightweight Bitwarden-compatible password manager",
Category: "app",
Logo: "https://cdn.simpleicons.org/bitwarden",
Config: `{"type":"web","runtime":"vaultwarden/server:latest","port":80,"health_check":"/alive"}`,
Variables: `[{"key":"ADMIN_TOKEN","label":"Admin Token","default":"","required":false,"secret":true},{"key":"SIGNUPS_ALLOWED","label":"Allow Signups","default":"false","required":false,"secret":false},{"key":"DOMAIN","label":"Domain URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-jellyfin",
Name: "Jellyfin",
Description: "Free software media system for movies, shows and music",
Category: "app",
Logo: "https://cdn.simpleicons.org/jellyfin",
Config: `{"type":"web","runtime":"jellyfin/jellyfin:latest","port":8096,"health_check":"/health"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-n8n",
Name: "n8n",
Description: "Workflow automation tool with hundreds of integrations",
Category: "app",
Logo: "https://cdn.simpleicons.org/n8n",
Config: `{"type":"web","runtime":"n8nio/n8n:latest","port":5678,"health_check":"/healthz"}`,
Variables: `[{"key":"N8N_HOST","label":"Hostname","default":"","required":false,"secret":false},{"key":"N8N_ENCRYPTION_KEY","label":"Encryption Key","default":"","required":true,"secret":true},{"key":"WEBHOOK_URL","label":"Webhook URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-grafana",
Name: "Grafana",
Description: "Observability dashboards for metrics, logs and traces",
Category: "app",
Logo: "https://cdn.simpleicons.org/grafana",
Config: `{"type":"web","runtime":"grafana/grafana:latest","port":3000,"health_check":"/api/health"}`,
Variables: `[{"key":"GF_SECURITY_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-immich",
Name: "Immich",
Description: "Self-hosted photo and video backup",
Category: "app",
Logo: "https://cdn.simpleicons.org/immich",
Config: `{"type":"web","runtime":"ghcr.io/immich-app/immich-server:release","port":2283,"health_check":"/api/server/ping"}`,
Variables: `[{"key":"DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"DB_HOSTNAME","label":"Database Host","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-meilisearch",
Name: "Meilisearch",
Description: "Fast, typo-tolerant search engine API",
Category: "app",
Logo: "https://cdn.simpleicons.org/meilisearch",
Config: `{"type":"web","runtime":"getmeili/meilisearch:v1","port":7700,"health_check":"/health"}`,
Variables: `[{"key":"MEILI_MASTER_KEY","label":"Master Key","default":"","required":true,"secret":true},{"key":"MEILI_ENV","label":"Environment","default":"production","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-plausible",
Name: "Plausible Analytics",
Description: "Lightweight, privacy-friendly web analytics",
Category: "app",
Logo: "https://cdn.simpleicons.org/plausibleanalytics",
Config: `{"type":"web","runtime":"ghcr.io/plausible/community-edition:v2","port":8000,"health_check":"/api/health"}`,
Variables: `[{"key":"BASE_URL","label":"Base URL","default":"","required":true,"secret":false},{"key":"SECRET_KEY_BASE","label":"Secret Key Base","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-minio",
Name: "MinIO",
Description: "S3-compatible object storage",
Category: "app",
Logo: "https://cdn.simpleicons.org/minio",
Config: `{"type":"web","runtime":"minio/minio:latest","start_command":"server /data --console-address \":9001\"","port":9001,"health_check":"/minio/health/live"}`,
Variables: `[{"key":"MINIO_ROOT_USER","label":"Root User","default":"minioadmin","required":true,"secret":false},{"key":"MINIO_ROOT_PASSWORD","label":"Root Password","default":"","required":true,"secret":true}]`,
IsOfficial: true,
},
{
ID: "tpl-appwrite",
Name: "Appwrite",
Description: "Backend-as-a-service for web and mobile apps",
Category: "app",
Logo: "https://cdn.simpleicons.org/appwrite",
Config: `{"type":"web","runtime":"appwrite/appwrite:1.6","port":80,"health_check":"/health"}`,
Variables: `[]`,
IsOfficial: true,
},
{
ID: "tpl-wordpress",
Name: "WordPress",
Description: "Classic CMS powering a large share of the web",
Category: "app",
Logo: "https://cdn.simpleicons.org/wordpress",
Config: `{"type":"web","runtime":"wordpress:6-apache","port":80,"health_check":"/wp-login.php"}`,
Variables: `[{"key":"WORDPRESS_DB_HOST","label":"Database Host","default":"","required":true,"secret":false},{"key":"WORDPRESS_DB_USER","label":"Database User","default":"wordpress","required":true,"secret":false},{"key":"WORDPRESS_DB_PASSWORD","label":"Database Password","default":"","required":true,"secret":true},{"key":"WORDPRESS_DB_NAME","label":"Database Name","default":"wordpress","required":true,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-paperless",
Name: "Paperless-ngx",
Description: "Document management system with OCR",
Category: "app",
Logo: "https://cdn.simpleicons.org/paperlessngx",
Config: `{"type":"web","runtime":"ghcr.io/paperless-ngx/paperless-ngx:latest","port":8000,"health_check":"/api/"}`,
Variables: `[{"key":"PAPERLESS_ADMIN_PASSWORD","label":"Admin Password","default":"","required":true,"secret":true},{"key":"PAPERLESS_URL","label":"Public URL","default":"","required":false,"secret":false}]`,
IsOfficial: true,
},
{
ID: "tpl-ntfy",
Name: "ntfy",
Description: "Push notification service over HTTP",
Category: "app",
Logo: "https://cdn.simpleicons.org/ntfy",
Config: `{"type":"web","runtime":"binwiederhier/ntfy:latest","start_command":"serve","port":80,"health_check":"/v1/health"}`,
Variables: `[]`,
IsOfficial: true,
},
}
return templates
}
@@ -605,12 +1013,30 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
}
}
// templateVisibleTo reports whether the caller may see a template: official
// templates are public, user templates are owner/admin only.
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
if t.IsOfficial {
return true
}
if contextIsAdmin(c) {
return true
}
userID := optionalUserID(c)
return userID != "" && t.OwnerID == userID
}
func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
variables := "[]"
if row.Variables.Valid && len(row.Variables.RawMessage) > 0 {
variables = string(row.Variables.RawMessage)
}
ownerID := ""
if row.OwnerID.Valid {
ownerID = row.OwnerID.UUID.String()
}
return ServiceTemplate{
ID: row.ID,
Name: row.Name,
@@ -620,6 +1046,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
Config: string(row.Config),
Variables: variables,
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
OwnerID: ownerID,
CreatedAt: templateNullTime(row.CreatedAt),
UpdatedAt: templateNullTime(row.UpdatedAt),
}
+6 -34
View File
@@ -47,26 +47,12 @@ func handleGetVariables(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
@@ -123,26 +109,12 @@ func handleUpdateVariables(c *gin.Context) {
return
}
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
var ownerCheck string
err = db.(*database.DB).QueryRow(
`SELECT p.owner_id FROM services s
JOIN projects p ON s.project_id = p.id
WHERE s.id = $1`,
serviceID,
).Scan(&ownerCheck)
if err != nil {
projectID, found := serviceProjectID(db.(*database.DB), serviceID)
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
return
}
if ownerCheck != userID.(string) {
if _, allowed := projectManageAccess(c, db.(*database.DB), projectID); !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}