package handlers import ( "auto-ssl/config" "auto-ssl/services" "fmt" "net/http" "path/filepath" "strconv" "time" "github.com/gin-gonic/gin" "strings" ) type CertHandler struct { Cfg *config.Config } func NewCertHandler(cfg *config.Config) *CertHandler { return &CertHandler{Cfg: cfg} } // ListCertificates returns all certificates func (h *CertHandler) ListCertificates(c *gin.Context) { var certs []config.Certificate if err := config.DB.Order("created_at desc").Find(&certs).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, certs) } // GetCertificate returns a single certificate func (h *CertHandler) GetCertificate(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var cert config.Certificate if err := config.DB.First(&cert, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) return } c.JSON(http.StatusOK, cert) } type CreateCertRequest struct { Domain string `json:"domain" binding:"required"` Email string `json:"email" binding:"required"` Provider string `json:"provider"` // letsencrypt, zerossl ChallengeType string `json:"challenge_type"` // http, dns DNSProvider string `json:"dns_provider"` DNSConfig string `json:"dns_config"` // JSON AutoRenew *bool `json:"auto_renew"` RenewDays *int `json:"renew_days"` } // CreateCertificate creates a new certificate entry and starts issuance func (h *CertHandler) CreateCertificate(c *gin.Context) { var req CreateCertRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Provider == "" { req.Provider = "letsencrypt" } if req.ChallengeType == "" { req.ChallengeType = "http" } // Trim spaces from domain req.Domain = strings.TrimSpace(req.Domain) // Use SQLite upsert (INSERT ... ON CONFLICT) to atomically handle domain uniqueness // This avoids the race condition between checking existence and inserting result := config.DB.Exec(` INSERT INTO certificates (domain, email, provider, challenge_type, dns_provider, dns_config, status, auto_renew, renew_days, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, datetime('now'), datetime('now')) ON CONFLICT(domain) DO UPDATE SET email = excluded.email, provider = excluded.provider, challenge_type = excluded.challenge_type, dns_provider = excluded.dns_provider, dns_config = excluded.dns_config, status = CASE WHEN status IN ('error', 'expired', 'pending') THEN 'pending' ELSE status END, error_message = CASE WHEN status IN ('error', 'expired', 'pending') THEN '' ELSE error_message END, updated_at = datetime('now') WHERE excluded.domain = certificates.domain `, req.Domain, req.Email, req.Provider, req.ChallengeType, req.DNSProvider, req.DNSConfig, req.AutoRenew != nil && *req.AutoRenew, req.RenewDays) if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()}) return } // Reload to get the current state var cert config.Certificate config.DB.Where("domain = ?", req.Domain).First(&cert) // If domain already existed and was active, return conflict if cert.Status != "pending" { c.JSON(http.StatusConflict, gin.H{"error": "domain already exists with status: " + cert.Status}) return } // Start issuance in background go func() { if err := services.GetACMECertificate(&cert, h.Cfg); err != nil { cert.Status = "error" cert.ErrorMessage = err.Error() } else { cert.Status = "active" } config.DB.Save(&cert) }() c.JSON(http.StatusAccepted, gin.H{"message": "certificate issuance started", "certificate": cert}) } // RenewCertificate manually renews a certificate func (h *CertHandler) RenewCertificate(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var cert config.Certificate if err := config.DB.First(&cert, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) return } cert.Status = "renewing" config.DB.Save(&cert) go func() { if err := services.RenewCertificate(&cert, h.Cfg); err != nil { cert.Status = "error" cert.ErrorMessage = err.Error() } else { cert.Status = "active" } config.DB.Save(&cert) }() c.JSON(http.StatusAccepted, gin.H{"message": "renewal started", "certificate": cert}) } // DeleteCertificate deletes a certificate record and files func (h *CertHandler) DeleteCertificate(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var cert config.Certificate if err := config.DB.First(&cert, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) return } if err := config.DB.Delete(&cert).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"}) } // UpdateCertificate updates certificate settings func (h *CertHandler) UpdateCertificate(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var cert config.Certificate if err := config.DB.First(&cert, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) return } var updates map[string]interface{} if err := c.ShouldBindJSON(&updates); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Only allow updating certain fields allowedFields := map[string]bool{ "auto_renew": true, "renew_days": true, "dns_config": true, } filtered := make(map[string]interface{}) for k, v := range updates { if allowedFields[k] { filtered[k] = v } } if err := config.DB.Model(&cert).Updates(filtered).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, cert) } // GetCertFiles returns the content of certificate files for download func (h *CertHandler) GetCertFiles(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } var cert config.Certificate if err := config.DB.First(&cert, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"}) return } fullchain, privkey, chain := services.GetCertFilesPaths(cert.Domain, h.Cfg) result := gin.H{ "domain": cert.Domain, "fullchain": readFileSafe(fullchain), "privkey": readFileSafe(privkey), "chain": readFileSafe(chain), } c.JSON(http.StatusOK, result) } // CheckRenewals checks all certificates and renews those about to expire func (h *CertHandler) CheckRenewals(c *gin.Context) { var certs []config.Certificate config.DB.Where("auto_renew = ? AND status = ?", true, "active").Find(&certs) renewed := []string{} failed := []string{} for _, cert := range certs { if cert.ExpiresAt != nil && time.Until(*cert.ExpiresAt).Hours() < float64(cert.RenewDays*24) { if err := services.RenewCertificate(&cert, h.Cfg); err != nil { cert.Status = "error" cert.ErrorMessage = fmt.Sprintf("auto renew failed: %v", err) failed = append(failed, cert.Domain) } else { cert.Status = "active" renewed = append(renewed, cert.Domain) } config.DB.Save(&cert) } } c.JSON(http.StatusOK, gin.H{ "message": "renewal check complete", "renewed": renewed, "failed": failed, }) } // Stats returns dashboard statistics func (h *CertHandler) Stats(c *gin.Context) { var total, active, expired, errors int64 config.DB.Model(&config.Certificate{}).Count(&total) config.DB.Model(&config.Certificate{}).Where("status = ?", "active").Count(&active) config.DB.Model(&config.Certificate{}).Where("status = ?", "expired").Count(&expired) config.DB.Model(&config.Certificate{}).Where("status = ?", "error").Count(&errors) c.JSON(http.StatusOK, gin.H{ "total": total, "active": active, "expired": expired, "errors": errors, }) } func readFileSafe(path string) string { data, err := filepath.Abs(path) if err != nil { return "" } return data }