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.public_key.public_key_pem.contains("BEGIN PUBLIC KEY"));
226 assert_eq!(
227 actor
228 .endpoints
229 .as_ref()
230 .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("https://pod.example", "bob", "Bob", None, "PEM");
238 assert_eq!(
241 actor.context[0],
242 serde_json::Value::String("https://www.w3.org/ns/activitystreams".to_string())
243 );
244 assert_eq!(
245 actor.context[1],
246 serde_json::Value::String("https://w3id.org/security/v1".to_string())
247 );
248 }
249
250 #[test]
251 fn actor_base_url_trailing_slash_normalised() {
252 let a = render_actor("https://pod.example/", "x", "X", None, "PEM");
253 let b = render_actor("https://pod.example", "x", "X", None, "PEM");
254 assert_eq!(a.id, b.id);
255 assert_eq!(a.inbox, b.inbox);
256 }
257
258 #[test]
259 fn actor_serialises_with_jsonld_fields() {
260 let actor = render_actor("https://pod.example", "alice", "Alice", None, "PEM");
261 let j = serde_json::to_value(&actor).unwrap();
262 assert!(j.get("@context").is_some());
263 assert_eq!(j["type"], "Person");
264 assert_eq!(j["preferredUsername"], "alice");
265 assert!(j.get("publicKey").is_some());
266 }
267
268 #[test]
269 fn also_known_as_appends() {
270 let actor = render_actor("https://pod.example", "a", "A", None, "PEM");
271 let linked = with_also_known_as(actor, ["did:nostr:abc".to_string()]);
272 assert_eq!(linked.also_known_as, vec!["did:nostr:abc".to_string()]);
273 }
274
275 #[test]
276 fn actor_keypair_generation_rsa2048() {
277 let (priv_pem, pub_pem) = generate_actor_keypair().expect("keypair generates");
278 assert!(priv_pem.starts_with("-----BEGIN PRIVATE KEY-----"));
279 assert!(pub_pem.starts_with("-----BEGIN PUBLIC KEY-----"));
280 use rsa::pkcs8::DecodePrivateKey;
282 use rsa::pkcs8::DecodePublicKey;
283 use rsa::traits::PublicKeyParts;
284 let sk = RsaPrivateKey::from_pkcs8_pem(&priv_pem).unwrap();
285 let pk = RsaPublicKey::from_public_key_pem(&pub_pem).unwrap();
286 assert_eq!(sk.size(), 256); assert_eq!(RsaPublicKey::from(&sk), pk);
288 }
289
290 #[test]
293 fn negotiate_activity_json_media_type() {
294 assert_eq!(
295 negotiate_actor_format("application/activity+json"),
296 ActorFormat::ActivityJson,
297 );
298 }
299
300 #[test]
301 fn negotiate_activity_json_with_charset() {
302 assert_eq!(
303 negotiate_actor_format("application/activity+json; charset=utf-8"),
304 ActorFormat::ActivityJson,
305 );
306 }
307
308 #[test]
309 fn negotiate_ld_json_with_activitystreams_profile() {
310 assert_eq!(
311 negotiate_actor_format(
312 r#"application/ld+json; profile="https://www.w3.org/ns/activitystreams""#
313 ),
314 ActorFormat::ActivityJson,
315 );
316 }
317
318 #[test]
319 fn negotiate_ld_json_without_profile_is_ldp() {
320 assert_eq!(
321 negotiate_actor_format("application/ld+json"),
322 ActorFormat::LdpProfile,
323 );
324 }
325
326 #[test]
327 fn negotiate_html_is_ldp() {
328 assert_eq!(negotiate_actor_format("text/html"), ActorFormat::LdpProfile,);
329 }
330
331 #[test]
332 fn negotiate_empty_is_ldp() {
333 assert_eq!(negotiate_actor_format(""), ActorFormat::LdpProfile,);
334 }
335
336 #[test]
337 fn negotiate_mixed_accept_with_activity_json() {
338 assert_eq!(
340 negotiate_actor_format("text/html, application/activity+json, */*"),
341 ActorFormat::ActivityJson,
342 );
343 }
344
345 #[test]
346 fn negotiate_case_insensitive() {
347 assert_eq!(
348 negotiate_actor_format("Application/Activity+JSON"),
349 ActorFormat::ActivityJson,
350 );
351 }
352}