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