solid_pod_rs_activitypub/
actor.rs1use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
16use rsa::{RsaPrivateKey, RsaPublicKey};
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct PublicKey {
22 pub id: String,
23 pub owner: String,
24 #[serde(rename = "publicKeyPem")]
25 pub public_key_pem: String,
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
32pub struct Endpoints {
33 #[serde(rename = "sharedInbox", skip_serializing_if = "Option::is_none")]
34 pub shared_inbox: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Actor {
44 #[serde(rename = "@context")]
45 pub context: Vec<serde_json::Value>,
46 pub id: String,
47 #[serde(rename = "type")]
48 pub actor_type: String,
49 #[serde(rename = "preferredUsername")]
50 pub preferred_username: String,
51 pub name: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub summary: Option<String>,
54 pub inbox: String,
55 pub outbox: String,
56 pub followers: String,
57 pub following: String,
58 #[serde(rename = "publicKey")]
59 pub public_key: PublicKey,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub endpoints: Option<Endpoints>,
62 #[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
65 pub also_known_as: Vec<String>,
66}
67
68pub fn generate_actor_keypair() -> Result<(String, String), crate::error::SigError> {
75 let mut rng = rand::thread_rng();
76 let private_key = RsaPrivateKey::new(&mut rng, 2048)
77 .map_err(|e| crate::error::SigError::Rsa(e.to_string()))?;
78 let public_key = RsaPublicKey::from(&private_key);
79 let priv_pem = private_key
80 .to_pkcs8_pem(LineEnding::LF)
81 .map_err(|e| crate::error::SigError::Rsa(e.to_string()))?
82 .to_string();
83 let pub_pem = public_key
84 .to_public_key_pem(LineEnding::LF)
85 .map_err(|e| crate::error::SigError::Rsa(e.to_string()))?;
86 Ok((priv_pem, pub_pem))
87}
88
89pub fn render_actor(
98 base_url: &str,
99 preferred_username: &str,
100 display_name: &str,
101 summary: Option<&str>,
102 pubkey_pem: &str,
103) -> Actor {
104 let base = base_url.trim_end_matches('/');
105 let profile = format!("{base}/profile/card.jsonld");
106 let actor_id = format!("{profile}#me");
107
108 Actor {
109 context: vec![
110 serde_json::Value::String("https://www.w3.org/ns/activitystreams".to_string()),
111 serde_json::Value::String("https://w3id.org/security/v1".to_string()),
112 ],
113 id: actor_id.clone(),
114 actor_type: "Person".to_string(),
115 preferred_username: preferred_username.to_string(),
116 name: display_name.to_string(),
117 summary: summary.map(|s| s.to_string()),
118 inbox: format!("{profile}/inbox"),
119 outbox: format!("{profile}/outbox"),
120 followers: format!("{profile}/followers"),
121 following: format!("{profile}/following"),
122 public_key: PublicKey {
123 id: format!("{profile}#main-key"),
124 owner: actor_id,
125 public_key_pem: pubkey_pem.to_string(),
126 },
127 endpoints: Some(Endpoints {
128 shared_inbox: Some(format!("{base}/inbox")),
129 }),
130 also_known_as: Vec::new(),
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ActorFormat {
140 ActivityJson,
143 LdpProfile,
145}
146
147pub fn negotiate_actor_format(accept: &str) -> ActorFormat {
158 let lower = accept.to_ascii_lowercase();
160
161 if lower.contains("application/activity+json") {
163 return ActorFormat::ActivityJson;
164 }
165
166 if lower.contains("application/ld+json") {
168 if lower.contains("https://www.w3.org/ns/activitystreams") {
172 return ActorFormat::ActivityJson;
173 }
174 }
175
176 ActorFormat::LdpProfile
177}
178
179pub fn with_also_known_as(mut actor: Actor, also: impl IntoIterator<Item = String>) -> Actor {
183 actor.also_known_as.extend(also);
184 actor
185}
186
187#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn actor_document_shape() {
197 let actor = render_actor(
198 "https://pod.example",
199 "alice",
200 "Alice Example",
201 Some("bio"),
202 "-----BEGIN PUBLIC KEY-----\nAAA\n-----END PUBLIC KEY-----",
203 );
204 assert_eq!(actor.id, "https://pod.example/profile/card.jsonld#me");
205 assert_eq!(actor.actor_type, "Person");
206 assert_eq!(actor.preferred_username, "alice");
207 assert_eq!(actor.inbox, "https://pod.example/profile/card.jsonld/inbox");
208 assert_eq!(
209 actor.outbox,
210 "https://pod.example/profile/card.jsonld/outbox"
211 );
212 assert_eq!(
213 actor.followers,
214 "https://pod.example/profile/card.jsonld/followers"
215 );
216 assert_eq!(
217 actor.following,
218 "https://pod.example/profile/card.jsonld/following"
219 );
220 assert_eq!(
221 actor.public_key.id,
222 "https://pod.example/profile/card.jsonld#main-key"
223 );
224 assert_eq!(actor.public_key.owner, actor.id);
225 assert!(actor
226 .public_key
227 .public_key_pem
228 .contains("BEGIN PUBLIC KEY"));
229 assert_eq!(
230 actor.endpoints.as_ref().and_then(|e| e.shared_inbox.as_deref()),
231 Some("https://pod.example/inbox")
232 );
233 }
234
235 #[test]
236 fn actor_context_order_preserved_for_fediverse_compat() {
237 let actor = render_actor(
238 "https://pod.example",
239 "bob",
240 "Bob",
241 None,
242 "PEM",
243 );
244 assert_eq!(
247 actor.context[0],
248 serde_json::Value::String("https://www.w3.org/ns/activitystreams".to_string())
249 );
250 assert_eq!(
251 actor.context[1],
252 serde_json::Value::String("https://w3id.org/security/v1".to_string())
253 );
254 }
255
256 #[test]
257 fn actor_base_url_trailing_slash_normalised() {
258 let a = render_actor("https://pod.example/", "x", "X", None, "PEM");
259 let b = render_actor("https://pod.example", "x", "X", None, "PEM");
260 assert_eq!(a.id, b.id);
261 assert_eq!(a.inbox, b.inbox);
262 }
263
264 #[test]
265 fn actor_serialises_with_jsonld_fields() {
266 let actor = render_actor("https://pod.example", "alice", "Alice", None, "PEM");
267 let j = serde_json::to_value(&actor).unwrap();
268 assert!(j.get("@context").is_some());
269 assert_eq!(j["type"], "Person");
270 assert_eq!(j["preferredUsername"], "alice");
271 assert!(j.get("publicKey").is_some());
272 }
273
274 #[test]
275 fn also_known_as_appends() {
276 let actor = render_actor("https://pod.example", "a", "A", None, "PEM");
277 let linked = with_also_known_as(actor, ["did:nostr:abc".to_string()]);
278 assert_eq!(linked.also_known_as, vec!["did:nostr:abc".to_string()]);
279 }
280
281 #[test]
282 fn actor_keypair_generation_rsa2048() {
283 let (priv_pem, pub_pem) = generate_actor_keypair().expect("keypair generates");
284 assert!(priv_pem.starts_with("-----BEGIN PRIVATE KEY-----"));
285 assert!(pub_pem.starts_with("-----BEGIN PUBLIC KEY-----"));
286 use rsa::pkcs8::DecodePrivateKey;
288 use rsa::pkcs8::DecodePublicKey;
289 use rsa::traits::PublicKeyParts;
290 let sk = RsaPrivateKey::from_pkcs8_pem(&priv_pem).unwrap();
291 let pk = RsaPublicKey::from_public_key_pem(&pub_pem).unwrap();
292 assert_eq!(sk.size(), 256); assert_eq!(RsaPublicKey::from(&sk), pk);
294 }
295
296 #[test]
299 fn negotiate_activity_json_media_type() {
300 assert_eq!(
301 negotiate_actor_format("application/activity+json"),
302 ActorFormat::ActivityJson,
303 );
304 }
305
306 #[test]
307 fn negotiate_activity_json_with_charset() {
308 assert_eq!(
309 negotiate_actor_format("application/activity+json; charset=utf-8"),
310 ActorFormat::ActivityJson,
311 );
312 }
313
314 #[test]
315 fn negotiate_ld_json_with_activitystreams_profile() {
316 assert_eq!(
317 negotiate_actor_format(
318 r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams""#
319 ),
320 ActorFormat::ActivityJson,
321 );
322 }
323
324 #[test]
325 fn negotiate_ld_json_without_profile_is_ldp() {
326 assert_eq!(
327 negotiate_actor_format("application/ld+json"),
328 ActorFormat::LdpProfile,
329 );
330 }
331
332 #[test]
333 fn negotiate_html_is_ldp() {
334 assert_eq!(
335 negotiate_actor_format("text/html"),
336 ActorFormat::LdpProfile,
337 );
338 }
339
340 #[test]
341 fn negotiate_empty_is_ldp() {
342 assert_eq!(
343 negotiate_actor_format(""),
344 ActorFormat::LdpProfile,
345 );
346 }
347
348 #[test]
349 fn negotiate_mixed_accept_with_activity_json() {
350 assert_eq!(
352 negotiate_actor_format("text/html, application/activity+json, */*"),
353 ActorFormat::ActivityJson,
354 );
355 }
356
357 #[test]
358 fn negotiate_case_insensitive() {
359 assert_eq!(
360 negotiate_actor_format("Application/Activity+JSON"),
361 ActorFormat::ActivityJson,
362 );
363 }
364}