Initial commit

This commit is contained in:
2026-02-04 00:17:30 +00:00
commit 890e52af8c
127 changed files with 9682 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
package api
import (
"fmt"
"net/http"
"os"
"github.com/gin-gonic/gin"
"clickploy/internal/builder"
"clickploy/internal/deployer"
"clickploy/internal/ports"
)
type Handler struct {
builder *builder.Builder
deployer *deployer.Deployer
ports *ports.Manager
}
func NewHandler(b *builder.Builder, d *deployer.Deployer, p *ports.Manager) *Handler {
return &Handler{
builder: b,
deployer: d,
ports: p,
}
}
type DeployRequest struct {
Repo string `json:"repo" binding:"required"`
Name string `json:"name" binding:"required"`
Port int `json:"port"`
GitToken string `json:"git_token"`
}
type DeployResponse struct {
Status string `json:"status"`
AppName string `json:"app_name"`
Port int `json:"port"`
URL string `json:"url"`
Message string `json:"message"`
}
func (h *Handler) RegisterRoutes(r *gin.Engine) {
r.POST("/deploy", h.handleDeploy)
h.RegisterStreamRoutes(r)
}
func (h *Handler) handleDeploy(c *gin.Context) {
var req DeployRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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
}
port, err := h.ports.GetPort(req.Name, req.Port)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)})
return
}
_, err = h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Deployment failed: %v", err)})
return
}
c.JSON(http.StatusOK, DeployResponse{
Status: "success",
AppName: req.Name,
Port: port,
URL: fmt.Sprintf("http://localhost:%d", port),
Message: "Container started successfully",
})
}
func (h *Handler) RegisterSystemRoutes(r *gin.Engine) {
r.GET("/api/system/status", h.handleSystemStatus)
}
func (h *Handler) handleSystemStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": "v0.1.0",
"status": "All systems normal",
})
}

View File

@@ -0,0 +1,147 @@
package api
import (
"net/http"
"clickploy/internal/auth"
"clickploy/internal/db"
"clickploy/internal/models"
"github.com/gin-gonic/gin"
)
type AuthRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Name string `json:"name"`
}
type AuthResponse struct {
Token string `json:"token"`
User models.User `json:"user"`
}
func (h *Handler) RegisterAuthRoutes(r *gin.Engine) {
authGroup := r.Group("/auth")
{
authGroup.POST("/register", h.register)
authGroup.POST("/login", h.login)
}
}
func (h *Handler) register(c *gin.Context) {
var req AuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hashed, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
user := models.User{
Email: req.Email,
Password: hashed,
Name: req.Name,
Avatar: "https://github.com/shadcn.png",
}
if result := db.DB.Create(&user); result.Error != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
return
}
token, _ := auth.GenerateToken(user.ID, user.Email)
c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user})
}
func (h *Handler) login(c *gin.Context) {
var req AuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if result := db.DB.Where("email = ?", req.Email).First(&user); result.Error != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if !auth.CheckPassword(req.Password, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
token, _ := auth.GenerateToken(user.ID, user.Email)
c.JSON(http.StatusOK, AuthResponse{Token: token, User: user})
}
func (h *Handler) RegisterUserRoutes(r *gin.Engine) {
userGroup := r.Group("/api/user", AuthMiddleware())
{
userGroup.PUT("/profile", h.updateProfile)
userGroup.PUT("/password", h.updatePassword)
}
}
func (h *Handler) updateProfile(c *gin.Context) {
userID, _ := c.Get("userID")
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if result := db.DB.First(&user, userID); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if req.Name != "" {
user.Name = req.Name
}
if req.Email != "" {
user.Email = req.Email
}
db.DB.Save(&user)
c.JSON(http.StatusOK, user)
}
func (h *Handler) updatePassword(c *gin.Context) {
userID, _ := c.Get("userID")
var req struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if result := db.DB.First(&user, userID); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if !auth.CheckPassword(req.OldPassword, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Incorrect old password"})
return
}
hashed, _ := auth.HashPassword(req.NewPassword)
user.Password = hashed
db.DB.Save(&user)
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
}

View File

@@ -0,0 +1,33 @@
package api
import (
"net/http"
"strings"
"clickploy/internal/auth"
"github.com/gin-gonic/gin"
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := auth.ValidateToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Set("userID", claims.UserID)
c.Set("email", claims.Email)
c.Next()
}
}

View File

