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