1use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7use url::Url;
8
9use crate::rels;
10
11#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct Jrd {
21 pub subject: String,
23
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub aliases: Vec<String>,
27
28 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31 pub properties: BTreeMap<String, Option<String>>,
32
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub links: Vec<JrdLink>,
36}
37
38impl Jrd {
39 pub fn builder(subject: impl Into<String>) -> JrdBuilder {
41 JrdBuilder {
42 inner: Self {
43 subject: subject.into(),
44 ..Self::default()
45 },
46 }
47 }
48
49 #[must_use]
51 pub fn find_link(&self, rel: &str) -> Option<&JrdLink> {
52 self.links.iter().find(|l| l.rel == rel)
53 }
54
55 #[must_use]
67 pub fn activitypub_actor(&self) -> Option<&JrdLink> {
68 self.links
69 .iter()
70 .find(|l| {
71 l.rel == rels::SELF
72 && matches!(
73 l.media_type.as_deref(),
74 Some(mt) if bare_media_type(mt).eq_ignore_ascii_case(rels::MEDIA_TYPE_ACTIVITYPUB)
75 )
76 })
77 .or_else(|| {
78 self.links.iter().find(|l| {
79 l.rel == rels::SELF
80 && matches!(
81 l.media_type.as_deref(),
82 Some(mt) if bare_media_type(mt).eq_ignore_ascii_case("application/ld+json")
83 )
84 })
85 })
86 }
87}
88
89#[derive(Debug)]
91pub struct JrdBuilder {
92 inner: Jrd,
93}
94
95impl JrdBuilder {
96 #[must_use]
98 pub fn alias(mut self, alias: impl Into<String>) -> Self {
99 self.inner.aliases.push(alias.into());
100 self
101 }
102
103 #[must_use]
105 pub fn property(mut self, key: impl Into<String>, value: Option<String>) -> Self {
106 self.inner.properties.insert(key.into(), value);
107 self
108 }
109
110 #[must_use]
112 pub fn link(mut self, link: JrdLink) -> Self {
113 self.inner.links.push(link);
114 self
115 }
116
117 #[must_use]
119 pub fn build(self) -> Jrd {
120 self.inner
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
133#[non_exhaustive]
134pub struct JrdLink {
135 pub rel: String,
137
138 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
140 pub media_type: Option<String>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub href: Option<Url>,
145
146 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
148 pub titles: BTreeMap<String, String>,
149
150 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
152 pub properties: BTreeMap<String, Option<String>>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub template: Option<String>,
157}
158
159impl JrdLink {
160 pub fn builder(rel: impl Into<String>) -> JrdLinkBuilder {
162 JrdLinkBuilder {
163 inner: Self {
164 rel: rel.into(),
165 ..Self::default()
166 },
167 }
168 }
169
170 pub const fn validate(&self) -> Result<(), &'static str> {
179 if self.href.is_some() && self.template.is_some() {
180 return Err("JRD link must not have both `href` and `template`");
181 }
182 Ok(())
183 }
184}
185
186#[derive(Debug)]
188pub struct JrdLinkBuilder {
189 inner: JrdLink,
190}
191
192impl JrdLinkBuilder {
193 #[must_use]
195 pub fn media_type(mut self, media_type: impl Into<String>) -> Self {
196 self.inner.media_type = Some(media_type.into());
197 self
198 }
199
200 #[must_use]
205 pub fn href(mut self, href: Url) -> Self {
206 self.inner.href = Some(href);
207 self.inner.template = None;
208 self
209 }
210
211 #[must_use]
213 pub fn title(mut self, lang: impl Into<String>, title: impl Into<String>) -> Self {
214 self.inner.titles.insert(lang.into(), title.into());
215 self
216 }
217
218 #[must_use]
220 pub fn property(mut self, key: impl Into<String>, value: Option<String>) -> Self {
221 self.inner.properties.insert(key.into(), value);
222 self
223 }
224
225 #[must_use]
230 pub fn template(mut self, template: impl Into<String>) -> Self {
231 self.inner.template = Some(template.into());
232 self.inner.href = None;
233 self
234 }
235
236 #[must_use]
238 pub fn build(self) -> JrdLink {
239 self.inner
240 }
241}
242
243fn bare_media_type(mt: &str) -> &str {
253 mt.split(';').next().unwrap_or(mt).trim()
254}
255
256#[cfg(test)]
257mod tests {
258 use pretty_assertions::assert_eq;
259 use serde_json::json;
260
261 use super::*;
262
263 #[test]
264 fn jrd_serializes_only_set_fields() {
265 let jrd = Jrd::builder("acct:alice@example.com").build();
266 let v = serde_json::to_value(&jrd).unwrap();
267 assert_eq!(v, json!({ "subject": "acct:alice@example.com" }));
268 }
269
270 #[test]
271 fn mastodon_style_jrd_roundtrips() {
272 let raw = json!({
273 "subject": "acct:Gargron@mastodon.social",
274 "aliases": [
275 "https://mastodon.social/@Gargron",
276 "https://mastodon.social/users/Gargron"
277 ],
278 "links": [
279 {
280 "rel": "http://webfinger.net/rel/profile-page",
281 "type": "text/html",
282 "href": "https://mastodon.social/@Gargron"
283 },
284 {
285 "rel": "self",
286 "type": "application/activity+json",
287 "href": "https://mastodon.social/users/Gargron"
288 },
289 {
290 "rel": "http://ostatus.org/schema/1.0/subscribe",
291 "template": "https://mastodon.social/authorize_interaction?uri={uri}"
292 }
293 ]
294 });
295
296 let jrd: Jrd = serde_json::from_value(raw.clone()).unwrap();
297 assert_eq!(jrd.subject, "acct:Gargron@mastodon.social");
298 assert_eq!(jrd.aliases.len(), 2);
299 assert_eq!(jrd.links.len(), 3);
300
301 let actor = jrd.activitypub_actor().expect("has actor link");
302 assert_eq!(
303 actor.href.as_ref().map(Url::as_str),
304 Some("https://mastodon.social/users/Gargron")
305 );
306
307 let subscribe = jrd.find_link(rels::OSTATUS_SUBSCRIBE).unwrap();
308 assert!(subscribe.template.is_some());
309 assert!(subscribe.href.is_none());
310
311 let back = serde_json::to_value(&jrd).unwrap();
312 assert_eq!(back, raw);
313 }
314
315 #[test]
316 fn builder_round_trips_through_serde() {
317 let jrd = Jrd::builder("acct:alice@example.com")
318 .alias("https://example.com/@alice")
319 .link(
320 JrdLink::builder(rels::ACTIVITYPUB_ACTOR)
321 .href("https://example.com/users/alice".parse().unwrap())
322 .media_type("application/activity+json")
323 .build(),
324 )
325 .build();
326
327 let actor = jrd.activitypub_actor().unwrap();
328 assert_eq!(actor.rel, "self");
329 let json = serde_json::to_value(&jrd).unwrap();
330 let back: Jrd = serde_json::from_value(json).unwrap();
331 assert_eq!(back, jrd);
332 }
333
334 #[test]
335 fn property_with_null_value_roundtrips() {
336 let raw = json!({
340 "subject": "acct:alice@example.com",
341 "properties": { "http://example/schema/foo": null }
342 });
343 let jrd: Jrd = serde_json::from_value(raw.clone()).expect("deserialise");
344 assert_eq!(
345 jrd.properties.get("http://example/schema/foo"),
346 Some(&None),
347 "null property must deserialise to Some(None), not None",
348 );
349 let back = serde_json::to_value(&jrd).expect("serialise");
350 assert_eq!(back, raw);
351 }
352
353 #[test]
354 fn jrd_link_validate_accepts_href_only() {
355 let link = JrdLink::builder(rels::ACTIVITYPUB_ACTOR)
356 .href("https://example.com/a".parse().expect("valid URL"))
357 .build();
358 assert!(link.validate().is_ok());
359 }
360
361 #[test]
362 fn jrd_link_validate_accepts_template_only() {
363 let link = JrdLink::builder(rels::OSTATUS_SUBSCRIBE)
364 .template("https://example.com/subscribe?uri={uri}")
365 .build();
366 assert!(link.validate().is_ok());
367 }
368
369 #[test]
370 fn jrd_link_validate_rejects_both_href_and_template() {
371 let mut link = JrdLink::builder(rels::SELF).build();
374 link.href = Some("https://example.com/a".parse().expect("valid URL"));
375 link.template = Some("https://example.com/t?u={u}".to_owned());
376 assert!(
377 link.validate().is_err(),
378 "RFC 7033 §4.4.4 forbids both `href` and `template` on a single link",
379 );
380 }
381
382 #[test]
383 fn jrd_link_builder_href_after_template_clears_template() {
384 let link = JrdLink::builder(rels::SELF)
388 .template("https://example.com/t?u={u}")
389 .href("https://example.com/a".parse().expect("valid URL"))
390 .build();
391 assert!(link.template.is_none(), "template must be cleared");
392 assert!(link.href.is_some(), "href must be retained");
393 assert!(link.validate().is_ok());
394 }
395
396 #[test]
397 fn activitypub_actor_falls_back_to_ld_json_profile() {
398 let jrd: Jrd = serde_json::from_value(json!({
402 "subject": "acct:alice@example.com",
403 "links": [{
404 "rel": "self",
405 "type": "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
406 "href": "https://example.com/users/alice"
407 }]
408 }))
409 .expect("JSON-LD profile JRD must parse");
410
411 let actor = jrd
412 .activitypub_actor()
413 .expect("should fall back to ld+json profile");
414 assert_eq!(
415 actor.href.as_ref().map(Url::as_str),
416 Some("https://example.com/users/alice"),
417 );
418 }
419
420 #[test]
421 fn find_link_returns_none_for_missing_rel() {
422 let jrd = Jrd::builder("acct:alice@example.com").build();
423 assert!(jrd.find_link("http://example.com/rel/missing").is_none());
424 }
425
426 #[test]
427 fn activitypub_actor_rejects_media_types_that_only_share_the_ld_json_prefix() {
428 let jrd: Jrd = serde_json::from_value(json!({
436 "subject": "acct:alice@example.com",
437 "links": [{
438 "rel": "self",
439 "type": "application/ld+jsonx",
442 "href": "https://example.com/attacker"
443 }]
444 }))
445 .expect("JRD must parse");
446 assert!(
447 jrd.activitypub_actor().is_none(),
448 "prefix-only media-type impersonation must NOT be accepted as the AP actor link",
449 );
450 }
451
452 #[test]
453 fn activitypub_actor_is_case_insensitive_on_media_type() {
454 let jrd: Jrd = serde_json::from_value(json!({
458 "subject": "acct:alice@example.com",
459 "links": [{
460 "rel": "self",
461 "type": "Application/Activity+JSON",
462 "href": "https://example.com/users/alice"
463 }]
464 }))
465 .expect("JRD must parse");
466 let actor = jrd
467 .activitypub_actor()
468 .expect("case-insensitive media-type must be recognised");
469 assert_eq!(
470 actor.href.as_ref().map(Url::as_str),
471 Some("https://example.com/users/alice"),
472 );
473 }
474}