@@ -0,0 +1,283 @@
package api
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"time"
"clickploy/internal/auth"
"clickploy/internal/db"
"clickploy/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func (h *Handler) RegisterProjectRoutes(r *gin.Engine) {
protected := r.Group("/api", AuthMiddleware())
{
protected.POST("/projects", h.createProject)
protected.GET("/projects", h.listProjects)
protected.GET("/projects/:id", h.getProject)
protected.PUT("/projects/:id/env", h.updateProjectEnv)
protected.POST("/projects/:id/redeploy", h.redeployProject)
protected.GET("/activity", h.getActivity)
}
}
func (h *Handler) updateProjectEnv(c *gin.Context) {
userID, _ := c.Get("userID")
projectID := c.Param("id")
var req struct {
EnvVars map[string]string `json:"env_vars"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
tx := db.DB.Begin()
if err := tx.Where("project_id = ?", project.ID).Delete(&models.EnvVar{}).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update env vars"})
return
}
for k, v := range req.EnvVars {
envVar := models.EnvVar{
ProjectID: project.ID,
Key: k,
Value: v,
}
if err := tx.Create(&envVar).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save env var"})
return
}
}
tx.Commit()
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
deployment := models.Deployment{
ProjectID: project.ID,
Status: "building",
Commit: "MANUAL",
Logs: "Starting manual redeploy...",
}
db.DB.Create(&deployment)
go func() {
var logBuffer bytes.Buffer
streamer := &StreamWriter{DeploymentID: deployment.ID}
multi := io.MultiWriter(&logBuffer, streamer)
envMap := make(map[string]string)
for _, env := range project.EnvVars {
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)
deployment.Logs = logBuffer.String()
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err)
db.DB.Save(&deployment)
return
}
var envStrings []string
for _, env := range project.EnvVars {
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
}
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, project.Name, project.Port, envStrings)
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err)
db.DB.Save(&deployment)
return
}
deployment.Status = "live"
deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port)
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
db.DB.Save(&deployment)
}()
c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID})
}
func (h *Handler) getActivity(c *gin.Context) {
userID, _ := c.Get("userID")
var deployments []models.Deployment
err := db.DB.Joins("JOIN projects ON projects.id = deployments.project_id").
Where("projects.owner_id = ?", userID).
Order("deployments.created_at desc").
Limit(20).
Preload("Project").
Find(&deployments).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch activity"})
return
}
c.JSON(http.StatusOK, deployments)
}
func (h *Handler) createProject(c *gin.Context) {
userID, _ := c.Get("userID")
var req struct {
DeployRequest
EnvVars map[string]string `json:"env_vars"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
InstallCommand string `json:"install_command"`
Runtime string `json:"runtime"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
port, err := h.ports.GetPort(req.Name, req.Port)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)})
return
}
var envVarsModel []models.EnvVar
var envStrings []string
for k, v := range req.EnvVars {
envStrings = append(envStrings, fmt.Sprintf("%s=%s", k, v))
envVarsModel = append(envVarsModel, models.EnvVar{Key: k, Value: v})
}
webhookSecret, _ := auth.HashPassword(fmt.Sprintf("%s-%d-%d", req.Name, userID, time.Now().UnixNano()))
project := models.Project{
Name: req.Name,
RepoURL: req.Repo,
OwnerID: userID.(uint),
Port: port,
WebhookSecret: webhookSecret,
GitToken: req.GitToken,
EnvVars: envVarsModel,
BuildCommand: req.BuildCommand,
StartCommand: req.StartCommand,
InstallCommand: req.InstallCommand,
Runtime: req.Runtime,
}
if result := db.DB.Create(&project); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save project to DB"})
return
}
deployment := models.Deployment{
ProjectID: project.ID,
Status: "building",
Commit: "HEAD",
Logs: "Starting build...",
}
db.DB.Create(&deployment)
go func() {
var logBuffer bytes.Buffer
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)
deployment.Logs = logBuffer.String()
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err)
db.DB.Save(&deployment)
return
}
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, envStrings)
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err)
db.DB.Save(&deployment)
return
}
deployment.Status = "live"
deployment.URL = fmt.Sprintf("http://localhost:%d", port)
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
db.DB.Save(&deployment)
}()
project.Deployments = []models.Deployment{deployment}
c.JSON(http.StatusOK, project)
}
func (h *Handler) listProjects(c *gin.Context) {
userID, _ := c.Get("userID")
var projects []models.Project
if result := db.DB.Preload("Deployments").Where("owner_id = ?", userID).Find(&projects); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch projects"})
return
}
c.JSON(http.StatusOK, projects)
}
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
c.JSON(http.StatusOK, project)
}

View File

@@ -0,0 +1,98 @@
package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"syscall"
"clickploy/internal/db"
"clickploy/internal/models"
"github.com/gin-gonic/gin"
)
type CreateDatabaseRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required,oneof=sqlite"`
}
type StorageStatsResponse struct {
TotalMB float64 `json:"total_mb"`
UsedMB float64 `json:"used_mb"`
}
func (h *Handler) RegisterStorageRoutes(r *gin.Engine) {
api := r.Group("/api/storage")
api.Use(AuthMiddleware())
{
api.GET("/stats", h.handleGetStorageStats)
api.GET("/databases", h.handleListDatabases)
api.POST("/databases", h.handleCreateDatabase)
}
}
func (h *Handler) handleGetStorageStats(c *gin.Context) {
var stat syscall.Statfs_t
wd, _ := os.Getwd()
if err := syscall.Statfs(wd, &stat); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get disk stats"})
return
}
totalBytes := stat.Blocks * uint64(stat.Bsize)
availBytes := stat.Bavail * uint64(stat.Bsize)
usedBytes := totalBytes - availBytes
c.JSON(http.StatusOK, StorageStatsResponse{
TotalMB: float64(totalBytes) / 1024 / 1024,
UsedMB: float64(usedBytes) / 1024 / 1024,
})
}
func (h *Handler) handleListDatabases(c *gin.Context) {
userId := c.GetUint("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"})
return
}
c.JSON(http.StatusOK, dbs)
}
func (h *Handler) handleCreateDatabase(c *gin.Context) {
userId := c.GetUint("userID")
var req CreateDatabaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
newDB := models.Database{
Name: req.Name,
Type: req.Type,
Status: "available",
OwnerID: userId,
SizeMB: 0,
}
dataDir := "./data/user_dbs"
os.MkdirAll(dataDir, 0755)
dbPath := filepath.Join(dataDir, fmt.Sprintf("%d_%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"})
return
}
file.Close()
if err := db.DB.Create(&newDB).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save database record"})
return
}
c.JSON(http.StatusOK, newDB)
}

