Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

add xrpc getBacklinks with new-style link source

Changed files
+210 -5
constellation
+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
··· 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&hellip;</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
··· 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
··· 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">