Files
Debian 62505d547c feat(license): Lizenz-System mit Ed25519-Verify gegen license.netcell-it.com
Portiert mail-gateway/internal/license (Verify, Cache, Trial, Signature)
+ DB-Mirror (internal/services/license) + REST-Handler (status/verify/key/clear)
+ UI-Page /license (Activate, Status, Limits, Features, Re-verify)
+ <LicenseBanner /> neben UpdateBanner (trial-expiring, expired, verify-failed)
+ Scheduler: täglich Re-verify (24h-Tick)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:41:16 +02:00

105 lines
3.8 KiB
Go

// Package license provides a thin DB-mirror of the in-memory
// license.Result. The actual verify-against-license-server logic
// lives in internal/license; this package only persists the latest
// result into the licenses table so the UI can show it without a
// live network call.
package license
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repo struct {
Pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
// State is what we persist + what the UI reads.
type State struct {
LicenseKey string `json:"license_key"`
Status string `json:"status"`
ValidUntil *time.Time `json:"valid_until,omitempty"`
LastVerifiedAt *time.Time `json:"last_verified_at,omitempty"`
LastVerifiedNode *string `json:"last_verified_node,omitempty"`
ActiveDomainsAtVerify *int `json:"active_domains_at_verify,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
LastError *string `json:"last_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Get returns the most-recent license row (we keep one row per key
// but typically the operator only has one key — so this returns the
// last-updated). Returns sql ErrNoRows-equivalent if none exists.
func (r *Repo) Get(ctx context.Context) (*State, error) {
row := r.Pool.QueryRow(ctx, `
SELECT license_key, status, valid_until, last_verified_at, last_verified_node,
active_domains_at_verify, payload, last_error, created_at, updated_at
FROM licenses
ORDER BY updated_at DESC
LIMIT 1`)
var s State
if err := row.Scan(&s.LicenseKey, &s.Status, &s.ValidUntil, &s.LastVerifiedAt,
&s.LastVerifiedNode, &s.ActiveDomainsAtVerify, &s.Payload, &s.LastError,
&s.CreatedAt, &s.UpdatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &s, nil
}
// Upsert persists a fresh verify-result. Empty error string clears
// any previous error.
func (r *Repo) Upsert(ctx context.Context, key, status string, validUntil *time.Time,
nodeID string, activeDomains int, payload []byte, lastErr string) error {
var nodeArg any = nodeID
if nodeID == "" {
nodeArg = nil
}
var errArg any = lastErr
if lastErr == "" {
errArg = nil
}
now := time.Now().UTC()
_, err := r.Pool.Exec(ctx, `
INSERT INTO licenses (license_key, status, valid_until, last_verified_at,
last_verified_node, active_domains_at_verify, payload, last_error)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (license_key) DO UPDATE SET
status = EXCLUDED.status,
valid_until = EXCLUDED.valid_until,
last_verified_at = EXCLUDED.last_verified_at,
last_verified_node = EXCLUDED.last_verified_node,
active_domains_at_verify = EXCLUDED.active_domains_at_verify,
payload = EXCLUDED.payload,
last_error = EXCLUDED.last_error,
updated_at = NOW()`,
key, status, validUntil, now, nodeArg, activeDomains, payload, errArg)
return err
}
// MarkError records a verify failure WITHOUT touching status — the
// previous valid status stays so a transient server failure doesn't
// flip the box into "expired" mid-session.
func (r *Repo) MarkError(ctx context.Context, key, errMsg string) error {
_, err := r.Pool.Exec(ctx,
`UPDATE licenses SET last_error = $1, updated_at = NOW() WHERE license_key = $2`,
errMsg, key)
return err
}
// Delete removes the row when the operator clears the key.
func (r *Repo) Delete(ctx context.Context, key string) error {
_, err := r.Pool.Exec(ctx, `DELETE FROM licenses WHERE license_key = $1`, key)
return err
}