Tutorial: a blog that federates
In this tutorial you'll read through a small blog built with Ojak, run it, and follow it from Mastodon. By the end, you'll know how an ActivityPub service fits together: an actor others can find and follow, posts they can read in their timelines, and replies and likes that come back.
The blog is a real program in the repository, examples/blog/, and every piece of code on this page is taken from it. The code you read here is the code its tests build and run.
What you'll build
The blog has one author. People can:
- find it as
@blog@your.domainfrom Mastodon, Misskey, or anywhere in the fediverse, and follow it; - see each new post in their home timeline, as an article with its title;
- reply to a post, and see the reply appear under it on the blog;
- like a post, and see the count go up.
The author writes posts on a page of the blog itself.
To follow along you'll need Rust, and to follow the blog from a real Mastodon account you'll also need a way to give your computer a public HTTPS address, such as Tailscale Funnel, Cloudflare Tunnel or ngrok.
Run it first
Clone the repository and start the blog, telling it the address it will be reachable at:
git clone https://github.com/eunha-space/ojak.git
cd ojak
BLOG_ORIGIN=https://your.domain cargo run -p ojak-example-blogIt prints where it is, and the token you'll publish posts with:
Serving https://your.domain/ as @blog@your.domain on 127.0.0.1:8080
Write a post at https://your.domain/new
with the token 3f9c…The blog listens on port 8080 on your own machine. Point your public address at it; with Tailscale, for instance:
tailscale funnel 8080Now search for @blog@your.domain on Mastodon and follow it. Then open /new, write a post with the token, and publish: it arrives in your home timeline. Reply to it, like it, and reload the post's page on the blog.
TIP
BLOG_ORIGIN has to be the exact address other servers reach the blog at, https included. Every link the blog writes is built from it, and a server that fetches the blog's author from a different address will not trust it.
The rest of this page explains how it works.
How it's laid out
| File | What it does |
|---|---|
| src/store.rs | The blog's data: posts, followers, replies, likes |
| src/activitypub.rs | The federation, and the documents it serves |
| src/inbox.rs | What the blog does with the activities it gets |
| src/web.rs, templates/ | The pages a browser sees, and publishing a post |
| src/lib.rs | How the pieces are put together |
| src/main.rs | Configuration, and running it |
| tests/federation.rs | The blog federating with another server |
The data lives in memory, in src/store.rs, so there's no database to set up. That keeps the example short, but it also means the blog forgets everything when it stops. Nothing in store.rs is about ActivityPub, and that's the point: Ojak never holds your application's data. It asks for it through the functions you register, and you keep it wherever you like.
The shared state
Every function Ojak calls receives a Context, which carries a value of your choosing. Here it is an Arc<Blog>, called App, holding the configuration, the store, the author's signing key, and the parts of Ojak that talk to other servers:
pub struct Blog {
pub config: Config,
pub store: Mutex<store::Store>,
/// The author's key, which signs deliveries and fetches.
pub key: SenderKey,
pub fetcher: Arc<Fetcher>,
pub deliverer: Deliverer<MemoryQueue, SenderKey>,
pub federation: Federation<App>,
}- The
Fetcherfetches other servers' documents, such as a new follower's actor, through a client that refuses to reach private addresses. - The
Deliverersends activities to other servers' inboxes through a queue, retrying each inbox on its own, and signs them with the author's key. A singleSenderKeysigns for every sender, which suits a blog with one author; an application with many actors gives it a map of keys by sender instead. - The
Federationanswers requests from other servers. It is built once, at start-up.
The federation
This is everything the blog tells Ojak, in one place:
/// The federation, registered once and serving every request.
///
/// # Errors
///
/// When a template is wrong.
pub fn federation(config: &Config, fetcher: Arc<Fetcher>) -> Result<Federation<App>, BuildError> {
Federation::builder()
.origin(config.origin.clone())
// The author, found by username, and by `@username@host` through
// WebFinger.
.actor(AUTHOR, AUTHOR_PATH, author)
.key_pairs(|ctx: Context<App>, actor: ActorRef| async move {
Ok::<_, Error>(vec![PublicKey::Rsa {
id: ctx.key_id(&actor.kind, &actor.identifier)?.to_string(),
pem: ctx.data().config.public_key_pem.clone(),
}])
})
.handle(|ctx: Context<App>, username: String| async move {
let ours = username == ctx.data().config.username;
Ok::<_, Error>(ours.then(|| ActorRef::new(AUTHOR, username)))
})
// Posts, and the activity that created each.
.object(
POST,
POST_PATH,
|ctx: Context<App>, values: Values| async move { found(&ctx, &values, article) },
)
.object(
CREATE,
CREATE_PATH,
|ctx: Context<App>, values: Values| async move { found(&ctx, &values, create) },
)
.collection(OUTBOX, "/users/{username}/outbox", outbox())
.collection(FOLLOWERS, "/users/{username}/followers", followers())
.nodeinfo(|ctx: Context<App>| async move {
let mut info = NodeInfo::new(Software {
name: "ojak-example-blog".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
repository: Some("https://github.com/eunha-space/ojak".to_owned()),
homepage: None,
});
info.usage.users_total = Some(1);
info.usage.local_posts = Some(ctx.data().store().posts().count() as u64);
Ok::<_, Error>(info)
})
// Where activities arrive, and what verifies them: the fetcher
// fetches senders' keys, the store caches them, and the author's key
// signs those fetches for servers that require it.
.inbox(AUTHOR, "/users/{username}/inbox")
.shared_inbox("/inbox")
.signed_fetch(
fetcher,
MemoryKvStore::new(),
Duration::from_secs(60 * 60),
|ctx: Context<App>| async move { Ok::<_, Error>(Some(ctx.data().key.clone())) },
)
.on(inbox::follow)
.on(inbox::undo)
.on(inbox::create)
.on(inbox::like)
.on(inbox::delete)
.on_error(|error| eprintln!("ActivityPub: {error}"))
.build()
}Each line registers something under a kind, such as "author" or "post", and a URI template, such as /users/{username}. Ojak uses the template both ways: to route a request for /users/blog to the author's function, and to build the URI of the author when the blog needs it. The two can never disagree.
The sections below go through what each line registers.
The author
In ActivityPub, anything that acts, such as a person, a group or a service, is an actor: a JSON document with an inbox to send it activities, and a public key to check its signatures with. The blog's author is a Person:
/// The author, as a `Person`.
async fn author(ctx: Context<App>, username: String) -> Result<Found<Value>, Error> {
let config = &ctx.data().config;
if username != config.username {
return Ok(Found::NotFound);
}
let id = ctx.actor_uri(AUTHOR, &username)?;
let mut actor = json!({
"id": id.as_str(),
"type": "Person",
"preferredUsername": username,
"name": config.title,
"url": ctx.origin().as_str(),
"inbox": ctx.inbox_uri(AUTHOR, &username)?.as_str(),
"outbox": ctx.collection_uri(OUTBOX, &username)?.as_str(),
"followers": ctx.collection_uri(FOLLOWERS, &username)?.as_str(),
"endpoints": {"sharedInbox": ctx.shared_inbox_uri()?.as_str()},
"manuallyApprovesFollowers": false,
"discoverable": true,
});
let keys = ctx.actor_keys(&ActorRef::new(AUTHOR, username)).await?;
with_keys(&mut actor, &keys);
Ok(Found::Found(actor))
}ctx.actor_uri,ctx.inbox_uri,ctx.shared_inbox_uriandctx.collection_uribuild URIs from the templates registered above.with_keysadds the author's public key, which.key_pairs(…)supplied, so other servers can check what the blog signs.endpoints.sharedInboxoffers one inbox for the whole server. A server delivering the same post to several people here sends it once.
Returning Found::NotFound for any other username makes a 404. An actor that used to exist would return Found::Gone, which Ojak serves as a Tombstone with a 410, so other servers know to forget it.
WebFinger, the lookup that turns @blog@your.domain into the author's URI, needs only .handle(…), which maps a username to an actor. Ojak answers /.well-known/webfinger and host-meta from that, and NodeInfo, which describes the server, from .nodeinfo(…).
Posts
A post is served as an Article. Mastodon shows an article as its title with a link to it, which suits a blog. Posting to followers means sending them a Create activity with the article inside:
/// A post, as an `Article`: Mastodon shows its title and links to it.
///
/// # Errors
///
/// When a URI cannot be built.
pub fn article(ctx: &Context<App>, post: &Post) -> Result<Value, Error> {
let username = &ctx.data().config.username;
let id = ctx.object_uri(POST, &[("id", &post.id.to_string())])?;
Ok(json!({
"id": id.as_str(),
"type": "Article",
"attributedTo": ctx.actor_uri(AUTHOR, username)?.as_str(),
"name": post.title,
"content": web::content(&post.body)?,
"url": id.as_str(),
"published": post.published.to_rfc3339(),
"to": [PUBLIC],
"cc": [ctx.collection_uri(FOLLOWERS, username)?.as_str()],
}))
}
/// The `Create` that published a post, with the post in it: what followers
/// are sent.
///
/// # Errors
///
/// When a URI cannot be built.
pub fn create(ctx: &Context<App>, post: &Post) -> Result<Value, Error> {
let username = &ctx.data().config.username;
let article = article(ctx, post)?;
Ok(json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": ctx.object_uri(CREATE, &[("id", &post.id.to_string())])?.as_str(),
"type": "Create",
"actor": ctx.actor_uri(AUTHOR, username)?.as_str(),
"published": article["published"],
"to": article["to"],
"cc": article["cc"],
"object": article,
}))
}toholds the public collection, so anyone may see the post, andccholds the author's followers, so it shows in their home timelines.urlis where a person reads the post. It's the same URL as the article itself: Ojak serves the article to a server that asks for ActivityPub, and passes a browser's request on to the blog's own page.- The content is rendered by the same template as the post's page, so the text is escaped the same way in both.
The two object dispatchers in the federation serve these for /posts/{id} and /posts/{id}/create.
Collections
The author links to two collections. The outbox lists what the author has published, ten posts to a page:
/// The author's posts, newest first, as their `Create`s, ten to a page. A
/// page's cursor is the ID of the newest post on it.
fn outbox() -> Collection<App> {
Collection::new(
|ctx: Context<App>, username: String, cursor: Option<String>| async move {
if username != ctx.data().config.username {
return Ok(None);
}
let newest = cursor.and_then(|c| c.parse().ok()).unwrap_or(u64::MAX);
let posts: Vec<Post> = ctx
.data()
.store()
.posts()
.filter(|post| post.id <= newest)
.take(PAGE_SIZE + 1)
.cloned()
.collect();
let next = posts.get(PAGE_SIZE).map(|post| post.id.to_string());
let items = posts
.iter()
.take(PAGE_SIZE)
.map(|post| create(&ctx, post))
.collect::<Result<_, _>>()?;
Ok::<_, Error>(Some(Page {
items,
next,
prev: None,
}))
},
)
.count(|ctx: Context<App>, _| async move {
Ok::<_, Error>(Some(ctx.data().store().posts().count() as u64))
})
.first_cursor(|ctx: Context<App>, _| async move {
let newest = ctx.data().store().posts().next().map_or(0, |post| post.id);
Ok::<_, Error>(Some(First::At(newest.to_string())))
})
}Ojak writes the OrderedCollection and its OrderedCollectionPages, with their first and next links, from the page function, the counter and the first cursor. A cursor is any string you choose; here it's the ID of the newest post on the page.
The followers collection shows how many people follow the blog, without listing who, as Mastodon does when an account hides its followers:
/// Who follows the blog: counted, and not listed.
fn followers() -> Collection<App> {
Collection::new(|ctx: Context<App>, username: String, _| async move {
let ours = username == ctx.data().config.username;
Ok::<_, Error>(ours.then(Page::default))
})
.count(|ctx: Context<App>, _| async move {
Ok::<_, Error>(Some(ctx.data().store().follower_count() as u64))
})
.first_cursor(|_, _| async { Ok::<_, Error>(Some(First::Hidden)) })
}Pages for people
The blog's pages share URLs with its ActivityPub documents. ojak_axum::wrap puts the federation in front of the blog's own axum router:
/// The whole blog as an axum router: Ojak answers ActivityPub requests,
/// and the web pages answer the rest.
pub fn router(app: App) -> axum::Router {
let pages = web::router().with_state(app.clone());
let federation = app.federation.clone();
ojak_axum::wrap(pages, federation, move |_| Some(app.clone()))
}A request that asks for ActivityPub, with Accept: application/activity+json, is answered by Ojak. Any other request, including a browser's for the same URL, goes on to the blog's routes. Here is the post page, rendered with a minijinja template:
async fn post_page(
State(blog): State<App>,
Path(id): Path<u64>,
) -> Result<Html<String>, StatusCode> {
let ctx = {
let store = blog.store();
let post = store.post(id).ok_or(StatusCode::NOT_FOUND)?;
let comments: Vec<Value> = store
.comments(id)
.iter()
.map(|comment| context! { author => comment.author.to_string(), text => comment.text.clone() })
.collect();
context! {
title => post.title.clone(),
date => post.published.format("%Y-%m-%d").to_string(),
paragraphs => paragraphs(&post.body),
likes => store.like_count(id),
comments,
}
};
render(&blog, "post.html", ctx)
}{% extends "base.html" %}
{% block title %}{{ title }} · {{ blog_title }}{% endblock %}
{% block main %}
<h1>{{ title }}</h1>
<p><small>{{ date }}</small></p>
{% include "content.html" %}
<p>♥ {{ likes }}</p>
<h2>Replies</h2>
<ul>
{% for comment in comments %}
<li><a href="{{ comment.author }}">{{ comment.author }}</a>: {{ comment.text }}</li>
{% else %}
<li>Reply from the fediverse, and it will show up here.</li>
{% endfor %}
</ul>
{% endblock %}minijinja escapes everything it puts into an .html template, which matters here: replies come from other servers, and nothing in them should be able to run in your readers' browsers.
Accepting follows
Everything so far answers GET requests. Activities from other servers arrive as POSTs to the inbox, and each one reaches a listener registered for its type, such as .on(inbox::follow).
Before any listener runs, Ojak has checked the request's HTTP signature against the sender's public key, and checked that the activity's actor is on the same server as that key. received.sender is the actor Ojak authenticated. Everything else the activity says is a claim.
When someone follows the blog, it notes where to deliver to them and accepts:
/// Someone follows the blog: note where to deliver to them, and accept.
pub async fn follow(ctx: Context<App>, follow: Received<Follow>) -> Result<(), Error> {
let blog = ctx.data();
let author = ctx.actor_uri(AUTHOR, &blog.config.username)?;
let names_author = |object: &AnyObject| {
object
.id()
.is_some_and(|iri| ctx.parse_actor(AUTHOR, iri.as_str()).is_some())
};
if !follow.activity.objects.iter().any(names_author) {
return Ok(());
}
let Some(id) = follow.activity.id.as_ref().map(ToString::to_string) else {
return Ok(());
};
// The follower's actor document says where their inbox is. The fetch
// is signed with the author's key, for servers that require it.
let actor = blog.fetcher.lookup(&follow.sender, Some(&blog.key)).await?;
let inbox = actor.json["endpoints"]["sharedInbox"]
.as_str()
.or_else(|| actor.json["inbox"].as_str())
.ok_or("the follower has no inbox")?;
let inbox: Url = inbox.parse()?;
blog.store().follow(
follow.sender.clone(),
Follower {
follow: id.clone(),
inbox: inbox.clone(),
},
);
let accept = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{author}#accepts/{}", blog.store().next_id()),
"type": "Accept",
"actor": author.as_str(),
"object": follow.document,
});
blog.deliverer
.send(author.as_str(), &accept, [inbox])
.await?;
Ok(())
}follow.activityis theFollow, read into a Rust type from ojak-vocab. The listener first checks that it names the blog's author.- The follower's actor document says where their inbox is.
fetcher.lookupfetches it, signed with the author's key, since some servers only answer signed requests. - The
Acceptnames theFollowit accepts. Until it arrives, Mastodon shows the follow as pending. deliverer.sendqueues theAcceptand returns. The delivery loop sends it, signed, and retries it if the other server is down.
Publishing
When the author publishes, from /new or from a program, the blog stores the post and queues its Create for every follower:
/// Publish a post, and queue its `Create` for every follower's inbox. The
/// deliverer sends it in the background, retrying each inbox on its own.
/// Returns the post's URL.
async fn publish_and_deliver(
blog: &std::sync::Arc<Blog>,
title: String,
body: String,
) -> Result<url::Url, ojak::federation::Error> {
let post = blog.store().publish(title, body);
let ctx = blog.context();
let create = activitypub::create(&ctx, &post)?;
let author = ctx.actor_uri(AUTHOR, &blog.config.username)?;
let inboxes = blog.store().inboxes();
blog.deliverer
.send(author.as_str(), &create, inboxes)
.await?;
Ok(ctx.object_uri(activitypub::POST, &[("id", &post.id.to_string())])?)
}Blog::context() builds a context outside of any request, with Federation::context, so publishing can build URIs the same way the dispatchers do. store.inboxes() gives each follower's shared inbox if their server has one, once each, so a server with a hundred followers of the blog gets one delivery.
Replies, likes and taking things back
A reply is a Create of a Note whose inReplyTo names one of the blog's posts:
/// A reply to one of the posts becomes a comment under it.
pub async fn create(ctx: Context<App>, create: Received<Create>) -> Result<(), Error> {
for object in &create.activity.objects {
// A note the sender could not vouch for arrives as a reference, and
// is left alone here; fetching it is an exercise in the tutorial.
let AnyObject::Note(note) = object else {
continue;
};
let (Some(id), Some(text)) = (¬e.id, ¬e.content.value) else {
continue;
};
for target in ¬e.reply_targets {
if let Some(post) = target.id().and_then(|iri| our_post(&ctx, iri.as_str())) {
ctx.data().store().comment(
post,
Comment {
id: id.to_string(),
author: create.sender.clone(),
text: crate::web::strip_tags(text),
},
);
}
}
}
Ok(())
}/// The post `iri` is, if it is one of ours.
fn our_post(ctx: &Context<App>, iri: &str) -> Option<u64> {
ctx.parse_object(POST, iri)?["id"].parse().ok()
}ctx.parse_object is the templates used backwards: it says whether an IRI is one of the blog's posts, and with which values. ctx.parse_actor does the same for actors, and the follow listener uses it to check that a Follow is for the author.
A reply's content is HTML from another server. The blog stores only its text, with the tags removed. To keep a reply's links and formatting, run it through an HTML sanitiser, such as the ammonia crate, instead.
The Note arrives embedded in the Create only when it's on the sender's own server. Ojak reduces an embedded object the sender can't vouch for to a bare reference, so a server can't put words in the mouth of someone elsewhere.
Likes are counted once per actor:
/// A like of one of the posts is counted, once per actor.
pub async fn like(ctx: Context<App>, like: Received<Like>) -> Result<(), Error> {
for object in &like.activity.objects {
if let Some(post) = object.id().and_then(|iri| our_post(&ctx, iri.as_str())) {
ctx.data().store().like(post, like.sender.clone());
}
}
Ok(())
}When the author of a reply deletes it, their server sends a Delete, and the blog removes the comment, but only if the sender wrote it:
/// A reply deleted where it was written is removed here too, if its author
/// is the one deleting it.
pub async fn delete(ctx: Context<App>, delete: Received<Delete>) -> Result<(), Error> {
for object in &delete.activity.objects {
if let Some(id) = object.id() {
ctx.data().store().uncomment(id.as_str(), &delete.sender);
}
}
Ok(())
}Unfollowing is an Undo of the Follow. Because followers are kept by actor, only the sender's own follow can be removed:
/// Someone stops following. Only the sender's own follow is removed: the
/// follower is looked up by the sender, and the follow has to be theirs.
pub async fn undo(ctx: Context<App>, undo: Received<Undo>) -> Result<(), Error> {
for object in &undo.activity.objects {
if let Some(follow) = object.id() {
ctx.data().store().unfollow(&undo.sender, follow.as_str());
}
}
Ok(())
}These checks, that the sender wrote the comment and that the follow is theirs, are the application's to make, because only the application knows who wrote what. Ojak makes sure sender is who it says it is.
Running it
src/main.rs reads the configuration from environment variables, makes the author's key on the first run, and starts two things:
// Ojak does not spawn tasks of its own: the delivery loop is ours to
// run. It finishes what is in flight before it stops.
let deliveries = tokio::spawn({
let blog = blog.clone();
async move {
blog.deliverer
.run_until(async {
let _ = tokio::signal::ctrl_c().await;
})
.await;
}
});
let listener = tokio::net::TcpListener::bind(&listen).await?;
let config = &blog.config;
println!(
"Serving {} as @{}@{} on {listen}",
config.origin,
config.username,
config.origin.host_str().unwrap_or_default()
);
println!("Write a post at {}new", config.origin);
if env::var("BLOG_TOKEN").is_err() {
println!("with the token {}", config.token);
}
axum::serve(listener, router(blog))
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
})
.await?;
deliveries.await?;Ojak never starts a task by itself, so the delivery loop is the blog's to run. When you press Ctrl-C, it finishes the deliveries it's sending before it stops.
Testing it
tests/federation.rs runs the blog against a second server, a reader, which is the part that's hard to test by hand. Ojak provides that server, with its testing feature:
[dev-dependencies]
ojak = { git = "https://github.com/eunha-space/ojak.git", features = ["testing"] }ojak::testing::Remote runs on the loopback interface. Any name is one of its actors, with a public key, and it keeps whatever is delivered to its inboxes. It also signs activities for the blog's inbox, the way a real server would:
/// `activity`, delivered to the blog's inbox by the reader's server, signed
/// by the reader.
async fn send(blog: &App, remote: &Remote, activity: &Value) -> StatusCode {
let request = remote.sign("reader", &format!("{BLOG}/users/blog/inbox"), activity);
router(blog.clone())
.oneshot(request.map(Body::from))
.await
.unwrap()
.status()
}The blog's client refuses to reach the loopback interface, as any server's should, so the test builds the blog with ojak::testing::client_config(), which allows it. Then the reader follows, receives the Accept and the new post, replies, likes, deletes the reply and unfollows, and the test checks each step against remote.received() and the blog's pages. deliverer.run_once() sends what's queued, so the test doesn't have to wait for the delivery loop.
Run the tests with:
cargo test -p ojak-example-blogWhere to go from here
Some things to try, each a small change to the blog:
- Keep the data. Store posts and followers in a database, and use ojak-postgres's
PostgresQueuefor the deliverer, so deliveries survive a restart. - Fetch replies it can't vouch for. A reply that arrives as a bare reference is ignored now. Fetch it with
fetcher.lookup, which checks it came from its author's server, and store it. - Take back likes. An
Undoof aLikeis ignored now. - Edit and delete posts. Send followers an
Updateor aDelete, and serve a deleted post asFound::Gone. - Followers-only posts. Address a post to the followers collection only, and refuse to serve it with
.authorize(…)unless the request is signed by a follower. - Queue the inbox. With
.inbox_queue(…)and anInboxWorker, listeners run in the background, and the inbox answers at once.
The guide explains each part of Ojak in more depth.