Admin Dashboard, Webdocs, LICENSE, webhook, and ID's update.

This commit is contained in:
2026-02-04 03:05:12 +00:00
parent 890e52af8c
commit 1d0ccca7d1
51 changed files with 1290 additions and 229 deletions

View File

@@ -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,
})
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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"})

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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"`
}