]> Untitled Git - lemmy.git/blob - crates/apub/src/lib.rs
da8c224485a6ac921ed7bc661d2000c45eb19960
[lemmy.git] / crates / apub / src / lib.rs
1 use crate::fetcher::post_or_comment::PostOrComment;
2 use activitypub_federation::{
3   core::signatures::PublicKey,
4   traits::{Actor, ApubObject},
5   InstanceSettings,
6   LocalInstance,
7   UrlVerifier,
8 };
9 use anyhow::Context;
10 use async_trait::async_trait;
11 use lemmy_db_schema::{
12   newtypes::DbUrl,
13   source::{activity::Activity, instance::Instance, local_site::LocalSite},
14   utils::DbPool,
15 };
16 use lemmy_utils::{error::LemmyError, location_info, settings::structs::Settings};
17 use lemmy_websocket::LemmyContext;
18 use once_cell::sync::Lazy;
19 use tokio::sync::OnceCell;
20 use url::{ParseError, Url};
21
22 pub mod activities;
23 pub(crate) mod activity_lists;
24 pub(crate) mod collections;
25 pub mod fetcher;
26 pub mod http;
27 pub(crate) mod mentions;
28 pub mod objects;
29 pub mod protocol;
30
31 static CONTEXT: Lazy<Vec<serde_json::Value>> = Lazy::new(|| {
32   serde_json::from_str(include_str!("../assets/lemmy/context.json")).expect("parse context")
33 });
34
35 // TODO: store this in context? but its only used in this crate, no need to expose it elsewhere
36 // TODO this singleton needs to be redone to account for live data.
37 async fn local_instance(context: &LemmyContext) -> &'static LocalInstance {
38   static LOCAL_INSTANCE: OnceCell<LocalInstance> = OnceCell::const_new();
39   LOCAL_INSTANCE
40     .get_or_init(|| async {
41       // Local site may be missing
42       let local_site = &LocalSite::read(context.pool()).await;
43       let worker_count = local_site
44         .as_ref()
45         .map(|l| l.federation_worker_count)
46         .unwrap_or(64) as u64;
47       let http_fetch_retry_limit = local_site
48         .as_ref()
49         .map(|l| l.federation_http_fetch_retry_limit)
50         .unwrap_or(25);
51       let federation_debug = local_site
52         .as_ref()
53         .map(|l| l.federation_debug)
54         .unwrap_or(true);
55
56       let settings = InstanceSettings::builder()
57         .http_fetch_retry_limit(http_fetch_retry_limit)
58         .worker_count(worker_count)
59         .debug(federation_debug)
60         .http_signature_compat(true)
61         .url_verifier(Box::new(VerifyUrlData(context.clone())))
62         .build()
63         .expect("configure federation");
64       LocalInstance::new(
65         context.settings().hostname.clone(),
66         context.client().clone(),
67         settings,
68       )
69     })
70     .await
71 }
72
73 #[derive(Clone)]
74 struct VerifyUrlData(LemmyContext);
75
76 #[async_trait]
77 impl UrlVerifier for VerifyUrlData {
78   async fn verify(&self, url: &Url) -> Result<(), &'static str> {
79     let local_site_data = fetch_local_site_data(self.0.pool())
80       .await
81       .expect("read local site data");
82     check_apub_id_valid(url, &local_site_data, self.0.settings())
83   }
84 }
85
86 /// Checks if the ID is allowed for sending or receiving.
87 ///
88 /// In particular, it checks for:
89 /// - federation being enabled (if its disabled, only local URLs are allowed)
90 /// - the correct scheme (either http or https)
91 /// - URL being in the allowlist (if it is active)
92 /// - URL not being in the blocklist (if it is active)
93 ///
94 /// `use_strict_allowlist` should be true only when parsing a remote community, or when parsing a
95 /// post/comment in a local community.
96 #[tracing::instrument(skip(settings, local_site_data))]
97 fn check_apub_id_valid(
98   apub_id: &Url,
99   local_site_data: &LocalSiteData,
100   settings: &Settings,
101 ) -> Result<(), &'static str> {
102   let domain = apub_id.domain().expect("apud id has domain").to_string();
103   let local_instance = settings
104     .get_hostname_without_port()
105     .expect("local hostname is valid");
106   if domain == local_instance {
107     return Ok(());
108   }
109
110   if !local_site_data
111     .local_site
112     .as_ref()
113     .map(|l| l.federation_enabled)
114     .unwrap_or(true)
115   {
116     return Err("Federation disabled");
117   }
118
119   if apub_id.scheme() != settings.get_protocol_string() {
120     return Err("Invalid protocol scheme");
121   }
122
123   if let Some(blocked) = local_site_data.blocked_instances.as_ref() {
124     if blocked.contains(&domain) {
125       return Err("Domain is blocked");
126     }
127   }
128
129   if let Some(allowed) = local_site_data.allowed_instances.as_ref() {
130     if !allowed.contains(&domain) {
131       return Err("Domain is not in allowlist");
132     }
133   }
134
135   Ok(())
136 }
137
138 #[derive(Clone)]
139 pub(crate) struct LocalSiteData {
140   local_site: Option<LocalSite>,
141   allowed_instances: Option<Vec<String>>,
142   blocked_instances: Option<Vec<String>>,
143 }
144
145 pub(crate) async fn fetch_local_site_data(
146   pool: &DbPool,
147 ) -> Result<LocalSiteData, diesel::result::Error> {
148   // LocalSite may be missing
149   let local_site = LocalSite::read(pool).await.ok();
150   let allowed = Instance::allowlist(pool).await?;
151   let blocked = Instance::blocklist(pool).await?;
152
153   // These can return empty vectors, so convert them to options
154   let allowed_instances = (!allowed.is_empty()).then_some(allowed);
155   let blocked_instances = (!blocked.is_empty()).then_some(blocked);
156
157   Ok(LocalSiteData {
158     local_site,
159     allowed_instances,
160     blocked_instances,
161   })
162 }
163
164 #[tracing::instrument(skip(settings, local_site_data))]
165 pub(crate) fn check_apub_id_valid_with_strictness(
166   apub_id: &Url,
167   is_strict: bool,
168   local_site_data: &LocalSiteData,
169   settings: &Settings,
170 ) -> Result<(), LemmyError> {
171   check_apub_id_valid(apub_id, local_site_data, settings).map_err(LemmyError::from_message)?;
172   let domain = apub_id.domain().expect("apud id has domain").to_string();
173   let local_instance = settings
174     .get_hostname_without_port()
175     .expect("local hostname is valid");
176   if domain == local_instance {
177     return Ok(());
178   }
179
180   if let Some(allowed) = local_site_data.allowed_instances.as_ref() {
181     // Only check allowlist if this is a community, or strict allowlist is enabled.
182     let strict_allowlist = local_site_data
183       .local_site
184       .as_ref()
185       .map(|l| l.federation_strict_allowlist)
186       .unwrap_or(true);
187     if is_strict || strict_allowlist {
188       // need to allow this explicitly because apub receive might contain objects from our local
189       // instance.
190       let mut allowed_and_local = allowed.clone();
191       allowed_and_local.push(local_instance);
192
193       if !allowed_and_local.contains(&domain) {
194         return Err(LemmyError::from_message(
195           "Federation forbidden by strict allowlist",
196         ));
197       }
198     }
199   }
200   Ok(())
201 }
202
203 pub enum EndpointType {
204   Community,
205   Person,
206   Post,
207   Comment,
208   PrivateMessage,
209 }
210
211 /// Generates an apub endpoint for a given domain, IE xyz.tld
212 pub fn generate_local_apub_endpoint(
213   endpoint_type: EndpointType,
214   name: &str,
215   domain: &str,
216 ) -> Result<DbUrl, ParseError> {
217   let point = match endpoint_type {
218     EndpointType::Community => "c",
219     EndpointType::Person => "u",
220     EndpointType::Post => "post",
221     EndpointType::Comment => "comment",
222     EndpointType::PrivateMessage => "private_message",
223   };
224
225   Ok(Url::parse(&format!("{}/{}/{}", domain, point, name))?.into())
226 }
227
228 pub fn generate_followers_url(actor_id: &DbUrl) -> Result<DbUrl, ParseError> {
229   Ok(Url::parse(&format!("{}/followers", actor_id))?.into())
230 }
231
232 pub fn generate_inbox_url(actor_id: &DbUrl) -> Result<DbUrl, ParseError> {
233   Ok(Url::parse(&format!("{}/inbox", actor_id))?.into())
234 }
235
236 pub fn generate_site_inbox_url(actor_id: &DbUrl) -> Result<DbUrl, ParseError> {
237   let mut actor_id: Url = actor_id.clone().into();
238   actor_id.set_path("site_inbox");
239   Ok(actor_id.into())
240 }
241
242 pub fn generate_shared_inbox_url(actor_id: &DbUrl) -> Result<DbUrl, LemmyError> {
243   let actor_id: Url = actor_id.clone().into();
244   let url = format!(
245     "{}://{}{}/inbox",
246     &actor_id.scheme(),
247     &actor_id.host_str().context(location_info!())?,
248     if let Some(port) = actor_id.port() {
249       format!(":{}", port)
250     } else {
251       String::new()
252     },
253   );
254   Ok(Url::parse(&url)?.into())
255 }
256
257 pub fn generate_outbox_url(actor_id: &DbUrl) -> Result<DbUrl, ParseError> {
258   Ok(Url::parse(&format!("{}/outbox", actor_id))?.into())
259 }
260
261 fn generate_moderators_url(community_id: &DbUrl) -> Result<DbUrl, LemmyError> {
262   Ok(Url::parse(&format!("{}/moderators", community_id))?.into())
263 }
264
265 /// Store a sent or received activity in the database, for logging purposes. These records are not
266 /// persistent.
267 #[tracing::instrument(skip(pool))]
268 async fn insert_activity(
269   ap_id: &Url,
270   activity: serde_json::Value,
271   local: bool,
272   sensitive: bool,
273   pool: &DbPool,
274 ) -> Result<bool, LemmyError> {
275   let ap_id = ap_id.clone().into();
276   Ok(Activity::insert(pool, ap_id, activity, local, Some(sensitive)).await?)
277 }
278
279 /// Common methods provided by ActivityPub actors (community and person). Not all methods are
280 /// implemented by all actors.
281 pub trait ActorType: Actor + ApubObject {
282   fn actor_id(&self) -> Url;
283
284   fn private_key(&self) -> Option<String>;
285
286   fn get_public_key(&self) -> PublicKey {
287     PublicKey::new_main_key(self.actor_id(), self.public_key().to_string())
288   }
289 }