+111
-4
constellation/src/server/mod.rs
+111
-4
constellation/src/server/mod.rs
···
28
28
const DEFAULT_CURSOR_LIMIT: u64 = 16;
29
29
const DEFAULT_CURSOR_LIMIT_MAX: u64 = 100;
30
30
31
+
fn get_default_cursor_limit() -> u64 {
32
+
DEFAULT_CURSOR_LIMIT
33
+
}
34
+
31
35
const INDEX_BEGAN_AT_TS: u64 = 1738083600; // TODO: not this
32
36
33
37
pub async fn serve<S, A>(store: S, addr: A, stay_alive: CancellationToken) -> anyhow::Result<()>
···
57
61
let store = store.clone();
58
62
move |accept, query| async {
59
63
block_in_place(|| count_distinct_dids(accept, query, store))
64
+
}
65
+
}),
66
+
)
67
+
.route(
68
+
"/xrpc/blue.microcosm.links.getBacklinks",
69
+
get({
70
+
let store = store.clone();
71
+
move |accept, query| async {
72
+
block_in_place(|| get_backlinks(accept, query, store))
60
73
}
61
74
}),
62
75
)
···
233
246
}
234
247
235
248
#[derive(Clone, Deserialize)]
249
+
struct GetBacklinksQuery {
250
+
/// The link target
251
+
///
252
+
/// can be an AT-URI, plain DID, or regular URI
253
+
subject: String,
254
+
/// Filter links only from this link source
255
+
///
256
+
/// eg.: `app.bsky.feed.like:subject.uri`
257
+
source: String,
258
+
cursor: Option<OpaqueApiCursor>,
259
+
/// Filter links only from these DIDs
260
+
///
261
+
/// include multiple times to filter by multiple source DIDs
262
+
#[serde(default)]
263
+
did: Vec<String>,
264
+
/// Set the max number of links to return per page of results
265
+
#[serde(default = "get_default_cursor_limit")]
266
+
limit: u64,
267
+
// TODO: allow reverse (er, forward) order as well
268
+
}
269
+
#[derive(Template, Serialize)]
270
+
#[template(path = "get-backlinks.html.j2")]
271
+
struct GetBacklinksResponse {
272
+
total: u64,
273
+
records: Vec<RecordId>,
274
+
cursor: Option<OpaqueApiCursor>,
275
+
#[serde(skip_serializing)]
276
+
query: GetBacklinksQuery,
277
+
#[serde(skip_serializing)]
278
+
collection: String,
279
+
#[serde(skip_serializing)]
280
+
path: String,
281
+
}
282
+
fn get_backlinks(
283
+
accept: ExtractAccept,
284
+
query: axum_extra::extract::Query<GetBacklinksQuery>, // supports multiple param occurrences
285
+
store: impl LinkReader,
286
+
) -> Result<impl IntoResponse, http::StatusCode> {
287
+
let until = query
288
+
.cursor
289
+
.clone()
290
+
.map(|oc| ApiCursor::try_from(oc).map_err(|_| http::StatusCode::BAD_REQUEST))
291
+
.transpose()?
292
+
.map(|c| c.next);
293
+
294
+
let limit = query.limit;
295
+
if limit > DEFAULT_CURSOR_LIMIT_MAX {
296
+
return Err(http::StatusCode::BAD_REQUEST);
297
+
}
298
+
299
+
let filter_dids: HashSet<Did> = HashSet::from_iter(
300
+
query
301
+
.did
302
+
.iter()
303
+
.map(|d| d.trim())
304
+
.filter(|d| !d.is_empty())
305
+
.map(|d| Did(d.to_string())),
306
+
);
307
+
308
+
let Some((collection, path)) = query.source.split_once(':') else {
309
+
return Err(http::StatusCode::BAD_REQUEST);
310
+
};
311
+
let path = format!(".{path}");
312
+
313
+
let paged = store
314
+
.get_links(
315
+
&query.subject,
316
+
collection,
317
+
&path,
318
+
limit,
319
+
until,
320
+
&filter_dids,
321
+
)
322
+
.map_err(|_| http::StatusCode::INTERNAL_SERVER_ERROR)?;
323
+
324
+
let cursor = paged.next.map(|next| {
325
+
ApiCursor {
326
+
version: paged.version,
327
+
next,
328
+
}
329
+
.into()
330
+
});
331
+
332
+
Ok(acceptable(
333
+
accept,
334
+
GetBacklinksResponse {
335
+
total: paged.total,
336
+
records: paged.items,
337
+
cursor,
338
+
query: (*query).clone(),
339
+
collection: collection.to_string(),
340
+
path,
341
+
},
342
+
))
343
+
}
344
+
345
+
#[derive(Clone, Deserialize)]
236
346
struct GetLinkItemsQuery {
237
347
target: String,
238
348
collection: String,
···
251
361
///
252
362
/// deprecated: use `did`, which can be repeated multiple times
253
363
from_dids: Option<String>, // comma separated: gross
254
-
#[serde(default = "get_default_limit")]
364
+
#[serde(default = "get_default_cursor_limit")]
255
365
limit: u64,
256
366
// TODO: allow reverse (er, forward) order as well
257
-
}
258
-
fn get_default_limit() -> u64 {
259
-
DEFAULT_CURSOR_LIMIT
260
367
}
261
368
#[derive(Template, Serialize)]
262
369
#[template(path = "links.html.j2")]
+54
constellation/templates/get-backlinks.html.j2
+54
constellation/templates/get-backlinks.html.j2
···
1
+
{% extends "base.html.j2" %}
2
+
{% import "try-it-macros.html.j2" as try_it %}
3
+
4
+
{% block title %}Links{% endblock %}
5
+
{% block description %}All {{ query.source }} records with links to {{ query.subject }}{% endblock %}
6
+
7
+
{% block content %}
8
+
9
+
{% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit) %}
10
+
11
+
<h2>
12
+
Links to <code>{{ query.subject }}</code>
13
+
{% if let Some(browseable_uri) = query.subject|to_browseable %}
14
+
<small style="font-weight: normal; font-size: 1rem"><a href="{{ browseable_uri }}">browse record</a></small>
15
+
{% endif %}
16
+
</h2>
17
+
18
+
<p><strong>{{ total|human_number }} links</strong> from <code>{{ query.source }}</code>.</p>
19
+
20
+
<ul>
21
+
<li>See distinct linking DIDs at <code>/links/distinct-dids</code>: <a href="/links/distinct-dids?target={{ query.subject|urlencode }}&collection={{ collection|urlencode }}&path={{ path|urlencode }}">/links/distinct-dids?target={{ query.subject }}&collection={{ collection }}&path={{ path }}</a></li>
22
+
<li>See all links to this target at <code>/links/all</code>: <a href="/links/all?target={{ query.subject|urlencode }}">/links/all?target={{ query.subject }}</a></li>
23
+
</ul>
24
+
25
+
<h3>Links, most recent first:</h3>
26
+
27
+
{% for record in records %}
28
+
<pre style="display: block; margin: 1em 2em" class="code"><strong>DID</strong>: {{ record.did().0 }} (<a href="/links/all?target={{ record.did().0|urlencode }}">DID links</a>)
29
+
<strong>Collection</strong>: {{ record.collection }}
30
+
<strong>RKey</strong>: {{ record.rkey }}
31
+
-> <a href="https://pdsls.dev/at://{{ record.did().0 }}/{{ record.collection }}/{{ record.rkey }}">browse record</a></pre>
32
+
{% endfor %}
33
+
34
+
{% if let Some(c) = cursor %}
35
+
<form method="get" action="/xrpc/blue.microcosm.links.getBacklinks">
36
+
<input type="hidden" name="subject" value="{{ query.subject }}" />
37
+
<input type="hidden" name="source" value="{{ query.source }}" />
38
+
<input type="hidden" name="limit" value="{{ query.limit }}" />
39
+
{% for did in query.did %}
40
+
<input type="hidden" name="did" value="{{ did }}" />
41
+
{% endfor %}
42
+
<input type="hidden" name="cursor" value={{ c|json|safe }} />
43
+
<button type="submit">next page…</button>
44
+
</form>
45
+
{% else %}
46
+
<button disabled><em>end of results</em></button>
47
+
{% endif %}
48
+
49
+
<details>
50
+
<summary>Raw JSON response</summary>
51
+
<pre class="code">{{ self|tojson }}</pre>
52
+
</details>
53
+
54
+
{% endblock %}
+19
constellation/templates/hello.html.j2
+19
constellation/templates/hello.html.j2
···
28
28
29
29
<h2>API Endpoints</h2>
30
30
31
+
<h3 class="route"><code>GET /xrpc/blue.microcosm.links.getBacklinks</code></h3>
32
+
33
+
<p>A list of records linking to any record, identity, or uri.</p>
34
+
35
+
<h4>Query parameters:</h4>
36
+
37
+
<ul>
38
+
<li><p><code>subject</code>: required, must url-encode. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li>
39
+
<li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li>
40
+
<li><p><code>did</code>: optional, filter links to those from specific users. Include multiple times to filter by multiple users. Example: <code>did=did:plc:vc7f4oafdgxsihk4cry2xpze&did=did:plc:vc7f4oafdgxsihk4cry2xpze</code></p></li>
41
+
<li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li>
42
+
</ul>
43
+
44
+
<p style="margin-bottom: 0"><strong>Try it:</strong></p>
45
+
{% call try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16) %}
46
+
47
+
31
48
<h3 class="route"><code>GET /links</code></h3>
32
49
33
50
<p>A list of records linking to a target.</p>
51
+
52
+
<p>[DEPRECATED]: use <code>GET /xrpc/blue.microcosm.links.getBacklinks</code>. New apps should avoid it, but this endpoint <strong>will</strong> remain supported for the forseeable future.</p>
34
53
35
54
<h4>Query parameters:</h4>
36
55
+26
-1
constellation/templates/try-it-macros.html.j2
+26
-1
constellation/templates/try-it-macros.html.j2
···
1
+
{% macro get_backlinks(subject, source, dids, limit) %}
2
+
<form method="get" action="/xrpc/blue.microcosm.links.getBacklinks">
3
+
<pre class="code"><strong>GET</strong> /links
4
+
?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." />
5
+
&source= <input type="text" name="source" value="{{ source }}" placeholder="app.bsky.feed.like:subject.uri" />
6
+
{%- for did in dids %}{% if !did.is_empty() %}
7
+
&did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %}
8
+
<span id="did-placeholder"></span> <button id="add-did">+ did filter</button>
9
+
&limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre>
10
+
</form>
11
+
<script>
12
+
const addDidButton = document.getElementById('add-did');
13
+
const didPlaceholder = document.getElementById('did-placeholder');
14
+
addDidButton.addEventListener('click', e => {
15
+
e.preventDefault();
16
+
const i = document.createElement('input');
17
+
i.placeholder = 'did:plc:...';
18
+
i.name = "did"
19
+
const p = addDidButton.parentNode;
20
+
p.insertBefore(document.createTextNode('&did= '), didPlaceholder);
21
+
p.insertBefore(i, didPlaceholder);
22
+
p.insertBefore(document.createTextNode('\n '), didPlaceholder);
23
+
});
24
+
</script>
25
+
{% endmacro %}
26
+
1
27
{% macro links(target, collection, path, dids, limit) %}
2
28
<form method="get" action="/links">
3
29
<pre class="code"><strong>GET</strong> /links
···
24
50
});
25
51
</script>
26
52
{% endmacro %}
27
-
28
53
29
54
{% macro dids(target, collection, path) %}
30
55
<form method="get" action="/links/distinct-dids">