mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
8f5789bf93
commit
8b6214a0f5
62 files changed
+4986
-1133
No files matched your search
@@ -86,6 +86,9 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
// Provision the platform owner account (idempotent, non-fatal).
|
||||
api.EnsureAdminAccount(context.Background(), db, cfg)
|
||||
|
||||
// Setup Gin router
|
||||
if cfg.IsProduction() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -91,6 +91,12 @@ type Config struct {
|
||||
|
||||
// Dashboard UI Configuration
|
||||
DashboardUIBasePath string `env:"DASHBOARD_UI_BASE_PATH" default:"/"`
|
||||
|
||||
// Admin bootstrap — when set, the account is provisioned/promoted on
|
||||
// startup so the owner always has admin credentials that work.
|
||||
AdminEmail string `env:"ADMIN_EMAIL" default:""`
|
||||
AdminPassword string `env:"ADMIN_PASSWORD" default:""`
|
||||
AdminName string `env:"ADMIN_NAME" default:""`
|
||||
}
|
||||
|
||||
// Environment represents the application environment
|
||||
@@ -181,6 +187,10 @@ func Load() *Config {
|
||||
|
||||
cfg.DashboardUIBasePath = getenv("DASHBOARD_UI_BASE_PATH", "/")
|
||||
|
||||
cfg.AdminEmail = getenv("ADMIN_EMAIL", "")
|
||||
cfg.AdminPassword = getenv("ADMIN_PASSWORD", "")
|
||||
cfg.AdminName = getenv("ADMIN_NAME", "")
|
||||
|
||||
// Validate configuration
|
||||
if err := cfg.Validate(); err != nil {
|
||||
panic(fmt.Sprintf("Invalid configuration: %v", err))
|
||||
|
||||
@@ -55,5 +55,5 @@ func (db *DB) Health(ctx context.Context) error {
|
||||
|
||||
// Stats returns connection pool statistics for monitoring
|
||||
func (db *DB) Stats() sql.DBStats {
|
||||
return db.Stats()
|
||||
return db.DB.Stats()
|
||||
}
|
||||
@@ -149,37 +149,18 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
||||
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
const getDatabaseServiceByID = `-- name: GetDatabaseServiceByID :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1 AND user_id = $2
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type GetDatabaseServiceByIDAndUserParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
type GetDatabaseServiceByIDAndUserRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Plan string `json:"plan"`
|
||||
Region string `json:"region"`
|
||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
|
||||
var i GetDatabaseServiceByIDAndUserRow
|
||||
func (q *Queries) GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDatabaseServiceByID, id)
|
||||
var i DatabaseService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
@@ -195,6 +176,94 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetDatabaseServiceByIDAndUserParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDatabaseServiceByIDAndUser, arg.ID, arg.UserID)
|
||||
var i DatabaseService
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
&i.Version,
|
||||
&i.Plan,
|
||||
&i.Region,
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDatabaseServiceOwnerID = `-- name: GetDatabaseServiceOwnerID :one
|
||||
SELECT user_id
|
||||
FROM database_services
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getDatabaseServiceOwnerID, id)
|
||||
var user_id string
|
||||
err := row.Scan(&user_id)
|
||||
return user_id, err
|
||||
}
|
||||
|
||||
const listAllDatabaseServices = `-- name: ListAllDatabaseServices :many
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listAllDatabaseServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []DatabaseService{}
|
||||
for rows.Next() {
|
||||
var i DatabaseService
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
&i.Version,
|
||||
&i.Plan,
|
||||
&i.Region,
|
||||
&i.ConnectionUrl,
|
||||
&i.BackupSchedule,
|
||||
&i.NextBackupAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listDatabaseBackupsByDatabaseAndUser = `-- name: ListDatabaseBackupsByDatabaseAndUser :many
|
||||
SELECT b.id, b.database_id, b.size, b.status, b.backup_path, b.created_at, b.completed_at
|
||||
FROM database_backups b
|
||||
@@ -242,38 +311,24 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
||||
}
|
||||
|
||||
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
||||
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
type ListDatabaseServicesByUserRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
Plan string `json:"plan"`
|
||||
Region string `json:"region"`
|
||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) {
|
||||
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDatabaseServicesByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListDatabaseServicesByUserRow{}
|
||||
items := []DatabaseService{}
|
||||
for rows.Next() {
|
||||
var i ListDatabaseServicesByUserRow
|
||||
var i DatabaseService
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Status,
|
||||
|
||||
@@ -154,6 +154,7 @@ type Project struct {
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
IsApproved bool `json:"is_approved"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
@@ -205,6 +206,7 @@ type ServiceTemplate struct {
|
||||
Config json.RawMessage `json:"config"`
|
||||
Variables pqtype.NullRawMessage `json:"variables"`
|
||||
IsOfficial sql.NullBool `json:"is_official"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
@@ -16,25 +16,31 @@ const countProjectsByUser = `-- name: CountProjectsByUser :one
|
||||
SELECT COUNT(*)::bigint AS total
|
||||
FROM projects p
|
||||
WHERE
|
||||
(p.owner_id = $1 OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = $1
|
||||
))
|
||||
(
|
||||
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
|
||||
)
|
||||
)
|
||||
AND (
|
||||
$2::text IS NULL
|
||||
OR p.name ILIKE ('%' || $2::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%')
|
||||
$3::text IS NULL
|
||||
OR p.name ILIKE ('%' || $3::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
|
||||
)
|
||||
`
|
||||
|
||||
type CountProjectsByUserParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Search sql.NullString `json:"search"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Search sql.NullString `json:"search"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.Search)
|
||||
row := q.db.QueryRowContext(ctx, countProjectsByUser, arg.UserID, arg.IsAdmin, arg.Search)
|
||||
var total int64
|
||||
err := row.Scan(&total)
|
||||
return total, err
|
||||
@@ -43,7 +49,7 @@ func (q *Queries) CountProjectsByUser(ctx context.Context, arg CountProjectsByUs
|
||||
const createProject = `-- name: CreateProject :one
|
||||
INSERT INTO projects (name, description, owner_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, description, owner_id, created_at, updated_at
|
||||
RETURNING id, name, description, owner_id, is_approved, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateProjectParams struct {
|
||||
@@ -60,6 +66,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.OwnerID,
|
||||
&i.IsApproved,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -80,11 +87,13 @@ func (q *Queries) DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (i
|
||||
}
|
||||
|
||||
const getProjectByIDForUser = `-- name: GetProjectByIDForUser :one
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
WHERE p.id = $1
|
||||
AND (
|
||||
p.owner_id = $2
|
||||
p.is_approved
|
||||
OR p.owner_id = $2
|
||||
OR $3::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
@@ -96,16 +105,18 @@ WHERE p.id = $1
|
||||
type GetProjectByIDForUserParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) {
|
||||
row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID)
|
||||
row := q.db.QueryRowContext(ctx, getProjectByIDForUser, arg.ProjectID, arg.UserID, arg.IsAdmin)
|
||||
var i Project
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.OwnerID,
|
||||
&i.IsApproved,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
@@ -127,21 +138,22 @@ func (q *Queries) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID)
|
||||
|
||||
const getProjectRoleForUser = `-- name: GetProjectRoleForUser :one
|
||||
SELECT (CASE
|
||||
WHEN p.owner_id = $1 THEN 'owner'
|
||||
WHEN p.owner_id = $1 OR $2::bool THEN 'owner'
|
||||
ELSE COALESCE(pm.role, '')
|
||||
END)::text AS role
|
||||
FROM projects p
|
||||
LEFT JOIN project_members pm ON p.id = pm.project_id AND pm.user_id = $1
|
||||
WHERE p.id = $2
|
||||
WHERE p.id = $3
|
||||
`
|
||||
|
||||
type GetProjectRoleForUserParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.ProjectID)
|
||||
row := q.db.QueryRowContext(ctx, getProjectRoleForUser, arg.UserID, arg.IsAdmin, arg.ProjectID)
|
||||
var role string
|
||||
err := row.Scan(&role)
|
||||
return role, err
|
||||
@@ -162,12 +174,52 @@ func (q *Queries) InsertProjectEnvironment(ctx context.Context, arg InsertProjec
|
||||
return err
|
||||
}
|
||||
|
||||
const listPendingProjects = `-- name: ListPendingProjects :many
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
WHERE p.is_approved = false
|
||||
ORDER BY p.created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListPendingProjects(ctx context.Context) ([]Project, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPendingProjects)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Project{}
|
||||
for rows.Next() {
|
||||
var i Project
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.OwnerID,
|
||||
&i.IsApproved,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listProjectsWithStatsByUser = `-- name: ListProjectsWithStatsByUser :many
|
||||
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.description,
|
||||
p.owner_id,
|
||||
p.is_approved,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
COUNT(DISTINCT s.id)::bigint AS service_count,
|
||||
@@ -185,23 +237,29 @@ FROM projects p
|
||||
LEFT JOIN services s ON s.project_id = p.id
|
||||
LEFT JOIN deployments d ON d.service_id = s.id
|
||||
WHERE
|
||||
(p.owner_id = $1 OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = $1
|
||||
))
|
||||
AND (
|
||||
$2::text IS NULL
|
||||
OR p.name ILIKE ('%' || $2::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || $2::text || '%')
|
||||
(
|
||||
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
|
||||
)
|
||||
)
|
||||
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
|
||||
AND (
|
||||
$3::text IS NULL
|
||||
OR p.name ILIKE ('%' || $3::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || $3::text || '%')
|
||||
)
|
||||
GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
ORDER BY p.updated_at DESC
|
||||
LIMIT $4 OFFSET $3
|
||||
LIMIT $5 OFFSET $4
|
||||
`
|
||||
|
||||
type ListProjectsWithStatsByUserParams struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Search sql.NullString `json:"search"`
|
||||
OffsetCount int32 `json:"offset_count"`
|
||||
LimitCount int32 `json:"limit_count"`
|
||||
@@ -212,6 +270,7 @@ type ListProjectsWithStatsByUserRow struct {
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
IsApproved bool `json:"is_approved"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ServiceCount int64 `json:"service_count"`
|
||||
@@ -220,9 +279,13 @@ type ListProjectsWithStatsByUserRow struct {
|
||||
LastDeployment sql.NullTime `json:"last_deployment"`
|
||||
}
|
||||
|
||||
// Visibility model: a project is readable when it is approved for public
|
||||
// display, when the caller owns or is a member of it, or when the caller is a
|
||||
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||
func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listProjectsWithStatsByUser,
|
||||
arg.UserID,
|
||||
arg.IsAdmin,
|
||||
arg.Search,
|
||||
arg.OffsetCount,
|
||||
arg.LimitCount,
|
||||
@@ -239,6 +302,7 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.OwnerID,
|
||||
&i.IsApproved,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ServiceCount,
|
||||
@@ -259,6 +323,25 @@ func (q *Queries) ListProjectsWithStatsByUser(ctx context.Context, arg ListProje
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setProjectApproved = `-- name: SetProjectApproved :execrows
|
||||
UPDATE projects
|
||||
SET is_approved = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
type SetProjectApprovedParams struct {
|
||||
IsApproved bool `json:"is_approved"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, setProjectApproved, arg.IsApproved, arg.ProjectID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const updateProjectByID = `-- name: UpdateProjectByID :execrows
|
||||
UPDATE projects
|
||||
SET
|
||||
|
||||
@@ -25,10 +25,12 @@ type Querier interface {
|
||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||
DeleteAgent(ctx context.Context, id string) error
|
||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||
GetAgent(ctx context.Context, id string) (NodeAgent, error)
|
||||
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
|
||||
@@ -36,7 +38,9 @@ type Querier interface {
|
||||
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
|
||||
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
|
||||
GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, arg GetDatabaseBackupByIDAndDatabaseAndUserParams) (DatabaseBackup, error)
|
||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error)
|
||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
||||
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
@@ -47,16 +51,21 @@ type Querier interface {
|
||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
|
||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListPendingProjects(ctx context.Context) ([]Project, error)
|
||||
// Visibility model: a project is readable when it is approved for public
|
||||
// display, when the caller owns or is a member of it, or when the caller is a
|
||||
// platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||
@@ -66,6 +75,7 @@ type Querier interface {
|
||||
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
||||
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
||||
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||
@@ -74,7 +84,9 @@ type Querier interface {
|
||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
|
||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
|
||||
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
||||
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
||||
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -8,8 +8,10 @@ package sqlcdb
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
const countServicesByProjectAndName = `-- name: CountServicesByProjectAndName :one
|
||||
@@ -122,6 +124,66 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
|
||||
return err
|
||||
}
|
||||
|
||||
const createUserTemplate = `-- name: CreateUserTemplate :exec
|
||||
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
false,
|
||||
$8
|
||||
)
|
||||
`
|
||||
|
||||
type CreateUserTemplateParams struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Logo sql.NullString `json:"logo"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Variables pqtype.NullRawMessage `json:"variables"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createUserTemplate,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.Logo,
|
||||
arg.Config,
|
||||
arg.Variables,
|
||||
arg.OwnerID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteUserTemplate = `-- name: DeleteUserTemplate :execrows
|
||||
DELETE FROM service_templates
|
||||
WHERE id = $1
|
||||
AND owner_id = $2
|
||||
AND is_official = false
|
||||
`
|
||||
|
||||
type DeleteUserTemplateParams struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteUserTemplate, arg.ID, arg.OwnerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getProjectOwnerID = `-- name: GetProjectOwnerID :one
|
||||
SELECT owner_id
|
||||
FROM projects
|
||||
@@ -136,7 +198,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
|
||||
}
|
||||
|
||||
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE id = $1
|
||||
`
|
||||
@@ -153,20 +215,28 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
|
||||
&i.Config,
|
||||
&i.Variables,
|
||||
&i.IsOfficial,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listServiceTemplates = `-- name: ListServiceTemplates :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE category = $1
|
||||
AND (is_official = true OR owner_id = $2)
|
||||
ORDER BY is_official DESC, name ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceTemplates)
|
||||
type ListServiceTemplatesByCategoryForUserParams struct {
|
||||
Category string `json:"category"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategoryForUser, arg.Category, arg.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -183,6 +253,7 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
|
||||
&i.Config,
|
||||
&i.Variables,
|
||||
&i.IsOfficial,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -199,15 +270,15 @@ func (q *Queries) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listServiceTemplatesByCategory = `-- name: ListServiceTemplatesByCategory :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE category = $1
|
||||
WHERE is_official = true OR owner_id = $1
|
||||
ORDER BY is_official DESC, name ASC
|
||||
`
|
||||
|
||||
func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceTemplatesByCategory, category)
|
||||
func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listServiceTemplatesForUser, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,6 +295,7 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
|
||||
&i.Config,
|
||||
&i.Variables,
|
||||
&i.IsOfficial,
|
||||
&i.OwnerID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
@@ -240,6 +312,48 @@ func (q *Queries) ListServiceTemplatesByCategory(ctx context.Context, category s
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateUserTemplate = `-- name: UpdateUserTemplate :execrows
|
||||
UPDATE service_templates
|
||||
SET name = $1,
|
||||
description = $2,
|
||||
category = $3,
|
||||
logo = $4,
|
||||
config = $5,
|
||||
variables = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $7
|
||||
AND owner_id = $8
|
||||
AND is_official = false
|
||||
`
|
||||
|
||||
type UpdateUserTemplateParams struct {
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Logo sql.NullString `json:"logo"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Variables pqtype.NullRawMessage `json:"variables"`
|
||||
ID string `json:"id"`
|
||||
OwnerID uuid.NullUUID `json:"owner_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, updateUserTemplate,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.Logo,
|
||||
arg.Config,
|
||||
arg.Variables,
|
||||
arg.ID,
|
||||
arg.OwnerID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const upsertEnvironmentVariable = `-- name: UpsertEnvironmentVariable :exec
|
||||
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
@@ -271,3 +385,51 @@ func (q *Queries) UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvir
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertServiceTemplate = `-- name: UpsertServiceTemplate :exec
|
||||
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
logo = EXCLUDED.logo,
|
||||
config = EXCLUDED.config,
|
||||
variables = EXCLUDED.variables,
|
||||
is_official = EXCLUDED.is_official,
|
||||
updated_at = NOW()
|
||||
`
|
||||
|
||||
type UpsertServiceTemplateParams struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description sql.NullString `json:"description"`
|
||||
Category string `json:"category"`
|
||||
Logo sql.NullString `json:"logo"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Variables pqtype.NullRawMessage `json:"variables"`
|
||||
IsOfficial sql.NullBool `json:"is_official"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertServiceTemplate,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.Description,
|
||||
arg.Category,
|
||||
arg.Logo,
|
||||
arg.Config,
|
||||
arg.Variables,
|
||||
arg.IsOfficial,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -69,41 +69,72 @@ func RequestID() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveRequestUser authenticates the request via Bearer JWT or the Better
|
||||
// Auth session cookie. On success it returns the local user id and email.
|
||||
// tokenErr/hasToken mirror the legacy error contract for Auth.
|
||||
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
|
||||
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
||||
if tokenString != "" {
|
||||
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
||||
userIDClaim, exists := claims["user_id"]
|
||||
if exists {
|
||||
id := strings.TrimSpace(fmt.Sprint(userIDClaim))
|
||||
if _, err := uuid.Parse(id); err == nil {
|
||||
if emailClaim, exists := claims["email"]; exists && emailClaim != nil {
|
||||
email = strings.TrimSpace(fmt.Sprint(emailClaim))
|
||||
}
|
||||
return id, email, "", hasToken, true
|
||||
}
|
||||
}
|
||||
tokenErr = "Invalid token claims"
|
||||
} else if tokenErr == "" {
|
||||
tokenErr = "Invalid token"
|
||||
}
|
||||
}
|
||||
|
||||
if sessionVerifier != nil {
|
||||
if id, mail, verified := sessionVerifier.resolveUser(c); verified {
|
||||
return id, mail, "", hasToken, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", tokenErr, hasToken, false
|
||||
}
|
||||
|
||||
// setAuthenticatedContext stores the resolved identity plus the admin flag on
|
||||
// the gin context so handlers and RequireAdmin can rely on it.
|
||||
func setAuthenticatedContext(c *gin.Context, userID, email string) {
|
||||
c.Set("user_id", userID)
|
||||
c.Set("email", email)
|
||||
c.Set("is_admin", loadIsAdmin(c, userID))
|
||||
}
|
||||
|
||||
func loadIsAdmin(c *gin.Context, userID string) bool {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
db, ok := dbValue.(*database.DB)
|
||||
if !ok || db == nil || db.DB == nil {
|
||||
return false
|
||||
}
|
||||
var isAdmin bool
|
||||
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
|
||||
return false
|
||||
}
|
||||
return isAdmin
|
||||
}
|
||||
|
||||
// Auth middleware for JWT authentication
|
||||
func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
sessionVerifier := newBetterAuthSessionVerifier()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
||||
if tokenString != "" {
|
||||
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
||||
userIDClaim, exists := claims["user_id"]
|
||||
if exists {
|
||||
userID := strings.TrimSpace(fmt.Sprint(userIDClaim))
|
||||
if _, err := uuid.Parse(userID); err == nil {
|
||||
email := ""
|
||||
if emailClaim, ok := claims["email"]; ok && emailClaim != nil {
|
||||
email = strings.TrimSpace(fmt.Sprint(emailClaim))
|
||||
}
|
||||
c.Set("user_id", userID)
|
||||
c.Set("email", email)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
tokenErr = "Invalid token claims"
|
||||
} else if tokenErr == "" {
|
||||
tokenErr = "Invalid token"
|
||||
}
|
||||
}
|
||||
|
||||
if sessionVerifier != nil {
|
||||
if userID, email, ok := sessionVerifier.resolveUser(c); ok {
|
||||
c.Set("user_id", userID)
|
||||
c.Set("email", email)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
|
||||
if ok {
|
||||
setAuthenticatedContext(c, userID, email)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if tokenErr != "" {
|
||||
@@ -121,6 +152,38 @@ func Auth(jwtSecret string) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth resolves identity when a valid credential is present but never
|
||||
// rejects the request — anonymous callers simply get no user context. Read
|
||||
// endpoints hang off this so the public site can browse approved resources.
|
||||
func OptionalAuth(jwtSecret string) gin.HandlerFunc {
|
||||
sessionVerifier := newBetterAuthSessionVerifier()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
if userID, email, _, _, ok := resolveRequestUser(c, jwtSecret, sessionVerifier); ok {
|
||||
setAuthenticatedContext(c, userID, email)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin rejects requests whose resolved user is not a platform admin.
|
||||
// Must run after Auth (or OptionalAuth) so user_id/is_admin are populated.
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("user_id"); !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !c.GetBool("is_admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
type betterAuthSessionVerifier struct {
|
||||
internalURL string
|
||||
internalToken string
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- Public platform model: projects are browsable without a session once the
|
||||
-- admin approves them; service_templates gains ownership for user templates.
|
||||
ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS is_approved boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Projects that already exist stay live; only newly created ones enter the
|
||||
-- approval queue.
|
||||
UPDATE public.projects SET is_approved = true;
|
||||
|
||||
ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS owner_id uuid;
|
||||
CREATE INDEX IF NOT EXISTS idx_service_templates_owner ON public.service_templates(owner_id);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE public.projects DROP COLUMN IF EXISTS is_approved;
|
||||
DROP INDEX IF EXISTS public.idx_service_templates_owner;
|
||||
ALTER TABLE public.service_templates DROP COLUMN IF EXISTS owner_id;
|
||||
@@ -1,11 +1,26 @@
|
||||
-- name: ListDatabaseServicesByUser :many
|
||||
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: ListAllDatabaseServices :many
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetDatabaseServiceByID :one
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetDatabaseServiceOwnerID :one
|
||||
SELECT user_id
|
||||
FROM database_services
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetDatabaseServiceByIDAndUser :one
|
||||
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
SELECT id, user_id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||
FROM database_services
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
-- Visibility model: a project is readable when it is approved for public
|
||||
-- display, when the caller owns or is a member of it, or when the caller is a
|
||||
-- platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false.
|
||||
|
||||
-- name: ListProjectsWithStatsByUser :many
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.description,
|
||||
p.owner_id,
|
||||
p.is_approved,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
COUNT(DISTINCT s.id)::bigint AS service_count,
|
||||
@@ -21,17 +26,22 @@ FROM projects p
|
||||
LEFT JOIN services s ON s.project_id = p.id
|
||||
LEFT JOIN deployments d ON d.service_id = s.id
|
||||
WHERE
|
||||
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
|
||||
))
|
||||
(
|
||||
p.is_approved
|
||||
OR p.owner_id = sqlc.arg(user_id)
|
||||
OR sqlc.arg(is_admin)::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
sqlc.narg(search)::text IS NULL
|
||||
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
|
||||
OR COALESCE(p.description, '') ILIKE ('%' || sqlc.narg(search)::text || '%')
|
||||
)
|
||||
GROUP BY p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
|
||||
GROUP BY p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
ORDER BY p.updated_at DESC
|
||||
LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
|
||||
|
||||
@@ -39,11 +49,16 @@ LIMIT sqlc.arg(limit_count) OFFSET sqlc.arg(offset_count);
|
||||
SELECT COUNT(*)::bigint AS total
|
||||
FROM projects p
|
||||
WHERE
|
||||
(p.owner_id = sqlc.arg(user_id) OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
|
||||
))
|
||||
(
|
||||
p.is_approved
|
||||
OR p.owner_id = sqlc.arg(user_id)
|
||||
OR sqlc.arg(is_admin)::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
WHERE pm.project_id = p.id AND pm.user_id = sqlc.arg(user_id)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
sqlc.narg(search)::text IS NULL
|
||||
OR p.name ILIKE ('%' || sqlc.narg(search)::text || '%')
|
||||
@@ -51,11 +66,13 @@ WHERE
|
||||
);
|
||||
|
||||
-- name: GetProjectByIDForUser :one
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.updated_at
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
WHERE p.id = sqlc.arg(project_id)
|
||||
AND (
|
||||
p.owner_id = sqlc.arg(user_id)
|
||||
p.is_approved
|
||||
OR p.owner_id = sqlc.arg(user_id)
|
||||
OR sqlc.arg(is_admin)::bool
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM project_members pm
|
||||
@@ -66,7 +83,7 @@ WHERE p.id = sqlc.arg(project_id)
|
||||
-- name: CreateProject :one
|
||||
INSERT INTO projects (name, description, owner_id)
|
||||
VALUES (sqlc.arg(name), sqlc.narg(description), sqlc.arg(owner_id))
|
||||
RETURNING id, name, description, owner_id, created_at, updated_at;
|
||||
RETURNING id, name, description, owner_id, is_approved, created_at, updated_at;
|
||||
|
||||
-- name: InsertProjectEnvironment :exec
|
||||
INSERT INTO environments (name, project_id)
|
||||
@@ -74,7 +91,7 @@ VALUES (sqlc.arg(name), sqlc.arg(project_id));
|
||||
|
||||
-- name: GetProjectRoleForUser :one
|
||||
SELECT (CASE
|
||||
WHEN p.owner_id = sqlc.arg(user_id) THEN 'owner'
|
||||
WHEN p.owner_id = sqlc.arg(user_id) OR sqlc.arg(is_admin)::bool THEN 'owner'
|
||||
ELSE COALESCE(pm.role, '')
|
||||
END)::text AS role
|
||||
FROM projects p
|
||||
@@ -97,3 +114,14 @@ WHERE id = sqlc.arg(project_id);
|
||||
-- name: DeleteProjectByID :execrows
|
||||
DELETE FROM projects
|
||||
WHERE id = sqlc.arg(project_id);
|
||||
|
||||
-- name: SetProjectApproved :execrows
|
||||
UPDATE projects
|
||||
SET is_approved = sqlc.arg(is_approved), updated_at = NOW()
|
||||
WHERE id = sqlc.arg(project_id);
|
||||
|
||||
-- name: ListPendingProjects :many
|
||||
SELECT p.id, p.name, p.description, p.owner_id, p.is_approved, p.created_at, p.updated_at
|
||||
FROM projects p
|
||||
WHERE p.is_approved = false
|
||||
ORDER BY p.created_at DESC;
|
||||
@@ -1,19 +1,76 @@
|
||||
-- name: ListServiceTemplates :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
-- name: ListServiceTemplatesForUser :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE is_official = true OR owner_id = sqlc.narg(owner_id)
|
||||
ORDER BY is_official DESC, name ASC;
|
||||
|
||||
-- name: ListServiceTemplatesByCategory :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
-- name: ListServiceTemplatesByCategoryForUser :many
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE category = $1
|
||||
WHERE category = sqlc.arg(category)
|
||||
AND (is_official = true OR owner_id = sqlc.narg(owner_id))
|
||||
ORDER BY is_official DESC, name ASC;
|
||||
|
||||
-- name: GetServiceTemplateByID :one
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, created_at, updated_at
|
||||
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
|
||||
FROM service_templates
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpsertServiceTemplate :exec
|
||||
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official)
|
||||
VALUES (
|
||||
sqlc.arg(id),
|
||||
sqlc.arg(name),
|
||||
sqlc.narg(description),
|
||||
sqlc.arg(category),
|
||||
sqlc.narg(logo),
|
||||
sqlc.arg(config),
|
||||
sqlc.narg(variables),
|
||||
sqlc.arg(is_official)
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
logo = EXCLUDED.logo,
|
||||
config = EXCLUDED.config,
|
||||
variables = EXCLUDED.variables,
|
||||
is_official = EXCLUDED.is_official,
|
||||
updated_at = NOW();
|
||||
|
||||
-- name: CreateUserTemplate :exec
|
||||
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id)
|
||||
VALUES (
|
||||
sqlc.arg(id),
|
||||
sqlc.arg(name),
|
||||
sqlc.narg(description),
|
||||
sqlc.arg(category),
|
||||
sqlc.narg(logo),
|
||||
sqlc.arg(config),
|
||||
sqlc.narg(variables),
|
||||
false,
|
||||
sqlc.arg(owner_id)
|
||||
);
|
||||
|
||||
-- name: UpdateUserTemplate :execrows
|
||||
UPDATE service_templates
|
||||
SET name = sqlc.arg(name),
|
||||
description = sqlc.narg(description),
|
||||
category = sqlc.arg(category),
|
||||
logo = sqlc.narg(logo),
|
||||
config = sqlc.arg(config),
|
||||
variables = sqlc.narg(variables),
|
||||
updated_at = NOW()
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND owner_id = sqlc.arg(owner_id)
|
||||
AND is_official = false;
|
||||
|
||||
-- name: DeleteUserTemplate :execrows
|
||||
DELETE FROM service_templates
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND owner_id = sqlc.arg(owner_id)
|
||||
AND is_official = false;
|
||||
|
||||
-- name: GetProjectOwnerID :one
|
||||
SELECT owner_id
|
||||
FROM projects
|
||||
|
||||
@@ -3,6 +3,7 @@ CREATE TABLE projects (
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
owner_id UUID NOT NULL,
|
||||
is_approved BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
@@ -82,6 +83,7 @@ CREATE TABLE service_templates (
|
||||
config JSONB NOT NULL,
|
||||
variables JSONB DEFAULT '[]',
|
||||
is_official BOOLEAN DEFAULT false,
|
||||
owner_id UUID,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user