package handlers
import (
"net/http"
"strconv"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AccessListHandler struct {
service *services.AccessListService
}
func NewAccessListHandler(db *gorm.DB) *AccessListHandler {
return &AccessListHandler{
service: services.NewAccessListService(db),
}
}
// Create handles POST /api/v1/access-lists
func (h *AccessListHandler) Create(c *gin.Context) {
var acl models.AccessList
if err := c.ShouldBindJSON(&acl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.Create(&acl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, acl)
}
// List handles GET /api/v1/access-lists
func (h *AccessListHandler) List(c *gin.Context) {
acls, err := h.service.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, acls)
}
// Get handles GET /api/v1/access-lists/:id
func (h *AccessListHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
acl, err := h.service.GetByID(uint(id))
if err != nil {
if err == services.ErrAccessListNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, acl)
}
// Update handles PUT /api/v1/access-lists/:id
func (h *AccessListHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
var updates models.AccessList
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.Update(uint(id), &updates); err != nil {
if err == services.ErrAccessListNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Fetch updated record
acl, _ := h.service.GetByID(uint(id))
c.JSON(http.StatusOK, acl)
}
// Delete handles DELETE /api/v1/access-lists/:id
func (h *AccessListHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
if err := h.service.Delete(uint(id)); err != nil {
if err == services.ErrAccessListNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
if err == services.ErrAccessListInUse {
c.JSON(http.StatusConflict, gin.H{"error": "access list is in use by proxy hosts"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "access list deleted"})
}
// TestIP handles POST /api/v1/access-lists/:id/test
func (h *AccessListHandler) TestIP(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
var req struct {
IPAddress string `json:"ip_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
allowed, reason, err := h.service.TestIP(uint(id), req.IPAddress)
if err != nil {
if err == services.ErrAccessListNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
if err == services.ErrInvalidIPAddress {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"allowed": allowed,
"reason": reason,
})
}
// GetTemplates handles GET /api/v1/access-lists/templates
func (h *AccessListHandler) GetTemplates(c *gin.Context) {
templates := h.service.GetTemplates()
c.JSON(http.StatusOK, templates)
}
package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
authService *services.AuthService
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
token, err := h.authService.Login(req.Email, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// Set cookie
c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod
c.JSON(http.StatusOK, gin.H{"token": token})
}
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name" binding:"required"`
}
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.authService.Register(req.Email, req.Password, req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, user)
}
func (h *AuthHandler) Logout(c *gin.Context) {
c.SetCookie("auth_token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
func (h *AuthHandler) Me(c *gin.Context) {
userID, _ := c.Get("userID")
role, _ := c.Get("role")
u, err := h.authService.GetUserByID(userID.(uint))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"role": role,
"name": u.Name,
"email": u.Email,
})
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
}
package handlers
import (
"net/http"
"os"
"path/filepath"
"github.com/Wikid82/charon/backend/internal/api/middleware"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
"github.com/gin-gonic/gin"
)
type BackupHandler struct {
service *services.BackupService
}
func NewBackupHandler(service *services.BackupService) *BackupHandler {
return &BackupHandler{service: service}
}
func (h *BackupHandler) List(c *gin.Context) {
backups, err := h.service.ListBackups()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backups"})
return
}
c.JSON(http.StatusOK, backups)
}
func (h *BackupHandler) Create(c *gin.Context) {
filename, err := h.service.CreateBackup()
if err != nil {
middleware.GetRequestLogger(c).WithField("action", "create_backup").WithError(err).Error("Failed to create backup")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup: " + err.Error()})
return
}
middleware.GetRequestLogger(c).WithField("action", "create_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup created successfully")
c.JSON(http.StatusCreated, gin.H{"filename": filename, "message": "Backup created successfully"})
}
func (h *BackupHandler) Delete(c *gin.Context) {
filename := c.Param("filename")
if err := h.service.DeleteBackup(filename); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Backup deleted"})
}
func (h *BackupHandler) Download(c *gin.Context) {
filename := c.Param("filename")
path, err := h.service.GetBackupPath(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := os.Stat(path); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(path)
}
func (h *BackupHandler) Restore(c *gin.Context) {
filename := c.Param("filename")
if err := h.service.RestoreBackup(filename); err != nil {
middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).WithError(err).Error("Failed to restore backup")
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore backup: " + err.Error()})
return
}
middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup restored successfully")
// In a real scenario, we might want to trigger a restart here
c.JSON(http.StatusOK, gin.H{"message": "Backup restored successfully. Please restart the container."})
}
package handlers
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// BackupServiceInterface defines the contract for backup service operations
type BackupServiceInterface interface {
CreateBackup() (string, error)
ListBackups() ([]services.BackupFile, error)
DeleteBackup(filename string) error
GetBackupPath(filename string) (string, error)
RestoreBackup(filename string) error
}
type CertificateHandler struct {
service *services.CertificateService
backupService BackupServiceInterface
notificationService *services.NotificationService
}
func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler {
return &CertificateHandler{
service: service,
backupService: backupService,
notificationService: ns,
}
}
func (h *CertificateHandler) List(c *gin.Context) {
certs, err := h.service.ListCertificates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, certs)
}
type UploadCertificateRequest struct {
Name string `form:"name" binding:"required"`
Certificate string `form:"certificate"` // PEM content
PrivateKey string `form:"private_key"` // PEM content
}
func (h *CertificateHandler) Upload(c *gin.Context) {
// Handle multipart form
name := c.PostForm("name")
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
// Read files
certFile, err := c.FormFile("certificate_file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"})
return
}
keyFile, err := c.FormFile("key_file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required"})
return
}
// Open and read content
certSrc, err := certFile.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
return
}
defer func() { _ = certSrc.Close() }()
keySrc, err := keyFile.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
return
}
defer func() { _ = keySrc.Close() }()
// Read to string
// Limit size to avoid DoS (e.g. 1MB)
certBytes := make([]byte, 1024*1024)
n, _ := certSrc.Read(certBytes)
certPEM := string(certBytes[:n])
keyBytes := make([]byte, 1024*1024)
n, _ = keySrc.Read(keyBytes)
keyPEM := string(keyBytes[:n])
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"cert",
"Certificate Uploaded",
fmt.Sprintf("Certificate %s uploaded", util.SanitizeForLog(cert.Name)),
map[string]interface{}{
"Name": util.SanitizeForLog(cert.Name),
"Domains": util.SanitizeForLog(cert.Domains),
"Action": "uploaded",
},
)
}
c.JSON(http.StatusCreated, cert)
}
func (h *CertificateHandler) Delete(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
// Check if certificate is in use before proceeding
inUse, err := h.service.IsCertificateInUse(uint(id))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"})
return
}
if inUse {
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
return
}
// Create backup before deletion
if h.backupService != nil {
if _, err := h.backupService.CreateBackup(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup before deletion"})
return
}
}
// Proceed with deletion
if err := h.service.DeleteCertificate(uint(id)); err != nil {
if err == services.ErrCertInUse {
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"cert",
"Certificate Deleted",
fmt.Sprintf("Certificate ID %d deleted", id),
map[string]interface{}{
"ID": id,
"Action": "deleted",
},
)
}
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
}
package handlers
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
)
// DefaultCrowdsecExecutor implements CrowdsecExecutor using OS processes.
type DefaultCrowdsecExecutor struct {
}
func NewDefaultCrowdsecExecutor() *DefaultCrowdsecExecutor { return &DefaultCrowdsecExecutor{} }
func (e *DefaultCrowdsecExecutor) pidFile(configDir string) string {
return filepath.Join(configDir, "crowdsec.pid")
}
func (e *DefaultCrowdsecExecutor) Start(ctx context.Context, binPath, configDir string) (int, error) {
cmd := exec.CommandContext(ctx, binPath, "--config-dir", configDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return 0, err
}
pid := cmd.Process.Pid
// write pid file
if err := os.WriteFile(e.pidFile(configDir), []byte(strconv.Itoa(pid)), 0o644); err != nil {
return pid, fmt.Errorf("failed to write pid file: %w", err)
}
// wait in background
go func() {
_ = cmd.Wait()
_ = os.Remove(e.pidFile(configDir))
}()
return pid, nil
}
func (e *DefaultCrowdsecExecutor) Stop(ctx context.Context, configDir string) error {
b, err := os.ReadFile(e.pidFile(configDir))
if err != nil {
return fmt.Errorf("pid file read: %w", err)
}
pid, err := strconv.Atoi(string(b))
if err != nil {
return fmt.Errorf("invalid pid: %w", err)
}
proc, err := os.FindProcess(pid)
if err != nil {
return err
}
if err := proc.Signal(syscall.SIGTERM); err != nil {
return err
}
// best-effort remove pid file
_ = os.Remove(e.pidFile(configDir))
return nil
}
func (e *DefaultCrowdsecExecutor) Status(ctx context.Context, configDir string) (bool, int, error) {
b, err := os.ReadFile(e.pidFile(configDir))
if err != nil {
return false, 0, nil
}
pid, err := strconv.Atoi(string(b))
if err != nil {
return false, 0, nil
}
// Check process exists
proc, err := os.FindProcess(pid)
if err != nil {
return false, pid, nil
}
// Sending signal 0 is not portable on Windows, but OK for Linux containers
if err := proc.Signal(syscall.Signal(0)); err != nil {
return false, pid, nil
}
return true, pid, nil
}
package handlers
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"github.com/Wikid82/charon/backend/internal/logger"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Executor abstracts starting/stopping CrowdSec so tests can mock it.
type CrowdsecExecutor interface {
Start(ctx context.Context, binPath, configDir string) (int, error)
Stop(ctx context.Context, configDir string) error
Status(ctx context.Context, configDir string) (running bool, pid int, err error)
}
// CrowdsecHandler manages CrowdSec process and config imports.
type CrowdsecHandler struct {
DB *gorm.DB
Executor CrowdsecExecutor
BinPath string
DataDir string
}
func NewCrowdsecHandler(db *gorm.DB, exec CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler {
return &CrowdsecHandler{DB: db, Executor: exec, BinPath: binPath, DataDir: dataDir}
}
// Start starts the CrowdSec process.
func (h *CrowdsecHandler) Start(c *gin.Context) {
ctx := c.Request.Context()
pid, err := h.Executor.Start(ctx, h.BinPath, h.DataDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "started", "pid": pid})
}
// Stop stops the CrowdSec process.
func (h *CrowdsecHandler) Stop(c *gin.Context) {
ctx := c.Request.Context()
if err := h.Executor.Stop(ctx, h.DataDir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "stopped"})
}
// Status returns simple running state.
func (h *CrowdsecHandler) Status(c *gin.Context) {
ctx := c.Request.Context()
running, pid, err := h.Executor.Status(ctx, h.DataDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"running": running, "pid": pid})
}
// ImportConfig accepts a tar.gz or zip upload and extracts into DataDir (backing up existing config).
func (h *CrowdsecHandler) ImportConfig(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
// Save to temp file
tmpDir := os.TempDir()
tmpPath := filepath.Join(tmpDir, fmt.Sprintf("crowdsec-import-%d", time.Now().UnixNano()))
if err := os.MkdirAll(tmpPath, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp dir"})
return
}
dst := filepath.Join(tmpPath, file.Filename)
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
// For safety, do minimal validation: ensure file non-empty
fi, err := os.Stat(dst)
if err != nil || fi.Size() == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "empty upload"})
return
}
// Backup current config
backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405")
if _, err := os.Stat(h.DataDir); err == nil {
_ = os.Rename(h.DataDir, backupDir)
}
// Create target dir
if err := os.MkdirAll(h.DataDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config dir"})
return
}
// For now, simply copy uploaded file into data dir for operator to handle extraction
target := filepath.Join(h.DataDir, file.Filename)
in, err := os.Open(dst)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open temp file"})
return
}
defer in.Close()
out, err := os.Create(target)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create target file"})
return
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": backupDir})
}
// ExportConfig creates a tar.gz archive of the CrowdSec data directory and streams it
// back to the client as a downloadable file.
func (h *CrowdsecHandler) ExportConfig(c *gin.Context) {
// Ensure DataDir exists
if _, err := os.Stat(h.DataDir); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "crowdsec config not found"})
return
}
// Create a gzip writer and tar writer that stream directly to the response
c.Header("Content-Type", "application/gzip")
filename := fmt.Sprintf("crowdsec-config-%s.tar.gz", time.Now().Format("20060102-150405"))
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
gw := gzip.NewWriter(c.Writer)
defer func() {
if err := gw.Close(); err != nil {
logger.Log().WithError(err).Warn("Failed to close gzip writer")
}
}()
tw := tar.NewWriter(gw)
defer func() {
if err := tw.Close(); err != nil {
logger.Log().WithError(err).Warn("Failed to close tar writer")
}
}()
// Walk the DataDir and add files to the archive
err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
rel, err := filepath.Rel(h.DataDir, path)
if err != nil {
return err
}
// Open file
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
hdr := &tar.Header{
Name: rel,
Size: info.Size(),
Mode: int64(info.Mode()),
ModTime: info.ModTime(),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := io.Copy(tw, f); err != nil {
return err
}
return nil
})
if err != nil {
// If any error occurred while creating the archive, return 500
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
// ListFiles returns a flat list of files under the CrowdSec DataDir.
func (h *CrowdsecHandler) ListFiles(c *gin.Context) {
var files []string
if _, err := os.Stat(h.DataDir); os.IsNotExist(err) {
c.JSON(http.StatusOK, gin.H{"files": files})
return
}
err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
rel, err := filepath.Rel(h.DataDir, path)
if err != nil {
return err
}
files = append(files, rel)
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"files": files})
}
// ReadFile returns the contents of a specific file under DataDir. Query param 'path' required.
func (h *CrowdsecHandler) ReadFile(c *gin.Context) {
rel := c.Query("path")
if rel == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
return
}
clean := filepath.Clean(rel)
// prevent directory traversal
p := filepath.Join(h.DataDir, clean)
if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
data, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"content": string(data)})
}
// WriteFile writes content to a file under the CrowdSec DataDir, creating a backup before doing so.
// JSON body: { "path": "relative/path.conf", "content": "..." }
func (h *CrowdsecHandler) WriteFile(c *gin.Context) {
var payload struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if payload.Path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
return
}
clean := filepath.Clean(payload.Path)
p := filepath.Join(h.DataDir, clean)
if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
// Backup existing DataDir
backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405")
if _, err := os.Stat(h.DataDir); err == nil {
if err := os.Rename(h.DataDir, backupDir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup"})
return
}
}
// Recreate DataDir and write file
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare dir"})
return
}
if err := os.WriteFile(p, []byte(payload.Content), 0o644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir})
}
// RegisterRoutes registers crowdsec admin routes under protected group
func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/admin/crowdsec/start", h.Start)
rg.POST("/admin/crowdsec/stop", h.Stop)
rg.GET("/admin/crowdsec/status", h.Status)
rg.POST("/admin/crowdsec/import", h.ImportConfig)
rg.GET("/admin/crowdsec/export", h.ExportConfig)
rg.GET("/admin/crowdsec/files", h.ListFiles)
rg.GET("/admin/crowdsec/file", h.ReadFile)
rg.POST("/admin/crowdsec/file", h.WriteFile)
}
package handlers
import (
"fmt"
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type DockerHandler struct {
dockerService *services.DockerService
remoteServerService *services.RemoteServerService
}
func NewDockerHandler(dockerService *services.DockerService, remoteServerService *services.RemoteServerService) *DockerHandler {
return &DockerHandler{
dockerService: dockerService,
remoteServerService: remoteServerService,
}
}
func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/docker/containers", h.ListContainers)
}
func (h *DockerHandler) ListContainers(c *gin.Context) {
host := c.Query("host")
serverID := c.Query("server_id")
// If server_id is provided, look up the remote server
if serverID != "" {
server, err := h.remoteServerService.GetByUUID(serverID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Remote server not found"})
return
}
// Construct Docker host string
// Assuming TCP for now as that's what RemoteServer supports (Host/Port)
// TODO: Support SSH if/when RemoteServer supports it
host = fmt.Sprintf("tcp://%s:%d", server.Host, server.Port)
}
containers, err := h.dockerService.ListContainers(c.Request.Context(), host)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers: " + err.Error()})
return
}
c.JSON(http.StatusOK, containers)
}
package handlers
import (
"fmt"
"net/http"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DomainHandler struct {
DB *gorm.DB
notificationService *services.NotificationService
}
func NewDomainHandler(db *gorm.DB, ns *services.NotificationService) *DomainHandler {
return &DomainHandler{
DB: db,
notificationService: ns,
}
}
func (h *DomainHandler) List(c *gin.Context) {
var domains []models.Domain
if err := h.DB.Order("name asc").Find(&domains).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch domains"})
return
}
c.JSON(http.StatusOK, domains)
}
func (h *DomainHandler) Create(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
domain := models.Domain{
Name: input.Name,
}
if err := h.DB.Create(&domain).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create domain"})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"domain",
"Domain Added",
fmt.Sprintf("Domain %s added", util.SanitizeForLog(domain.Name)),
map[string]interface{}{
"Name": util.SanitizeForLog(domain.Name),
"Action": "created",
},
)
}
c.JSON(http.StatusCreated, domain)
}
func (h *DomainHandler) Delete(c *gin.Context) {
id := c.Param("id")
var domain models.Domain
if err := h.DB.Where("uuid = ?", id).First(&domain).Error; err == nil {
// Send Notification before delete (or after if we keep the name)
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"domain",
"Domain Deleted",
fmt.Sprintf("Domain %s deleted", util.SanitizeForLog(domain.Name)),
map[string]interface{}{
"Name": util.SanitizeForLog(domain.Name),
"Action": "deleted",
},
)
}
}
if err := h.DB.Where("uuid = ?", id).Delete(&models.Domain{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete domain"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Domain deleted"})
}
package handlers
import (
"net/http"
"os"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
// FeatureFlagsHandler exposes simple DB-backed feature flags with env fallback.
type FeatureFlagsHandler struct {
DB *gorm.DB
}
func NewFeatureFlagsHandler(db *gorm.DB) *FeatureFlagsHandler {
return &FeatureFlagsHandler{DB: db}
}
// defaultFlags lists the canonical feature flags we expose.
var defaultFlags = []string{
"feature.global.enabled",
"feature.cerberus.enabled",
"feature.uptime.enabled",
"feature.notifications.enabled",
"feature.docker.enabled",
}
// GetFlags returns a map of feature flag -> bool. DB setting takes precedence
// and falls back to environment variables if present.
func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
result := make(map[string]bool)
for _, key := range defaultFlags {
// Try DB
var s models.Setting
if err := h.DB.Where("key = ?", key).First(&s).Error; err == nil {
v := strings.ToLower(strings.TrimSpace(s.Value))
b := v == "1" || v == "true" || v == "yes"
result[key] = b
continue
}
// Fallback to env vars. Try FEATURE_... and also stripped service name e.g. CERBERUS_ENABLED
envKey := strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
if ev, ok := os.LookupEnv(envKey); ok {
if bv, err := strconv.ParseBool(ev); err == nil {
result[key] = bv
continue
}
// accept 1/0
result[key] = ev == "1"
continue
}
// Try shorter variant after removing leading "feature."
if strings.HasPrefix(key, "feature.") {
short := strings.ToUpper(strings.ReplaceAll(strings.TrimPrefix(key, "feature."), ".", "_"))
if ev, ok := os.LookupEnv(short); ok {
if bv, err := strconv.ParseBool(ev); err == nil {
result[key] = bv
continue
}
result[key] = ev == "1"
continue
}
}
// Default false
result[key] = false
}
c.JSON(http.StatusOK, result)
}
// UpdateFlags accepts a JSON object map[string]bool and upserts settings.
func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
var payload map[string]bool
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for k, v := range payload {
// Only allow keys in the default list to avoid arbitrary settings
allowed := false
for _, ak := range defaultFlags {
if ak == k {
allowed = true
break
}
}
if !allowed {
continue
}
s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"}
if err := h.DB.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save setting"})
return
}
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
package handlers
import (
"net"
"net/http"
"github.com/Wikid82/charon/backend/internal/version"
"github.com/gin-gonic/gin"
)
// getLocalIP returns the non-loopback local IP of the host
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, address := range addrs {
// check the address type and if it is not a loopback then return it
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
}
return ""
}
// HealthHandler responds with basic service metadata for uptime checks.
func HealthHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": version.Name,
"version": version.Version,
"git_commit": version.GitCommit,
"build_time": version.BuildTime,
"internal_ip": getLocalIP(),
})
}
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/middleware"
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// ImportHandler handles Caddyfile import operations.
type ImportHandler struct {
db *gorm.DB
proxyHostSvc *services.ProxyHostService
importerservice *caddy.Importer
importDir string
mountPath string
}
// NewImportHandler creates a new import handler.
func NewImportHandler(db *gorm.DB, caddyBinary, importDir, mountPath string) *ImportHandler {
return &ImportHandler{
db: db,
proxyHostSvc: services.NewProxyHostService(db),
importerservice: caddy.NewImporter(caddyBinary),
importDir: importDir,
mountPath: mountPath,
}
}
// RegisterRoutes registers import-related routes.
func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/import/status", h.GetStatus)
router.GET("/import/preview", h.GetPreview)
router.POST("/import/upload", h.Upload)
router.POST("/import/upload-multi", h.UploadMulti)
router.POST("/import/detect-imports", h.DetectImports)
router.POST("/import/commit", h.Commit)
router.DELETE("/import/cancel", h.Cancel)
}
// GetStatus returns current import session status.
func (h *ImportHandler) GetStatus(c *gin.Context) {
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").
First(&session).Error
if err == gorm.ErrRecordNotFound {
// No pending/reviewing session, check if there's a mounted Caddyfile available for transient preview
if h.mountPath != "" {
if fileInfo, err := os.Stat(h.mountPath); err == nil {
// Check if this mount has already been committed recently
var committedSession models.ImportSession
err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
Order("committed_at DESC").
First(&committedSession).Error
// Allow re-import if:
// 1. Never committed before (err == gorm.ErrRecordNotFound), OR
// 2. File was modified after last commit
allowImport := err == gorm.ErrRecordNotFound
if !allowImport && committedSession.CommittedAt != nil {
fileMod := fileInfo.ModTime()
commitTime := *committedSession.CommittedAt
allowImport = fileMod.After(commitTime)
}
if allowImport {
// Mount file is available for import
c.JSON(http.StatusOK, gin.H{
"has_pending": true,
"session": gin.H{
"id": "transient",
"state": "transient",
"source_file": h.mountPath,
},
})
return
}
// Mount file was already committed and hasn't been modified, don't offer it again
}
}
c.JSON(http.StatusOK, gin.H{"has_pending": false})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"has_pending": true,
"session": gin.H{
"id": session.UUID,
"state": session.Status,
"created_at": session.CreatedAt,
"updated_at": session.UpdatedAt,
},
})
}
// GetPreview returns parsed hosts and conflicts for review.
func (h *ImportHandler) GetPreview(c *gin.Context) {
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").
First(&session).Error
if err == nil {
// DB session found
var result caddy.ImportResult
if err := json.Unmarshal([]byte(session.ParsedData), &result); err == nil {
// Update status to reviewing
session.Status = "reviewing"
h.db.Save(&session)
// Read original Caddyfile content if available
var caddyfileContent string
if session.SourceFile != "" {
if content, err := os.ReadFile(session.SourceFile); err == nil {
caddyfileContent = string(content)
} else {
backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile))
if content, err := os.ReadFile(backupPath); err == nil {
caddyfileContent = string(content)
}
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{
"id": session.UUID,
"state": session.Status,
"created_at": session.CreatedAt,
"updated_at": session.UpdatedAt,
"source_file": session.SourceFile,
},
"preview": result,
"caddyfile_content": caddyfileContent,
})
return
}
}
// No DB session found or failed to parse session. Try transient preview from mountPath.
if h.mountPath != "" {
if fileInfo, err := os.Stat(h.mountPath); err == nil {
// Check if this mount has already been committed recently
var committedSession models.ImportSession
err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
Order("committed_at DESC").
First(&committedSession).Error
// Allow preview if:
// 1. Never committed before (err == gorm.ErrRecordNotFound), OR
// 2. File was modified after last commit
allowPreview := err == gorm.ErrRecordNotFound
if !allowPreview && committedSession.CommittedAt != nil {
allowPreview = fileInfo.ModTime().After(*committedSession.CommittedAt)
}
if !allowPreview {
// Mount file was already committed and hasn't been modified, don't offer preview again
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
return
}
// Parse mounted Caddyfile transiently
transient, err := h.importerservice.ImportFile(h.mountPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
return
}
// Build a transient session id (not persisted)
sid := uuid.NewString()
var caddyfileContent string
if content, err := os.ReadFile(h.mountPath); err == nil {
caddyfileContent = string(content)
}
// Check for conflicts with existing hosts and build conflict details
existingHosts, _ := h.proxyHostSvc.List()
existingDomainsMap := make(map[string]models.ProxyHost)
for _, eh := range existingHosts {
existingDomainsMap[eh.DomainNames] = eh
}
conflictDetails := make(map[string]gin.H)
for _, ph := range transient.Hosts {
if existing, found := existingDomainsMap[ph.DomainNames]; found {
transient.Conflicts = append(transient.Conflicts, ph.DomainNames)
conflictDetails[ph.DomainNames] = gin.H{
"existing": gin.H{
"forward_scheme": existing.ForwardScheme,
"forward_host": existing.ForwardHost,
"forward_port": existing.ForwardPort,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath},
"preview": transient,
"caddyfile_content": caddyfileContent,
"conflict_details": conflictDetails,
})
return
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
}
// Upload handles manual Caddyfile upload or paste.
func (h *ImportHandler) Upload(c *gin.Context) {
var req struct {
Content string `json:"content" binding:"required"`
Filename string `json:"filename"`
}
// Capture raw request for better diagnostics in tests
if err := c.ShouldBindJSON(&req); err != nil {
// Try to include raw body preview when binding fails
entry := middleware.GetRequestLogger(c)
if raw, _ := c.GetRawData(); len(raw) > 0 {
entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import Upload: failed to bind JSON")
} else {
entry.WithError(err).Error("Import Upload: failed to bind JSON")
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
middleware.GetRequestLogger(c).WithField("filename", util.SanitizeForLog(filepath.Base(req.Filename))).WithField("content_len", len(req.Content)).Info("Import Upload: received upload")
// Save upload to import/uploads/<uuid>.caddyfile and return transient preview (do not persist yet)
sid := uuid.NewString()
uploadsDir, err := safeJoin(h.importDir, "uploads")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid import directory"})
return
}
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
return
}
tempPath, err := safeJoin(uploadsDir, fmt.Sprintf("%s.caddyfile", sid))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid temp path"})
return
}
if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil {
middleware.GetRequestLogger(c).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithError(err).Error("Import Upload: failed to write temp file")
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
return
}
// Parse uploaded file transiently
result, err := h.importerservice.ImportFile(tempPath)
if err != nil {
// Read a small preview of the uploaded file for diagnostics
preview := ""
if b, rerr := os.ReadFile(tempPath); rerr == nil {
if len(b) > 200 {
preview = string(b[:200])
} else {
preview = string(b)
}
}
middleware.GetRequestLogger(c).WithError(err).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithField("content_preview", util.SanitizeForLog(preview)).Error("Import Upload: import failed")
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
return
}
// If no hosts were parsed, provide a clearer error when import directives exist
if len(result.Hosts) == 0 {
imports := detectImportDirectives(req.Content)
if len(imports) > 0 {
sanitizedImports := make([]string, 0, len(imports))
for _, imp := range imports {
sanitizedImports = append(sanitizedImports, util.SanitizeForLog(filepath.Base(imp)))
}
middleware.GetRequestLogger(c).WithField("imports", sanitizedImports).Warn("Import Upload: no hosts parsed but imports detected")
} else {
middleware.GetRequestLogger(c).WithField("content_len", len(req.Content)).Warn("Import Upload: no hosts parsed and no imports detected")
}
if len(imports) > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile; imports detected; please upload the referenced site files using the multi-file import flow", "imports": imports})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile"})
return
}
// Check for conflicts with existing hosts and build conflict details
existingHosts, _ := h.proxyHostSvc.List()
existingDomainsMap := make(map[string]models.ProxyHost)
for _, eh := range existingHosts {
existingDomainsMap[eh.DomainNames] = eh
}
conflictDetails := make(map[string]gin.H)
for _, ph := range result.Hosts {
if existing, found := existingDomainsMap[ph.DomainNames]; found {
result.Conflicts = append(result.Conflicts, ph.DomainNames)
conflictDetails[ph.DomainNames] = gin.H{
"existing": gin.H{
"forward_scheme": existing.ForwardScheme,
"forward_host": existing.ForwardHost,
"forward_port": existing.ForwardPort,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": tempPath},
"conflict_details": conflictDetails,
"preview": result,
})
}
// DetectImports analyzes Caddyfile content and returns detected import directives.
func (h *ImportHandler) DetectImports(c *gin.Context) {
var req struct {
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
entry := middleware.GetRequestLogger(c)
if raw, _ := c.GetRawData(); len(raw) > 0 {
entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import UploadMulti: failed to bind JSON")
} else {
entry.WithError(err).Error("Import UploadMulti: failed to bind JSON")
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imports := detectImportDirectives(req.Content)
c.JSON(http.StatusOK, gin.H{
"has_imports": len(imports) > 0,
"imports": imports,
})
}
// UploadMulti handles upload of main Caddyfile + multiple site files.
func (h *ImportHandler) UploadMulti(c *gin.Context) {
var req struct {
Files []struct {
Filename string `json:"filename" binding:"required"`
Content string `json:"content" binding:"required"`
} `json:"files" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate: at least one file must be named "Caddyfile" or have no path separator
hasCaddyfile := false
for _, f := range req.Files {
if f.Filename == "Caddyfile" || !strings.Contains(f.Filename, "/") {
hasCaddyfile = true
break
}
}
if !hasCaddyfile {
c.JSON(http.StatusBadRequest, gin.H{"error": "must include a main Caddyfile"})
return
}
// Create session directory
sid := uuid.NewString()
sessionDir, err := safeJoin(h.importDir, filepath.Join("uploads", sid))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid session directory"})
return
}
if err := os.MkdirAll(sessionDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session directory"})
return
}
// Write all files
mainCaddyfile := ""
for _, f := range req.Files {
if strings.TrimSpace(f.Content) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file '%s' is empty", f.Filename)})
return
}
// Clean filename and create subdirectories if needed
cleanName := filepath.Clean(f.Filename)
targetPath, err := safeJoin(sessionDir, cleanName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid filename: %s", f.Filename)})
return
}
// Create parent directory if file is in a subdirectory
if dir := filepath.Dir(targetPath); dir != sessionDir {
if err := os.MkdirAll(dir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create directory for %s", f.Filename)})
return
}
}
if err := os.WriteFile(targetPath, []byte(f.Content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s", f.Filename)})
return
}
// Track main Caddyfile
if cleanName == "Caddyfile" || !strings.Contains(cleanName, "/") {
mainCaddyfile = targetPath
}
}
// Parse the main Caddyfile (which will automatically resolve imports)
result, err := h.importerservice.ImportFile(mainCaddyfile)
if err != nil {
// Provide diagnostics
preview := ""
if b, rerr := os.ReadFile(mainCaddyfile); rerr == nil {
if len(b) > 200 {
preview = string(b[:200])
} else {
preview = string(b)
}
}
middleware.GetRequestLogger(c).WithError(err).WithField("mainCaddyfile", util.SanitizeForLog(filepath.Base(mainCaddyfile))).WithField("preview", util.SanitizeForLog(preview)).Error("Import UploadMulti: import failed")
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
return
}
// If parsing succeeded but no hosts were found, and imports were present in the main file,
// inform the caller to upload the site files.
if len(result.Hosts) == 0 {
mainContentBytes, _ := os.ReadFile(mainCaddyfile)
imports := detectImportDirectives(string(mainContentBytes))
if len(imports) > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites parsed from main Caddyfile; import directives detected; please include site files in upload", "imports": imports})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "no sites parsed from main Caddyfile"})
return
}
// Check for conflicts
existingHosts, _ := h.proxyHostSvc.List()
existingDomains := make(map[string]bool)
for _, eh := range existingHosts {
existingDomains[eh.DomainNames] = true
}
for _, ph := range result.Hosts {
if existingDomains[ph.DomainNames] {
result.Conflicts = append(result.Conflicts, ph.DomainNames)
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": mainCaddyfile},
"preview": result,
})
}
// detectImportDirectives scans Caddyfile content for import directives.
func detectImportDirectives(content string) []string {
imports := []string{}
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "import ") {
path := strings.TrimSpace(strings.TrimPrefix(trimmed, "import"))
// Remove any trailing comments
if idx := strings.Index(path, "#"); idx != -1 {
path = strings.TrimSpace(path[:idx])
}
imports = append(imports, path)
}
}
return imports
}
// safeJoin joins a user-supplied path to a base directory and ensures
// the resulting path is contained within the base directory.
func safeJoin(baseDir, userPath string) (string, error) {
clean := filepath.Clean(userPath)
if clean == "" || clean == "." {
return "", fmt.Errorf("empty path not allowed")
}
if filepath.IsAbs(clean) {
return "", fmt.Errorf("absolute paths not allowed")
}
// Prevent attempts like ".." at start
if strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || clean == ".." {
return "", fmt.Errorf("path traversal detected")
}
target := filepath.Join(baseDir, clean)
rel, err := filepath.Rel(baseDir, target)
if err != nil {
return "", fmt.Errorf("invalid path")
}
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("path traversal detected")
}
// Normalize to use base's separators
target = path.Clean(target)
return target, nil
}
// isSafePathUnderBase reports whether userPath, when cleaned and joined
// to baseDir, stays within baseDir. Used by tests.
func isSafePathUnderBase(baseDir, userPath string) bool {
_, err := safeJoin(baseDir, userPath)
return err == nil
}
// Commit finalizes the import with user's conflict resolutions.
func (h *ImportHandler) Commit(c *gin.Context) {
var req struct {
SessionUUID string `json:"session_uuid" binding:"required"`
Resolutions map[string]string `json:"resolutions"` // domain -> action (keep/skip, overwrite, rename)
Names map[string]string `json:"names"` // domain -> custom name
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Try to find a DB-backed session first
var session models.ImportSession
// Basic sanitize of session id to prevent path separators
sid := filepath.Base(req.SessionUUID)
if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
return
}
var result *caddy.ImportResult
if err := h.db.Where("uuid = ? AND status = ?", sid, "reviewing").First(&session).Error; err == nil {
// DB session found
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
return
}
} else {
// No DB session: check for uploaded temp file
var parseErr error
uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
if err == nil {
if _, err := os.Stat(uploadsPath); err == nil {
r, err := h.importerservice.ImportFile(uploadsPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse uploaded file"})
return
}
result = r
// We'll create a committed DB session after applying
session = models.ImportSession{UUID: sid, SourceFile: uploadsPath}
}
}
// If not found yet, check mounted Caddyfile
if result == nil && h.mountPath != "" {
if _, err := os.Stat(h.mountPath); err == nil {
r, err := h.importerservice.ImportFile(h.mountPath)
if err != nil {
parseErr = err
} else {
result = r
session = models.ImportSession{UUID: sid, SourceFile: h.mountPath}
}
}
}
// If still not parsed, return not found or error
if result == nil {
if parseErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"})
return
}
}
// Convert parsed hosts to ProxyHost models
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
middleware.GetRequestLogger(c).WithField("parsed_hosts", len(result.Hosts)).WithField("proxy_hosts", len(proxyHosts)).Info("Import Commit: Parsed and converted hosts")
created := 0
updated := 0
skipped := 0
errors := []string{}
// Get existing hosts to check for overwrites
existingHosts, _ := h.proxyHostSvc.List()
existingMap := make(map[string]*models.ProxyHost)
for i := range existingHosts {
existingMap[existingHosts[i].DomainNames] = &existingHosts[i]
}
for _, host := range proxyHosts {
action := req.Resolutions[host.DomainNames]
// Apply custom name from user input
if customName, ok := req.Names[host.DomainNames]; ok && customName != "" {
host.Name = customName
}
// "keep" means keep existing (don't import), same as "skip"
if action == "skip" || action == "keep" {
skipped++
continue
}
if action == "rename" {
host.DomainNames += "-imported"
}
// Handle overwrite: preserve existing ID, UUID, and certificate
if action == "overwrite" {
if existing, found := existingMap[host.DomainNames]; found {
host.ID = existing.ID
host.UUID = existing.UUID
host.CertificateID = existing.CertificateID // Preserve certificate association
host.CreatedAt = existing.CreatedAt
if err := h.proxyHostSvc.Update(&host); err != nil {
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
errors = append(errors, errMsg)
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).WithField("error", sanitizeForLog(errMsg)).Error("Import Commit Error (update)")
} else {
updated++
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Import Commit Success: Updated host")
}
continue
}
// If "overwrite" but doesn't exist, fall through to create
}
// Create new host
host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&host); err != nil {
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
errors = append(errors, errMsg)
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).WithField("error", util.SanitizeForLog(errMsg)).Error("Import Commit Error")
} else {
created++
middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Import Commit Success: Created host")
}
}
// Persist an import session record now that user confirmed
now := time.Now()
session.Status = "committed"
session.CommittedAt = &now
session.UserResolutions = string(mustMarshal(req.Resolutions))
// If ParsedData/ConflictReport not set, fill from result
if session.ParsedData == "" {
session.ParsedData = string(mustMarshal(result))
}
if session.ConflictReport == "" {
session.ConflictReport = string(mustMarshal(result.Conflicts))
}
if err := h.db.Save(&session).Error; err != nil {
middleware.GetRequestLogger(c).WithError(err).Warn("Warning: failed to save import session")
}
c.JSON(http.StatusOK, gin.H{
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors,
})
}
// Cancel discards a pending import session.
func (h *ImportHandler) Cancel(c *gin.Context) {
sessionUUID := c.Query("session_uuid")
if sessionUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
return
}
sid := filepath.Base(sessionUUID)
if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
return
}
var session models.ImportSession
if err := h.db.Where("uuid = ?", sid).First(&session).Error; err == nil {
session.Status = "rejected"
h.db.Save(&session)
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
return
}
// If no DB session, check for uploaded temp file and delete it
uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
if err == nil {
if _, err := os.Stat(uploadsPath); err == nil {
os.Remove(uploadsPath)
c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"})
return
}
}
// If neither exists, return not found
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
}
// CheckMountedImport checks for mounted Caddyfile on startup.
func CheckMountedImport(db *gorm.DB, mountPath, caddyBinary, importDir string) error {
if _, err := os.Stat(mountPath); os.IsNotExist(err) {
// If mount is gone, remove any pending/reviewing sessions created previously for this mount
db.Where("source_file = ? AND status IN ?", mountPath, []string{"pending", "reviewing"}).Delete(&models.ImportSession{})
return nil // No mounted file, nothing to import
}
// Check if already processed (includes committed to avoid re-imports)
var count int64
db.Model(&models.ImportSession{}).Where("source_file = ? AND status IN ?",
mountPath, []string{"pending", "reviewing", "committed"}).Count(&count)
if count > 0 {
return nil // Already processed
}
// Do not create a DB session automatically for mounted imports; preview will be transient.
return nil
}
func mustMarshal(v interface{}) []byte {
b, _ := json.Marshal(v)
return b
}
package handlers
import (
"io"
"net/http"
"os"
"strconv"
"strings"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type LogsHandler struct {
service *services.LogService
}
func NewLogsHandler(service *services.LogService) *LogsHandler {
return &LogsHandler{service: service}
}
func (h *LogsHandler) List(c *gin.Context) {
logs, err := h.service.ListLogs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list logs"})
return
}
c.JSON(http.StatusOK, logs)
}
func (h *LogsHandler) Read(c *gin.Context) {
filename := c.Param("filename")
// Parse query parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
filter := models.LogFilter{
Search: c.Query("search"),
Host: c.Query("host"),
Status: c.Query("status"),
Level: c.Query("level"),
Limit: limit,
Offset: offset,
Sort: c.DefaultQuery("sort", "desc"),
}
logs, total, err := h.service.QueryLogs(filename, filter)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read log"})
return
}
c.JSON(http.StatusOK, gin.H{
"filename": filename,
"logs": logs,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *LogsHandler) Download(c *gin.Context) {
filename := c.Param("filename")
path, err := h.service.GetLogPath(filename)
if err != nil {
if strings.Contains(err.Error(), "invalid filename") {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
return
}
// Create a temporary file to serve a consistent snapshot
// This prevents Content-Length mismatches if the live log file grows during download
tmpFile, err := os.CreateTemp("", "charon-log-*.log")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp file"})
return
}
defer os.Remove(tmpFile.Name())
srcFile, err := os.Open(path)
if err != nil {
_ = tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open log file"})
return
}
defer func() { _ = srcFile.Close() }()
if _, err := io.Copy(tmpFile, srcFile); err != nil {
_ = tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to copy log file"})
return
}
_ = tmpFile.Close()
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(tmpFile.Name())
}
package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationHandler struct {
service *services.NotificationService
}
func NewNotificationHandler(service *services.NotificationService) *NotificationHandler {
return &NotificationHandler{service: service}
}
func (h *NotificationHandler) List(c *gin.Context) {
unreadOnly := c.Query("unread") == "true"
notifications, err := h.service.List(unreadOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"})
return
}
c.JSON(http.StatusOK, notifications)
}
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
id := c.Param("id")
if err := h.service.MarkAsRead(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"})
}
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
if err := h.service.MarkAllAsRead(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark all notifications as read"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"})
}
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationProviderHandler struct {
service *services.NotificationService
}
func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler {
return &NotificationProviderHandler{service: service}
}
func (h *NotificationProviderHandler) List(c *gin.Context) {
providers, err := h.service.ListProviders()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers"})
return
}
c.JSON(http.StatusOK, providers)
}
func (h *NotificationProviderHandler) Create(c *gin.Context) {
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.CreateProvider(&provider); err != nil {
// If it's a validation error from template parsing, return 400
if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
return
}
c.JSON(http.StatusCreated, provider)
}
func (h *NotificationProviderHandler) Update(c *gin.Context) {
id := c.Param("id")
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
provider.ID = id
if err := h.service.UpdateProvider(&provider); err != nil {
if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
return
}
c.JSON(http.StatusOK, provider)
}
func (h *NotificationProviderHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.service.DeleteProvider(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Provider deleted"})
}
func (h *NotificationProviderHandler) Test(c *gin.Context) {
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.TestProvider(provider); err != nil {
// Create internal notification for the failure
_, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed: %v", provider.Name, err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}
// Templates returns a list of built-in templates a provider can use.
func (h *NotificationProviderHandler) Templates(c *gin.Context) {
c.JSON(http.StatusOK, []gin.H{
{"id": "minimal", "name": "Minimal", "description": "Small JSON payload with title, message and time."},
{"id": "detailed", "name": "Detailed", "description": "Full JSON payload with host, services and all data."},
{"id": "custom", "name": "Custom", "description": "Use your own JSON template in the Config field."},
})
}
// Preview renders the template for a provider and returns the resulting JSON object or an error.
func (h *NotificationProviderHandler) Preview(c *gin.Context) {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var provider models.NotificationProvider
// Marshal raw into provider to get proper types
if b, err := json.Marshal(raw); err == nil {
_ = json.Unmarshal(b, &provider)
}
var payload map[string]interface{}
if d, ok := raw["data"].(map[string]interface{}); ok {
payload = d
}
if payload == nil {
payload = map[string]interface{}{}
}
// Add some defaults for preview
if _, ok := payload["Title"]; !ok {
payload["Title"] = "Preview Title"
}
if _, ok := payload["Message"]; !ok {
payload["Message"] = "Preview Message"
}
payload["Time"] = time.Now().Format(time.RFC3339)
payload["EventType"] = "preview"
rendered, parsed, err := h.service.RenderTemplate(provider, payload)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
return
}
c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
}
package handlers
import (
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"net/http"
)
type NotificationTemplateHandler struct {
service *services.NotificationService
}
func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler {
return &NotificationTemplateHandler{service: s}
}
func (h *NotificationTemplateHandler) List(c *gin.Context) {
list, err := h.service.ListTemplates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list templates"})
return
}
c.JSON(http.StatusOK, list)
}
func (h *NotificationTemplateHandler) Create(c *gin.Context) {
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.CreateTemplate(&t); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"})
return
}
c.JSON(http.StatusCreated, t)
}
func (h *NotificationTemplateHandler) Update(c *gin.Context) {
id := c.Param("id")
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
t.ID = id
if err := h.service.UpdateTemplate(&t); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"})
return
}
c.JSON(http.StatusOK, t)
}
func (h *NotificationTemplateHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.service.DeleteTemplate(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// Preview allows rendering an arbitrary template (provided in request) or a stored template by id.
func (h *NotificationTemplateHandler) Preview(c *gin.Context) {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var tmplStr string
if id, ok := raw["template_id"].(string); ok && id != "" {
t, err := h.service.GetTemplate(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"})
return
}
tmplStr = t.Config
} else if s, ok := raw["template"].(string); ok {
tmplStr = s
}
data := map[string]interface{}{}
if d, ok := raw["data"].(map[string]interface{}); ok {
data = d
}
// Build a fake provider to leverage existing RenderTemplate logic
provider := models.NotificationProvider{Template: "custom", Config: tmplStr}
rendered, parsed, err := h.service.RenderTemplate(provider, data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
return
}
c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
}
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/middleware"
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// ProxyHostHandler handles CRUD operations for proxy hosts.
type ProxyHostHandler struct {
service *services.ProxyHostService
caddyManager *caddy.Manager
notificationService *services.NotificationService
uptimeService *services.UptimeService
}
// NewProxyHostHandler creates a new proxy host handler.
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService, uptimeService *services.UptimeService) *ProxyHostHandler {
return &ProxyHostHandler{
service: services.NewProxyHostService(db),
caddyManager: caddyManager,
notificationService: ns,
uptimeService: uptimeService,
}
}
// RegisterRoutes registers proxy host routes.
func (h *ProxyHostHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/proxy-hosts", h.List)
router.POST("/proxy-hosts", h.Create)
router.GET("/proxy-hosts/:uuid", h.Get)
router.PUT("/proxy-hosts/:uuid", h.Update)
router.DELETE("/proxy-hosts/:uuid", h.Delete)
router.POST("/proxy-hosts/test", h.TestConnection)
router.PUT("/proxy-hosts/bulk-update-acl", h.BulkUpdateACL)
}
// List retrieves all proxy hosts.
func (h *ProxyHostHandler) List(c *gin.Context) {
hosts, err := h.service.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, hosts)
}
// Create creates a new proxy host.
func (h *ProxyHostHandler) Create(c *gin.Context) {
var host models.ProxyHost
if err := c.ShouldBindJSON(&host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate and normalize advanced config if present
if host.AdvancedConfig != "" {
var parsed interface{}
if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()})
return
}
parsed = caddy.NormalizeAdvancedConfig(parsed)
if norm, err := json.Marshal(parsed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()})
return
} else {
host.AdvancedConfig = string(norm)
}
}
host.UUID = uuid.NewString()
// Assign UUIDs to locations
for i := range host.Locations {
host.Locations[i].UUID = uuid.NewString()
}
if err := h.service.Create(&host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
// Rollback: delete the created host if config application fails
middleware.GetRequestLogger(c).WithError(err).Error("Error applying config")
if deleteErr := h.service.Delete(host.ID); deleteErr != nil {
idStr := strconv.FormatUint(uint64(host.ID), 10)
middleware.GetRequestLogger(c).WithField("host_id", idStr).WithError(deleteErr).Error("Critical: Failed to rollback host")
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"proxy_host",
"Proxy Host Created",
fmt.Sprintf("Proxy Host %s (%s) created", util.SanitizeForLog(host.Name), util.SanitizeForLog(host.DomainNames)),
map[string]interface{}{
"Name": util.SanitizeForLog(host.Name),
"Domains": util.SanitizeForLog(host.DomainNames),
"Action": "created",
},
)
}
c.JSON(http.StatusCreated, host)
}
// Get retrieves a proxy host by UUID.
func (h *ProxyHostHandler) Get(c *gin.Context) {
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}
c.JSON(http.StatusOK, host)
}
// Update updates an existing proxy host.
func (h *ProxyHostHandler) Update(c *gin.Context) {
uuidStr := c.Param("uuid")
host, err := h.service.GetByUUID(uuidStr)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}
// Perform a partial update: only mutate fields present in payload
var payload map[string]interface{}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Handle simple scalar fields by json tag names (snake_case)
if v, ok := payload["name"].(string); ok {
host.Name = v
}
if v, ok := payload["domain_names"].(string); ok {
host.DomainNames = v
}
if v, ok := payload["forward_scheme"].(string); ok {
host.ForwardScheme = v
}
if v, ok := payload["forward_host"].(string); ok {
host.ForwardHost = v
}
if v, ok := payload["forward_port"]; ok {
switch t := v.(type) {
case float64:
host.ForwardPort = int(t)
case int:
host.ForwardPort = t
case string:
if p, err := strconv.Atoi(t); err == nil {
host.ForwardPort = p
}
}
}
if v, ok := payload["ssl_forced"].(bool); ok {
host.SSLForced = v
}
if v, ok := payload["http2_support"].(bool); ok {
host.HTTP2Support = v
}
if v, ok := payload["hsts_enabled"].(bool); ok {
host.HSTSEnabled = v
}
if v, ok := payload["hsts_subdomains"].(bool); ok {
host.HSTSSubdomains = v
}
if v, ok := payload["block_exploits"].(bool); ok {
host.BlockExploits = v
}
if v, ok := payload["websocket_support"].(bool); ok {
host.WebsocketSupport = v
}
if v, ok := payload["application"].(string); ok {
host.Application = v
}
if v, ok := payload["enabled"].(bool); ok {
host.Enabled = v
}
// Nullable foreign keys
if v, ok := payload["certificate_id"]; ok {
if v == nil {
host.CertificateID = nil
} else {
switch t := v.(type) {
case float64:
id := uint(t)
host.CertificateID = &id
case int:
id := uint(t)
host.CertificateID = &id
case string:
if n, err := strconv.ParseUint(t, 10, 32); err == nil {
id := uint(n)
host.CertificateID = &id
}
}
}
}
if v, ok := payload["access_list_id"]; ok {
if v == nil {
host.AccessListID = nil
} else {
switch t := v.(type) {
case float64:
id := uint(t)
host.AccessListID = &id
case int:
id := uint(t)
host.AccessListID = &id
case string:
if n, err := strconv.ParseUint(t, 10, 32); err == nil {
id := uint(n)
host.AccessListID = &id
}
}
}
}
// Locations: replace only if provided
if v, ok := payload["locations"].([]interface{}); ok {
// Rebind to []models.Location
b, _ := json.Marshal(v)
var locs []models.Location
if err := json.Unmarshal(b, &locs); err == nil {
// Ensure UUIDs exist for any new location entries
for i := range locs {
if locs[i].UUID == "" {
locs[i].UUID = uuid.New().String()
}
}
host.Locations = locs
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid locations payload"})
return
}
}
// Advanced config: normalize if provided and changed
if v, ok := payload["advanced_config"].(string); ok {
if v != "" && v != host.AdvancedConfig {
var parsed interface{}
if err := json.Unmarshal([]byte(v), &parsed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()})
return
}
parsed = caddy.NormalizeAdvancedConfig(parsed)
if norm, err := json.Marshal(parsed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()})
return
} else {
// Backup previous
host.AdvancedConfigBackup = host.AdvancedConfig
host.AdvancedConfig = string(norm)
}
} else if v == "" { // allow clearing advanced config
host.AdvancedConfigBackup = host.AdvancedConfig
host.AdvancedConfig = ""
}
}
if err := h.service.Update(host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, host)
}
// Delete removes a proxy host.
func (h *ProxyHostHandler) Delete(c *gin.Context) {
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}
// check if we should also delete associated uptime monitors (query param: delete_uptime=true)
deleteUptime := c.DefaultQuery("delete_uptime", "false") == "true"
if deleteUptime && h.uptimeService != nil {
// Find all monitors referencing this proxy host and delete each
var monitors []models.UptimeMonitor
if err := h.uptimeService.DB.Where("proxy_host_id = ?", host.ID).Find(&monitors).Error; err == nil {
for _, m := range monitors {
_ = h.uptimeService.DeleteMonitor(m.ID)
}
}
}
if err := h.service.Delete(host.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"proxy_host",
"Proxy Host Deleted",
fmt.Sprintf("Proxy Host %s deleted", host.Name),
map[string]interface{}{
"Name": host.Name,
"Action": "deleted",
},
)
}
c.JSON(http.StatusOK, gin.H{"message": "proxy host deleted"})
}
// TestConnection checks if the proxy host is reachable.
func (h *ProxyHostHandler) TestConnection(c *gin.Context) {
var req struct {
ForwardHost string `json:"forward_host" binding:"required"`
ForwardPort int `json:"forward_port" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.TestConnection(req.ForwardHost, req.ForwardPort); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Connection successful"})
}
// BulkUpdateACL applies or removes an access list to multiple proxy hosts.
func (h *ProxyHostHandler) BulkUpdateACL(c *gin.Context) {
var req struct {
HostUUIDs []string `json:"host_uuids" binding:"required"`
AccessListID *uint `json:"access_list_id"` // nil means remove ACL
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.HostUUIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "host_uuids cannot be empty"})
return
}
updated := 0
errors := []map[string]string{}
for _, uuid := range req.HostUUIDs {
host, err := h.service.GetByUUID(uuid)
if err != nil {
errors = append(errors, map[string]string{
"uuid": uuid,
"error": "proxy host not found",
})
continue
}
host.AccessListID = req.AccessListID
if err := h.service.Update(host); err != nil {
errors = append(errors, map[string]string{
"uuid": uuid,
"error": err.Error(),
})
continue
}
updated++
}
// Apply Caddy config once for all updates
if updated > 0 && h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to apply configuration: " + err.Error(),
"updated": updated,
"errors": errors,
})
return
}
}
c.JSON(http.StatusOK, gin.H{
"updated": updated,
"errors": errors,
})
}
package handlers
import (
"fmt"
"net"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// RemoteServerHandler handles HTTP requests for remote server management.
type RemoteServerHandler struct {
service *services.RemoteServerService
notificationService *services.NotificationService
}
// NewRemoteServerHandler creates a new remote server handler.
func NewRemoteServerHandler(service *services.RemoteServerService, ns *services.NotificationService) *RemoteServerHandler {
return &RemoteServerHandler{
service: service,
notificationService: ns,
}
}
// RegisterRoutes registers remote server routes.
func (h *RemoteServerHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/remote-servers", h.List)
router.POST("/remote-servers", h.Create)
router.GET("/remote-servers/:uuid", h.Get)
router.PUT("/remote-servers/:uuid", h.Update)
router.DELETE("/remote-servers/:uuid", h.Delete)
router.POST("/remote-servers/test", h.TestConnectionCustom)
router.POST("/remote-servers/:uuid/test", h.TestConnection)
}
// List retrieves all remote servers.
func (h *RemoteServerHandler) List(c *gin.Context) {
enabledOnly := c.Query("enabled") == "true"
servers, err := h.service.List(enabledOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, servers)
}
// Create creates a new remote server.
func (h *RemoteServerHandler) Create(c *gin.Context) {
var server models.RemoteServer
if err := c.ShouldBindJSON(&server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
server.UUID = uuid.NewString()
if err := h.service.Create(&server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"remote_server",
"Remote Server Added",
fmt.Sprintf("Remote Server %s (%s:%d) added", util.SanitizeForLog(server.Name), util.SanitizeForLog(server.Host), server.Port),
map[string]interface{}{
"Name": util.SanitizeForLog(server.Name),
"Host": util.SanitizeForLog(server.Host),
"Port": server.Port,
"Action": "created",
},
)
}
c.JSON(http.StatusCreated, server)
}
// Get retrieves a remote server by UUID.
func (h *RemoteServerHandler) Get(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
c.JSON(http.StatusOK, server)
}
// Update updates an existing remote server.
func (h *RemoteServerHandler) Update(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := c.ShouldBindJSON(server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.Update(server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, server)
}
// Delete removes a remote server.
func (h *RemoteServerHandler) Delete(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := h.service.Delete(server.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(c.Request.Context(),
"remote_server",
"Remote Server Deleted",
fmt.Sprintf("Remote Server %s deleted", util.SanitizeForLog(server.Name)),
map[string]interface{}{
"Name": util.SanitizeForLog(server.Name),
"Action": "deleted",
},
)
}
c.JSON(http.StatusNoContent, nil)
}
// TestConnection tests the TCP connection to a remote server.
func (h *RemoteServerHandler) TestConnection(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
// Test TCP connection with 5 second timeout
address := net.JoinHostPort(server.Host, fmt.Sprintf("%d", server.Port))
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
result := gin.H{
"server_uuid": server.UUID,
"address": address,
"timestamp": time.Now().UTC(),
}
if err != nil {
result["reachable"] = false
result["error"] = err.Error()
// Update server reachability status
server.Reachable = false
now := time.Now().UTC()
server.LastChecked = &now
_ = h.service.Update(server)
c.JSON(http.StatusOK, result)
return
}
defer func() { _ = conn.Close() }()
// Connection successful
result["reachable"] = true
result["latency_ms"] = time.Since(time.Now()).Milliseconds()
// Update server reachability status
server.Reachable = true
now := time.Now().UTC()
server.LastChecked = &now
_ = h.service.Update(server)
c.JSON(http.StatusOK, result)
}
// TestConnectionCustom tests connectivity to a host/port provided in the body
func (h *RemoteServerHandler) TestConnectionCustom(c *gin.Context) {
var req struct {
Host string `json:"host" binding:"required"`
Port int `json:"port" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Test TCP connection with 5 second timeout
address := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
start := time.Now()
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
result := gin.H{
"address": address,
"timestamp": time.Now().UTC(),
}
if err != nil {
result["reachable"] = false
result["error"] = err.Error()
c.JSON(http.StatusOK, result)
return
}
defer func() { _ = conn.Close() }()
// Connection successful
result["reachable"] = true
result["latency_ms"] = time.Since(start).Milliseconds()
c.JSON(http.StatusOK, result)
}
package handlers
import (
"regexp"
"strings"
)
// sanitizeForLog removes control characters and newlines from user content before logging.
func sanitizeForLog(s string) string {
if s == "" {
return s
}
// Replace CRLF and LF with spaces and remove other control chars
s = strings.ReplaceAll(s, "\r\n", " ")
s = strings.ReplaceAll(s, "\n", " ")
// remove any other non-printable control characters
re := regexp.MustCompile(`[\x00-\x1F\x7F]+`)
s = re.ReplaceAllString(s, " ")
return s
}
package handlers
import (
"errors"
"net"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
// SecurityHandler handles security-related API requests.
type SecurityHandler struct {
cfg config.SecurityConfig
db *gorm.DB
svc *services.SecurityService
caddyManager *caddy.Manager
}
// NewSecurityHandler creates a new SecurityHandler.
func NewSecurityHandler(cfg config.SecurityConfig, db *gorm.DB, caddyManager *caddy.Manager) *SecurityHandler {
svc := services.NewSecurityService(db)
return &SecurityHandler{cfg: cfg, db: db, svc: svc, caddyManager: caddyManager}
}
// GetStatus returns the current status of all security services.
func (h *SecurityHandler) GetStatus(c *gin.Context) {
enabled := h.cfg.CerberusEnabled
// Check runtime setting override
var settingKey = "security.cerberus.enabled"
if h.db != nil {
var setting struct{ Value string }
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", settingKey).Scan(&setting).Error; err == nil && setting.Value != "" {
if strings.EqualFold(setting.Value, "true") {
enabled = true
} else {
enabled = false
}
}
}
// Allow runtime overrides for CrowdSec mode + API URL via settings table
mode := h.cfg.CrowdSecMode
apiURL := h.cfg.CrowdSecAPIURL
if h.db != nil {
var m struct{ Value string }
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&m).Error; err == nil && m.Value != "" {
mode = m.Value
}
var a struct{ Value string }
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.api_url").Scan(&a).Error; err == nil && a.Value != "" {
apiURL = a.Value
}
}
// Only allow 'local' as an enabled mode. Any other value should be treated as disabled.
if mode != "local" {
mode = "disabled"
apiURL = ""
}
// Allow runtime override for ACL enabled flag via settings table
aclEnabled := h.cfg.ACLMode == "enabled"
aclEffective := aclEnabled && enabled
if h.db != nil {
var a struct{ Value string }
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.acl.enabled").Scan(&a).Error; err == nil && a.Value != "" {
if strings.EqualFold(a.Value, "true") {
aclEnabled = true
} else if strings.EqualFold(a.Value, "false") {
aclEnabled = false
}
// If Cerberus is disabled, ACL should not be considered enabled even
// if the ACL setting is true. This keeps ACL tied to the Cerberus
// suite state in the UI and APIs.
aclEffective = aclEnabled && enabled
}
}
c.JSON(http.StatusOK, gin.H{
"cerberus": gin.H{"enabled": enabled},
"crowdsec": gin.H{
"mode": mode,
"api_url": apiURL,
"enabled": mode == "local",
},
"waf": gin.H{
"mode": h.cfg.WAFMode,
"enabled": h.cfg.WAFMode != "" && h.cfg.WAFMode != "disabled",
},
"rate_limit": gin.H{
"mode": h.cfg.RateLimitMode,
"enabled": h.cfg.RateLimitMode == "enabled",
},
"acl": gin.H{
"mode": h.cfg.ACLMode,
"enabled": aclEffective,
},
})
}
// GetConfig returns the site security configuration from DB or default
func (h *SecurityHandler) GetConfig(c *gin.Context) {
cfg, err := h.svc.Get()
if err != nil {
if err == services.ErrSecurityConfigNotFound {
c.JSON(http.StatusOK, gin.H{"config": nil})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
return
}
c.JSON(http.StatusOK, gin.H{"config": cfg})
}
// UpdateConfig creates or updates the SecurityConfig in DB
func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
var payload models.SecurityConfig
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if payload.Name == "" {
payload.Name = "default"
}
if err := h.svc.Upsert(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Apply updated config to Caddy so WAF mode changes take effect
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
log.WithError(err).Warn("failed to apply security config changes to Caddy")
}
}
c.JSON(http.StatusOK, gin.H{"config": payload})
}
// GenerateBreakGlass generates a break-glass token and returns the plaintext token once
func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) {
token, err := h.svc.GenerateBreakGlassToken("default")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}
// ListDecisions returns recent security decisions
func (h *SecurityHandler) ListDecisions(c *gin.Context) {
limit := 50
if q := c.Query("limit"); q != "" {
if v, err := strconv.Atoi(q); err == nil {
limit = v
}
}
list, err := h.svc.ListDecisions(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list decisions"})
return
}
c.JSON(http.StatusOK, gin.H{"decisions": list})
}
// CreateDecision creates a manual decision (override) - for now no checks besides payload
func (h *SecurityHandler) CreateDecision(c *gin.Context) {
var payload models.SecurityDecision
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if payload.IP == "" || payload.Action == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ip and action are required"})
return
}
// Populate source
payload.Source = "manual"
if err := h.svc.LogDecision(&payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to log decision"})
return
}
// Record an audit entry
actor := c.GetString("user_id")
if actor == "" {
actor = c.ClientIP()
}
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "create_decision", Details: payload.Details})
c.JSON(http.StatusOK, gin.H{"decision": payload})
}
// ListRuleSets returns the list of known rulesets
func (h *SecurityHandler) ListRuleSets(c *gin.Context) {
list, err := h.svc.ListRuleSets()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list rule sets"})
return
}
c.JSON(http.StatusOK, gin.H{"rulesets": list})
}
// UpsertRuleSet uploads or updates a ruleset
func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
var payload models.SecurityRuleSet
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if payload.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
return
}
if err := h.svc.UpsertRuleSet(&payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upsert ruleset"})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
// Create an audit event
actor := c.GetString("user_id")
if actor == "" {
actor = c.ClientIP()
}
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "upsert_ruleset", Details: payload.Name})
c.JSON(http.StatusOK, gin.H{"ruleset": payload})
}
// DeleteRuleSet removes a ruleset by id
func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) {
idParam := c.Param("id")
if idParam == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.svc.DeleteRuleSet(uint(id)); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "ruleset not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete ruleset"})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
actor := c.GetString("user_id")
if actor == "" {
actor = c.ClientIP()
}
_ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "delete_ruleset", Details: idParam})
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// Enable toggles Cerberus on, validating admin whitelist or break-glass token
func (h *SecurityHandler) Enable(c *gin.Context) {
// Look for requester's IP and optional breakglass token
adminIP := c.ClientIP()
var body struct {
Token string `json:"break_glass_token"`
}
_ = c.ShouldBindJSON(&body)
// If config exists, require that adminIP is in whitelist or token matches
cfg, err := h.svc.Get()
if err != nil && err != services.ErrSecurityConfigNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve security config"})
return
}
if cfg != nil {
// Check admin whitelist
if cfg.AdminWhitelist == "" && body.Token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "admin whitelist missing; provide break_glass_token or add admin_whitelist CIDR before enabling"})
return
}
if body.Token != "" {
ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
if err == nil && ok {
// proceed
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
return
}
} else {
// verify client IP in admin whitelist
found := false
for _, entry := range strings.Split(cfg.AdminWhitelist, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if entry == adminIP {
found = true
break
}
// If CIDR, check contains
if _, cidr, err := net.ParseCIDR(entry); err == nil {
if cidr.Contains(net.ParseIP(adminIP)) {
found = true
break
}
}
}
if !found {
c.JSON(http.StatusForbidden, gin.H{"error": "admin IP not present in admin_whitelist"})
return
}
}
}
// Set enabled true
newCfg := &models.SecurityConfig{Name: "default", Enabled: true}
if cfg != nil {
newCfg = cfg
newCfg.Enabled = true
}
if err := h.svc.Upsert(newCfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable Cerberus"})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"enabled": true})
}
// Disable toggles Cerberus off; requires break-glass token or localhost request
func (h *SecurityHandler) Disable(c *gin.Context) {
var body struct {
Token string `json:"break_glass_token"`
}
_ = c.ShouldBindJSON(&body)
// Allow requests from localhost to disable without token
clientIP := c.ClientIP()
if clientIP == "127.0.0.1" || clientIP == "::1" {
cfg, _ := h.svc.Get()
if cfg == nil {
cfg = &models.SecurityConfig{Name: "default", Enabled: false}
} else {
cfg.Enabled = false
}
_ = h.svc.Upsert(cfg)
if h.caddyManager != nil {
_ = h.caddyManager.ApplyConfig(c.Request.Context())
}
c.JSON(http.StatusOK, gin.H{"enabled": false})
return
}
cfg, err := h.svc.Get()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read config"})
return
}
if body.Token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token required to disable Cerberus from non-localhost"})
return
}
ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
if err != nil || !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
return
}
cfg.Enabled = false
_ = h.svc.Upsert(cfg)
if h.caddyManager != nil {
_ = h.caddyManager.ApplyConfig(c.Request.Context())
}
c.JSON(http.StatusOK, gin.H{"enabled": false})
}
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/Wikid82/charon/backend/internal/config"
)
func TestSecurityHandler_GetStatus_Fixed(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
cfg config.SecurityConfig
expectedStatus int
expectedBody map[string]interface{}
}{
{
name: "All Disabled",
cfg: config.SecurityConfig{
CrowdSecMode: "disabled",
WAFMode: "disabled",
RateLimitMode: "disabled",
ACLMode: "disabled",
},
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"cerberus": map[string]interface{}{"enabled": false},
"crowdsec": map[string]interface{}{
"mode": "disabled",
"api_url": "",
"enabled": false,
},
"waf": map[string]interface{}{
"mode": "disabled",
"enabled": false,
},
"rate_limit": map[string]interface{}{
"mode": "disabled",
"enabled": false,
},
"acl": map[string]interface{}{
"mode": "disabled",
"enabled": false,
},
},
},
{
name: "All Enabled",
cfg: config.SecurityConfig{
CrowdSecMode: "local",
WAFMode: "enabled",
RateLimitMode: "enabled",
ACLMode: "enabled",
},
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"cerberus": map[string]interface{}{"enabled": true},
"crowdsec": map[string]interface{}{
"mode": "local",
"api_url": "",
"enabled": true,
},
"waf": map[string]interface{}{
"mode": "enabled",
"enabled": true,
},
"rate_limit": map[string]interface{}{
"mode": "enabled",
"enabled": true,
},
"acl": map[string]interface{}{
"mode": "enabled",
"enabled": true,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewSecurityHandler(tt.cfg, nil, nil)
router := gin.New()
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
expectedJSON, _ := json.Marshal(tt.expectedBody)
var expectedNormalized map[string]interface{}
if err := json.Unmarshal(expectedJSON, &expectedNormalized); err != nil {
t.Fatalf("failed to unmarshal expected JSON: %v", err)
}
assert.Equal(t, expectedNormalized, response)
})
}
}
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
type SettingsHandler struct {
DB *gorm.DB
}
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
return &SettingsHandler{DB: db}
}
// GetSettings returns all settings.
func (h *SettingsHandler) GetSettings(c *gin.Context) {
var settings []models.Setting
if err := h.DB.Find(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
}
// Convert to map for easier frontend consumption
settingsMap := make(map[string]string)
for _, s := range settings {
settingsMap[s.Key] = s.Value
}
c.JSON(http.StatusOK, settingsMap)
}
type UpdateSettingRequest struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
Category string `json:"category"`
Type string `json:"type"`
}
// UpdateSetting updates or creates a setting.
func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
var req UpdateSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
setting := models.Setting{
Key: req.Key,
Value: req.Value,
}
if req.Category != "" {
setting.Category = req.Category
}
if req.Type != "" {
setting.Type = req.Type
}
// Upsert
if err := h.DB.Where(models.Setting{Key: req.Key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save setting"})
return
}
c.JSON(http.StatusOK, setting)
}
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
type SystemHandler struct{}
func NewSystemHandler() *SystemHandler {
return &SystemHandler{}
}
type MyIPResponse struct {
IP string `json:"ip"`
Source string `json:"source"`
}
// GetMyIP returns the client's public IP address
func (h *SystemHandler) GetMyIP(c *gin.Context) {
// Try to get the real IP from various headers (in order of preference)
// This handles proxies, load balancers, and CDNs
ip := getClientIP(c.Request)
source := "direct"
if c.GetHeader("X-Forwarded-For") != "" {
source = "X-Forwarded-For"
} else if c.GetHeader("X-Real-IP") != "" {
source = "X-Real-IP"
} else if c.GetHeader("CF-Connecting-IP") != "" {
source = "Cloudflare"
}
c.JSON(http.StatusOK, MyIPResponse{
IP: ip,
Source: source,
})
}
// getClientIP extracts the real client IP from the request
// Checks headers in order of trust/reliability
func getClientIP(r *http.Request) string {
// Cloudflare
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
// Other CDNs/proxies
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
// Standard proxy header (can be a comma-separated list)
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
// Take the first IP in the list (client IP)
ips := strings.Split(forwarded, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// Fallback to RemoteAddr (format: "IP:port")
if ip := r.RemoteAddr; ip != "" {
// Remove port if present
if idx := strings.LastIndex(ip, ":"); idx != -1 {
return ip[:idx]
}
return ip
}
return "unknown"
}
package handlers
import (
"fmt"
"math/rand"
"strings"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// openTestDB creates a SQLite in-memory DB unique per test and applies
// a busy timeout and WAL journal mode to reduce SQLITE locking during parallel tests.
func OpenTestDB(t *testing.T) *gorm.DB {
t.Helper()
// Append a timestamp/random suffix to ensure uniqueness even across parallel runs
dsnName := strings.ReplaceAll(t.Name(), "/", "_")
rand.Seed(time.Now().UnixNano())
uniqueSuffix := fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(10000))
dsn := fmt.Sprintf("file:%s_%s?mode=memory&cache=shared&_journal_mode=WAL&_busy_timeout=5000", dsnName, uniqueSuffix)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open test db: %v", err)
}
return db
}
package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type UpdateHandler struct {
service *services.UpdateService
}
func NewUpdateHandler(service *services.UpdateService) *UpdateHandler {
return &UpdateHandler{service: service}
}
func (h *UpdateHandler) Check(c *gin.Context) {
info, err := h.service.CheckForUpdates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check for updates"})
return
}
c.JSON(http.StatusOK, info)
}
package handlers
import (
"net/http"
"strconv"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type UptimeHandler struct {
service *services.UptimeService
}
func NewUptimeHandler(service *services.UptimeService) *UptimeHandler {
return &UptimeHandler{service: service}
}
func (h *UptimeHandler) List(c *gin.Context) {
monitors, err := h.service.ListMonitors()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list monitors"})
return
}
c.JSON(http.StatusOK, monitors)
}
func (h *UptimeHandler) GetHistory(c *gin.Context) {
id := c.Param("id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
history, err := h.service.GetMonitorHistory(id, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"})
return
}
c.JSON(http.StatusOK, history)
}
func (h *UptimeHandler) Update(c *gin.Context) {
id := c.Param("id")
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
monitor, err := h.service.UpdateMonitor(id, updates)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, monitor)
}
func (h *UptimeHandler) Sync(c *gin.Context) {
if err := h.service.SyncMonitors(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync monitors"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Sync started"})
}
// Delete removes a monitor and its associated data
func (h *UptimeHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.service.DeleteMonitor(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete monitor"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Monitor deleted"})
}
// CheckMonitor triggers an immediate check for a specific monitor
func (h *UptimeHandler) CheckMonitor(c *gin.Context) {
id := c.Param("id")
monitor, err := h.service.GetMonitorByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Monitor not found"})
return
}
// Trigger immediate check in background
go h.service.CheckMonitor(*monitor)
c.JSON(http.StatusOK, gin.H{"message": "Check triggered"})
}
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
type UserHandler struct {
DB *gorm.DB
}
func NewUserHandler(db *gorm.DB) *UserHandler {
return &UserHandler{DB: db}
}
func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/setup", h.GetSetupStatus)
r.POST("/setup", h.Setup)
r.GET("/profile", h.GetProfile)
r.POST("/regenerate-api-key", h.RegenerateAPIKey)
r.PUT("/profile", h.UpdateProfile)
}
// GetSetupStatus checks if the application needs initial setup (i.e., no users exist).
func (h *UserHandler) GetSetupStatus(c *gin.Context) {
var count int64
if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
return
}
c.JSON(http.StatusOK, gin.H{
"setupRequired": count == 0,
})
}
type SetupRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
// Setup creates the initial admin user and configures the ACME email.
func (h *UserHandler) Setup(c *gin.Context) {
// 1. Check if setup is allowed
var count int64
if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
return
}
if count > 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
return
}
// 2. Parse request
var req SetupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 3. Create User
user := models.User{
UUID: uuid.New().String(),
Name: req.Name,
Email: strings.ToLower(req.Email),
Role: "admin",
Enabled: true,
APIKey: uuid.New().String(),
}
if err := user.SetPassword(req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// 4. Create Setting for ACME Email
acmeEmailSetting := models.Setting{
Key: "caddy.acme_email",
Value: req.Email,
Type: "string",
Category: "caddy",
}
// Transaction to ensure both succeed
err := h.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err
}
// Use Save to update if exists (though it shouldn't in fresh setup) or create
if err := tx.Where(models.Setting{Key: "caddy.acme_email"}).Assign(models.Setting{Value: req.Email}).FirstOrCreate(&acmeEmailSetting).Error; err != nil {
return err
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Setup completed successfully",
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
})
}
// RegenerateAPIKey generates a new API key for the authenticated user.
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
apiKey := uuid.New().String()
if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"})
return
}
c.JSON(http.StatusOK, gin.H{"api_key": apiKey})
}
// GetProfile returns the current user's profile including API key.
func (h *UserHandler) GetProfile(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var user models.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"api_key": user.APIKey,
})
}
type UpdateProfileRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
CurrentPassword string `json:"current_password"`
}
// UpdateProfile updates the authenticated user's profile.
func (h *UserHandler) UpdateProfile(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get current user
var user models.User
if err := h.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
// Check if email is already taken by another user
req.Email = strings.ToLower(req.Email)
var count int64
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email availability"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
return
}
// If email is changing, verify password
if req.Email != user.Email {
if req.CurrentPassword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is required to change email"})
return
}
if !user.CheckPassword(req.CurrentPassword) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
return
}
}
if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"name": req.Name,
"email": req.Email,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"})
}