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
···
const DEFAULT_CURSOR_LIMIT: u64 = 16;
const DEFAULT_CURSOR_LIMIT_MAX: u64 = 100;
+
fn get_default_cursor_limit() -> u64 {
+
DEFAULT_CURSOR_LIMIT
+
}
+
const INDEX_BEGAN_AT_TS: u64 = 1738083600; // TODO: not this
pub async fn serve<S, A>(store: S, addr: A, stay_alive: CancellationToken) -> anyhow::Result<()>
···
let store = store.clone();
move |accept, query| async {
block_in_place(|| count_distinct_dids(accept, query, store))
+
}
+
}),
+
)
+
.route(
+
"/xrpc/blue.microcosm.links.getBacklinks",
+
get({
+
let store = store.clone();
+
move |accept, query| async {
+
block_in_place(|| get_backlinks(accept, query, store))
}
}),
)
···
}
#[derive(Clone, Deserialize)]
+
struct GetBacklinksQuery {
+
/// The link target
+
///
+
/// can be an AT-URI, plain DID, or regular URI
+
subject: String,
+
/// Filter links only from this link source
+
///
+
/// eg.: `app.bsky.feed.like:subject.uri`
+
source: String,
+
cursor: Option<OpaqueApiCursor>,
+
/// Filter links only from these DIDs
+
///
+
/// include multiple times to filter by multiple source DIDs
+
#[serde(default)]
+
did: Vec<String>,
+
/// Set the max number of links to return per page of results
+
#[serde(default = "get_default_cursor_limit")]
+
limit: u64,
+
// TODO: allow reverse (er, forward) order as well
+
}
+
#[derive(Template, Serialize)]
+
#[template(path = "get-backlinks.html.j2")]
+
struct GetBacklinksResponse {
+
total: u64,
+
records: Vec<RecordId>,
+
cursor: Option<OpaqueApiCursor>,
+
#[serde(skip_serializing)]
+
query: GetBacklinksQuery,
+
#[serde(skip_serializing)]
+
collection: String,
+
#[serde(skip_serializing)]
+
path: String,
+
}
+
fn get_backlinks(
+
accept: ExtractAccept,
+
query: axum_extra::extract::Query<GetBacklinksQuery>, // supports multiple param occurrences
+
store: impl LinkReader,
+
) -> Result<impl IntoResponse, http::StatusCode> {
+
let until = query
+
.cursor
+
.clone()
+
.map(|oc| ApiCursor::try_from(oc).map_err(|_| http::StatusCode::BAD_REQUEST))
+
.transpose()?
+
.map(|c| c.next);
+
+
let limit = query.limit;
+
if limit > DEFAULT_CURSOR_LIMIT_MAX {
+
return Err(http::StatusCode::BAD_REQUEST);
+
}
+
+
let filter_dids: HashSet<Did> = HashSet::from_iter(
+
query
+
.did
+
.iter()
+
.map(|d| d.trim())
+
.filter(|d| !d.is_empty())
+
.map(|d| Did(d.to_string())),
+
);
+
+
let Some((collection, path)) = query.source.split_once(':') else {
+
return Err(http::StatusCode::BAD_REQUEST);
+
};
+
let path = format!(".{path}");
+
+
let paged = store
+
.get_links(
+
&query.subject,
+
collection,
+
&path,
+
limit,
+
until,
+
&filter_dids,
+
)
+
.map_err(|_| http::StatusCode::INTERNAL_SERVER_ERROR)?;
+
+
let cursor = paged.next.map(|next| {
+
ApiCursor {
+
version: paged.version,
+
next,
+
}
+
.into()
+
});
+
+
Ok(acceptable(
+
accept,
+
GetBacklinksResponse {
+
total: paged.total,
+
records: paged.items,
+
cursor,
+
query: (*query).clone(),
+
collection: collection.to_string(),
+
path,
+
},
+
))
+
}
+
+
#[derive(Clone, Deserialize)]
struct GetLinkItemsQuery {
target: String,
collection: String,
···
///
/// deprecated: use `did`, which can be repeated multiple times
from_dids: Option<String>, // comma separated: gross
-
#[serde(default = "get_default_limit")]
+
#[serde(default = "get_default_cursor_limit")]
limit: u64,
// TODO: allow reverse (er, forward) order as well
-
}
-
fn get_default_limit() -> u64 {
-
DEFAULT_CURSOR_LIMIT
}
#[derive(Template, Serialize)]
#[template(path = "links.html.j2")]
+54
constellation/templates/get-backlinks.html.j2
···
+
{% extends "base.html.j2" %}
+
{% import "try-it-macros.html.j2" as try_it %}
+
+
{% block title %}Links{% endblock %}
+
{% block description %}All {{ query.source }} records with links to {{ query.subject }}{% endblock %}
+
+
{% block content %}
+
+
{% call try_it::get_backlinks(query.subject, query.source, query.did, query.limit) %}
+
+
<h2>
+
Links to <code>{{ query.subject }}</code>
+
{% if let Some(browseable_uri) = query.subject|to_browseable %}
+
<small style="font-weight: normal; font-size: 1rem"><a href="{{ browseable_uri }}">browse record</a></small>
+
{% endif %}
+
</h2>
+
+
<p><strong>{{ total|human_number }} links</strong> from <code>{{ query.source }}</code>.</p>
+
+
<ul>
+
<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>
+
<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>
+
</ul>
+
+
<h3>Links, most recent first:</h3>
+
+
{% for record in records %}
+
<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>)
+
<strong>Collection</strong>: {{ record.collection }}
+
<strong>RKey</strong>: {{ record.rkey }}
+
-> <a href="https://pdsls.dev/at://{{ record.did().0 }}/{{ record.collection }}/{{ record.rkey }}">browse record</a></pre>
+
{% endfor %}
+
+
{% if let Some(c) = cursor %}
+
<form method="get" action="/xrpc/blue.microcosm.links.getBacklinks">
+
<input type="hidden" name="subject" value="{{ query.subject }}" />
+
<input type="hidden" name="source" value="{{ query.source }}" />
+
<input type="hidden" name="limit" value="{{ query.limit }}" />
+
{% for did in query.did %}
+
<input type="hidden" name="did" value="{{ did }}" />
+
{% endfor %}
+
<input type="hidden" name="cursor" value={{ c|json|safe }} />
+
<button type="submit">next page&hellip;</button>
+
</form>
+
{% else %}
+
<button disabled><em>end of results</em></button>
+
{% endif %}
+
+
<details>
+
<summary>Raw JSON response</summary>
+
<pre class="code">{{ self|tojson }}</pre>
+
</details>
+
+
{% endblock %}
+19
constellation/templates/hello.html.j2
···
<h2>API Endpoints</h2>
+
<h3 class="route"><code>GET /xrpc/blue.microcosm.links.getBacklinks</code></h3>
+
+
<p>A list of records linking to any record, identity, or uri.</p>
+
+
<h4>Query parameters:</h4>
+
+
<ul>
+
<li><p><code>subject</code>: required, must url-encode. Example: <code>at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lgwdn7vd722r</code></p></li>
+
<li><p><code>source</code>: required. Example: <code>app.bsky.feed.like:subject.uri</code></p></li>
+
<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>
+
<li><p><code>limit</code>: optional. Default: <code>16</code>. Maximum: <code>100</code></p></li>
+
</ul>
+
+
<p style="margin-bottom: 0"><strong>Try it:</strong></p>
+
{% call try_it::get_backlinks("at://did:plc:a4pqq234yw7fqbddawjo7y35/app.bsky.feed.post/3m237ilwc372e", "app.bsky.feed.like:subject.uri", [""], 16) %}
+
+
<h3 class="route"><code>GET /links</code></h3>
<p>A list of records linking to a target.</p>
+
+
<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>
<h4>Query parameters:</h4>
+26 -1
constellation/templates/try-it-macros.html.j2
···
+
{% macro get_backlinks(subject, source, dids, limit) %}
+
<form method="get" action="/xrpc/blue.microcosm.links.getBacklinks">
+
<pre class="code"><strong>GET</strong> /links
+
?subject= <input type="text" name="subject" value="{{ subject }}" placeholder="at-uri, did, uri..." />
+
&source= <input type="text" name="source" value="{{ source }}" placeholder="app.bsky.feed.like:subject.uri" />
+
{%- for did in dids %}{% if !did.is_empty() %}
+
&did= <input type="text" name="did" value="{{ did }}" placeholder="did:plc:..." />{% endif %}{% endfor %}
+
<span id="did-placeholder"></span> <button id="add-did">+ did filter</button>
+
&limit= <input type="number" name="limit" value="{{ limit }}" max="100" placeholder="100" /> <button type="submit">get links</button></pre>
+
</form>
+
<script>
+
const addDidButton = document.getElementById('add-did');
+
const didPlaceholder = document.getElementById('did-placeholder');
+
addDidButton.addEventListener('click', e => {
+
e.preventDefault();
+
const i = document.createElement('input');
+
i.placeholder = 'did:plc:...';
+
i.name = "did"
+
const p = addDidButton.parentNode;
+
p.insertBefore(document.createTextNode('&did= '), didPlaceholder);
+
p.insertBefore(i, didPlaceholder);
+
p.insertBefore(document.createTextNode('\n '), didPlaceholder);
+
});
+
</script>
+
{% endmacro %}
+
{% macro links(target, collection, path, dids, limit) %}
<form method="get" action="/links">
<pre class="code"><strong>GET</strong> /links
···
});
</script>
{% endmacro %}
-
{% macro dids(target, collection, path) %}
<form method="get" action="/links/distinct-dids">