@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user