diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2cc1757 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sir Blob + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index a215be6..470ef88 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ A minimal, self-hosted Platform as a Service (PaaS) for building and deploying a - **Docker-based**: Each deployment runs in an isolated Docker container. - **Real-time Logs**: View build and runtime logs streamed directly to the dashboard. - **Port Management**: Automatically assigns and manages ports for your applications. +- **Deployment History**: Track every build with detailed commit info and status. +- **Environment Variables**: Securely manage runtime configuration. +- **Manual Redeploy**: Trigger rebuilds with a single click. - **Zero Configuration**: Just provide a repo URL, and Clickploy handles the rest. ## Tech Stack @@ -15,7 +18,17 @@ A minimal, self-hosted Platform as a Service (PaaS) for building and deploying a - **Build Engine**: Nixpacks - **Database**: SQLite (Embedded) +## Getting Started +### Prerequisites +- Docker & Docker Compose +- Go 1.21+ (for development) +- Node.js 20+ (for development) + +### Running Locally +1. Clone the repository. +2. Run `docker-compose up --build`. +3. Access the dashboard at `http://localhost:5173`. ## Architecture Clickploy acts as a control plane for your deployments. It clones your repository, builds a Docker image using Nixpacks, and spins up a container. It manages a persistent database of projects and deployments, ensuring state is maintained across restarts. diff --git a/backend/README.md b/backend/README.md index 1b2a44f..539b4e4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -16,6 +16,8 @@ This service handles: ### API Endpoints - `GET /api/projects`: List projects - `POST /api/projects`: Create a project +- `PUT /api/projects/:id/env`: Update environment variables +- `POST /api/projects/:id/redeploy`: Manual redeploy - `GET /api/activity`: Get recent activity - `WS /api/deployments/:id/logs/stream`: Stream logs @@ -26,3 +28,4 @@ This service handles: - `internal/models/`: GORM models. - `internal/builder/`: Nixpacks wrapper. - `internal/deployer/`: Docker SDK wrapper. +- `internal/ports/`: Port allocation logic. diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a2b1371..088cfd1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -43,6 +43,7 @@ func main() { handler.RegisterWebhookRoutes(r) handler.RegisterSystemRoutes(r) handler.RegisterStorageRoutes(r) + handler.RegisterAdminRoutes(r) log.Println("Starting Clickploy Backend on :8080") if err := r.Run(":8080"); err != nil { diff --git a/backend/go.mod b/backend/go.mod index b8c027a..71c6f2b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -38,6 +38,7 @@ require ( github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect + github.com/matoous/go-nanoid/v2 v2.1.0 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/moby/term v0.5.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 621a93c..328f6d2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -73,6 +73,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= +github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= @@ -111,6 +113,7 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go new file mode 100644 index 0000000..a7882ba --- /dev/null +++ b/backend/internal/api/admin.go @@ -0,0 +1,53 @@ +package api + +import ( + "net/http" + + "clickploy/internal/db" + "clickploy/internal/models" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) RegisterAdminRoutes(r *gin.Engine) { + admin := r.Group("/api/admin", AuthMiddleware(), AdminMiddleware()) + { + admin.GET("/users", h.adminListUsers) + admin.DELETE("/users/:id", h.adminDeleteUser) + admin.GET("/stats", h.adminGetStats) + } +} + +func (h *Handler) adminListUsers(c *gin.Context) { + var users []models.User + if err := db.DB.Preload("Projects").Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"}) + return + } + c.JSON(http.StatusOK, users) +} + +func (h *Handler) adminDeleteUser(c *gin.Context) { + id := c.Param("id") + if err := db.DB.Where("id = ?", id).Delete(&models.User{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "deleted"}) +} + +func (h *Handler) adminGetStats(c *gin.Context) { + var userCount int64 + var projectCount int64 + var deploymentCount int64 + + db.DB.Model(&models.User{}).Count(&userCount) + db.DB.Model(&models.Project{}).Count(&projectCount) + db.DB.Model(&models.Deployment{}).Count(&deploymentCount) + + c.JSON(http.StatusOK, gin.H{ + "users": userCount, + "projects": projectCount, + "deployments": deploymentCount, + }) +} diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 10706d8..0c93e88 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -53,7 +53,7 @@ func (h *Handler) handleDeploy(c *gin.Context) { return } - imageName, err := h.builder.Build(req.Repo, req.Name, req.GitToken, "", "", "", "", nil, os.Stdout) + imageName, _, err := h.builder.Build(req.Repo, req.Name, req.GitToken, "", "", "", "", nil, os.Stdout) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Build failed: %v", err)}) return diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index c27fda0..2ad7613 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -8,6 +8,7 @@ import ( "clickploy/internal/models" "github.com/gin-gonic/gin" + gonanoid "github.com/matoous/go-nanoid/v2" ) type AuthRequest struct { @@ -42,11 +43,17 @@ func (h *Handler) register(c *gin.Context) { return } + var count int64 + db.DB.Model(&models.User{}).Count(&count) + + userID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) user := models.User{ + ID: userID, Email: req.Email, Password: hashed, Name: req.Name, Avatar: "https://github.com/shadcn.png", + IsAdmin: count == 0, } if result := db.DB.Create(&user); result.Error != nil { @@ -101,7 +108,7 @@ func (h *Handler) updateProfile(c *gin.Context) { } var user models.User - if result := db.DB.First(&user, userID); result.Error != nil { + if result := db.DB.Where("id = ?", userID).First(&user); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } @@ -130,7 +137,7 @@ func (h *Handler) updatePassword(c *gin.Context) { } var user models.User - if result := db.DB.First(&user, userID); result.Error != nil { + if result := db.DB.Where("id = ?", userID).First(&user); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index e8e5439..7109690 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -5,6 +5,8 @@ import ( "strings" "clickploy/internal/auth" + "clickploy/internal/db" + "clickploy/internal/models" "github.com/gin-gonic/gin" ) @@ -31,3 +33,29 @@ func AuthMiddleware() gin.HandlerFunc { c.Next() } } + +func AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + userID, exists := c.Get("userID") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + c.Abort() + return + } + + var user models.User + if err := db.DB.Where("id = ?", userID).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + c.Abort() + return + } + + if !user.IsAdmin { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin privileges required"}) + c.Abort() + return + } + + c.Next() + } +} diff --git a/backend/internal/api/project.go b/backend/internal/api/project.go index 36569e3..40aaf7a 100644 --- a/backend/internal/api/project.go +++ b/backend/internal/api/project.go @@ -2,17 +2,17 @@ package api import ( "bytes" + "crypto/rand" + "encoding/hex" "fmt" "io" "net/http" - "strconv" - "time" - "clickploy/internal/auth" "clickploy/internal/db" "clickploy/internal/models" "github.com/gin-gonic/gin" + gonanoid "github.com/matoous/go-nanoid/v2" "gorm.io/gorm" ) @@ -40,14 +40,8 @@ func (h *Handler) updateProjectEnv(c *gin.Context) { return } - pid, err := strconv.ParseUint(projectID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"}) - return - } - var project models.Project - if result := db.DB.Where("id = ? AND owner_id = ?", pid, userID).First(&project); result.Error != nil { + if result := db.DB.Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } @@ -80,19 +74,15 @@ func (h *Handler) redeployProject(c *gin.Context) { userID, _ := c.Get("userID") projectID := c.Param("id") - pid, err := strconv.ParseUint(projectID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"}) - return - } - var project models.Project - if result := db.DB.Preload("EnvVars").Where("id = ? AND owner_id = ?", pid, userID).First(&project); result.Error != nil { + if result := db.DB.Preload("EnvVars").Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } + depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) deployment := models.Deployment{ + ID: depID, ProjectID: project.ID, Status: "building", Commit: "MANUAL", @@ -110,7 +100,7 @@ func (h *Handler) redeployProject(c *gin.Context) { envMap[env.Key] = env.Value } - imageName, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi) + imageName, commitHash, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi) deployment.Logs = logBuffer.String() if err != nil { @@ -120,6 +110,11 @@ func (h *Handler) redeployProject(c *gin.Context) { return } + // Update commit hash if we got one + if commitHash != "" { + deployment.Commit = commitHash + } + var envStrings []string for _, env := range project.EnvVars { envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value)) @@ -190,12 +185,17 @@ func (h *Handler) createProject(c *gin.Context) { envVarsModel = append(envVarsModel, models.EnvVar{Key: k, Value: v}) } - webhookSecret, _ := auth.HashPassword(fmt.Sprintf("%s-%d-%d", req.Name, userID, time.Now().UnixNano())) + secretBytes := make([]byte, 16) + rand.Read(secretBytes) + webhookSecret := hex.EncodeToString(secretBytes) + + id, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) project := models.Project{ + ID: id, Name: req.Name, RepoURL: req.Repo, - OwnerID: userID.(uint), + OwnerID: userID.(string), Port: port, WebhookSecret: webhookSecret, GitToken: req.GitToken, @@ -211,7 +211,9 @@ func (h *Handler) createProject(c *gin.Context) { return } + depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) deployment := models.Deployment{ + ID: depID, ProjectID: project.ID, Status: "building", Commit: "HEAD", @@ -224,7 +226,7 @@ func (h *Handler) createProject(c *gin.Context) { streamer := &StreamWriter{DeploymentID: deployment.ID} multi := io.MultiWriter(&logBuffer, streamer) - imageName, err := h.builder.Build(req.Repo, req.Name, req.GitToken, req.BuildCommand, req.StartCommand, req.InstallCommand, req.Runtime, req.EnvVars, multi) + imageName, commitHash, err := h.builder.Build(req.Repo, req.Name, req.GitToken, req.BuildCommand, req.StartCommand, req.InstallCommand, req.Runtime, req.EnvVars, multi) deployment.Logs = logBuffer.String() if err != nil { @@ -234,6 +236,11 @@ func (h *Handler) createProject(c *gin.Context) { return } + // Update commit hash + if commitHash != "" { + deployment.Commit = commitHash + } + containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, envStrings) if err != nil { deployment.Status = "failed" @@ -266,16 +273,10 @@ func (h *Handler) getProject(c *gin.Context) { userID, _ := c.Get("userID") projectID := c.Param("id") - pid, err := strconv.ParseUint(projectID, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"}) - return - } - var project models.Project if result := db.DB.Order("created_at desc").Preload("Deployments", func(db *gorm.DB) *gorm.DB { return db.Order("deployments.created_at desc") - }).Preload("EnvVars").Where("id = ? AND owner_id = ?", pid, userID).First(&project); result.Error != nil { + }).Preload("EnvVars").Where("id = ? AND owner_id = ?", projectID, userID).First(&project); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } diff --git a/backend/internal/api/storage.go b/backend/internal/api/storage.go index 1caa1d0..acfdb55 100644 --- a/backend/internal/api/storage.go +++ b/backend/internal/api/storage.go @@ -53,7 +53,7 @@ func (h *Handler) handleGetStorageStats(c *gin.Context) { } func (h *Handler) handleListDatabases(c *gin.Context) { - userId := c.GetUint("userID") + userId := c.GetString("userID") var dbs []models.Database if err := db.DB.Where("owner_id = ?", userId).Find(&dbs).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list databases"}) @@ -63,7 +63,7 @@ func (h *Handler) handleListDatabases(c *gin.Context) { } func (h *Handler) handleCreateDatabase(c *gin.Context) { - userId := c.GetUint("userID") + userId := c.GetString("userID") var req CreateDatabaseRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -81,7 +81,7 @@ func (h *Handler) handleCreateDatabase(c *gin.Context) { dataDir := "./data/user_dbs" os.MkdirAll(dataDir, 0755) - dbPath := filepath.Join(dataDir, fmt.Sprintf("%d_%s.db", userId, req.Name)) + dbPath := filepath.Join(dataDir, fmt.Sprintf("%s_%s.db", userId, req.Name)) file, err := os.Create(dbPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database file"}) diff --git a/backend/internal/api/stream.go b/backend/internal/api/stream.go index f0b7d22..11c2298 100644 --- a/backend/internal/api/stream.go +++ b/backend/internal/api/stream.go @@ -2,7 +2,6 @@ package api import ( "net/http" - "strconv" "sync" "time" @@ -16,14 +15,14 @@ var upgrader = websocket.Upgrader{ type LogHub struct { mu sync.Mutex - streams map[uint][]chan []byte + streams map[string][]chan []byte } var Hub = &LogHub{ - streams: make(map[uint][]chan []byte), + streams: make(map[string][]chan []byte), } -func (h *LogHub) Broadcast(deploymentID uint, p []byte) { +func (h *LogHub) Broadcast(deploymentID string, p []byte) { h.mu.Lock() defer h.mu.Unlock() if sinks, ok := h.streams[deploymentID]; ok { @@ -36,7 +35,7 @@ func (h *LogHub) Broadcast(deploymentID uint, p []byte) { } } -func (h *LogHub) Subscribe(deploymentID uint) chan []byte { +func (h *LogHub) Subscribe(deploymentID string) chan []byte { h.mu.Lock() defer h.mu.Unlock() ch := make(chan []byte, 256) @@ -44,7 +43,7 @@ func (h *LogHub) Subscribe(deploymentID uint) chan []byte { return ch } -func (h *LogHub) Unsubscribe(deploymentID uint, ch chan []byte) { +func (h *LogHub) Unsubscribe(deploymentID string, ch chan []byte) { h.mu.Lock() defer h.mu.Unlock() if sinks, ok := h.streams[deploymentID]; ok { @@ -62,7 +61,7 @@ func (h *LogHub) Unsubscribe(deploymentID uint, ch chan []byte) { } type StreamWriter struct { - DeploymentID uint + DeploymentID string } func (w *StreamWriter) Write(p []byte) (n int, err error) { @@ -73,9 +72,8 @@ func (w *StreamWriter) Write(p []byte) (n int, err error) { } func (h *Handler) streamDeploymentLogs(c *gin.Context) { - deploymentIDStr := c.Param("id") - deploymentID, err := strconv.ParseUint(deploymentIDStr, 10, 64) - if err != nil { + deploymentID := c.Param("id") + if deploymentID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"}) return } @@ -86,8 +84,8 @@ func (h *Handler) streamDeploymentLogs(c *gin.Context) { } defer conn.Close() - logChan := Hub.Subscribe(uint(deploymentID)) - defer Hub.Unsubscribe(uint(deploymentID), logChan) + logChan := Hub.Subscribe(deploymentID) + defer Hub.Unsubscribe(deploymentID, logChan) go func() { for { diff --git a/backend/internal/api/webhook.go b/backend/internal/api/webhook.go index d55a0f9..afafed9 100644 --- a/backend/internal/api/webhook.go +++ b/backend/internal/api/webhook.go @@ -5,38 +5,41 @@ import ( "fmt" "io" "net/http" - "strconv" "clickploy/internal/db" "clickploy/internal/models" "github.com/gin-gonic/gin" + gonanoid "github.com/matoous/go-nanoid/v2" ) func (h *Handler) RegisterWebhookRoutes(r *gin.Engine) { - r.POST("/webhooks/trigger", h.handleWebhook) + r.POST("/projects/:projectID/webhook/:webhookID", h.handleWebhook) } func (h *Handler) handleWebhook(c *gin.Context) { - projectIDHex := c.Query("project_id") - if projectIDHex == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "project_id required"}) - return - } + projectID := c.Param("projectID") + webhookSecret := c.Param("webhookID") - pid, err := strconv.ParseUint(projectIDHex, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"}) + if projectID == "" || webhookSecret == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook url"}) return } var project models.Project - if result := db.DB.Preload("EnvVars").First(&project, pid); result.Error != nil { + if result := db.DB.Preload("EnvVars").Where("id = ?", projectID).First(&project); result.Error != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } + if project.WebhookSecret != webhookSecret { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Webhook Secret"}) + return + } + + depID, _ := gonanoid.Generate("abcdefghijklmnopqrstuvwxyz0123456789", 10) deployment := models.Deployment{ + ID: depID, ProjectID: project.ID, Status: "building", Commit: "WEBHOOK", @@ -57,7 +60,7 @@ func (h *Handler) handleWebhook(c *gin.Context) { envMap[env.Key] = env.Value } - imageName, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi) + imageName, commitHash, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi) deployment.Logs = logBuffer.String() if err != nil { deployment.Status = "failed" @@ -66,6 +69,10 @@ func (h *Handler) handleWebhook(c *gin.Context) { return } + if commitHash != "" { + deployment.Commit = commitHash + } + var envStrings []string for _, env := range project.EnvVars { envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value)) diff --git a/backend/internal/auth/utils.go b/backend/internal/auth/utils.go index 6456fb4..b37683f 100644 --- a/backend/internal/auth/utils.go +++ b/backend/internal/auth/utils.go @@ -11,7 +11,7 @@ import ( var SecretKey = []byte("super-secret-key-change-me") type Claims struct { - UserID uint `json:"user_id"` + UserID string `json:"user_id"` Email string `json:"email"` jwt.RegisteredClaims } @@ -26,7 +26,7 @@ func CheckPassword(password, hash string) bool { return err == nil } -func GenerateToken(userID uint, email string) (string, error) { +func GenerateToken(userID string, email string) (string, error) { expirationTime := time.Now().Add(24 * time.Hour) claims := &Claims{ UserID: userID, diff --git a/backend/internal/builder/builder.go b/backend/internal/builder/builder.go index 2283edb..25a7e1d 100644 --- a/backend/internal/builder/builder.go +++ b/backend/internal/builder/builder.go @@ -16,20 +16,20 @@ func NewBuilder() *Builder { return &Builder{} } -func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installCmd, runtime string, envVars map[string]string, logWriter io.Writer) (string, error) { +func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installCmd, runtime string, envVars map[string]string, logWriter io.Writer) (string, string, error) { workDir := filepath.Join("/tmp", "paas-builds", appName) if err := os.RemoveAll(workDir); err != nil { - return "", fmt.Errorf("failed to clean work dir: %w", err) + return "", "", fmt.Errorf("failed to clean work dir: %w", err) } if err := os.MkdirAll(workDir, 0755); err != nil { - return "", fmt.Errorf("failed to create work dir: %w", err) + return "", "", fmt.Errorf("failed to create work dir: %w", err) } cloneURL := repoURL if gitToken != "" { u, err := url.Parse(repoURL) if err != nil { - return "", fmt.Errorf("invalid repo url: %w", err) + return "", "", fmt.Errorf("invalid repo url: %w", err) } u.User = url.UserPassword("oauth2", gitToken) cloneURL = u.String() @@ -41,7 +41,19 @@ func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installC cloneCmd.Stdout = logWriter cloneCmd.Stderr = logWriter if err := cloneCmd.Run(); err != nil { - return "", fmt.Errorf("git clone failed: %w", err) + return "", "", fmt.Errorf("git clone failed: %w", err) + } + + // Get commit hash + commitCmd := exec.Command("git", "rev-parse", "HEAD") + commitCmd.Dir = workDir + commitHashBytes, err := commitCmd.Output() + commitHash := "" + if err == nil { + commitHash = strings.TrimSpace(string(commitHashBytes)) + fmt.Fprintf(logWriter, ">>> Checked out commit: %s\n", commitHash) + } else { + fmt.Fprintf(logWriter, ">>> Failed to get commit hash: %v\n", err) } if runtime == "" { @@ -106,7 +118,7 @@ cmd = "%s" if _, err := os.Stat(filepath.Join(workDir, "package.json")); err == nil { configPath := filepath.Join(workDir, "nixpacks.toml") if err := os.WriteFile(configPath, []byte(nixpacksConfig), 0644); err != nil { - return "", fmt.Errorf("failed to write nixpacks.toml: %w", err) + return "", "", fmt.Errorf("failed to write nixpacks.toml: %w", err) } } @@ -129,10 +141,10 @@ cmd = "%s" ) if err := nixCmd.Run(); err != nil { - return "", fmt.Errorf("nixpacks build failed: %w", err) + return "", "", fmt.Errorf("nixpacks build failed: %w", err) } fmt.Fprintf(logWriter, "\n>>> Build successful!\n") - return imageName, nil + return imageName, commitHash, nil } diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 077d656..57f13e0 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -1,49 +1,61 @@ package models import ( + "time" + "gorm.io/gorm" ) type User struct { - gorm.Model - Email string `gorm:"uniqueIndex;not null" json:"email"` - Password string `json:"-"` - Name string `json:"name"` - Avatar string `json:"avatar"` - Projects []Project `gorm:"foreignKey:OwnerID" json:"projects"` + ID string `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Password string `json:"-"` + Name string `json:"name"` + Avatar string `json:"avatar"` + IsAdmin bool `json:"is_admin"` + Projects []Project `gorm:"foreignKey:OwnerID" json:"projects"` } type Project struct { - gorm.Model - Name string `gorm:"uniqueIndex" json:"name"` - RepoURL string `json:"repo_url"` - OwnerID uint `json:"owner_id"` - Port int `json:"port"` - WebhookSecret string `json:"webhook_secret"` - GitToken string `json:"-"` - BuildCommand string `json:"build_command"` - StartCommand string `json:"start_command"` - InstallCommand string `json:"install_command"` - Runtime string `json:"runtime"` - Deployments []Deployment `gorm:"foreignKey:ProjectID" json:"deployments"` - EnvVars []EnvVar `gorm:"foreignKey:ProjectID" json:"env_vars"` + ID string `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + Name string `gorm:"uniqueIndex" json:"name"` + RepoURL string `json:"repo_url"` + OwnerID string `json:"owner_id"` + Port int `json:"port"` + WebhookSecret string `json:"webhook_secret"` + GitToken string `json:"-"` + BuildCommand string `json:"build_command"` + StartCommand string `json:"start_command"` + InstallCommand string `json:"install_command"` + Runtime string `json:"runtime"` + Deployments []Deployment `gorm:"foreignKey:ProjectID" json:"deployments"` + EnvVars []EnvVar `gorm:"foreignKey:ProjectID" json:"env_vars"` } type EnvVar struct { gorm.Model - ProjectID uint `json:"project_id"` + ProjectID string `json:"project_id"` Key string `json:"key"` Value string `json:"value"` } type Deployment struct { - gorm.Model - ProjectID uint `json:"project_id"` - Project Project `json:"project" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` - Status string `json:"status"` - Commit string `json:"commit"` - Logs string `json:"logs"` - URL string `json:"url"` + ID string `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` + ProjectID string `json:"project_id"` + Project Project `json:"project" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + Status string `json:"status"` + Commit string `json:"commit"` + Logs string `json:"logs"` + URL string `json:"url"` } type Database struct { @@ -51,6 +63,6 @@ type Database struct { Name string `json:"name"` Type string `json:"type"` Status string `json:"status"` - OwnerID uint `json:"owner_id"` + OwnerID string `json:"owner_id"` SizeMB float64 `json:"size_mb"` } diff --git a/frontend/README.md b/frontend/README.md index bc4ce7c..d282a87 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -15,7 +15,8 @@ A modern, responsive dashboard to manage your applications, monitor deployments, ## Features - **Project List**: View all deployed applications. -- **Real-time Logs**: Watch builds and runtime logs live. +- **Deployment History**: Track past builds and status. +- **Real-time Logs**: Watch builds and runtime logs live via WebSockets. - **Redeploy**: Trigger manual redeployments. - **Environment Variables**: Manage runtime configuration. - **Responsive Design**: Works on desktop and mobile. diff --git a/frontend/package.json b/frontend/package.json index ae1dec1..deff9af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { - "@internationalized/date": "^3.10.1", + "@internationalized/date": "^3.11.0", "@lucide/svelte": "^0.561.0", "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/kit": "^2.50.2", @@ -32,6 +32,8 @@ "vite": "^7.3.1" }, "dependencies": { + "gray-matter": "^4.0.3", + "marked": "^17.0.1", "svelte-sonner": "^1.0.7" } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fa7f88d..6ab9d28 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -22,12 +22,23 @@ export interface EnvVar { value: string; } +export interface Deployment { + id: string; + project_id: string; + status: string; + commit: string; + logs: string; + url: string; + created_at: string; + updated_at: string; +} + export interface Project { - ID: number; + id: string; name: string; repo_url: string; port: number; - deployments: any[]; + deployments: Deployment[]; env_vars: EnvVar[]; webhook_secret: string; git_token?: string; @@ -228,3 +239,33 @@ export async function createDatabase(name: string, type: string = "sqlite") { return null; } } + +export async function getAdminUsers() { + try { + return await fetchWithAuth("/api/admin/users"); + } catch (e: any) { + toast.error(e.message); + return []; + } +} + +export async function deleteAdminUser(id: string) { + try { + await fetchWithAuth(`/api/admin/users/${id}`, { + method: "DELETE", + }); + return true; + } catch (e: any) { + toast.error(e.message); + return false; + } +} + +export async function getAdminStats() { + try { + return await fetchWithAuth("/api/admin/stats"); + } catch (e: any) { + console.error(e); + return null; + } +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 8473d7f..a3eb565 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -2,10 +2,11 @@ import { writable } from 'svelte/store'; import { browser } from '$app/environment'; export interface User { - ID: number; + id: string; email: string; name: string; avatar: string; + is_admin: boolean; } const storedUser = browser ? localStorage.getItem('user') : null; diff --git a/frontend/src/lib/components/Footer.svelte b/frontend/src/lib/components/Footer.svelte index d2f3b45..53a16a4 100644 --- a/frontend/src/lib/components/Footer.svelte +++ b/frontend/src/lib/components/Footer.svelte @@ -42,15 +42,13 @@ class="flex flex-wrap justify-center gap-4 text-sm text-muted-foreground" > Home - Docs + Docs GitHub - Support - Legal @@ -63,6 +61,11 @@ {isNormal ? "All systems normal." : status} + {#if version} + + {version} + + {/if} diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 9ca2ead..a50462f 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -12,6 +12,8 @@ Rocket, Network, Database, + Shield, + Book, } from "@lucide/svelte"; import { page } from "$app/stores"; import * as Sheet from "$lib/components/ui/sheet"; @@ -35,48 +37,83 @@ {#if $user} {/if} @@ -84,6 +121,26 @@ {#if $user}
+ {$page.error?.message || "Something went wrong."} +
+ +- The simplified PaaS for your personal projects. Push, build, and scale - without the complexity. -
-+ Self-hosted PaaS made simple. Push your code, we handle the rest. No + complex configs, just pure deployment power. +
+System overview and user management.
++ A minimal, self-hosted Platform as a Service (PaaS) for building and + deploying applications quickly. +
++ Clickploy is designed to be a simple, powerful alternative to complex + container orchestration platforms. It leverages Docker and Nixpacks to turn + your Git repositories into running applications with zero configuration. +
+
+ The backend exposes a RESTful API for automation and integration. All
+ authenticated endpoints require a Bearer token.
+
+ How Clickploy components interact. +
++ Clickploy operates as a control plane for your deployments. Here's how the + components interact: +
++ Explore the core capabilities of Clickploy and learn how to use them. +
++ Clickploy automatically detects your application's language and framework (Node.js, Python, Go, Rust, etc.) and builds a container image without needing a Dockerfile. +
++ Every application runs in its own isolated Docker container. This ensures consistent performance, security, and prevents dependency conflicts between projects. +
++ Watch your build process and application logs stream in real-time via WebSockets. Access historical logs for any past deployment to debug issues. +
++ Manage environment variables (API keys, database URLs) securely. Variables are injected into the container at runtime. +
++ Provision lightweight SQLite databases instantly for your applications. Perfect for prototypes and small-to-medium apps. +
++ Administrators have full visibility into system performance, user registration, and global deployment statistics. +
+