This commit is contained in:
17
.env.example
17
.env.example
@@ -109,6 +109,9 @@ BEACON_DRAND_BEACON_ID=quicknet
|
||||
# Same secret is read by the game server and Customer Service. Generate a
|
||||
# separate random value; do not reuse JWT_SECRET.
|
||||
CUSTOMER_SERVICE_SHARED_SECRET=replace-with-a-separate-32-plus-char-random-secret
|
||||
# Game -> Customer Service internal callback for durable hosted-worker credit rewards.
|
||||
# Docker/Compose: http://customer-service:8092 ; local go run: http://127.0.0.1:8092
|
||||
CUSTOMER_SERVICE_INTERNAL_URL=http://127.0.0.1:8092
|
||||
|
||||
# Customer portal (8090), private Customer-Service admin (8091), and the
|
||||
# Docker-network-only worker registration listener (8092).
|
||||
@@ -129,6 +132,20 @@ CS_ADMIN_PASSWORD=replace-with-another-strong-unique-password
|
||||
# in production unless you explicitly need private admin grants.
|
||||
CS_ALLOW_MANUAL_CREDITS=0
|
||||
|
||||
# Customer engagement defaults. They are seeded into customer-service SQLite
|
||||
# only once and can then be changed live in the private Customer Admin UI.
|
||||
CS_CUSTOMER_LOGIN_ENABLED=true
|
||||
CS_CUSTOMER_REGISTRATION_ENABLED=true
|
||||
# 0 disables browser registration proof-of-work; 16 is a moderate default.
|
||||
CS_REGISTRATION_POW_BITS=16
|
||||
# When true, registration also requires an admin-generated one-shot invite.
|
||||
CS_REGISTRATION_INVITE_REQUIRED=false
|
||||
# Free prepaid credits for a newly created customer account. 0 disables.
|
||||
CS_NEW_CUSTOMER_CREDITS=0
|
||||
# Credits granted when a hosted worker improves its personal best score.
|
||||
# 0 disables the reward. Normal browser/CLI identities are never credited here.
|
||||
CS_POSITIVE_TIP_CREDITS=0
|
||||
|
||||
# Hosted worker billing: 1.0 means one credit is consumed for each paid minute
|
||||
# of each running worker. Billing is prepaid; the worker stops before a minute
|
||||
# that cannot be funded.
|
||||
|
||||
@@ -241,3 +241,21 @@ stores the returned signature for external verification. It does **not** yet
|
||||
perform local BLS signature verification against the drand chain public key.
|
||||
If cryptographic self-verification is a product requirement, add a vetted drand
|
||||
client/verifier before making that claim in customer-facing material.
|
||||
|
||||
|
||||
## V4.2.6 engagement credits and account controls
|
||||
|
||||
The Hosted Service has two optional customer-funded-by-game credit rewards. Both default to zero and are administered persistently from the private Customer Admin:
|
||||
|
||||
```env
|
||||
CS_NEW_CUSTOMER_CREDITS=0
|
||||
CS_POSITIVE_TIP_CREDITS=0
|
||||
```
|
||||
|
||||
`CS_NEW_CUSTOMER_CREDITS` is booked atomically with account creation. `CS_POSITIVE_TIP_CREDITS` is booked only for a delegated Hosted Worker when an accepted/evaluated guess creates a new personal best score. A ticket that is not selected by the lottery is not scored and earns no reward. Normal browser/CLI identities do not create these Customer-Service reward events.
|
||||
|
||||
The game persists each positive-tip event to an outbox and retries delivery to the private Customer Service (`CUSTOMER_SERVICE_INTERNAL_URL`, normally `http://customer-service:8092` in Docker). Customer Service applies its current configured reward amount and uses an idempotent ledger reference, so temporary outages and retries neither lose nor duplicate credits.
|
||||
|
||||
When PayPal is disabled the public Customer Portal hides the purchase panel entirely. Its visible credit history is aggregated by UTC day/reason for readability; the raw ledger remains unchanged in the database.
|
||||
|
||||
The private Customer Admin can also block/unblock accounts, stop individual/all workers, disable new logins, disable new registrations, configure registration Proof-of-Work and optionally enforce time-limited one-shot invite codes. Blocking an account deletes its active sessions and revokes Worker leases immediately.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
> **V4.2.6 Customer Engagement + Admin Controls:** Der Hosted Customer Service kann neuen Konten ein konfigurierbares Startguthaben geben und Hosted Workern für echte persönliche Best-Score-Verbesserungen konfigurierbare Bonus-Credits gutschreiben. Das Customer Portal blendet PayPal vollständig aus, wenn PayPal deaktiviert ist, und fasst den Credit-Verlauf kompakt nach Tag/Buchungsart zusammen. Im privaten Customer-Admin gibt es Benutzer sperren/freigeben, Worker-Stop, Login-/Registrierungs-Kill-Switches, Registrierungs-Proof-of-Work und optionale einmalige Invite-Codes. Positive-Tip-Rewards werden über eine persistente Game-Outbox idempotent an den Customer Service zugestellt. Details: `V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md`.
|
||||
|
||||
> **V4.2 Pipeline Dockerfiles:** Server, Customer Service und Worker besitzen jetzt jeweils ein eigenes Dockerfile (`Dockerfile.server`, `Dockerfile.customer-service`, `Dockerfile.worker`). Das bestehende `Dockerfile` baut weiterhin den Server, damit vorhandene Single-Image-Pipelines kompatibel bleiben. `CS_WORKER_IMAGE` zeigt weiterhin direkt auf das veröffentlichte Worker-Image.
|
||||
|
||||
> **V4.0 Beacon Hunt + Hosted PrePaid Service:** Optional kann die Task-Lotterie jetzt PULSE/FLUX/ORBIT als vorab signierte Spielerentscheidung verwenden. Der Draw nutzt einen erst nach Fensterschluss verfügbaren drand-Round und speichert Round, Signatur, abgeleitete Randomness und Boost für Audit/Collectible-Traits. Zusätzlich gibt es einen separat aktivierbaren Customer-Service mit PrePaid-Zeitabrechnung, PayPal-Sandbox/Orders-v2-Flow, mehreren Docker-Workern pro Kunde, portablen Worker-Identitäten und delegiertem Reward-Owner. Details: `HOSTED_SERVICE.md`.
|
||||
|
||||
21
TESTING.md
21
TESTING.md
@@ -480,3 +480,24 @@ After publishing the new worker image, start a hosted worker that already owns
|
||||
an identity volume from an older release. It must start without `permission
|
||||
denied`, keep the same client identity, and remain writable at
|
||||
`/identity/identity.json`. Do not delete the named volume for this test.
|
||||
|
||||
|
||||
## V4.2.6 Customer engagement/admin smoke test
|
||||
|
||||
1. Start Game + Customer Service with the same `CUSTOMER_SERVICE_SHARED_SECRET`. In Docker ensure the Game container has:
|
||||
|
||||
```env
|
||||
CUSTOMER_SERVICE_INTERNAL_URL=http://customer-service:8092
|
||||
```
|
||||
|
||||
2. In private Customer Admin set a small **Startguthaben**, e.g. `3`, register a fresh customer and confirm its first balance is exactly `3` Credits and the compact ledger contains `Startguthaben`.
|
||||
3. Set a small **Reward pro positivem Worker-Tipp**, link a main reward identity, start a Hosted Worker, and let it produce evaluated guesses. A guess earns a reward only when it raises that Worker's personal best. Lottery-rejected/non-evaluated tickets and non-improving guesses must not add credits.
|
||||
4. Stop Customer Service temporarily while a Hosted Worker gets a personal-best improvement, then restart it. Confirm the Game DB's pending outbox is delivered later and the Customer ledger receives the reward exactly once.
|
||||
5. Set `PAYPAL_ENABLED=false`; after reload the public portal must not show **Credits kaufen** and must not request package data as part of normal portal loading.
|
||||
6. Generate enough minute/reward entries to verify the Customer credit history groups repeated rows by UTC day and reason. Confirm the raw `credit_ledger` rows still exist separately in SQLite.
|
||||
7. In private Customer Admin block a test user. Existing Customer sessions must become unauthorized, all its Worker leases must be revoked/stopped, and login must return 403 until the user is unblocked.
|
||||
8. Disable **Benutzer-Anmeldung erlauben**. New login attempts must receive 503; an already-authenticated, non-blocked session intentionally remains valid. Re-enable afterwards.
|
||||
9. Disable **Neue Registrierungen erlauben** and confirm both challenge/registration are rejected. Re-enable and set PoW to a small test value (e.g. 8) to verify the browser solves the challenge automatically.
|
||||
10. Enable **Registrierung nur mit einmaligem Invite-Code**, create an invite in Customer Admin, register one account with it, then verify the same invite cannot create a second account.
|
||||
|
||||
For production/open registration, do not rely on PoW as a one-human/one-account guarantee. Keep Traefik/Caddy rate/concurrency limits on registration/login routes and consider invite-only registration when free signup credits have meaningful value.
|
||||
|
||||
69
V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md
Normal file
69
V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# V4.2.6 — Customer Engagement + Admin Controls
|
||||
|
||||
## Credit rewards
|
||||
|
||||
Two optional reward values are managed persistently by the private Customer-Service admin UI:
|
||||
|
||||
- **Signup bonus**: credited atomically when a new Customer account is created.
|
||||
- **Positive-tip bonus**: credited when a **Hosted Worker** submits a signed, lottery-selected/evaluated guess that improves that worker's own best score for the task.
|
||||
|
||||
The defaults are intentionally `0`, so an update does not change the existing economy until the operator enables it:
|
||||
|
||||
```env
|
||||
CS_NEW_CUSTOMER_CREDITS=0
|
||||
CS_POSITIVE_TIP_CREDITS=0
|
||||
```
|
||||
|
||||
These environment values seed SQLite only the first time the setting exists. Afterwards the live values are controlled from Customer Admin and survive restarts.
|
||||
|
||||
A positive-tip reward is **not** granted for every submitted guess and not for a guess that loses the task lottery before evaluation. Ordinary browser/CLI identities also do not receive Customer-Service credits. The Game server creates the reward event only when the submitting identity has an active Hosted Worker delegation.
|
||||
|
||||
To avoid losing rewards during a Customer-Service restart/outage, the Game DB contains a durable `hosted_credit_events` outbox. The server retries delivery to the private Customer Service endpoint. Both the outbox event ID and Customer-Service ledger reference are idempotent, so retries cannot double-credit the customer.
|
||||
|
||||
For Docker/Compose the Game server therefore needs:
|
||||
|
||||
```env
|
||||
CUSTOMER_SERVICE_INTERNAL_URL=http://customer-service:8092
|
||||
```
|
||||
|
||||
For a direct local `go run` setup use `http://127.0.0.1:8092` instead.
|
||||
|
||||
## Customer dashboard
|
||||
|
||||
When PayPal is disabled or not fully configured, **Credits kaufen** is not rendered as an available purchase area and package requests are skipped. The credit history is intentionally compact: entries are grouped by UTC day and booking reason, while the raw immutable-style `credit_ledger` remains unchanged in SQLite for audit/idempotency.
|
||||
|
||||
## Private Customer Admin
|
||||
|
||||
The private `:8091` Customer Admin now supports:
|
||||
|
||||
- block / unblock a Customer account;
|
||||
- stop one Worker or all Workers of a Customer;
|
||||
- disable/enable new Customer logins globally;
|
||||
- disable/enable new registrations independently;
|
||||
- configure registration Proof-of-Work difficulty (`0..24` bits);
|
||||
- optionally require a one-shot registration invite;
|
||||
- create time-limited one-shot invite codes;
|
||||
- configure signup and positive-tip credit rewards.
|
||||
|
||||
Blocking an account invalidates all Customer sessions immediately and revokes/stops its Worker leases. Docker stop is attempted immediately. Even if that Docker operation fails, the Worker cannot renew its lease and its existing lease-failure shutdown path terminates it.
|
||||
|
||||
The global **login kill switch** intentionally blocks **new logins**; it does not mass-log-out already authenticated customers. Use individual account blocking when an existing session must be invalidated. Registration has a separate switch.
|
||||
|
||||
## Anti-bot / account farming
|
||||
|
||||
Open registration can require a stateless, username-bound SHA-256 Proof-of-Work. The challenge is HMAC-authenticated by Customer Service, short lived and cannot be moved to another username. This adds a measurable cost to bulk account creation, especially when signup credits are enabled.
|
||||
|
||||
Proof-of-Work is friction, not proof that one account equals one human. For controlled launches or valuable signup credits, enable **invite required** as the stronger control. Each `nhinvite_...` code is one-shot, has an expiry and is stored only as a hash. Continue to rate-limit `/api/register/challenge`, `/api/register` and `/api/login` at Traefik/Caddy as an independent layer.
|
||||
|
||||
Suggested starting point for an open beta:
|
||||
|
||||
```env
|
||||
CS_CUSTOMER_LOGIN_ENABLED=true
|
||||
CS_CUSTOMER_REGISTRATION_ENABLED=true
|
||||
CS_REGISTRATION_POW_BITS=16
|
||||
CS_REGISTRATION_INVITE_REQUIRED=false
|
||||
CS_NEW_CUSTOMER_CREDITS=0
|
||||
CS_POSITIVE_TIP_CREDITS=0
|
||||
```
|
||||
|
||||
If signup credits become meaningful, increase PoW gradually and/or switch to invite-only registration before raising the free balance.
|
||||
@@ -132,6 +132,12 @@ func validate(cfg customer.Config) error {
|
||||
if cfg.WorkerImage == "" {
|
||||
return fmt.Errorf("CS_WORKER_IMAGE is required")
|
||||
}
|
||||
if cfg.RegistrationPOWBits < 0 || cfg.RegistrationPOWBits > 24 {
|
||||
return fmt.Errorf("CS_REGISTRATION_POW_BITS must be between 0 and 24")
|
||||
}
|
||||
if cfg.NewCustomerCreditsMicros < 0 || cfg.PositiveTipCreditsMicros < 0 {
|
||||
return fmt.Errorf("CS_NEW_CUSTOMER_CREDITS and CS_POSITIVE_TIP_CREDITS must be >= 0")
|
||||
}
|
||||
if _, err := customer.RegistryAuthHeader(cfg.WorkerRegistryUsername, cfg.WorkerRegistryPassword, cfg.WorkerRegistryServer); err != nil {
|
||||
return fmt.Errorf("worker registry auth: %w", err)
|
||||
}
|
||||
@@ -158,6 +164,7 @@ func main() {
|
||||
PublicBaseURL: strings.TrimRight(env("CS_PUBLIC_BASE_URL", ""), "/"), GamePublicURL: env("CS_GAME_PUBLIC_URL", "http://127.0.0.1:8080"), GameAdminURL: env("CS_GAME_ADMIN_URL", "http://127.0.0.1:8081"), SharedSecret: env("CUSTOMER_SERVICE_SHARED_SECRET", ""),
|
||||
DockerHost: env("DOCKER_HOST", "unix:///var/run/docker.sock"), WorkerImage: env("CS_WORKER_IMAGE", "neuralhunt-worker:local"), WorkerEntrypoint: env("CS_WORKER_ENTRYPOINT", ""), WorkerNetwork: env("CS_WORKER_NETWORK", "neuralhunt_backend"), WorkerRegisterURL: env("CS_WORKER_REGISTER_URL", "http://customer-service:8092/internal/workers/register"), WorkerAutoPull: boolEnv("CS_WORKER_AUTO_PULL", true), WorkerRegistryUsername: env("CS_WORKER_REGISTRY_USERNAME", ""), WorkerRegistryPassword: env("CS_WORKER_REGISTRY_PASSWORD", ""), WorkerRegistryServer: env("CS_WORKER_REGISTRY_SERVER", ""), WorkerRateMicrosPerMinute: int64(rate*1_000_000 + 0.5), MaxWorkersPerCustomer: intEnv("CS_MAX_WORKERS_PER_CUSTOMER", 20), MaxWorkersGlobal: intEnv("CS_MAX_WORKERS_GLOBAL", 1000), MaxRunningPerCustomer: intEnv("CS_MAX_RUNNING_WORKERS_PER_CUSTOMER", 10), MaxRunningGlobal: intEnv("CS_MAX_RUNNING_WORKERS_GLOBAL", 100),
|
||||
SessionTTL: durationEnv("CS_SESSION_TTL", 24*time.Hour), CookieSecure: boolEnv("CS_COOKIE_SECURE", true), AdminCookieSecureMode: env("CS_ADMIN_COOKIE_SECURE", "auto"), AdminUser: env("CS_ADMIN_USER", "admin"), AdminPassword: env("CS_ADMIN_PASSWORD", ""), AllowManualCredits: boolEnv("CS_ALLOW_MANUAL_CREDITS", false),
|
||||
LoginEnabled: boolEnv("CS_CUSTOMER_LOGIN_ENABLED", true), RegistrationEnabled: boolEnv("CS_CUSTOMER_REGISTRATION_ENABLED", true), RegistrationPOWBits: intEnv("CS_REGISTRATION_POW_BITS", 16), RegistrationInviteRequired: boolEnv("CS_REGISTRATION_INVITE_REQUIRED", false), NewCustomerCreditsMicros: int64(floatEnv("CS_NEW_CUSTOMER_CREDITS", 0)*1_000_000 + 0.5), PositiveTipCreditsMicros: int64(floatEnv("CS_POSITIVE_TIP_CREDITS", 0)*1_000_000 + 0.5),
|
||||
PayPalEnabled: boolEnv("PAYPAL_ENABLED", false), PayPalEnvironment: env("PAYPAL_ENVIRONMENT", "sandbox"), PayPalWebhookID: env("PAYPAL_WEBHOOK_ID", ""), PayPalLiveApprovalAck: env("PAYPAL_LIVE_APPROVAL_ACK", ""), Packages: pkgs,
|
||||
}
|
||||
if err := validate(cfg); err != nil {
|
||||
@@ -180,6 +187,9 @@ func main() {
|
||||
}
|
||||
pp := customer.NewPayPalClient(env("PAYPAL_CLIENT_ID", ""), env("PAYPAL_CLIENT_SECRET", ""), cfg.PayPalEnvironment)
|
||||
svc := customer.NewService(st, dc, pp, cfg)
|
||||
if err := svc.SeedPortalSettings(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
if err := svc.CheckGameControlPlane(checkCtx); err != nil {
|
||||
log.Printf("WARNING: Hosted-Code/Reward-Control-Plane nicht bereit: %v", err)
|
||||
|
||||
@@ -10,6 +10,9 @@ services:
|
||||
environment:
|
||||
SQLITE_PATH: /data/neuralhunt.db
|
||||
ARTIFACT_DIR: /data/artifacts
|
||||
# Durable positive-tip credit outbox is delivered to the Customer Service
|
||||
# only when the hosted profile is running.
|
||||
CUSTOMER_SERVICE_INTERNAL_URL: http://customer-service:8092
|
||||
ports:
|
||||
- "8080:8080"
|
||||
# Main-game admin/control plane. Route this only through VPN/private proxy.
|
||||
|
||||
328
internal/customer/portal_controls.go
Normal file
328
internal/customer/portal_controls.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package customer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
settingLoginEnabled = "customer_login_enabled"
|
||||
settingRegistrationEnabled = "customer_registration_enabled"
|
||||
settingRegistrationPOWBits = "customer_registration_pow_bits"
|
||||
settingInviteRequired = "customer_registration_invite_required"
|
||||
settingSignupBonusMicros = "customer_signup_bonus_micros"
|
||||
settingPositiveTipBonusMicro = "customer_positive_tip_bonus_micros"
|
||||
)
|
||||
|
||||
type PortalSettings struct {
|
||||
LoginEnabled bool `json:"login_enabled"`
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
RegistrationPOWBits int `json:"registration_pow_bits"`
|
||||
InviteRequired bool `json:"invite_required"`
|
||||
SignupBonusMicros int64 `json:"signup_bonus_micros"`
|
||||
PositiveTipBonusMicros int64 `json:"positive_tip_bonus_micros"`
|
||||
ActiveRegistrationInvites int `json:"active_registration_invites"`
|
||||
}
|
||||
|
||||
func boolSetting(v string, fallback bool) bool {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
func intSetting(v string, fallback int) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
func int64Setting(v string, fallback int64) int64 {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SeedPortalSettings copies environment defaults into SQLite only once. After
|
||||
// that the private admin UI owns the live values across restarts.
|
||||
func (s *Service) SeedPortalSettings(ctx context.Context) error {
|
||||
defaults := map[string]string{
|
||||
settingLoginEnabled: strconv.FormatBool(s.cfg.LoginEnabled),
|
||||
settingRegistrationEnabled: strconv.FormatBool(s.cfg.RegistrationEnabled),
|
||||
settingRegistrationPOWBits: strconv.Itoa(s.cfg.RegistrationPOWBits),
|
||||
settingInviteRequired: strconv.FormatBool(s.cfg.RegistrationInviteRequired),
|
||||
settingSignupBonusMicros: strconv.FormatInt(s.cfg.NewCustomerCreditsMicros, 10),
|
||||
settingPositiveTipBonusMicro: strconv.FormatInt(s.cfg.PositiveTipCreditsMicros, 10),
|
||||
}
|
||||
for k, v := range defaults {
|
||||
if err := s.store.SeedSetting(ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) portalSettings(ctx context.Context) PortalSettings {
|
||||
pow := intSetting(s.store.Setting(ctx, settingRegistrationPOWBits, strconv.Itoa(s.cfg.RegistrationPOWBits)), s.cfg.RegistrationPOWBits)
|
||||
if pow < 0 {
|
||||
pow = 0
|
||||
}
|
||||
if pow > 24 {
|
||||
pow = 24
|
||||
}
|
||||
signup := int64Setting(s.store.Setting(ctx, settingSignupBonusMicros, strconv.FormatInt(s.cfg.NewCustomerCreditsMicros, 10)), s.cfg.NewCustomerCreditsMicros)
|
||||
positive := int64Setting(s.store.Setting(ctx, settingPositiveTipBonusMicro, strconv.FormatInt(s.cfg.PositiveTipCreditsMicros, 10)), s.cfg.PositiveTipCreditsMicros)
|
||||
if signup < 0 {
|
||||
signup = 0
|
||||
}
|
||||
if positive < 0 {
|
||||
positive = 0
|
||||
}
|
||||
return PortalSettings{
|
||||
LoginEnabled: boolSetting(s.store.Setting(ctx, settingLoginEnabled, strconv.FormatBool(s.cfg.LoginEnabled)), s.cfg.LoginEnabled),
|
||||
RegistrationEnabled: boolSetting(s.store.Setting(ctx, settingRegistrationEnabled, strconv.FormatBool(s.cfg.RegistrationEnabled)), s.cfg.RegistrationEnabled),
|
||||
RegistrationPOWBits: pow,
|
||||
InviteRequired: boolSetting(s.store.Setting(ctx, settingInviteRequired, strconv.FormatBool(s.cfg.RegistrationInviteRequired)), s.cfg.RegistrationInviteRequired),
|
||||
SignupBonusMicros: signup,
|
||||
PositiveTipBonusMicros: positive,
|
||||
ActiveRegistrationInvites: s.store.ActiveInviteCount(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) registrationConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.portalSettings(r.Context())
|
||||
jsonOut(w, http.StatusOK, map[string]any{
|
||||
"login_enabled": cfg.LoginEnabled,
|
||||
"registration_enabled": cfg.RegistrationEnabled,
|
||||
"pow_bits": cfg.RegistrationPOWBits,
|
||||
"invite_required": cfg.InviteRequired,
|
||||
"signup_bonus_micros": cfg.SignupBonusMicros,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) registrationChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.portalSettings(r.Context())
|
||||
if !cfg.RegistrationEnabled {
|
||||
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "registration is currently disabled"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if decode(r, &in) != nil || len(strings.TrimSpace(in.Username)) < 3 || len(strings.TrimSpace(in.Username)) > 80 {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "valid username required"})
|
||||
return
|
||||
}
|
||||
if cfg.RegistrationPOWBits <= 0 {
|
||||
jsonOut(w, http.StatusOK, map[string]any{"pow_bits": 0, "challenge": ""})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusOK, map[string]any{
|
||||
"pow_bits": cfg.RegistrationPOWBits,
|
||||
"challenge": RegistrationChallenge(s.cfg.SharedSecret, in.Username, 300),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) adminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, http.StatusOK, s.portalSettings(r.Context()))
|
||||
}
|
||||
|
||||
func (s *Service) adminUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
LoginEnabled bool `json:"login_enabled"`
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
RegistrationPOWBits int `json:"registration_pow_bits"`
|
||||
InviteRequired bool `json:"invite_required"`
|
||||
SignupBonusCredits float64 `json:"signup_bonus_credits"`
|
||||
PositiveTipBonusCredit float64 `json:"positive_tip_bonus_credits"`
|
||||
}
|
||||
if decode(r, &in) != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "bad json"})
|
||||
return
|
||||
}
|
||||
if in.RegistrationPOWBits < 0 || in.RegistrationPOWBits > 24 {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "registration_pow_bits must be 0..24"})
|
||||
return
|
||||
}
|
||||
if in.SignupBonusCredits < 0 || in.SignupBonusCredits > 1_000_000 || in.PositiveTipBonusCredit < 0 || in.PositiveTipBonusCredit > 1_000_000 {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "credit rewards must be between 0 and 1000000"})
|
||||
return
|
||||
}
|
||||
vals := map[string]string{
|
||||
settingLoginEnabled: strconv.FormatBool(in.LoginEnabled),
|
||||
settingRegistrationEnabled: strconv.FormatBool(in.RegistrationEnabled),
|
||||
settingRegistrationPOWBits: strconv.Itoa(in.RegistrationPOWBits),
|
||||
settingInviteRequired: strconv.FormatBool(in.InviteRequired),
|
||||
settingSignupBonusMicros: strconv.FormatInt(int64(in.SignupBonusCredits*1_000_000+0.5), 10),
|
||||
settingPositiveTipBonusMicro: strconv.FormatInt(int64(in.PositiveTipBonusCredit*1_000_000+0.5), 10),
|
||||
}
|
||||
for k, v := range vals {
|
||||
if err := s.store.SetSetting(r.Context(), k, v); err != nil {
|
||||
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
jsonOut(w, http.StatusOK, s.portalSettings(r.Context()))
|
||||
}
|
||||
|
||||
func (s *Service) stopAllCustomerWorkers(ctx context.Context, cid string) []string {
|
||||
workers, err := s.store.Workers(ctx, cid)
|
||||
if err != nil {
|
||||
return []string{err.Error()}
|
||||
}
|
||||
var warnings []string
|
||||
for _, wk := range workers {
|
||||
if wk.Status == "stopped" && wk.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
if err := s.stopWorkerInternal(ctx, wk, "stopped"); err != nil {
|
||||
warnings = append(warnings, wk.ID+": "+err.Error())
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
func (s *Service) adminBlockCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
cid := chi.URLParam(r, "id")
|
||||
var in struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = decode(r, &in)
|
||||
if _, err := s.store.CustomerByID(r.Context(), cid); err != nil {
|
||||
jsonOut(w, http.StatusNotFound, map[string]string{"error": "customer not found"})
|
||||
return
|
||||
}
|
||||
if err := s.store.SetCustomerBlocked(r.Context(), cid, true, in.Reason); err != nil {
|
||||
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.store.DeleteCustomerSessions(r.Context(), cid)
|
||||
warnings := s.stopAllCustomerWorkers(r.Context(), cid)
|
||||
jsonOut(w, http.StatusOK, map[string]any{"ok": true, "warnings": warnings})
|
||||
}
|
||||
|
||||
func (s *Service) adminUnblockCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
cid := chi.URLParam(r, "id")
|
||||
if err := s.store.SetCustomerBlocked(r.Context(), cid, false, ""); err != nil {
|
||||
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Service) adminStopCustomerWorkers(w http.ResponseWriter, r *http.Request) {
|
||||
cid := chi.URLParam(r, "id")
|
||||
if _, err := s.store.CustomerByID(r.Context(), cid); err != nil {
|
||||
jsonOut(w, http.StatusNotFound, map[string]string{"error": "customer not found"})
|
||||
return
|
||||
}
|
||||
warnings := s.stopAllCustomerWorkers(r.Context(), cid)
|
||||
jsonOut(w, http.StatusOK, map[string]any{"ok": len(warnings) == 0, "warnings": warnings})
|
||||
}
|
||||
|
||||
func (s *Service) adminStopAnyWorker(w http.ResponseWriter, r *http.Request) {
|
||||
wk, err := s.store.WorkerByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusNotFound, map[string]string{"error": "worker not found"})
|
||||
return
|
||||
}
|
||||
if err := s.stopWorkerInternal(r.Context(), wk, "stopped"); err != nil {
|
||||
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Service) adminCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Label string `json:"label"`
|
||||
Hours int `json:"hours"`
|
||||
}
|
||||
if decode(r, &in) != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "bad json"})
|
||||
return
|
||||
}
|
||||
if in.Hours <= 0 {
|
||||
in.Hours = 24
|
||||
}
|
||||
if in.Hours > 24*30 {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "invite lifetime must be <= 720 hours"})
|
||||
return
|
||||
}
|
||||
code := "nhinvite_" + RandomToken(24)
|
||||
expires := time.Now().UTC().Add(time.Duration(in.Hours) * time.Hour)
|
||||
if err := s.store.CreateRegistrationInvite(r.Context(), HashInviteCode(code), in.Label, expires); err != nil {
|
||||
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusCreated, map[string]any{"invite_code": code, "expires_at": expires})
|
||||
}
|
||||
|
||||
func (s *Service) authenticateGameInternal(r *http.Request) bool {
|
||||
provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
want := strings.TrimSpace(s.cfg.SharedSecret)
|
||||
return want != "" && len(provided) == len(want) && subtle.ConstantTimeCompare([]byte(provided), []byte(want)) == 1
|
||||
}
|
||||
|
||||
// internalGamePositiveTip is called only by the private game control-plane
|
||||
// dispatcher. The amount is deliberately chosen here, not trusted from the
|
||||
// game payload, and the event id is unique in the immutable credit ledger.
|
||||
func (s *Service) internalGamePositiveTip(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authenticateGameInternal(r) {
|
||||
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
EventID string `json:"event_id"`
|
||||
WorkerClientID string `json:"worker_client_id"`
|
||||
RewardClientID string `json:"reward_client_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Seq int64 `json:"seq"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
if decode(r, &in) != nil || strings.TrimSpace(in.EventID) == "" || strings.TrimSpace(in.WorkerClientID) == "" {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "invalid positive-tip event"})
|
||||
return
|
||||
}
|
||||
var cid string
|
||||
if wk, err := s.store.WorkerByClientID(r.Context(), in.WorkerClientID); err == nil {
|
||||
cid = wk.CustomerID
|
||||
} else if strings.TrimSpace(in.RewardClientID) != "" {
|
||||
if c, err := s.store.CustomerByRewardClientID(r.Context(), in.RewardClientID); err == nil {
|
||||
cid = c.ID
|
||||
}
|
||||
}
|
||||
if cid == "" {
|
||||
jsonOut(w, http.StatusNotFound, map[string]string{"error": "hosted worker/customer not found"})
|
||||
return
|
||||
}
|
||||
customer, err := s.store.CustomerByID(r.Context(), cid)
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusNotFound, map[string]string{"error": "customer not found"})
|
||||
return
|
||||
}
|
||||
if customer.Blocked {
|
||||
jsonOut(w, http.StatusOK, map[string]any{"ok": true, "credited": false, "blocked": true, "credits_micros": 0})
|
||||
return
|
||||
}
|
||||
amount := s.portalSettings(r.Context()).PositiveTipBonusMicros
|
||||
if amount <= 0 {
|
||||
jsonOut(w, http.StatusOK, map[string]any{"ok": true, "credited": false, "credits_micros": 0})
|
||||
return
|
||||
}
|
||||
added, err := s.store.AddLedgerOnce(r.Context(), cid, amount, "positive_tip", "positive_tip:"+in.EventID)
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusOK, map[string]any{"ok": true, "credited": added, "credits_micros": amount})
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var rawURL = base64.RawURLEncoding
|
||||
@@ -69,3 +71,63 @@ func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
|
||||
}
|
||||
return out[:keyLen]
|
||||
}
|
||||
|
||||
// RegistrationChallenge is a stateless, username-bound proof-of-work challenge.
|
||||
// It prevents a solved challenge from being reused to create multiple different
|
||||
// accounts while avoiding an unbounded server-side challenge store.
|
||||
func RegistrationChallenge(secret, username string, ttlSeconds int) string {
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = 300
|
||||
}
|
||||
nonce := RandomToken(18)
|
||||
expires := time.Now().UTC().Add(time.Duration(ttlSeconds) * time.Second).Unix()
|
||||
body := strings.ToLower(strings.TrimSpace(username)) + "|" + nonce + "|" + strconv.FormatInt(expires, 10)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(body))
|
||||
return nonce + "." + strconv.FormatInt(expires, 10) + "." + rawURL.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func VerifyRegistrationChallenge(secret, username, challenge string) bool {
|
||||
parts := strings.Split(challenge, ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
expires, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || expires < time.Now().UTC().Unix() || expires > time.Now().UTC().Add(10*time.Minute).Unix() {
|
||||
return false
|
||||
}
|
||||
body := strings.ToLower(strings.TrimSpace(username)) + "|" + parts[0] + "|" + parts[1]
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(body))
|
||||
want := mac.Sum(nil)
|
||||
got, err := rawURL.DecodeString(parts[2])
|
||||
return err == nil && len(got) == len(want) && subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
func VerifyRegistrationProof(username, challenge string, counter uint64, bits int) bool {
|
||||
if bits <= 0 {
|
||||
return true
|
||||
}
|
||||
if bits > 28 {
|
||||
return false
|
||||
}
|
||||
msg := "nh-register|" + strings.ToLower(strings.TrimSpace(username)) + "|" + challenge + "|" + strconv.FormatUint(counter, 10)
|
||||
h := sha256.Sum256([]byte(msg))
|
||||
full := bits / 8
|
||||
rem := bits % 8
|
||||
for i := 0; i < full; i++ {
|
||||
if h[i] != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if rem > 0 {
|
||||
mask := byte(0xff << (8 - rem))
|
||||
return h[full]&mask == 0
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func HashInviteCode(code string) string {
|
||||
h := sha256.Sum256([]byte(strings.TrimSpace(code)))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
42
internal/customer/security_registration_test.go
Normal file
42
internal/customer/security_registration_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package customer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRegistrationChallengeAndProof(t *testing.T) {
|
||||
secret := "0123456789abcdef0123456789abcdef"
|
||||
user := "Alice"
|
||||
challenge := RegistrationChallenge(secret, user, 60)
|
||||
if !VerifyRegistrationChallenge(secret, user, challenge) {
|
||||
t.Fatal("valid challenge rejected")
|
||||
}
|
||||
if VerifyRegistrationChallenge(secret, "Bob", challenge) {
|
||||
t.Fatal("challenge must be username-bound")
|
||||
}
|
||||
const bits = 12
|
||||
var counter uint64
|
||||
for ; counter < 1<<20; counter++ {
|
||||
if VerifyRegistrationProof(user, challenge, counter, bits) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if counter == 1<<20 {
|
||||
t.Fatal("could not find proof")
|
||||
}
|
||||
if !VerifyRegistrationProof(user, challenge, counter, bits) {
|
||||
t.Fatal("valid proof rejected")
|
||||
}
|
||||
if VerifyRegistrationProof("Bob", challenge, counter, bits) {
|
||||
t.Fatal("proof must be username-bound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHashIsStableAndDoesNotExposeCode(t *testing.T) {
|
||||
code := "nhinvite_example-secret"
|
||||
h := HashInviteCode(code)
|
||||
if h == "" || h == code {
|
||||
t.Fatalf("unexpected invite hash %q", h)
|
||||
}
|
||||
if h != HashInviteCode(code) {
|
||||
t.Fatal("invite hash not stable")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,12 @@ type Config struct {
|
||||
AdminCookieSecureMode string
|
||||
AdminUser, AdminPassword string
|
||||
AllowManualCredits bool
|
||||
LoginEnabled bool
|
||||
RegistrationEnabled bool
|
||||
RegistrationPOWBits int
|
||||
RegistrationInviteRequired bool
|
||||
NewCustomerCreditsMicros int64
|
||||
PositiveTipCreditsMicros int64
|
||||
PayPalEnabled bool
|
||||
PayPalEnvironment string
|
||||
PayPalWebhookID string
|
||||
@@ -210,6 +216,8 @@ func (s *Service) PublicRoutes(ui http.Handler) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(s.security)
|
||||
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
|
||||
r.Get("/api/registration/config", s.registrationConfig)
|
||||
r.Post("/api/register/challenge", s.registrationChallenge)
|
||||
r.Post("/api/register", s.register)
|
||||
r.Post("/api/login", s.login)
|
||||
r.Post("/api/logout", s.logout)
|
||||
@@ -243,7 +251,14 @@ func (s *Service) AdminRoutes(ui http.Handler) http.Handler {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireAdmin)
|
||||
r.Get("/api/admin/overview", s.adminOverview)
|
||||
r.Get("/api/admin/settings", s.adminSettings)
|
||||
r.Put("/api/admin/settings", s.adminUpdateSettings)
|
||||
r.Post("/api/admin/credits/grant", s.adminCreditGrant)
|
||||
r.Post("/api/admin/customers/{id}/block", s.adminBlockCustomer)
|
||||
r.Post("/api/admin/customers/{id}/unblock", s.adminUnblockCustomer)
|
||||
r.Post("/api/admin/customers/{id}/workers/stop", s.adminStopCustomerWorkers)
|
||||
r.Post("/api/admin/workers/{id}/stop", s.adminStopAnyWorker)
|
||||
r.Post("/api/admin/invites", s.adminCreateInvite)
|
||||
})
|
||||
r.Mount("/", ui)
|
||||
return r
|
||||
@@ -253,12 +268,24 @@ func (s *Service) InternalRoutes() http.Handler {
|
||||
r.Use(s.security)
|
||||
r.Post("/internal/workers/register", s.internalWorkerRegister)
|
||||
r.Post("/internal/workers/lease", s.internalWorkerLease)
|
||||
r.Post("/internal/game/positive-tip", s.internalGamePositiveTip)
|
||||
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Service) register(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Username, Password string }
|
||||
cfg := s.portalSettings(r.Context())
|
||||
if !cfg.RegistrationEnabled {
|
||||
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "Registrierungen sind derzeit deaktiviert."})
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password"`
|
||||
Challenge string `json:"Challenge"`
|
||||
ProofCounter uint64 `json:"ProofCounter"`
|
||||
InviteCode string `json:"InviteCode"`
|
||||
}
|
||||
if decode(r, &in) != nil {
|
||||
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
||||
return
|
||||
@@ -268,13 +295,31 @@ func (s *Service) register(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 400, map[string]string{"error": "username must be 3..80 characters"})
|
||||
return
|
||||
}
|
||||
if cfg.RegistrationPOWBits > 0 {
|
||||
if !VerifyRegistrationChallenge(s.cfg.SharedSecret, in.Username, in.Challenge) || !VerifyRegistrationProof(in.Username, in.Challenge, in.ProofCounter, cfg.RegistrationPOWBits) {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "Registrierungs-Proof-of-Work ist ungültig oder abgelaufen."})
|
||||
return
|
||||
}
|
||||
}
|
||||
inviteHash := ""
|
||||
if cfg.InviteRequired {
|
||||
if strings.TrimSpace(in.InviteCode) == "" {
|
||||
jsonOut(w, http.StatusForbidden, map[string]string{"error": "Für die Registrierung ist ein einmaliger Invite-Code erforderlich."})
|
||||
return
|
||||
}
|
||||
inviteHash = HashInviteCode(in.InviteCode)
|
||||
}
|
||||
salt, hash, err := NewPasswordHash(in.Password)
|
||||
if err != nil {
|
||||
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cid := "cust_" + RandomToken(16)
|
||||
if err := s.store.CreateCustomer(r.Context(), cid, in.Username, salt, hash); err != nil {
|
||||
if err := s.store.CreateCustomer(r.Context(), cid, in.Username, salt, hash, cfg.SignupBonusMicros, inviteHash); err != nil {
|
||||
if errors.Is(err, ErrInvalidInvite) {
|
||||
jsonOut(w, http.StatusForbidden, map[string]string{"error": "Invite-Code ist ungültig, abgelaufen oder bereits verwendet."})
|
||||
return
|
||||
}
|
||||
jsonOut(w, 409, map[string]string{"error": "username already exists"})
|
||||
return
|
||||
}
|
||||
@@ -284,9 +329,14 @@ func (s *Service) register(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.setCookie(w, r, customerCookie, sid, s.cfg.SessionTTL)
|
||||
jsonOut(w, 201, map[string]string{"id": cid, "username": in.Username})
|
||||
jsonOut(w, 201, map[string]any{"id": cid, "username": in.Username, "signup_bonus_micros": cfg.SignupBonusMicros})
|
||||
}
|
||||
|
||||
func (s *Service) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.portalSettings(r.Context()).LoginEnabled {
|
||||
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "Anmeldung ist momentan administrativ deaktiviert."})
|
||||
return
|
||||
}
|
||||
var in struct{ Username, Password string }
|
||||
if decode(r, &in) != nil {
|
||||
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
||||
@@ -298,6 +348,10 @@ func (s *Service) login(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if c.Blocked {
|
||||
jsonOut(w, http.StatusForbidden, map[string]string{"error": "Dieses Kundenkonto ist gesperrt."})
|
||||
return
|
||||
}
|
||||
sid := RandomToken(32)
|
||||
if err := s.store.CreateSession(r.Context(), sid, c.ID, s.cfg.SessionTTL); err != nil {
|
||||
jsonOut(w, 500, map[string]string{"error": "session failed"})
|
||||
@@ -324,7 +378,7 @@ func (s *Service) me(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, map[string]any{"customer": c, "balance_micros": bal, "worker_rate_micros_per_minute": s.cfg.WorkerRateMicrosPerMinute, "paypal_enabled": s.paypalAllowed()})
|
||||
}
|
||||
func (s *Service) ledger(w http.ResponseWriter, r *http.Request) {
|
||||
x, err := s.store.Ledger(r.Context(), customerID(r), 100)
|
||||
x, err := s.store.LedgerSummary(r.Context(), customerID(r), 60)
|
||||
if err != nil {
|
||||
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -689,12 +743,18 @@ func (s *Service) stopWorker(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
func (s *Service) stopWorkerInternal(ctx context.Context, wk Worker, status string) error {
|
||||
// Revoke the lease in SQLite first. Even if Docker itself is temporarily
|
||||
// unavailable, the worker's next lease renewal will fail and its agent will
|
||||
// self-terminate instead of continuing with a blocked/stopped account.
|
||||
if err := s.store.SetWorkerRuntime(ctx, wk.ID, status, wk.ContainerID, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if wk.ContainerID != "" {
|
||||
if err := s.docker.Stop(ctx, wk.ContainerID, 5); err != nil && !strings.Contains(err.Error(), "304") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.store.SetWorkerRuntime(ctx, wk.ID, status, wk.ContainerID, "")
|
||||
return nil
|
||||
}
|
||||
func (s *Service) deleteWorker(w http.ResponseWriter, r *http.Request) {
|
||||
cid, wid := customerID(r), chi.URLParam(r, "id")
|
||||
@@ -798,6 +858,10 @@ func (s *Service) internalWorkerLease(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 409, map[string]string{"error": "worker lease revoked"})
|
||||
return
|
||||
}
|
||||
if cust, err := s.store.CustomerByID(r.Context(), wk.CustomerID); err != nil || cust.Blocked {
|
||||
jsonOut(w, 409, map[string]string{"error": "worker lease revoked"})
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]any{"ok": true, "lease_sec": 45})
|
||||
}
|
||||
|
||||
@@ -988,6 +1052,11 @@ func (s *Service) billOnce(ctx context.Context) {
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, wk := range workers {
|
||||
if cust, err := s.store.CustomerByID(ctx, wk.CustomerID); err == nil && cust.Blocked {
|
||||
_ = s.stopWorkerInternal(ctx, wk, "stopped")
|
||||
log.Printf("billing worker %s stopped: customer account blocked", wk.ID)
|
||||
continue
|
||||
}
|
||||
if wk.ContainerID == "" {
|
||||
continue
|
||||
}
|
||||
@@ -1020,7 +1089,12 @@ func (s *Service) billOnce(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (s *Service) billingPackages(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, map[string]any{"paypal_enabled": s.paypalAllowed(), "environment": s.cfg.PayPalEnvironment, "packages": s.cfg.Packages})
|
||||
enabled := s.paypalAllowed()
|
||||
packages := s.cfg.Packages
|
||||
if !enabled {
|
||||
packages = nil
|
||||
}
|
||||
jsonOut(w, 200, map[string]any{"paypal_enabled": enabled, "environment": s.cfg.PayPalEnvironment, "packages": packages})
|
||||
}
|
||||
func (s *Service) paypalAllowed() bool {
|
||||
if !s.cfg.PayPalEnabled || s.paypal == nil || !s.paypal.Ready() {
|
||||
@@ -1274,7 +1348,7 @@ func (s *Service) adminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.username,c.reward_client_id,c.created_at,COALESCE((SELECT sum(delta_micros) FROM credit_ledger l WHERE l.customer_id=c.id),0),(SELECT count(*) FROM workers w WHERE w.customer_id=c.id),(SELECT count(*) FROM workers w WHERE w.customer_id=c.id AND w.status='running') FROM customers c ORDER BY c.created_at DESC LIMIT 500`)
|
||||
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.username,c.reward_client_id,c.blocked,c.blocked_reason,c.blocked_at,c.created_at,COALESCE((SELECT sum(delta_micros) FROM credit_ledger l WHERE l.customer_id=c.id),0) FROM customers c ORDER BY c.created_at DESC LIMIT 500`)
|
||||
if err != nil {
|
||||
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -1282,16 +1356,39 @@ func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
defer rows.Close()
|
||||
var out []map[string]any
|
||||
for rows.Next() {
|
||||
var id, user, reward string
|
||||
var id, user, reward, blockedReason string
|
||||
var blocked int
|
||||
var blockedAt sql.NullInt64
|
||||
var created, bal int64
|
||||
var workers, running int
|
||||
if err := rows.Scan(&id, &user, &reward, &created, &bal, &workers, &running); err != nil {
|
||||
if err := rows.Scan(&id, &user, &reward, &blocked, &blockedReason, &blockedAt, &created, &bal); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{"id": id, "username": user, "reward_client_id": reward, "created_at": time.UnixMilli(created).UTC(), "balance_micros": bal, "workers": workers, "running": running})
|
||||
workers, _ := s.store.Workers(r.Context(), id)
|
||||
running := 0
|
||||
for _, wk := range workers {
|
||||
if wk.Status == "running" {
|
||||
running++
|
||||
}
|
||||
}
|
||||
item := map[string]any{
|
||||
"id": id, "username": user, "reward_client_id": reward,
|
||||
"blocked": blocked != 0, "blocked_reason": blockedReason,
|
||||
"created_at": time.UnixMilli(created).UTC(), "balance_micros": bal,
|
||||
"workers": len(workers), "running": running, "worker_items": workers,
|
||||
}
|
||||
if blockedAt.Valid {
|
||||
item["blocked_at"] = time.UnixMilli(blockedAt.Int64).UTC()
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
jsonOut(w, 200, map[string]any{"manual_credits_enabled": s.cfg.AllowManualCredits, "customers": out})
|
||||
jsonOut(w, 200, map[string]any{
|
||||
"manual_credits_enabled": s.cfg.AllowManualCredits,
|
||||
"paypal_enabled": s.paypalAllowed(),
|
||||
"settings": s.portalSettings(r.Context()),
|
||||
"customers": out,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) adminCreditGrant(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.AllowManualCredits {
|
||||
jsonOut(w, 403, map[string]string{"error": "manual credit bypass disabled; set CS_ALLOW_MANUAL_CREDITS=1 on the private admin service"})
|
||||
|
||||
@@ -22,6 +22,9 @@ CREATE TABLE IF NOT EXISTS customers(
|
||||
password_salt TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
reward_client_id TEXT NOT NULL DEFAULT '',
|
||||
blocked INTEGER NOT NULL DEFAULT 0,
|
||||
blocked_reason TEXT NOT NULL DEFAULT '',
|
||||
blocked_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -71,6 +74,20 @@ CREATE TABLE IF NOT EXISTS paypal_orders(
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS paypal_orders_customer_idx ON paypal_orders(customer_id,created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS customer_settings(
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS registration_invites(
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_by TEXT REFERENCES customers(id) ON DELETE SET NULL,
|
||||
used_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS registration_invites_exp_idx ON registration_invites(expires_at,used_at);
|
||||
`
|
||||
|
||||
type Store struct{ DB *sql.DB }
|
||||
@@ -116,14 +133,51 @@ func Open(ctx context.Context, path string) (*Store, error) {
|
||||
return nil, fmt.Errorf("customer schema %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
// Existing V4 customer databases need explicit column evolution because
|
||||
// CREATE TABLE IF NOT EXISTS does not alter an already-created table.
|
||||
for _, m := range []struct{ name, def string }{
|
||||
{"blocked", "INTEGER NOT NULL DEFAULT 0"},
|
||||
{"blocked_reason", "TEXT NOT NULL DEFAULT ''"},
|
||||
{"blocked_at", "INTEGER"},
|
||||
} {
|
||||
if err := ensureCustomerColumn(ctx, db, "customers", m.name, m.def); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("customer add customers.%s: %w", m.name, err)
|
||||
}
|
||||
}
|
||||
return &Store{DB: db}, nil
|
||||
}
|
||||
|
||||
func ensureCustomerColumn(ctx context.Context, db *sql.DB, table, name, def string) error {
|
||||
rows, err := db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var col, typ string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &col, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(col, name) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
_, err = db.ExecContext(ctx, `ALTER TABLE `+table+` ADD COLUMN `+name+` `+def)
|
||||
return err
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
RewardClientID string `json:"reward_client_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
RewardClientID string `json:"reward_client_id"`
|
||||
Blocked bool `json:"blocked"`
|
||||
BlockedReason string `json:"blocked_reason,omitempty"`
|
||||
BlockedAt *time.Time `json:"blocked_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
@@ -143,31 +197,87 @@ type Worker struct {
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateCustomer(ctx context.Context, id, username, salt, hash string) error {
|
||||
var ErrInvalidInvite = errors.New("registration invite invalid, expired or already used")
|
||||
|
||||
func scanCustomer(row interface{ Scan(...any) error }, withPassword bool) (Customer, string, string, error) {
|
||||
var c Customer
|
||||
var blocked int
|
||||
var blockedAt sql.NullInt64
|
||||
var created int64
|
||||
var salt, hash string
|
||||
args := []any{&c.ID, &c.Username, &c.RewardClientID, &blocked, &c.BlockedReason, &blockedAt, &created}
|
||||
if withPassword {
|
||||
args = append(args, &salt, &hash)
|
||||
}
|
||||
err := row.Scan(args...)
|
||||
c.Blocked = blocked != 0
|
||||
c.CreatedAt = time.UnixMilli(created).UTC()
|
||||
if blockedAt.Valid {
|
||||
v := time.UnixMilli(blockedAt.Int64).UTC()
|
||||
c.BlockedAt = &v
|
||||
}
|
||||
return c, salt, hash, err
|
||||
}
|
||||
|
||||
// CreateCustomer atomically creates the account, consumes an optional one-shot
|
||||
// invite and grants the configured signup credits. A failed invite therefore
|
||||
// cannot leave behind a partially-created account or free ledger entry.
|
||||
func (s *Store) CreateCustomer(ctx context.Context, id, username, salt, hash string, signupBonusMicros int64, inviteHash string) error {
|
||||
tx, err := s.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO customers(id,username,password_salt,password_hash,created_at,updated_at) VALUES(?,?,?,?,?,?)`, id, strings.TrimSpace(username), salt, hash, now, now)
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO customers(id,username,password_salt,password_hash,created_at,updated_at) VALUES(?,?,?,?,?,?)`, id, strings.TrimSpace(username), salt, hash, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(inviteHash) != "" {
|
||||
res, err := tx.ExecContext(ctx, `UPDATE registration_invites SET used_by=?,used_at=? WHERE token_hash=? AND used_by IS NULL AND expires_at>?`, id, now, inviteHash, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n != 1 {
|
||||
return ErrInvalidInvite
|
||||
}
|
||||
}
|
||||
if signupBonusMicros > 0 {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO credit_ledger(customer_id,delta_micros,reason,reference,created_at) VALUES(?,?,?,?,?)`, id, signupBonusMicros, "signup_bonus", "signup:"+id, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) CustomerByUsername(ctx context.Context, username string) (Customer, string, string, error) {
|
||||
var c Customer
|
||||
var salt, hash string
|
||||
var created int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,created_at,password_salt,password_hash FROM customers WHERE username=?`, strings.TrimSpace(username)).Scan(&c.ID, &c.Username, &c.RewardClientID, &created, &salt, &hash)
|
||||
c.CreatedAt = time.UnixMilli(created).UTC()
|
||||
return c, salt, hash, err
|
||||
return scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,created_at,password_salt,password_hash FROM customers WHERE username=?`, strings.TrimSpace(username)), true)
|
||||
}
|
||||
func (s *Store) CustomerByID(ctx context.Context, id string) (Customer, error) {
|
||||
var c Customer
|
||||
var created int64
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,created_at FROM customers WHERE id=?`, id).Scan(&c.ID, &c.Username, &c.RewardClientID, &created)
|
||||
c.CreatedAt = time.UnixMilli(created).UTC()
|
||||
c, _, _, err := scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,created_at FROM customers WHERE id=?`, id), false)
|
||||
return c, err
|
||||
}
|
||||
func (s *Store) CustomerByRewardClientID(ctx context.Context, rewardClientID string) (Customer, error) {
|
||||
c, _, _, err := scanCustomer(s.DB.QueryRowContext(ctx, `SELECT id,username,reward_client_id,blocked,blocked_reason,blocked_at,created_at FROM customers WHERE reward_client_id=? ORDER BY created_at LIMIT 1`, strings.TrimSpace(rewardClientID)), false)
|
||||
return c, err
|
||||
}
|
||||
func (s *Store) SetRewardClientID(ctx context.Context, id, cid string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE customers SET reward_client_id=?,updated_at=? WHERE id=?`, strings.TrimSpace(cid), time.Now().UTC().UnixMilli(), id)
|
||||
return err
|
||||
}
|
||||
func (s *Store) SetCustomerBlocked(ctx context.Context, id string, blocked bool, reason string) error {
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
if blocked {
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE customers SET blocked=1,blocked_reason=?,blocked_at=?,updated_at=? WHERE id=?`, strings.TrimSpace(reason), now, now, id)
|
||||
return err
|
||||
}
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE customers SET blocked=0,blocked_reason='',blocked_at=NULL,updated_at=? WHERE id=?`, now, id)
|
||||
return err
|
||||
}
|
||||
func (s *Store) DeleteCustomerSessions(ctx context.Context, cid string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `DELETE FROM customer_sessions WHERE customer_id=?`, cid)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, sid, cid string, ttl time.Duration) error {
|
||||
now := time.Now().UTC()
|
||||
@@ -176,7 +286,7 @@ func (s *Store) CreateSession(ctx context.Context, sid, cid string, ttl time.Dur
|
||||
}
|
||||
func (s *Store) SessionCustomer(ctx context.Context, sid string) (string, error) {
|
||||
var cid string
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT customer_id FROM customer_sessions WHERE id=? AND expires_at>?`, sid, time.Now().UTC().UnixMilli()).Scan(&cid)
|
||||
err := s.DB.QueryRowContext(ctx, `SELECT s.customer_id FROM customer_sessions s JOIN customers c ON c.id=s.customer_id WHERE s.id=? AND s.expires_at>? AND c.blocked=0`, sid, time.Now().UTC().UnixMilli()).Scan(&cid)
|
||||
return cid, err
|
||||
}
|
||||
func (s *Store) DeleteSession(ctx context.Context, sid string) {
|
||||
@@ -228,6 +338,84 @@ func (s *Store) Ledger(ctx context.Context, cid string, limit int) ([]LedgerItem
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type LedgerSummaryItem struct {
|
||||
DeltaMicros int64 `json:"delta_micros"`
|
||||
Reason string `json:"reason"`
|
||||
Count int `json:"count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Day string `json:"day"`
|
||||
}
|
||||
|
||||
// LedgerSummary intentionally collapses high-frequency worker-minute and
|
||||
// positive-tip entries into daily reason buckets. The raw ledger stays intact
|
||||
// in SQLite for auditing and idempotency.
|
||||
func (s *Store) LedgerSummary(ctx context.Context, cid string, limit int) ([]LedgerSummaryItem, error) {
|
||||
if limit < 1 || limit > 120 {
|
||||
limit = 45
|
||||
}
|
||||
rows, err := s.DB.QueryContext(ctx, `SELECT reason,COALESCE(sum(delta_micros),0),count(*),max(created_at),strftime('%Y-%m-%d',max(created_at)/1000,'unixepoch')
|
||||
FROM credit_ledger WHERE customer_id=?
|
||||
GROUP BY reason,strftime('%Y-%m-%d',created_at/1000,'unixepoch')
|
||||
ORDER BY max(created_at) DESC LIMIT ?`, cid, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []LedgerSummaryItem
|
||||
for rows.Next() {
|
||||
var x LedgerSummaryItem
|
||||
var ms int64
|
||||
if err := rows.Scan(&x.Reason, &x.DeltaMicros, &x.Count, &ms, &x.Day); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.CreatedAt = time.UnixMilli(ms).UTC()
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) AddLedgerOnce(ctx context.Context, cid string, delta int64, reason, ref string) (bool, error) {
|
||||
if delta == 0 {
|
||||
return false, nil
|
||||
}
|
||||
res, err := s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO credit_ledger(customer_id,delta_micros,reason,reference,created_at) VALUES(?,?,?,?,?)`, cid, delta, reason, ref, time.Now().UTC().UnixMilli())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func (s *Store) SeedSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO customer_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
func (s *Store) Setting(ctx context.Context, key, fallback string) string {
|
||||
var v string
|
||||
if err := s.DB.QueryRowContext(ctx, `SELECT value FROM customer_settings WHERE key=?`, key).Scan(&v); err != nil {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO customer_settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, key, value, time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateRegistrationInvite(ctx context.Context, tokenHash, label string, expiresAt time.Time) error {
|
||||
_, err := s.DB.ExecContext(ctx, `INSERT INTO registration_invites(token_hash,label,expires_at,created_at) VALUES(?,?,?,?)`, tokenHash, strings.TrimSpace(label), expiresAt.UTC().UnixMilli(), time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
func (s *Store) ActiveInviteCount(ctx context.Context) int {
|
||||
var n int
|
||||
_ = s.DB.QueryRowContext(ctx, `SELECT count(*) FROM registration_invites WHERE used_by IS NULL AND expires_at>?`, time.Now().UTC().UnixMilli()).Scan(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) WorkerByClientID(ctx context.Context, clientID string) (Worker, error) {
|
||||
return scanWorker(s.DB.QueryRowContext(ctx, `SELECT `+workerCols+` FROM workers WHERE worker_client_id=? ORDER BY created_at DESC LIMIT 1`, strings.TrimSpace(clientID)))
|
||||
}
|
||||
|
||||
func scanWorker(row interface{ Scan(...any) error }) (Worker, error) {
|
||||
var w Worker
|
||||
var last sql.NullInt64
|
||||
|
||||
14
internal/customerui/dist/admin/app.js
vendored
14
internal/customerui/dist/admin/app.js
vendored
@@ -1 +1,13 @@
|
||||
const $=id=>document.getElementById(id);let manual=false;function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}function msg(t,e=false){$('msg').textContent=t;$('msg').className='msg'+(e?' err':'');$('msg').classList.remove('hidden')}async function api(p,o={}){if(o.body&&typeof o.body!=='string'){o.headers={'Content-Type':'application/json',...(o.headers||{})};o.body=JSON.stringify(o.body)}const r=await fetch(p,{credentials:'same-origin',...o});const x=await r.json().catch(()=>({}));if(!r.ok)throw new Error(x.error||`HTTP ${r.status}`);return x}const cr=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});async function load(){const x=await api('/api/admin/overview');manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER TEST-CREDIT-BYPASS IST AKTIV. Nur auf dem privaten Listener verwenden.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';$('customers').innerHTML=(x.customers||[]).map(c=>`<div class="customer" data-id="${esc(c.id)}"><div><strong>${esc(c.username)}</strong><div class="small">${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}</div></div><div>${cr(c.balance_micros)} Credits</div><div>${c.running}/${c.workers} Worker aktiv</div>${manual?'<div class="grant"><input class="amount" type="number" min="0.001" step="1" placeholder="Credits"><button class="grantBtn">+ TEST</button></div>':'<div></div>'}</div>`).join('');document.querySelectorAll('.grantBtn').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const n=Number(row.querySelector('.amount').value);if(!n)return;try{await api('/api/admin/credits/grant',{method:'POST',body:{customer_id:row.dataset.id,credits:n,reason:'admin-ui'}});msg(`${n} Test-Credits gebucht`);await load()}catch(e){msg(e.message,true)}})}$('login').onclick=async()=>{try{await api('/api/admin/login',{method:'POST',body:{Username:$('user').value,Password:$('pass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/admin/logout',{method:'POST'}).catch(()=>{});location.reload()};load().catch(()=>{});
|
||||
const $=id=>document.getElementById(id);let manual=false,last=null;
|
||||
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function msg(t,e=false){$('msg').textContent=t;$('msg').className='msg'+(e?' err':'');$('msg').classList.remove('hidden');setTimeout(()=>$('msg').classList.add('hidden'),7000)}
|
||||
async function api(p,o={}){if(o.body&&typeof o.body!=='string'){o.headers={'Content-Type':'application/json',...(o.headers||{})};o.body=JSON.stringify(o.body)}const r=await fetch(p,{credentials:'same-origin',...o});const x=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(x.error||`HTTP ${r.status}`);e.status=r.status;throw e}return x}
|
||||
const cr=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});
|
||||
function settingsToUI(s){$('loginEnabled').checked=!!s.login_enabled;$('registrationEnabled').checked=!!s.registration_enabled;$('inviteRequired').checked=!!s.invite_required;$('powBits').value=s.registration_pow_bits??0;$('signupBonus').value=Number(s.signup_bonus_micros||0)/1e6;$('positiveBonus').value=Number(s.positive_tip_bonus_micros||0)/1e6;$('inviteCount').textContent=s.active_registration_invites??0}
|
||||
function settingsPayload(){return{login_enabled:$('loginEnabled').checked,registration_enabled:$('registrationEnabled').checked,invite_required:$('inviteRequired').checked,registration_pow_bits:Number($('powBits').value||0),signup_bonus_credits:Number($('signupBonus').value||0),positive_tip_bonus_credits:Number($('positiveBonus').value||0)}}
|
||||
async function saveSettings(){try{const x=await api('/api/admin/settings',{method:'PUT',body:settingsPayload()});settingsToUI(x);msg('Portal-Einstellungen gespeichert')}catch(e){msg(e.message,true)}}
|
||||
function workerRows(c){if(!c.worker_items?.length)return'<div class="small">Keine Worker.</div>';return `<div class="worker-list">${c.worker_items.map(w=>`<div class="worker-row"><div><strong>${esc(w.id)}</strong><div class="small">${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)}</div></div><span class="pill ${w.status==='running'?'on':''}">${esc(w.status)}</span><button class="ghost stopWorker" data-wid="${esc(w.id)}" ${w.status==='stopped'?'disabled':''}>STOP</button></div>`).join('')}</div>`}
|
||||
function customerHTML(c){const status=c.blocked?`<span class="pill danger">GESPERRT</span>`:'<span class="pill on">AKTIV</span>';return `<article class="customer ${c.blocked?'blocked':''}" data-id="${esc(c.id)}"><div class="customer-head"><div><h3>${esc(c.username)} ${status}</h3><div class="small">${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}</div>${c.blocked_reason?`<div class="small danger-text">Grund: ${esc(c.blocked_reason)}</div>`:''}</div><div class="customer-stats"><strong>${cr(c.balance_micros)} Credits</strong><span>${c.running}/${c.workers} Worker aktiv</span></div></div><div class="actions">${c.blocked?'<button class="unblock">FREIGEBEN</button>':'<button class="danger block">BENUTZER SPERREN</button>'}<button class="ghost stopAll" ${c.running?'' :'disabled'}>ALLE WORKER STOPPEN</button>${manual?'<input class="amount" type="number" min="0.001" step="1" placeholder="Credits"><button class="grantBtn">+ CREDIT</button>':''}</div>${workerRows(c)}</article>`}
|
||||
function bindCustomerActions(){document.querySelectorAll('.block').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const reason=prompt('Optionaler Sperrgrund:','');if(reason===null)return;if(!confirm('Benutzer sperren, Sessions invalidieren und alle Worker stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/block`,{method:'POST',body:{reason}});msg(x.warnings?.length?`Benutzer gesperrt · Warnungen: ${x.warnings.join(' · ')}`:'Benutzer gesperrt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.unblock').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/unblock`,{method:'POST'});msg('Benutzer freigegeben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopAll').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');if(!confirm('Alle Worker dieses Benutzers stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers/stop`,{method:'POST'});msg(x.warnings?.length?x.warnings.join(' · '):'Worker gestoppt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopWorker').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/workers/${encodeURIComponent(b.dataset.wid)}/stop`,{method:'POST'});msg('Worker gestoppt');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.grantBtn').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const n=Number(row.querySelector('.amount').value);if(!n)return;try{await api('/api/admin/credits/grant',{method:'POST',body:{customer_id:row.dataset.id,credits:n,reason:'admin-ui'}});msg(`${n} Credits gebucht`);await load()}catch(e){msg(e.message,true)}})}
|
||||
async function load(){const x=await api('/api/admin/overview');last=x;manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER CREDIT-BYPASS IST AKTIV. Jede Buchung bleibt im Ledger nachvollziehbar.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';settingsToUI(x.settings||{});$('customers').innerHTML=(x.customers||[]).map(customerHTML).join('')||'<div class="notice">Noch keine Benutzer.</div>';bindCustomerActions()}
|
||||
$('saveSettings').onclick=saveSettings;$('saveRewards').onclick=saveSettings;$('createInvite').onclick=async()=>{try{const x=await api('/api/admin/invites',{method:'POST',body:{label:$('inviteLabel').value,hours:Number($('inviteHours').value||24)}});$('inviteOut').textContent=x.invite_code;$('inviteOut').classList.remove('hidden');msg('Invite erzeugt · jetzt sicher an genau einen Benutzer weitergeben');await load()}catch(e){msg(e.message,true)}};$('refresh').onclick=()=>load().catch(e=>msg(e.message,true));$('login').onclick=async()=>{try{await api('/api/admin/login',{method:'POST',body:{Username:$('user').value,Password:$('pass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/admin/logout',{method:'POST'}).catch(()=>{});location.reload()};load().catch(()=>{});
|
||||
|
||||
13
internal/customerui/dist/admin/index.html
vendored
13
internal/customerui/dist/admin/index.html
vendored
@@ -1,2 +1,13 @@
|
||||
<!doctype html><html lang="de"><head>
|
||||
<link rel="icon" href="data:,"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Admin</title><link rel="stylesheet" href="/styles.css"></head><body><main><div class="eyebrow">NEURAL HUNT · PRIVATE CONTROL PLANE</div><h1>Customer Service Admin</h1><div id="msg" class="msg hidden"></div><section id="loginBox"><label>Admin<input id="user"></label><label>Passwort<input id="pass" type="password"></label><button id="login">ANMELDEN</button></section><section id="panel" class="hidden"><div class="head"><p>Dieser Listener gehört ausschließlich hinter VPN / privates Netz.</p><button id="logout">ABMELDEN</button></div><div id="manual" class="notice"></div><div id="customers"></div></section></main><script src="/app.js" defer></script></body></html>
|
||||
<link rel="icon" href="data:,"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Admin</title><link rel="stylesheet" href="/styles.css"></head><body><main>
|
||||
<div class="eyebrow">NEURAL HUNT · PRIVATE CONTROL PLANE</div><h1>Customer Service Admin</h1><div id="msg" class="msg hidden"></div>
|
||||
<section id="loginBox"><label>Admin<input id="user" autocomplete="username"></label><label>Passwort<input id="pass" type="password" autocomplete="current-password"></label><button id="login">ANMELDEN</button></section>
|
||||
<section id="panel" class="hidden"><div class="head"><p>Dieser Listener gehört ausschließlich hinter VPN / privates Netz.</p><button id="logout">ABMELDEN</button></div>
|
||||
<div class="admin-grid">
|
||||
<article><h2>Portal-Steuerung</h2><label class="toggle"><input id="loginEnabled" type="checkbox"> Benutzer-Anmeldung erlauben</label><label class="toggle"><input id="registrationEnabled" type="checkbox"> Neue Registrierungen erlauben</label><label class="toggle"><input id="inviteRequired" type="checkbox"> Registrierung nur mit einmaligem Invite-Code</label><label>Anti-Bot Proof-of-Work · Bit<input id="powBits" type="number" min="0" max="24" step="1"></label><p class="small">0 deaktiviert PoW. Für offene Registrierung sind 16–20 Bit ein sinnvoller Start; Reverse-Proxy-Limits bleiben zusätzlich empfehlenswert.</p><button id="saveSettings">EINSTELLUNGEN SPEICHERN</button></article>
|
||||
<article><h2>Credit-Rewards</h2><label>Startguthaben pro neuem Konto · Credits<input id="signupBonus" type="number" min="0" step="0.001"></label><label>Reward pro positivem Worker-Tipp · Credits<input id="positiveBonus" type="number" min="0" step="0.001"></label><p class="small">Positiver Tipp = der signierte Tipp verbessert den persönlichen Best-Score dieses Hosted Workers. Normale Browser-/CLI-Spieler erhalten darüber keine Customer-Credits.</p><button id="saveRewards">REWARDS SPEICHERN</button></article>
|
||||
<article><h2>Invite-Codes</h2><p class="small">Optionaler Schutz gegen Account-Farming. Jeder Code ist einmalig und wird nur hier bei der Erstellung im Klartext gezeigt.</p><label>Bezeichnung<input id="inviteLabel" placeholder="z. B. Beta Tester"></label><label>Gültigkeit · Stunden<input id="inviteHours" type="number" min="1" max="720" value="24"></label><button id="createInvite">INVITE ERZEUGEN</button><div id="inviteOut" class="invite hidden"></div><div class="small">Aktive, unbenutzte Invites: <strong id="inviteCount">0</strong></div></article>
|
||||
</div>
|
||||
<div id="manual" class="notice"></div>
|
||||
<div class="section-title"><h2>Benutzer</h2><button id="refresh" class="ghost">AKTUALISIEREN</button></div><div id="customers"></div>
|
||||
</section></main><script src="/app.js" defer></script></body></html>
|
||||
|
||||
2
internal/customerui/dist/admin/styles.css
vendored
2
internal/customerui/dist/admin/styles.css
vendored
@@ -1 +1 @@
|
||||
:root{color-scheme:dark;--bg:#080b10;--card:#111821;--line:#293746;--text:#edf4fb;--muted:#8fa1b2;--accent:#54f0a6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 ui-monospace,monospace}main{max-width:1100px;margin:auto;padding:34px 20px}.eyebrow{color:var(--accent);letter-spacing:.2em}h1{font:700 38px system-ui}section,.customer{border:1px solid var(--line);background:var(--card);border-radius:12px;padding:16px;margin:12px 0}label{display:block;color:var(--muted);margin:10px 0}input{display:block;width:100%;margin-top:5px;background:#090e14;color:var(--text);border:1px solid var(--line);border-radius:8px;padding:10px}button{background:var(--accent);border:0;border-radius:8px;padding:9px 12px;font-weight:900;cursor:pointer}.hidden{display:none!important}.head,.row{display:flex;justify-content:space-between;gap:12px;align-items:center}.customer{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:10px;align-items:center}.small{font-size:12px;color:var(--muted);word-break:break-all}.grant{display:flex;gap:6px}.grant input{margin:0}.msg,.notice{padding:10px;border-radius:8px;background:#18261f;margin:10px 0}.msg.err{background:#32171d}@media(max-width:800px){.customer{grid-template-columns:1fr}.head,.row{align-items:flex-start;flex-direction:column}}
|
||||
:root{color-scheme:dark;--bg:#080b10;--card:#111821;--line:#293746;--text:#edf4fb;--muted:#8fa1b2;--accent:#54f0a6;--danger:#ff6978;--warn:#ffca56}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 ui-monospace,monospace}main{max-width:1200px;margin:auto;padding:34px 20px}.eyebrow{color:var(--accent);letter-spacing:.2em}h1{font:700 38px system-ui}h2,h3{font-family:system-ui;margin:0 0 10px}section,.customer,article{border:1px solid var(--line);background:var(--card);border-radius:12px;padding:16px;margin:12px 0}label{display:block;color:var(--muted);margin:10px 0}label.toggle{display:flex;align-items:center;gap:9px;color:var(--text)}label.toggle input{width:auto;margin:0}input{display:block;width:100%;margin-top:5px;background:#090e14;color:var(--text);border:1px solid var(--line);border-radius:8px;padding:10px}button{background:var(--accent);color:#06120c;border:0;border-radius:8px;padding:9px 12px;font-weight:900;cursor:pointer}button:disabled{opacity:.4;cursor:not-allowed}.ghost{background:#1a2530;color:var(--text);border:1px solid var(--line)}button.danger{background:#4b1d24;color:#ffd7dc;border:1px solid #79343f}.hidden{display:none!important}.head,.section-title,.customer-head,.actions,.worker-row{display:flex;justify-content:space-between;gap:12px;align-items:center}.admin-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.admin-grid article{margin:0}.small{font-size:12px;color:var(--muted);word-break:break-all}.notice,.msg,.invite{padding:10px;border-radius:8px;background:#18261f;margin:10px 0}.msg.err{background:#32171d}.invite{border:1px solid var(--accent);font-weight:800;word-break:break-all;user-select:all}.customer{margin:12px 0}.customer.blocked{border-color:#6b333b}.customer h3{display:flex;align-items:center;gap:8px}.customer-stats{display:flex;flex-direction:column;align-items:flex-end;color:var(--muted)}.actions{justify-content:flex-start;flex-wrap:wrap;margin:14px 0}.actions input{width:130px;margin:0}.worker-list{display:grid;gap:6px;border-top:1px solid var(--line);padding-top:10px}.worker-row{background:#0b1118;border-radius:8px;padding:8px 10px}.pill{font-size:11px;padding:3px 7px;border-radius:99px;background:#222d38;color:var(--muted)}.pill.on{background:#123022;color:var(--accent)}.pill.danger{background:#3c171d;color:#ff9ba6}.danger-text{color:#ff9ba6}@media(max-width:900px){.admin-grid{grid-template-columns:1fr}.customer-head,.head,.section-title{align-items:flex-start;flex-direction:column}.customer-stats{align-items:flex-start}.worker-row{align-items:flex-start;flex-wrap:wrap}}
|
||||
|
||||
22
internal/customerui/dist/public/app.js
vendored
22
internal/customerui/dist/public/app.js
vendored
@@ -1,17 +1,23 @@
|
||||
const $=id=>document.getElementById(id);let state={me:null,tasks:[],workers:[]};
|
||||
function msg(t,err=false){const e=$('msg');e.textContent=t;e.className='msg'+(err?' err':'');e.classList.remove('hidden');setTimeout(()=>e.classList.add('hidden'),5000)}
|
||||
const $=id=>document.getElementById(id);let state={me:null,tasks:[],workers:[],registration:null};
|
||||
function msg(t,err=false){const e=$('msg');e.textContent=t;e.className='msg'+(err?' err':'');e.classList.remove('hidden');setTimeout(()=>e.classList.add('hidden'),6500)}
|
||||
async function api(path,opt={}){const o={credentials:'same-origin',...opt};if(o.body&&typeof o.body!=='string'){o.headers={...(o.headers||{}),'Content-Type':'application/json'};o.body=JSON.stringify(o.body)}const r=await fetch(path,o);const ct=r.headers.get('content-type')||'';const b=ct.includes('json')?await r.json():await r.text();if(!r.ok){const e=new Error(b?.error||b||`HTTP ${r.status}`);e.status=r.status;e.path=path;throw e}return b}
|
||||
const credits=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});
|
||||
function showAuth(){state.me=null;$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden')}
|
||||
function showPortal(me){$('auth').classList.add('hidden');$('portal').classList.remove('hidden');$('logout').classList.remove('hidden');$('balance').textContent=credits(me.balance_micros);$('rate').textContent=credits(me.worker_rate_micros_per_minute);$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt'}
|
||||
function showAuth(){state.me=null;$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden');loadRegistrationConfig().catch(()=>{})}
|
||||
function showPortal(me){$('auth').classList.add('hidden');$('portal').classList.remove('hidden');$('logout').classList.remove('hidden');$('balance').textContent=credits(me.balance_micros);$('rate').textContent=credits(me.worker_rate_micros_per_minute);$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt';$('paypalSection').classList.toggle('hidden',!me.paypal_enabled);$('topGrid').classList.toggle('single',!me.paypal_enabled)}
|
||||
async function loadRegistrationConfig(){const c=await api('/api/registration/config');state.registration=c;$('login').disabled=!c.login_enabled;$('loginNote').textContent=c.login_enabled?'':'Anmeldungen sind derzeit administrativ deaktiviert.';$('register').disabled=!c.registration_enabled;$('regInviteRow').classList.toggle('hidden',!c.invite_required);const bonus=credits(c.signup_bonus_micros);let note=c.registration_enabled?(c.signup_bonus_micros>0?`Neukonten erhalten ${bonus} Start-Credits.`:'Registrierung ist geöffnet.'):'Registrierungen sind derzeit deaktiviert.';if(c.registration_enabled&&c.pow_bits>0)note+=` Anti-Bot Proof-of-Work: ${c.pow_bits} Bit.`;if(c.invite_required)note+=' Ein einmaliger Invite-Code ist erforderlich.';$('registrationNote').textContent=note}
|
||||
async function boot(){try{await load()}catch(e){if(e.status===401)showAuth();else{showAuth();msg(`Portal konnte nicht geladen werden: ${e.message}`,true)}}const q=new URLSearchParams(location.search);if(q.get('paypal')==='return'&&q.get('token')){try{await api('/api/billing/paypal/capture',{method:'POST',body:{order_id:q.get('token')}});history.replaceState({},'',location.pathname);msg('PayPal-Zahlung verbucht');await load()}catch(e){msg(e.message,true)}}}
|
||||
async function load(){const me=await api('/api/me');state.me=me;showPortal(me);const reqs=[['tasks','/api/tasks'],['workers','/api/workers'],['ledger','/api/ledger'],['packages','/api/billing/packages']];const rs=await Promise.allSettled(reqs.map(([,path])=>api(path)));const errors=[];let tasks=[],workers=[],ledger=[],packages={paypal_enabled:false,packages:[]};rs.forEach((r,i)=>{const [name,path]=reqs[i];if(r.status==='fulfilled'){if(name==='tasks')tasks=r.value;if(name==='workers')workers=r.value;if(name==='ledger')ledger=r.value;if(name==='packages')packages=r.value}else{errors.push(`${path}: ${r.reason?.message||'Fehler'}`)}});state.tasks=Array.isArray(tasks)?tasks:[];state.workers=Array.isArray(workers)?workers:[];$('workerCount').textContent=state.workers.length;$('runningCount').textContent=`${state.workers.filter(x=>x.status==='running').length} aktiv`;renderTasks();renderWorkers();renderLedger(Array.isArray(ledger)?ledger:[]);renderPackages(packages||{paypal_enabled:false,packages:[]});if(errors.length)msg(`Session ist aktiv, aber Teile des Portals sind nicht erreichbar: ${errors.join(' · ')}`,true)}
|
||||
async function load(){const me=await api('/api/me');state.me=me;showPortal(me);const reqs=[['tasks','/api/tasks'],['workers','/api/workers'],['ledger','/api/ledger']];if(me.paypal_enabled)reqs.push(['packages','/api/billing/packages']);const rs=await Promise.allSettled(reqs.map(([,path])=>api(path)));const errors=[];let tasks=[],workers=[],ledger=[],packages={paypal_enabled:false,packages:[]};rs.forEach((r,i)=>{const [name,path]=reqs[i];if(r.status==='fulfilled'){if(name==='tasks')tasks=r.value;if(name==='workers')workers=r.value;if(name==='ledger')ledger=r.value;if(name==='packages')packages=r.value}else{errors.push(`${path}: ${r.reason?.message||'Fehler'}`)}});state.tasks=Array.isArray(tasks)?tasks:[];state.workers=Array.isArray(workers)?workers:[];$('workerCount').textContent=state.workers.length;$('runningCount').textContent=`${state.workers.filter(x=>x.status==='running').length} aktiv`;renderTasks();renderWorkers();renderLedger(Array.isArray(ledger)?ledger:[]);renderPackages(packages||{paypal_enabled:false,packages:[]});if(errors.length)msg(`Session ist aktiv, aber Teile des Portals sind nicht erreichbar: ${errors.join(' · ')}`,true)}
|
||||
function taskName(t){return t.display_name||`Task ${String(t.id).slice(-8)}`}
|
||||
function renderTasks(){const html=state.tasks.map(t=>`<option value="${esc(t.id)}">${esc(taskName(t))} · ${t.range_bits} bit${t.paused?' · PAUSED':''}</option>`).join('');$('newTask').innerHTML=html}
|
||||
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||
function renderWorkers(){const host=$('workers');if(!state.workers.length){host.innerHTML='<article class="muted">Noch keine Worker angelegt.</article>';return}host.innerHTML=state.workers.map(w=>{const opts=state.tasks.map(t=>`<option value="${esc(t.id)}" ${t.id===w.task_id?'selected':''}>${esc(taskName(t))}</option>`).join('');return `<article class="worker" data-id="${esc(w.id)}"><span class="status ${w.status==='error'?'error':''}">${esc(w.status)}</span><h2>${esc(w.id)}</h2><div class="meta">Worker-ID: ${esc(w.worker_client_id||'wird beim ersten Start erzeugt')}</div><label>Task<select class="task">${opts}</select></label><label>Beacon<select class="path"><option value="auto" ${w.beacon_path==='auto'?'selected':''}>AUTO</option><option value="pulse" ${w.beacon_path==='pulse'?'selected':''}>PULSE</option><option value="flux" ${w.beacon_path==='flux'?'selected':''}>FLUX</option><option value="orbit" ${w.beacon_path==='orbit'?'selected':''}>ORBIT</option></select></label><div class="meta">${w.last_error?`Fehler: ${esc(w.last_error)}`:`Rate: ${credits(w.rate_micros_per_minute)} Credits/min`}</div><div class="buttons"><button data-act="save" class="ghost">ZUORDNUNG</button>${w.status==='running'?'<button data-act="stop" class="ghost">STOP</button>':'<button data-act="start">START</button>'}<button data-act="getid" class="ghost">IDENTITY ↓</button><button data-act="putid" class="ghost">IDENTITY ↑</button><button data-act="delete" class="danger">LÖSCHEN</button><input class="idfile hidden" type="file" accept="application/json,.json"></div></article>`}).join('');host.querySelectorAll('button[data-act]').forEach(b=>b.onclick=()=>workerAction(b.closest('.worker'),b.dataset.act));host.querySelectorAll('.idfile').forEach(i=>i.onchange=()=>uploadIdentity(i.closest('.worker'),i.files?.[0]))}
|
||||
async function workerAction(card,act){const id=card.dataset.id;try{if(act==='save')await api(`/api/workers/${encodeURIComponent(id)}`,{method:'PUT',body:{TaskID:card.querySelector('.task').value,BeaconPath:card.querySelector('.path').value}});if(act==='start')await api(`/api/workers/${encodeURIComponent(id)}/start`,{method:'POST'});if(act==='stop')await api(`/api/workers/${encodeURIComponent(id)}/stop`,{method:'POST'});if(act==='delete'){if(!confirm('Worker UND seine private Identity dauerhaft löschen? Vorher Identity herunterladen, falls sie erhalten bleiben soll.'))return;await api(`/api/workers/${encodeURIComponent(id)}`,{method:'DELETE'})}if(act==='getid'){const r=await fetch(`/api/workers/${encodeURIComponent(id)}/identity`,{credentials:'same-origin'});if(!r.ok){const x=await r.json().catch(()=>({error:'Download fehlgeschlagen'}));throw new Error(x.error)}const blob=await r.blob();const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${id}-identity.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);return}if(act==='putid'){card.querySelector('.idfile').click();return}msg('Worker aktualisiert');await load()}catch(e){msg(e.message,true)}}
|
||||
async function uploadIdentity(card,file){if(!file)return;if(!confirm('Die aktuelle Worker-Identity wird ersetzt. Der Worker wird dabei gestoppt. Fortfahren?'))return;try{const r=await fetch(`/api/workers/${encodeURIComponent(card.dataset.id)}/identity`,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:await file.text()});const x=await r.json().catch(()=>({}));if(!r.ok)throw new Error(x.error||'Upload fehlgeschlagen');msg('Identity ersetzt');await load()}catch(e){msg(e.message,true)}}
|
||||
function renderLedger(xs){$('ledger').innerHTML=xs.length?xs.map(x=>`<div class="ledger-row"><span>${esc(x.reason)}</span><span>${new Date(x.created_at).toLocaleString()}</span><strong class="${x.delta_micros>=0?'credit':'debit'}">${x.delta_micros>=0?'+':''}${credits(x.delta_micros)}</strong></div>`).join(''):'<div class="muted">Noch keine Buchungen.</div>'}
|
||||
function renderPackages(v){const h=$('packages');$('paypalNote').textContent=v.paypal_enabled?`PayPal ${v.environment||''} · Credits werden erst nach serverseitig bestätigtem Capture verbucht.`:'PayPal ist derzeit deaktiviert.';h.innerHTML=(v.packages||[]).map(p=>`<div class="package"><span><strong>${credits(p.credits_micros)} Credits</strong><br><small>${(p.amount_cents/100).toFixed(2)} ${esc(p.currency)}</small></span><button data-pkg="${esc(p.id)}" ${v.paypal_enabled?'':'disabled'}>PAYPAL</button></div>`).join('');h.querySelectorAll('[data-pkg]').forEach(b=>b.onclick=async()=>{try{const x=await api('/api/billing/paypal/order',{method:'POST',body:{package_id:b.dataset.pkg}});location.href=x.approval_url}catch(e){msg(e.message,true)}})}
|
||||
$('login').onclick=async()=>{try{await api('/api/login',{method:'POST',body:{Username:$('loginUser').value,Password:$('loginPass').value}});await load()}catch(e){msg(e.message,true)}};$('register').onclick=async()=>{try{await api('/api/register',{method:'POST',body:{Username:$('regUser').value,Password:$('regPass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/logout',{method:'POST'}).catch(()=>{});location.reload()};$('saveReward').onclick=async()=>{try{const code=$('rewardLinkCode').value.trim();if(!code)throw new Error('Hosted-Code einfügen');const x=await api('/api/reward-identity',{method:'PUT',body:{link_code:code}});$('rewardLinkCode').value='';msg(x.warning||'Haupt-Identität sicher gekoppelt',!!x.warning);await load()}catch(e){msg(e.message,true)}};$('clearReward').onclick=async()=>{if(!confirm('Reward-Kopplung wirklich lösen? Laufende Worker sollten vorher gestoppt werden.'))return;try{await api('/api/reward-identity',{method:'PUT',body:{clear:true}});msg('Reward-Kopplung gelöst');await load()}catch(e){msg(e.message,true)}};$('newWorker').onclick=()=> $('newWorkerBox').classList.toggle('hidden');$('createWorker').onclick=async()=>{try{await api('/api/workers',{method:'POST',body:{TaskID:$('newTask').value,BeaconPath:$('newPath').value}});$('newWorkerBox').classList.add('hidden');await load()}catch(e){msg(e.message,true)}};boot();
|
||||
function ledgerLabel(reason){if(reason==='worker_minute')return'Worker-Laufzeit';if(reason==='positive_tip')return'Positive Tipps';if(reason==='signup_bonus')return'Startguthaben';if(reason==='paypal_topup')return'PayPal-Aufladung';if(reason.startsWith('manual_test_grant'))return'Manuelle Gutschrift';if(reason.startsWith('worker_start_refund'))return'Worker-Erstattung';return reason.replaceAll('_',' ')}
|
||||
function renderLedger(xs){$('ledger').innerHTML=xs.length?xs.map(x=>`<div class="ledger-row"><span><strong>${esc(ledgerLabel(x.reason))}</strong>${x.count>1?` <small>× ${x.count}</small>`:''}</span><span>${new Date(x.created_at).toLocaleDateString()} </span><strong class="${x.delta_micros>=0?'credit':'debit'}">${x.delta_micros>=0?'+':''}${credits(x.delta_micros)}</strong></div>`).join(''):'<div class="muted">Noch keine Buchungen.</div>'}
|
||||
function renderPackages(v){const sec=$('paypalSection');if(!v.paypal_enabled){sec.classList.add('hidden');$('packages').innerHTML='';return}sec.classList.remove('hidden');$('paypalNote').textContent=`PayPal ${v.environment||''} · Credits werden erst nach serverseitig bestätigtem Capture verbucht.`;$('packages').innerHTML=(v.packages||[]).map(p=>`<div class="package"><span><strong>${credits(p.credits_micros)} Credits</strong><br><small>${(p.amount_cents/100).toFixed(2)} ${esc(p.currency)}</small></span><button data-pkg="${esc(p.id)}">PAYPAL</button></div>`).join('');$('packages').querySelectorAll('[data-pkg]').forEach(b=>b.onclick=async()=>{try{const x=await api('/api/billing/paypal/order',{method:'POST',body:{package_id:b.dataset.pkg}});location.href=x.approval_url}catch(e){msg(e.message,true)}})}
|
||||
function leadingZeroBits(buf,bits){const a=new Uint8Array(buf);let full=Math.floor(bits/8),rem=bits%8;for(let i=0;i<full;i++)if(a[i]!==0)return false;if(rem)return (a[full]&(0xff<<(8-rem)))===0;return true}
|
||||
async function solveRegistrationPow(username,challenge,bits){if(!bits)return 0;if(!globalThis.crypto?.subtle)throw new Error('WebCrypto wird für den Anti-Bot-Proof benötigt. Bitte HTTPS/aktuellen Browser verwenden.');const enc=new TextEncoder(),batch=192;let base=0;while(true){const jobs=[];for(let i=0;i<batch;i++){const n=base+i;const msg=`nh-register|${username.trim().toLowerCase()}|${challenge}|${n}`;jobs.push(crypto.subtle.digest('SHA-256',enc.encode(msg)).then(h=>[n,h]))}const out=await Promise.all(jobs);for(const [n,h] of out)if(leadingZeroBits(h,bits))return n;base+=batch;if(base%(batch*40)===0){$('register').textContent=`ANTI-BOT · ${base.toLocaleString('de-DE')}`;await new Promise(r=>setTimeout(r,0))}}}
|
||||
$('login').onclick=async()=>{try{await api('/api/login',{method:'POST',body:{Username:$('loginUser').value,Password:$('loginPass').value}});await load()}catch(e){msg(e.message,true)}};
|
||||
$('register').onclick=async()=>{const b=$('register');try{const username=$('regUser').value,password=$('regPass').value;const cfg=state.registration||await api('/api/registration/config');if(!cfg.registration_enabled)throw new Error('Registrierungen sind derzeit deaktiviert.');b.disabled=true;b.textContent='PRÜFE…';const ch=await api('/api/register/challenge',{method:'POST',body:{username}});const counter=await solveRegistrationPow(username,ch.challenge,ch.pow_bits||0);b.textContent='REGISTRIERE…';const x=await api('/api/register',{method:'POST',body:{Username:username,Password:password,Challenge:ch.challenge||'',ProofCounter:counter,InviteCode:$('regInvite').value.trim()}});msg(x.signup_bonus_micros>0?`Konto erstellt · ${credits(x.signup_bonus_micros)} Start-Credits`:'Konto erstellt');await load()}catch(e){msg(e.message,true)}finally{b.textContent='REGISTRIEREN';b.disabled=!(state.registration?.registration_enabled??true)}};
|
||||
$('logout').onclick=async()=>{await api('/api/logout',{method:'POST'}).catch(()=>{});location.reload()};$('saveReward').onclick=async()=>{try{const code=$('rewardLinkCode').value.trim();if(!code)throw new Error('Hosted-Code einfügen');const x=await api('/api/reward-identity',{method:'PUT',body:{link_code:code}});$('rewardLinkCode').value='';msg(x.warning||'Haupt-Identität sicher gekoppelt',!!x.warning);await load()}catch(e){msg(e.message,true)}};$('clearReward').onclick=async()=>{if(!confirm('Reward-Kopplung wirklich lösen? Laufende Worker sollten vorher gestoppt werden.'))return;try{await api('/api/reward-identity',{method:'PUT',body:{clear:true}});msg('Reward-Kopplung gelöst');await load()}catch(e){msg(e.message,true)}};$('newWorker').onclick=()=> $('newWorkerBox').classList.toggle('hidden');$('createWorker').onclick=async()=>{try{await api('/api/workers',{method:'POST',body:{TaskID:$('newTask').value,BeaconPath:$('newPath').value}});$('newWorkerBox').classList.add('hidden');await load()}catch(e){msg(e.message,true)}};boot();
|
||||
|
||||
12
internal/customerui/dist/public/index.html
vendored
12
internal/customerui/dist/public/index.html
vendored
@@ -5,16 +5,16 @@
|
||||
<header><div><div class="eyebrow">NEURAL HUNT</div><h1>Customer Service</h1><p class="muted">PrePaid Worker · Task-Zuordnung · Reward Management</p></div><button id="logout" class="ghost hidden">ABMELDEN</button></header>
|
||||
<div id="msg" class="msg hidden"></div>
|
||||
<section id="auth" class="grid auth-grid">
|
||||
<article><h2>Anmelden</h2><label>Benutzername<input id="loginUser" autocomplete="username"></label><label>Passwort<input id="loginPass" type="password" autocomplete="current-password"></label><button id="login">ANMELDEN</button></article>
|
||||
<article><h2>Konto erstellen</h2><label>Benutzername<input id="regUser" autocomplete="username"></label><label>Passwort · min. 12 Zeichen<input id="regPass" type="password" autocomplete="new-password"></label><button id="register">REGISTRIEREN</button></article>
|
||||
<article id="loginCard"><h2>Anmelden</h2><p id="loginNote" class="hint"></p><label>Benutzername<input id="loginUser" autocomplete="username"></label><label>Passwort<input id="loginPass" type="password" autocomplete="current-password"></label><button id="login">ANMELDEN</button></article>
|
||||
<article id="registerCard"><h2>Konto erstellen</h2><p id="registrationNote" class="hint"></p><label>Benutzername<input id="regUser" autocomplete="username"></label><label>Passwort · min. 12 Zeichen<input id="regPass" type="password" autocomplete="new-password"></label><label id="regInviteRow" class="hidden">Invite-Code<input id="regInvite" autocomplete="off" placeholder="nhinvite_…"></label><button id="register">REGISTRIEREN</button></article>
|
||||
</section>
|
||||
<div id="portal" class="hidden">
|
||||
<section class="stats"><article><span>GUTHABEN</span><strong id="balance">—</strong><small>PrePaid Credits</small></article><article><span>KOSTEN</span><strong id="rate">—</strong><small>pro laufendem Worker / Minute</small></article><article><span>WORKER</span><strong id="workerCount">—</strong><small id="runningCount">— aktiv</small></article></section>
|
||||
<section class="grid two">
|
||||
<article><h2>Haupt-Identität / Rewards</h2><p class="muted">Gewinne aller verwalteten Worker werden deiner Haupt-Identität zugeordnet. Aus Sicherheitsgründen reicht eine öffentliche Client-ID nicht: Erzeuge im Neural-Hunt-Spiel als eingeloggte Haupt-Identität einen einmaligen <b>HOSTED CODE</b> und füge ihn hier ein.</p><div class="meta">Aktuell: <code id="rewardId">noch nicht gekoppelt</code></div><label>Einmaliger Hosted-Code<input id="rewardLinkCode" autocomplete="off" placeholder="nhlink_…"></label><div class="buttons"><button id="saveReward">IDENTITÄT KOPPELN</button><button id="clearReward" class="ghost">KOPPLUNG LÖSEN</button></div><p class="hint">Der Code gilt 10 Minuten und nur einmal. Dein privater P-256-Schlüssel bleibt im Browser/CLI und wird niemals an Customer Service übertragen. Worker behalten jeweils eine eigene kryptografische Identität und Presence.</p></article>
|
||||
<article><h2>PrePaid aufladen</h2><div id="packages" class="packages"></div><p id="paypalNote" class="hint"></p></article>
|
||||
<section class="grid two" id="topGrid">
|
||||
<article><h2>Haupt-Identität / Rewards</h2><p class="muted">Gewinne aller verwalteten Worker werden deiner Haupt-Identität zugeordnet. Erzeuge im Neural-Hunt-Spiel als eingeloggte Haupt-Identität einen einmaligen <b>HOSTED CODE</b> und füge ihn hier ein.</p><div class="meta">Aktuell: <code id="rewardId">noch nicht gekoppelt</code></div><label>Einmaliger Hosted-Code<input id="rewardLinkCode" autocomplete="off" placeholder="nhlink_…"></label><div class="buttons"><button id="saveReward">IDENTITÄT KOPPELN</button><button id="clearReward" class="ghost">KOPPLUNG LÖSEN</button></div><p class="hint">Der Code gilt 10 Minuten und nur einmal. Dein privater P-256-Schlüssel bleibt im Browser/CLI. Worker behalten jeweils eine eigene kryptografische Identität und Presence.</p></article>
|
||||
<article id="paypalSection"><h2>Credits kaufen</h2><div id="packages" class="packages"></div><p id="paypalNote" class="hint"></p></article>
|
||||
</section>
|
||||
<section><div class="section-head"><div><h2>Worker</h2><p class="muted">Jeder Worker hat eine eigene persistente Identity und kann unabhängig einem Task zugeordnet werden.</p></div><button id="newWorker">+ WORKER</button></div><div id="newWorkerBox" class="new-worker hidden"><label>Task<select id="newTask"></select></label><label>Beacon-Pfad<select id="newPath"><option value="auto">AUTO</option><option value="pulse">PULSE</option><option value="flux">FLUX</option><option value="orbit">ORBIT</option></select></label><button id="createWorker">ERSTELLEN</button></div><div id="workers" class="worker-grid"></div></section>
|
||||
<section><h2>Abrechnung</h2><p class="muted">Abgerechnet wird in bezahlten Worker-Zeitfenstern. Wenn das PrePaid-Guthaben nicht mehr für die nächste Minute reicht, stoppt der Service den Worker automatisch.</p><div id="ledger" class="ledger"></div></section>
|
||||
<section><h2>Credit-Verlauf</h2><p class="muted">Häufige Worker-Minuten und positive Tipps werden pro Tag zusammengefasst. Das vollständige Ledger bleibt serverseitig unverändert erhalten.</p><div id="ledger" class="ledger"></div></section>
|
||||
</div>
|
||||
</main><script src="/app.js" defer></script></body></html>
|
||||
|
||||
2
internal/customerui/dist/public/styles.css
vendored
2
internal/customerui/dist/public/styles.css
vendored
@@ -1 +1,3 @@
|
||||
:root{color-scheme:dark;--bg:#070a0f;--card:#101720;--line:#263443;--text:#eef5fb;--muted:#8da0b1;--accent:#54f0a6;--warn:#ffca56;--danger:#ff6978}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#112318 0,#070a0f 38%);color:var(--text);font:15px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}main{max-width:1180px;margin:auto;padding:32px 20px 80px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:28px}h1{font:700 clamp(32px,5vw,58px)/1 system-ui;margin:4px 0}h2{font:700 21px system-ui;margin:0 0 12px}.eyebrow{letter-spacing:.28em;color:var(--accent);font-weight:800}.muted,.hint,small{color:var(--muted)}article,section>article,.new-worker,.worker,.ledger-row{background:rgba(16,23,32,.88);border:1px solid var(--line);border-radius:14px;padding:18px}.grid{display:grid;gap:18px}.auth-grid,.two{grid-template-columns:repeat(2,minmax(0,1fr))}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}.stats article{display:flex;flex-direction:column}.stats span{font-size:12px;letter-spacing:.18em;color:var(--muted)}.stats strong{font:700 30px system-ui;margin:5px 0}.section-head{display:flex;justify-content:space-between;align-items:end;margin:28px 0 12px}.section-head h2{margin:0}label{display:flex;flex-direction:column;gap:6px;color:var(--muted);margin:12px 0}input,select,button{font:inherit}input,select{width:100%;background:#080d13;color:var(--text);border:1px solid #344555;border-radius:9px;padding:11px}button{background:var(--accent);color:#04110b;border:0;border-radius:9px;padding:11px 14px;font-weight:900;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}.ghost{background:#1a2530;color:var(--text);border:1px solid var(--line)}.danger{background:#3a171c;color:#ffbac2;border:1px solid #67313a}.hidden{display:none!important}.msg{position:sticky;top:12px;z-index:5;padding:12px 15px;border:1px solid var(--line);background:#14211b;border-radius:10px;margin-bottom:14px}.msg.err{background:#2c1418;color:#ffd5da}.packages{display:grid;gap:8px}.package{display:flex;align-items:center;justify-content:space-between;background:#0b1118;border:1px solid var(--line);padding:11px;border-radius:9px}.worker-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:14px}.worker{position:relative}.worker .status{display:inline-flex;padding:4px 8px;border-radius:999px;background:#15231d;color:var(--accent);font-size:12px;text-transform:uppercase}.worker .status.error{background:#30171b;color:#ff8c98}.worker .meta{font-size:12px;color:var(--muted);word-break:break-all}.buttons{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.buttons button{padding:8px 10px;font-size:12px}.new-worker{display:grid;grid-template-columns:2fr 1fr auto;gap:12px;align-items:end;margin-bottom:14px}.new-worker label{margin:0}.ledger{display:grid;gap:7px}.ledger-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;padding:10px 12px}.credit{color:var(--accent)}.debit{color:var(--warn)}section{margin-top:18px}@media(max-width:760px){.auth-grid,.two,.stats{grid-template-columns:1fr}.new-worker{grid-template-columns:1fr}.ledger-row{grid-template-columns:1fr}header{align-items:flex-start}}
|
||||
|
||||
.two.single{grid-template-columns:1fr}
|
||||
|
||||
62
internal/server/customer_credit.go
Normal file
62
internal/server/customer_credit.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"neuralhunt/internal/data"
|
||||
)
|
||||
|
||||
func (s *Server) dispatchHostedCreditEvents(ctx context.Context) {
|
||||
if strings.TrimSpace(s.customerServiceInternalURL) == "" || strings.TrimSpace(s.internalServiceSecret) == "" {
|
||||
return
|
||||
}
|
||||
events, err := s.store.PendingHostedCreditEvents(ctx, 25)
|
||||
if err != nil {
|
||||
log.Printf("hosted credit outbox: %v", err)
|
||||
return
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ev.Attempts > 0 && ev.Attempts%20 == 0 {
|
||||
log.Printf("hosted credit event %s still pending after %d attempts", ev.EventID, ev.Attempts)
|
||||
}
|
||||
if err := s.deliverHostedCreditEvent(ctx, ev); err != nil {
|
||||
_ = s.store.MarkHostedCreditEventAttempt(ctx, ev.EventID, err.Error())
|
||||
continue
|
||||
}
|
||||
_ = s.store.MarkHostedCreditEventDelivered(ctx, ev.EventID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) deliverHostedCreditEvent(ctx context.Context, ev data.HostedCreditEvent) error {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"event_id": ev.EventID,
|
||||
"worker_client_id": ev.WorkerClientID,
|
||||
"reward_client_id": ev.RewardClientID,
|
||||
"task_id": ev.TaskID,
|
||||
"seq": ev.Seq,
|
||||
"score": ev.Score,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.customerServiceInternalURL+"/internal/game/positive-tip", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.internalServiceSecret)
|
||||
resp, err := s.customerServiceHTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("customer service HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -47,6 +47,8 @@ type Server struct {
|
||||
lottery *guessLottery
|
||||
adminUser, adminPass, staticDir, artifactDir string
|
||||
internalServiceSecret string
|
||||
customerServiceInternalURL string
|
||||
customerServiceHTTP *http.Client
|
||||
upgrader websocket.Upgrader
|
||||
wsAllowedOrigins map[string]struct{}
|
||||
maxUserWS, maxLeaderboardWS int64
|
||||
@@ -65,14 +67,16 @@ func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub,
|
||||
lottery: newGuessLottery(func(d beaconDrawAudit) {
|
||||
_ = store.RecordBeaconDraw(context.Background(), d.TaskID, d.WindowEnd, d.BeaconID, d.BeaconRound, d.Randomness, d.Signature, d.BoostedPath, d.Tickets, d.Selected)
|
||||
}),
|
||||
adminUser: env("ADMIN_USER", "admin"),
|
||||
adminPass: env("ADMIN_PASSWORD", "change-me"),
|
||||
staticDir: env("STATIC_DIR", ""),
|
||||
artifactDir: artifactDir,
|
||||
internalServiceSecret: strings.TrimSpace(os.Getenv("CUSTOMER_SERVICE_SHARED_SECRET")),
|
||||
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
|
||||
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
|
||||
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
|
||||
adminUser: env("ADMIN_USER", "admin"),
|
||||
adminPass: env("ADMIN_PASSWORD", "change-me"),
|
||||
staticDir: env("STATIC_DIR", ""),
|
||||
artifactDir: artifactDir,
|
||||
internalServiceSecret: strings.TrimSpace(os.Getenv("CUSTOMER_SERVICE_SHARED_SECRET")),
|
||||
customerServiceInternalURL: strings.TrimRight(strings.TrimSpace(os.Getenv("CUSTOMER_SERVICE_INTERNAL_URL")), "/"),
|
||||
customerServiceHTTP: &http.Client{Timeout: 5 * time.Second},
|
||||
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
|
||||
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
|
||||
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
|
||||
}
|
||||
s.upgrader = websocket.Upgrader{CheckOrigin: s.checkWSOrigin, Subprotocols: []string{"neuralhunt.v1"}}
|
||||
return s
|
||||
@@ -851,6 +855,17 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.runtime.MarkSQLiteWrite()
|
||||
// A positive hosted-worker tip means a genuine personal-best improvement,
|
||||
// not merely a submitted or lottery-selected guess. Record it durably and
|
||||
// let the scheduler deliver the idempotent credit event to Customer Service.
|
||||
if accepted.Improved {
|
||||
rewardOwner := s.store.RewardOwnerForWorker(r.Context(), c.ClientID)
|
||||
if _, created, err := s.store.RecordHostedPositiveCreditEvent(r.Context(), id, c.ClientID, rewardOwner, in.Seq, accepted.State.BestScore); err != nil {
|
||||
log.Printf("hosted positive-tip outbox: %v", err)
|
||||
} else if created {
|
||||
s.runtime.MarkSQLiteWrite()
|
||||
}
|
||||
}
|
||||
p.Score = round(p.Score, s.settings.Get().PublicScorePrecision)
|
||||
s.hub.PublishPoint(id, c.ClientID, p)
|
||||
}
|
||||
@@ -1825,6 +1840,7 @@ func (s *Server) Scheduler(ctx context.Context) {
|
||||
s.runDueActions(ctx)
|
||||
maintenance++
|
||||
if maintenance%5 == 0 {
|
||||
s.dispatchHostedCreditEvents(ctx)
|
||||
if err := s.store.EnsureActiveTasks(ctx, s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits); err != nil {
|
||||
log.Printf("scheduler: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user