mirror of
https://github.com/LemmyNet/activitypub-federation-rust
synced 2024-11-23 20:03:14 +00:00
Further improvements
This commit is contained in:
parent
69e77dfa74
commit
19c459fc02
34 changed files with 1026 additions and 401 deletions
23
.drone.yml
23
.drone.yml
|
@ -18,7 +18,7 @@ steps:
|
|||
environment:
|
||||
CARGO_HOME: .cargo
|
||||
commands:
|
||||
- cargo check --all --all-targets
|
||||
- cargo check --all-features --all-targets
|
||||
|
||||
- name: cargo clippy
|
||||
image: rust:1.65-bullseye
|
||||
|
@ -26,33 +26,38 @@ steps:
|
|||
CARGO_HOME: .cargo
|
||||
commands:
|
||||
- rustup component add clippy
|
||||
- cargo clippy --workspace --tests --all-targets --all-features --
|
||||
- cargo clippy --all-targets --all-features --
|
||||
-D warnings -D deprecated -D clippy::perf -D clippy::complexity
|
||||
-D clippy::dbg_macro -D clippy::inefficient_to_string
|
||||
-D clippy::items-after-statements -D clippy::implicit_clone
|
||||
-D clippy::wildcard_imports -D clippy::cast_lossless
|
||||
-D clippy::manual_string_new -D clippy::redundant_closure_for_method_calls
|
||||
- cargo clippy --workspace --all-features -- -D clippy::unwrap_used
|
||||
- cargo clippy --all-features -- -D clippy::unwrap_used
|
||||
|
||||
- name: cargo test
|
||||
image: rust:1.65-bullseye
|
||||
environment:
|
||||
CARGO_HOME: .cargo
|
||||
RUST_BACKTRACE: 1
|
||||
commands:
|
||||
- cargo test --workspace --no-fail-fast
|
||||
- cargo test --all-features --no-fail-fast
|
||||
|
||||
- name: cargo run actix
|
||||
- name: cargo doc
|
||||
image: rust:1.65-bullseye
|
||||
environment:
|
||||
CARGO_HOME: .cargo
|
||||
commands:
|
||||
- cargo doc --all-features
|
||||
|
||||
- name: cargo run actix example
|
||||
image: rust:1.65-bullseye
|
||||
environment:
|
||||
CARGO_HOME: .cargo
|
||||
RUST_BACKTRACE: 1
|
||||
commands:
|
||||
- cargo run --example simple_federation --features actix-web
|
||||
- name: cargo run axum
|
||||
|
||||
- name: cargo run axum example
|
||||
image: rust:1.65-bullseye
|
||||
environment:
|
||||
CARGO_HOME: .cargo
|
||||
RUST_BACKTRACE: 1
|
||||
commands:
|
||||
- cargo run --example simple_federation --features axum
|
||||
|
|
42
Cargo.lock
generated
42
Cargo.lock
generated
|
@ -18,6 +18,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"chrono",
|
||||
"derive_builder",
|
||||
"displaydoc",
|
||||
"dyn-clone",
|
||||
"enum_delegate",
|
||||
"env_logger",
|
||||
|
@ -32,6 +33,7 @@ dependencies = [
|
|||
"openssl",
|
||||
"pin-project-lite",
|
||||
"rand",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
"serde",
|
||||
|
@ -290,13 +292,12 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
|||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.6.0"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "744864363a200a5e724a7e61bc8c11b6628cf2e3ec519c8a1a48e609a8156b40"
|
||||
checksum = "2fb79c228270dcf2426e74864cabc94babb5dbab01a4314e702d2f16540e1591"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
|
@ -325,9 +326,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79b8558f5a0581152dc94dcd289132a1d377494bdeafcd41869b3258e3e2ad92"
|
||||
checksum = "1cae3e661676ffbacb30f1a824089a8c9150e71017f7e1e38f2aa32009188d34"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
@ -651,6 +652,17 @@ dependencies = [
|
|||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bf95dc3f046b9da4f2d51833c0d3547d8564ef6910f5c1ed130306a75b92886"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.9"
|
||||
|
@ -866,9 +878,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
|
@ -1142,9 +1154,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3dfc802da7b1cf80aefffa0c7b2f77247c8b32206cc83c270b61264f5b360a80"
|
||||
checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
|
@ -1416,9 +1428,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a"
|
||||
checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
|
@ -1600,9 +1612,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "184c643044780f7ceb59104cef98a5a6f12cb2288a7bc701ab93a362b49fd47d"
|
||||
checksum = "26b04f22b563c91331a10074bda3dd5492e3cc39d56bd557e91c0af42b6c7341"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
@ -1694,9 +1706,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8"
|
||||
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||
|
||||
[[package]]
|
||||
name = "task-local-extensions"
|
||||
|
|
|
@ -37,14 +37,16 @@ bytes = "1.3.0"
|
|||
futures-core = { version = "0.3.25", default-features = false }
|
||||
pin-project-lite = "0.2.9"
|
||||
activitystreams-kinds = "0.2.1"
|
||||
regex = { version = "1.7.1", default-features = false, features = ["std"] }
|
||||
|
||||
# Actix-web
|
||||
actix-web = { version = "4.2.1", default-features = false, optional = true }
|
||||
|
||||
# Axum
|
||||
axum = { version = "0.6.0", features = ["json", "headers", "macros", "original-uri"], optional = true }
|
||||
axum = { version = "0.6.0", features = ["json", "headers"], default-features = false, optional = true }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
hyper = { version = "0.14", optional = true }
|
||||
displaydoc = "0.2.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
@ -55,6 +57,7 @@ axum = ["dep:axum", "dep:tower", "dep:hyper"]
|
|||
rand = "0.8.5"
|
||||
env_logger = "0.9.3"
|
||||
tower-http = { version = "0.3", features = ["map-request-body", "util"] }
|
||||
axum = { version = "0.6.0", features = ["http1", "tokio", "query"], default-features = false }
|
||||
axum-macros = "0.3.4"
|
||||
|
||||
[profile.dev]
|
||||
|
|
418
README.md
418
README.md
|
@ -3,54 +3,404 @@ Activitypub-Federation
|
|||
[![Build Status](https://drone.join-lemmy.org/api/badges/LemmyNet/activitypub-federation-rust/status.svg)](https://drone.join-lemmy.org/LemmyNet/activitypub-federation-rust)
|
||||
[![Crates.io](https://img.shields.io/crates/v/activitypub-federation.svg)](https://crates.io/crates/activitypub-federation)
|
||||
|
||||
A high-level framework for [ActivityPub](https://www.w3.org/TR/activitypub/) federation in Rust, extracted from [Lemmy](https://join-lemmy.org/). The goal is that this library can take care of almost everything related to federation for different projects. For now it is still far away from that goal, and has many rough edges that need to be smoothed.
|
||||
A high-level framework for [ActivityPub](https://www.w3.org/TR/activitypub/) federation in Rust. The goal is to encapsulate all basic functionality, so that developers can easily use the protocol without any prior knowledge.
|
||||
|
||||
You can join the Matrix channel [#activitystreams:matrix.asonix.dog](https://matrix.to/#/%23activitystreams:matrix.asonix.dog?via=matrix.asonix.dog) to discuss about Activitypub in Rust.
|
||||
The ActivityPub protocol is a decentralized social networking protocol. It allows web servers to exchange data using JSON over HTTP. Data can be fetched on demand, and also delivered directly to inboxes for live updates.
|
||||
|
||||
## Features
|
||||
While Activitypub is not in widespread use yet, is has the potential to form the basis of the next generation of social media. This is because it has a number of major advantages compared to existing platforms and alternative technologies:
|
||||
|
||||
- ObjectId type, wraps the `id` url and allows for type safe fetching of objects, both from database and HTTP
|
||||
- Queue for activity sending, handles HTTP signatures, retry with exponential backoff, all in background workers
|
||||
- Inbox for receiving activities, verifies HTTP signatures, performs other basic checks and helps with routing
|
||||
- Data structures for federation are defined by the user, not the library. This gives you maximal flexibility, and lets you accept only messages which your code can handle. Others are rejected automatically during deserialization.
|
||||
- Generic error type (unfortunately this was necessary)
|
||||
- various helpers for verification, (de)serialization, context etc
|
||||
- **Interoperability**: Imagine being able to comment under a Youtube video directly from twitter.com, and having the comment shown under the video on youtube.com. Or following a Subreddit from Facebook. Such functionality is already available on the equivalent Fediverse platforms, thanks to common usage of Activitypub.
|
||||
- **Ease of use**: From a user perspective, decentralized social media works almost identically to existing websites: a website with email and password based login. Unlike pure peer-to-peer networks, it is not necessary to handle private keys or install any local software.
|
||||
- **Open ecosystem**: All existing Fediverse software is open source, and there are no legal or bureaucratic requirements to start federating. That means anyone can create or fork federated software. In this way different software platforms can exist in the same network according to the preferences of different user groups. It is not necessary to target the lowest common denominator as with corporate social media.
|
||||
- **Censorship resistance**: Current social media platforms are under the control of a few corporations and are actively being censored as revealed by the [Twitter Files](https://jordansather.substack.com/p/running-list-of-all-twitter-files). This would be much more difficult on a federated network, as it would require the cooperation of every single instance administrator. Additionally, users who are affected by censorship can create their own websites and stay connected with the network.
|
||||
- **Low barrier to entry**: All it takes to host a federated website are a small server, a domain and a TLS certificate. All of this is easily in the reach of individual hobbyists. There is also some technical knowledge needed, but this can be avoided with managed hosting platforms.
|
||||
|
||||
## How to use
|
||||
Below is a complete guide that explains how to create a federated project from scratch.
|
||||
|
||||
Feel free to open an issue if you have any questions regarding this crate. You can also join the Matrix channel [#activitystreams](https://matrix.to/#/%23activitystreams:matrix.asonix.dog) for discussion about Activitypub in Rust. Additionally check out [Socialhub forum](https://socialhub.activitypub.rocks/) for general ActivityPub development.
|
||||
|
||||
To get started, have a look at the [API documentation](https://docs.rs/activitypub_federation/latest/activitypub_federation/)
|
||||
and [example code](https://github.com/LemmyNet/activitypub-federation-rust/tree/main/examples). You can also find some [ActivityPub resources in the Lemmy documentation](https://join-lemmy.org/docs/en/contributing/resources.html#activitypub-resources).
|
||||
If anything is unclear, please open an issue for clarification. For a more advanced implementation,
|
||||
take a look at the [Lemmy federation code](https://github.com/LemmyNet/lemmy/tree/main/crates/apub).
|
||||
## Overview
|
||||
|
||||
Currently supported frameworks include [actix](https://actix.rs/) and [axum](https://github.com/tokio-rs/axum):
|
||||
It is recommended to read the [W3C Activitypub standard document](https://www.w3.org/TR/activitypub/) which explains in detail how the protocol works. Note that it includes a section about client to server interactions, this functionality is not implemented by any major Fediverse project. Other relevant standard documents are [Activitystreams](https://www.w3.org/ns/activitystreams) and [Activity Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/). Its a good idea to keep these around as references during development.
|
||||
|
||||
**actix:**
|
||||
This crate provides high level abstractions for the core functionality of Activitypub: fetching, sending and receiving data, as well as handling HTTP signatures. It was built from the experience of developing [Lemmy](https://join-lemmy.org/) which is the biggest Fediverse project written in Rust. Nevertheless it very generic and appropriate for any type of application wishing to implement the Activitypub protocol.
|
||||
|
||||
```toml
|
||||
activitypub_federation = { version = "*", features = ["actix"] }
|
||||
There are two examples included to see how the library altogether:
|
||||
|
||||
- `local_federation`: Creates two instances which run on localhost and federate with each other. This setup is ideal for quick development and well as automated tests.
|
||||
- `live_federation`: A minimal application which can be deployed on a server and federate with other platforms such as Mastodon. For this it needs run at the root of a (sub)domain which is available over HTTPS. Edit `main.rs` to configure the server domain and your Fediverse handle. Once started, it will automatically send a message to you and log any incoming messages.
|
||||
|
||||
To see how this library is used in production, have a look at the [Lemmy federation code](https://github.com/LemmyNet/lemmy/tree/main/crates/apub).
|
||||
|
||||
## Setup
|
||||
|
||||
To use this crate in your project you need a web framework, preferably `actix-web` or `axum`. Be sure to enable the corresponding feature to access the full functionality. You also need a persistent storage such as PostgreSQL. Additionally, `serde` and `serde_json` are required to (de)serialize data which is sent over the network.
|
||||
|
||||
## Federating users
|
||||
|
||||
This library intentionally doesn't include any predefined data structures for federated data. The reason is that each federated application is different, and needs different data formats. Activitypub also doesn't define any specific data structures, but provides a few mandatory fields and many which are optional. For this reason it works best to let each application define its own data structures, and take advantage of serde for (de)serialization. This means we don't use `json-ld` which Activitypub is based on, but that doesn't cause any problems in practice.
|
||||
|
||||
The first thing we need to federate are users. Its easiest to get started by looking at the data sent by other platforms. Here we fetch an account from Mastodon, ignoring the many optional fields. This curl command is generally very helpful to inspect and debug federated services.
|
||||
|
||||
```text
|
||||
$ curl -H 'Accept: application/activity+json' https://mastodon.social/@LemmyDev | jq
|
||||
{
|
||||
"id": "https://mastodon.social/users/LemmyDev",
|
||||
"type": "Person",
|
||||
"preferredUsername": "LemmyDev",
|
||||
"name": "Lemmy",
|
||||
"inbox": "https://mastodon.social/users/LemmyDev/inbox",
|
||||
"outbox": "https://mastodon.social/users/LemmyDev/outbox",
|
||||
"publicKey": {
|
||||
"id": "https://mastodon.social/users/LemmyDev#main-key",
|
||||
"owner": "https://mastodon.social/users/LemmyDev",
|
||||
"publicKeyPem": "..."
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**axum:**
|
||||
TODO: is outbox required by mastodon?
|
||||
|
||||
```toml
|
||||
activitypub_federation = { version = "*", features = ["axum"] }
|
||||
The most important fields are:
|
||||
- `id`: Unique identifier for this object. At the same time it is the URL where we can fetch the object from
|
||||
- `type`: The type of this object
|
||||
- `preferredUsername`: Immutable username which was chosen at signup and is used in URLs as well as in mentions like `@LemmyDev@mastodon.social`
|
||||
- `name`: Displayname which can be freely changed at any time
|
||||
- `inbox`: URL where incoming activities are delivered to, treated in a later section
|
||||
see xx document for a definition of each field
|
||||
- `publicKey`: Key which is used for [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures)
|
||||
|
||||
Refer to [Activity Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/) for further details and description of other fields. You can also inspect many other URLs on federated platforms with the given curl command.
|
||||
|
||||
Based on this we can define the following minimal struct to (de)serialize a `Person` with serde.
|
||||
|
||||
```rust
|
||||
# use activitypub_federation::protocol::public_key::PublicKey;
|
||||
# use activitypub_federation::core::object_id::ObjectId;
|
||||
# use serde::{Deserialize, Serialize};
|
||||
# use activitystreams_kinds::actor::PersonType;
|
||||
# use url::Url;
|
||||
# use activitypub_federation::traits::tests::DbUser;
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Person {
|
||||
id: ObjectId<DbUser>,
|
||||
#[serde(rename = "type")]
|
||||
kind: PersonType,
|
||||
preferred_username: String,
|
||||
name: String,
|
||||
inbox: Url,
|
||||
outbox: Url,
|
||||
public_key: PublicKey,
|
||||
}
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
`ObjectId` is a wrapper for `Url` which helps to fetch data from a remote server, and convert it to `DbUser` which is the type that's stored in our local database. It also helps with caching data so that it doesn't have to be refetched every time.
|
||||
|
||||
`PersonType` is an enum with a single variant `Person`. It is used to deserialize objects in a typesafe way: If the JSON type value does not match the string `Person`, deserialization fails. This helps in places where we don't know the exact data type that is being deserialized, as you will see later.
|
||||
|
||||
Besides we also need a second struct to represent the data which gets stored in our local database. This is necessary because the data format used by SQL is very different from that used by that from Activitypub. It is organized by an integer primary key instead of a link id. Nested structs are complicated to represent and easier if flattened. Some fields like `type` don't need to be stored at all. On the other hand, the database contains fields which can't be federated, such as the private key and a boolean indicating if the item is local or remote.
|
||||
|
||||
```rust
|
||||
# use url::Url;
|
||||
|
||||
pub struct DbUser {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub apub_id: Url,
|
||||
pub inbox: Url,
|
||||
pub outbox: Url,
|
||||
pub local: bool,
|
||||
public_key: String,
|
||||
private_key: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Field names and other details of this type can be chosen freely according to your requirements. It only matters that the required data is being stored. Its important that this struct doesn't represent only local users who registered directly on our website, but also remote users that are registered on other instances and federated to us. The `local` column helps to easily distinguish both. It can also be distinguished from the domain of the `apub_id` URL, but that would be a much more expensive operation. All users have a `public_key`, but only local users have a `private_key`. On the other hand, `password_hash` and `email` are only present for local users. inbox` and `outbox` URLs need to be stored because each implementation is free to choose its own format for them, so they can't be regenerated on the fly.
|
||||
|
||||
In larger projects it makes sense to split this data in two. One for data relevant to local users (`password_hash`, `email` etc) and one for data that is shared by both local and federated users (`apub_id`, `public_key` etc).
|
||||
|
||||
Finally we need to implement the traits [ApubObject](crate::traits::ApubObject) and [Actor](crate::traits::Actor) for `DbUser`. These traits are used to convert between `Person` and `DbUser` types. [ApubObject::from_apub](crate::traits::ApubObject::from_apub) must store the received object in database, so that it can later be retrieved without network calls using [ApubObject::read_from_apub_id](crate::traits::ApubObject::read_from_apub_id). Refer to the documentation for more details.
|
||||
|
||||
## Configuration and fetching data
|
||||
|
||||
Next we need to do some configuration. Most importantly we need to specify the domain where the federated instance is running. It should be at the domain root and available over HTTPS for production. See the documentation for a list of config options. The parameter `user_data` is for anything that your application requires in handler functions, such as database connection handle, configuration etc.
|
||||
|
||||
```
|
||||
# use activitypub_federation::config::FederationConfig;
|
||||
# let db_connection = ();
|
||||
# let _ = actix_rt::System::new();
|
||||
let config = FederationConfig::builder()
|
||||
.domain("example.com")
|
||||
.app_data(db_connection)
|
||||
.build()?;
|
||||
# Ok::<(), anyhow::Error>(())
|
||||
```
|
||||
|
||||
With this we can already fetch data from remote servers:
|
||||
|
||||
```no_run
|
||||
# use activitypub_federation::core::object_id::ObjectId;
|
||||
# use activitypub_federation::traits::tests::DbUser;
|
||||
# use activitypub_federation::config::FederationConfig;
|
||||
# let db_connection = activitypub_federation::traits::tests::DbConnection;
|
||||
# let _ = actix_rt::System::new();
|
||||
# actix_rt::Runtime::new().unwrap().block_on(async {
|
||||
let config = FederationConfig::builder().domain("example.com").app_data(db_connection).build()?;
|
||||
let user_id = ObjectId::<DbUser>::new("https://mastodon.social/@LemmyDev")?;
|
||||
let data = config.to_request_data();
|
||||
let user = user_id.dereference(&data).await;
|
||||
assert!(user.is_ok());
|
||||
# Ok::<(), anyhow::Error>(())
|
||||
}).unwrap()
|
||||
```
|
||||
|
||||
`dereference` retrieves the object JSON at the given URL, and uses serde to convert it to `Person`. It then calls your method `ApubObject::from_apub` which inserts it in the database and returns a `DbUser` struct. `request_data` contains the federation config as well as a counter of outgoing HTTP requests. If this counter exceeds the configured maximum, further requests are aborted in order to avoid recursive fetching which could allow for a denial of service attack.
|
||||
|
||||
After dereferencing a remote object, it is stored in the local database and can be retrieved using [ObjectId::dereference_local](crate::core::object_id::ObjectId::dereference_local) without any network requests. This is important for performance reasons and for searching.
|
||||
|
||||
## Federating posts
|
||||
|
||||
We repeat the same steps taken above for users in order to federate our posts.
|
||||
|
||||
```text
|
||||
$ curl -H 'Accept: application/activity+json' https://mastodon.social/@LemmyDev/109790106847504642 | jq
|
||||
{
|
||||
"id": "https://mastodon.social/users/LemmyDev/statuses/109790106847504642",
|
||||
"type": "Note",
|
||||
"content": "<p><a href=\"https://mastodon.social/tags/lemmy\" ...",
|
||||
"attributedTo": "https://mastodon.social/users/LemmyDev",
|
||||
"to": [
|
||||
"https://www.w3.org/ns/activitystreams#Public"
|
||||
],
|
||||
"cc": [
|
||||
"https://mastodon.social/users/LemmyDev/followers"
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
The most important fields are:
|
||||
- `id`: Unique identifier for this object. At the same time it is the URL where we can fetch the object from
|
||||
- `type`: The type of this object
|
||||
- `content`: Post text in HTML format
|
||||
- `attributedTo`: ID of the user who created this post
|
||||
- `to`, `cc`: Who the object is for. The special "public" URL indicates that everyone can view it. It also gets delivered to followers of the LemmyDev account.
|
||||
|
||||
Just like for `Person` before, we need to implement a protocol type and a database type, then implement trait `ApubObject`. See the example for details.
|
||||
|
||||
## HTTP endpoints
|
||||
|
||||
The next step is to allow other servers to fetch our actors and objects. For this we need to create an HTTP route, most commonly at the same path where the actor or object can be viewed in a web browser. On this path there should be another route which responds to requests with header `Accept: application/activity+json` and serves the JSON data. This needs to be done for all actors and objects. Note that only local items should be served in this way.
|
||||
|
||||
```no_run
|
||||
# use std::net::SocketAddr;
|
||||
# use activitypub_federation::config::FederationConfig;
|
||||
# use activitypub_federation::protocol::context::WithContext;
|
||||
# use activitypub_federation::core::axum::json::ApubJson;
|
||||
# use anyhow::Error;
|
||||
# use activitypub_federation::traits::tests::Person;
|
||||
# use activitypub_federation::config::RequestData;
|
||||
# use activitypub_federation::traits::tests::DbConnection;
|
||||
# use axum::extract::Path;
|
||||
# use activitypub_federation::config::ApubMiddleware;
|
||||
# use axum::routing::get;
|
||||
# use crate::activitypub_federation::traits::ApubObject;
|
||||
# use axum::headers::ContentType;
|
||||
# use activitypub_federation::APUB_JSON_CONTENT_TYPE;
|
||||
# use axum::TypedHeader;
|
||||
# use axum::response::IntoResponse;
|
||||
# use http::HeaderMap;
|
||||
# async fn generate_user_html(_: String, _: RequestData<DbConnection>) -> axum::response::Response { todo!() }
|
||||
|
||||
#[actix_rt::main]
|
||||
async fn main() -> Result<(), Error> {
|
||||
let data = FederationConfig::builder()
|
||||
.domain("example.com")
|
||||
.app_data(DbConnection)
|
||||
.build()?;
|
||||
|
||||
let app = axum::Router::new()
|
||||
.route("/user/:name", get(http_get_user))
|
||||
.layer(ApubMiddleware::new(data));
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||
tracing::debug!("listening on {}", addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn http_get_user(
|
||||
header_map: HeaderMap,
|
||||
Path(name): Path<String>,
|
||||
data: RequestData<DbConnection>,
|
||||
) -> impl IntoResponse {
|
||||
let accept = header_map.get("accept").map(|v| v.to_str().unwrap());
|
||||
if accept == Some(APUB_JSON_CONTENT_TYPE) {
|
||||
let db_user = data.read_local_user(name).await.unwrap();
|
||||
let apub_user = db_user.into_apub(&data).await.unwrap();
|
||||
ApubJson(WithContext::new_default(apub_user)).into_response()
|
||||
}
|
||||
else {
|
||||
generate_user_html(name, data).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
There are a couple of things going on here. Like before we are constructing the federation config with our domain and application data. We pass this to a middleware to make it available in request handlers, then listening on a port with the axum webserver.
|
||||
|
||||
The `http_get_user` method allows retrieving a user profile from `/user/:name`. It checks the `accept` header, and compares it to the one used by Activitypub (`application/activity+json`). If it matches, the user is read from database and converted to Activitypub json format. The `context` field is added (`WithContext` for `json-ld` compliance), and it is converted to a JSON response with header `content-type: application/activity+json` using `ApubJson`. It can now be retrieved with the command `curl -H 'Accept: application/activity+json' ...` introduced earlier, or with `ObjectId`.
|
||||
|
||||
If the `accept` header doesn't match, it renders the user profile as HTML for viewing in a web browser.
|
||||
|
||||
## Webfinger
|
||||
|
||||
Webfinger can resolve a handle like `@nutomic@lemmy.ml` into an ID like `https://lemmy.ml/u/nutomic` which can be used by Activitypub. Webfinger is not part of the ActivityPub standard, but the fact that Mastodon requires it makes it de-facto mandatory. It is defined in [RFC 7033](https://www.rfc-editor.org/rfc/rfc7033). Implementing it basically means handling requests of the form`https://mastodon.social/.well-known/webfinger?resource=acct:LemmyDev@mastodon.social`.
|
||||
|
||||
To do this we can implement the following HTTP handler which must be bound to path `.well-known/webfinger`.
|
||||
|
||||
```rust
|
||||
# use serde::Deserialize;
|
||||
# use axum::{extract::Query, Json};
|
||||
# use activitypub_federation::config::RequestData;
|
||||
# use activitypub_federation::webfinger::Webfinger;
|
||||
# use anyhow::Error;
|
||||
# use activitypub_federation::traits::tests::DbConnection;
|
||||
# use activitypub_federation::webfinger::extract_webfinger_name;
|
||||
# use activitypub_federation::webfinger::build_webfinger_response;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WebfingerQuery {
|
||||
resource: String,
|
||||
}
|
||||
|
||||
async fn webfinger(
|
||||
Query(query): Query<WebfingerQuery>,
|
||||
data: RequestData<DbConnection>,
|
||||
) -> Result<Json<Webfinger>, Error> {
|
||||
let name = extract_webfinger_name(&query.resource, &data)?;
|
||||
let db_user = data.read_local_user(name).await?;
|
||||
Ok(Json(build_webfinger_response(query.resource, db_user.apub_id)))
|
||||
}
|
||||
```
|
||||
|
||||
The resolve a user via webfinger call the following method:
|
||||
```rust
|
||||
# use activitypub_federation::traits::tests::DbConnection;
|
||||
# use activitypub_federation::config::FederationConfig;
|
||||
# use activitypub_federation::webfinger::webfinger_resolve_actor;
|
||||
# use activitypub_federation::traits::tests::DbUser;
|
||||
# let db_connection = DbConnection;
|
||||
# let _ = actix_rt::System::new();
|
||||
# actix_rt::Runtime::new().unwrap().block_on(async {
|
||||
# let config = FederationConfig::builder().domain("example.com").app_data(db_connection).build()?;
|
||||
# let data = config.to_request_data();
|
||||
let user: DbUser = webfinger_resolve_actor("nutomic@lemmy.ml", &data).await?;
|
||||
# Ok::<(), anyhow::Error>(())
|
||||
# }).unwrap();
|
||||
```
|
||||
|
||||
Note that webfinger queries don't contain a leading `@`. `webfinger_resolve_actor` fetches the webfinger response, finds the matching actor ID, fetches the actor using [ObjectId::dereference](crate::core::object_id::ObjectId::dereference) and converts it with [ApubObject::from_apub](crate::traits::ApubObject::from_apub). It is possible tha there are multiple Activitypub IDs returned for a single webfinger query in case of multiple actors with the same name (for example Lemmy permits group and person with the same name). In this case `webfinger_resolve_actor` automatically loops and returns the first item which can be dereferenced successfully to the given type.
|
||||
|
||||
## Sending and receiving activities
|
||||
|
||||
TODO: continue here
|
||||
|
||||
```text
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Follow {
|
||||
pub actor: ObjectId<DbUser>,
|
||||
pub object: ObjectId<DbUser>,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: FollowType,
|
||||
pub id: Url,
|
||||
}
|
||||
```
|
||||
|
||||
## Fetching remote object with unknown type
|
||||
|
||||
It is sometimes necessary to fetch from a URL, but we don't know the exact type of object it will return. An example is the search field in most federated platforms, which allows pasting and `id` URL and fetches it from the origin server. It can implemented in the following way:
|
||||
|
||||
```no_run
|
||||
# use activitypub_federation::traits::tests::{DbUser, DbPost};
|
||||
# use activitypub_federation::core::object_id::ObjectId;
|
||||
# use activitypub_federation::traits::ApubObject;
|
||||
# use activitypub_federation::config::FederationConfig;
|
||||
# use serde::{Deserialize, Serialize};
|
||||
# use activitypub_federation::traits::tests::DbConnection;
|
||||
# use activitypub_federation::config::RequestData;
|
||||
# use url::Url;
|
||||
# use activitypub_federation::traits::tests::{Person, Note};
|
||||
|
||||
pub enum SearchableDbObjects {
|
||||
User(DbUser),
|
||||
Post(DbPost)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum SearchableApubObjects {
|
||||
Person(Person),
|
||||
Note(Note)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ApubObject for SearchableDbObjects {
|
||||
type DataType = DbConnection;
|
||||
type ApubType = SearchableApubObjects;
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn read_from_apub_id(
|
||||
object_id: Url,
|
||||
data: &RequestData<Self::DataType>,
|
||||
) -> Result<Option<Self>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn into_apub(
|
||||
self,
|
||||
data: &RequestData<Self::DataType>,
|
||||
) -> Result<Self::ApubType, Self::Error> {
|
||||
unimplemented!();
|
||||
}
|
||||
|
||||
async fn from_apub(
|
||||
apub: Self::ApubType,
|
||||
data: &RequestData<Self::DataType>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
use SearchableDbObjects::*;
|
||||
match apub {
|
||||
SearchableApubObjects::Person(p) => Ok(User(DbUser::from_apub(p, data).await?)),
|
||||
SearchableApubObjects::Note(n) => Ok(Post(DbPost::from_apub(n, data).await?)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_rt::main]
|
||||
async fn main() -> Result<(), anyhow::Error> {
|
||||
# let config = FederationConfig::builder().domain("example.com").app_data(DbConnection).build().unwrap();
|
||||
# let data = config.to_request_data();
|
||||
let query = "https://example.com/id/413";
|
||||
let query_result = ObjectId::<SearchableDbObjects>::new(query)?
|
||||
.dereference(&data)
|
||||
.await?;
|
||||
match query_result {
|
||||
SearchableDbObjects::Post(post) => {} // retrieved object is a post
|
||||
SearchableDbObjects::User(user) => {} // object is a user
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
What this does is...
|
||||
|
||||
Things to work on in the future:
|
||||
- **Improve documentation and example**: Some things could probably be documented better. The example code should be simplified. where possible.
|
||||
- **Simplify generics**: The library uses a lot of generic parameters, where clauses and associated types. It should be possible to simplify them.
|
||||
- **Improve macro**: The macro is implemented very badly and doesn't have any error handling.
|
||||
- **Generate HTTP endpoints**: It would be possible to generate HTTP endpoints automatically for each actor.
|
||||
- **Support for other web frameworks**: Can be implemented using feature flags if other projects require it.
|
||||
- **Signed fetch**: JSON can only be fetched by authenticated actors, which means that fetches from blocked instances can also be blocked. In combination with the previous point, this could be handled entirely in the library.
|
||||
- **Helpers for testing**: Lemmy has a pretty useful test suite which (de)serializes json from other projects, to ensure that federation remains compatible. Helpers for this could be added to the library.
|
||||
- **[Webfinger](https://datatracker.ietf.org/doc/html/rfc7033) support**: Not part of the Activitypub standard, but often used together for user discovery.
|
||||
- **Remove request_counter from API**: It should be handled internally and not exposed. Maybe as part of `Data` struct.
|
||||
-
|
||||
## License
|
||||
|
||||
Licensed under [AGPLv3](LICENSE).
|
||||
Licensed under [AGPLv3](/LICENSE).
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
use crate::{activities::follow::Follow, instance::DatabaseHandle, objects::person::DbUser};
|
||||
use activitypub_federation::{
|
||||
config::RequestData,
|
||||
core::object_id::ObjectId,
|
||||
kinds::activity::AcceptType,
|
||||
request_data::RequestData,
|
||||
traits::ActivityHandler,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
|
@ -4,10 +4,10 @@ use crate::{
|
|||
DbPost,
|
||||
};
|
||||
use activitypub_federation::{
|
||||
config::RequestData,
|
||||
core::object_id::ObjectId,
|
||||
kinds::activity::CreateType,
|
||||
protocol::helpers::deserialize_one_or_many,
|
||||
request_data::RequestData,
|
||||
traits::{ActivityHandler, ApubObject},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
|
@ -5,9 +5,9 @@ use crate::{
|
|||
objects::person::DbUser,
|
||||
};
|
||||
use activitypub_federation::{
|
||||
config::RequestData,
|
||||
core::object_id::ObjectId,
|
||||
kinds::activity::FollowType,
|
||||
request_data::RequestData,
|
||||
traits::{ActivityHandler, Actor},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
@ -60,7 +60,7 @@ impl ActivityHandler for Follow {
|
|||
|
||||
// send back an accept
|
||||
let follower = self.actor.dereference(data).await?;
|
||||
let id = generate_object_id(data.hostname())?;
|
||||
let id = generate_object_id(data.domain())?;
|
||||
let accept = Accept::new(local_user.ap_id.clone(), self, id.clone());
|
||||
local_user
|
||||
.send(accept, vec![follower.shared_inbox_or_inbox()], data)
|
||||
|
|
|
@ -4,24 +4,26 @@ use crate::{
|
|||
objects::person::{DbUser, PersonAcceptedActivities},
|
||||
};
|
||||
use activitypub_federation::{
|
||||
config::FederationConfig,
|
||||
config::{ApubMiddleware, FederationConfig, RequestData},
|
||||
core::actix_web::inbox::receive_activity,
|
||||
protocol::context::WithContext,
|
||||
request_data::{ApubMiddleware, RequestData},
|
||||
traits::ApubObject,
|
||||
webfinger::{build_webfinger_response, extract_webfinger_name},
|
||||
APUB_JSON_CONTENT_TYPE,
|
||||
};
|
||||
use actix_web::{web, web::Bytes, App, HttpRequest, HttpResponse, HttpServer};
|
||||
use anyhow::anyhow;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub fn listen(data: &FederationConfig<DatabaseHandle>) -> Result<(), Error> {
|
||||
let hostname = data.hostname();
|
||||
let data = data.clone();
|
||||
pub fn listen(config: &FederationConfig<DatabaseHandle>) -> Result<(), Error> {
|
||||
let hostname = config.hostname();
|
||||
let config = config.clone();
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(ApubMiddleware::new(data.clone()))
|
||||
.wrap(ApubMiddleware::new(config.clone()))
|
||||
.route("/{user}", web::get().to(http_get_user))
|
||||
.route("/{user}/inbox", web::post().to(http_post_user_inbox))
|
||||
.route("/.well-known/webfinger", web::get().to(webfinger))
|
||||
})
|
||||
.bind(hostname)?
|
||||
.run();
|
||||
|
@ -56,3 +58,20 @@ pub async fn http_post_user_inbox(
|
|||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WebfingerQuery {
|
||||
resource: String,
|
||||
}
|
||||
|
||||
pub async fn webfinger(
|
||||
query: web::Query<WebfingerQuery>,
|
||||
data: RequestData<DatabaseHandle>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let name = extract_webfinger_name(&query.resource, &data)?;
|
||||
let db_user = data.read_user(&name)?;
|
||||
Ok(HttpResponse::Ok().json(build_webfinger_response(
|
||||
query.resource.clone(),
|
||||
db_user.ap_id.into_inner(),
|
||||
)))
|
||||
}
|
||||
|
|
|
@ -4,28 +4,31 @@ use crate::{
|
|||
objects::person::{DbUser, Person, PersonAcceptedActivities},
|
||||
};
|
||||
use activitypub_federation::{
|
||||
config::FederationConfig,
|
||||
config::{ApubMiddleware, FederationConfig, RequestData},
|
||||
core::axum::{inbox::receive_activity, json::ApubJson, ActivityData},
|
||||
protocol::context::WithContext,
|
||||
request_data::{ApubMiddleware, RequestData},
|
||||
traits::ApubObject,
|
||||
webfinger::{build_webfinger_response, extract_webfinger_name, Webfinger},
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use axum::{
|
||||
extract::Path,
|
||||
extract::{Path, Query},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json,
|
||||
Router,
|
||||
};
|
||||
use axum_macros::debug_handler;
|
||||
use serde::Deserialize;
|
||||
use std::net::ToSocketAddrs;
|
||||
|
||||
pub fn listen(data: &FederationConfig<DatabaseHandle>) -> Result<(), Error> {
|
||||
let hostname = data.hostname();
|
||||
let data = data.clone();
|
||||
pub fn listen(config: &FederationConfig<DatabaseHandle>) -> Result<(), Error> {
|
||||
let hostname = config.hostname();
|
||||
let config = config.clone();
|
||||
let app = Router::new()
|
||||
.route("/:user/inbox", post(http_post_user_inbox))
|
||||
.route("/:user", get(http_get_user))
|
||||
.layer(ApubMiddleware::new(data));
|
||||
.route("/.well-known/webfinger", get(webfinger))
|
||||
.layer(ApubMiddleware::new(config));
|
||||
|
||||
let addr = hostname
|
||||
.to_socket_addrs()?
|
||||
|
@ -37,21 +40,17 @@ pub fn listen(data: &FederationConfig<DatabaseHandle>) -> Result<(), Error> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[axum_macros::debug_handler]
|
||||
#[debug_handler]
|
||||
async fn http_get_user(
|
||||
Path(user): Path<String>,
|
||||
Path(name): Path<String>,
|
||||
data: RequestData<DatabaseHandle>,
|
||||
) -> Result<ApubJson<WithContext<Person>>, Error> {
|
||||
let db_user = data.local_user();
|
||||
if user == db_user.name {
|
||||
let apub_user = db_user.into_apub(&data).await?;
|
||||
Ok(ApubJson(WithContext::new_default(apub_user)))
|
||||
} else {
|
||||
Err(anyhow!("Invalid user {user}").into())
|
||||
}
|
||||
let db_user = data.read_user(&name)?;
|
||||
let apub_user = db_user.into_apub(&data).await?;
|
||||
Ok(ApubJson(WithContext::new_default(apub_user)))
|
||||
}
|
||||
|
||||
#[axum_macros::debug_handler]
|
||||
#[debug_handler]
|
||||
async fn http_post_user_inbox(
|
||||
data: RequestData<DatabaseHandle>,
|
||||
activity_data: ActivityData,
|
||||
|
@ -62,3 +61,21 @@ async fn http_post_user_inbox(
|
|||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WebfingerQuery {
|
||||
resource: String,
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn webfinger(
|
||||
Query(query): Query<WebfingerQuery>,
|
||||
data: RequestData<DatabaseHandle>,
|
||||
) -> Result<Json<Webfinger>, Error> {
|
||||
let name = extract_webfinger_name(&query.resource, &data)?;
|
||||
let db_user = data.read_user(&name)?;
|
||||
Ok(Json(build_webfinger_response(
|
||||
query.resource,
|
||||
db_user.ap_id.into_inner(),
|
||||
)))
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ use crate::error::Error;
|
|||
use ::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
#[allow(clippy::diverging_sub_expression, clippy::items_after_statements)]
|
||||
pub mod http;
|
||||
|
||||
impl IntoResponse for Error {
|
||||
|
|
|
@ -3,10 +3,28 @@ use crate::{
|
|||
Error,
|
||||
};
|
||||
use activitypub_federation::config::{FederationConfig, UrlVerifier};
|
||||
use anyhow::anyhow;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use url::Url;
|
||||
|
||||
pub fn new_instance(
|
||||
hostname: &str,
|
||||
name: String,
|
||||
) -> Result<FederationConfig<DatabaseHandle>, Error> {
|
||||
let local_user = DbUser::new(hostname, name)?;
|
||||
let database = Arc::new(Database {
|
||||
users: Mutex::new(vec![local_user]),
|
||||
posts: Mutex::new(vec![]),
|
||||
});
|
||||
let config = FederationConfig::builder()
|
||||
.domain(hostname)
|
||||
.app_data(database)
|
||||
.debug(true)
|
||||
.build()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub type DatabaseHandle = Arc<Database>;
|
||||
|
||||
/// Our "database" which contains all known posts users (local and federated)
|
||||
|
@ -30,34 +48,29 @@ impl UrlVerifier for MyUrlVerifier {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn listen(data: &FederationConfig<DatabaseHandle>) -> Result<(), Error> {
|
||||
pub fn listen(config: &FederationConfig<DatabaseHandle>) -> Result<(), Error> {
|
||||
if cfg!(feature = "actix-web") == cfg!(feature = "axum") {
|
||||
panic!("Exactly one of features \"actix-web\" and \"axum\" must be enabled");
|
||||
}
|
||||
#[cfg(feature = "actix-web")]
|
||||
crate::actix_web::http::listen(data)?;
|
||||
crate::actix_web::http::listen(config)?;
|
||||
#[cfg(feature = "axum")]
|
||||
crate::axum::http::listen(data)?;
|
||||
crate::axum::http::listen(config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new(hostname: &str, name: String) -> Result<FederationConfig<DatabaseHandle>, Error> {
|
||||
let local_user = DbUser::new(hostname, name)?;
|
||||
let database = Arc::new(Database {
|
||||
users: Mutex::new(vec![local_user]),
|
||||
posts: Mutex::new(vec![]),
|
||||
});
|
||||
let config = FederationConfig::builder()
|
||||
.hostname(hostname)
|
||||
.app_data(database)
|
||||
.debug(true)
|
||||
.build()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn local_user(&self) -> DbUser {
|
||||
let lock = self.users.lock().unwrap();
|
||||
lock.first().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn read_user(&self, name: &str) -> Result<DbUser, Error> {
|
||||
let db_user = self.local_user();
|
||||
if name == db_user.name {
|
||||
Ok(db_user)
|
||||
} else {
|
||||
Err(anyhow!("Invalid user {name}").into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use crate::{
|
||||
instance::{listen, Database},
|
||||
instance::{listen, new_instance},
|
||||
objects::post::DbPost,
|
||||
utils::generate_object_id,
|
||||
};
|
||||
|
@ -25,16 +25,16 @@ async fn main() -> Result<(), Error> {
|
|||
.init();
|
||||
|
||||
info!("Starting local instances alpha and beta on localhost:8001, localhost:8002");
|
||||
let alpha = Database::new("localhost:8001", "alpha".to_string())?;
|
||||
let beta = Database::new("localhost:8002", "beta".to_string())?;
|
||||
let alpha = new_instance("localhost:8001", "alpha".to_string())?;
|
||||
let beta = new_instance("localhost:8002", "beta".to_string())?;
|
||||
listen(&alpha)?;
|
||||
listen(&beta)?;
|
||||
info!("Local instances started");
|
||||
|
||||
info!("Alpha user follows beta user");
|
||||
info!("Alpha user follows beta user via webfinger");
|
||||
alpha
|
||||
.local_user()
|
||||
.follow(&beta.local_user(), &alpha.to_request_data())
|
||||
.follow("beta@localhost:8002", &alpha.to_request_data())
|
||||
.await?;
|
||||
assert_eq!(
|
||||
beta.local_user().followers(),
|
||||
|
|
|
@ -6,15 +6,16 @@ use crate::{
|
|||
utils::generate_object_id,
|
||||
};
|
||||
use activitypub_federation::{
|
||||
config::RequestData,
|
||||
core::{
|
||||
activity_queue::send_activity,
|
||||
http_signatures::generate_actor_keypair,
|
||||
object_id::ObjectId,
|
||||
signatures::{generate_actor_keypair, PublicKey},
|
||||
},
|
||||
kinds::actor::PersonType,
|
||||
protocol::context::WithContext,
|
||||
request_data::RequestData,
|
||||
protocol::{context::WithContext, public_key::PublicKey},
|
||||
traits::{ActivityHandler, Actor, ApubObject},
|
||||
webfinger::webfinger_resolve_actor,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Debug;
|
||||
|
@ -81,15 +82,16 @@ impl DbUser {
|
|||
}
|
||||
|
||||
fn public_key(&self) -> PublicKey {
|
||||
PublicKey::new_main_key(self.ap_id.clone().into_inner(), self.public_key.clone())
|
||||
PublicKey::new(self.ap_id.clone().into_inner(), self.public_key.clone())
|
||||
}
|
||||
|
||||
pub async fn follow(
|
||||
&self,
|
||||
other: &DbUser,
|
||||
other: &str,
|
||||
data: &RequestData<DatabaseHandle>,
|
||||
) -> Result<(), Error> {
|
||||
let id = generate_object_id(data.hostname())?;
|
||||
let other: DbUser = webfinger_resolve_actor(other, data).await?;
|
||||
let id = generate_object_id(data.domain())?;
|
||||
let follow = Follow::new(self.ap_id.clone(), other.ap_id.clone(), id.clone());
|
||||
self.send(follow, vec![other.shared_inbox_or_inbox()], data)
|
||||
.await?;
|
||||
|
@ -101,11 +103,11 @@ impl DbUser {
|
|||
post: DbPost,
|
||||
data: &RequestData<DatabaseHandle>,
|
||||
) -> Result<(), Error> {
|
||||
let id = generate_object_id(data.hostname())?;
|
||||
let id = generate_object_id(data.domain())?;
|
||||
let create = CreateNote::new(post.into_apub(data).await?, id.clone());
|
||||
let mut inboxes = vec![];
|
||||
for f in self.followers.clone() {
|
||||
let user: DbUser = ObjectId::new(f).dereference(data).await?;
|
||||
let user: DbUser = ObjectId::from(f).dereference(data).await?;
|
||||
inboxes.push(user.shared_inbox_or_inbox());
|
||||
}
|
||||
self.send(create, inboxes, data).await?;
|
||||
|
@ -125,7 +127,6 @@ impl DbUser {
|
|||
let activity = WithContext::new_default(activity);
|
||||
send_activity(
|
||||
activity,
|
||||
self.public_key(),
|
||||
self.private_key.clone().unwrap(),
|
||||
recipients,
|
||||
data,
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
use crate::{error::Error, generate_object_id, instance::DatabaseHandle, objects::person::DbUser};
|
||||
use activitypub_federation::{
|
||||
config::RequestData,
|
||||
core::object_id::ObjectId,
|
||||
kinds::{object::NoteType, public},
|
||||
protocol::helpers::deserialize_one_or_many,
|
||||
request_data::RequestData,
|
||||
traits::ApubObject,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
use crate::{
|
||||
core::activity_queue::create_activity_queue,
|
||||
request_data::RequestData,
|
||||
error::Error,
|
||||
traits::ActivityHandler,
|
||||
utils::verify_domains_match,
|
||||
Error,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use background_jobs::Manager;
|
||||
|
@ -11,7 +10,11 @@ use derive_builder::Builder;
|
|||
use dyn_clone::{clone_trait_object, DynClone};
|
||||
use reqwest_middleware::ClientWithMiddleware;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::{ops::Deref, sync::Arc, time::Duration};
|
||||
use std::{
|
||||
ops::Deref,
|
||||
sync::{atomic::AtomicI32, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
/// Various settings related to Activitypub federation.
|
||||
|
@ -22,7 +25,7 @@ use url::Url;
|
|||
/// # use activitypub_federation::config::FederationConfig;
|
||||
/// # let _ = actix_rt::System::new();
|
||||
/// let settings = FederationConfig::builder()
|
||||
/// .hostname("example.com")
|
||||
/// .domain("example.com")
|
||||
/// .app_data(())
|
||||
/// .http_fetch_limit(50)
|
||||
/// .worker_count(16)
|
||||
|
@ -34,7 +37,7 @@ use url::Url;
|
|||
pub struct FederationConfig<T: Clone> {
|
||||
/// The domain where this federated instance is running
|
||||
#[builder(setter(into))]
|
||||
pub(crate) hostname: String,
|
||||
pub(crate) domain: String,
|
||||
/// Data which the application requires in handlers, such as database connection
|
||||
/// or configuration.
|
||||
pub(crate) app_data: T,
|
||||
|
@ -78,6 +81,7 @@ impl<T: Clone> FederationConfig<T> {
|
|||
pub fn builder() -> FederationConfigBuilder<T> {
|
||||
FederationConfigBuilder::default()
|
||||
}
|
||||
|
||||
pub(crate) async fn verify_url_and_domain<Activity, Datatype>(
|
||||
&self,
|
||||
activity: &Activity,
|
||||
|
@ -146,12 +150,12 @@ impl<T: Clone> FederationConfig<T> {
|
|||
if let Some(port) = url.port() {
|
||||
domain = format!("{}:{}", domain, port);
|
||||
}
|
||||
domain == self.hostname
|
||||
domain == self.domain
|
||||
}
|
||||
|
||||
/// Returns the local hostname
|
||||
pub fn hostname(&self) -> &str {
|
||||
&self.hostname
|
||||
&self.domain
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -228,3 +232,40 @@ impl UrlVerifier for DefaultUrlVerifier {
|
|||
}
|
||||
|
||||
clone_trait_object!(UrlVerifier);
|
||||
|
||||
/// Stores data for handling one specific HTTP request.
|
||||
///
|
||||
/// Most importantly this contains a counter for outgoing HTTP requests. This is necessary to
|
||||
/// prevent denial of service attacks, where an attacker triggers fetching of recursive objects.
|
||||
///
|
||||
/// <https://www.w3.org/TR/activitypub/#security-recursive-objects>
|
||||
pub struct RequestData<T: Clone> {
|
||||
pub(crate) config: FederationConfig<T>,
|
||||
pub(crate) request_counter: AtomicI32,
|
||||
}
|
||||
|
||||
impl<T: Clone> RequestData<T> {
|
||||
pub fn app_data(&self) -> &T {
|
||||
&self.config.app_data
|
||||
}
|
||||
pub fn domain(&self) -> &str {
|
||||
&self.config.domain
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> Deref for RequestData<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &T {
|
||||
&self.config.app_data
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApubMiddleware<T: Clone>(pub(crate) FederationConfig<T>);
|
||||
|
||||
impl<T: Clone> ApubMiddleware<T> {
|
||||
pub fn new(config: FederationConfig<T>) -> Self {
|
||||
ApubMiddleware(config)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
use crate::{
|
||||
core::signatures::{sign_request, PublicKey},
|
||||
request_data::RequestData,
|
||||
config::RequestData,
|
||||
core::http_signatures::sign_request,
|
||||
error::Error,
|
||||
traits::ActivityHandler,
|
||||
utils::reqwest_shim::ResponseExt,
|
||||
Error,
|
||||
APUB_JSON_CONTENT_TYPE,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
|
@ -29,54 +29,95 @@ use std::{
|
|||
use tracing::{debug, info, warn};
|
||||
use url::Url;
|
||||
|
||||
/// Hands off an activity for delivery to remote actors.
|
||||
/// Signs and delivers outgoing activities with retry.
|
||||
///
|
||||
/// Send out the given activity to all recipient inboxes, automatically generating the HTTP
|
||||
/// signatures. In production builds, sending is done on a background thread, and automatically retried on failure with
|
||||
/// exponential backoff.
|
||||
/// The list of inboxes gets deduplicated (important for shared inbox). All inboxes on the local
|
||||
/// domain and those which fail the [crate::config::UrlVerifier] check are excluded from delivery.
|
||||
/// For each remaining inbox a background tasks is created. It signs the HTTP header with the given
|
||||
/// private key. Finally the activity is delivered to the inbox.
|
||||
///
|
||||
/// It is possible that delivery fails because the target instance is temporarily unreachable. In
|
||||
/// this case the task is scheduled for retry after a certain waiting time. For each task delivery
|
||||
/// is retried up to 3 times after the initial attempt. The retry intervals are as follows:
|
||||
/// - one minute, for service restart
|
||||
/// - one hour, for instance maintenance
|
||||
/// - 2.5 days, for major incident with rebuild from backup
|
||||
///
|
||||
/// In case [crate::config::FederationConfigBuilder::debug] is enabled, no background thread is used but activities
|
||||
/// are sent directly on the foreground. This makes it easier to catch delivery errors and avoids
|
||||
/// complicated steps to await delivery.
|
||||
///
|
||||
/// - `activity`: The activity to be sent, gets converted to json
|
||||
/// - `public_key`: The sending actor's public key. In fact, we only need the key id for signing
|
||||
/// - `private_key`: The sending actor's private key for signing HTTP signature
|
||||
/// - `recipients`: List of actors who should receive the activity. This gets deduplicated, and
|
||||
/// local/invalid inbox urls removed
|
||||
/// - `private_key`: Private key belonging to the actor who sends the activity, for signing HTTP
|
||||
/// signature. Generated with [crate::core::http_signatures::generate_actor_keypair].
|
||||
/// - `inboxes`: List of actor inboxes that should receive the activity. Should be built by calling
|
||||
/// [crate::traits::Actor::shared_inbox_or_inbox] for each target actor.
|
||||
///
|
||||
/// TODO: how can this only take a single pubkey? seems completely wrong, should be one per recipient
|
||||
/// TODO: example
|
||||
/// TODO: consider reading privkey from activity
|
||||
pub async fn send_activity<Activity, T>(
|
||||
/// ```
|
||||
/// # use activitypub_federation::config::FederationConfig;
|
||||
/// # use activitypub_federation::core::activity_queue::send_activity;
|
||||
/// # use activitypub_federation::core::http_signatures::generate_actor_keypair;
|
||||
/// # use activitypub_federation::traits::Actor;
|
||||
/// # use activitypub_federation::core::object_id::ObjectId;
|
||||
/// # use activitypub_federation::traits::tests::{DB_USER, DbConnection, Follow};
|
||||
/// # let _ = actix_rt::System::new();
|
||||
/// # actix_rt::Runtime::new().unwrap().block_on(async {
|
||||
/// # let db_connection = DbConnection;
|
||||
/// # let config = FederationConfig::builder()
|
||||
/// # .domain("example.com")
|
||||
/// # .app_data(db_connection)
|
||||
/// # .build()?;
|
||||
/// # let data = config.to_request_data();
|
||||
/// # let recipient = DB_USER.clone();
|
||||
/// // Each actor has a keypair. Generate it on signup and store it in the database.
|
||||
/// let keypair = generate_actor_keypair()?;
|
||||
/// let activity = Follow {
|
||||
/// actor: ObjectId::new("https://lemmy.ml/u/nutomic")?,
|
||||
/// object: recipient.apub_id.clone().into(),
|
||||
/// kind: Default::default(),
|
||||
/// id: "https://lemmy.ml/activities/321".try_into()?
|
||||
/// };
|
||||
/// let inboxes = vec![recipient.shared_inbox_or_inbox()];
|
||||
/// send_activity(activity, keypair.private_key, inboxes, &data).await?;
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// # }).unwrap()
|
||||
/// ```
|
||||
pub async fn send_activity<Activity, Datatype>(
|
||||
activity: Activity,
|
||||
public_key: PublicKey,
|
||||
private_key: String,
|
||||
recipients: Vec<Url>,
|
||||
data: &RequestData<T>,
|
||||
inboxes: Vec<Url>,
|
||||
data: &RequestData<Datatype>,
|
||||
) -> Result<(), <Activity as ActivityHandler>::Error>
|
||||
where
|
||||
Activity: ActivityHandler + Serialize,
|
||||
<Activity as ActivityHandler>::Error: From<anyhow::Error> + From<serde_json::Error>,
|
||||
T: Clone,
|
||||
Datatype: Clone,
|
||||
{
|
||||
let config = &data.config;
|
||||
let actor_id = activity.actor();
|
||||
let activity_id = activity.id();
|
||||
let activity_serialized = serde_json::to_string_pretty(&activity)?;
|
||||
let inboxes: Vec<Url> = recipients
|
||||
let inboxes: Vec<Url> = inboxes
|
||||
.into_iter()
|
||||
.unique()
|
||||
.filter(|i| !config.is_local_url(i))
|
||||
.collect();
|
||||
|
||||
// This field is only optional to make builder work, its always present at this point
|
||||
let activity_queue = config.activity_queue.as_ref().unwrap();
|
||||
let activity_queue = config
|
||||
.activity_queue
|
||||
.as_ref()
|
||||
.expect("Config has activity queue");
|
||||
for inbox in inboxes {
|
||||
if config.verify_url_valid(&inbox).await.is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = SendActivityTask {
|
||||
actor_id: actor_id.clone(),
|
||||
activity_id: activity_id.clone(),
|
||||
inbox,
|
||||
activity: activity_serialized.clone(),
|
||||
public_key: public_key.clone(),
|
||||
private_key: private_key.clone(),
|
||||
http_signature_compat: config.http_signature_compat,
|
||||
};
|
||||
|
@ -89,15 +130,15 @@ where
|
|||
warn!("{}", e);
|
||||
}
|
||||
} else {
|
||||
activity_queue.queue::<SendActivityTask>(message).await?;
|
||||
activity_queue.queue(message).await?;
|
||||
let stats = activity_queue.get_stats().await?;
|
||||
info!(
|
||||
"Activity queue stats: pending: {}, running: {}, dead (this hour): {}, complete (this hour): {}",
|
||||
stats.pending,
|
||||
stats.running,
|
||||
stats.dead.this_hour(),
|
||||
stats.complete.this_hour()
|
||||
);
|
||||
"Activity queue stats: pending: {}, running: {}, dead (this hour): {}, complete (this hour): {}",
|
||||
stats.pending,
|
||||
stats.running,
|
||||
stats.dead.this_hour(),
|
||||
stats.complete.this_hour()
|
||||
);
|
||||
if stats.running as u64 == config.worker_count {
|
||||
warn!("Maximum number of activitypub workers reached. Consider increasing worker count to avoid federation delays");
|
||||
}
|
||||
|
@ -109,29 +150,24 @@ where
|
|||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
struct SendActivityTask {
|
||||
actor_id: Url,
|
||||
activity_id: Url,
|
||||
inbox: Url,
|
||||
activity: String,
|
||||
public_key: PublicKey,
|
||||
inbox: Url,
|
||||
private_key: String,
|
||||
http_signature_compat: bool,
|
||||
}
|
||||
|
||||
/// Signs the activity with the sending actor's key, and delivers to the given inbox. Also retries
|
||||
/// if the delivery failed.
|
||||
impl ActixJob for SendActivityTask {
|
||||
type State = MyState;
|
||||
type State = QueueState;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>>;
|
||||
const NAME: &'static str = "SendActivityTask";
|
||||
|
||||
/// We need to retry activity sending in case the target instances is temporarily unreachable.
|
||||
/// In this case, the task is stored and resent when the instance is hopefully back up. This
|
||||
/// list shows the retry intervals, and which events of the target instance can be covered:
|
||||
/// - 60s (one minute, service restart)
|
||||
/// - 60min (one hour, instance maintenance)
|
||||
/// - 60h (2.5 days, major incident with rebuild from backup)
|
||||
/// TODO: make the intervals configurable
|
||||
const MAX_RETRIES: MaxRetries = MaxRetries::Count(3);
|
||||
/// This gives the following retry intervals:
|
||||
/// - 60s (one minute, for service restart)
|
||||
/// - 60min (one hour, for instance maintenance)
|
||||
/// - 60h (2.5 days, for major incident with rebuild from backup)
|
||||
const BACKOFF: Backoff = Backoff::Exponential(60);
|
||||
|
||||
fn run(self, state: Self::State) -> Self::Future {
|
||||
|
@ -151,9 +187,9 @@ async fn do_send(
|
|||
.headers(generate_request_headers(&task.inbox));
|
||||
let request = sign_request(
|
||||
request_builder,
|
||||
task.activity.clone(),
|
||||
task.public_key.clone(),
|
||||
task.private_key.clone(),
|
||||
task.actor_id,
|
||||
task.activity,
|
||||
task.private_key,
|
||||
task.http_signature_compat,
|
||||
)
|
||||
.await?;
|
||||
|
@ -176,7 +212,7 @@ async fn do_send(
|
|||
}
|
||||
Ok(o) => {
|
||||
let status = o.status();
|
||||
let text = o.text_limited().await.map_err(Error::conv)?;
|
||||
let text = o.text_limited().await.map_err(Error::other)?;
|
||||
Err(anyhow!(
|
||||
"Queueing activity {} to {} for retry after failure with status {}: {}",
|
||||
task.activity_id,
|
||||
|
@ -227,7 +263,7 @@ pub(crate) fn create_activity_queue(
|
|||
let worker_count = if debug { 0 } else { worker_count };
|
||||
|
||||
// Configure and start our workers
|
||||
WorkerConfig::new_managed(Storage::new(ActixTimer), move |_| MyState {
|
||||
WorkerConfig::new_managed(Storage::new(ActixTimer), move |_| QueueState {
|
||||
client: client.clone(),
|
||||
timeout: request_timeout,
|
||||
})
|
||||
|
@ -237,7 +273,7 @@ pub(crate) fn create_activity_queue(
|
|||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MyState {
|
||||
struct QueueState {
|
||||
client: ClientWithMiddleware,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
use crate::{
|
||||
config::RequestData,
|
||||
core::{
|
||||
http_signatures::{verify_inbox_hash, verify_signature},
|
||||
object_id::ObjectId,
|
||||
signatures::{verify_inbox_hash, verify_signature},
|
||||
},
|
||||
request_data::RequestData,
|
||||
error::Error,
|
||||
traits::{ActivityHandler, Actor, ApubObject},
|
||||
Error,
|
||||
};
|
||||
use actix_web::{web::Bytes, HttpRequest, HttpResponse};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
@ -32,7 +32,7 @@ where
|
|||
|
||||
let activity: Activity = serde_json::from_slice(&body)?;
|
||||
data.config.verify_url_and_domain(&activity).await?;
|
||||
let actor = ObjectId::<ActorT>::new(activity.actor().clone())
|
||||
let actor = ObjectId::<ActorT>::from(activity.actor().clone())
|
||||
.dereference(data)
|
||||
.await?;
|
||||
|
||||
|
@ -53,13 +53,12 @@ mod test {
|
|||
use super::*;
|
||||
use crate::{
|
||||
config::FederationConfig,
|
||||
core::signatures::{sign_request, PublicKey},
|
||||
core::http_signatures::sign_request,
|
||||
traits::tests::{DbConnection, DbUser, Follow, DB_USER_KEYPAIR},
|
||||
};
|
||||
use actix_web::test::TestRequest;
|
||||
use reqwest::Client;
|
||||
use reqwest_middleware::ClientWithMiddleware;
|
||||
use url::Url;
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_receive_activity() {
|
||||
|
@ -109,21 +108,17 @@ mod test {
|
|||
async fn setup_receive_test() -> (String, TestRequest, FederationConfig<DbConnection>) {
|
||||
let request_builder =
|
||||
ClientWithMiddleware::from(Client::default()).post("https://example.com/inbox");
|
||||
let public_key = PublicKey::new_main_key(
|
||||
Url::parse("https://example.com").unwrap(),
|
||||
DB_USER_KEYPAIR.public_key.clone(),
|
||||
);
|
||||
let activity = Follow {
|
||||
actor: "http://localhost:123".try_into().unwrap(),
|
||||
object: "http://localhost:124".try_into().unwrap(),
|
||||
actor: ObjectId::new("http://localhost:123").unwrap(),
|
||||
object: ObjectId::new("http://localhost:124").unwrap(),
|
||||
kind: Default::default(),
|
||||
id: "http://localhost:123/1".try_into().unwrap(),
|
||||
};
|
||||
let body = serde_json::to_string(&activity).unwrap();
|
||||
let outgoing_request = sign_request(
|
||||
request_builder,
|
||||
activity.actor.into_inner(),
|
||||
body.to_string(),
|
||||
public_key,
|
||||
DB_USER_KEYPAIR.private_key.clone(),
|
||||
false,
|
||||
)
|
||||
|
@ -135,7 +130,7 @@ mod test {
|
|||
}
|
||||
|
||||
let config = FederationConfig::builder()
|
||||
.hostname("localhost:8002")
|
||||
.domain("localhost:8002")
|
||||
.app_data(DbConnection)
|
||||
.debug(true)
|
||||
.build()
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
use crate::{
|
||||
config::FederationConfig,
|
||||
request_data::{ApubMiddleware, RequestData},
|
||||
};
|
||||
use crate::config::{ApubMiddleware, FederationConfig, RequestData};
|
||||
use actix_web::{
|
||||
dev::{forward_ready, Payload, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
Error,
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
use crate::{
|
||||
config::RequestData,
|
||||
core::{
|
||||
axum::ActivityData,
|
||||
http_signatures::{verify_inbox_hash, verify_signature},
|
||||
object_id::ObjectId,
|
||||
signatures::{verify_inbox_hash, verify_signature},
|
||||
},
|
||||
request_data::RequestData,
|
||||
error::Error,
|
||||
traits::{ActivityHandler, Actor, ApubObject},
|
||||
Error,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use tracing::debug;
|
||||
|
@ -31,7 +31,7 @@ where
|
|||
|
||||
let activity: Activity = serde_json::from_slice(&activity_data.body)?;
|
||||
data.config.verify_url_and_domain(&activity).await?;
|
||||
let actor = ObjectId::<ActorT>::new(activity.actor().clone())
|
||||
let actor = ObjectId::<ActorT>::from(activity.actor().clone())
|
||||
.dereference(data)
|
||||
.await?;
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ use serde::Serialize;
|
|||
/// # use axum::extract::Path;
|
||||
/// # use activitypub_federation::core::axum::json::ApubJson;
|
||||
/// # use activitypub_federation::protocol::context::WithContext;
|
||||
/// # use activitypub_federation::request_data::RequestData;
|
||||
/// # use activitypub_federation::config::RequestData;
|
||||
/// # use activitypub_federation::traits::ApubObject;
|
||||
/// # use activitypub_federation::traits::tests::{DbConnection, DbUser, Person};
|
||||
/// async fn http_get_user(Path(name): Path<String>, data: RequestData<DbConnection>) -> Result<ApubJson<WithContext<Person>>, Error> {
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
use crate::{
|
||||
config::FederationConfig,
|
||||
request_data::{ApubMiddleware, RequestData},
|
||||
};
|
||||
use crate::config::{ApubMiddleware, FederationConfig, RequestData};
|
||||
use axum::{async_trait, body::Body, extract::FromRequestParts, http::Request, response::Response};
|
||||
use http::{request::Parts, StatusCode};
|
||||
use std::task::{Context, Poll};
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
use crate::{utils::header_to_map, Error, Error::ActivitySignatureInvalid};
|
||||
use anyhow::anyhow;
|
||||
use crate::{
|
||||
error::{Error, Error::ActivitySignatureInvalid},
|
||||
protocol::public_key::main_key_id,
|
||||
};
|
||||
use http::{header::HeaderName, uri::PathAndQuery, HeaderValue, Method, Uri};
|
||||
use http_signature_normalization_reqwest::prelude::{Config, SignExt};
|
||||
use once_cell::sync::{Lazy, OnceCell};
|
||||
|
@ -11,9 +13,8 @@ use openssl::{
|
|||
};
|
||||
use reqwest::Request;
|
||||
use reqwest_middleware::RequestBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::ErrorKind;
|
||||
use std::{collections::BTreeMap, fmt::Debug, io::ErrorKind};
|
||||
use tracing::debug;
|
||||
use url::Url;
|
||||
|
||||
|
@ -49,11 +50,12 @@ pub fn generate_actor_keypair() -> Result<Keypair, std::io::Error> {
|
|||
/// `activity` as request body. The request is signed with `private_key` and then sent.
|
||||
pub(crate) async fn sign_request(
|
||||
request_builder: RequestBuilder,
|
||||
actor_id: Url,
|
||||
activity: String,
|
||||
public_key: PublicKey,
|
||||
private_key: String,
|
||||
http_signature_compat: bool,
|
||||
) -> Result<Request, anyhow::Error> {
|
||||
let key_id = main_key_id(&actor_id);
|
||||
let sig_conf = HTTP_SIG_CONFIG.get_or_init(|| {
|
||||
let c = Config::new();
|
||||
if http_signature_compat {
|
||||
|
@ -65,7 +67,7 @@ pub(crate) async fn sign_request(
|
|||
request_builder
|
||||
.signature_with_digest(
|
||||
sig_conf.clone(),
|
||||
public_key.id,
|
||||
key_id,
|
||||
Sha256::new(),
|
||||
activity,
|
||||
move |signing_string| {
|
||||
|
@ -79,33 +81,6 @@ pub(crate) async fn sign_request(
|
|||
.await
|
||||
}
|
||||
|
||||
/// Public key of actors which is used for HTTP signatures. This needs to be federated in the
|
||||
/// `public_key` field of all actors.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicKey {
|
||||
pub(crate) id: String,
|
||||
pub(crate) owner: Url,
|
||||
pub public_key_pem: String,
|
||||
}
|
||||
|
||||
impl PublicKey {
|
||||
/// Create public key with default id, for actors that only have a single keypair
|
||||
pub fn new_main_key(owner: Url, public_key_pem: String) -> Self {
|
||||
let key_id = format!("{}#main-key", &owner);
|
||||
PublicKey::new(key_id, owner, public_key_pem)
|
||||
}
|
||||
|
||||
/// Create public key with custom key id. Use this method if there are multiple keypairs per actor
|
||||
pub fn new(id: String, owner: Url, public_key_pem: String) -> Self {
|
||||
PublicKey {
|
||||
id,
|
||||
owner,
|
||||
public_key_pem,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static CONFIG2: Lazy<http_signature_normalization::Config> =
|
||||
Lazy::new(http_signature_normalization::Config::new);
|
||||
|
||||
|
@ -119,12 +94,17 @@ pub fn verify_signature<'a, H>(
|
|||
where
|
||||
H: IntoIterator<Item = (&'a HeaderName, &'a HeaderValue)>,
|
||||
{
|
||||
let headers = header_to_map(headers);
|
||||
let mut header_map = BTreeMap::<String, String>::new();
|
||||
for (name, value) in headers {
|
||||
if let Ok(value) = value.to_str() {
|
||||
header_map.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
let path_and_query = uri.path_and_query().map(PathAndQuery::as_str).unwrap_or("");
|
||||
|
||||
let verified = CONFIG2
|
||||
.begin_verify(method.as_str(), path_and_query, headers)
|
||||
.map_err(Error::conv)?
|
||||
.begin_verify(method.as_str(), path_and_query, header_map)
|
||||
.map_err(Error::other)?
|
||||
.verify(|signature, signing_string| -> anyhow::Result<bool> {
|
||||
debug!(
|
||||
"Verifying with key {}, message {}",
|
||||
|
@ -135,7 +115,7 @@ where
|
|||
verifier.update(signing_string.as_bytes())?;
|
||||
Ok(verifier.verify(&base64::decode(signature)?)?)
|
||||
})
|
||||
.map_err(Error::conv)?;
|
||||
.map_err(Error::other)?;
|
||||
|
||||
if verified {
|
||||
debug!("verified signature for {}", uri);
|
||||
|
@ -147,7 +127,10 @@ where
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
struct DigestPart {
|
||||
/// We assume that SHA256 is used which is the case with all major fediverse platforms
|
||||
#[allow(dead_code)]
|
||||
pub algorithm: String,
|
||||
/// The hashsum
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
|
@ -179,14 +162,16 @@ impl DigestPart {
|
|||
pub(crate) fn verify_inbox_hash(
|
||||
digest_header: Option<&HeaderValue>,
|
||||
body: &[u8],
|
||||
) -> Result<(), crate::Error> {
|
||||
let digests = DigestPart::try_from_header(digest_header.unwrap()).unwrap();
|
||||
) -> Result<(), Error> {
|
||||
let digest = digest_header
|
||||
.and_then(DigestPart::try_from_header)
|
||||
.ok_or(Error::ActivityBodyDigestInvalid)?;
|
||||
let mut hasher = Sha256::new();
|
||||
|
||||
for part in digests {
|
||||
for part in digest {
|
||||
hasher.update(body);
|
||||
if base64::encode(hasher.finalize_reset()) != part.digest {
|
||||
return Err(crate::Error::ActivityBodyDigestInvalid);
|
||||
return Err(Error::ActivityBodyDigestInvalid);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
pub mod activity_queue;
|
||||
pub mod http_signatures;
|
||||
pub mod object_id;
|
||||
pub mod signatures;
|
||||
|
||||
#[cfg(feature = "axum")]
|
||||
pub mod axum;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::{request_data::RequestData, traits::ApubObject, utils::fetch_object_http, Error};
|
||||
use crate::{config::RequestData, error::Error, traits::ApubObject, utils::fetch_object_http};
|
||||
use anyhow::anyhow;
|
||||
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
@ -22,17 +22,17 @@ use url::Url;
|
|||
/// ```
|
||||
/// # use activitypub_federation::core::object_id::ObjectId;
|
||||
/// # use activitypub_federation::config::FederationConfig;
|
||||
/// # use activitypub_federation::Error::NotFound;
|
||||
/// # use activitypub_federation::error::Error::NotFound;
|
||||
/// # use activitypub_federation::traits::tests::{DbConnection, DbUser};
|
||||
/// # let _ = actix_rt::System::new();
|
||||
/// # actix_rt::Runtime::new().unwrap().block_on(async {
|
||||
/// # let db_connection = DbConnection;
|
||||
/// let config = FederationConfig::builder()
|
||||
/// .hostname("example.com")
|
||||
/// .domain("example.com")
|
||||
/// .app_data(db_connection)
|
||||
/// .build()?;
|
||||
/// let request_data = config.to_request_data();
|
||||
/// let object_id: ObjectId::<DbUser> = "https://lemmy.ml/u/nutomic".try_into()?;
|
||||
/// let object_id = ObjectId::<DbUser>::new("https://lemmy.ml/u/nutomic")?;
|
||||
/// // Attempt to fetch object from local database or fall back to remote server
|
||||
/// let user = object_id.dereference(&request_data).await;
|
||||
/// assert!(user.is_ok());
|
||||
|
@ -55,11 +55,12 @@ where
|
|||
for<'de2> <Kind as ApubObject>::ApubType: serde::Deserialize<'de2>,
|
||||
{
|
||||
/// Construct a new objectid instance
|
||||
pub fn new<T>(url: T) -> Self
|
||||
pub fn new<T>(url: T) -> Result<Self, url::ParseError>
|
||||
where
|
||||
T: Into<Url>,
|
||||
T: TryInto<Url>,
|
||||
url::ParseError: From<<T as TryInto<Url>>::Error>,
|
||||
{
|
||||
ObjectId(Box::new(url.into()), PhantomData::<Kind>)
|
||||
Ok(ObjectId(Box::new(url.try_into()?), PhantomData::<Kind>))
|
||||
}
|
||||
|
||||
pub fn inner(&self) -> &Url {
|
||||
|
@ -213,19 +214,7 @@ where
|
|||
for<'de2> <Kind as ApubObject>::ApubType: serde::Deserialize<'de2>,
|
||||
{
|
||||
fn from(url: Url) -> Self {
|
||||
ObjectId::new(url)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Kind> TryFrom<&'a str> for ObjectId<Kind>
|
||||
where
|
||||
Kind: ApubObject + Send + 'static,
|
||||
for<'de2> <Kind as ApubObject>::ApubType: serde::Deserialize<'de2>,
|
||||
{
|
||||
type Error = url::ParseError;
|
||||
|
||||
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
|
||||
Ok(ObjectId::new(Url::parse(value)?))
|
||||
ObjectId(Box::new(url), PhantomData::<Kind>)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -246,8 +235,7 @@ pub mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_deserialize() {
|
||||
let url = Url::parse("http://test.com/").unwrap();
|
||||
let id = ObjectId::<DbUser>::new(url);
|
||||
let id = ObjectId::<DbUser>::new("http://test.com/").unwrap();
|
||||
|
||||
let string = serde_json::to_string(&id).unwrap();
|
||||
assert_eq!("\"http://test.com/\"", string);
|
||||
|
@ -259,9 +247,9 @@ pub mod tests {
|
|||
#[test]
|
||||
fn test_should_refetch_object() {
|
||||
let one_second_ago = Utc::now().naive_utc() - ChronoDuration::seconds(1);
|
||||
assert_eq!(false, should_refetch_object(one_second_ago));
|
||||
assert!(!should_refetch_object(one_second_ago));
|
||||
|
||||
let two_days_ago = Utc::now().naive_utc() - ChronoDuration::days(2);
|
||||
assert_eq!(true, should_refetch_object(two_days_ago));
|
||||
assert!(should_refetch_object(two_days_ago));
|
||||
}
|
||||
}
|
||||
|
|
40
src/error.rs
Normal file
40
src/error.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
use displaydoc::Display;
|
||||
|
||||
/// Error messages returned by this library.
|
||||
#[derive(thiserror::Error, Debug, Display)]
|
||||
pub enum Error {
|
||||
/// Object was not found in local database
|
||||
NotFound,
|
||||
/// Request limit was reached during fetch
|
||||
RequestLimit,
|
||||
/// Response body limit was reached during fetch
|
||||
ResponseBodyLimit,
|
||||
/// Object to be fetched was deleted
|
||||
ObjectDeleted,
|
||||
/// {0}
|
||||
UrlVerificationError(&'static str),
|
||||
/// Incoming activity has invalid digest for body
|
||||
ActivityBodyDigestInvalid,
|
||||
/// Incoming activity has invalid signature
|
||||
ActivitySignatureInvalid,
|
||||
/// Failed to resolve actor via webfinger
|
||||
WebfingerResolveFailed,
|
||||
/// Other errors which are not explicitly handled
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub(crate) fn other<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<anyhow::Error>,
|
||||
{
|
||||
Error::Other(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Error {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
std::mem::discriminant(self) == std::mem::discriminant(other)
|
||||
}
|
||||
}
|
48
src/lib.rs
48
src/lib.rs
|
@ -1,48 +1,20 @@
|
|||
#![doc = include_str!("../README.md")]
|
||||
|
||||
//#![deny(missing_docs)]
|
||||
|
||||
pub use activitystreams_kinds as kinds;
|
||||
/// Configuration for this library
|
||||
pub mod config;
|
||||
/// Contains main library functionality
|
||||
pub mod core;
|
||||
pub mod error;
|
||||
/// Data structures which help to define federated messages
|
||||
pub mod protocol;
|
||||
pub mod request_data;
|
||||
/// Traits which need to be implemented for federated data types
|
||||
pub mod traits;
|
||||
pub mod utils;
|
||||
pub mod webfinger;
|
||||
|
||||
pub use activitystreams_kinds as kinds;
|
||||
|
||||
/// Mime type for Activitypub, used for `Accept` and `Content-Type` HTTP headers
|
||||
pub static APUB_JSON_CONTENT_TYPE: &str = "application/activity+json";
|
||||
|
||||
/// Error messages returned by this library.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Object was not found in local database")]
|
||||
NotFound,
|
||||
#[error("Request limit was reached during fetch")]
|
||||
RequestLimit,
|
||||
#[error("Response body limit was reached during fetch")]
|
||||
ResponseBodyLimit,
|
||||
#[error("Object to be fetched was deleted")]
|
||||
ObjectDeleted,
|
||||
#[error("{0}")]
|
||||
UrlVerificationError(&'static str),
|
||||
#[error("Incoming activity has invalid digest for body")]
|
||||
ActivityBodyDigestInvalid,
|
||||
#[error("incoming activity has invalid signature")]
|
||||
ActivitySignatureInvalid,
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn conv<T>(error: T) -> Self
|
||||
where
|
||||
T: Into<anyhow::Error>,
|
||||
{
|
||||
Error::Other(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Error {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
std::mem::discriminant(self) == std::mem::discriminant(other)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::{
|
||||
config::RequestData,
|
||||
protocol::helpers::deserialize_one_or_many,
|
||||
request_data::RequestData,
|
||||
traits::ActivityHandler,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
pub mod context;
|
||||
pub mod helpers;
|
||||
pub mod public_key;
|
||||
pub mod values;
|
||||
|
|
27
src/protocol/public_key.rs
Normal file
27
src/protocol/public_key.rs
Normal file
|
@ -0,0 +1,27 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
/// Public key of actors which is used for HTTP signatures. This needs to be federated in the
|
||||
/// `public_key` field of all actors.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicKey {
|
||||
pub(crate) id: String,
|
||||
pub(crate) owner: Url,
|
||||
pub public_key_pem: String,
|
||||
}
|
||||
|
||||
impl PublicKey {
|
||||
pub fn new(owner: Url, public_key_pem: String) -> Self {
|
||||
let id = main_key_id(&owner);
|
||||
PublicKey {
|
||||
id,
|
||||
owner,
|
||||
public_key_pem,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn main_key_id(owner: &Url) -> String {
|
||||
format!("{}#main-key", &owner)
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
use crate::config::FederationConfig;
|
||||
use std::{ops::Deref, sync::atomic::AtomicI32};
|
||||
|
||||
/// Stores data for handling one specific HTTP request.
|
||||
///
|
||||
/// Most importantly this contains a counter for outgoing HTTP requests. This is necessary to
|
||||
/// prevent denial of service attacks, where an attacker triggers fetching of recursive objects.
|
||||
///
|
||||
/// <https://www.w3.org/TR/activitypub/#security-recursive-objects>
|
||||
pub struct RequestData<T: Clone> {
|
||||
pub(crate) config: FederationConfig<T>,
|
||||
pub(crate) request_counter: AtomicI32,
|
||||
}
|
||||
|
||||
impl<T: Clone> RequestData<T> {
|
||||
pub fn app_data(&self) -> &T {
|
||||
&self.config.app_data
|
||||
}
|
||||
pub fn hostname(&self) -> &str {
|
||||
&self.config.hostname
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> Deref for RequestData<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &T {
|
||||
&self.config.app_data
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApubMiddleware<T: Clone>(pub(crate) FederationConfig<T>);
|
||||
|
||||
impl<T: Clone> ApubMiddleware<T> {
|
||||
pub fn new(config: FederationConfig<T>) -> Self {
|
||||
ApubMiddleware(config)
|
||||
}
|
||||
}
|
102
src/traits.rs
102
src/traits.rs
|
@ -1,4 +1,4 @@
|
|||
use crate::request_data::RequestData;
|
||||
use crate::config::RequestData;
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use std::ops::Deref;
|
||||
|
@ -8,8 +8,8 @@ use url::Url;
|
|||
///
|
||||
/// ```
|
||||
/// # use url::Url;
|
||||
/// # use activitypub_federation::core::signatures::PublicKey;
|
||||
/// # use activitypub_federation::request_data::RequestData;
|
||||
/// # use activitypub_federation::protocol::public_key::PublicKey;
|
||||
/// # use activitypub_federation::config::RequestData;
|
||||
/// # use activitypub_federation::traits::ApubObject;
|
||||
/// # use activitypub_federation::traits::tests::{DbConnection, Person};
|
||||
/// # pub struct DbUser {
|
||||
|
@ -40,7 +40,7 @@ use url::Url;
|
|||
/// preferred_username: self.name,
|
||||
/// id: self.ap_id.clone().into(),
|
||||
/// inbox: self.inbox,
|
||||
/// public_key: PublicKey::new_main_key(self.ap_id, self.public_key),
|
||||
/// public_key: PublicKey::new(self.ap_id, self.public_key),
|
||||
/// })
|
||||
/// }
|
||||
///
|
||||
|
@ -57,8 +57,7 @@ use url::Url;
|
|||
/// };
|
||||
///
|
||||
/// // Make sure not to overwrite any local object
|
||||
/// // TODO: this should be handled by library so the method doesnt get called for local object
|
||||
/// if data.hostname() == user.ap_id.domain().unwrap() {
|
||||
/// if data.domain() == user.ap_id.domain().unwrap() {
|
||||
/// // Activitypub doesnt distinguish between creating and updating an object. Thats why we
|
||||
/// // need to use upsert functionality here
|
||||
/// data.upsert(&user).await?;
|
||||
|
@ -69,7 +68,8 @@ use url::Url;
|
|||
/// }
|
||||
#[async_trait]
|
||||
pub trait ApubObject: Sized {
|
||||
/// App data type passed to handlers. Must be identical to [crate::config::FederationConfig::app_data].
|
||||
/// App data type passed to handlers. Must be identical to
|
||||
/// [crate::config::FederationConfigBuilder::app_data] type.
|
||||
type DataType: Clone + Send + Sync;
|
||||
/// The type of protocol struct which gets sent over network to federate this database struct.
|
||||
type ApubType;
|
||||
|
@ -125,7 +125,7 @@ pub trait ApubObject: Sized {
|
|||
/// # use activitystreams_kinds::activity::FollowType;
|
||||
/// # use url::Url;
|
||||
/// # use activitypub_federation::core::object_id::ObjectId;
|
||||
/// # use activitypub_federation::request_data::RequestData;
|
||||
/// # use activitypub_federation::config::RequestData;
|
||||
/// # use activitypub_federation::traits::ActivityHandler;
|
||||
/// # use activitypub_federation::traits::tests::{DbConnection, DbUser};
|
||||
/// #[derive(serde::Deserialize)]
|
||||
|
@ -161,7 +161,8 @@ pub trait ApubObject: Sized {
|
|||
#[async_trait]
|
||||
#[enum_delegate::register]
|
||||
pub trait ActivityHandler {
|
||||
/// App data type passed to handlers. Must be identical to [crate::config::FederationConfig::app_data].
|
||||
/// App data type passed to handlers. Must be identical to
|
||||
/// [crate::config::FederationConfigBuilder::app_data] type.
|
||||
type DataType: Clone + Send + Sync;
|
||||
/// Error type returned by handler methods
|
||||
type Error;
|
||||
|
@ -181,7 +182,7 @@ pub trait ActivityHandler {
|
|||
|
||||
/// Trait to allow retrieving common Actor data.
|
||||
pub trait Actor: ApubObject {
|
||||
/// The actor's public key for verification of HTTP signatures
|
||||
/// The actor's public key for verifying signatures of incoming activities.
|
||||
fn public_key(&self) -> &str;
|
||||
|
||||
/// The inbox where activities for this user should be sent to
|
||||
|
@ -224,11 +225,15 @@ where
|
|||
///
|
||||
/// TODO: Should be using `cfg[doctest]` but blocked by <https://github.com/rust-lang/rust/issues/67295>
|
||||
#[doc(hidden)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
pub mod tests {
|
||||
use super::*;
|
||||
use crate::core::{
|
||||
object_id::ObjectId,
|
||||
signatures::{generate_actor_keypair, Keypair, PublicKey},
|
||||
use crate::{
|
||||
core::{
|
||||
http_signatures::{generate_actor_keypair, Keypair},
|
||||
object_id::ObjectId,
|
||||
},
|
||||
protocol::public_key::PublicKey,
|
||||
};
|
||||
use activitystreams_kinds::{activity::FollowType, actor::PersonType};
|
||||
use anyhow::Error;
|
||||
|
@ -266,11 +271,10 @@ pub mod tests {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct DbUser {
|
||||
pub name: String,
|
||||
pub ap_id: Url,
|
||||
pub apub_id: Url,
|
||||
pub inbox: Url,
|
||||
// exists for all users (necessary to verify http signatures)
|
||||
pub public_key: String,
|
||||
// exists only for local users
|
||||
#[allow(dead_code)]
|
||||
private_key: Option<String>,
|
||||
pub followers: Vec<Url>,
|
||||
pub local: bool,
|
||||
|
@ -278,6 +282,16 @@ pub mod tests {
|
|||
|
||||
pub static DB_USER_KEYPAIR: Lazy<Keypair> = Lazy::new(|| generate_actor_keypair().unwrap());
|
||||
|
||||
pub static DB_USER: Lazy<DbUser> = Lazy::new(|| DbUser {
|
||||
name: String::new(),
|
||||
apub_id: "https://localhost/123".parse().unwrap(),
|
||||
inbox: "https://localhost/123/inbox".parse().unwrap(),
|
||||
public_key: DB_USER_KEYPAIR.public_key.clone(),
|
||||
private_key: None,
|
||||
followers: vec![],
|
||||
local: false,
|
||||
});
|
||||
|
||||
#[async_trait]
|
||||
impl ApubObject for DbUser {
|
||||
type DataType = DbConnection;
|
||||
|
@ -285,29 +299,21 @@ pub mod tests {
|
|||
type Error = Error;
|
||||
|
||||
async fn read_from_apub_id(
|
||||
object_id: Url,
|
||||
_object_id: Url,
|
||||
_data: &RequestData<Self::DataType>,
|
||||
) -> Result<Option<Self>, Self::Error> {
|
||||
Ok(Some(DbUser {
|
||||
name: "".to_string(),
|
||||
ap_id: object_id.clone().into(),
|
||||
inbox: object_id.into(),
|
||||
public_key: DB_USER_KEYPAIR.public_key.clone(),
|
||||
private_key: None,
|
||||
followers: vec![],
|
||||
local: false,
|
||||
}))
|
||||
Ok(Some(DB_USER.clone()))
|
||||
}
|
||||
|
||||
async fn into_apub(
|
||||
self,
|
||||
_data: &RequestData<Self::DataType>,
|
||||
) -> Result<Self::ApubType, Self::Error> {
|
||||
let public_key = PublicKey::new_main_key(self.ap_id.clone(), self.public_key.clone());
|
||||
let public_key = PublicKey::new(self.apub_id.clone(), self.public_key.clone());
|
||||
Ok(Person {
|
||||
preferred_username: self.name.clone(),
|
||||
kind: Default::default(),
|
||||
id: self.ap_id.into(),
|
||||
id: self.apub_id.into(),
|
||||
inbox: self.inbox,
|
||||
public_key,
|
||||
})
|
||||
|
@ -319,7 +325,7 @@ pub mod tests {
|
|||
) -> Result<Self, Self::Error> {
|
||||
Ok(DbUser {
|
||||
name: apub.preferred_username,
|
||||
ap_id: apub.id.into(),
|
||||
apub_id: apub.id.into(),
|
||||
inbox: apub.inbox,
|
||||
public_key: apub.public_key.public_key_pem,
|
||||
private_key: None,
|
||||
|
@ -335,7 +341,7 @@ pub mod tests {
|
|||
}
|
||||
|
||||
fn inbox(&self) -> Url {
|
||||
todo!()
|
||||
self.inbox.clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -362,8 +368,42 @@ pub mod tests {
|
|||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn receive(self, data: &RequestData<Self::DataType>) -> Result<(), Self::Error> {
|
||||
async fn receive(self, _data: &RequestData<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Note {}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DbPost {}
|
||||
|
||||
#[async_trait]
|
||||
impl ApubObject for DbPost {
|
||||
type DataType = DbConnection;
|
||||
type ApubType = Note;
|
||||
type Error = Error;
|
||||
|
||||
async fn read_from_apub_id(
|
||||
_: Url,
|
||||
_: &RequestData<Self::DataType>,
|
||||
) -> Result<Option<Self>, Self::Error> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn into_apub(
|
||||
self,
|
||||
_: &RequestData<Self::DataType>,
|
||||
) -> Result<Self::ApubType, Self::Error> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn from_apub(
|
||||
_: Self::ApubType,
|
||||
_: &RequestData<Self::DataType>,
|
||||
) -> Result<Self, Self::Error> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
use crate::{
|
||||
request_data::RequestData,
|
||||
config::RequestData,
|
||||
error::Error,
|
||||
utils::reqwest_shim::ResponseExt,
|
||||
Error,
|
||||
APUB_JSON_CONTENT_TYPE,
|
||||
};
|
||||
use http::{header::HeaderName, HeaderValue, StatusCode};
|
||||
use http::StatusCode;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::{collections::BTreeMap, sync::atomic::Ordering};
|
||||
use std::sync::atomic::Ordering;
|
||||
use tracing::info;
|
||||
use url::Url;
|
||||
|
||||
|
@ -28,7 +28,7 @@ pub async fn fetch_object_http<T: Clone, Kind: DeserializeOwned>(
|
|||
) -> Result<Kind, Error> {
|
||||
let config = &data.config;
|
||||
// dont fetch local objects this way
|
||||
debug_assert!(url.domain() != Some(&config.hostname));
|
||||
debug_assert!(url.domain() != Some(&config.domain));
|
||||
config.verify_url_valid(url).await?;
|
||||
info!("Fetching remote object {}", url.to_string());
|
||||
|
||||
|
@ -44,7 +44,7 @@ pub async fn fetch_object_http<T: Clone, Kind: DeserializeOwned>(
|
|||
.timeout(config.request_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Error::conv)?;
|
||||
.map_err(Error::other)?;
|
||||
|
||||
if res.status() == StatusCode::GONE {
|
||||
return Err(Error::ObjectDeleted);
|
||||
|
@ -86,19 +86,3 @@ pub fn verify_urls_match(a: &Url, b: &Url) -> Result<(), Error> {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Utility to converts either actix or axum headermap to a BTreeMap
|
||||
pub fn header_to_map<'a, H>(headers: H) -> BTreeMap<String, String>
|
||||
where
|
||||
H: IntoIterator<Item = (&'a HeaderName, &'a HeaderValue)>,
|
||||
{
|
||||
let mut header_map = BTreeMap::new();
|
||||
|
||||
for (name, value) in headers {
|
||||
if let Ok(value) = value.to_str() {
|
||||
header_map.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
header_map
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::Error;
|
||||
use crate::error::Error;
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use futures_core::{ready, stream::BoxStream, Stream};
|
||||
use pin_project_lite::pin_project;
|
||||
|
@ -32,7 +32,7 @@ impl Future for BytesFuture {
|
|||
let this = self.as_mut().project();
|
||||
if let Some(chunk) = ready!(this.stream.poll_next(cx))
|
||||
.transpose()
|
||||
.map_err(Error::conv)?
|
||||
.map_err(Error::other)?
|
||||
{
|
||||
this.aggregator.put(chunk);
|
||||
if this.aggregator.len() > *this.limit {
|
||||
|
@ -66,7 +66,7 @@ where
|
|||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let this = self.project();
|
||||
let bytes = ready!(this.future.poll(cx))?;
|
||||
Poll::Ready(serde_json::from_slice(&bytes).map_err(Error::conv))
|
||||
Poll::Ready(serde_json::from_slice(&bytes).map_err(Error::other))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -83,7 +83,7 @@ impl Future for TextFuture {
|
|||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let this = self.project();
|
||||
let bytes = ready!(this.future.poll(cx))?;
|
||||
Poll::Ready(String::from_utf8(bytes.to_vec()).map_err(Error::conv))
|
||||
Poll::Ready(String::from_utf8(bytes.to_vec()).map_err(Error::other))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
140
src/webfinger.rs
Normal file
140
src/webfinger.rs
Normal file
|
@ -0,0 +1,140 @@
|
|||
use crate::{
|
||||
config::RequestData,
|
||||
core::object_id::ObjectId,
|
||||
error::{Error, Error::WebfingerResolveFailed},
|
||||
traits::{Actor, ApubObject},
|
||||
utils::fetch_object_http,
|
||||
APUB_JSON_CONTENT_TYPE,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use itertools::Itertools;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
use url::Url;
|
||||
|
||||
/// Turns a person id like `@name@example.com` into an apub ID, like `https://example.com/user/name`,
|
||||
/// using webfinger.
|
||||
pub async fn webfinger_resolve_actor<T: Clone, Kind>(
|
||||
identifier: &str,
|
||||
data: &RequestData<T>,
|
||||
) -> Result<Kind, <Kind as ApubObject>::Error>
|
||||
where
|
||||
Kind: ApubObject + Actor + Send + 'static + ApubObject<DataType = T>,
|
||||
for<'de2> <Kind as ApubObject>::ApubType: serde::Deserialize<'de2>,
|
||||
<Kind as ApubObject>::Error:
|
||||
From<crate::error::Error> + From<anyhow::Error> + From<url::ParseError> + Send + Sync,
|
||||
{
|
||||
let (_, domain) = identifier
|
||||
.splitn(2, '@')
|
||||
.collect_tuple()
|
||||
.ok_or_else(|| WebfingerResolveFailed)?;
|
||||
let protocol = if data.config.debug { "http" } else { "https" };
|
||||
let fetch_url =
|
||||
format!("{protocol}://{domain}/.well-known/webfinger?resource=acct:{identifier}");
|
||||
debug!("Fetching webfinger url: {}", &fetch_url);
|
||||
|
||||
let res: Webfinger = fetch_object_http(&Url::parse(&fetch_url)?, data).await?;
|
||||
|
||||
let links: Vec<Url> = res
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| {
|
||||
if let Some(type_) = &link.kind {
|
||||
type_.starts_with("application/")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.filter_map(|l| l.href.clone())
|
||||
.collect();
|
||||
for l in links {
|
||||
let object = ObjectId::<Kind>::from(l).dereference(data).await;
|
||||
if object.is_ok() {
|
||||
return object;
|
||||
}
|
||||
}
|
||||
Err(WebfingerResolveFailed.into())
|
||||
}
|
||||
|
||||
/// Extracts username from a webfinger resource parameter.
|
||||
///
|
||||
/// For a parameter of the form `acct:gargron@mastodon.social` it returns `gargron`.
|
||||
///
|
||||
/// Returns an error if query doesn't match local domain.
|
||||
pub fn extract_webfinger_name<T>(query: &str, data: &RequestData<T>) -> Result<String, Error>
|
||||
where
|
||||
T: Clone,
|
||||
{
|
||||
// TODO: would be nice if we could implement this without regex and remove the dependency
|
||||
let regex = Regex::new(&format!("^acct:([a-zA-Z0-9_]{{3,}})@{}$", data.domain()))
|
||||
.map_err(Error::other)?;
|
||||
Ok(regex
|
||||
.captures(query)
|
||||
.and_then(|c| c.get(1))
|
||||
.ok_or_else(|| Error::other(anyhow!("Webfinger regex failed to match")))?
|
||||
.as_str()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Builds a basic webfinger response under the assumption that `html` and `activity+json`
|
||||
/// links are identical.
|
||||
pub fn build_webfinger_response(resource: String, url: Url) -> Webfinger {
|
||||
Webfinger {
|
||||
subject: resource,
|
||||
links: vec![
|
||||
WebfingerLink {
|
||||
rel: Some("http://webfinger.net/rel/profile-page".to_string()),
|
||||
kind: Some("text/html".to_string()),
|
||||
href: Some(url.clone()),
|
||||
properties: Default::default(),
|
||||
},
|
||||
WebfingerLink {
|
||||
rel: Some("self".to_string()),
|
||||
kind: Some(APUB_JSON_CONTENT_TYPE.to_string()),
|
||||
href: Some(url),
|
||||
properties: Default::default(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Webfinger {
|
||||
pub subject: String,
|
||||
pub links: Vec<WebfingerLink>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct WebfingerLink {
|
||||
pub rel: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: Option<String>,
|
||||
pub href: Option<Url>,
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
config::FederationConfig,
|
||||
traits::tests::{DbConnection, DbUser},
|
||||
};
|
||||
|
||||
#[actix_rt::test]
|
||||
async fn test_webfinger() {
|
||||
let config = FederationConfig::builder()
|
||||
.domain("example.com")
|
||||
.app_data(DbConnection)
|
||||
.build()
|
||||
.unwrap();
|
||||
let data = config.to_request_data();
|
||||
let res =
|
||||
webfinger_resolve_actor::<DbConnection, DbUser>("LemmyDev@mastodon.social", &data)
|
||||
.await;
|
||||
assert!(res.is_ok());
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue