This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

leaderboard stuff

author
pds.dad
date (Jul 2, 2026, 3:12 PM -0500) commit f0abea78 parent 926e7532 change-id swxptqrr
+238 -72
+4
api/lichess/puzzlegetLeaderboard.go
··· 14 14 // 15 15 // One player's standing on the leaderboard. 16 16 type PuzzleGetLeaderboard_Entry struct { 17 + // avatar: URL of the player's avatar image, if they have one. 18 + Avatar *string `json:"avatar,omitempty" cborgen:"avatar,omitempty"` 17 19 // did: The player's atproto DID. 18 20 Did string `json:"did" cborgen:"did"` 21 + // handle: The player's atproto handle, if resolvable. 22 + Handle *string `json:"handle,omitempty" cborgen:"handle,omitempty"` 19 23 // nb: Number of rated puzzle attempts. 20 24 Nb int64 `json:"nb" cborgen:"nb"` 21 25 // rating: The player's Glicko-2 rating, rounded.
-3
client/.env.example
··· 5 5 # Ranked calls are proxied through the user's PDS via the atproto-proxy header, 6 6 # so the PDS must be able to resolve this DID and reach its serviceEndpoint. 7 7 PUBLIC_SERVICE_DID=did:web:example.com 8 - 9 - # Where to resolve handles during OAuth sign-in. 10 - PUBLIC_HANDLE_RESOLVER=https://bsky.social
-51
client/src/lib/api/profiles.ts
··· 1 - export interface MiniProfile { 2 - handle?: string; 3 - avatar?: string; 4 - displayName?: string; 5 - } 6 - 7 - const cache = new Map<string, MiniProfile>(); 8 - const BATCH_SIZE = 25; // app.bsky.actor.getProfiles caps `actors` at 25 9 - 10 - /** 11 - * Resolve DIDs to handles/avatars via the public Bluesky AppView. Best-effort: 12 - * unresolvable DIDs are simply absent from the returned map (callers fall back 13 - * to showing the DID). 14 - */ 15 - export async function resolveProfiles(dids: string[]): Promise<Map<string, MiniProfile>> { 16 - const missing = [...new Set(dids)].filter((did) => !cache.has(did)); 17 - const batches: string[][] = []; 18 - for (let i = 0; i < missing.length; i += BATCH_SIZE) { 19 - batches.push(missing.slice(i, i + BATCH_SIZE)); 20 - } 21 - 22 - await Promise.all( 23 - batches.map(async (batch) => { 24 - try { 25 - const params = new URLSearchParams(); 26 - for (const did of batch) params.append('actors', did); 27 - const res = await fetch( 28 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}` 29 - ); 30 - if (!res.ok) return; 31 - const data = await res.json(); 32 - for (const profile of data.profiles ?? []) { 33 - cache.set(profile.did, { 34 - handle: profile.handle, 35 - avatar: profile.avatar, 36 - displayName: profile.displayName 37 - }); 38 - } 39 - } catch { 40 - // Cosmetic only. 41 - } 42 - }) 43 - ); 44 - 45 - const result = new Map<string, MiniProfile>(); 46 - for (const did of dids) { 47 - const profile = cache.get(did); 48 - if (profile) result.set(did, profile); 49 - } 50 - return result; 51 - }
+4 -3
client/src/lib/auth/auth.svelte.ts
··· 1 - import { PUBLIC_HANDLE_RESOLVER } from "$env/static/public"; 2 1 import { Agent } from "@atproto/api"; 3 2 import { 3 + AtprotoDohHandleResolver, 4 4 atprotoLoopbackClientMetadata, 5 5 BrowserOAuthClient, 6 6 type OAuthSession, ··· 40 40 //TODO come back and do proper scopes 41 41 const scopes = encodeURIComponent("atproto transition:generic"); 42 42 this.client = new BrowserOAuthClient({ 43 - //TODO swap to use doh for this probably 44 - handleResolver: PUBLIC_HANDLE_RESOLVER, 43 + handleResolver: new AtprotoDohHandleResolver({ 44 + dohEndpoint: "https://cloudflare-dns.com/dns-query", 45 + }), 45 46 clientMetadata: atprotoLoopbackClientMetadata( 46 47 `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopes}`, 47 48 ),
+10
client/src/lib/lexicon/lexicons.ts
··· 708 708 type: 'integer', 709 709 description: 'Number of rated puzzle attempts.', 710 710 }, 711 + handle: { 712 + type: 'string', 713 + format: 'handle', 714 + description: "The player's atproto handle, if resolvable.", 715 + }, 716 + avatar: { 717 + type: 'string', 718 + format: 'uri', 719 + description: "URL of the player's avatar image, if they have one.", 720 + }, 711 721 }, 712 722 }, 713 723 },
+4
client/src/lib/lexicon/types/org/lichess/puzzle/getLeaderboard.ts
··· 52 52 ratingDeviation: number 53 53 /** Number of rated puzzle attempts. */ 54 54 nb: number 55 + /** The player's atproto handle, if resolvable. */ 56 + handle?: string 57 + /** URL of the player's avatar image, if they have one. */ 58 + avatar?: string 55 59 } 56 60 57 61 const hashEntry = 'entry'
+5 -11
client/src/routes/leaderboard/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from "svelte"; 3 3 import { api } from "$lib/api/client"; 4 - import { resolveProfiles, type MiniProfile } from "$lib/api/profiles"; 5 4 import { auth } from "$lib/auth/auth.svelte"; 6 5 import type { Entry } from "$lib/lexicon/types/org/lichess/puzzle/getLeaderboard"; 7 6 8 7 let entries = $state.raw<Entry[]>([]); 9 - let profiles = $state.raw<Map<string, MiniProfile>>(new Map()); 10 8 let loading = $state(true); 11 9 let error = $state<string | null>(null); 12 10 ··· 21 19 }); 22 20 entries = res.data.entries; 23 21 loading = false; 24 - //TODO probably move this to slingshot Or hydrate from the appview, yeah probably do that 25 - // and it can call slingshot 26 - profiles = await resolveProfiles(entries.map((e) => e.did)); 27 22 } catch { 28 23 error = "Could not load the leaderboard. Is the service reachable?"; 29 24 loading = false; ··· 60 55 </thead> 61 56 <tbody> 62 57 {#each entries as entry, index (entry.did)} 63 - {@const profile = profiles.get(entry.did)} 64 58 <tr 65 59 class={{ 66 60 "bg-primary/10 font-semibold": ··· 70 64 <td>{index + 1}</td> 71 65 <td> 72 66 <div class="flex items-center gap-2"> 73 - {#if profile?.avatar} 67 + {#if entry.avatar} 74 68 <div class="avatar"> 75 69 <div class="w-7 rounded-full"> 76 70 <img 77 - src={profile.avatar} 71 + src={entry.avatar} 78 72 alt="" 79 73 /> 80 74 </div> 81 75 </div> 82 76 {/if} 83 - {#if profile?.handle} 77 + {#if entry.handle} 84 78 <a 85 79 class="link-hover link" 86 - href="https://bsky.app/profile/{profile.handle}" 80 + href="https://bsky.app/profile/{entry.handle}" 87 81 target="_blank" 88 82 rel="noreferrer" 89 - >@{profile.handle}</a 83 + >@{entry.handle}</a 90 84 > 91 85 {:else} 92 86 <span
+32 -3
cmd/puzzlemethis/main.go
··· 3 3 import ( 4 4 "context" 5 5 "log/slog" 6 + "net" 7 + "net/http" 6 8 "os" 7 9 "os/signal" 8 10 "sync" 9 11 "syscall" 12 + "time" 10 13 11 14 "github.com/bluesky-social/indigo/atproto/atcrypto" 12 15 "github.com/bluesky-social/indigo/atproto/auth" ··· 17 20 "tangled.org/pds.dad/PuzzleMeThis/internal/server" 18 21 ) 19 22 23 + // newIdentityDirectory mirrors indigo's identity.DefaultDirectory() but with a 24 + // configurable PLC directory URL (config plc.url / env PLC_URL). 25 + func newIdentityDirectory(plcURL string) identity.Directory { 26 + base := identity.BaseDirectory{ 27 + PLCURL: plcURL, 28 + HTTPClient: http.Client{ 29 + Timeout: time.Second * 10, 30 + Transport: &http.Transport{ 31 + Proxy: http.ProxyFromEnvironment, 32 + IdleConnTimeout: time.Millisecond * 1000, 33 + MaxIdleConns: 100, 34 + }, 35 + }, 36 + Resolver: net.Resolver{ 37 + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 38 + d := net.Dialer{Timeout: time.Second * 3} 39 + return d.DialContext(ctx, network, address) 40 + }, 41 + }, 42 + TryAuthoritativeDNS: true, 43 + // primary Bluesky PDS instance only supports HTTP resolution method 44 + SkipDNSDomainSuffixes: []string{".bsky.social"}, 45 + UserAgent: internal.UserAgent(), 46 + } 47 + return identity.NewCacheDirectory(&base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) 48 + } 49 + 20 50 func main() { 21 51 internal.LoadConfig() 22 52 ··· 53 83 54 84 validator := &auth.ServiceAuthValidator{ 55 85 Audience: serviceDID, 56 - //TODO have this swap out the plc 57 - Dir: identity.DefaultDirectory(), 86 + Dir: newIdentityDirectory(viper.GetString("plc.url")), 58 87 } 59 88 60 89 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ··· 64 93 65 94 wg.Go(func() { 66 95 defer stop() 67 - if err := server.RunHTTPServer(ctx, gdb, validator, privKey, serviceDID, challengeTTL); err != nil { 96 + if err := server.RunHTTPServer(ctx, gdb, validator, privKey, serviceDID, challengeTTL, viper.GetString("slingshot.url")); err != nil { 68 97 slog.Error("http server failed", "err", err) 69 98 } 70 99 })
+2
internal/config.go
··· 31 31 viper.SetDefault("service.host", "") 32 32 viper.SetDefault("service.private.key", "") 33 33 viper.SetDefault("service.did", "") 34 + viper.SetDefault("plc.url", "https://plc.directory") 35 + viper.SetDefault("slingshot.url", "https://slingshot.microcosm.blue") 34 36 35 37 viper.SetDefault("challenge.ttl", "60m") 36 38
+1
internal/server/leaderboardHandlers.go
··· 62 62 Nb: int64(p.Nb), 63 63 }) 64 64 } 65 + s.profiles.hydrate(c.Request().Context(), entries) 65 66 return c.JSON(http.StatusOK, &lichess.PuzzleGetLeaderboard_Output{Entries: entries}) 66 67 }
+160
internal/server/profileHydrator.go
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "net/url" 10 + "sync" 11 + "time" 12 + 13 + "tangled.org/pds.dad/PuzzleMeThis/api/lichess" 14 + "tangled.org/pds.dad/PuzzleMeThis/internal" 15 + ) 16 + 17 + // profileHydrator fills leaderboard entries with handles and avatar URLs, 18 + // resolved through a Slingshot instance (https://microcosm.blue/slingshot): 19 + // resolveMiniDoc for the verified handle + PDS, repo.getRecord for the 20 + // app.bsky.actor.profile record whose avatar blobRef becomes a PDS getBlob 21 + // URL. Everything is best-effort — a down Slingshot must never break the 22 + // leaderboard, so failures just leave the fields nil. 23 + type profileHydrator struct { 24 + slingshotURL string 25 + httpClient *http.Client 26 + 27 + mu sync.Mutex 28 + cache map[string]cachedProfile 29 + } 30 + 31 + // cachedProfile is one DID's resolved handle/avatar. Failures are cached too 32 + // (empty fields), so an unreachable Slingshot only costs latency once per TTL. 33 + type cachedProfile struct { 34 + handle string 35 + avatar string 36 + expires time.Time 37 + } 38 + 39 + const ( 40 + profileCacheTTL = 10 * time.Minute 41 + hydrateMaxConcurrency = 8 42 + ) 43 + 44 + func newProfileHydrator(slingshotURL string) *profileHydrator { 45 + return &profileHydrator{ 46 + slingshotURL: slingshotURL, 47 + httpClient: &http.Client{Timeout: 3 * time.Second}, 48 + cache: make(map[string]cachedProfile), 49 + } 50 + } 51 + 52 + // hydrate fills Handle/Avatar on the entries in place, resolving uncached DIDs 53 + // concurrently. It never fails; entries that can't be resolved are left as-is. 54 + // An empty slingshot URL disables hydration entirely. 55 + func (h *profileHydrator) hydrate(ctx context.Context, entries []*lichess.PuzzleGetLeaderboard_Entry) { 56 + if h == nil || h.slingshotURL == "" { 57 + return 58 + } 59 + sem := make(chan struct{}, hydrateMaxConcurrency) 60 + var wg sync.WaitGroup 61 + for _, entry := range entries { 62 + wg.Add(1) 63 + sem <- struct{}{} 64 + go func() { 65 + defer wg.Done() 66 + defer func() { <-sem }() 67 + p := h.lookup(ctx, entry.Did) 68 + if p.handle != "" { 69 + entry.Handle = &p.handle 70 + } 71 + if p.avatar != "" { 72 + entry.Avatar = &p.avatar 73 + } 74 + }() 75 + } 76 + wg.Wait() 77 + } 78 + 79 + func (h *profileHydrator) lookup(ctx context.Context, did string) cachedProfile { 80 + now := time.Now() 81 + h.mu.Lock() 82 + if p, ok := h.cache[did]; ok && now.Before(p.expires) { 83 + h.mu.Unlock() 84 + return p 85 + } 86 + h.mu.Unlock() 87 + 88 + p := h.resolve(ctx, did) 89 + p.expires = now.Add(profileCacheTTL) 90 + h.mu.Lock() 91 + h.cache[did] = p 92 + h.mu.Unlock() 93 + return p 94 + } 95 + 96 + func (h *profileHydrator) resolve(ctx context.Context, did string) cachedProfile { 97 + var p cachedProfile 98 + 99 + var mini struct { 100 + Handle string `json:"handle"` 101 + PDS string `json:"pds"` 102 + } 103 + err := h.getJSON(ctx, "com.bad-example.identity.resolveMiniDoc", url.Values{"identifier": {did}}, &mini) 104 + if err != nil { 105 + slog.Debug("slingshot miniDoc resolution failed", "did", did, "err", err) 106 + return p 107 + } 108 + if mini.Handle != "" && mini.Handle != "handle.invalid" { 109 + p.handle = mini.Handle 110 + } 111 + 112 + // The avatar blobRef lives on the profile record; a missing record (or one 113 + // without an avatar) is normal. 114 + var record struct { 115 + Value struct { 116 + Avatar struct { 117 + Ref struct { 118 + Link string `json:"$link"` 119 + } `json:"ref"` 120 + Cid string `json:"cid"` // legacy blob encoding 121 + } `json:"avatar"` 122 + } `json:"value"` 123 + } 124 + err = h.getJSON(ctx, "com.atproto.repo.getRecord", url.Values{ 125 + "repo": {did}, 126 + "collection": {"app.bsky.actor.profile"}, 127 + "rkey": {"self"}, 128 + }, &record) 129 + if err != nil { 130 + slog.Debug("slingshot profile record fetch failed", "did", did, "err", err) 131 + return p 132 + } 133 + cid := record.Value.Avatar.Ref.Link 134 + if cid == "" { 135 + cid = record.Value.Avatar.Cid 136 + } 137 + if cid != "" && mini.PDS != "" { 138 + p.avatar = fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 139 + mini.PDS, url.QueryEscape(did), url.QueryEscape(cid)) 140 + } 141 + return p 142 + } 143 + 144 + func (h *profileHydrator) getJSON(ctx context.Context, nsid string, params url.Values, out any) error { 145 + u := fmt.Sprintf("%s/xrpc/%s?%s", h.slingshotURL, nsid, params.Encode()) 146 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) 147 + if err != nil { 148 + return err 149 + } 150 + req.Header.Set("User-Agent", internal.UserAgent()) 151 + resp, err := h.httpClient.Do(req) 152 + if err != nil { 153 + return err 154 + } 155 + defer resp.Body.Close() 156 + if resp.StatusCode != http.StatusOK { 157 + return fmt.Errorf("%s returned status %d", nsid, resp.StatusCode) 158 + } 159 + return json.NewDecoder(resp.Body).Decode(out) 160 + }
+2
internal/server/ranked_test.go
··· 103 103 pubKey: pub, 104 104 serviceDID: "did:web:svc.example", 105 105 challengeTTL: time.Minute, 106 + // empty slingshot URL: hydration disabled, no network calls in tests 107 + profiles: newProfileHydrator(""), 106 108 } 107 109 } 108 110
+4 -1
internal/server/server.go
··· 28 28 serviceDID string 29 29 // challengeTTL bounds how long a player has to submit an issued ranked puzzle. 30 30 challengeTTL time.Duration 31 + // profiles resolves leaderboard handles/avatars through Slingshot. 32 + profiles *profileHydrator 31 33 } 32 34 33 - func RunHTTPServer(ctx context.Context, db *gorm.DB, validator *auth.ServiceAuthValidator, privKey atcrypto.PrivateKey, serviceDID string, challengeTTL time.Duration) error { 35 + func RunHTTPServer(ctx context.Context, db *gorm.DB, validator *auth.ServiceAuthValidator, privKey atcrypto.PrivateKey, serviceDID string, challengeTTL time.Duration, slingshotURL string) error { 34 36 pubKey, err := privKey.PublicKey() 35 37 if err != nil { 36 38 return fmt.Errorf("derive public key: %w", err) ··· 43 45 pubKey: pubKey, 44 46 serviceDID: serviceDID, 45 47 challengeTTL: challengeTTL, 48 + profiles: newProfileHydrator(slingshotURL), 46 49 } 47 50 48 51 e := echo.New()
+10
lexicons/org/lichess/puzzle/getLeaderboard.json
··· 56 56 "nb": { 57 57 "type": "integer", 58 58 "description": "Number of rated puzzle attempts." 59 + }, 60 + "handle": { 61 + "type": "string", 62 + "format": "handle", 63 + "description": "The player's atproto handle, if resolvable." 64 + }, 65 + "avatar": { 66 + "type": "string", 67 + "format": "uri", 68 + "description": "URL of the player's avatar image, if they have one." 59 69 } 60 70 } 61 71 }