refactor: replace SQLite/GORM with JSON file storage

- Remove GORM and SQLite, use simple JSON file (data/certs.json) for persistence
- CertStore with sync.RWMutex for thread-safe in-memory cache
- All handlers updated to use config.Store instead of config.DB
- Cron job and stats updated accordingly
- go mod tidy removes unused gorm/sqlite dependencies
This commit is contained in:
2026-05-12 15:27:10 +08:00
parent d0e738e1ef
commit 3fa77d9bc0
6 changed files with 292 additions and 164 deletions
+116 -97
View File
@@ -7,10 +7,10 @@ import (
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"strings"
)
type CertHandler struct {
@@ -21,13 +21,20 @@ func NewCertHandler(cfg *config.Config) *CertHandler {
return &CertHandler{Cfg: cfg}
}
type CreateCertRequest struct {
Domain string `json:"domain" binding:"required"`
Email string `json:"email" binding:"required"`
Provider string `json:"provider"`
ChallengeType string `json:"challenge_type"`
DNSProvider string `json:"dns_provider"`
DNSConfig string `json:"dns_config"`
AutoRenew *bool `json:"auto_renew"`
RenewDays *int `json:"renew_days"`
}
// 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
}
certs := config.Store.GetAll()
c.JSON(http.StatusOK, certs)
}
@@ -38,25 +45,14 @@ func (h *CertHandler) GetCertificate(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var cert config.Certificate
if err := config.DB.First(&cert, id).Error; err != nil {
cert := config.Store.GetByID(uint(id))
if cert == 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
@@ -72,56 +68,81 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
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)
autoRenew := true
renewDays := 30
if req.AutoRenew != nil {
autoRenew = *req.AutoRenew
}
if req.RenewDays != nil {
renewDays = *req.RenewDays
}
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
existing := config.Store.GetByDomain(req.Domain)
if existing != nil {
// Domain exists: only retry if it's in a retryable state
if existing.Status == "error" || existing.Status == "expired" || existing.Status == "pending" {
existing.Email = req.Email
existing.Provider = req.Provider
existing.ChallengeType = req.ChallengeType
existing.DNSProvider = req.DNSProvider
existing.DNSConfig = req.DNSConfig
existing.Status = "pending"
existing.ErrorMessage = ""
existing.AutoRenew = autoRenew
existing.RenewDays = renewDays
config.Store.Upsert(existing)
// Start issuance in background
go func() {
domain := existing.Domain
saved := config.Store.GetByDomain(domain)
if err := services.GetACMECertificate(saved, h.Cfg); err != nil {
saved.Status = "error"
saved.ErrorMessage = err.Error()
} else {
saved.Status = "active"
}
config.Store.Upsert(saved)
}()
c.JSON(http.StatusAccepted, gin.H{"message": "certificate re-issuance started", "certificate": existing})
return
}
c.JSON(http.StatusConflict, gin.H{"error": "domain already exists with status: " + existing.Status})
return
}
// Reload to get the current state
var cert config.Certificate
config.DB.Where("domain = ?", req.Domain).First(&cert)
// Create new certificate
cert := &config.Certificate{
Domain: req.Domain,
Email: req.Email,
Provider: req.Provider,
ChallengeType: req.ChallengeType,
DNSProvider: req.DNSProvider,
DNSConfig: req.DNSConfig,
Status: "pending",
AutoRenew: autoRenew,
RenewDays: renewDays,
}
// 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})
if err := config.Store.Upsert(cert); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Start issuance in background
go func() {
if err := services.GetACMECertificate(&cert, h.Cfg); err != nil {
cert.Status = "error"
cert.ErrorMessage = err.Error()
domain := cert.Domain
saved := config.Store.GetByDomain(domain)
if err := services.GetACMECertificate(saved, h.Cfg); err != nil {
saved.Status = "error"
saved.ErrorMessage = err.Error()
} else {
cert.Status = "active"
saved.Status = "active"
}
config.DB.Save(&cert)
config.Store.Upsert(saved)
}()
c.JSON(http.StatusAccepted, gin.H{"message": "certificate issuance started", "certificate": cert})
@@ -135,23 +156,25 @@ func (h *CertHandler) RenewCertificate(c *gin.Context) {
return
}
var cert config.Certificate
if err := config.DB.First(&cert, id).Error; err != nil {
cert := config.Store.GetByID(uint(id))
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
cert.Status = "renewing"
config.DB.Save(&cert)
config.Store.Upsert(cert)
go func() {
if err := services.RenewCertificate(&cert, h.Cfg); err != nil {
cert.Status = "error"
cert.ErrorMessage = err.Error()
domain := cert.Domain
saved := config.Store.GetByDomain(domain)
if err := services.RenewCertificate(saved, h.Cfg); err != nil {
saved.Status = "error"
saved.ErrorMessage = err.Error()
} else {
cert.Status = "active"
saved.Status = "active"
}
config.DB.Save(&cert)
config.Store.Upsert(saved)
}()
c.JSON(http.StatusAccepted, gin.H{"message": "renewal started", "certificate": cert})
@@ -165,13 +188,7 @@ func (h *CertHandler) DeleteCertificate(c *gin.Context) {
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 {
if err := config.Store.Delete(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -187,8 +204,8 @@ func (h *CertHandler) UpdateCertificate(c *gin.Context) {
return
}
var cert config.Certificate
if err := config.DB.First(&cert, id).Error; err != nil {
cert := config.Store.GetByID(uint(id))
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
@@ -199,25 +216,19 @@ func (h *CertHandler) UpdateCertificate(c *gin.Context) {
return
}
// Only allow updating certain fields
allowedFields := map[string]bool{
"auto_renew": true,
"renew_days": true,
"dns_config": true,
if v, ok := updates["auto_renew"]; ok {
cert.AutoRenew, _ = v.(bool)
}
filtered := make(map[string]interface{})
for k, v := range updates {
if allowedFields[k] {
filtered[k] = v
if v, ok := updates["renew_days"]; ok {
if f, ok := v.(float64); ok {
cert.RenewDays = int(f)
}
}
if err := config.DB.Model(&cert).Updates(filtered).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
if v, ok := updates["dns_config"]; ok {
cert.DNSConfig, _ = v.(string)
}
config.Store.Upsert(cert)
c.JSON(http.StatusOK, cert)
}
@@ -229,8 +240,8 @@ func (h *CertHandler) GetCertFiles(c *gin.Context) {
return
}
var cert config.Certificate
if err := config.DB.First(&cert, id).Error; err != nil {
cert := config.Store.GetByID(uint(id))
if cert == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
return
}
@@ -248,15 +259,14 @@ func (h *CertHandler) GetCertFiles(c *gin.Context) {
// 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)
certs := config.Store.GetActiveWithAutoRenew()
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 {
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)
@@ -264,7 +274,7 @@ func (h *CertHandler) CheckRenewals(c *gin.Context) {
cert.Status = "active"
renewed = append(renewed, cert.Domain)
}
config.DB.Save(&cert)
config.Store.Upsert(cert)
}
}
@@ -277,11 +287,20 @@ func (h *CertHandler) CheckRenewals(c *gin.Context) {
// 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)
certs := config.Store.GetAll()
var total, active, expired, errors int
for _, cert := range certs {
total++
switch cert.Status {
case "active":
active++
case "expired":
expired++
case "error":
errors++
}
}
c.JSON(http.StatusOK, gin.H{
"total": total,