@@ -0,0 +1,169 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var beaconPaths = []string{"PULSE", "FLUX", "ORBIT"}
|
||||
|
||||
func normalizeBeaconPath(v string) string {
|
||||
v = strings.ToUpper(strings.TrimSpace(v))
|
||||
for _, p := range beaconPaths {
|
||||
if v == p {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type drandInfo struct {
|
||||
Period int64 `json:"period"`
|
||||
GenesisTime int64 `json:"genesis_time"`
|
||||
}
|
||||
|
||||
type drandRound struct {
|
||||
Round uint64 `json:"round"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
type beaconReveal struct {
|
||||
Source string
|
||||
BeaconID string
|
||||
Round uint64
|
||||
Randomness string
|
||||
Signature string
|
||||
}
|
||||
|
||||
type beaconClient struct {
|
||||
base string
|
||||
beaconID string
|
||||
hc *http.Client
|
||||
mu sync.Mutex
|
||||
info drandInfo
|
||||
infoAt time.Time
|
||||
}
|
||||
|
||||
func newBeaconClient() *beaconClient {
|
||||
base := strings.TrimRight(strings.TrimSpace(os.Getenv("BEACON_DRAND_URL")), "/")
|
||||
if base == "" {
|
||||
base = "https://api.drand.sh"
|
||||
}
|
||||
id := strings.TrimSpace(os.Getenv("BEACON_DRAND_BEACON_ID"))
|
||||
if id == "" {
|
||||
id = "quicknet"
|
||||
}
|
||||
return &beaconClient{base: base, beaconID: id, hc: &http.Client{Timeout: 5 * time.Second}}
|
||||
}
|
||||
|
||||
func (b *beaconClient) chainInfo(ctx context.Context) (drandInfo, error) {
|
||||
b.mu.Lock()
|
||||
if b.info.Period > 0 && time.Since(b.infoAt) < 6*time.Hour {
|
||||
v := b.info
|
||||
b.mu.Unlock()
|
||||
return v, nil
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.base+"/v2/beacons/"+b.beaconID+"/info", nil)
|
||||
if err != nil {
|
||||
return drandInfo{}, err
|
||||
}
|
||||
resp, err := b.hc.Do(req)
|
||||
if err != nil {
|
||||
return drandInfo{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return drandInfo{}, fmt.Errorf("drand info HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var info drandInfo
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil {
|
||||
return drandInfo{}, err
|
||||
}
|
||||
if info.Period <= 0 || info.GenesisTime <= 0 {
|
||||
return drandInfo{}, errors.New("drand returned invalid chain info")
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.info, b.infoAt = info, time.Now()
|
||||
b.mu.Unlock()
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func roundTime(info drandInfo, round uint64) time.Time {
|
||||
if round == 0 {
|
||||
return time.Unix(info.GenesisTime, 0).UTC()
|
||||
}
|
||||
return time.Unix(info.GenesisTime+int64(round-1)*info.Period, 0).UTC()
|
||||
}
|
||||
|
||||
// targetRound chooses the first beacon round that starts strictly after the
|
||||
// guess window closes. That makes the beacon value unknowable while players
|
||||
// are choosing PULSE/FLUX/ORBIT and submitting their ticket.
|
||||
func targetRound(info drandInfo, windowEnd time.Time) uint64 {
|
||||
if windowEnd.Unix() <= info.GenesisTime {
|
||||
return 1
|
||||
}
|
||||
elapsed := windowEnd.Unix() - info.GenesisTime
|
||||
return uint64(elapsed/info.Period) + 2
|
||||
}
|
||||
|
||||
func (b *beaconClient) plan(ctx context.Context, windowEnd time.Time) (uint64, time.Time, error) {
|
||||
info, err := b.chainInfo(ctx)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, err
|
||||
}
|
||||
r := targetRound(info, windowEnd)
|
||||
return r, roundTime(info, r), nil
|
||||
}
|
||||
|
||||
func (b *beaconClient) reveal(ctx context.Context, round uint64) (beaconReveal, error) {
|
||||
path := b.base + "/v2/beacons/" + b.beaconID + "/rounds/" + strconv.FormatUint(round, 10)
|
||||
var last error
|
||||
for attempt := 0; attempt < 6; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return beaconReveal{}, err
|
||||
}
|
||||
resp, err := b.hc.Do(req)
|
||||
if err == nil {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
_ = resp.Body.Close()
|
||||
if readErr == nil && resp.StatusCode/100 == 2 {
|
||||
var rr drandRound
|
||||
if json.Unmarshal(body, &rr) == nil && rr.Round == round && rr.Signature != "" {
|
||||
sig, decErr := hex.DecodeString(rr.Signature)
|
||||
if decErr != nil {
|
||||
return beaconReveal{}, fmt.Errorf("decode drand signature: %w", decErr)
|
||||
}
|
||||
h := sha256.Sum256(sig)
|
||||
return beaconReveal{Source: "drand", BeaconID: b.beaconID, Round: round, Randomness: hex.EncodeToString(h[:]), Signature: rr.Signature}, nil
|
||||
}
|
||||
}
|
||||
last = fmt.Errorf("drand round HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
} else {
|
||||
last = err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return beaconReveal{}, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt+1) * 750 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if last == nil {
|
||||
last = errors.New("drand round unavailable")
|
||||
}
|
||||
return beaconReveal{}, last
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package server
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sync"
|
||||
@@ -10,85 +12,149 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errLotteryDuplicate = errors.New("guess already entered in current lottery window")
|
||||
errLotteryFull = errors.New("guess lottery window is full")
|
||||
errLotteryDuplicate = errors.New("guess already entered in current lottery window")
|
||||
errLotteryFull = errors.New("guess lottery window is full")
|
||||
errBeaconUnavailable = errors.New("external randomness beacon unavailable")
|
||||
)
|
||||
|
||||
const maxLotteryTicketsPerWindow = 250000
|
||||
|
||||
type lotteryResult struct {
|
||||
Selected bool `json:"selected"`
|
||||
BeaconEnabled bool `json:"beacon_enabled"`
|
||||
ChosenPath string `json:"chosen_path,omitempty"`
|
||||
BoostedPath string `json:"boosted_path,omitempty"`
|
||||
BeaconSource string `json:"beacon_source,omitempty"`
|
||||
BeaconID string `json:"beacon_id,omitempty"`
|
||||
BeaconRound uint64 `json:"beacon_round,omitempty"`
|
||||
Randomness string `json:"randomness,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
WindowEnd int64 `json:"window_end,omitempty"`
|
||||
}
|
||||
|
||||
type lotteryTicket struct {
|
||||
key string
|
||||
path string
|
||||
ctx context.Context
|
||||
result chan bool
|
||||
result chan lotteryDelivery
|
||||
}
|
||||
|
||||
type lotteryDelivery struct {
|
||||
result lotteryResult
|
||||
err error
|
||||
}
|
||||
|
||||
type lotteryBucket struct {
|
||||
max int
|
||||
end time.Time
|
||||
tickets []*lotteryTicket
|
||||
keys map[string]struct{}
|
||||
max int
|
||||
end time.Time
|
||||
tickets []*lotteryTicket
|
||||
keys map[string]struct{}
|
||||
beacon bool
|
||||
bonusWeight int
|
||||
beaconRound uint64
|
||||
revealAt time.Time
|
||||
}
|
||||
|
||||
type beaconDrawAudit struct {
|
||||
TaskID string
|
||||
WindowEnd time.Time
|
||||
BeaconID string
|
||||
BeaconRound uint64
|
||||
Randomness string
|
||||
Signature string
|
||||
BoostedPath string
|
||||
Tickets int
|
||||
Selected int
|
||||
}
|
||||
|
||||
type guessLottery struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*lotteryBucket
|
||||
beacon *beaconClient
|
||||
onDraw func(beaconDrawAudit)
|
||||
}
|
||||
|
||||
func newGuessLottery() *guessLottery {
|
||||
return &guessLottery{buckets: make(map[string]*lotteryBucket)}
|
||||
func newGuessLottery(onDraw func(beaconDrawAudit)) *guessLottery {
|
||||
return &guessLottery{buckets: make(map[string]*lotteryBucket), beacon: newBeaconClient(), onDraw: onDraw}
|
||||
}
|
||||
|
||||
// enter batches all valid tips for a task into aligned time windows. At the
|
||||
// window boundary exactly up to max tickets are selected uniformly at random.
|
||||
// The request intentionally waits for the draw so later arrivals in the same
|
||||
// window have the same chance as earlier arrivals.
|
||||
func (l *guessLottery) enter(ctx context.Context, taskID, clientID string, seq int64, window time.Duration, max int) (bool, error) {
|
||||
// enter batches all valid tips for a task into aligned time windows. When
|
||||
// Beacon Hunt is enabled, players commit to PULSE/FLUX/ORBIT before the window
|
||||
// closes. The first drand round after the boundary becomes the deterministic
|
||||
// source for both the boosted path and the weighted draw.
|
||||
func (l *guessLottery) enter(ctx context.Context, taskID, clientID string, seq int64, path string, window time.Duration, max int, beaconEnabled bool, bonusWeight int) (lotteryResult, error) {
|
||||
if max <= 0 || window <= 0 {
|
||||
return true, nil
|
||||
return lotteryResult{Selected: true}, nil
|
||||
}
|
||||
if beaconEnabled {
|
||||
path = normalizeBeaconPath(path)
|
||||
if path == "" {
|
||||
return lotteryResult{}, errors.New("beacon path required")
|
||||
}
|
||||
if bonusWeight < 1 {
|
||||
bonusWeight = 1
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
windowNS := window.Nanoseconds()
|
||||
if windowNS <= 0 {
|
||||
return true, nil
|
||||
return lotteryResult{Selected: true}, nil
|
||||
}
|
||||
idx := now.UnixNano() / windowNS
|
||||
end := time.Unix(0, (idx+1)*windowNS).UTC()
|
||||
bucketKey := taskID + "|" + end.Format(time.RFC3339Nano) + "|" + window.String()
|
||||
ticketKey := clientID + "|" + big.NewInt(seq).String()
|
||||
t := &lotteryTicket{key: ticketKey, ctx: ctx, result: make(chan bool, 1)}
|
||||
t := &lotteryTicket{key: ticketKey, path: path, ctx: ctx, result: make(chan lotteryDelivery, 1)}
|
||||
|
||||
l.mu.Lock()
|
||||
b := l.buckets[bucketKey]
|
||||
if b == nil {
|
||||
b = &lotteryBucket{max: max, end: end, keys: make(map[string]struct{})}
|
||||
b = &lotteryBucket{max: max, end: end, keys: make(map[string]struct{}), beacon: beaconEnabled, bonusWeight: bonusWeight}
|
||||
if beaconEnabled {
|
||||
planCtx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
round, revealAt, err := l.beacon.plan(planCtx, end)
|
||||
cancel()
|
||||
if err != nil {
|
||||
l.mu.Unlock()
|
||||
return lotteryResult{}, errBeaconUnavailable
|
||||
}
|
||||
b.beaconRound, b.revealAt = round, revealAt
|
||||
}
|
||||
l.buckets[bucketKey] = b
|
||||
delay := time.Until(end)
|
||||
drawAt := end
|
||||
if b.beacon && b.revealAt.After(drawAt) {
|
||||
drawAt = b.revealAt.Add(700 * time.Millisecond)
|
||||
}
|
||||
delay := time.Until(drawAt)
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
time.AfterFunc(delay, func() { l.draw(bucketKey) })
|
||||
time.AfterFunc(delay, func() { l.draw(bucketKey, taskID) })
|
||||
} else if b.beacon != beaconEnabled {
|
||||
l.mu.Unlock()
|
||||
return lotteryResult{}, errors.New("lottery mode changed during active window")
|
||||
}
|
||||
if _, exists := b.keys[ticketKey]; exists {
|
||||
l.mu.Unlock()
|
||||
return false, errLotteryDuplicate
|
||||
return lotteryResult{}, errLotteryDuplicate
|
||||
}
|
||||
if len(b.tickets) >= maxLotteryTicketsPerWindow {
|
||||
l.mu.Unlock()
|
||||
return false, errLotteryFull
|
||||
return lotteryResult{}, errLotteryFull
|
||||
}
|
||||
b.keys[ticketKey] = struct{}{}
|
||||
b.tickets = append(b.tickets, t)
|
||||
l.mu.Unlock()
|
||||
|
||||
select {
|
||||
case selected := <-t.result:
|
||||
return selected, nil
|
||||
case d := <-t.result:
|
||||
return d.result, d.err
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
return lotteryResult{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *guessLottery) draw(bucketKey string) {
|
||||
func (l *guessLottery) draw(bucketKey, taskID string) {
|
||||
l.mu.Lock()
|
||||
b := l.buckets[bucketKey]
|
||||
if b == nil {
|
||||
@@ -97,39 +163,122 @@ func (l *guessLottery) draw(bucketKey string) {
|
||||
}
|
||||
delete(l.buckets, bucketKey)
|
||||
tickets := append([]*lotteryTicket(nil), b.tickets...)
|
||||
max := b.max
|
||||
l.mu.Unlock()
|
||||
|
||||
// Canceled HTTP requests do not consume one of the scarce winning slots.
|
||||
alive := tickets[:0]
|
||||
for _, t := range tickets {
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
// skip
|
||||
default:
|
||||
alive = append(alive, t)
|
||||
}
|
||||
}
|
||||
tickets = alive
|
||||
max := b.max
|
||||
if max > len(tickets) {
|
||||
max = len(tickets)
|
||||
}
|
||||
// Partial Fisher-Yates with crypto/rand gives every ticket equal odds.
|
||||
|
||||
if b.beacon {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
reveal, err := l.beacon.reveal(ctx, b.beaconRound)
|
||||
cancel()
|
||||
if err != nil {
|
||||
for _, t := range tickets {
|
||||
select {
|
||||
case t.result <- lotteryDelivery{err: errBeaconUnavailable}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
boosted := beaconPathFromRandomness(reveal.Randomness, bucketKey)
|
||||
selected := weightedBeaconDraw(tickets, max, boosted, b.bonusWeight, reveal.Randomness, bucketKey)
|
||||
selectedCount := 0
|
||||
for _, yes := range selected {
|
||||
if yes {
|
||||
selectedCount++
|
||||
}
|
||||
}
|
||||
for i, t := range tickets {
|
||||
weight := 1
|
||||
if t.path == boosted {
|
||||
weight = b.bonusWeight
|
||||
}
|
||||
res := lotteryResult{Selected: selected[i], BeaconEnabled: true, ChosenPath: t.path, BoostedPath: boosted, BeaconSource: reveal.Source, BeaconID: reveal.BeaconID, BeaconRound: reveal.Round, Randomness: reveal.Randomness, Weight: weight, WindowEnd: b.end.Unix()}
|
||||
select {
|
||||
case t.result <- lotteryDelivery{result: res}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if l.onDraw != nil {
|
||||
l.onDraw(beaconDrawAudit{TaskID: taskID, WindowEnd: b.end, BeaconID: reveal.BeaconID, BeaconRound: reveal.Round, Randomness: reveal.Randomness, Signature: reveal.Signature, BoostedPath: boosted, Tickets: len(tickets), Selected: selectedCount})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy lottery: partial Fisher-Yates with crypto/rand.
|
||||
for i := 0; i < max; i++ {
|
||||
nBig, err := crand.Int(crand.Reader, big.NewInt(int64(len(tickets)-i)))
|
||||
if err != nil {
|
||||
// crypto/rand failure is extremely unusual; deterministic fallback still
|
||||
// keeps the quota safe, but does not claim cryptographic randomness.
|
||||
nBig = big.NewInt(0)
|
||||
}
|
||||
j := i + int(nBig.Int64())
|
||||
tickets[i], tickets[j] = tickets[j], tickets[i]
|
||||
}
|
||||
for i, t := range tickets {
|
||||
selected := i < max
|
||||
select {
|
||||
case t.result <- selected:
|
||||
case t.result <- lotteryDelivery{result: lotteryResult{Selected: i < max}}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func beaconPathFromRandomness(randomness, bucketKey string) string {
|
||||
h := sha256.Sum256([]byte("nh-beacon-path-v1|" + randomness + "|" + bucketKey))
|
||||
return beaconPaths[int(h[0])%len(beaconPaths)]
|
||||
}
|
||||
|
||||
func deterministicUint64(seed string, counter int) uint64 {
|
||||
h := sha256.Sum256([]byte(seed + "|" + big.NewInt(int64(counter)).String()))
|
||||
return binary.BigEndian.Uint64(h[:8])
|
||||
}
|
||||
|
||||
func weightedBeaconDraw(tickets []*lotteryTicket, max int, boosted string, bonusWeight int, randomness, bucketKey string) []bool {
|
||||
out := make([]bool, len(tickets))
|
||||
remaining := make([]int, len(tickets))
|
||||
for i := range tickets {
|
||||
remaining[i] = i
|
||||
}
|
||||
seed := "nh-beacon-draw-v1|" + randomness + "|" + bucketKey
|
||||
for pick := 0; pick < max && len(remaining) > 0; pick++ {
|
||||
total := 0
|
||||
for _, idx := range remaining {
|
||||
w := 1
|
||||
if tickets[idx].path == boosted {
|
||||
w = bonusWeight
|
||||
}
|
||||
total += w
|
||||
}
|
||||
if total <= 0 {
|
||||
break
|
||||
}
|
||||
r := int(deterministicUint64(seed, pick) % uint64(total))
|
||||
chosenPos := 0
|
||||
for pos, idx := range remaining {
|
||||
w := 1
|
||||
if tickets[idx].path == boosted {
|
||||
w = bonusWeight
|
||||
}
|
||||
if r < w {
|
||||
chosenPos = pos
|
||||
break
|
||||
}
|
||||
r -= w
|
||||
}
|
||||
idx := remaining[chosenPos]
|
||||
out[idx] = true
|
||||
remaining = append(remaining[:chosenPos], remaining[chosenPos+1:]...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -8,20 +8,20 @@ import (
|
||||
)
|
||||
|
||||
func TestGuessLotteryDrawSelectsExactQuota(t *testing.T) {
|
||||
l := newGuessLottery()
|
||||
l := newGuessLottery(nil)
|
||||
const total = 40
|
||||
const quota = 9
|
||||
b := &lotteryBucket{max: quota, end: time.Now().Add(time.Second), keys: make(map[string]struct{})}
|
||||
for i := 0; i < total; i++ {
|
||||
ticket := &lotteryTicket{key: fmt.Sprintf("c-%d|0", i), ctx: context.Background(), result: make(chan bool, 1)}
|
||||
ticket := &lotteryTicket{key: fmt.Sprintf("c-%d|0", i), ctx: context.Background(), result: make(chan lotteryDelivery, 1)}
|
||||
b.tickets = append(b.tickets, ticket)
|
||||
b.keys[ticket.key] = struct{}{}
|
||||
}
|
||||
l.buckets["test"] = b
|
||||
l.draw("test")
|
||||
l.draw("test", "task-test")
|
||||
selected := 0
|
||||
for _, ticket := range b.tickets {
|
||||
if <-ticket.result {
|
||||
if (<-ticket.result).result.Selected {
|
||||
selected++
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,47 @@ func TestGuessLotteryDrawSelectsExactQuota(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGuessLotteryCanceledTicketDoesNotConsumeQuota(t *testing.T) {
|
||||
l := newGuessLottery()
|
||||
l := newGuessLottery(nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
canceled := &lotteryTicket{key: "canceled", ctx: ctx, result: make(chan bool, 1)}
|
||||
alive := &lotteryTicket{key: "alive", ctx: context.Background(), result: make(chan bool, 1)}
|
||||
canceled := &lotteryTicket{key: "canceled", ctx: ctx, result: make(chan lotteryDelivery, 1)}
|
||||
alive := &lotteryTicket{key: "alive", ctx: context.Background(), result: make(chan lotteryDelivery, 1)}
|
||||
l.buckets["test"] = &lotteryBucket{max: 1, tickets: []*lotteryTicket{canceled, alive}, keys: map[string]struct{}{"canceled": {}, "alive": {}}}
|
||||
l.draw("test")
|
||||
if got := <-alive.result; !got {
|
||||
l.draw("test", "task-test")
|
||||
if got := (<-alive.result).result.Selected; !got {
|
||||
t.Fatal("live ticket should receive the available slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedBeaconDrawIsDeterministicAndRewardsBoostedPath(t *testing.T) {
|
||||
tickets := []*lotteryTicket{
|
||||
{key: "a", path: "PULSE"}, {key: "b", path: "FLUX"}, {key: "c", path: "PULSE"},
|
||||
{key: "d", path: "ORBIT"}, {key: "e", path: "PULSE"}, {key: "f", path: "FLUX"},
|
||||
}
|
||||
a := weightedBeaconDraw(tickets, 3, "PULSE", 2, "deadbeef", "bucket")
|
||||
b := weightedBeaconDraw(tickets, 3, "PULSE", 2, "deadbeef", "bucket")
|
||||
if len(a) != len(b) {
|
||||
t.Fatal("draw length mismatch")
|
||||
}
|
||||
count := 0
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("draw is not deterministic at %d", i)
|
||||
}
|
||||
if a[i] {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("selected %d, want 3", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeaconTargetRoundIsStrictlyAfterWindow(t *testing.T) {
|
||||
info := drandInfo{Period: 3, GenesisTime: 1000}
|
||||
end := time.Unix(1006, 0).UTC() // exact round boundary
|
||||
r := targetRound(info, end)
|
||||
if !roundTime(info, r).After(end) {
|
||||
t.Fatalf("round %d at %s must be after %s", r, roundTime(info, r), end)
|
||||
}
|
||||
}
|
||||
|
||||
+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