Customer Service Admin
Dieser Listener gehört ausschließlich hinter VPN / privates Netz.
diff --git a/.env.example b/.env.example index 4172155..29a272b 100644 --- a/.env.example +++ b/.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. diff --git a/HOSTED_SERVICE.md b/HOSTED_SERVICE.md index 4882a08..9f21e53 100644 --- a/HOSTED_SERVICE.md +++ b/HOSTED_SERVICE.md @@ -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. diff --git a/README.md b/README.md index d3859bf..bb19e4e 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/TESTING.md b/TESTING.md index 62d8147..079cddc 100644 --- a/TESTING.md +++ b/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. diff --git a/V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md b/V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md new file mode 100644 index 0000000..2892190 --- /dev/null +++ b/V4.2.6_CUSTOMER_ENGAGEMENT_ADMIN.md @@ -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. diff --git a/cmd/customer-service/main.go b/cmd/customer-service/main.go index b09bf40..85febbd 100644 --- a/cmd/customer-service/main.go +++ b/cmd/customer-service/main.go @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 936af4e..5725de4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/internal/customer/portal_controls.go b/internal/customer/portal_controls.go new file mode 100644 index 0000000..a8063ef --- /dev/null +++ b/internal/customer/portal_controls.go @@ -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}) +} diff --git a/internal/customer/security.go b/internal/customer/security.go index f1a7858..1facc9f 100644 --- a/internal/customer/security.go +++ b/internal/customer/security.go @@ -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[:]) +} diff --git a/internal/customer/security_registration_test.go b/internal/customer/security_registration_test.go new file mode 100644 index 0000000..847b0f4 --- /dev/null +++ b/internal/customer/security_registration_test.go @@ -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") + } +} diff --git a/internal/customer/server.go b/internal/customer/server.go index 28995db..6fccb3f 100644 --- a/internal/customer/server.go +++ b/internal/customer/server.go @@ -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"}) diff --git a/internal/customer/store.go b/internal/customer/store.go index 525331d..37fc028 100644 --- a/internal/customer/store.go +++ b/internal/customer/store.go @@ -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 diff --git a/internal/customerui/dist/admin/app.js b/internal/customerui/dist/admin/app.js index c73ece9..0934692 100644 --- a/internal/customerui/dist/admin/app.js +++ b/internal/customerui/dist/admin/app.js @@ -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=>`
Dieser Listener gehört ausschließlich hinter VPN / privates Netz.
Dieser Listener gehört ausschließlich hinter VPN / privates Netz.
0 deaktiviert PoW. Für offene Registrierung sind 16–20 Bit ein sinnvoller Start; Reverse-Proxy-Limits bleiben zusätzlich empfehlenswert.
Positiver Tipp = der signierte Tipp verbessert den persönlichen Best-Score dieses Hosted Workers. Normale Browser-/CLI-Spieler erhalten darüber keine Customer-Credits.
Optionaler Schutz gegen Account-Farming. Jeder Code ist einmalig und wird nur hier bei der Erstellung im Klartext gezeigt.