RC-6
All checks were successful
release-tag / release-image (push) Successful in 2m10s

This commit is contained in:
2026-08-10 19:44:17 +02:00
parent 551ed007e6
commit 185ccf1101
20 changed files with 875 additions and 124 deletions

View File

@@ -1,8 +1,26 @@
# Core
# Public listener: put this behind your normal HTTPS reverse proxy.
HTTP_ADDR=:8080
JWT_SECRET=change-me-to-a-long-random-secret
# Private control-plane listener: expose only through VPN/private reverse proxy.
ADMIN_HTTP_ADDR=:8081
# REQUIRED for normal/public operation. Generate unique secrets; startup refuses
# the known development defaults. For local throwaway development only, you may
# set ALLOW_INSECURE_DEV_DEFAULTS=1.
JWT_SECRET=replace-with-at-least-32-random-characters
ADMIN_USER=admin
ADMIN_PASSWORD=change-me
ADMIN_PASSWORD=replace-with-a-strong-unique-password
# Admin session cookies are Secure by default. Set false only for local HTTP dev.
ADMIN_COOKIE_SECURE=true
# ALLOW_INSECURE_DEV_DEFAULTS=1
# WebSocket hardening. Same-origin browser WebSockets work automatically. Add
# explicit extra origins only when your proxy topology genuinely needs them.
WS_ALLOWED_ORIGINS=
WS_MAX_USER_CONNECTIONS=5000
WS_MAX_LEADERBOARD_CONNECTIONS=500
# Legacy token-in-query fallback is disabled by default because URLs are logged.
# WS_ALLOW_QUERY_TOKEN=1
# Local data paths. Docker Compose overrides both to /data/... so the named volume remains persistent.
SQLITE_PATH=./data/neuralhunt.db
@@ -16,6 +34,14 @@ DEFAULT_GUESS_MIN_INTERVAL_SEC=10
DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC=11
DEFAULT_GUESS_LOTTERY_WINDOW_SEC=60
DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED=0
# Anti-Sybil friction for newly created browser identities. 0 disables each part.
DEFAULT_SYBIL_POW_BITS=15
DEFAULT_SYBIL_WARMUP_SEC=15
# OpenAI cost circuit breaker. Rolling windows; 0 disables the individual limit.
DEFAULT_OPENAI_MAX_CALLS_1H=20
DEFAULT_OPENAI_MAX_CALLS_24H=100
DEFAULT_OPENAI_MAX_COST_24H_USD=5.00
DEFAULT_OPENAI_BUDGET_RESERVE_USD=0.20
DEFAULT_TASK_RANGE_BITS=28
DEFAULT_ACTIVE_TASK_COUNT=1
DEFAULT_PRESENCE_TTL_SEC=35

View File

@@ -47,7 +47,7 @@ Danach:
- Client: `http://localhost:8080/`
- Echtzeit-Leaderboard: `http://localhost:8080/leaderboard`
- Admin: `http://localhost:8080/admin`
- Admin (private listener): `http://localhost:8081/admin`
## V2.5: Task-Landing-Page und Task-Serien
@@ -644,3 +644,16 @@ Im Adminbereich unter **TASK ACTIONS** kann für den ausgewählten Task eine kom
Reference images sent to the OpenAI Images Edit endpoint are uploaded with an explicit per-part MIME type (`image/png`, `image/jpeg` or `image/webp`). This is required because Go's `multipart.CreateFormFile` otherwise labels file parts as `application/octet-stream`, which the Images API rejects. The character anchor is always sent as `image/png`; task style references preserve their detected image MIME type.
## Public-hosting hardening (V3.8)
Neural Hunt now starts two HTTP listeners. `HTTP_ADDR` (default `:8080`) is the public client/leaderboard API and deliberately returns 404 for `/admin` and `/api/admin/*`. `ADMIN_HTTP_ADDR` (default `:8081`) is the private control plane. Keep port 8081 behind a VPN/private reverse-proxy route; the supplied Compose file binds it to host loopback while other containers on the same Docker network can still reach `app:8081`.
Admin authentication uses an `HttpOnly`, `SameSite=Strict` session cookie instead of browser `localStorage`. The cookie is marked `Secure` whenever TLS is visible directly or through `X-Forwarded-Proto: https`. The application also emits CSP, anti-clickjacking, HSTS and related browser hardening headers. Public browser WebSockets are same-origin checked; optional extra origins can be listed in `WS_ALLOWED_ORIGINS`. User WebSocket bearer tokens are no longer put in the URL by the bundled web/CLI clients.
New identities can be given one-time Hashcash-style proof-of-work plus a short warm-up before guesses are eligible (`sybil_pow_bits`, `sybil_warmup_sec` in Admin → Runtime). This is deliberate Sybil *friction*, not proof of a real-world person; serious adversaries can still buy compute or distribute identities. Reverse-proxy request/IP controls remain recommended.
Before every OpenAI image request the worker checks rolling 1-hour/24-hour call caps and a rolling estimated 24-hour USD budget. If the breaker trips, a winner artifact is returned to `pending` with a diagnostic message and is retried after the hold period rather than spending more immediately. Configure `openai_max_calls_1h`, `openai_max_calls_24h`, `openai_max_cost_24h_usd`, and `openai_budget_reserve_usd` in Admin → Runtime. Successful provider usage remains the basis for the local estimate.
For public operation, `JWT_SECRET` must be a unique random string of at least 32 characters and `ADMIN_PASSWORD` must be at least 16 characters; known development defaults make startup fail. `ALLOW_INSECURE_DEV_DEFAULTS=1` exists only for local throwaway development.

30
SECURITY.md Normal file
View File

@@ -0,0 +1,30 @@
# Neural Hunt security notes
## Network split
- `:8080` (`HTTP_ADDR`) is the public listener. It does not expose `/admin` or `/api/admin/*`.
- `:8081` (`ADMIN_HTTP_ADDR`) is the private control plane. Publish it only through a VPN/private reverse-proxy route.
- The supplied Compose file binds host port 8081 to `127.0.0.1`. A reverse proxy container on the same Docker network can route directly to `app:8081` instead.
## Secrets and sessions
- `OPENAI_API_KEY` is read only by the backend and must stay in runtime environment/.env. `.env` is ignored by Git and Docker build context and is deliberately not shipped in release ZIPs.
- Normal startup rejects weak/default `JWT_SECRET` and `ADMIN_PASSWORD`. `ALLOW_INSECURE_DEV_DEFAULTS=1` is only for throwaway local development.
- Admin authentication uses an HttpOnly, SameSite=Strict cookie plus a server-side session allowlist. Logout revokes the current session ID. Restarting the process also invalidates existing admin sessions.
## Public abuse controls
- New identities can require a one-time SHA-256 proof-of-work and a warm-up interval before their first eligible guess. This raises the cost of creating many identities but is not proof of a real human and cannot make Sybil attacks impossible without an external identity/attestation system.
- One active presence per cryptographic client identity remains enforced. The guess lottery continues to allow at most one outstanding ticket per client/sequence.
- WebSocket browser Origins are same-origin checked (plus optional `WS_ALLOWED_ORIGINS`), user bearer tokens are not placed in URLs by bundled clients, connection counts are globally capped, frames have read limits/deadlines, and persistently slow readers are disconnected.
- IP/request-rate/concurrency controls are intentionally left to the reverse proxy, where they can use trusted client IP information correctly.
## OpenAI circuit breaker
Before every OpenAI image request, the backend checks rolling 1-hour and 24-hour successful-call counts plus a rolling 24-hour locally estimated USD cost. A configurable safety reserve is added before the next call. If a limit is reached, automatic artifact work is returned to `pending` and retried later without making another OpenAI request. Manual anchor generation returns HTTP 429 while the breaker is active.
The USD figure is a local safety estimate based on provider-reported usage and pinned pricing data, not an OpenAI billing-system balance. Keep provider-side project budgets/limits enabled as an independent final backstop.
## Browser headers
Responses include CSP with `script-src 'self'`, `frame-ancestors 'none'`, `X-Frame-Options: DENY`, HSTS, `nosniff`, no-referrer, restricted browser permissions, and no-store caching for auth/admin resources.

View File