View File

@@ -0,0 +1,111 @@
package api
import (
"net/http"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type LogHub struct {
mu sync.Mutex
streams map[uint][]chan []byte
}
var Hub = &LogHub{
streams: make(map[uint][]chan []byte),
}
func (h *LogHub) Broadcast(deploymentID uint, p []byte) {
h.mu.Lock()
defer h.mu.Unlock()
if sinks, ok := h.streams[deploymentID]; ok {
for _, sink := range sinks {
select {
case sink <- p:
default:
}
}
}
}
func (h *LogHub) Subscribe(deploymentID uint) chan []byte {
h.mu.Lock()
defer h.mu.Unlock()
ch := make(chan []byte, 256)
h.streams[deploymentID] = append(h.streams[deploymentID], ch)
return ch
}
func (h *LogHub) Unsubscribe(deploymentID uint, ch chan []byte) {
h.mu.Lock()
defer h.mu.Unlock()
if sinks, ok := h.streams[deploymentID]; ok {
for i, sink := range sinks {
if sink == ch {
h.streams[deploymentID] = append(sinks[:i], sinks[i+1:]...)
close(ch)
break
}
}
if len(h.streams[deploymentID]) == 0 {
delete(h.streams, deploymentID)
}
}
}
type StreamWriter struct {
DeploymentID uint
}
func (w *StreamWriter) Write(p []byte) (n int, err error) {
c := make([]byte, len(p))
copy(c, p)
Hub.Broadcast(w.DeploymentID, c)
return len(p), nil
}
func (h *Handler) streamDeploymentLogs(c *gin.Context) {
deploymentIDStr := c.Param("id")
deploymentID, err := strconv.ParseUint(deploymentIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
logChan := Hub.Subscribe(uint(deploymentID))
defer Hub.Unsubscribe(uint(deploymentID), logChan)
go func() {
for {
if _, _, err := conn.NextReader(); err != nil {
conn.Close()
return
}
}
}()
for logChunk := range logChan {
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, logChunk); err != nil {
return
}
}
}
func (h *Handler) RegisterStreamRoutes(r *gin.Engine) {
r.GET("/api/deployments/:id/logs/stream", h.streamDeploymentLogs)
}

View File

@@ -0,0 +1,89 @@
package api
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"clickploy/internal/db"
"clickploy/internal/models"
"github.com/gin-gonic/gin"
)
func (h *Handler) RegisterWebhookRoutes(r *gin.Engine) {
r.POST("/webhooks/trigger", 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
}
pid, err := strconv.ParseUint(projectIDHex, 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").First(&project, pid); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
deployment := models.Deployment{
ProjectID: project.ID,
Status: "building",
Commit: "WEBHOOK",
Logs: "Webhook triggered. Starting build...",
}
if err := db.DB.Create(&deployment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment record"})
return
}
go func() {
var logBuffer bytes.Buffer
streamer := &StreamWriter{DeploymentID: deployment.ID}
multi := io.MultiWriter(&logBuffer, streamer)
envMap := make(map[string]string)
for _, env := range project.EnvVars {
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)
deployment.Logs = logBuffer.String()
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nBuild Failed: %v", err)
db.DB.Save(&deployment)
return
}
var envStrings []string
for _, env := range project.EnvVars {
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
}
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, project.Name, project.Port, envStrings)
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nDeployment Failed: %v", err)
db.DB.Save(&deployment)
return
}
deployment.Status = "live"
deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port)
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
db.DB.Save(&deployment)
}()
c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID})
}