Skip to main content

netget/server/activitypub/
actions.rs

1//! What the model does as an ActivityPub instance: answer Follow requests, post notes (to
2//! followers and named actors), like, follow and unfollow — in answer to every activity
3//! that arrives, signed, at an inbox. Rust owns WebFinger, actor documents, keys, HTTP
4//! Signatures and delivery.
5use crate::llm::actions::{
6    protocol_trait::{ActionResult, Protocol, Server},
7    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::{log_template::LogTemplate, EventType, SpawnContext};
10use crate::state::app_state::AppState;
11use anyhow::{bail, ensure, Context, Result};
12use serde_json::{json, Value};
13use std::sync::LazyLock;
14
15pub const ACCEPT: &str = "activitypub_accept";
16pub const REJECT: &str = "activitypub_reject";
17pub const POST: &str = "activitypub_post";
18pub const LIKE: &str = "activitypub_like";
19pub const FOLLOW: &str = "activitypub_follow";
20pub const UNFOLLOW: &str = "activitypub_unfollow";
21/// The actors an instance hosts when none are configured.
22pub const DEFAULT_ACTORS: &[&str] = &["netget"];
23/// A note's text is at most this long.
24pub const MAX_CONTENT: usize = 10_000;
25
26#[derive(Default, Clone)]
27pub struct ActivityPubProtocol;
28
29impl ActivityPubProtocol {
30    pub fn new() -> Self {
31        Self
32    }
33}
34
35pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
36    Parameter {
37        name: name.into(),
38        type_hint: type_hint.into(),
39        description: description.into(),
40        required,
41    }
42}
43
44pub fn action(
45    name: &str,
46    description: &str,
47    parameters: Vec<Parameter>,
48    example: Value,
49) -> ActionDefinition {
50    ActionDefinition {
51        name: name.into(),
52        description: description.into(),
53        parameters,
54        example,
55        log_template: Some(LogTemplate::new().with_info(format!(
56            "-> ActivityPub {}",
57            name.trim_start_matches("activitypub_")
58        ))),
59    }
60}
61
62fn as_param() -> Parameter {
63    p(
64        "as",
65        "string",
66        "Which local actor acts (default: the one whose inbox it was, else the first)",
67        false,
68    )
69}
70
71pub fn post_action() -> ActionDefinition {
72    action(
73        POST,
74        "Publish a note: delivered to the actor's followers and to anyone in `to`.",
75        vec![
76            p(
77                "content",
78                "string",
79                "The note's text (plain text; NetGet makes the HTML)",
80                true,
81            ),
82            p(
83                "to",
84                "array",
85                "Extra recipients: actor URLs or handles like alice@example.social",
86                false,
87            ),
88            p(
89                "public",
90                "boolean",
91                "Address it to the public as well (default true)",
92                false,
93            ),
94            p(
95                "in_reply_to",
96                "string",
97                "The id of the note this answers",
98                false,
99            ),
100            as_param(),
101        ],
102        json!({"type": POST, "content": "Hello, fediverse", "public": true}),
103    )
104}
105
106pub fn target_param() -> Parameter {
107    p(
108        "target",
109        "string",
110        "An actor URL or a handle like alice@example.social",
111        true,
112    )
113}
114
115pub fn shared_actions() -> Vec<ActionDefinition> {
116    vec![
117        post_action(),
118        action(LIKE, "Like an object, telling its author.",
119            vec![p("object", "string", "The id of the object liked", true),
120                 p("to", "string", "Whom to tell: the author's actor URL or handle (default: whoever sent the event)", false), as_param()],
121            json!({"type": LIKE, "object": "https://example.social/notes/1"})),
122        action(FOLLOW, "Follow a remote actor (it answers with Accept or Reject).",
123            vec![target_param(), as_param()],
124            json!({"type": FOLLOW, "target": "alice@example.social"})),
125        action(UNFOLLOW, "Stop following a remote actor.",
126            vec![target_param(), as_param()],
127            json!({"type": UNFOLLOW, "target": "alice@example.social"})),
128    ]
129}
130
131pub fn all_actions() -> Vec<ActionDefinition> {
132    let mut v = vec![
133        action(
134            ACCEPT,
135            "Accept the Follow request being answered: the follower is added and sent an Accept.",
136            vec![],
137            json!({"type": ACCEPT}),
138        ),
139        action(
140            REJECT,
141            "Refuse the Follow request being answered: the follower is sent a Reject.",
142            vec![],
143            json!({"type": REJECT}),
144        ),
145    ];
146    v.extend(shared_actions());
147    v
148}
149
150pub static ACTIVITY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
151    EventType::new(
152        "activitypub_activity",
153        "A signed activity arrived at an inbox (Follow, Undo, Create, Like, Announce, …). The signature, digest and date were verified and the actor is the signer. For a Follow, answer activitypub_accept or activitypub_reject.",
154        json!({"type": ACCEPT}),
155    )
156    .with_parameters(vec![
157        p("to_actor", "string", "The local actor whose inbox it was (absent for the shared inbox)", false),
158        p("type", "string", "The activity type, e.g. Follow, Create, Like, Undo", true),
159        p("id", "string", "The activity's id", false),
160        p("actor", "string", "The sender's actor URL (proven by its signature)", true),
161        p("actor_handle", "string", "The sender as user@host, when its document names a username", false),
162        p("object_type", "string", "The type of the object, e.g. Note", false),
163        p("object_id", "string", "The object's id (for a Follow: whom it follows)", false),
164        p("content", "string", "A note's text, HTML removed", false),
165        p("in_reply_to", "string", "What a note answers", false),
166    ])
167    .with_actions(all_actions())
168});
169
170pub fn check(v: &Value) -> Result<()> {
171    let s = |k: &str| v.get(k).filter(|x| !x.is_null());
172    match v["type"].as_str().unwrap_or_default() {
173        ACCEPT | REJECT => {}
174        POST => {
175            let c = v["content"].as_str().context("content is required")?;
176            ensure!(
177                !c.is_empty() && c.len() <= MAX_CONTENT,
178                "content must be 1-{MAX_CONTENT} bytes"
179            );
180            if let Some(to) = s("to") {
181                ensure!(
182                    to.as_array()
183                        .is_some_and(|a| a.iter().all(Value::is_string)),
184                    "to must be a list of actors"
185                );
186            }
187        }
188        LIKE => {
189            v["object"].as_str().context("object is required")?;
190        }
191        FOLLOW | UNFOLLOW => {
192            let t = v["target"].as_str().context("target is required")?;
193            ensure!(
194                t.starts_with("http") || t.contains('@'),
195                "target must be an actor URL or user@host"
196            );
197        }
198        other => bail!("Unknown ActivityPub action {other:?}"),
199    }
200    Ok(())
201}
202
203impl Protocol for ActivityPubProtocol {
204    fn protocol_name(&self) -> &'static str {
205        "ActivityPub"
206    }
207    fn stack_name(&self) -> &'static str {
208        "ETH>IP>TCP>HTTP>ActivityPub"
209    }
210    fn keywords(&self) -> Vec<&'static str> {
211        vec![
212            "activitypub",
213            "fediverse",
214            "mastodon",
215            "webfinger",
216            "federation",
217        ]
218    }
219    fn description(&self) -> &'static str {
220        "ActivityPub instance (server-to-server): WebFinger, actors with RSA keys, signed inboxes and delivery; the model answers Follows and posts, likes and follows as its actors"
221    }
222    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
223        Vec::new()
224    }
225    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
226        all_actions()
227    }
228    fn get_event_types(&self) -> Vec<EventType> {
229        vec![ACTIVITY_EVENT.clone()]
230    }
231    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
232        Some(crate::protocol::BindingDefaults::port_based("127.0.0.1", 0))
233    }
234    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
235        vec![
236            ParameterDefinition {
237                name: "actors".into(),
238                type_hint: "array".into(),
239                description: "Usernames of the actors this instance hosts".into(),
240                required: false,
241                example: json!(["netget", "bot"]),
242                default: Some(json!(DEFAULT_ACTORS)),
243            },
244            ParameterDefinition {
245                name: "base_url".into(),
246                type_hint: "string".into(),
247                description: "The public URL ids are built from, e.g. https://social.example (default: http:// and the address it listens on)".into(),
248                required: false,
249                example: json!("https://social.example"),
250                default: None,
251            },
252        ]
253    }
254    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
255        use crate::protocol::metadata::*;
256        ProtocolMetadataV2::builder()
257            .state(DevelopmentState::Experimental)
258            .privilege_requirement(PrivilegeRequirement::None)
259            .implementation("hyper HTTP/1.1 (src/server/activitypub): WebFinger, NodeInfo 2.1, Person actors with RSA-2048 keys, followers/following/outbox collections, notes; inboxes (per actor and shared) verify draft-cavage HTTP Signatures (rsa-sha256) over (request-target), host, date and digest, with the signer's key fetched and the activity's actor required to be the signer; outgoing GETs and deliveries are signed the same way (reqwest)")
260            .llm_control("Every verified activity: whether to accept Follows, and what to post, like and follow in answer")
261            .e2e_testing("tests/server/activitypub: Fedify 2.4.2 — its CLI resolves and parses NetGet's actor (webfinger, lookup), and an actor built on the Fedify library follows NetGet's actor and verifies its signed Accept and Create; raw HTTP for refused signatures and bounds")
262            .notes("No object integrity proofs, no RFC 9421 signatures (Fedify falls back to draft-cavage after a 401), no authorized-fetch enforcement on GETs, no media, no Announce/boost sending, no persistence: followers, outboxes and notes live while the server runs (bounded). Inbox POSTs and fetched documents are capped at 1 MiB, deliveries at 100 inboxes per post.")
263            .request_only("Every response answers an HTTP request; deliveries are separate requests")
264            .answers_on_failure()
265            .max_inbound_bytes(super::instance::MAX_DOCUMENT)
266            .build()
267    }
268    fn example_prompt(&self) -> &'static str {
269        "ActivityPub instance hosting @netget that accepts every follower and greets them with a post"
270    }
271    fn get_startup_examples(&self) -> StartupExamples {
272        let base = json!({"type":"open_server","base_stack":"activitypub","port":0,
273            "instruction":"Accept every Follow and post a public greeting naming the new follower"});
274        let mut static_example = base.clone();
275        static_example["event_handlers"] = json!([{"event_pattern":"activitypub_activity","handler":{"type":"static","actions":[{"type":ACCEPT}]}}]);
276        let mut scripted = base.clone();
277        scripted["event_handlers"] = json!([{"event_pattern":"*","handler":{"type":"script","language":"python",
278            "code":"import json,sys\ne=json.load(sys.stdin)['event']\na=[]\nif e['type']=='Follow': a=[{'type':'activitypub_accept'},{'type':'activitypub_post','content':'Welcome '+e['actor']}]\nprint(json.dumps({'actions':a}))"}}]);
279        StartupExamples::new(base, scripted, static_example)
280    }
281    fn group_name(&self) -> &'static str {
282        "Application"
283    }
284}
285
286impl Server for ActivityPubProtocol {
287    fn spawn(
288        &self,
289        ctx: SpawnContext,
290    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
291    {
292        Box::pin(super::spawn(ctx))
293    }
294    fn execute_action(&self, action: Value) -> Result<ActionResult> {
295        check(&action)?;
296        let name = action["type"].as_str().unwrap_or_default().to_string();
297        Ok(ActionResult::Custom { name, data: action })
298    }
299}