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