+246
-26
@@ -2,6 +2,10 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -42,6 +46,7 @@ type Server struct {
|
||||
artifactWorker *artifact.Worker
|
||||
lottery *guessLottery
|
||||
adminUser, adminPass, staticDir, artifactDir string
|
||||
internalServiceSecret string
|
||||
upgrader websocket.Upgrader
|
||||
wsAllowedOrigins map[string]struct{}
|
||||
maxUserWS, maxLeaderboardWS int64
|
||||
@@ -51,20 +56,23 @@ type Server struct {
|
||||
|
||||
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(),
|
||||
adminUser: env("ADMIN_USER", "admin"),
|
||||
adminPass: env("ADMIN_PASSWORD", "change-me"),
|
||||
staticDir: env("STATIC_DIR", ""),
|
||||
artifactDir: artifactDir,
|
||||
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
|
||||
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
|
||||
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
|
||||
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
|
||||
@@ -242,7 +250,7 @@ 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/") {
|
||||
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
|
||||
}
|
||||
@@ -260,7 +268,7 @@ func (s *Server) AdminRoutes() http.Handler {
|
||||
http.Redirect(w, r, "/admin", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
allowed := p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/healthz" || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/app.js" || p == "/styles.css" || p == "/index.html"
|
||||
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
|
||||
@@ -290,6 +298,8 @@ func (s *Server) Routes() http.Handler {
|
||||
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)
|
||||
@@ -297,6 +307,9 @@ func (s *Server) Routes() http.Handler {
|
||||
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)
|
||||
@@ -305,6 +318,9 @@ 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/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) {
|
||||
@@ -532,6 +548,9 @@ func taskDTO(t data.Task, next int64, sm settings.Runtime) map[string]any {
|
||||
"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,
|
||||
@@ -620,7 +639,10 @@ func (s *Server) currentTask(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, taskDTO(t, g.NextSeq, s.settings.Get()))
|
||||
}
|
||||
|
||||
func guessMsg(taskID string, seq int64, guess string) string {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -645,9 +667,10 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Guess string `json:"guess"`
|
||||
Signature string `json:"signature"`
|
||||
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)
|
||||
@@ -687,7 +710,12 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
pub, _ := auth.PublicKey(jwk)
|
||||
if pub == nil || !auth.VerifyRaw(pub, guessMsg(id, in.Seq, in.Guess), in.Signature) {
|
||||
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
|
||||
}
|
||||
@@ -714,14 +742,18 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var draw lotteryResult
|
||||
if cfg.GuessLotteryMaxAccepted > 0 {
|
||||
selected, drawErr := s.lottery.enter(r.Context(), t.ID, c.ClientID, in.Seq, time.Duration(cfg.GuessLotteryWindowSec)*time.Second, cfg.GuessLotteryMaxAccepted)
|
||||
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:
|
||||
@@ -746,7 +778,7 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
t = fresh
|
||||
if !selected {
|
||||
if !draw.Selected {
|
||||
next, skipErr := s.runtime.SkipLottery(t.Task, c.ClientID, in.Seq, minInterval)
|
||||
if skipErr != nil {
|
||||
if errors.Is(skipErr, rtx.ErrBadSequence) {
|
||||
@@ -758,7 +790,15 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
jsonAPIError(w, http.StatusTooManyRequests, "lottery_not_selected", "guess was not selected in this lottery window", map[string]any{"next_seq": next})
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -788,7 +828,15 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// Losing tips are intentionally ephemeral: no SQLite write and no websocket event.
|
||||
if accepted.Improved || correct {
|
||||
p, err := s.store.PersistImprovement(r.Context(), t, c.ClientID, accepted.State.NextSeq, accepted.State.GuessCount, accepted.State.LastGuess, accepted.State.BestScore, in.Guess, in.Signature, 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 {
|
||||
@@ -806,7 +854,8 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
s.hub.PublishPoint(id, c.ClientID, p)
|
||||
}
|
||||
if correct {
|
||||
dataOut := map[string]string{"winner_client_id": c.ClientID}
|
||||
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)
|
||||
@@ -816,9 +865,139 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
_ = 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 {
|
||||
@@ -857,6 +1036,47 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user