- Kompletna Go implementacija licencnog servera (19 Go fajlova) - Klijentski API: activate, deactivate, validate - Admin API: CRUD licence, stats, audit log - Admin dashboard: htmx + Go templates - RSA-2048 potpisivanje licencnih podataka - Rate limiting i API key autentifikacija - MySQL migracije i seed podaci (ESIR, ARV, LIGHT_TICKET) - Unit testovi: keygen, crypto, model, middleware (24 testa) - Dokumentacija: SPEC.md, ARCHITECTURE.md, SETUP.md, API.md, TESTING.md, README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
222 lines
8.7 KiB
Go
222 lines
8.7 KiB
Go
package repository
|
|
|
|
import (
|
|
"dal-license-server/internal/model"
|
|
"database/sql"
|
|
"fmt"
|
|
)
|
|
|
|
type LicenseRepo struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewLicenseRepo(db *sql.DB) *LicenseRepo {
|
|
return &LicenseRepo{db: db}
|
|
}
|
|
|
|
func (r *LicenseRepo) Create(l *model.License) (int64, error) {
|
|
res, err := r.db.Exec(`INSERT INTO licenses (product_id, license_key, license_type, customer_name, customer_pib, customer_email, limits_json, features, expires_at, grace_days, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
l.ProductID, l.LicenseKey, l.LicenseType, l.CustomerName, l.CustomerPIB, l.CustomerEmail,
|
|
string(l.Limits), string(l.Features), l.ExpiresAt, l.GraceDays, l.Notes)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("create license: %w", err)
|
|
}
|
|
return res.LastInsertId()
|
|
}
|
|
|
|
func (r *LicenseRepo) GetByID(id int64) (*model.License, error) {
|
|
l := &model.License{}
|
|
var limits, features string
|
|
var revokedReason, notes sql.NullString
|
|
err := r.db.QueryRow(`SELECT l.id, l.product_id, l.license_key, l.license_type, l.customer_name, l.customer_pib, l.customer_email,
|
|
l.limits_json, l.features, l.issued_at, l.expires_at, l.grace_days, l.active, l.revoked, l.revoked_at, l.revoked_reason, l.notes, l.created_at, l.updated_at,
|
|
p.code, p.name, p.key_prefix
|
|
FROM licenses l JOIN products p ON l.product_id = p.id WHERE l.id = ?`, id).
|
|
Scan(&l.ID, &l.ProductID, &l.LicenseKey, &l.LicenseType, &l.CustomerName, &l.CustomerPIB, &l.CustomerEmail,
|
|
&limits, &features, &l.IssuedAt, &l.ExpiresAt, &l.GraceDays, &l.Active, &l.Revoked, &l.RevokedAt, &revokedReason, ¬es, &l.CreatedAt, &l.UpdatedAt,
|
|
&l.ProductCode, &l.ProductName, &l.ProductPrefix)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get license %d: %w", id, err)
|
|
}
|
|
l.Limits = []byte(limits)
|
|
l.Features = []byte(features)
|
|
if revokedReason.Valid {
|
|
l.RevokedReason = revokedReason.String
|
|
}
|
|
if notes.Valid {
|
|
l.Notes = notes.String
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func (r *LicenseRepo) GetByKey(key string) (*model.License, error) {
|
|
l := &model.License{}
|
|
var limits, features string
|
|
var revokedReason, notes sql.NullString
|
|
err := r.db.QueryRow(`SELECT l.id, l.product_id, l.license_key, l.license_type, l.customer_name, l.customer_pib, l.customer_email,
|
|
l.limits_json, l.features, l.issued_at, l.expires_at, l.grace_days, l.active, l.revoked, l.revoked_at, l.revoked_reason, l.notes, l.created_at, l.updated_at,
|
|
p.code, p.name, p.key_prefix
|
|
FROM licenses l JOIN products p ON l.product_id = p.id WHERE l.license_key = ?`, key).
|
|
Scan(&l.ID, &l.ProductID, &l.LicenseKey, &l.LicenseType, &l.CustomerName, &l.CustomerPIB, &l.CustomerEmail,
|
|
&limits, &features, &l.IssuedAt, &l.ExpiresAt, &l.GraceDays, &l.Active, &l.Revoked, &l.RevokedAt, &revokedReason, ¬es, &l.CreatedAt, &l.UpdatedAt,
|
|
&l.ProductCode, &l.ProductName, &l.ProductPrefix)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get license by key: %w", err)
|
|
}
|
|
l.Limits = []byte(limits)
|
|
l.Features = []byte(features)
|
|
if revokedReason.Valid {
|
|
l.RevokedReason = revokedReason.String
|
|
}
|
|
if notes.Valid {
|
|
l.Notes = notes.String
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func (r *LicenseRepo) List(productCode, status, search string) ([]model.LicenseWithActivation, error) {
|
|
query := `SELECT l.id, l.product_id, l.license_key, l.license_type, l.customer_name, l.customer_pib, l.customer_email,
|
|
l.limits_json, l.features, l.issued_at, l.expires_at, l.grace_days, l.active, l.revoked, l.revoked_at, l.revoked_reason, l.notes, l.created_at, l.updated_at,
|
|
p.code, p.name, p.key_prefix,
|
|
(SELECT COUNT(*) FROM activations a WHERE a.license_id = l.id AND a.is_active = TRUE) as active_activations
|
|
FROM licenses l JOIN products p ON l.product_id = p.id WHERE 1=1`
|
|
var args []interface{}
|
|
|
|
if productCode != "" {
|
|
query += " AND p.code = ?"
|
|
args = append(args, productCode)
|
|
}
|
|
if search != "" {
|
|
query += " AND (l.customer_name LIKE ? OR l.license_key LIKE ?)"
|
|
args = append(args, "%"+search+"%", "%"+search+"%")
|
|
}
|
|
switch status {
|
|
case "active":
|
|
query += " AND l.active = TRUE AND l.revoked = FALSE AND (l.expires_at IS NULL OR l.expires_at > NOW())"
|
|
case "expired":
|
|
query += " AND l.expires_at IS NOT NULL AND l.expires_at <= NOW() AND l.revoked = FALSE"
|
|
case "revoked":
|
|
query += " AND l.revoked = TRUE"
|
|
case "trial":
|
|
query += " AND l.license_type = 'TRIAL'"
|
|
}
|
|
|
|
query += " ORDER BY l.created_at DESC LIMIT 200"
|
|
|
|
rows, err := r.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list licenses: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var licenses []model.LicenseWithActivation
|
|
for rows.Next() {
|
|
var lwa model.LicenseWithActivation
|
|
var limits, features string
|
|
var revokedReason, notes sql.NullString
|
|
err := rows.Scan(&lwa.ID, &lwa.ProductID, &lwa.LicenseKey, &lwa.LicenseType, &lwa.CustomerName, &lwa.CustomerPIB, &lwa.CustomerEmail,
|
|
&limits, &features, &lwa.IssuedAt, &lwa.ExpiresAt, &lwa.GraceDays, &lwa.Active, &lwa.Revoked, &lwa.RevokedAt, &revokedReason, ¬es, &lwa.CreatedAt, &lwa.UpdatedAt,
|
|
&lwa.ProductCode, &lwa.ProductName, &lwa.ProductPrefix,
|
|
&lwa.ActiveActivations)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan license: %w", err)
|
|
}
|
|
lwa.Limits = []byte(limits)
|
|
lwa.Features = []byte(features)
|
|
if revokedReason.Valid {
|
|
lwa.RevokedReason = revokedReason.String
|
|
}
|
|
if notes.Valid {
|
|
lwa.Notes = notes.String
|
|
}
|
|
licenses = append(licenses, lwa)
|
|
}
|
|
return licenses, nil
|
|
}
|
|
|
|
func (r *LicenseRepo) Update(l *model.License) error {
|
|
_, err := r.db.Exec(`UPDATE licenses SET customer_name=?, customer_pib=?, customer_email=?, limits_json=?, features=?, grace_days=?, expires_at=?, notes=? WHERE id=?`,
|
|
l.CustomerName, l.CustomerPIB, l.CustomerEmail, string(l.Limits), string(l.Features), l.GraceDays, l.ExpiresAt, l.Notes, l.ID)
|
|
return err
|
|
}
|
|
|
|
func (r *LicenseRepo) Revoke(id int64, reason string) error {
|
|
_, err := r.db.Exec(`UPDATE licenses SET revoked=TRUE, revoked_at=NOW(), revoked_reason=?, active=FALSE WHERE id=?`, reason, id)
|
|
return err
|
|
}
|
|
|
|
func (r *LicenseRepo) CountByProduct() ([]model.ProductStats, error) {
|
|
rows, err := r.db.Query(`SELECT p.code, p.name,
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN l.active AND NOT l.revoked AND (l.expires_at IS NULL OR l.expires_at > NOW()) THEN 1 ELSE 0 END) as act,
|
|
SUM(CASE WHEN l.expires_at IS NOT NULL AND l.expires_at <= NOW() AND NOT l.revoked THEN 1 ELSE 0 END) as exp,
|
|
SUM(CASE WHEN l.expires_at IS NOT NULL AND l.expires_at <= NOW() AND DATE_ADD(l.expires_at, INTERVAL l.grace_days DAY) > NOW() AND NOT l.revoked THEN 1 ELSE 0 END) as grace,
|
|
SUM(CASE WHEN l.license_type = 'TRIAL' THEN 1 ELSE 0 END) as trial,
|
|
(SELECT COUNT(*) FROM activations a JOIN licenses l2 ON a.license_id=l2.id WHERE l2.product_id=p.id AND a.is_active=TRUE) as active_acts
|
|
FROM licenses l JOIN products p ON l.product_id=p.id GROUP BY p.id, p.code, p.name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var stats []model.ProductStats
|
|
for rows.Next() {
|
|
var s model.ProductStats
|
|
rows.Scan(&s.ProductCode, &s.ProductName, &s.Total, &s.Active, &s.Expired, &s.InGrace, &s.Trial, &s.ActiveActivations)
|
|
stats = append(stats, s)
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (r *LicenseRepo) GetProducts() ([]model.Product, error) {
|
|
rows, err := r.db.Query("SELECT id, code, name, key_prefix, default_limits, available_features, active, created_at FROM products WHERE active = TRUE")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var products []model.Product
|
|
for rows.Next() {
|
|
var p model.Product
|
|
var limits, features string
|
|
rows.Scan(&p.ID, &p.Code, &p.Name, &p.KeyPrefix, &limits, &features, &p.Active, &p.CreatedAt)
|
|
p.DefaultLimits = []byte(limits)
|
|
p.AvailableFeatures = []byte(features)
|
|
products = append(products, p)
|
|
}
|
|
return products, nil
|
|
}
|
|
|
|
func (r *LicenseRepo) GetProductByID(id int64) (*model.Product, error) {
|
|
var p model.Product
|
|
var limits, features string
|
|
err := r.db.QueryRow("SELECT id, code, name, key_prefix, default_limits, available_features, active, created_at FROM products WHERE id = ?", id).
|
|
Scan(&p.ID, &p.Code, &p.Name, &p.KeyPrefix, &limits, &features, &p.Active, &p.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.DefaultLimits = []byte(limits)
|
|
p.AvailableFeatures = []byte(features)
|
|
return &p, nil
|
|
}
|
|
|
|
func (r *LicenseRepo) ExpiringIn(days int) ([]model.License, error) {
|
|
rows, err := r.db.Query(`SELECT l.id, l.license_key, l.license_type, l.customer_name, l.expires_at, p.code, p.name, p.key_prefix
|
|
FROM licenses l JOIN products p ON l.product_id=p.id
|
|
WHERE l.active AND NOT l.revoked AND l.expires_at IS NOT NULL AND l.expires_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL ? DAY)
|
|
ORDER BY l.expires_at`, days)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var licenses []model.License
|
|
for rows.Next() {
|
|
var l model.License
|
|
rows.Scan(&l.ID, &l.LicenseKey, &l.LicenseType, &l.CustomerName, &l.ExpiresAt, &l.ProductCode, &l.ProductName, &l.ProductPrefix)
|
|
licenses = append(licenses, l)
|
|
}
|
|
return licenses, nil
|
|
}
|