RC-15-PLACE
release-tag / release-image (push) Successful in 3m56s

This commit is contained in:
2026-08-14 10:13:57 +02:00
parent 47c523dd98
commit 52d33b25fd
13 changed files with 1008 additions and 43 deletions
+159
View File
@@ -0,0 +1,159 @@
package server
import (
"errors"
"net/http"
"strconv"
"time"
"neuralhunt/internal/data"
wsx "neuralhunt/internal/ws"
)
var placePalette = []string{
"#FFFFFF", "#D4D7D9", "#898D90", "#515252", "#000000", "#FFB470", "#FF8A3D", "#E64B35",
"#FF5C7A", "#E33765", "#B72B73", "#811E9F", "#B44AC0", "#7E57C2", "#5A4FCF", "#3690EA",
"#2450A4", "#51E9F4", "#00A8B8", "#00CCC0", "#34D399", "#00A368", "#00756F", "#7EED56",
"#46A758", "#A8D26D", "#FFD635", "#F9A31B", "#D6A25E", "#9C6926", "#6D482F", "#493B2A",
}
func (s *Server) placeConfig() map[string]any {
cfg := s.settings.Get()
return map[string]any{
"enabled": cfg.PlaceEnabled == 1,
"width": cfg.PlaceWidth,
"height": cfg.PlaceHeight,
"points_per_score": cfg.PlacePointsPerScore,
"pixel_cost": cfg.PlacePixelCost,
"palette": placePalette,
"economy_explanation": "1.00 Hunt-Score-Fortschritt erzeugt points_per_score Punkte. Ein Placement kostet pixel_cost Punkte.",
"worker_progress_credits": true,
}
}
func (s *Server) publicPlace(w http.ResponseWriter, r *http.Request) {
pixels, stats, err := s.store.PlaceSnapshot(r.Context())
if err != nil {
jsonOut(w, 500, map[string]string{"error": "place snapshot failed"})
return
}
recent, _ := s.store.PlaceRecent(r.Context(), 30)
w.Header().Set("Cache-Control", "no-store")
jsonOut(w, 200, map[string]any{"config": s.placeConfig(), "stats": stats, "pixels": pixels, "recent": recent})
}
func (s *Server) publicPlaceChanges(w http.ResponseWriter, r *http.Request) {
after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
pixels, revision, err := s.store.PlaceChanges(r.Context(), after, 5000)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "place changes failed"})
return
}
w.Header().Set("Cache-Control", "no-store")
jsonOut(w, 200, map[string]any{"revision": revision, "pixels": pixels})
}
func (s *Server) placeMe(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
}
s.store.TouchClientThrottled(r.Context(), c.ClientID, 5*time.Minute)
cfg := s.settings.Get()
wallet, err := s.store.PlaceWallet(r.Context(), c.ClientID, cfg.PlacePixelCost)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "place wallet failed"})
return
}
jsonOut(w, 200, map[string]any{"config": s.placeConfig(), "wallet": wallet})
}
func (s *Server) placePixel(w http.ResponseWriter, r *http.Request) {
cfg := s.settings.Get()
if cfg.PlaceEnabled != 1 {
jsonAPIError(w, http.StatusLocked, "place_disabled", "Neural Place is currently disabled", nil)
return
}
var in struct {
X int `json:"x"`
Y int `json:"y"`
ColorIndex int `json:"color_index"`
}
if err := decode(r, &in); err != nil {
jsonOut(w, 400, map[string]string{"error": "bad json: " + err.Error()})
return
}
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
}
s.store.TouchClient(r.Context(), c.ClientID)
p, wallet, err := s.store.PlacePixel(r.Context(), c.ClientID, in.X, in.Y, in.ColorIndex, cfg.PlaceWidth, cfg.PlaceHeight, cfg.PlacePixelCost, len(placePalette))
if err != nil {
// The pixel transaction may already be committed even if the optional
// wallet refresh immediately afterwards fails. Never report that case as
// a failed placement: a client retry would spend the pixel cost twice.
if p.Revision > 0 {
_ = s.placeHub.Publish(r.Context(), wsx.Event{Type: "place_pixel", Data: p})
jsonOut(w, 200, map[string]any{"pixel": p, "wallet": nil, "wallet_refresh_required": true})
return
}
switch {
case errors.Is(err, data.ErrPlaceInsufficientPoints):
jsonAPIError(w, http.StatusPaymentRequired, "insufficient_place_points", "not enough Place points for this pixel", map[string]any{"pixel_cost": cfg.PlacePixelCost})
case errors.Is(err, data.ErrPlaceBounds), errors.Is(err, data.ErrPlaceColor):
jsonOut(w, 400, map[string]string{"error": err.Error()})
default:
jsonOut(w, 500, map[string]string{"error": "pixel placement failed"})
}
return
}
_ = s.placeHub.Publish(r.Context(), wsx.Event{Type: "place_pixel", Data: p})
jsonOut(w, 200, map[string]any{"pixel": p, "wallet": wallet})
}
func (s *Server) placeWS(w http.ResponseWriter, r *http.Request) {
if !acquireWSCap(&s.placeWSCount, s.maxPlaceWS) {
http.Error(w, "place websocket capacity reached", http.StatusServiceUnavailable)
return
}
defer s.placeWSCount.Add(-1)
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
cl := wsx.NewClient(conn, "", "place", true)
s.placeHub.Add(cl)
defer s.placeHub.Remove(cl)
revision, _ := s.store.PlaceRevision(r.Context())
cl.Enqueue(wsx.Event{Type: "place_ready", Data: map[string]any{"revision": revision}})
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
}
}
}
}
+39
View File
@@ -53,3 +53,42 @@ func TestProfileCleanupDurationGuard(t *testing.T) {
t.Fatalf("got=%d err=%v", got, err)
}
}
func TestProfileCleanupProtectsPlaceParticipant(t *testing.T) {
ctx := context.Background()
db, err := data.OpenSQLite(ctx, t.TempDir()+"/cleanup-place.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := data.New(db)
jwk := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: "AQ", Y: "Ag"}
if err := store.UpsertClient(ctx, "place_old", jwk); err != nil {
t.Fatal(err)
}
old := time.Now().UTC().Add(-10 * 24 * time.Hour).UnixMilli()
if _, err := db.ExecContext(ctx, `UPDATE clients SET last_seen=? WHERE id='place_old'`, old); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO place_wallets(client_id,balance_milli,earned_milli,updated_at) VALUES('place_old',1000,1000,?)`, old); err != nil {
t.Fatal(err)
}
s := &Server{store: store, runtime: rtx.New()}
preview, ids, err := s.profileCleanupPreview(ctx, 7*24*60*60)
if err != nil {
t.Fatal(err)
}
if preview.Eligible != 0 || preview.ProtectedPlace != 1 {
t.Fatalf("unexpected preview: %+v", preview)
}
if len(ids) != 0 {
t.Fatalf("place participant should be protected, got ids %#v", ids)
}
deleted, err := store.DeleteInactiveNonWinnerClients(ctx, time.Now().UTC().Add(-7*24*time.Hour).UnixMilli(), []string{"place_old"})
if err != nil {
t.Fatal(err)
}
if len(deleted) != 0 || !store.ClientExists(ctx, "place_old") {
t.Fatal("place participant was deleted")
}
}
+60 -21
View File
@@ -38,22 +38,23 @@ import (
)
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
customerServiceInternalURL string
customerServiceHTTP *http.Client
upgrader websocket.Upgrader
wsAllowedOrigins map[string]struct{}
maxUserWS, maxLeaderboardWS int64
userWSCount, leaderboardWSCount, adminWSCount atomic.Int64
adminSessions sync.Map // sid -> exp unix seconds
store *data.Store
auth *auth.Manager
settings *settings.Manager
hub *wsx.Hub
placeHub *wsx.Hub
runtime *rtx.State
artifactWorker *artifact.Worker
lottery *guessLottery
adminUser, adminPass, staticDir, artifactDir string
internalServiceSecret string
customerServiceInternalURL string
customerServiceHTTP *http.Client
upgrader websocket.Upgrader
wsAllowedOrigins map[string]struct{}
maxUserWS, maxLeaderboardWS, maxPlaceWS int64
userWSCount, leaderboardWSCount, placeWSCount, adminWSCount 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 {
@@ -62,6 +63,7 @@ func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub,
auth: a,
settings: sm,
hub: hub,
placeHub: wsx.New(),
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(func(d beaconDrawAudit) {
@@ -77,6 +79,7 @@ func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub,
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
maxPlaceWS: int64(envIntServer("WS_MAX_PLACE_CONNECTIONS", 1500)),
}
s.upgrader = websocket.Upgrader{CheckOrigin: s.checkWSOrigin, Subprotocols: []string{"neuralhunt.v1"}}
return s
@@ -293,7 +296,7 @@ func (s *Server) Routes() http.Handler {
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") {
if path == "/" || path == "/admin" || path == "/leaderboard" || path == "/place" || 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)
@@ -304,9 +307,12 @@ func (s *Server) Routes() http.Handler {
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/place", s.publicPlace)
r.Get("/api/public/place/changes", s.publicPlaceChanges)
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.Get("/api/place/ws", s.placeWS)
r.Post("/api/auth/challenge", s.challenge)
r.Post("/api/auth/login", s.login)
r.Post("/api/admin/login", s.adminLogin)
@@ -323,6 +329,8 @@ func (s *Server) Routes() http.Handler {
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/place/me", s.placeMe)
r.Post("/api/place/pixel", s.placePixel)
r.Get("/api/me/artifacts", s.myArtifacts)
r.Get("/api/me/artifacts/{id}/download", s.myArtifactDownload)
r.Post("/api/me/customer-link", s.customerLinkCode)
@@ -847,7 +855,11 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
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)
placePointsPerScore := 0
if placeCfg := s.settings.Get(); placeCfg.PlaceEnabled == 1 {
placePointsPerScore = placeCfg.PlacePointsPerScore
}
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, placePointsPerScore)
if err != nil {
s.runtime.Restore(t.Task, c.ClientID, accepted.State.NextSeq, accepted.Previous)
switch {
@@ -1346,6 +1358,7 @@ type profileCleanupPreview struct {
InactiveForSeconds int64 `json:"inactive_for_seconds"`
Eligible int `json:"eligible"`
ProtectedWinners int64 `json:"protected_winners"`
ProtectedPlace int64 `json:"protected_place"`
ProtectedConnected int `json:"protected_connected"`
OldestEligibleMS int64 `json:"oldest_eligible_ms,omitempty"`
NewestEligibleMS int64 `json:"newest_eligible_ms,omitempty"`
@@ -1374,8 +1387,12 @@ func (s *Server) profileCleanupPreview(ctx context.Context, inactiveForSeconds i
if err != nil {
return profileCleanupPreview{}, nil, err
}
protectedPlace, err := s.store.OldPlaceParticipantCount(ctx, cutoff)
if err != nil {
return profileCleanupPreview{}, nil, err
}
ids := make([]string, 0, len(candidates))
out := profileCleanupPreview{CutoffMS: cutoff, InactiveForSeconds: inactiveForSeconds, ProtectedWinners: protectedWinners}
out := profileCleanupPreview{CutoffMS: cutoff, InactiveForSeconds: inactiveForSeconds, ProtectedWinners: protectedWinners, ProtectedPlace: protectedPlace}
for _, c := range candidates {
if s.runtime.IsConnected(c.ClientID) {
out.ProtectedConnected++
@@ -1437,6 +1454,7 @@ func (s *Server) adminProfileCleanup(w http.ResponseWriter, r *http.Request) {
"deleted": len(deleted),
"eligible_before": preview.Eligible,
"protected_winners": preview.ProtectedWinners,
"protected_place": preview.ProtectedPlace,
"protected_connected": preview.ProtectedConnected,
"cutoff_ms": preview.CutoffMS,
"inactive_for_seconds": seconds,
@@ -1457,11 +1475,16 @@ func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request) {
func (s *Server) adminPerformance(w http.ResponseWriter, r *http.Request) {
rm := s.runtime.Metrics()
wm := s.hub.Metrics()
pm := wsx.Metrics{}
if s.placeHub != nil {
pm = s.placeHub.Metrics()
}
var ms gort.MemStats
gort.ReadMemStats(&ms)
jsonOut(w, 200, map[string]any{
"runtime": rm,
"websocket": wm,
"runtime": rm,
"websocket": wm,
"place_websocket": pm,
"process": map[string]any{
"goroutines": gort.NumGoroutine(),
"heap_bytes": ms.HeapAlloc,
@@ -1480,6 +1503,22 @@ func (s *Server) adminSettingsPut(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
if err := settings.Validate(v); err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
current := s.settings.Get()
if v.PlaceWidth < current.PlaceWidth || v.PlaceHeight < current.PlaceHeight {
outside, err := s.store.PlacePixelsOutside(r.Context(), v.PlaceWidth, v.PlaceHeight)
if err != nil {
jsonOut(w, 500, map[string]string{"error": "place canvas bounds check failed"})
return
}
if outside > 0 {
jsonOut(w, 400, map[string]string{"error": fmt.Sprintf("place canvas cannot shrink: %d existing pixels would fall outside the new dimensions", outside)})
return
}
}
if err := s.settings.Update(r.Context(), v); err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return