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>
105 lines
3.8 KiB
Go
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
|
|
}
|