1820 lines
64 KiB
Go
1820 lines
64 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
iofs "io/fs"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
gort "runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"neuralhunt/internal/artifact"
|
|
"neuralhunt/internal/auth"
|
|
"neuralhunt/internal/core"
|
|
"neuralhunt/internal/data"
|
|
rtx "neuralhunt/internal/runtime"
|
|
"neuralhunt/internal/settings"
|
|
"neuralhunt/internal/webui"
|
|
wsx "neuralhunt/internal/ws"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
type Server struct {
|
|
store *data.Store
|
|
auth *auth.Manager
|
|
settings *settings.Manager
|
|
hub *wsx.Hub
|
|
runtime *rtx.State
|
|
artifactWorker *artifact.Worker
|
|
lottery *guessLottery
|
|
adminUser, adminPass, staticDir, artifactDir string
|
|
internalServiceSecret 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 {
|
|
s := &Server{
|
|
store: store,
|
|
auth: a,
|
|
settings: sm,
|
|
hub: hub,
|
|
runtime: runtimeState,
|
|
artifactWorker: artifactWorker,
|
|
lottery: newGuessLottery(func(d beaconDrawAudit) {
|
|
_ = store.RecordBeaconDraw(context.Background(), d.TaskID, d.WindowEnd, d.BeaconID, d.BeaconRound, d.Randomness, d.Signature, d.BoostedPath, d.Tickets, d.Selected)
|
|
}),
|
|
adminUser: env("ADMIN_USER", "admin"),
|
|
adminPass: env("ADMIN_PASSWORD", "change-me"),
|
|
staticDir: env("STATIC_DIR", ""),
|
|
artifactDir: artifactDir,
|
|
internalServiceSecret: strings.TrimSpace(os.Getenv("CUSTOMER_SERVICE_SHARED_SECRET")),
|
|
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
|
|
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
|
|
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
|
|
}
|
|
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 {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return d
|
|
}
|
|
|
|
func jsonOut(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func jsonAPIError(w http.ResponseWriter, status int, code, message string, extra map[string]any) {
|
|
body := map[string]any{"error": message, "code": code}
|
|
for k, v := range extra {
|
|
body[k] = v
|
|
}
|
|
jsonOut(w, status, body)
|
|
}
|
|
|
|
func decode(r *http.Request, v any) error {
|
|
d := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
d.DisallowUnknownFields()
|
|
if err := d.Decode(v); err != nil {
|
|
return err
|
|
}
|
|
// Reject a second JSON value while still allowing insignificant trailing
|
|
// whitespace. This keeps the request format unambiguous.
|
|
var extra any
|
|
if err := d.Decode(&extra); err != io.EOF {
|
|
if err == nil {
|
|
return errors.New("multiple JSON values")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Authentication receives a standards-compliant JWK exported by the browser.
|
|
// Different WebCrypto implementations may add optional JWK members such as
|
|
// alg/use/kid (and future implementations may add more). Those members are not
|
|
// security relevant here: ClientID/PublicKey only consume kty, crv, x and y.
|
|
// Therefore auth payloads intentionally accept unknown nested JWK properties.
|
|
func decodeAuth(r *http.Request, v any) error {
|
|
d := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
if err := d.Decode(v); err != nil {
|
|
return err
|
|
}
|
|
var extra any
|
|
if err := d.Decode(&extra); err != io.EOF {
|
|
if err == nil {
|
|
return errors.New("multiple JSON values")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type ctxKey string
|
|
|
|
const claimsKey ctxKey = "claims"
|
|
|
|
func (s *Server) bearer(r *http.Request) (auth.Claims, error) {
|
|
h := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(h, "Bearer ") {
|
|
return auth.Claims{}, errors.New("missing bearer")
|
|
}
|
|
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) {
|
|
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
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, c)))
|
|
})
|
|
}
|
|
|
|
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/") || p == "/api/internal" || strings.HasPrefix(p, "/api/internal/") {
|
|
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 == "/api/internal" || strings.HasPrefix(p, "/api/internal/") || 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.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)
|
|
})
|
|
})
|
|
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
|
|
r.Get("/api/public/leaderboard", s.publicLeaderboard)
|
|
r.Get("/api/public/artifacts", s.publicArtifacts)
|
|
r.Get("/api/public/beacon/{id}/latest", s.latestBeaconDraw)
|
|
r.Get("/api/public/tasks", s.publicTaskCatalog)
|
|
r.Get("/api/public/artifacts/{id}/preview", s.publicArtifactPreview)
|
|
r.Get("/api/public/tasks/{id}/style-reference", s.publicTaskStyleReference)
|
|
r.Get("/api/leaderboard/ws", s.leaderboardWS)
|
|
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.Post("/api/internal/delegations", s.internalDelegation)
|
|
r.Post("/api/internal/identity-exists", s.internalIdentityExists)
|
|
r.Post("/api/internal/customer-link/consume", s.internalCustomerLinkConsume)
|
|
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)
|
|
r.Get("/api/tasks/current", s.currentTask)
|
|
r.Post("/api/tasks/select", s.selectTask)
|
|
r.Post("/api/tasks/{id}/guess", s.guess)
|
|
r.Get("/api/tasks/{id}/points", s.points)
|
|
r.Get("/api/me", s.me)
|
|
r.Get("/api/me/artifacts", s.myArtifacts)
|
|
r.Get("/api/me/artifacts/{id}/download", s.myArtifactDownload)
|
|
r.Post("/api/me/customer-link", s.customerLinkCode)
|
|
r.Get("/api/leaderboard", s.leaderboard)
|
|
})
|
|
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)
|
|
r.Post("/api/admin/profiles/cleanup", s.adminProfileCleanup)
|
|
r.Get("/api/admin/settings", s.adminSettingsGet)
|
|
r.Put("/api/admin/settings", s.adminSettingsPut)
|
|
r.Get("/api/admin/tasks", s.adminTasks)
|
|
r.Get("/api/admin/tasks/{id}/points", s.adminPoints)
|
|
r.Get("/api/admin/tasks/{id}/actions", s.adminTaskActions)
|
|
r.Put("/api/admin/tasks/{id}/config", s.adminTaskConfigPut)
|
|
r.Post("/api/admin/tasks/{id}/actions", s.adminScheduleAction)
|
|
r.Post("/api/admin/actions/{id}/cancel", s.adminCancelAction)
|
|
r.Get("/api/admin/artifact/providers", s.adminArtifactProviders)
|
|
r.Get("/api/admin/artifact/usage", s.adminArtifactUsage)
|
|
r.Post("/api/admin/artifact/character-anchor", s.adminCreateCharacterAnchor)
|
|
r.Get("/api/admin/artifact/character-anchor", s.adminCharacterAnchorFile)
|
|
r.Put("/api/admin/tasks/{id}/style-reference", s.adminTaskStyleReferencePut)
|
|
r.Delete("/api/admin/tasks/{id}/style-reference", s.adminTaskStyleReferenceDelete)
|
|
r.Get("/api/admin/tasks/{id}/style-reference", s.adminTaskStyleReferenceFile)
|
|
r.Post("/api/admin/tasks/{id}/pipeline-test", s.adminCreatePipelineTestCard)
|
|
r.Get("/api/admin/tasks/{id}/pipeline-test/card", s.adminPipelineTestCardFile)
|
|
r.Get("/api/admin/tasks/{id}/artifact", s.adminArtifactFile)
|
|
r.Get("/api/admin/tasks/{id}/manifest", s.adminArtifactManifest)
|
|
r.Post("/api/admin/tasks/{id}/close", s.adminCloseTask)
|
|
r.Post("/api/admin/tasks/ensure", s.adminEnsure)
|
|
})
|
|
r.Get("/api/ws", s.ws)
|
|
// Original winner artifacts are intentionally not publicly file-served.
|
|
// Public viewers only receive /api/public/artifacts/{id}/preview, which is
|
|
// watermarked. Keep the old namespace as an explicit 404 instead of letting
|
|
// the SPA fallback accidentally return index.html for an artifact URL.
|
|
r.Handle("/artifacts/*", http.NotFoundHandler())
|
|
// The production UI is embedded in the Go binary. STATIC_DIR remains an
|
|
// optional development override, but Node.js/npm are never required to run
|
|
// the application.
|
|
var staticFS iofs.FS
|
|
if s.staticDir != "" {
|
|
if st, err := os.Stat(s.staticDir); err == nil && st.IsDir() {
|
|
staticFS = os.DirFS(s.staticDir)
|
|
log.Printf("serving frontend override from %q", s.staticDir)
|
|
}
|
|
}
|
|
if staticFS == nil {
|
|
embedded, err := iofs.Sub(webui.Dist, "dist")
|
|
if err != nil {
|
|
panic(fmt.Errorf("embedded frontend: %w", err))
|
|
}
|
|
staticFS = embedded
|
|
}
|
|
fileServer := http.FileServer(http.FS(staticFS))
|
|
spa := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
name := strings.TrimPrefix(r.URL.Path, "/")
|
|
if name == "" {
|
|
name = "index.html"
|
|
}
|
|
if info, err := iofs.Stat(staticFS, name); err == nil && !info.IsDir() {
|
|
fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// Browser-side routes such as /admin receive the SPA entry point.
|
|
b, err := iofs.ReadFile(staticFS, "index.html")
|
|
if err != nil {
|
|
http.Error(w, "embedded frontend unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write(b)
|
|
})
|
|
r.Handle("/", spa)
|
|
r.Handle("/*", spa)
|
|
return r
|
|
}
|
|
|
|
func (s *Server) challenge(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
PublicJWK auth.PublicJWK `json:"public_jwk"`
|
|
}
|
|
if err := decodeAuth(r, &in); err != nil {
|
|
log.Printf("auth challenge decode: %v", err)
|
|
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
|
|
return
|
|
}
|
|
cid, err := auth.ClientID(in.PublicJWK)
|
|
if err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
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]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"`
|
|
ProofOfWorkCounter string `json:"proof_of_work_counter"`
|
|
}
|
|
if err := decodeAuth(r, &in); err != nil {
|
|
log.Printf("auth login decode: %v", err)
|
|
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
|
|
return
|
|
}
|
|
cid, err := auth.ClientID(in.PublicJWK)
|
|
if err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
pub, err := auth.PublicKey(in.PublicJWK)
|
|
if err != nil || !auth.VerifyRaw(pub, "login|"+in.Challenge+"|"+cid, in.Signature) {
|
|
jsonOut(w, 401, map[string]string{"error": "invalid signature"})
|
|
return
|
|
}
|
|
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
|
|
}
|
|
if err = s.store.UpsertClient(r.Context(), cid, in.PublicJWK); err != nil {
|
|
log.Printf("auth store client %s: %v", cid, err)
|
|
jsonOut(w, 500, map[string]string{"error": "client store failed"})
|
|
return
|
|
}
|
|
tok, _, err := s.auth.Issue(cid, "user", 24*time.Hour)
|
|
if err != nil {
|
|
log.Printf("auth issue token for %s: %v", cid, err)
|
|
jsonOut(w, 500, map[string]string{"error": "token creation failed"})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]string{"token": tok, "client_id": cid})
|
|
}
|
|
|
|
func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
User string `json:"user"`
|
|
Password string `json:"password"`
|
|
}
|
|
if decode(r, &in) != nil || in.User != s.adminUser || in.Password != s.adminPass {
|
|
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
|
|
return
|
|
}
|
|
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": "session creation failed"})
|
|
return
|
|
}
|
|
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) {
|
|
serverMin := sm.GuessMinIntervalSec
|
|
clientSubmit := sm.ClientSubmitIntervalSec
|
|
if t.GuessMinIntervalSec != nil {
|
|
serverMin = *t.GuessMinIntervalSec
|
|
}
|
|
if t.ClientSubmitIntervalSec != nil {
|
|
clientSubmit = *t.ClientSubmitIntervalSec
|
|
}
|
|
return serverMin, clientSubmit
|
|
}
|
|
|
|
func taskDTO(t data.Task, next int64, sm settings.Runtime) map[string]any {
|
|
serverMin, clientSubmit := taskIntervals(t, sm)
|
|
return map[string]any{
|
|
"id": t.ID,
|
|
"public_seed": t.PublicSeed,
|
|
"range_bits": t.RangeBits,
|
|
"next_seq": next,
|
|
"server_min_interval_sec": serverMin,
|
|
"client_submit_interval_sec": clientSubmit,
|
|
"guess_lottery_window_sec": sm.GuessLotteryWindowSec,
|
|
"guess_lottery_max_accepted": sm.GuessLotteryMaxAccepted,
|
|
"beacon_hunt_enabled": sm.BeaconHuntEnabled,
|
|
"beacon_bonus_weight": sm.BeaconBonusWeight,
|
|
"beacon_paths": beaconPaths,
|
|
"default_max_nodes": sm.DefaultMaxNodes,
|
|
"paused": t.Paused,
|
|
"revision": t.Revision,
|
|
"display_name": t.DisplayName,
|
|
"description": t.Description,
|
|
"parent_task_id": t.ParentTaskID,
|
|
"created_at": t.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func (s *Server) clientTasks(w http.ResponseWriter, r *http.Request) {
|
|
c := claims(r)
|
|
if !s.store.ClientExists(r.Context(), c.ClientID) {
|
|
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "identity not registered in this database"})
|
|
return
|
|
}
|
|
items, err := s.store.ActiveTasksForClient(r.Context(), c.ClientID)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "tasks failed"})
|
|
return
|
|
}
|
|
if len(items) == 0 {
|
|
_ = s.store.EnsureActiveTasks(r.Context(), s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
|
|
items, err = s.store.ActiveTasksForClient(r.Context(), c.ClientID)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "tasks failed"})
|
|
return
|
|
}
|
|
}
|
|
// Keep score disclosure consistent with the regular client endpoints.
|
|
prec := s.settings.Get().PublicScorePrecision
|
|
for i := range items {
|
|
items[i].OwnScore = round(items[i].OwnScore, prec)
|
|
}
|
|
jsonOut(w, 200, items)
|
|
}
|
|
|
|
func (s *Server) selectTask(w http.ResponseWriter, r *http.Request) {
|
|
c := claims(r)
|
|
var in struct {
|
|
TaskID string `json:"task_id"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
|
|
return
|
|
}
|
|
in.TaskID = strings.TrimSpace(in.TaskID)
|
|
if in.TaskID == "" {
|
|
jsonOut(w, 400, map[string]string{"error": "task_id required"})
|
|
return
|
|
}
|
|
if err := s.store.SetClientTaskSelection(r.Context(), c.ClientID, in.TaskID); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
s.runtime.SetTaskSelection(c.ClientID, in.TaskID)
|
|
t, err := s.store.TaskForClient(r.Context(), c.ClientID)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "task selection failed"})
|
|
return
|
|
}
|
|
s.runtime.SetTaskSelection(c.ClientID, t.ID)
|
|
snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID)
|
|
g := s.runtime.InitGuess(t, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount})
|
|
jsonOut(w, 200, taskDTO(t, g.NextSeq, s.settings.Get()))
|
|
}
|
|
|
|
func (s *Server) currentTask(w http.ResponseWriter, r *http.Request) {
|
|
c := claims(r)
|
|
if !s.store.ClientExists(r.Context(), c.ClientID) {
|
|
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "identity not registered in this database"})
|
|
return
|
|
}
|
|
t, err := s.store.TaskForClient(r.Context(), c.ClientID)
|
|
if data.IsNoRows(err) {
|
|
_ = s.store.EnsureActiveTasks(r.Context(), s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
|
|
t, err = s.store.TaskForClient(r.Context(), c.ClientID)
|
|
}
|
|
if err != nil {
|
|
jsonOut(w, 503, map[string]string{"error": "no active task"})
|
|
return
|
|
}
|
|
s.runtime.SetTaskSelection(c.ClientID, t.ID)
|
|
snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID)
|
|
g := s.runtime.InitGuess(t, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount})
|
|
jsonOut(w, 200, taskDTO(t, g.NextSeq, s.settings.Get()))
|
|
}
|
|
|
|
func guessMsg(taskID string, seq int64, guess, beaconPath string, beaconEnabled bool) string {
|
|
if beaconEnabled {
|
|
return fmt.Sprintf("guess|%s|%d|%s|%s", taskID, seq, guess, normalizeBeaconPath(beaconPath))
|
|
}
|
|
return fmt.Sprintf("guess|%s|%d|%s", taskID, seq, guess)
|
|
}
|
|
|
|
func round(v float64, p int) float64 {
|
|
m := math.Pow10(p)
|
|
return math.Round(v*m) / m
|
|
}
|
|
|
|
func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
|
c := claims(r)
|
|
id := chi.URLParam(r, "id")
|
|
if selected := s.runtime.TaskSelection(c.ClientID); selected != id {
|
|
durable, err := s.store.SelectedTaskID(r.Context(), c.ClientID)
|
|
if err != nil || durable != id {
|
|
jsonAPIError(w, http.StatusConflict, "selection_conflict", "task selection changed", nil)
|
|
return
|
|
}
|
|
s.runtime.SetTaskSelection(c.ClientID, id)
|
|
}
|
|
if !s.runtime.HasPresence(c.ClientID, c.SessionID) {
|
|
jsonAPIError(w, http.StatusConflict, "presence_required", "live connection lost; reconnect websocket", nil)
|
|
return
|
|
}
|
|
var in struct {
|
|
Seq int64 `json:"seq"`
|
|
Guess string `json:"guess"`
|
|
Signature string `json:"signature"`
|
|
BeaconPath string `json:"beacon_path,omitempty"`
|
|
}
|
|
if decode(r, &in) != nil {
|
|
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)
|
|
return
|
|
}
|
|
if t.Paused {
|
|
jsonOut(w, 423, false)
|
|
return
|
|
}
|
|
if core.ExpectedGuess(id, t.PublicSeed, c.ClientID, in.Seq, t.RangeBits) != in.Guess {
|
|
// This most commonly means an admin rerolled/changed the task between the
|
|
// client's config read and its submit. Treat it as recoverable config drift.
|
|
jsonAPIError(w, http.StatusConflict, "task_config_changed", "task configuration changed; resync required", map[string]any{"revision": t.Revision})
|
|
return
|
|
}
|
|
jwk, err := s.store.ClientPublicJWK(r.Context(), c.ClientID)
|
|
if err != nil {
|
|
jsonOut(w, 401, false)
|
|
return
|
|
}
|
|
pub, _ := auth.PublicKey(jwk)
|
|
beaconEnabled := s.settings.Get().BeaconHuntEnabled == 1 && s.settings.Get().GuessLotteryMaxAccepted > 0
|
|
if beaconEnabled && normalizeBeaconPath(in.BeaconPath) == "" {
|
|
jsonAPIError(w, http.StatusBadRequest, "beacon_path_required", "choose PULSE, FLUX or ORBIT before entering the draw", map[string]any{"paths": beaconPaths})
|
|
return
|
|
}
|
|
if pub == nil || !auth.VerifyRaw(pub, guessMsg(id, in.Seq, in.Guess, in.BeaconPath, beaconEnabled), in.Signature) {
|
|
jsonOut(w, 401, false)
|
|
return
|
|
}
|
|
if _, ok := s.runtime.Current(t.Task, c.ClientID); !ok {
|
|
snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID)
|
|
s.runtime.InitGuess(t.Task, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount})
|
|
}
|
|
cfg := s.settings.Get()
|
|
serverMin, _ := taskIntervals(t.Task, cfg)
|
|
minInterval := time.Duration(serverMin) * time.Second
|
|
if err := s.runtime.CanSubmit(t.Task, c.ClientID, in.Seq, minInterval); err != nil {
|
|
switch {
|
|
case errors.Is(err, rtx.ErrRateLimited):
|
|
jsonOut(w, 429, false)
|
|
case errors.Is(err, rtx.ErrBadSequence):
|
|
expected := int64(0)
|
|
if cur, ok := s.runtime.Current(t.Task, c.ClientID); ok {
|
|
expected = cur.NextSeq
|
|
}
|
|
jsonAPIError(w, http.StatusConflict, "sequence_mismatch", "guess sequence is stale", map[string]any{"next_seq": expected})
|
|
default:
|
|
jsonOut(w, 500, false)
|
|
}
|
|
return
|
|
}
|
|
|
|
var draw lotteryResult
|
|
if cfg.GuessLotteryMaxAccepted > 0 {
|
|
drawResult, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, in.BeaconPath, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted, cfg.BeaconHuntEnabled == 1, cfg.BeaconBonusWeight)
|
|
draw = drawResult
|
|
if drawErr != nil {
|
|
switch {
|
|
case errors.Is(drawErr, errLotteryDuplicate):
|
|
jsonAPIError(w, http.StatusConflict, "lottery_duplicate", "guess is already waiting for the current draw", nil)
|
|
case errors.Is(drawErr, errLotteryFull):
|
|
jsonAPIError(w, http.StatusTooManyRequests, "lottery_full", "guess lottery window is full", nil)
|
|
case errors.Is(drawErr, errBeaconUnavailable):
|
|
jsonAPIError(w, http.StatusServiceUnavailable, "beacon_unavailable", "external randomness beacon is temporarily unavailable; ticket was not evaluated", nil)
|
|
case errors.Is(drawErr, context.Canceled), errors.Is(drawErr, context.DeadlineExceeded):
|
|
return
|
|
default:
|
|
jsonOut(w, 500, false)
|
|
}
|
|
return
|
|
}
|
|
|
|
// A task may have been rerolled, paused or completed while this request
|
|
// waited for the draw. Never consume/evaluate a stale lottery ticket.
|
|
fresh, freshErr := s.store.SecretTask(r.Context(), id)
|
|
if freshErr != nil || fresh.Status != "active" {
|
|
jsonAPIError(w, http.StatusConflict, "task_inactive", "task is no longer active", nil)
|
|
return
|
|
}
|
|
if fresh.Paused {
|
|
jsonOut(w, 423, false)
|
|
return
|
|
}
|
|
if fresh.PublicSeed != t.PublicSeed || fresh.Revision != t.Revision {
|
|
jsonAPIError(w, http.StatusConflict, "task_config_changed", "task configuration changed during lottery; resync required", map[string]any{"revision": fresh.Revision})
|
|
return
|
|
}
|
|
t = fresh
|
|
if !draw.Selected {
|
|
next, skipErr := s.runtime.SkipLottery(t.Task, c.ClientID, in.Seq, minInterval)
|
|
if skipErr != nil {
|
|
if errors.Is(skipErr, rtx.ErrBadSequence) {
|
|
jsonAPIError(w, http.StatusConflict, "sequence_mismatch", "guess sequence changed while waiting for lottery", map[string]any{"next_seq": next})
|
|
} else if errors.Is(skipErr, rtx.ErrRateLimited) {
|
|
jsonOut(w, 429, false)
|
|
} else {
|
|
jsonOut(w, 500, false)
|
|
}
|
|
return
|
|
}
|
|
extra := map[string]any{"next_seq": next}
|
|
if draw.BeaconEnabled {
|
|
extra["chosen_path"] = draw.ChosenPath
|
|
extra["boosted_path"] = draw.BoostedPath
|
|
extra["beacon_round"] = draw.BeaconRound
|
|
extra["beacon_id"] = draw.BeaconID
|
|
extra["weight"] = draw.Weight
|
|
}
|
|
jsonAPIError(w, http.StatusTooManyRequests, "lottery_not_selected", "guess was not selected in this lottery window", extra)
|
|
return
|
|
}
|
|
}
|
|
|
|
d, err := core.Distance(in.Guess, t.Secret)
|
|
if err != nil {
|
|
jsonOut(w, 400, false)
|
|
return
|
|
}
|
|
correct := d.Sign() == 0
|
|
score := core.Score(d, t.RangeBits)
|
|
accepted, err := s.runtime.Accept(t.Task, c.ClientID, in.Seq, score, minInterval)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, rtx.ErrRateLimited):
|
|
jsonOut(w, 429, false)
|
|
case errors.Is(err, rtx.ErrBadSequence):
|
|
expected := int64(0)
|
|
if cur, ok := s.runtime.Current(t.Task, c.ClientID); ok {
|
|
expected = cur.NextSeq
|
|
}
|
|
jsonAPIError(w, http.StatusConflict, "sequence_mismatch", "guess sequence is stale", map[string]any{"next_seq": expected})
|
|
default:
|
|
jsonOut(w, 500, false)
|
|
}
|
|
return
|
|
}
|
|
// Losing tips are intentionally ephemeral: no SQLite write and no websocket event.
|
|
if accepted.Improved || correct {
|
|
rewardOwner := c.ClientID
|
|
if correct {
|
|
rewardOwner = s.store.RewardOwnerForWorker(r.Context(), c.ClientID)
|
|
}
|
|
beaconPath, beaconBoost, beaconRound := "", "", uint64(0)
|
|
if correct && draw.BeaconEnabled {
|
|
beaconPath, beaconBoost, beaconRound = draw.ChosenPath, draw.BoostedPath, draw.BeaconRound
|
|
}
|
|
p, err := s.store.PersistImprovement(r.Context(), t, c.ClientID, rewardOwner, accepted.State.NextSeq, accepted.State.GuessCount, accepted.State.LastGuess, accepted.State.BestScore, in.Guess, in.Signature, correct, beaconPath, beaconBoost, beaconRound)
|
|
if err != nil {
|
|
s.runtime.Restore(t.Task, c.ClientID, accepted.State.NextSeq, accepted.Previous)
|
|
switch {
|
|
case errors.Is(err, data.ErrTaskCompleted):
|
|
jsonAPIError(w, http.StatusConflict, "task_inactive", "task completed while submitting", nil)
|
|
case errors.Is(err, data.ErrTaskPaused):
|
|
jsonOut(w, 423, false)
|
|
default:
|
|
jsonOut(w, 500, false)
|
|
}
|
|
return
|
|
}
|
|
s.runtime.MarkSQLiteWrite()
|
|
p.Score = round(p.Score, s.settings.Get().PublicScorePrecision)
|
|
s.hub.PublishPoint(id, c.ClientID, p)
|
|
}
|
|
if correct {
|
|
rewardOwner := s.store.RewardOwnerForWorker(r.Context(), c.ClientID)
|
|
dataOut := map[string]string{"winner_client_id": rewardOwner, "winner_worker_client_id": c.ClientID}
|
|
if successor, succErr := s.store.EnsureSuccessorTask(r.Context(), id, s.settings.Get().TaskRangeBits); succErr == nil {
|
|
dataOut["successor_task_id"] = successor.ID
|
|
s.runtime.ReplaceTaskSelection(id, successor.ID)
|
|
} else {
|
|
log.Printf("successor for %s: %v", id, succErr)
|
|
}
|
|
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_completed", TaskID: id, Data: dataOut})
|
|
_ = s.store.EnsureActiveTasks(r.Context(), s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
|
|
}
|
|
if draw.BeaconEnabled {
|
|
w.Header().Set("X-NeuralHunt-Beacon-Path", draw.BoostedPath)
|
|
w.Header().Set("X-NeuralHunt-Beacon-Round", strconv.FormatUint(draw.BeaconRound, 10))
|
|
}
|
|
jsonOut(w, 200, correct)
|
|
}
|
|
|
|
func serviceTokenOK(secret, header string) bool {
|
|
provided := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
|
return secret != "" && len(secret) == len(provided) && subtle.ConstantTimeCompare([]byte(secret), []byte(provided)) == 1
|
|
}
|
|
|
|
func customerLinkHash(code string) string {
|
|
h := sha256.Sum256([]byte("nh-customer-link-v1|" + strings.TrimSpace(code)))
|
|
return fmt.Sprintf("%x", h[:])
|
|
}
|
|
|
|
// customerLinkCode issues a short-lived one-shot proof that the authenticated
|
|
// browser/CLI controls this exact P-256 identity. Customer Service redeems the
|
|
// code over the private 8081 control plane; the private key never leaves the
|
|
// owner device.
|
|
func (s *Server) customerLinkCode(w http.ResponseWriter, r *http.Request) {
|
|
c := claims(r)
|
|
b := make([]byte, 24)
|
|
if _, err := rand.Read(b); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "could not create pairing code"})
|
|
return
|
|
}
|
|
code := "nhlink_" + base64.RawURLEncoding.EncodeToString(b)
|
|
expires := time.Now().UTC().Add(10 * time.Minute)
|
|
if err := s.store.CreateCustomerLinkToken(r.Context(), customerLinkHash(code), c.ClientID, expires); err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "could not store pairing code"})
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
jsonOut(w, 201, map[string]any{"code": code, "client_id": c.ClientID, "expires_at": expires})
|
|
}
|
|
|
|
func (s *Server) internalCustomerLinkConsume(w http.ResponseWriter, r *http.Request) {
|
|
secret := strings.TrimSpace(s.internalServiceSecret)
|
|
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
|
|
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
var in struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := decode(r, &in); err != nil || !strings.HasPrefix(strings.TrimSpace(in.Code), "nhlink_") {
|
|
jsonOut(w, 400, map[string]string{"error": "valid pairing code required"})
|
|
return
|
|
}
|
|
cid, err := s.store.ConsumeCustomerLinkToken(r.Context(), customerLinkHash(in.Code))
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "pairing code expired, invalid, or already used"})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]string{"client_id": cid})
|
|
}
|
|
|
|
func (s *Server) internalIdentityExists(w http.ResponseWriter, r *http.Request) {
|
|
secret := strings.TrimSpace(s.internalServiceSecret)
|
|
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
|
|
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
var in struct {
|
|
ClientID string `json:"client_id"`
|
|
}
|
|
if err := decode(r, &in); err != nil || strings.TrimSpace(in.ClientID) == "" {
|
|
jsonOut(w, 400, map[string]string{"error": "client_id required"})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]bool{"exists": s.store.ClientExists(r.Context(), strings.TrimSpace(in.ClientID))})
|
|
}
|
|
|
|
func (s *Server) internalDelegation(w http.ResponseWriter, r *http.Request) {
|
|
secret := strings.TrimSpace(s.internalServiceSecret)
|
|
if !serviceTokenOK(secret, r.Header.Get("Authorization")) {
|
|
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
return
|
|
}
|
|
var in struct {
|
|
WorkerClientID string `json:"worker_client_id"`
|
|
OwnerClientID string `json:"owner_client_id"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
|
|
return
|
|
}
|
|
if err := s.store.SetIdentityDelegation(r.Context(), in.WorkerClientID, in.OwnerClientID); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]any{"ok": true, "worker_client_id": in.WorkerClientID, "owner_client_id": in.OwnerClientID})
|
|
}
|
|
|
|
func (s *Server) publicTaskCatalog(w http.ResponseWriter, r *http.Request) {
|
|
items, err := s.store.ActiveTasksForClient(r.Context(), "")
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "tasks failed"})
|
|
return
|
|
}
|
|
cfg := s.settings.Get()
|
|
out := make([]map[string]any, 0, len(items))
|
|
for _, t := range items {
|
|
out = append(out, map[string]any{
|
|
"id": t.ID, "display_name": t.DisplayName, "description": t.Description,
|
|
"range_bits": t.RangeBits, "paused": t.Paused,
|
|
"guess_lottery_window_sec": cfg.GuessLotteryWindowSec,
|
|
"guess_lottery_max_accepted": cfg.GuessLotteryMaxAccepted,
|
|
"beacon_hunt_enabled": cfg.BeaconHuntEnabled,
|
|
"beacon_bonus_weight": cfg.BeaconBonusWeight,
|
|
"beacon_paths": beaconPaths,
|
|
"style_reference_uri": "/api/public/tasks/" + url.PathEscape(t.ID) + "/style-reference",
|
|
})
|
|
}
|
|
jsonOut(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) latestBeaconDraw(w http.ResponseWriter, r *http.Request) {
|
|
taskID := chi.URLParam(r, "id")
|
|
d, err := s.store.LatestBeaconDraw(r.Context(), taskID)
|
|
if err != nil {
|
|
if data.IsNoRows(err) {
|
|
jsonOut(w, 404, map[string]string{"error": "no beacon draw yet"})
|
|
return
|
|
}
|
|
jsonOut(w, 500, map[string]string{"error": "beacon draw lookup failed"})
|
|
return
|
|
}
|
|
jsonOut(w, 200, d)
|
|
}
|
|
|
|
func (s *Server) points(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
if limit <= 0 {
|
|
limit = s.settings.Get().DefaultMaxNodes * 3
|
|
}
|
|
if limit > 10000 {
|
|
limit = 10000
|
|
}
|
|
ps, err := s.store.PointsForClient(r.Context(), chi.URLParam(r, "id"), claims(r).ClientID, limit)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "points failed"})
|
|
return
|
|
}
|
|
for i := range ps {
|
|
ps[i].Score = round(ps[i].Score, s.settings.Get().PublicScorePrecision)
|
|
}
|
|
jsonOut(w, 200, ps)
|
|
}
|
|
|
|
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|
c := claims(r)
|
|
// A JWT can outlive a replaced/empty SQLite database. Do not treat such a
|
|
// token as a valid registered browser identity; force challenge/login again
|
|
// so the public key is upserted into this database.
|
|
if !s.store.ClientExists(r.Context(), c.ClientID) {
|
|
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "identity not registered in this database"})
|
|
return
|
|
}
|
|
t, err := s.store.TaskForClient(r.Context(), c.ClientID)
|
|
if err != nil {
|
|
jsonOut(w, 200, data.Me{ClientID: c.ClientID, Unlocks: []string{}})
|
|
return
|
|
}
|
|
m, _ := s.store.Me(r.Context(), c.ClientID, t.ID)
|
|
m.Score = round(m.Score, s.settings.Get().PublicScorePrecision)
|
|
jsonOut(w, 200, m)
|
|
}
|
|
|
|
func (s *Server) myArtifacts(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
items, err := s.store.OwnedArtifacts(r.Context(), claims(r).ClientID, limit)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "owned artifacts failed"})
|
|
return
|
|
}
|
|
jsonOut(w, 200, items)
|
|
}
|
|
|
|
func (s *Server) myArtifactDownload(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if id == "" {
|
|
jsonOut(w, 400, map[string]string{"error": "task id required"})
|
|
return
|
|
}
|
|
uri, ok, err := s.store.OwnedArtifactSource(r.Context(), id, claims(r).ClientID)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "artifact lookup failed"})
|
|
return
|
|
}
|
|
if !ok {
|
|
// Deliberately use 404 rather than revealing that another identity owns it.
|
|
jsonOut(w, 404, map[string]string{"error": "artifact not found"})
|
|
return
|
|
}
|
|
path, err := artifactLocalPath(s.artifactDir, uri)
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "artifact file unavailable"})
|
|
return
|
|
}
|
|
ext := filepath.Ext(path)
|
|
if ext == "" {
|
|
ext = ".bin"
|
|
}
|
|
name := "neuralhunt-" + id + ext
|
|
w.Header().Set("Cache-Control", "private, no-store")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, name))
|
|
http.ServeFile(w, r, path)
|
|
}
|
|
|
|
func (s *Server) leaderboard(w http.ResponseWriter, r *http.Request) {
|
|
l, err := s.store.Leaderboard(r.Context(), 100)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "leaderboard failed"})
|
|
return
|
|
}
|
|
for i := range l {
|
|
l[i].Connected = s.runtime.IsConnected(l[i].ClientID)
|
|
}
|
|
jsonOut(w, 200, l)
|
|
}
|
|
|
|
func (s *Server) publicLeaderboard(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
mode := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("mode")))
|
|
var (
|
|
l []data.Leader
|
|
err error
|
|
)
|
|
if mode == "live" {
|
|
l, err = s.store.LiveLeaderboard(r.Context(), limit)
|
|
} else {
|
|
l, err = s.store.Leaderboard(r.Context(), limit)
|
|
}
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "leaderboard failed"})
|
|
return
|
|
}
|
|
prec := s.settings.Get().PublicScorePrecision
|
|
for i := range l {
|
|
l[i].BestScore = round(l[i].BestScore, prec)
|
|
l[i].LiveScore = round(l[i].LiveScore, prec)
|
|
l[i].Connected = s.runtime.IsConnected(l[i].ClientID)
|
|
}
|
|
jsonOut(w, 200, l)
|
|
}
|
|
|
|
func (s *Server) publicArtifacts(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
winner := strings.TrimSpace(r.URL.Query().Get("winner"))
|
|
items, err := s.store.PublicArtifacts(r.Context(), limit, winner)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "artifact gallery failed"})
|
|
return
|
|
}
|
|
jsonOut(w, 200, items)
|
|
}
|
|
|
|
func (s *Server) publicArtifactPreview(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
if id == "" {
|
|
jsonOut(w, 400, map[string]string{"error": "task id required"})
|
|
return
|
|
}
|
|
artifactURI, _, ok, err := s.store.PublicArtifactSource(r.Context(), id)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "artifact lookup failed"})
|
|
return
|
|
}
|
|
if !ok {
|
|
jsonOut(w, 404, map[string]string{"error": "artifact not found"})
|
|
return
|
|
}
|
|
path, err := artifactLocalPath(s.artifactDir, artifactURI)
|
|
if err != nil {
|
|
log.Printf("artifact preview path %s: %v", id, err)
|
|
jsonOut(w, 404, map[string]string{"error": "artifact file unavailable"})
|
|
return
|
|
}
|
|
preview, contentType, err := watermarkPreviewFile(path, "NEURAL HUNT PREVIEW")
|
|
if err != nil {
|
|
log.Printf("artifact preview render %s: %v", id, err)
|
|
jsonOut(w, 500, map[string]string{"error": "preview generation failed"})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
w.Header().Set("X-Neural-Hunt-Watermark", "leaderboard-preview")
|
|
w.Header().Set("Content-Disposition", "inline")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(preview)
|
|
}
|
|
|
|
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
|
|
}
|
|
cl := wsx.NewClient(conn, "", "leaderboard", true)
|
|
s.hub.Add(cl)
|
|
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 {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-ping.C:
|
|
if err := cl.Ping(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) adminArtifactFile(w http.ResponseWriter, r *http.Request) {
|
|
s.serveAdminArtifactPart(w, r, false)
|
|
}
|
|
|
|
func (s *Server) adminArtifactManifest(w http.ResponseWriter, r *http.Request) {
|
|
s.serveAdminArtifactPart(w, r, true)
|
|
}
|
|
|
|
func (s *Server) serveAdminArtifactPart(w http.ResponseWriter, r *http.Request, manifest bool) {
|
|
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
|
imageURI, manifestURI, ok, err := s.store.TaskArtifactURIs(r.Context(), id)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "artifact lookup failed"})
|
|
return
|
|
}
|
|
if !ok {
|
|
jsonOut(w, 404, map[string]string{"error": "artifact not found"})
|
|
return
|
|
}
|
|
uri := imageURI
|
|
if manifest {
|
|
uri = manifestURI
|
|
if uri == "" {
|
|
jsonOut(w, 404, map[string]string{"error": "manifest not found"})
|
|
return
|
|
}
|
|
}
|
|
path, err := artifactLocalPath(s.artifactDir, uri)
|
|
if err != nil {
|
|
jsonOut(w, 404, map[string]string{"error": "artifact file unavailable"})
|
|
return
|
|
}
|
|
if manifest {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `inline; filename="manifest.json"`)
|
|
} else {
|
|
w.Header().Set("Content-Disposition", `inline`)
|
|
}
|
|
w.Header().Set("Cache-Control", "private, no-store")
|
|
http.ServeFile(w, r, path)
|
|
}
|
|
|
|
type profileCleanupPreview struct {
|
|
CutoffMS int64 `json:"cutoff_ms"`
|
|
InactiveForSeconds int64 `json:"inactive_for_seconds"`
|
|
Eligible int `json:"eligible"`
|
|
ProtectedWinners int64 `json:"protected_winners"`
|
|
ProtectedConnected int `json:"protected_connected"`
|
|
OldestEligibleMS int64 `json:"oldest_eligible_ms,omitempty"`
|
|
NewestEligibleMS int64 `json:"newest_eligible_ms,omitempty"`
|
|
}
|
|
|
|
func profileCleanupDurationSeconds(raw string) (int64, error) {
|
|
seconds, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
|
if err != nil {
|
|
return 0, errors.New("inactive_for_seconds must be an integer")
|
|
}
|
|
// A one-hour minimum prevents an accidental near-live purge while still
|
|
// allowing short-lived development/test deployments to clean up quickly.
|
|
if seconds < 3600 || seconds > 10*365*24*60*60 {
|
|
return 0, errors.New("inactive_for_seconds must be between 3600 seconds and 10 years")
|
|
}
|
|
return seconds, nil
|
|
}
|
|
|
|
func (s *Server) profileCleanupPreview(ctx context.Context, inactiveForSeconds int64) (profileCleanupPreview, []string, error) {
|
|
cutoff := time.Now().UTC().Add(-time.Duration(inactiveForSeconds) * time.Second).UnixMilli()
|
|
candidates, err := s.store.InactiveNonWinnerClients(ctx, cutoff)
|
|
if err != nil {
|
|
return profileCleanupPreview{}, nil, err
|
|
}
|
|
protectedWinners, err := s.store.OldWinnerCount(ctx, cutoff)
|
|
if err != nil {
|
|
return profileCleanupPreview{}, nil, err
|
|
}
|
|
ids := make([]string, 0, len(candidates))
|
|
out := profileCleanupPreview{CutoffMS: cutoff, InactiveForSeconds: inactiveForSeconds, ProtectedWinners: protectedWinners}
|
|
for _, c := range candidates {
|
|
if s.runtime.IsConnected(c.ClientID) {
|
|
out.ProtectedConnected++
|
|
continue
|
|
}
|
|
ids = append(ids, c.ClientID)
|
|
if out.OldestEligibleMS == 0 || c.LastSeen < out.OldestEligibleMS {
|
|
out.OldestEligibleMS = c.LastSeen
|
|
}
|
|
if c.LastSeen > out.NewestEligibleMS {
|
|
out.NewestEligibleMS = c.LastSeen
|
|
}
|
|
}
|
|
out.Eligible = len(ids)
|
|
return out, ids, nil
|
|
}
|
|
|
|
func (s *Server) adminProfileCleanupPreview(w http.ResponseWriter, r *http.Request) {
|
|
seconds, err := profileCleanupDurationSeconds(r.URL.Query().Get("inactive_for_seconds"))
|
|
if err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
preview, _, err := s.profileCleanupPreview(r.Context(), seconds)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "profile cleanup preview failed: " + err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, preview)
|
|
}
|
|
|
|
func (s *Server) adminProfileCleanup(w http.ResponseWriter, r *http.Request) {
|
|
var in struct {
|
|
InactiveForSeconds int64 `json:"inactive_for_seconds"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
|
|
return
|
|
}
|
|
seconds, err := profileCleanupDurationSeconds(strconv.FormatInt(in.InactiveForSeconds, 10))
|
|
if err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
preview, ids, err := s.profileCleanupPreview(r.Context(), seconds)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "profile cleanup check failed: " + err.Error()})
|
|
return
|
|
}
|
|
deleted, err := s.store.DeleteInactiveNonWinnerClients(r.Context(), preview.CutoffMS, ids)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": "profile cleanup failed: " + err.Error()})
|
|
return
|
|
}
|
|
for _, id := range deleted {
|
|
s.runtime.ForgetClient(id)
|
|
}
|
|
jsonOut(w, 200, map[string]any{
|
|
"deleted": len(deleted),
|
|
"eligible_before": preview.Eligible,
|
|
"protected_winners": preview.ProtectedWinners,
|
|
"protected_connected": preview.ProtectedConnected,
|
|
"cutoff_ms": preview.CutoffMS,
|
|
"inactive_for_seconds": seconds,
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
|
|
var clients, activeTasks, completedTasks, guesses, artifacts int64
|
|
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*) FROM clients`).Scan(&clients)
|
|
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*) FROM tasks WHERE status='active'`).Scan(&activeTasks)
|
|
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*) FROM tasks WHERE status='completed'`).Scan(&completedTasks)
|
|
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT COALESCE(sum(guess_count),0) FROM task_points`).Scan(&guesses)
|
|
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*) FROM tasks WHERE artifact_status='ready'`).Scan(&artifacts)
|
|
connected := s.runtime.ConnectedCount()
|
|
jsonOut(w, 200, map[string]int64{"clients": clients, "connected": connected, "active_tasks": activeTasks, "completed_tasks": completedTasks, "guesses": guesses, "artifacts_ready": artifacts})
|
|
}
|
|
|
|
func (s *Server) adminPerformance(w http.ResponseWriter, r *http.Request) {
|
|
rm := s.runtime.Metrics()
|
|
wm := s.hub.Metrics()
|
|
var ms gort.MemStats
|
|
gort.ReadMemStats(&ms)
|
|
jsonOut(w, 200, map[string]any{
|
|
"runtime": rm,
|
|
"websocket": wm,
|
|
"process": map[string]any{
|
|
"goroutines": gort.NumGoroutine(),
|
|
"heap_bytes": ms.HeapAlloc,
|
|
"heap_objects": ms.HeapObjects,
|
|
"gc_cycles": ms.NumGC,
|
|
},
|
|
})
|
|
}
|
|
func (s *Server) adminSettingsGet(w http.ResponseWriter, r *http.Request) {
|
|
jsonOut(w, 200, s.settings.Get())
|
|
}
|
|
|
|
func (s *Server) adminSettingsPut(w http.ResponseWriter, r *http.Request) {
|
|
var v settings.Runtime
|
|
if decode(r, &v) != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json"})
|
|
return
|
|
}
|
|
if err := s.settings.Update(r.Context(), v); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, v)
|
|
}
|
|
|
|
func (s *Server) adminTasks(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
ts, err := s.store.AdminTasks(r.Context(), r.URL.Query().Get("status"), r.URL.Query().Get("q"), limit)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, ts)
|
|
}
|
|
|
|
func (s *Server) adminTaskConfigPut(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var in struct {
|
|
DisplayName string `json:"display_name"`
|
|
Description string `json:"description"`
|
|
NFTPromptInstructions string `json:"nft_prompt_instructions"`
|
|
NFTNegativePrompt string `json:"nft_negative_prompt"`
|
|
}
|
|
if err := decode(r, &in); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
|
|
return
|
|
}
|
|
if err := s.store.UpdateTaskConfig(r.Context(), id, in.DisplayName, in.Description, in.NFTPromptInstructions, in.NFTNegativePrompt); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "config"}})
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Server) adminPoints(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
ps, err := s.store.Points(r.Context(), chi.URLParam(r, "id"), limit)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, ps)
|
|
}
|
|
|
|
func (s *Server) adminTaskActions(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
actions, err := s.store.TaskActions(r.Context(), chi.URLParam(r, "id"), limit)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, actions)
|
|
}
|
|
|
|
type scheduleActionRequest struct {
|
|
ActionType string `json:"action_type"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
ExecuteAt *time.Time `json:"execute_at"`
|
|
}
|
|
|
|
func validateAction(actionType string, raw json.RawMessage) error {
|
|
actionType = strings.ToLower(strings.TrimSpace(actionType))
|
|
switch actionType {
|
|
case "set_range_bits":
|
|
var p struct {
|
|
Bits int `json:"bits"`
|
|
Mode string `json:"mode"`
|
|
}
|
|
if len(raw) == 0 || json.Unmarshal(raw, &p) != nil {
|
|
return errors.New("set_range_bits requires payload {bits,mode}")
|
|
}
|
|
if p.Bits < 8 || p.Bits > 128 {
|
|
return errors.New("bits must be 8..128")
|
|
}
|
|
if p.Mode != "preserve" && p.Mode != "reroll" {
|
|
return errors.New("mode must be preserve or reroll")
|
|
}
|
|
case "set_intervals":
|
|
var p struct {
|
|
Server int `json:"server_min_interval_sec"`
|
|
Client int `json:"client_submit_interval_sec"`
|
|
}
|
|
if len(raw) == 0 || json.Unmarshal(raw, &p) != nil {
|
|
return errors.New("set_intervals requires interval payload")
|
|
}
|
|
if p.Server < 1 || p.Server > 3600 || p.Client <= p.Server || p.Client > 7200 {
|
|
return errors.New("intervals require server 1..3600 and client > server <=7200")
|
|
}
|
|
case "pause", "resume", "reroll", "clear_intervals", "close", "regenerate_artifact":
|
|
// no payload required
|
|
default:
|
|
return fmt.Errorf("unsupported action_type %q", actionType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) adminScheduleAction(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
var in scheduleActionRequest
|
|
if err := decode(r, &in); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
|
|
return
|
|
}
|
|
in.ActionType = strings.ToLower(strings.TrimSpace(in.ActionType))
|
|
if err := validateAction(in.ActionType, in.Payload); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
executeAt := time.Now().UTC()
|
|
if in.ExecuteAt != nil {
|
|
executeAt = in.ExecuteAt.UTC()
|
|
}
|
|
if executeAt.Before(time.Now().Add(-2 * time.Minute)) {
|
|
jsonOut(w, 400, map[string]string{"error": "execute_at is in the past"})
|
|
return
|
|
}
|
|
if executeAt.After(time.Now().Add(366 * 24 * time.Hour)) {
|
|
jsonOut(w, 400, map[string]string{"error": "execute_at is more than one year away"})
|
|
return
|
|
}
|
|
payload := any(map[string]any{})
|
|
if len(in.Payload) > 0 {
|
|
var x any
|
|
if err := json.Unmarshal(in.Payload, &x); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": "invalid payload"})
|
|
return
|
|
}
|
|
payload = x
|
|
}
|
|
a, err := s.store.ScheduleTaskAction(r.Context(), id, in.ActionType, payload, executeAt)
|
|
if err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
// "Run now" feels immediate in the admin UI while still going through the
|
|
// same persisted action/audit path as scheduled changes.
|
|
if !executeAt.After(time.Now().Add(1500 * time.Millisecond)) {
|
|
s.runTaskAction(r.Context(), a)
|
|
actions, _ := s.store.TaskActions(r.Context(), id, 1)
|
|
if len(actions) > 0 {
|
|
a = actions[0]
|
|
}
|
|
}
|
|
jsonOut(w, 200, a)
|
|
}
|
|
|
|
func (s *Server) adminCancelAction(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.store.CancelTaskAction(r.Context(), chi.URLParam(r, "id")); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Server) adminArtifactProviders(w http.ResponseWriter, r *http.Request) {
|
|
cfg := s.settings.Get()
|
|
anchorPath := filepath.Join(s.artifactDir, "_collection", "character_anchor.png")
|
|
anchorReady := false
|
|
if st, err := os.Stat(anchorPath); err == nil && st.Size() > 1024 {
|
|
anchorReady = true
|
|
}
|
|
jsonOut(w, 200, map[string]any{
|
|
"current": cfg.ArtifactProvider,
|
|
"preset": cfg.ArtifactPreset,
|
|
"model": cfg.ArtifactModel,
|
|
"character_anchor": anchorReady,
|
|
"providers": map[string]bool{
|
|
"local": true,
|
|
"openai": strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "",
|
|
"comfyui": strings.TrimSpace(os.Getenv("COMFYUI_URL")) != "" && strings.TrimSpace(os.Getenv("COMFYUI_WORKFLOW_PATH")) != "",
|
|
"a1111": strings.TrimSpace(os.Getenv("A1111_URL")) != "",
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) executeTaskAction(ctx context.Context, a data.TaskAction) error {
|
|
switch a.ActionType {
|
|
case "set_range_bits":
|
|
var p struct {
|
|
Bits int `json:"bits"`
|
|
Mode string `json:"mode"`
|
|
}
|
|
if err := json.Unmarshal(a.Payload, &p); err != nil {
|
|
return err
|
|
}
|
|
return s.store.SetTaskRangeBits(ctx, a.TaskID, p.Bits, p.Mode)
|
|
case "set_intervals":
|
|
var p struct {
|
|
Server int `json:"server_min_interval_sec"`
|
|
Client int `json:"client_submit_interval_sec"`
|
|
}
|
|
if err := json.Unmarshal(a.Payload, &p); err != nil {
|
|
return err
|
|
}
|
|
return s.store.SetTaskIntervals(ctx, a.TaskID, p.Server, p.Client)
|
|
case "clear_intervals":
|
|
return s.store.ClearTaskIntervals(ctx, a.TaskID)
|
|
case "pause":
|
|
return s.store.SetTaskPaused(ctx, a.TaskID, true)
|
|
case "resume":
|
|
return s.store.SetTaskPaused(ctx, a.TaskID, false)
|
|
case "reroll":
|
|
return s.store.RerollTask(ctx, a.TaskID)
|
|
case "close":
|
|
return s.store.CloseTask(ctx, a.TaskID)
|
|
case "regenerate_artifact":
|
|
return s.store.QueueArtifact(ctx, a.TaskID)
|
|
default:
|
|
return fmt.Errorf("unsupported action %q", a.ActionType)
|
|
}
|
|
}
|
|
|
|
func (s *Server) runTaskAction(ctx context.Context, a data.TaskAction) {
|
|
if !s.store.StartTaskAction(ctx, a.ID) {
|
|
return
|
|
}
|
|
err := s.executeTaskAction(ctx, a)
|
|
s.store.FinishTaskAction(ctx, a.ID, err)
|
|
if err != nil {
|
|
log.Printf("task action %s (%s): %v", a.ID, a.ActionType, err)
|
|
return
|
|
}
|
|
if a.ActionType == "close" {
|
|
dataOut := map[string]string{"reason": "scheduled_close"}
|
|
if successor, succErr := s.store.EnsureSuccessorTask(ctx, a.TaskID, s.settings.Get().TaskRangeBits); succErr == nil {
|
|
dataOut["successor_task_id"] = successor.ID
|
|
s.runtime.ReplaceTaskSelection(a.TaskID, successor.ID)
|
|
} else {
|
|
log.Printf("successor for %s: %v", a.TaskID, succErr)
|
|
}
|
|
_ = s.hub.Publish(ctx, wsx.Event{Type: "task_completed", TaskID: a.TaskID, Data: dataOut})
|
|
_ = s.store.EnsureActiveTasks(ctx, s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
|
|
return
|
|
}
|
|
_ = s.hub.Publish(ctx, wsx.Event{Type: "task_changed", TaskID: a.TaskID, Data: map[string]string{"action": a.ActionType}})
|
|
}
|
|
|
|
func (s *Server) runDueActions(ctx context.Context) {
|
|
actions, err := s.store.DueTaskActions(ctx, 20)
|
|
if err != nil {
|
|
log.Printf("task actions: %v", err)
|
|
return
|
|
}
|
|
for _, a := range actions {
|
|
s.runTaskAction(ctx, a)
|
|
}
|
|
}
|
|
|
|
func (s *Server) adminCloseTask(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := s.store.CloseTask(r.Context(), id); err != nil {
|
|
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
dataOut := map[string]string{"reason": "closed_by_admin"}
|
|
if successor, succErr := s.store.EnsureSuccessorTask(r.Context(), id, s.settings.Get().TaskRangeBits); succErr == nil {
|
|
dataOut["successor_task_id"] = successor.ID
|
|
s.runtime.ReplaceTaskSelection(id, successor.ID)
|
|
} else {
|
|
log.Printf("successor for %s: %v", id, succErr)
|
|
}
|
|
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_completed", TaskID: id, Data: dataOut})
|
|
_ = s.store.EnsureActiveTasks(r.Context(), s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
|
|
jsonOut(w, 200, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Server) adminEnsure(w http.ResponseWriter, r *http.Request) {
|
|
err := s.store.EnsureActiveTasks(r.Context(), s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits)
|
|
if err != nil {
|
|
jsonOut(w, 500, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
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 := websocketAuthToken(r)
|
|
c, err := s.auth.Parse(tok)
|
|
if err != nil || c.Role != "user" {
|
|
http.Error(w, "unauthorized", 401)
|
|
return
|
|
}
|
|
if !s.store.ClientExists(r.Context(), c.ClientID) {
|
|
http.Error(w, "identity not registered", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
// WebSocket use counts as recent profile activity. This also makes a race
|
|
// with the admin cleanup safe: a newly connecting identity no longer
|
|
// matches an old last_seen cutoff.
|
|
s.store.TouchClient(r.Context(), c.ClientID)
|
|
t, err := s.store.TaskForClient(r.Context(), c.ClientID)
|
|
if err != nil {
|
|
http.Error(w, "no task", 503)
|
|
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)
|
|
return
|
|
}
|
|
conn, err := s.upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID)
|
|
return
|
|
}
|
|
cl := wsx.NewClient(conn, t.ID, c.ClientID, false)
|
|
s.hub.Add(cl)
|
|
defer func() {
|
|
s.hub.Remove(cl)
|
|
s.runtime.ReleasePresence(c.ClientID, c.SessionID, leaseID)
|
|
// Mark the disconnect time as last activity. A client that stayed online
|
|
// for days therefore starts its inactivity window only after disconnect.
|
|
s.store.TouchClient(context.Background(), c.ClientID)
|
|
}()
|
|
|
|
// A map point is durable only once per client/task. Subsequent losing guesses
|
|
// stay in memory; improvements are checkpointed by the guess handler.
|
|
if p, err := s.store.EnsurePoint(r.Context(), t.ID, c.ClientID); err == nil {
|
|
p.Score = round(p.Score, s.settings.Get().PublicScorePrecision)
|
|
s.hub.PublishPoint(t.ID, c.ClientID, p)
|
|
}
|
|
snap, _ := s.store.LoadGuessState(r.Context(), t.ID, c.ClientID)
|
|
s.runtime.InitGuess(t, c.ClientID, rtx.GuessState{NextSeq: snap.NextSeq, LastGuess: snap.LastGuess, BestScore: snap.BestScore, GuessCount: snap.GuessCount})
|
|
|
|
maxNodes := s.settings.Get().DefaultMaxNodes
|
|
if q, _ := strconv.Atoi(r.URL.Query().Get("max_nodes")); q > 0 {
|
|
maxNodes = q
|
|
}
|
|
// Give the browser a bounded overscan set for local LOD, not the entire task.
|
|
limit := maxNodes * 3
|
|
if limit < 300 {
|
|
limit = 300
|
|
}
|
|
if limit > 10000 {
|
|
limit = 10000
|
|
}
|
|
ps, _ := s.store.PointsForClient(r.Context(), t.ID, c.ClientID, limit)
|
|
for i := range ps {
|
|
ps[i].Score = round(ps[i].Score, s.settings.Get().PublicScorePrecision)
|
|
}
|
|
cl.Enqueue(wsx.Event{Type: "snapshot", TaskID: t.ID, Data: ps})
|
|
|
|
conn.SetReadLimit(4 << 10)
|
|
_ = 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 {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-ping.C:
|
|
// Real WebSocket control ping. The browser answers with pong
|
|
// automatically and the PongHandler extends the 90s read deadline.
|
|
if err := cl.Ping(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) Scheduler(ctx context.Context) {
|
|
t := time.NewTicker(time.Second)
|
|
defer t.Stop()
|
|
maintenance := 0
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
s.runDueActions(ctx)
|
|
maintenance++
|
|
if maintenance%5 == 0 {
|
|
if err := s.store.EnsureActiveTasks(ctx, s.settings.Get().ActiveTaskCount, s.settings.Get().TaskRangeBits); err != nil {
|
|
log.Printf("scheduler: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|