···44use serde::{Deserialize, Serialize};
55use serde_with::serde_as;
66use std::collections::HashMap;
77+use std::time::{UNIX_EPOCH, Duration};
78use tokio::net::{TcpListener, ToSocketAddrs};
89use tokio::task::block_in_place;
910use tokio_util::sync::CancellationToken;
10111111-use crate::storage::LinkReader;
1212+use crate::storage::{LinkReader, StorageStats};
1213use constellation::{CountsByCount, Did, RecordId};
13141415mod acceptable;
···1920const DEFAULT_CURSOR_LIMIT: u64 = 16;
2021const DEFAULT_CURSOR_LIMIT_MAX: u64 = 100;
21222323+const INDEX_BEGAN_AT_TS: u64 = 1738083600; // TODO: not this
2424+2525+2226pub async fn serve<S, A>(store: S, addr: A, stay_alive: CancellationToken) -> anyhow::Result<()>
2327where
2428 S: LinkReader,
2529 A: ToSocketAddrs,
2630{
2731 let app = Router::new()
2828- .route("/", get(hello))
3232+ .route(
3333+ "/",
3434+ get({
3535+ let store = store.clone();
3636+ move |accept| async { block_in_place(|| hello(accept, store)) }
3737+ }),
3838+ )
2939 .route(
3040 "/links/count",
3141 get({
···93103#[template(path = "hello.html.j2")]
94104struct HelloReponse {
95105 help: &'static str,
106106+ days_indexed: u64,
107107+ stats: StorageStats,
96108}
9797-async fn hello(accept: ExtractAccept) -> impl IntoResponse {
9898- acceptable(accept, HelloReponse {
9999- help: "open this URL in a web browser (or request with Accept: text/html) for information about this API."
100100- })
109109+fn hello(accept: ExtractAccept, store: impl LinkReader) -> Result<impl IntoResponse, http::StatusCode> {
110110+ let stats = store
111111+ .get_stats()
112112+ .map_err(|_| http::StatusCode::INTERNAL_SERVER_ERROR)?;
113113+ let days_indexed = (UNIX_EPOCH + Duration::from_secs(INDEX_BEGAN_AT_TS))
114114+ .elapsed()
115115+ .map_err(|_| http::StatusCode::INTERNAL_SERVER_ERROR)?
116116+ .as_secs() / 86400;
117117+ Ok(acceptable(accept, HelloReponse {
118118+ help: "open this URL in a web browser (or request with Accept: text/html) for information about this API.",
119119+ days_indexed,
120120+ stats,
121121+ }))
101122}
102123103124#[derive(Clone, Deserialize)]
+2-1
constellation/src/storage/mod.rs
···11use anyhow::Result;
22use constellation::{ActionableEvent, CountsByCount, Did, RecordId};
33use std::collections::HashMap;
44+use serde::{Deserialize, Serialize};
4556pub mod mem_store;
67pub use mem_store::MemStorage;
···1718 pub next: Option<u64>,
1819}
19202020-#[derive(Debug, PartialEq)]
2121+#[derive(Debug, Deserialize, Serialize, PartialEq)]
2122pub struct StorageStats {
2223 /// estimate of how many accounts we've seen create links. the _subjects_ of any links are not represented here.
2324 /// for example: new user A follows users B and C. this count will only increment by one, for A.
+6-1
constellation/templates/base.html.j2
···3939 padding: 0.5em 0.3em;
4040 max-width: 100%;
4141 }
4242+ .stat {
4343+ color: #f90;
4444+ font-size: 1.618rem;
4545+ font-weight: bold;
4646+ }
4247 details {
4348 margin: 2em 0 3em;
4449 }
···4954 </style>
5055 </head>
5156 <body class="{% block body_classes %}{% endblock %}">
5252- <h1><a href="/">This</a> is a <a href="https://github.com/at-ucosm/links/tree/main/constellation">constellation 🌌</a> server from <a href="https://github.com/at-ucosm">microcosm</a> ✨</h1>
5757+ <h1><a href="/">This</a> is a <a href="https://github.com/at-ucosm/links/tree/main/constellation">constellation 🌌</a> API server from <a href="https://github.com/at-ucosm">microcosm</a> ✨</h1>
5358 {% block content %}{% endblock %}
54595560 <footer>
+1-1
constellation/templates/dids-count.html.j2
···1414 {% endif %}
1515 </h2>
16161717- <p><strong><code>{{ total }}</code></strong> total linking DIDs from <code>{{ query.collection }}</code> at <code>{{ query.path }}</code></p>
1717+ <p><strong><code>{{ total|human_number }}</code></strong> total linking DIDs from <code>{{ query.collection }}</code> at <code>{{ query.path }}</code></p>
18181919 <ul>
2020 <li>See these dids at <code>/links/distinct-dids</code>: <a href="/links/distinct-dids?target={{ query.target|urlencode }}&collection={{ query.collection|urlencode }}&path={{ query.path|urlencode() }}">/links/distinct-dids?target={{ query.target|urlencode }}&collection={{ query.collection|urlencode }}&path={{ query.path|urlencode }}</a></li>
+1-1
constellation/templates/dids.html.j2
···1414 {% endif %}
1515 </h2>
16161717- <p><strong>{{ total }} dids</strong> from <code>{{ query.collection }}</code> at <code>{{ query.path }}</code></p>
1717+ <p><strong>{{ total|human_number }} dids</strong> from <code>{{ query.collection }}</code> at <code>{{ query.path }}</code></p>
18181919 <ul>
2020 <li>See linking records to this target at <code>/links</code>: <a href="/links?target={{ query.target|urlencode }}&collection={{ query.collection|urlencode }}&path={{ query.path|urlencode }}">/links?target={{ query.target }}&collection={{ query.collection }}&path={{ query.path }}</a></li>
···55{% block body_classes %}home{% endblock %}
6677{% block content %}
88- <p>Every interaction in Bluesky and atproto at large tends to appear as a <em>link</em> from a new repository record to <em>somewhere</em>: liking a post creates a record with a link to the post, blocking a spammer creates a record with reference to their DID.</p>
981010- <p>This service attempts to aggregate all of these links, globally, from all content coming throught the firehose. It provides generic API endpoints to answer questions like <strong>how many likes does a post have</strong> and <strong>who follows a user</strong>.</p>
99+ <p>Constellation is a self-hosted JSON API to an atproto-wide index of PDS record back-links, so you can query social interactions in real time. It can answer questions like:</p>
11101212- <p>It is very much a <strong>work in progress</strong>. The database has not been backfilled, so any interactions occurring before its last reset will be missing.</p>
1111+ <ul>
1212+ <li><a href="/links/count/distinct-dids?target={{ "at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3lhhz7k2yqk2h"|urlencode }}&collection=app.bsky.feed.like&path=.subject.uri">How many people liked a liked a bluesky post?</a></li>
1313+ <li><a href="/links/distinct-dids?target=did:plc:oky5czdrnfjpqslsw2a5iclo&collection=app.bsky.graph.follow&path=.subject">Who are all the bluesky followers of an identity?</a></li>
1414+ <li><a href="/links?target=at://did:plc:nlromb2qyyl6rszaluwhfy6j/fyi.unravel.frontpage.post/3lhd2ivyc422n&collection=fyi.unravel.frontpage.comment&path=.post.uri">What are all the replies to a Frontpage submission?</a></li>
1515+ <li><a href="/links/all?target=did:plc:vc7f4oafdgxsihk4cry2xpze">What are <em>all</em> the sources of links to an identity?</a></li>
1616+ <li>and more</li>
1717+ </ul>
13181919+ <p>
2020+ This server has indexed <span class="stat">{{ stats.linking_records|human_number }}</span> links between <span class="stat">{{ stats.targetables|human_number }}</span> targets and sources from <span class="stat">{{ stats.dids|human_number }}</span> identities over <span class="stat">{{ days_indexed|human_number }}</span> days.<br/>
2121+ <small>(indexing new records in real time, backfill still TODO)</small>
2222+ </p>
14231515- <h2>Endpoints</h2>
2424+ <p>The API is currently <strong>unstable</strong>. But feel free to use it! If you want to be nice, put your project name and bsky username (or email) in your user-agent header for api requests.</p>
2525+2626+2727+ <h2>API Endpoints</h2>
16281729 <h3 class="route"><code>GET /links</code></h3>
1830