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>
This commit is contained in:
104
internal/services/license/license.go
Normal file
104
internal/services/license/license.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user