@@ -341,3 +341,19 @@ Relevant automated tests: `internal/data/profile_cleanup_test.go` and `internal/
4. Admin-Telemetrie: `Reject/s` steigt für nicht gezogene Tipps; `Guess/s` zählt nur tatsächlich gezogene/ausgewertete Tipps.
5. Mit `Max. gezogene Tipps je Task/Fenster=0` speichern: Tipps müssen wieder ohne Lotterie-Verzögerung normal verarbeitet werden.
6. Bei mehreren aktiven Tasks prüfen, dass jeder Task sein eigenes Kontingent erhält.
## V3.8 security/hardening checks
The production server exposes the public app on `HTTP_ADDR` (default `:8080`) and the private control plane on `ADMIN_HTTP_ADDR` (default `:8081`). Verify that the public listener returns 404 for `/admin` and `/api/admin/settings`, while the private listener serves `/admin` and requires the HttpOnly admin session cookie.
For large synthetic load tests, temporarily set `sybil_pow_bits=0` and `sybil_warmup_sec=0` from the private Admin Runtime page (or use the matching `DEFAULT_...` values on a fresh database). Otherwise the load generator intentionally pays the same new-identity proof-of-work as a real Sybil client.
Useful browser checks after HTTPS proxying:
```text
/public: Content-Security-Policy and X-Frame-Options: DENY are present
/admin: document.cookie does not expose neuralhunt_admin_session (HttpOnly)
WS: normal browser websocket connects; a foreign browser Origin is rejected
```
The OpenAI circuit breaker uses rolling windows and successful usage rows. Set very small limits in Admin Runtime to verify that a winning artifact stays `pending` with an `OpenAI cost circuit breaker active` diagnostic instead of making another provider call. Restore the desired limits afterward.

View File

@@ -117,10 +117,38 @@ func (c *apiClient) do(ctx context.Context, method, path string, body, out any)
return nil
}
func leadingZeroBitsClient(b []byte) int {
n := 0
for _, x := range b {
if x == 0 {
n += 8
continue
}
for m := byte(0x80); m != 0 && x&m == 0; m >>= 1 {
n++
}
break
}
return n
}
func solveProofClient(challenge, cid string, bits int) string {
if bits <= 0 {
return ""
}
for i := uint64(0); ; i++ {
counter := strconv.FormatUint(i, 10)
h := sha256.Sum256([]byte("nh-pow-v1|" + challenge + "|" + cid + "|" + counter))
if leadingZeroBitsClient(h[:]) >= bits {
return counter
}
}
}
func (c *apiClient) login(ctx context.Context) error {
var ch struct {
ClientID string `json:"client_id"`
Challenge string `json:"challenge"`
ClientID string `json:"client_id"`
Challenge string `json:"challenge"`
ProofOfWorkBits int `json:"proof_of_work_bits"`
}
if err := c.do(ctx, http.MethodPost, "/api/auth/challenge", map[string]any{"public_jwk": c.id.PublicJWK}, &ch); err != nil {
return fmt.Errorf("challenge: %w", err)
@@ -133,10 +161,12 @@ func (c *apiClient) login(ctx context.Context) error {
Token string `json:"token"`
ClientID string `json:"client_id"`
}
pow := solveProofClient(ch.Challenge, ch.ClientID, ch.ProofOfWorkBits)
if err := c.do(ctx, http.MethodPost, "/api/auth/login", map[string]any{
"public_jwk": c.id.PublicJWK,
"challenge": ch.Challenge,
"signature": sig,
"public_jwk": c.id.PublicJWK,
"challenge": ch.Challenge,
"signature": sig,
"proof_of_work_counter": pow,
}, &lg); err != nil {
return fmt.Errorf("login: %w", err)
}
@@ -276,10 +306,11 @@ func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn,
scheme = "wss"
}
q := url.Values{}
q.Set("token", c.token)
q.Set("max_nodes", strconv.Itoa(maxNodes))
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, wu, nil)
h := http.Header{}
h.Set("Authorization", "Bearer "+c.token)
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, wu, h)
if err != nil && resp != nil {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()

View File

@@ -83,6 +83,34 @@ func (c *apiClient) do(method, path string, body any, out any) error {
}
return nil
}
func leadingZeroBitsLoad(b []byte) int {
n := 0
for _, x := range b {
if x == 0 {
n += 8
continue
}
for m := byte(0x80); m != 0 && x&m == 0; m >>= 1 {
n++
}
break
}
return n
}
func solveProofLoad(challenge, cid string, bits int) string {
if bits <= 0 {
return ""
}
for i := uint64(0); ; i++ {
counter := fmt.Sprint(i)
h := sha256.Sum256([]byte("nh-pow-v1|" + challenge + "|" + cid + "|" + counter))
if leadingZeroBitsLoad(h[:]) >= bits {
return counter
}
}
}
func (c *apiClient) authn() error {
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
@@ -91,8 +119,9 @@ func (c *apiClient) authn() error {
c.key = k
j := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: b64.EncodeToString(pad32(k.X)), Y: b64.EncodeToString(pad32(k.Y)), Ext: true}
var ch struct {
ClientID string `json:"client_id"`
Challenge string `json:"challenge"`
ClientID string `json:"client_id"`
Challenge string `json:"challenge"`
ProofOfWorkBits int `json:"proof_of_work_bits"`
}
if err = c.do("POST", "/api/auth/challenge", map[string]any{"public_jwk": j}, &ch); err != nil {
return err
@@ -102,7 +131,8 @@ func (c *apiClient) authn() error {
var lg struct {
Token string `json:"token"`
}
if err = c.do("POST", "/api/auth/login", map[string]any{"public_jwk": j, "challenge": ch.Challenge, "signature": sig}, &lg); err != nil {
pow := solveProofLoad(ch.Challenge, c.cid, ch.ProofOfWorkBits)
if err = c.do("POST", "/api/auth/login", map[string]any{"public_jwk": j, "challenge": ch.Challenge, "signature": sig, "proof_of_work_counter": pow}, &lg); err != nil {
return err
}
c.token = lg.Token
@@ -123,10 +153,11 @@ func (c *apiClient) ws(ctx context.Context, maxNodes int) (*websocket.Conn, erro
scheme = "wss"
}
q := url.Values{}
q.Set("token", c.token)
q.Set("max_nodes", fmt.Sprint(maxNodes))
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wu, nil)
h := http.Header{}
h.Set("Authorization", "Bearer "+c.token)
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wu, h)
return conn, err
}

View File

@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"fmt"
"log"
"net/http"
"os"
@@ -61,8 +62,26 @@ func loadDotEnv(path string) {
}
}
func validateSecurityConfig() error {
if strings.EqualFold(strings.TrimSpace(os.Getenv("ALLOW_INSECURE_DEV_DEFAULTS")), "1") {
return nil
}
jwt := strings.TrimSpace(os.Getenv("JWT_SECRET"))
if len(jwt) < 32 || jwt == "dev-secret-change-me" || jwt == "change-me-to-a-long-random-secret" {
return fmt.Errorf("JWT_SECRET must be set to a unique random value of at least 32 characters (or set ALLOW_INSECURE_DEV_DEFAULTS=1 for local development only)")
}
pass := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
if len(pass) < 16 || pass == "change-me" {
return fmt.Errorf("ADMIN_PASSWORD must be set to a unique value of at least 16 characters")
}
return nil
}
func main() {
loadDotEnv(".env")
if err := validateSecurityConfig(); err != nil {
log.Fatal(err)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
@@ -94,10 +113,17 @@ func main() {
srv := server.New(store, a, sm, hub, runtimeState, artifactDir, aw)
go srv.Scheduler(ctx)
httpSrv := &http.Server{Addr: env("HTTP_ADDR", ":8080"), Handler: srv.Routes(), ReadHeaderTimeout: 5 * time.Second}
publicSrv := &http.Server{Addr: env("HTTP_ADDR", ":8080"), Handler: srv.PublicRoutes(), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
adminSrv := &http.Server{Addr: env("ADMIN_HTTP_ADDR", ":8081"), Handler: srv.AdminRoutes(), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("listening on %s", httpSrv.Addr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("public listener on %s", publicSrv.Addr)
if err := publicSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
go func() {
log.Printf("admin listener on %s (do not expose publicly)", adminSrv.Addr)
if err := adminSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
@@ -105,5 +131,6 @@ func main() {
<-ctx.Done()
shutdown, done := context.WithTimeout(context.Background(), 10*time.Second)
defer done()
_ = httpSrv.Shutdown(shutdown)
_ = publicSrv.Shutdown(shutdown)
_ = adminSrv.Shutdown(shutdown)
}

View File

@@ -1,12 +1,17 @@
services:
app:
build: .
env_file: .env
env_file:
- path: .env
required: false
environment:
SQLITE_PATH: /data/neuralhunt.db
ARTIFACT_DIR: /data/artifacts
ports:
- "8080:8080"
# Admin is bound only to host loopback by default. A VPN/reverse proxy on
# the host can publish it privately; Docker-network proxies can use app:8081.
- "127.0.0.1:8081:8081"
# Lets the optional ComfyUI/A1111 providers reach a UI running on the Docker host.
extra_hosts:
- "host.docker.internal:host-gateway"

142
internal/artifact/budget.go Normal file
View File

@@ -0,0 +1,142 @@
package artifact
import (
"context"
"errors"
"fmt"
"sync"
"time"
"neuralhunt/internal/settings"
)
var ErrOpenAIBudgetExceeded = errors.New("OpenAI cost circuit breaker active")
type OpenAIBudgetError struct {
Reason string
RetryAfter time.Duration
}
func (e *OpenAIBudgetError) Error() string {
if e == nil {
return ErrOpenAIBudgetExceeded.Error()
}
return fmt.Sprintf("%s: %s", ErrOpenAIBudgetExceeded, e.Reason)
}
func (e *OpenAIBudgetError) Unwrap() error { return ErrOpenAIBudgetExceeded }
type openAIBudgetSnapshot struct {
Calls1H int64
Calls24H int64
Cost24H float64
Oldest1H int64
Oldest24H int64
}
var openAIBudgetMu sync.Mutex
func (w *Worker) openAIBudgetSnapshot(ctx context.Context) (openAIBudgetSnapshot, error) {
now := time.Now().UTC()
oneHour := now.Add(-time.Hour).UnixMilli()
day := now.Add(-24 * time.Hour).UnixMilli()
var x openAIBudgetSnapshot
if err := w.db.QueryRowContext(ctx, `SELECT
COALESCE(sum(CASE WHEN created_at>=? THEN 1 ELSE 0 END),0),
count(*),COALESCE(sum(estimated_cost_usd),0),
COALESCE(min(CASE WHEN created_at>=? THEN created_at END),0),
COALESCE(min(created_at),0)
FROM artifact_api_usage
WHERE provider='openai' AND created_at>=?`, oneHour, oneHour, day).
Scan(&x.Calls1H, &x.Calls24H, &x.Cost24H, &x.Oldest1H, &x.Oldest24H); err != nil {
return x, err
}
return x, nil
}
func retryFromOldest(oldestMS int64, window time.Duration) time.Duration {
if oldestMS <= 0 {
return time.Minute
}
d := time.Until(time.UnixMilli(oldestMS).UTC().Add(window))
if d < time.Second {
d = time.Second
}
return d
}
func (w *Worker) checkOpenAIBudget(ctx context.Context, cfg settings.Runtime) error {
if cfg.OpenAIMaxCalls1H <= 0 && cfg.OpenAIMaxCalls24H <= 0 && cfg.OpenAIMaxCost24HUSD <= 0 {
return nil
}
if cfg.OpenAIMaxCost24HUSD > 0 {
if _, ok := openAIImagePricing(cfg.ArtifactModel); !ok {
return &OpenAIBudgetError{Reason: "daily USD budget is enabled but this image model has no pinned local price", RetryAfter: 10 * time.Minute}
}
}
x, err := w.openAIBudgetSnapshot(ctx)
if err != nil {
return err
}
var wait time.Duration
var reasons []string
if cfg.OpenAIMaxCalls1H > 0 && x.Calls1H >= int64(cfg.OpenAIMaxCalls1H) {
reasons = append(reasons, fmt.Sprintf("%d/%d OpenAI image calls in the last hour", x.Calls1H, cfg.OpenAIMaxCalls1H))
wait = maxDuration(wait, retryFromOldest(x.Oldest1H, time.Hour))
}
if cfg.OpenAIMaxCalls24H > 0 && x.Calls24H >= int64(cfg.OpenAIMaxCalls24H) {
reasons = append(reasons, fmt.Sprintf("%d/%d OpenAI image calls in the last 24h", x.Calls24H, cfg.OpenAIMaxCalls24H))
wait = maxDuration(wait, retryFromOldest(x.Oldest24H, 24*time.Hour))
}
if cfg.OpenAIMaxCost24HUSD > 0 && x.Cost24H+cfg.OpenAIBudgetReserveUSD > cfg.OpenAIMaxCost24HUSD {
reasons = append(reasons, fmt.Sprintf("estimated cost $%.4f + $%.4f safety reserve exceeds $%.4f/24h", x.Cost24H, cfg.OpenAIBudgetReserveUSD, cfg.OpenAIMaxCost24HUSD))
// Re-evaluate periodically because several old calls may age out together.
wait = maxDuration(wait, 5*time.Minute)
}
if len(reasons) > 0 {
return &OpenAIBudgetError{Reason: joinBudgetReasons(reasons), RetryAfter: wait}
}
return nil
}
func joinBudgetReasons(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += "; "
}
out += p
}
return out
}
func maxDuration(a, b time.Duration) time.Duration {
if b > a {
return b
}
return a
}
// openAITrackedRequest serializes budget check -> provider call -> usage log.
// That prevents two concurrent admin/worker requests from both observing the
// same remaining budget before either successful call is recorded.
func (w *Worker) openAITrackedRequest(ctx context.Context, cfg settings.Runtime, taskID, kind, prompt string, refs []referenceImage) (imageResult, error) {
openAIBudgetMu.Lock()
defer openAIBudgetMu.Unlock()
if err := w.checkOpenAIBudget(ctx, cfg); err != nil {
return imageResult{}, err
}
res, err := w.openAIRequest(ctx, cfg, prompt, refs)
if err != nil {
return imageResult{}, err
}
if err := w.recordOpenAIUsage(ctx, taskID, kind, res); err != nil {
if res.Meta == nil {
res.Meta = map[string]any{}
}
// Never retry a successfully generated image merely because telemetry
// failed; that could double-spend. Surface the logging failure in metadata.
res.Meta["usage_log_error"] = err.Error()
}
return res, nil
}

View File

@@ -71,16 +71,10 @@ func (w *Worker) createCharacterAnchorLocked(ctx context.Context, cfg settings.R
// The global anchor is intentionally generated without a style image. It is
// an identity reference only; visual style is supplied independently per
// task as Image 2 during actual card generation.
res, err := w.openAIRequest(ctx, cfg, characterAnchorPrompt, nil)
res, err := w.openAITrackedRequest(ctx, cfg, "", "character_anchor", characterAnchorPrompt, nil)
if err != nil {
return nil, false, fmt.Errorf("create canonical RIFT anchor: %w", err)
}
if err := w.recordOpenAIUsage(ctx, "", "character_anchor", res); err != nil {
if res.Meta == nil {
res.Meta = map[string]any{}
}
res.Meta["usage_log_error"] = err.Error()
}
if len(res.Bytes) == 0 {
return nil, false, errors.New("create canonical RIFT anchor: OpenAI returned empty image")
}
@@ -112,17 +106,7 @@ func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt
return imageResult{}, err
}
if !strings.EqualFold(strings.TrimSpace(cfg.ArtifactPreset), collectionPresetRaccoon) {
res, err := w.openAIRequest(ctx, cfg, prompt, nil)
if err != nil {
return imageResult{}, err
}
if err := w.recordOpenAIUsage(ctx, x.ID, "artifact", res); err != nil {
if res.Meta == nil {
res.Meta = map[string]any{}
}
res.Meta["usage_log_error"] = err.Error()
}
return res, nil
return w.openAITrackedRequest(ctx, cfg, x.ID, "artifact", prompt, nil)
}
anchor, created, err := w.ensureCharacterAnchor(ctx, cfg)
if err != nil {
@@ -132,7 +116,7 @@ func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt
if err != nil {
return imageResult{}, err
}
res, err := w.openAIRequest(ctx, cfg, prompt, []referenceImage{
res, err := w.openAITrackedRequest(ctx, cfg, x.ID, "artifact", prompt, []referenceImage{
{Name: "character_anchor.png", ContentType: "image/png", Bytes: anchor},
{Name: styleRef.Name, ContentType: styleRef.ContentType, Bytes: styleRef.Bytes},
})
@@ -149,9 +133,6 @@ func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt
res.Meta["style_reference_name"] = styleRef.Name
res.Meta["style_reference_custom"] = styleRef.Custom
res.Meta["reference_mode"] = "character-plus-task-style"
if err := w.recordOpenAIUsage(ctx, x.ID, "artifact", res); err != nil {
res.Meta["usage_log_error"] = err.Error()
}
return res, nil
}

View File

@@ -18,17 +18,20 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"neuralhunt/internal/settings"
)
type Worker struct {
db *sql.DB
dir string
publicBase string
settings *settings.Manager
http *http.Client
db *sql.DB
dir string
publicBase string
settings *settings.Manager
http *http.Client
budgetHoldMu sync.Mutex
budgetHoldUntil time.Time
}
func New(db *sql.DB, dir string, sm *settings.Manager) (*Worker, error) {
@@ -122,7 +125,33 @@ type imageResult struct {
PricingBasis string
}
func (w *Worker) budgetHoldActive() bool {
w.budgetHoldMu.Lock()
defer w.budgetHoldMu.Unlock()
return time.Now().Before(w.budgetHoldUntil)
}
func (w *Worker) holdBudgetFor(d time.Duration) {
if d < time.Second {
d = time.Minute
}
w.budgetHoldMu.Lock()
until := time.Now().Add(d)
if until.After(w.budgetHoldUntil) {
w.budgetHoldUntil = until
}
w.budgetHoldMu.Unlock()
}
func (w *Worker) deferBudget(ctx context.Context, taskID string, err error) {
msg := err.Error()
_, _ = w.db.ExecContext(ctx, `UPDATE tasks SET artifact_status='pending',artifact_error=? WHERE id=? AND artifact_status='generating'`, msg, taskID)
}
func (w *Worker) one(ctx context.Context) error {
if w.budgetHoldActive() {
return nil
}
x, err := w.claim(ctx)
if errors.Is(err, sql.ErrNoRows) {
return nil
@@ -144,6 +173,13 @@ func (w *Worker) one(ctx context.Context) error {
}
img, err := w.generate(ctx, cfg, x, prompt, negativePrompt)
if err != nil {
var budgetErr *OpenAIBudgetError
if errors.As(err, &budgetErr) {
w.deferBudget(ctx, x.ID, err)
w.holdBudgetFor(budgetErr.RetryAfter)
log.Printf("artifact worker task %s deferred by OpenAI circuit breaker: %v", x.ID, err)
return nil
}
w.fail(ctx, x.ID, err)
return fmt.Errorf("task %s: %w", x.ID, err)
}

View File

@@ -44,6 +44,7 @@ type Claims struct {
type challengeEntry struct {
ClientID string
ExpiresAt time.Time
ProofBits int
}
type Manager struct {
@@ -109,6 +110,10 @@ func randomB64(n int) (string, error) {
}
func (m *Manager) NewChallenge(ctx context.Context, cid string) (string, error) {
return m.NewChallengeWithProof(ctx, cid, 0)
}
func (m *Manager) NewChallengeWithProof(ctx context.Context, cid string, proofBits int) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
@@ -128,11 +133,55 @@ func (m *Manager) NewChallenge(ctx context.Context, cid string) (string, error)
delete(m.challenges, nonce)
}
}
m.challenges[c] = challengeEntry{ClientID: cid, ExpiresAt: expires}
if len(m.challenges) >= 50000 {
return "", errors.New("too many pending authentication challenges")
}
if proofBits < 0 {
proofBits = 0
}
if proofBits > 22 {
proofBits = 22
}
m.challenges[c] = challengeEntry{ClientID: cid, ExpiresAt: expires, ProofBits: proofBits}
return c, nil
}
func (m *Manager) ConsumeChallenge(ctx context.Context, cid, challenge string) error {
return m.ConsumeChallengeWithProof(ctx, cid, challenge, "")
}
func leadingZeroBits(b []byte) int {
n := 0
for _, x := range b {
if x == 0 {
n += 8
continue
}
for mask := byte(0x80); mask != 0 && x&mask == 0; mask >>= 1 {
n++
}
break
}
return n
}
func verifyChallengeProof(challenge, cid, counter string, bits int) bool {
if bits <= 0 {
return true
}
if len(counter) == 0 || len(counter) > 24 {
return false
}
for _, r := range counter {
if r < '0' || r > '9' {
return false
}
}
h := sha256.Sum256([]byte("nh-pow-v1|" + challenge + "|" + cid + "|" + counter))
return leadingZeroBits(h[:]) >= bits
}
func (m *Manager) ConsumeChallengeWithProof(ctx context.Context, cid, challenge, counter string) error {
if err := ctx.Err(); err != nil {
return err
}
@@ -151,6 +200,9 @@ func (m *Manager) ConsumeChallenge(ctx context.Context, cid, challenge string) e
if entry.ClientID != cid {
return errors.New("challenge identity mismatch")
}
if !verifyChallengeProof(challenge, cid, counter, entry.ProofBits) {
return errors.New("identity proof of work invalid")
}
return nil
}

View File

@@ -78,3 +78,27 @@ func TestManyConcurrentChallenges(t *testing.T) {
}
}
}
func TestChallengeProofOfWorkForNewIdentity(t *testing.T) {
m := New(nil, "test-secret")
ctx := context.Background()
const cid = "client-pow"
challenge, err := m.NewChallengeWithProof(ctx, cid, 8)
if err != nil {
t.Fatal(err)
}
counter := ""
for i := 0; i < 100000; i++ {
candidate := fmt.Sprint(i)
if verifyChallengeProof(challenge, cid, candidate, 8) {
counter = candidate
break
}
}
if counter == "" {
t.Fatal("failed to solve small test proof")
}
if err := m.ConsumeChallengeWithProof(ctx, cid, challenge, counter); err != nil {
t.Fatalf("valid proof rejected: %v", err)
}
}

View File

@@ -183,6 +183,14 @@ func (s *Store) ClientExists(ctx context.Context, id string) bool {
return n == 1
}
func (s *Store) ClientCreatedAt(ctx context.Context, id string) (time.Time, error) {
var ms int64
if err := s.DB.QueryRowContext(ctx, `SELECT created_at FROM clients WHERE id=?`, id).Scan(&ms); err != nil {
return time.Time{}, err
}
return time.UnixMilli(ms).UTC(), nil
}
func (s *Store) ClientPublicJWK(ctx context.Context, id string) (auth.PublicJWK, error) {
var raw string
if err := s.DB.QueryRowContext(ctx, `SELECT public_jwk FROM clients WHERE id=?`, id).Scan(&raw); err != nil {

View File

@@ -34,6 +34,10 @@ func (s *Server) adminCreateCharacterAnchor(w http.ResponseWriter, r *http.Reque
jsonOut(w, http.StatusConflict, map[string]string{"error": "character anchor already exists"})
return
}
if errors.Is(err, artifact.ErrOpenAIBudgetExceeded) {
jsonOut(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}

View File

@@ -64,6 +64,19 @@ func (s *Server) adminArtifactUsage(w http.ResponseWriter, r *http.Request) {
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),COALESCE(sum(estimated_cost_usd),0)
FROM artifact_api_usage WHERE kind='character_anchor'`).Scan(&anchorCalls, &anchorCost)
var rollingCalls1H, rollingCalls24H int64
var rollingCost24H float64
oneHourMS := now.Add(-time.Hour).UnixMilli()
twentyFourHoursMS := now.Add(-24 * time.Hour).UnixMilli()
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT
COALESCE(sum(CASE WHEN created_at>=? THEN 1 ELSE 0 END),0),
count(*),COALESCE(sum(estimated_cost_usd),0)
FROM artifact_api_usage WHERE provider='openai' AND created_at>=?`, oneHourMS, twentyFourHoursMS).Scan(&rollingCalls1H, &rollingCalls24H, &rollingCost24H)
cfg := s.settings.Get()
circuitBlocked := (cfg.OpenAIMaxCalls1H > 0 && rollingCalls1H >= int64(cfg.OpenAIMaxCalls1H)) ||
(cfg.OpenAIMaxCalls24H > 0 && rollingCalls24H >= int64(cfg.OpenAIMaxCalls24H)) ||
(cfg.OpenAIMaxCost24HUSD > 0 && rollingCost24H+cfg.OpenAIBudgetReserveUSD > cfg.OpenAIMaxCost24HUSD)
recent := make([]artifactUsageRow, 0, 20)
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT created_at,task_id,kind,model,endpoint,size,quality,request_id,
input_tokens,input_text_tokens,input_image_tokens,output_tokens,total_tokens,estimated_cost_usd,pricing_basis
@@ -114,6 +127,16 @@ func (s *Server) adminArtifactUsage(w http.ResponseWriter, r *http.Request) {
"output_tokens": outputTokens,
"total_tokens": totalTokens,
},
"circuit_breaker": map[string]any{
"blocked": circuitBlocked,
"calls_1h": rollingCalls1H,
"calls_24h": rollingCalls24H,
"cost_24h_usd": rollingCost24H,
"max_calls_1h": cfg.OpenAIMaxCalls1H,
"max_calls_24h": cfg.OpenAIMaxCalls24H,
"max_cost_24h_usd": cfg.OpenAIMaxCost24HUSD,
"reserve_per_call_usd": cfg.OpenAIBudgetReserveUSD,
},
"cost_method": "estimated from provider-reported token usage using pinned OpenAI standard public token rates; not invoice reconciliation",
"recent": recent,
})

View File

@@ -10,11 +10,14 @@ import (
"log"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
gort "runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"neuralhunt/internal/artifact"
@@ -40,23 +43,69 @@ type Server struct {
lottery *guessLottery
adminUser, adminPass, staticDir, artifactDir string
upgrader websocket.Upgrader
wsAllowedOrigins map[string]struct{}
maxUserWS, maxLeaderboardWS int64
userWSCount, leaderboardWSCount atomic.Int64
adminSessions sync.Map // sid -> exp unix seconds
}
func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, runtimeState *rtx.State, artifactDir string, artifactWorker *artifact.Worker) *Server {
return &Server{
store: store,
auth: a,
settings: sm,
hub: hub,
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(),
adminUser: env("ADMIN_USER", "admin"),
adminPass: env("ADMIN_PASSWORD", "change-me"),
staticDir: env("STATIC_DIR", ""),
artifactDir: artifactDir,
upgrader: websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }},
s := &Server{
store: store,
auth: a,
settings: sm,
hub: hub,
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(),
adminUser: env("ADMIN_USER", "admin"),
adminPass: env("ADMIN_PASSWORD", "change-me"),
staticDir: env("STATIC_DIR", ""),
artifactDir: artifactDir,
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
}
func envIntServer(k string, d int) int {
if raw := strings.TrimSpace(os.Getenv(k)); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
return n
}
}
return d
}
func parseOriginAllowlist(raw string) map[string]struct{} {
out := map[string]struct{}{}
for _, item := range strings.Split(raw, ",") {
item = strings.TrimSpace(strings.TrimRight(item, "/"))
if item != "" {
out[strings.ToLower(item)] = struct{}{}
}
}
return out
}
func (s *Server) checkWSOrigin(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
// Native clients do not normally send Origin. Authentication and global
// connection caps still apply; browser cross-site websockets do send it.
return true
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
if strings.EqualFold(u.Host, r.Host) {
return true
}
_, ok := s.wsAllowedOrigins[strings.ToLower(strings.TrimRight(origin, "/"))]
return ok
}
func env(k, d string) string {
@@ -130,9 +179,53 @@ func (s *Server) bearer(r *http.Request) (auth.Claims, error) {
return s.auth.Parse(strings.TrimPrefix(h, "Bearer "))
}
const adminSessionCookie = "neuralhunt_admin_session"
func adminCookieSecure(r *http.Request) bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv("ADMIN_COOKIE_SECURE")))
switch raw {
case "0", "false", "no", "off":
return false
case "auto":
if r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https")
default:
// Public deployments should terminate HTTPS in front of this listener.
// Defaulting to Secure avoids a proxy-header mistake silently weakening
// the admin session. Local plain-HTTP development can set false explicitly.
return true
}
}
func (s *Server) adminCookieClaims(r *http.Request) (auth.Claims, error) {
cookie, err := r.Cookie(adminSessionCookie)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return auth.Claims{}, errors.New("missing admin session")
}
c, err := s.auth.Parse(cookie.Value)
if err != nil || c.Role != "admin" || c.SessionID == "" {
return auth.Claims{}, errors.New("invalid admin session")
}
expRaw, ok := s.adminSessions.Load(c.SessionID)
exp, typed := expRaw.(int64)
if !ok || !typed || exp < time.Now().Unix() {
s.adminSessions.Delete(c.SessionID)
return auth.Claims{}, errors.New("admin session revoked or expired")
}
return c, nil
}
func (s *Server) require(role string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := s.bearer(r)
var c auth.Claims
var err error
if role == "admin" {
c, err = s.adminCookieClaims(r)
} else {
c, err = s.bearer(r)
}
if err != nil || (role != "" && c.Role != role) {
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
return
@@ -143,15 +236,52 @@ func (s *Server) require(role string, next http.Handler) http.Handler {
func claims(r *http.Request) auth.Claims { return r.Context().Value(claimsKey).(auth.Claims) }
// PublicRoutes is safe to expose to the Internet. The admin UI and every
// /api/admin endpoint are deliberately absent from this listener.
func (s *Server) PublicRoutes() http.Handler {
next := s.Routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.ToLower(r.URL.Path)
if p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// AdminRoutes is intended for the private/VPN listener. It serves only the
// control plane, its static frontend assets and health check.
func (s *Server) AdminRoutes() http.Handler {
next := s.Routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.ToLower(r.URL.Path)
if p == "/" {
http.Redirect(w, r, "/admin", http.StatusTemporaryRedirect)
return
}
allowed := p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/healthz" || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/app.js" || p == "/styles.css" || p == "/index.html"
if !allowed {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) Routes() http.Handler {
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self' data:; form-action 'self'")
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
path := strings.ToLower(req.URL.Path)
if path == "/" || path == "/admin" || path == "/leaderboard" || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
if path == "/" || path == "/admin" || path == "/leaderboard" || strings.HasPrefix(path, "/api/admin") || strings.HasPrefix(path, "/api/auth") || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, req)
@@ -166,6 +296,7 @@ func (s *Server) Routes() http.Handler {
r.Post("/api/auth/challenge", s.challenge)
r.Post("/api/auth/login", s.login)
r.Post("/api/admin/login", s.adminLogin)
r.Post("/api/admin/logout", s.adminLogout)
r.Group(func(r chi.Router) {
r.Use(func(n http.Handler) http.Handler { return s.require("user", n) })
r.Get("/api/tasks", s.clientTasks)
@@ -178,6 +309,7 @@ func (s *Server) Routes() http.Handler {
})
r.Group(func(r chi.Router) {
r.Use(func(n http.Handler) http.Handler { return s.require("admin", n) })
r.Get("/api/admin/session", s.adminSession)
r.Get("/api/admin/overview", s.adminOverview)
r.Get("/api/admin/performance", s.adminPerformance)
r.Get("/api/admin/profiles/cleanup-preview", s.adminProfileCleanupPreview)
@@ -265,20 +397,25 @@ func (s *Server) challenge(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
c, err := s.auth.NewChallenge(r.Context(), cid)
proofBits := 0
if !s.store.ClientExists(r.Context(), cid) {
proofBits = s.settings.Get().SybilProofOfWorkBits
}
c, err := s.auth.NewChallengeWithProof(r.Context(), cid, proofBits)
if err != nil {
log.Printf("auth challenge for %s: %v", cid, err)
jsonOut(w, 500, map[string]string{"error": "challenge failed"})
return
}
jsonOut(w, 200, map[string]string{"client_id": cid, "challenge": c})
jsonOut(w, 200, map[string]any{"client_id": cid, "challenge": c, "proof_of_work_bits": proofBits})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var in struct {
PublicJWK auth.PublicJWK `json:"public_jwk"`
Challenge string `json:"challenge"`
Signature string `json:"signature"`
PublicJWK auth.PublicJWK `json:"public_jwk"`
Challenge string `json:"challenge"`
Signature string `json:"signature"`
ProofOfWorkCounter string `json:"proof_of_work_counter"`
}
if err := decodeAuth(r, &in); err != nil {
log.Printf("auth login decode: %v", err)
@@ -295,7 +432,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 401, map[string]string{"error": "invalid signature"})
return
}
if err = s.auth.ConsumeChallenge(r.Context(), cid, in.Challenge); err != nil {
if err = s.auth.ConsumeChallengeWithProof(r.Context(), cid, in.Challenge, in.ProofOfWorkCounter); err != nil {
log.Printf("auth login challenge for %s: %v", cid, err)
jsonOut(w, 401, map[string]string{"error": err.Error()})
return
@@ -323,13 +460,53 @@ func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
return
}
tok, _, err := s.auth.Issue("admin", "admin", 8*time.Hour)
tok, sessionClaims, err := s.auth.Issue("admin", "admin", 8*time.Hour)
if err != nil {
log.Printf("admin auth issue token: %v", err)
jsonOut(w, 500, map[string]string{"error": "token creation failed"})
jsonOut(w, 500, map[string]string{"error": "session creation failed"})
return
}
jsonOut(w, 200, map[string]string{"token": tok})
nowUnix := time.Now().Unix()
s.adminSessions.Range(func(key, value any) bool {
if exp, ok := value.(int64); !ok || exp < nowUnix {
s.adminSessions.Delete(key)
}
return true
})
s.adminSessions.Store(sessionClaims.SessionID, sessionClaims.Exp)
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookie,
Value: tok,
Path: "/",
MaxAge: int((8 * time.Hour).Seconds()),
HttpOnly: true,
Secure: adminCookieSecure(r),
SameSite: http.SameSiteStrictMode,
})
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(adminSessionCookie); err == nil {
if c, err := s.auth.Parse(cookie.Value); err == nil && c.SessionID != "" {
s.adminSessions.Delete(c.SessionID)
}
}
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: adminCookieSecure(r),
SameSite: http.SameSiteStrictMode,
})
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Server) adminSession(w http.ResponseWriter, r *http.Request) {
c := claims(r)
jsonOut(w, 200, map[string]any{"ok": true, "role": c.Role, "expires_at": time.Unix(c.Exp, 0).UTC()})
}
func taskIntervals(t data.Task, sm settings.Runtime) (int, int) {
@@ -476,6 +653,19 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, false)
return
}
if warmup := s.settings.Get().SybilWarmupSec; warmup > 0 {
created, err := s.store.ClientCreatedAt(r.Context(), c.ClientID)
if err != nil {
jsonOut(w, 401, false)
return
}
readyAt := created.Add(time.Duration(warmup) * time.Second)
if wait := time.Until(readyAt); wait > 0 {
retry := int(math.Ceil(wait.Seconds()))
jsonAPIError(w, http.StatusTooManyRequests, "identity_warmup", "new identity is still in anti-sybil warmup", map[string]any{"retry_after_sec": retry})
return
}
}
t, err := s.store.SecretTask(r.Context(), id)
if err != nil || t.Status != "active" {
jsonAPIError(w, http.StatusConflict, "task_inactive", "task is no longer active", nil)
@@ -751,6 +941,11 @@ func (s *Server) publicArtifactPreview(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) leaderboardWS(w http.ResponseWriter, r *http.Request) {
if !acquireWSCap(&s.leaderboardWSCount, s.maxLeaderboardWS) {
http.Error(w, "leaderboard websocket capacity reached", http.StatusServiceUnavailable)
return
}
defer s.leaderboardWSCount.Add(-1)
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return
@@ -760,9 +955,31 @@ func (s *Server) leaderboardWS(w http.ResponseWriter, r *http.Request) {
defer s.hub.Remove(cl)
initial, _ := s.store.LiveLeaderboard(r.Context(), 200)
cl.Enqueue(wsx.Event{Type: "leaderboard", Data: initial})
conn.SetReadLimit(1024)
_ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(90 * time.Second)) })
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
for {
if _, _, err := conn.ReadMessage(); err != nil {
select {
case <-done:
return
case <-r.Context().Done():
return
case <-ping.C:
if err := cl.Ping(); err != nil {
return
}
}
}
}
@@ -1226,8 +1443,40 @@ func (s *Server) adminEnsure(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]bool{"ok": true})
}
func websocketAuthToken(r *http.Request) string {
if h := strings.TrimSpace(r.Header.Get("Authorization")); strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
}
for _, part := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "nh-auth.") {
return strings.TrimPrefix(part, "nh-auth.")
}
}
if strings.EqualFold(strings.TrimSpace(os.Getenv("WS_ALLOW_QUERY_TOKEN")), "1") {
return r.URL.Query().Get("token")
}
return ""
}
func acquireWSCap(counter *atomic.Int64, max int64) bool {
if max <= 0 {
counter.Add(1)
return true
}
for {
cur := counter.Load()
if cur >= max {
return false
}
if counter.CompareAndSwap(cur, cur+1) {
return true
}
}
}
func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
tok := r.URL.Query().Get("token")
tok := websocketAuthToken(r)
c, err := s.auth.Parse(tok)
if err != nil || c.Role != "user" {
http.Error(w, "unauthorized", 401)
@@ -1247,6 +1496,11 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
return
}
s.runtime.SetTaskSelection(c.ClientID, t.ID)
if !acquireWSCap(&s.userWSCount, s.maxUserWS) {
http.Error(w, "websocket capacity reached", http.StatusServiceUnavailable)
return
}
defer s.userWSCount.Add(-1)
leaseID, err := s.runtime.AcquirePresence(c.ClientID, c.SessionID)
if err != nil {
http.Error(w, "identity already connected", 409)

View File

@@ -13,24 +13,30 @@ import (
)
type Runtime struct {
GuessMinIntervalSec int `json:"guess_min_interval_sec"`
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
TaskRangeBits int `json:"task_range_bits"`
ActiveTaskCount int `json:"active_task_count"`
PresenceTTLSec int `json:"presence_ttl_sec"`
DefaultMaxNodes int `json:"default_max_nodes"`
PublicScorePrecision int `json:"public_score_precision"`
ArtifactPreset string `json:"artifact_preset"`
ArtifactProvider string `json:"artifact_provider"`
ArtifactModel string `json:"artifact_model"`
ArtifactPrompt string `json:"artifact_prompt"`
ArtifactNegativePrompt string `json:"artifact_negative_prompt"`
ArtifactWidth int `json:"artifact_width"`
ArtifactHeight int `json:"artifact_height"`
ArtifactSteps int `json:"artifact_steps"`
ArtifactQuality string `json:"artifact_quality"`
GuessMinIntervalSec int `json:"guess_min_interval_sec"`
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
SybilProofOfWorkBits int `json:"sybil_pow_bits"`
SybilWarmupSec int `json:"sybil_warmup_sec"`
OpenAIMaxCalls1H int `json:"openai_max_calls_1h"`
OpenAIMaxCalls24H int `json:"openai_max_calls_24h"`
OpenAIMaxCost24HUSD float64 `json:"openai_max_cost_24h_usd"`
OpenAIBudgetReserveUSD float64 `json:"openai_budget_reserve_usd"`
TaskRangeBits int `json:"task_range_bits"`
ActiveTaskCount int `json:"active_task_count"`
PresenceTTLSec int `json:"presence_ttl_sec"`
DefaultMaxNodes int `json:"default_max_nodes"`
PublicScorePrecision int `json:"public_score_precision"`
ArtifactPreset string `json:"artifact_preset"`
ArtifactProvider string `json:"artifact_provider"`
ArtifactModel string `json:"artifact_model"`
ArtifactPrompt string `json:"artifact_prompt"`
ArtifactNegativePrompt string `json:"artifact_negative_prompt"`
ArtifactWidth int `json:"artifact_width"`
ArtifactHeight int `json:"artifact_height"`
ArtifactSteps int `json:"artifact_steps"`
ArtifactQuality string `json:"artifact_quality"`
}
type Manager struct {
@@ -55,12 +61,27 @@ func envString(k, def string) string {
return def
}
func envFloat(k string, def float64) float64 {
if s := strings.TrimSpace(os.Getenv(k)); s != "" {
if n, err := strconv.ParseFloat(s, 64); err == nil {
return n
}
}
return def
}
func Defaults() Runtime {
return Runtime{
GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10),
ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11),
GuessLotteryWindowSec: envInt("DEFAULT_GUESS_LOTTERY_WINDOW_SEC", 60),
GuessLotteryMaxAccepted: envInt("DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED", 0),
SybilProofOfWorkBits: envInt("DEFAULT_SYBIL_POW_BITS", 15),
SybilWarmupSec: envInt("DEFAULT_SYBIL_WARMUP_SEC", 15),
OpenAIMaxCalls1H: envInt("DEFAULT_OPENAI_MAX_CALLS_1H", 20),
OpenAIMaxCalls24H: envInt("DEFAULT_OPENAI_MAX_CALLS_24H", 100),
OpenAIMaxCost24HUSD: envFloat("DEFAULT_OPENAI_MAX_COST_24H_USD", 5.00),
OpenAIBudgetReserveUSD: envFloat("DEFAULT_OPENAI_BUDGET_RESERVE_USD", 0.20),
TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28),
ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1),
PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35),
@@ -209,6 +230,24 @@ func Validate(v Runtime) error {
if v.GuessLotteryMaxAccepted < 0 || v.GuessLotteryMaxAccepted > 100000 {
return fmt.Errorf("guess_lottery_max_accepted must be 0..100000 (0 disables lottery)")
}
if v.SybilProofOfWorkBits < 0 || v.SybilProofOfWorkBits > 22 {
return fmt.Errorf("sybil_pow_bits must be 0..22")
}
if v.SybilWarmupSec < 0 || v.SybilWarmupSec > 86400 {
return fmt.Errorf("sybil_warmup_sec must be 0..86400")
}
if v.OpenAIMaxCalls1H < 0 || v.OpenAIMaxCalls1H > 100000 {
return fmt.Errorf("openai_max_calls_1h must be 0..100000")
}
if v.OpenAIMaxCalls24H < 0 || v.OpenAIMaxCalls24H > 1000000 {
return fmt.Errorf("openai_max_calls_24h must be 0..1000000")
}
if v.OpenAIMaxCost24HUSD < 0 || v.OpenAIMaxCost24HUSD > 100000 {
return fmt.Errorf("openai_max_cost_24h_usd must be 0..100000")
}
if v.OpenAIBudgetReserveUSD < 0 || v.OpenAIBudgetReserveUSD > 10000 {
return fmt.Errorf("openai_budget_reserve_usd must be 0..10000")
}
if v.TaskRangeBits < 8 || v.TaskRangeBits > 128 {
return fmt.Errorf("task_range_bits must be 8..128")
}

View File

@@ -8,7 +8,6 @@ const lerp = (a,b,t) => a + (b-a)*t;
const smooth = t => { t=clamp(t,0,1); return t*t*(3-2*t); };
const tokenKey = 'neuralhunt.token';
const adminTokenKey = 'neuralhunt.adminToken';
const mobileModeKey = 'neuralhunt.mobileMode';
const getToken = () => localStorage.getItem(tokenKey) || '';
function autoMobileMode(){return matchMedia('(max-width: 850px)').matches || matchMedia('(pointer: coarse)').matches}
@@ -20,11 +19,11 @@ const setToken = t => localStorage.setItem(tokenKey, t);
const clearToken = () => localStorage.removeItem(tokenKey);
async function api(path, init={}, admin=false) {
const token = admin ? localStorage.getItem(adminTokenKey) : getToken();
const token = admin ? '' : getToken();
const headers = new Headers(init.headers || {});
if (!(init.body instanceof FormData)) headers.set('Content-Type','application/json');
if (token) headers.set('Authorization','Bearer '+token);
const r = await fetch(path,{...init,headers});
const r = await fetch(path,{...init,headers,credentials:'same-origin'});
if (!r.ok) {
let msg = `HTTP ${r.status}`, payload = null;
try { payload=await r.json(); msg=payload?.error || msg; } catch {}
@@ -34,8 +33,8 @@ async function api(path, init={}, admin=false) {
}
async function loadProtectedImage(path,img,admin=false){
if(!img)return;const token=admin?localStorage.getItem(adminTokenKey):getToken();const headers={};if(token)headers.Authorization='Bearer '+token;
const r=await fetch(path,{headers});if(!r.ok)throw new Error(`Bild HTTP ${r.status}`);const blob=await r.blob();const old=img.dataset.objectUrl;if(old)URL.revokeObjectURL(old);const u=URL.createObjectURL(blob);img.dataset.objectUrl=u;img.src=u;
if(!img)return;const token=admin?'':getToken();const headers={};if(token)headers.Authorization='Bearer '+token;
const r=await fetch(path,{headers,credentials:'same-origin'});if(!r.ok)throw new Error(`Bild HTTP ${r.status}`);const blob=await r.blob();const old=img.dataset.objectUrl;if(old)URL.revokeObjectURL(old);const u=URL.createObjectURL(blob);img.dataset.objectUrl=u;img.src=u;
}
// Browser-persistent cryptographic identity. The private key never leaves the
@@ -57,7 +56,9 @@ async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(r
async function clientId(pub){const subtle=requireWebCrypto();const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await subtle.digest('SHA-256',new TextEncoder().encode(s)))}
async function sign(message){const subtle=requireWebCrypto();const b=await ensureIdentity();const k=await subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await subtle.sign({name:'ECDSA',hash:'SHA-256'},k,new TextEncoder().encode(message)))}
async function responseError(r,fallback){try{const b=await r.json();return b?.error?`${fallback}: ${b.error}`:`${fallback} (HTTP ${r.status})`}catch{return `${fallback} (HTTP ${r.status})`}}
async function loginIdentity(){const b=await ensureIdentity();const cr=await fetch('/api/auth/challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk})});if(!cr.ok)throw new Error(await responseError(cr,'Challenge fehlgeschlagen'));const c=await cr.json();const signature=await sign(`login|${c.challenge}|${c.client_id}`);const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk,challenge:c.challenge,signature})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()}
function zeroBits(bytes){let n=0;for(const x of bytes){if(x===0){n+=8;continue}for(let m=0x80;m&&!(x&m);m>>=1)n++;break}return n}
async function solveIdentityProof(challenge,cid,bits){bits=Number(bits||0);if(bits<=0)return '';const subtle=requireWebCrypto(),enc=new TextEncoder(),prefix=`nh-pow-v1|${challenge}|${cid}|`;let counter=0;const batch=96;while(true){const nums=Array.from({length:batch},(_,i)=>counter+i),hashes=await Promise.all(nums.map(n=>subtle.digest('SHA-256',enc.encode(prefix+n))));for(let i=0;i<hashes.length;i++)if(zeroBits(new Uint8Array(hashes[i]))>=bits)return String(nums[i]);counter+=batch;if(counter%3072===0)await new Promise(r=>setTimeout(r,0))}}
async function loginIdentity(){const b=await ensureIdentity();const cr=await fetch('/api/auth/challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk})});if(!cr.ok)throw new Error(await responseError(cr,'Challenge fehlgeschlagen'));const c=await cr.json(),bits=Number(c.proof_of_work_bits||0);if(bits>0&&$('status'))$('status').textContent=`Neue Identität wird geprüft · ${bits}-Bit Proof-of-Work …`;const proof_of_work_counter=await solveIdentityProof(c.challenge,c.client_id,bits),signature=await sign(`login|${c.challenge}|${c.client_id}`);const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk,challenge:c.challenge,signature,proof_of_work_counter})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()}
async function deterministicGuess(taskID,seed,cid,seq,bits){const subtle=requireWebCrypto();const h=new Uint8Array(await subtle.digest('SHA-256',new TextEncoder().encode(`${taskID}|${seed}|${cid}|${seq}`)));let n=0n;for(const x of h)n=(n<<8n)|BigInt(x);return (n%(1n<<BigInt(bits))).toString()}
async function exportIdentity(passphrase){const subtle=requireWebCrypto();const b=await ensureIdentity();const salt=crypto.getRandomValues(new Uint8Array(16));const iv=crypto.getRandomValues(new Uint8Array(12));const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt,iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['encrypt']);const ct=await subtle.encrypt({name:'AES-GCM',iv},aes,new TextEncoder().encode(JSON.stringify(b)));return JSON.stringify({version:1,salt:b64u(salt),iv:b64u(iv),ciphertext:b64u(ct)},null,2)}
async function importIdentity(raw,passphrase){const subtle=requireWebCrypto();const x=JSON.parse(raw);const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt:ub64(x.salt),iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['decrypt']);const pt=await subtle.decrypt({name:'AES-GCM',iv:ub64(x.iv)},aes,ub64(x.ciphertext));const b=JSON.parse(new TextDecoder().decode(pt));localStorage.setItem(identityKey,JSON.stringify(b));return b}
@@ -308,7 +309,7 @@ function userShell(){
<div class="distance-legend glass"><b>TARGET FIELD</b><span>0 · WEIT</span><i></i><span>75</span><span>90</span><span>95</span><span>99+</span><span>100 · TASK</span></div>
<div class="node-limit glass"><span>MAX NODES</span><input id="maxnodes" type="range" min="100" max="25000" step="100" value="2000"><b id="maxnodesvalue">2000</b></div>
<nav class="dock glass" aria-label="3D-Steuerung">
<button id="chooseTask">TASKS</button><button id="toggleMobile">MOBILE</button><button id="toggleDetails">DETAILS</button><button id="toggleProximity" class="active">TARGET FIELD</button><button id="toggleRotate" class="active">ORBIT</button><button id="toggleLabels" class="active">LABELS</button><button id="toggleEdges" class="active">SIGNALWEGE</button><button id="toggleShells" class="active">SCORE-RINGE</button><button id="toggleLOD" class="active">LOD</button><button id="toggleEco">ECO</button><button id="resetView">ZENTRIEREN</button><a class="dock-link" href="/leaderboard">RANKING</a><a class="dock-link" href="/admin">ADMIN</a>
<button id="chooseTask">TASKS</button><button id="toggleMobile">MOBILE</button><button id="toggleDetails">DETAILS</button><button id="toggleProximity" class="active">TARGET FIELD</button><button id="toggleRotate" class="active">ORBIT</button><button id="toggleLabels" class="active">LABELS</button><button id="toggleEdges" class="active">SIGNALWEGE</button><button id="toggleShells" class="active">SCORE-RINGE</button><button id="toggleLOD" class="active">LOD</button><button id="toggleEco">ECO</button><button id="resetView">ZENTRIEREN</button><a class="dock-link" href="/leaderboard">RANKING</a>
</nav>
<section id="taskLanding" class="task-landing visible">
<div class="task-landing-bg"></div>
@@ -360,8 +361,8 @@ async function runUser(){
if(e.code==='task_config_changed')await refreshTaskConfig(true);
nextGuessAt=Date.now()+1500;status(e.code==='presence_required'?'Live-Verbindung wird automatisch wiederhergestellt …':'Client wird automatisch synchronisiert …')
}
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),signature=await sign(`guess|${task.id}|${seq}|${guess}`);if(Number(task.guess_lottery_max_accepted||0)>0)status(`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;status(correct?'Treffer — Task gelöst!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;if(e.code==='lottery_not_selected'){status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
function openWS(force=false){if(!task||$('taskLanding').classList.contains('visible'))return;clearWSReconnect();if(ws&&(ws.readyState===WebSocket.OPEN||ws.readyState===WebSocket.CONNECTING)){if(!force)return;const old=ws;old._plannedClose=true;try{old.close(1000,'reconnect')}catch{}}const proto=location.protocol==='https:'?'wss':'ws',mx=+$('maxnodes').value||task.default_max_nodes||2000,socket=new WebSocket(`${proto}://${location.host}/api/ws?token=${encodeURIComponent(getToken())}&max_nodes=${encodeURIComponent(mx)}`);ws=socket;socket.onopen=()=>{if(ws!==socket)return;wsBackoff=500;status(task?.paused?'Task pausiert':'verbunden')};socket.onmessage=async ev=>{if(ws!==socket)return;const e=JSON.parse(ev.data);if(e.type==='snapshot'){points=Array.isArray(e.data)?e.data:[];render()}else if(e.type==='point'){const p=e.data,i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p;render()}else if(e.type==='points'){for(const p of (Array.isArray(e.data)?e.data:[])){const i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p}render()}else if(e.type==='task_changed'){await refreshTaskConfig(true);await Promise.all([refreshMe(),refreshLeaders()])}else if(e.type==='task_completed'){status('Task abgeschlossen — Folge-Task ist bereit','researching');setTimeout(()=>showLanding(),1300)}};socket.onclose=e=>{if(ws===socket)ws=null;if(!socket._plannedClose&&task&&!$('taskLanding').classList.contains('visible')){status('Live-Verbindung unterbrochen · verbinde automatisch neu …');scheduleWSReconnect()}};socket.onerror=()=>{if(!socket._plannedClose&&ws===socket)status('WebSocket-Fehler · Reconnect folgt automatisch')}}
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),signature=await sign(`guess|${task.id}|${seq}|${guess}`);if(Number(task.guess_lottery_max_accepted||0)>0)status(`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;status(correct?'Treffer — Task gelöst!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;if(e.code==='identity_warmup'){const wait=Math.max(1,Number(e.data?.retry_after_sec||task?.client_submit_interval_sec||15));nextGuessAt=Date.now()+wait*1000;status(`Anti-Sybil-Wartezeit · ${wait}s`,'living');return}if(e.code==='lottery_not_selected'){status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
function openWS(force=false){if(!task||$('taskLanding').classList.contains('visible'))return;clearWSReconnect();if(ws&&(ws.readyState===WebSocket.OPEN||ws.readyState===WebSocket.CONNECTING)){if(!force)return;const old=ws;old._plannedClose=true;try{old.close(1000,'reconnect')}catch{}}const proto=location.protocol==='https:'?'wss':'ws',mx=+$('maxnodes').value||task.default_max_nodes||2000,socket=new WebSocket(`${proto}://${location.host}/api/ws?max_nodes=${encodeURIComponent(mx)}`,['neuralhunt.v1',`nh-auth.${getToken()}`]);ws=socket;socket.onopen=()=>{if(ws!==socket)return;wsBackoff=500;status(task?.paused?'Task pausiert':'verbunden')};socket.onmessage=async ev=>{if(ws!==socket)return;const e=JSON.parse(ev.data);if(e.type==='snapshot'){points=Array.isArray(e.data)?e.data:[];render()}else if(e.type==='point'){const p=e.data,i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p;render()}else if(e.type==='points'){for(const p of (Array.isArray(e.data)?e.data:[])){const i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p}render()}else if(e.type==='task_changed'){await refreshTaskConfig(true);await Promise.all([refreshMe(),refreshLeaders()])}else if(e.type==='task_completed'){status('Task abgeschlossen — Folge-Task ist bereit','researching');setTimeout(()=>showLanding(),1300)}};socket.onclose=e=>{if(ws===socket)ws=null;if(!socket._plannedClose&&task&&!$('taskLanding').classList.contains('visible')){status('Live-Verbindung unterbrochen · verbinde automatisch neu …');scheduleWSReconnect()}};socket.onerror=()=>{if(!socket._plannedClose&&ws===socket)status('WebSocket-Fehler · Reconnect folgt automatisch')}}
async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}}
try{const id=await ensureIdentity();cid=await clientId(id.publicJwk);$('cid').textContent=cid;$('landingCid').textContent=cid;await ensureSession();syncMobile();await Promise.all([refreshLeaders()]);await showLanding()}catch(e){status(e.message||'Startfehler');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Startfehler</b><span>${esc(e.message||'')}</span></div>`}
$('landingRefresh').onclick=()=>showLanding();$('chooseTask').onclick=()=>showLanding();
@@ -375,7 +376,7 @@ async function runUser(){
function leaderboardShell(){
app.className='leaderboard-page';app.innerHTML=`
<header class="leaderboard-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / REALTIME LEADERBOARD</strong><small>Live score · wins · watermarked winner NFTs</small></div></div><div class="leaderboard-nav"><button id="lbMobile">MOBILE</button><a href="/">CLIENT</a><a href="/admin">ADMIN</a></div></header>
<header class="leaderboard-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / REALTIME LEADERBOARD</strong><small>Live score · wins · watermarked winner NFTs</small></div></div><div class="leaderboard-nav"><button id="lbMobile">MOBILE</button><a href="/">CLIENT</a></div></header>
<main class="lb-main">
<section class="lb-hero"><div class="lb-title"><span class="eyebrow">PUBLIC SIGNAL</span><h1>Echtzeit-Ranking</h1><p>Live-Score zeigt die beste Position in einem aktuell aktiven Task. Gewinner-Artefakte werden öffentlich ausschließlich über eine serverseitig erzeugte Vorschau mit Wasserzeichen angezeigt.</p></div><div class="lb-controls glass"><div class="segmented"><button id="lbLive" class="active">LIVE</button><button id="lbAll">ALL-TIME</button></div><input id="lbSearch" placeholder="Client-ID / Task suchen"><span id="lbState">verbinde …</span></div></section>
<section id="lbPodium" class="lb-podium"></section>
@@ -409,10 +410,10 @@ async function runLeaderboard(){
await refresh();const proto=location.protocol==='https:'?'wss':'ws';ws=new WebSocket(`${proto}://${location.host}/api/leaderboard/ws`);ws.onopen=()=>{$('lbState').textContent='LIVE · verbunden'};ws.onmessage=debounce;ws.onclose=()=>{$('lbState').textContent='WebSocket getrennt'};addEventListener('beforeunload',()=>{clearTimeout(refreshTimer);if(ws)ws.close()},{once:true});
}
function adminLoginShell(){app.className='login';app.innerHTML=`<div class="login-card glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Control plane</small></div></div><input id="adminuser" value="admin" placeholder="Benutzer"><input id="adminpass" type="password" placeholder="Passwort"><button id="adminlogin">ANMELDEN</button><p class="danger statusline" id="adminerr"></p><a href="/">← Client-Ansicht</a></div>`}
function adminLoginShell(){app.className='login';app.innerHTML=`<div class="login-card glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Control plane</small></div></div><input id="adminuser" value="admin" placeholder="Benutzer"><input id="adminpass" type="password" placeholder="Passwort"><button id="adminlogin">ANMELDEN</button><p class="danger statusline" id="adminerr"></p></div>`}
function adminShell(){
app.className='admin';app.innerHTML=`
<header class="admin-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Tasks · Clients · Runtime · Scheduler</small></div></div><div class="admin-actions"><button id="adminMobileToggle">MOBILE</button><a href="/">CLIENT</a><a href="/leaderboard">LEADERBOARD</a><button id="adminlogout">LOGOUT</button></div></header>
<header class="admin-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Tasks · Clients · Runtime · Scheduler</small></div></div><div class="admin-actions"><button id="adminMobileToggle">MOBILE</button><span class="small">PRIVATE CONTROL PLANE</span><button id="adminlogout">LOGOUT</button></div></header>
<div class="overviewStrip glass" id="overview"></div><div class="overviewStrip glass perf-strip" id="performance"></div>
<nav class="admin-mobile-tabs glass" id="adminMobileTabs"><button data-admin-panel="map" class="active">MAP</button><button data-admin-panel="tasks">TASKS</button><button data-admin-panel="control">CONTROL</button></nav>
<main class="adminGrid mobile-show-map" id="adminGrid">
@@ -423,13 +424,13 @@ function adminShell(){
}
async function runAdmin(){
if(!localStorage.getItem(adminTokenKey)){adminLoginShell();$('adminlogin').onclick=async()=>{try{const r=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:$('adminuser').value,password:$('adminpass').value})});if(!r.ok)throw new Error('Login fehlgeschlagen');const x=await r.json();localStorage.setItem(adminTokenKey,x.token);location.reload()}catch(e){$('adminerr').textContent=e.message}};return}
let adminOK=false;try{await api('/api/admin/session',{},true);adminOK=true}catch{}if(!adminOK){adminLoginShell();$('adminlogin').onclick=async()=>{try{const r=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({user:$('adminuser').value,password:$('adminpass').value})});if(!r.ok)throw new Error('Login fehlgeschlagen');location.reload()}catch(e){$('adminerr').textContent=e.message}};return}
adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false;
const setAdminPanel=name=>{const grid=$('adminGrid');grid.classList.remove('mobile-show-map','mobile-show-tasks','mobile-show-control');grid.classList.add(`mobile-show-${name}`);document.querySelectorAll('[data-admin-panel]').forEach(b=>b.classList.toggle('active',b.dataset.adminPanel===name));if(name==='map')setTimeout(()=>map.resize(),30)};
const syncAdminMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('adminMobileToggle').textContent=mobileButtonLabel();setActive('adminMobileToggle',on);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.94);setActive('adminEco',true);setActive('adminEdges',false);if(+$('adminmaxnodes').value>1500){$('adminmaxnodes').value=1500;$('adminmaxvalue').textContent='1.500'}}else{map.eco=false;setActive('adminEco',false)}map.resize()};
$('adminMobileToggle').onclick=()=>toggleMobileMode();document.querySelectorAll('[data-admin-panel]').forEach(b=>b.onclick=()=>setAdminPanel(b.dataset.adminPanel));window.addEventListener('neuralhunt-mobile-mode',syncAdminMobile);syncAdminMobile();
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision'];
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'};
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision'];
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'};
const msg=s=>$('adminstatus').textContent=s;
const saveDraft=()=>{try{draft.tab=tab;draft.selectedTaskId=selected?.id||draft.selectedTaskId||'';draft.filters={status:$('statusfilter')?.value||'',q:$('taskquery')?.value||''};localStorage.setItem(adminDraftKey,JSON.stringify(draft))}catch{}};
const draftScope=()=>tab==='task'&&selected?`task:${selected.id}`:`global:${tab}`;
@@ -439,10 +440,10 @@ async function runAdmin(){
const clearDraftKeys=keys=>{const scope=draftScope(),values=draft.fields?.[scope];if(values){keys.forEach(k=>delete values[k]);saveDraft()}};
function renderOverview(o){$('overview').innerHTML=`<span><b>${o.connected}</b> verbunden</span><span><b>${o.clients}</b> Identitäten</span><span><b>${o.active_tasks}</b> aktive Tasks</span><span><b>${o.completed_tasks}</b> abgeschlossen</span><span><b>${Number(o.guesses||0).toLocaleString('de-DE')}</b> Tipps</span><span><b>${o.artifacts_ready}</b> Artefakte</span>`}
function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`<span><b>${Number(r.guesses_per_sec||0).toFixed(1)}</b> Guess/s</span><span><b>${Number(r.rejected_per_sec||0).toFixed(1)}</b> Reject/s</span><span><b>${Number(r.improvements_per_sec||0).toFixed(1)}</b> Improve/s</span><span><b>${Number(r.sqlite_writes_per_sec||0).toFixed(1)}</b> SQLite W/s</span><span><b>${Number(w.frames_per_sec||0).toFixed(0)}</b> WS Frames/s</span><span><b>${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)}</b> WS MB/s</span><span><b>${Number(w.dropped_per_sec||0).toFixed(1)}</b> Drops/s</span><span><b>${Number(x.goroutines||0).toLocaleString('de-DE')}</b> Goroutines</span><span><b>${(Number(x.heap_bytes||0)/1048576).toFixed(1)}</b> Heap MB</span>`}
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const token=localStorage.getItem(adminTokenKey),r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{headers:{Authorization:'Bearer '+token}});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}}
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{credentials:'same-origin'});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}}
function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>{const aerr=String(t.artifact_error||'').trim();return `<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span class="artifact-state-wrap"><b class="artifact-state ${esc(t.artifact_status||'')}">${esc(t.artifact_status||'—')}</b>${aerr?`<small class="danger artifact-error-summary" title="${esc(aerr)}">${esc(aerr.length>76?aerr.slice(0,75)+'…':aerr)}</small>`:`<small>${t.parent_task_id?`${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small>`}<small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`}).join(''):'<div class="empty">Keine Tasks</div>';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})}
function renderRuntime(){
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert &gt; 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" step="any" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert &gt; 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small"><b>Anti-Sybil:</b> Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.</p><p class="small"><b>OpenAI Circuit Breaker:</b> Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b> und <b>niemals Gewinner</b> eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.</p>
<div class="cleanup-controls"><label><span>Inaktiv seit mindestens</span><input id="profileCleanupValue" data-draft="profileCleanupValue" type="number" min="1" step="1" value="30"></label><label><span>Einheit</span><select id="profileCleanupUnit" data-draft="profileCleanupUnit"><option value="hours">Stunden</option><option value="days" selected>Tage</option><option value="weeks">Wochen</option></select></label></div>
<div class="cleanup-actions"><button id="previewProfileCleanup">PRÜFEN</button><button id="runProfileCleanup" class="danger-button">PROFILE LÖSCHEN</button></div><div id="profileCleanupResult" class="cleanup-result">Noch nicht geprüft.</div>
@@ -454,7 +455,7 @@ async function runAdmin(){
$('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline und niemals Gewinner.\nGewinner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}};
}
function renderArtifact(){
const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[];
const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},cb=u.circuit_breaker||{},recent=Array.isArray(u.recent)?u.recent:[];
const usd=(n,d=4)=>Number(n||0).toLocaleString('de-DE',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d});
const usageRows=recent.length?recent.map(r=>`<tr><td>${fmtDate(r.created_at)}</td><td>${r.kind==='character_anchor'?'ANCHOR':'KARTE'}</td><td>${esc(r.model||'—')}<small>${esc(r.quality||'—')} · ${esc(r.size||'—')}</small></td><td>${Number(r.input_text_tokens||0).toLocaleString('de-DE')} T + ${Number(r.input_image_tokens||0).toLocaleString('de-DE')} I → ${Number(r.output_tokens||0).toLocaleString('de-DE')}</td><td>${r.estimated_cost_usd==null?'—':usd(r.estimated_cost_usd,5)}</td><td><small>${esc(r.request_id?String(r.request_id).slice(-16):'—')}</small></td></tr>`).join(''):'<tr><td colspan="6" class="empty small">Noch keine OpenAI-Bildgenerierung protokolliert.</td></tr>';
if(preset==='raccoon_full_art_v1'){
@@ -464,7 +465,7 @@ async function runAdmin(){
<div class="reference-admin-box"><div><div class="section-title">RIFT CHARACTER ANCHOR</div><p class="small">Der Anchor definiert ausschließlich, <b>wer RIFT ist</b>. Er wird einmalig als neutrales Character-Referenzbild erzeugt und anschließend für alle Tasks verwendet. Die visuelle Stilrichtung kommt getrennt aus der Style-Referenz des jeweiligen Tasks.</p>${anchorReady?'<p class="small ready-copy">Anchor vorhanden · Identität ist gesperrt. Es gibt absichtlich keinen Überschreiben-Button.</p>':'<p class="small">Du kannst den Anchor jetzt kontrolliert erzeugen. Falls du das nicht tust, erzeugt der Worker ihn weiterhin automatisch beim ersten Gewinnerbild als Sicherheits-Fallback.</p>'}</div><div class="reference-preview ${anchorReady?'has-image':''}">${anchorReady?'<img id="anchorPreview" alt="RIFT Character Anchor">':'<span>NO ANCHOR</span>'}</div></div>
${anchorReady?'':`<div class="task-config-actions anchor-actions"><button id="createCharacterAnchor" ${ps.openai?'':'disabled'}>RIFT-ANCHOR JETZT ERZEUGEN</button></div>`}
<div class="task-config-box"><div class="section-title">PIPELINE</div><p class="small">Provider <b>OpenAI</b> · Ausgabe <b>1024 × 1536</b> · Quality <b>${esc(settings?.artifact_quality||'medium')}</b> · Preset <b>raccoon_full_art_v1</b>.</p><p class="small">Jede Karten-Generierung sendet zwei getrennte Referenzen: <b>Image 1 = globaler RIFT-Character-Anchor</b>, <b>Image 2 = Style-Referenz des gewählten Tasks</b>. Ohne eigenen Task-Style wird das eingebettete <code>internal/artifact/assets/style_reference.jpg</code> nur als Default-Style verwendet.</p><p class="small">Theme, Kleidung, Accessoires, Szene, Pose, Stimmung, Farb-Akzente und Rarity werden deterministisch aus Task/Winner/Seed gewählt. Das Modell erzeugt nur die Full-Art-Illustration; das finale Kartenlayout wird anschließend programmgesteuert aufgebaut.</p></div>
<div class="section-title">OPENAI NUTZUNG & KOSTEN</div><div class="cost-grid"><div class="cost-card"><small>KOSTEN HEUTE</small><b id="artifactCostToday">${usd(u.today_cost_usd,4)}</b><span id="artifactCostTodayMeta">${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}</span></div><div class="cost-card"><small>Ø KOSTEN PRO KARTE</small><b id="artifactAvgCardCost">${usd(u.avg_card_cost_usd,5)}</b><span id="artifactAvgCardCostMeta">${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen</span></div><div class="cost-card"><small>KOSTEN PRO 1.000 KARTEN</small><b id="artifactCostPer1000">${usd(u.cost_per_1000_usd,2)}</b><span>hochgerechnet aus dem bisherigen Kartenmittel</span></div></div>
<div class="section-title">OPENAI NUTZUNG & KOSTEN</div><div class="cost-grid"><div class="cost-card"><small>KOSTEN HEUTE</small><b id="artifactCostToday">${usd(u.today_cost_usd,4)}</b><span id="artifactCostTodayMeta">${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}</span></div><div class="cost-card"><small>Ø KOSTEN PRO KARTE</small><b id="artifactAvgCardCost">${usd(u.avg_card_cost_usd,5)}</b><span id="artifactAvgCardCostMeta">${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen</span></div><div class="cost-card"><small>KOSTEN PRO 1.000 KARTEN</small><b id="artifactCostPer1000">${usd(u.cost_per_1000_usd,2)}</b><span>hochgerechnet aus dem bisherigen Kartenmittel</span></div><div class="cost-card"><small>CIRCUIT BREAKER</small><b id="artifactCircuitState">${cb.blocked?'BLOCKED':'READY'}</b><span id="artifactCircuitMeta">${Number(cb.calls_1h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_1h||0).toLocaleString('de-DE')} Calls 1h · ${Number(cb.calls_24h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_24h||0).toLocaleString('de-DE')} Calls 24h · ${usd(cb.cost_24h_usd,3)} / ${usd(cb.max_cost_24h_usd,2)}</span></div></div>
<div class="usage-note">Die Token-Nutzung stammt direkt aus der OpenAI-Antwort. Die USD-Werte werden lokal daraus mit fest hinterlegten öffentlichen Standardpreisen berechnet; sie sind kein Rechnungsabgleich. Anchor-Kosten zählen in „heute“, aber nicht in den Karten-Durchschnitt.</div>
<div class="usage-table-wrap"><table class="usage-table"><thead><tr><th>Zeit</th><th>Typ</th><th>Modell</th><th>Tokens (Text + Bild → Output)</th><th>Kosten</th><th>Request</th></tr></thead><tbody id="artifactUsageRows">${usageRows}</tbody></table></div>
<p class="small">Task-spezifische Style-Bilder und optionale kreative Vorgaben pflegst du im Tab <b>TASK ACTIONS</b>.</p></div>`;
@@ -501,7 +502,7 @@ async function runAdmin(){
const styleImg=$('taskStylePreview');loadProtectedImage(`/api/admin/tasks/${selected.id}/style-reference?ts=${Date.now()}`,styleImg,true).catch(()=>{if(styleImg)styleImg.alt='Style-Referenz konnte nicht geladen werden'});
const uploadStyle=$('uploadTaskStyle');if(uploadStyle)uploadStyle.onclick=async()=>{const file=$('taskStyleFile')?.files?.[0];if(!file){msg('Bitte zuerst ein JPEG- oder PNG-Stylebild auswählen');return}const fd=new FormData();fd.append('file',file,file.name);uploadStyle.disabled=true;uploadStyle.textContent='STYLE WIRD HOCHGELADEN …';try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'PUT',body:fd},true);msg('Task-Style gespeichert · Folge-Task übernimmt ihn');await load(true,true)}catch(e){msg(e.message);uploadStyle.disabled=false;uploadStyle.textContent='STYLE HOCHLADEN / ERSETZEN'}};
const clearStyle=$('clearTaskStyle');if(clearStyle)clearStyle.onclick=async()=>{if(!confirm('Eigenen Task-Style entfernen und wieder den eingebetteten Default-Style verwenden?'))return;try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'DELETE'},true);msg('Task-Style auf Default zurückgesetzt');await load(true,true)}catch(e){msg(e.message)}};
const pipelineTest=$('runPipelineTest');if(pipelineTest)pipelineTest.onclick=async()=>{pipelineTest.disabled=true;pipelineTest.textContent='TEST-KARTE WIRD LOKAL ERZEUGT …';try{const r=await api(`/api/admin/tasks/${selected.id}/pipeline-test`,{method:'POST'},true);const popup=window.open('','_blank');const token=localStorage.getItem(adminTokenKey),resp=await fetch(r.url,{headers:{Authorization:'Bearer '+token}});if(!resp.ok)throw new Error(await responseError(resp,'Testkarte konnte nicht geöffnet werden'));const blob=await resp.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000);msg('Lokale Testkarte erzeugt · 0 API-Calls · $0.00')}catch(e){msg(e.message)}finally{pipelineTest.disabled=false;pipelineTest.textContent='TEST-KARTE ERZEUGEN · 0 API-TOKENS'}};
const pipelineTest=$('runPipelineTest');if(pipelineTest)pipelineTest.onclick=async()=>{pipelineTest.disabled=true;pipelineTest.textContent='TEST-KARTE WIRD LOKAL ERZEUGT …';try{const r=await api(`/api/admin/tasks/${selected.id}/pipeline-test`,{method:'POST'},true);const popup=window.open('','_blank');const resp=await fetch(r.url,{credentials:'same-origin'});if(!resp.ok)throw new Error(await responseError(resp,'Testkarte konnte nicht geöffnet werden'));const blob=await resp.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000);msg('Lokale Testkarte erzeugt · 0 API-Calls · $0.00')}catch(e){msg(e.message)}finally{pipelineTest.disabled=false;pipelineTest.textContent='TEST-KARTE ERZEUGEN · 0 API-TOKENS'}};
const renderPayload=()=>{const type=$('actionType').value,host=$('actionPayload');if(type==='set_range_bits')host.innerHTML=`<label><span>Task-Zahlenraum (Bit)</span><input id="actionBits" data-draft="actionBits" type="number" min="8" max="128" value="${selected.range_bits}"></label><label><span>Änderungsmodus</span><select id="actionMode" data-draft="actionMode"><option value="preserve">preserve · bestehendes Ziel</option><option value="reroll">reroll · neues Ziel</option></select></label><p class="small">Preserve erhält Seed/Sequenzen und re-skaliert Scores mathematisch. Reroll setzt Scores/Sequenzen zurück.</p>`;else if(type==='set_intervals')host.innerHTML=`<label><span>Server Minimum (s)</span><input id="actionServer" data-draft="actionServer" type="number" min="1" max="3600" value="${selected.guess_min_interval_sec??settings.guess_min_interval_sec}"></label><label><span>Client Submit (s)</span><input id="actionClient" data-draft="actionClient" type="number" min="2" max="7200" value="${selected.client_submit_interval_sec??settings.client_submit_interval_sec}"></label>`;else host.innerHTML=`<p class="small">${type==='reroll'?'Achtung: neues Ziel und neuer öffentlicher Seed; aktuelle Scores werden auf 0 gesetzt.':type==='close'?'Beendet den Task. Der automatisch erzeugte Folge-Task erbt Zahlenraum, Intervalle, Darstellung, RIFT-Prompt und Style-Referenz.':type==='regenerate_artifact'?'Nur für abgeschlossene Tasks: setzt das Artifact wieder auf pending. DONE im Audit bestätigt nur das Einreihen; der Artifact-Status zeigt danach generating, ready oder error.':'Keine weiteren Parameter.'}</p>`};
const defaultAt=new Date(Date.now()+5*60*1000);defaultAt.setMinutes(defaultAt.getMinutes()-defaultAt.getTimezoneOffset());$('actionAt').value=defaultAt.toISOString().slice(0,16);restoreDraft();renderPayload();restoreDraft();$('actionType').onchange=()=>{renderPayload();restoreDraft();captureDraft()};
$('saveTaskConfig').onclick=async()=>{try{captureDraft();await api(`/api/admin/tasks/${selected.id}/config`,{method:'PUT',body:JSON.stringify({display_name:$('taskDisplayName').value,description:$('taskDescription').value,nft_prompt_instructions:$('taskNFTPrompt').value,nft_negative_prompt:$('taskNFTNegative').value})},true);clearDraftKeys(['taskDisplayName','taskDescription','taskNFTPrompt','taskNFTNegative']);msg('Task-Konfiguration gespeichert · Folge-Task übernimmt sie');await load(true,true)}catch(e){msg(e.message)}};
@@ -515,20 +516,20 @@ async function runAdmin(){
// navigation/actions are allowed to rebuild this subtree.
function refreshArtifactUsageTelemetry(){
if(!$('artifactCostToday'))return;const u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[],usd=(n,d=4)=>Number(n||0).toLocaleString('de-DE',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d});
$('artifactCostToday').textContent=usd(u.today_cost_usd,4);$('artifactCostTodayMeta').textContent=`${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}`;$('artifactAvgCardCost').textContent=usd(u.avg_card_cost_usd,5);$('artifactAvgCardCostMeta').textContent=`${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen`;$('artifactCostPer1000').textContent=usd(u.cost_per_1000_usd,2);
const cb=u.circuit_breaker||{};$('artifactCostToday').textContent=usd(u.today_cost_usd,4);$('artifactCostTodayMeta').textContent=`${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}`;$('artifactAvgCardCost').textContent=usd(u.avg_card_cost_usd,5);$('artifactAvgCardCostMeta').textContent=`${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen`;$('artifactCostPer1000').textContent=usd(u.cost_per_1000_usd,2);if($('artifactCircuitState'))$('artifactCircuitState').textContent=cb.blocked?'BLOCKED':'READY';if($('artifactCircuitMeta'))$('artifactCircuitMeta').textContent=`${Number(cb.calls_1h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_1h||0).toLocaleString('de-DE')} Calls 1h · ${Number(cb.calls_24h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_24h||0).toLocaleString('de-DE')} Calls 24h · ${usd(cb.cost_24h_usd,3)} / ${usd(cb.max_cost_24h_usd,2)}`;
$('artifactUsageRows').innerHTML=recent.length?recent.map(r=>`<tr><td>${fmtDate(r.created_at)}</td><td>${r.kind==='character_anchor'?'ANCHOR':'KARTE'}</td><td>${esc(r.model||'—')}<small>${esc(r.quality||'—')} · ${esc(r.size||'—')}</small></td><td>${Number(r.input_text_tokens||0).toLocaleString('de-DE')} T + ${Number(r.input_image_tokens||0).toLocaleString('de-DE')} I → ${Number(r.output_tokens||0).toLocaleString('de-DE')}</td><td>${r.estimated_cost_usd==null?'—':usd(r.estimated_cost_usd,5)}</td><td><small>${esc(r.request_id?String(r.request_id).slice(-16):'—')}</small></td></tr>`).join(''):'<tr><td colspan="6" class="empty small">Noch keine OpenAI-Bildgenerierung protokolliert.</td></tr>';
}
function renderSettings(){setActive('tabRuntime',tab==='runtime');setActive('tabTask',tab==='task');setActive('tabArtifact',tab==='artifact');if(tab==='runtime')renderRuntime();else if(tab==='artifact')renderArtifact();else renderTaskControl();restoreDraft()}
function renderMap(){points=Array.isArray(points)?points:[];const min=Number($('adminMinScore')?.value||0),needle=String($('adminClientFilter')?.value||'').trim().toLowerCase(),filtered=points.filter(p=>Number(p.score||0)>=min&&(!needle||String(p.client_id||'').toLowerCase().includes(needle)));$('adminpoints').textContent=`${filtered.length.toLocaleString('de-DE')} / ${points.length.toLocaleString('de-DE')}`;$('selectedtask').textContent=selected?.id||'Task wählen';$('adminbits').textContent=selected?.range_bits??'—';$('winner').textContent=selected?.winner_client_id?`Winner ${shortID(selected.winner_client_id,12)}`:selected?.paused?'PAUSED':'';map.update(filtered,'',+$('adminmaxnodes').value)}
async function refreshSelected(refreshControls=false){if(!selected){points=[];actions=[];renderMap();if(refreshControls)renderSettings();return}try{const [ps,as]=await Promise.all([api(`/api/admin/tasks/${selected.id}/points?limit=100000`,{},true),api(`/api/admin/tasks/${selected.id}/actions?limit=100`,{},true)]);points=Array.isArray(ps)?ps:[];actions=Array.isArray(as)?as:[];renderMap();if(refreshControls)renderSettings()}catch(e){msg(e.message)}}
async function load(keepMessage=false,refreshControls=false){if(loading)return;loading=true;try{const status=$('statusfilter').value,q=$('taskquery').value,dayStart=new Date();dayStart.setHours(0,0,0,0);const [ts,st,ov,pv,pf,au]=await Promise.all([api(`/api/admin/tasks?status=${encodeURIComponent(status)}&q=${encodeURIComponent(q)}&limit=300`,{},true),api('/api/admin/settings',{},true),api('/api/admin/overview',{},true),api('/api/admin/artifact/providers',{},true),api('/api/admin/performance',{},true),api(`/api/admin/artifact/usage?day_start_ms=${dayStart.getTime()}`,{},true)]);tasks=Array.isArray(ts)?ts:[];settings=st||{};providers=pv||{};artifactUsage=au||{};refreshArtifactUsageTelemetry();renderOverview(ov||{});renderPerformance(pf||{});if(selected){selected=tasks.find(t=>t.id===selected.id)||selected}renderTasks();if(selected)await refreshSelected(refreshControls);else if(refreshControls)renderSettings()}catch(e){if(e.status===401){localStorage.removeItem(adminTokenKey);location.reload();return}msg(e.message||'Laden fehlgeschlagen')}finally{loading=false}}
async function load(keepMessage=false,refreshControls=false){if(loading)return;loading=true;try{const status=$('statusfilter').value,q=$('taskquery').value,dayStart=new Date();dayStart.setHours(0,0,0,0);const [ts,st,ov,pv,pf,au]=await Promise.all([api(`/api/admin/tasks?status=${encodeURIComponent(status)}&q=${encodeURIComponent(q)}&limit=300`,{},true),api('/api/admin/settings',{},true),api('/api/admin/overview',{},true),api('/api/admin/artifact/providers',{},true),api('/api/admin/performance',{},true),api(`/api/admin/artifact/usage?day_start_ms=${dayStart.getTime()}`,{},true)]);tasks=Array.isArray(ts)?ts:[];settings=st||{};providers=pv||{};artifactUsage=au||{};refreshArtifactUsageTelemetry();renderOverview(ov||{});renderPerformance(pf||{});if(selected){selected=tasks.find(t=>t.id===selected.id)||selected}renderTasks();if(selected)await refreshSelected(refreshControls);else if(refreshControls)renderSettings()}catch(e){if(e.status===401){location.reload();return}msg(e.message||'Laden fehlgeschlagen')}finally{loading=false}}
async function openTask(t){captureDraft();selected=t;draft.selectedTaskId=t?.id||'';saveDraft();await refreshSelected(true);renderTasks();if(document.documentElement.classList.contains('mobile-mode'))setAdminPanel('map')}
$('filter').onclick=()=>{saveDraft();load()};$('statusfilter').onchange=()=>{saveDraft();load()};$('taskquery').addEventListener('input',saveDraft);$('taskquery').addEventListener('keydown',e=>{if(e.key==='Enter'){saveDraft();load()}});$('adminmaxnodes').oninput=e=>{$('adminmaxvalue').textContent=Number(e.target.value).toLocaleString('de-DE');renderMap()};$('adminMinScore').oninput=renderMap;$('adminClientFilter').oninput=renderMap;
$('adminProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('adminProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('adminProximity',map.proximityFocus)};$('adminRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('adminRotate',map.autoRotate)};$('adminEdges').onclick=()=>{map.edges=!map.edges;setActive('adminEdges',map.edges)};$('adminShells').onclick=()=>{map.shells=!map.shells;setActive('adminShells',map.shells)};$('adminLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('adminLOD',map.lodEnabled)};$('adminEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('adminEco',map.eco)};$('adminReset').onclick=()=>map.resetView();
$('tabRuntime').onclick=()=>{captureDraft();tab='runtime';saveDraft();renderSettings()};$('tabTask').onclick=()=>{captureDraft();tab='task';saveDraft();renderSettings()};$('tabArtifact').onclick=()=>{captureDraft();tab='artifact';saveDraft();renderSettings()};
$('savesettings').onclick=async()=>{try{captureDraft();const out={...settings};document.querySelectorAll('[data-setting]').forEach(i=>out[i.dataset.setting]=Number(i.value));document.querySelectorAll('[data-setting-string]').forEach(i=>out[i.dataset.settingString]=i.value);settings=await api('/api/admin/settings',{method:'PUT',body:JSON.stringify(out)},true);if(draft.fields)delete draft.fields[draftScope()];saveDraft();msg('gespeichert');renderSettings()}catch(e){msg(e.message)}};
$('ensuretasks').onclick=async()=>{try{await api('/api/admin/tasks/ensure',{method:'POST'},true);msg('aktive Tasks sichergestellt');await load(true,true)}catch(e){msg(e.message)}};
$('adminlogout').onclick=()=>{localStorage.removeItem(adminTokenKey);location.reload()};
$('adminlogout').onclick=async()=>{try{await fetch('/api/admin/logout',{method:'POST',credentials:'same-origin'})}finally{location.reload()}};
$('settingfields').addEventListener('input',captureDraft);$('settingfields').addEventListener('change',captureDraft);if(draft.filters){$('statusfilter').value=draft.filters.status||'';$('taskquery').value=draft.filters.q||''}await load();const first=tasks.find(t=>t.id===draft.selectedTaskId)||(tasks.find(t=>t.status==='active')||tasks[0]);if(first)await openTask(first);else renderSettings();poll=setInterval(()=>load(true,false),3000);addEventListener('beforeunload',()=>{captureDraft();clearInterval(poll);window.removeEventListener('neuralhunt-mobile-mode',syncAdminMobile);map.destroy()},{once:true});
}

View File

@@ -17,15 +17,16 @@ type Event struct {
}
type Client struct {
Conn *websocket.Conn
TaskID string
ClientID string
All bool
send chan []byte
closed chan struct{}
onWrite func(int)
onDrop func()
closeOnce sync.Once
Conn *websocket.Conn
TaskID string
ClientID string
All bool
send chan []byte
closed chan struct{}
onWrite func(int)
onDrop func()
closeOnce sync.Once
dropStreak atomic.Uint32
}
func NewClient(conn *websocket.Conn, taskID, clientID string, all bool) *Client {
@@ -64,11 +65,18 @@ func (c *Client) EnqueueBytes(b []byte) bool {
}
select {
case c.send <- b:
c.dropStreak.Store(0)
return true
default:
if c.onDrop != nil {
c.onDrop()
}
// A persistently slow reader can otherwise hold memory/socket resources
// forever while every broadcast is dropped. Close after a short burst of
// consecutive queue overflows; healthy clients reset the streak on enqueue.
if c.dropStreak.Add(1) >= 8 {
c.Close()
}
return false
}
}