diff --git a/Cargo.lock b/Cargo.lock index eebb1ce1a7..0b19fe6f6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2773,6 +2773,7 @@ dependencies = [ "lemmy_utils", "reqwest 0.12.8", "reqwest-middleware", + "reqwest-tracing", "rss", "serde", "tokio", diff --git a/api_tests/run-federation-test.sh b/api_tests/run-federation-test.sh index 969a95b3e7..f9eab50395 100755 --- a/api_tests/run-federation-test.sh +++ b/api_tests/run-federation-test.sh @@ -11,7 +11,7 @@ killall -s1 lemmy_server || true popd pnpm i -pnpm api-test || true +pnpm api-test-image || true killall -s1 lemmy_server || true killall -s1 pict-rs || true diff --git a/crates/api/src/local_user/save_settings.rs b/crates/api/src/local_user/save_settings.rs index 992fea1635..bcf1b02b6e 100644 --- a/crates/api/src/local_user/save_settings.rs +++ b/crates/api/src/local_user/save_settings.rs @@ -46,10 +46,6 @@ pub async fn save_user_settings( .as_deref(), ); - let avatar = diesel_url_update(data.avatar.as_deref())?; - replace_image(&avatar, &local_user_view.person.avatar, &context).await?; - let avatar = proxy_image_link_opt_api(avatar, &context).await?; - let banner = diesel_url_update(data.banner.as_deref())?; replace_image(&banner, &local_user_view.person.banner, &context).await?; let banner = proxy_image_link_opt_api(banner, &context).await?; @@ -108,7 +104,6 @@ pub async fn save_user_settings( bio, matrix_user_id, bot_account: data.bot_account, - avatar, banner, ..Default::default() }; diff --git a/crates/api_common/src/person.rs b/crates/api_common/src/person.rs index b95cf5e774..9c8a1d087b 100644 --- a/crates/api_common/src/person.rs +++ b/crates/api_common/src/person.rs @@ -114,9 +114,6 @@ pub struct SaveUserSettings { /// The language of the lemmy interface #[cfg_attr(feature = "full", ts(optional))] pub interface_language: Option, - /// A URL for your avatar. - #[cfg_attr(feature = "full", ts(optional))] - pub avatar: Option, /// A URL for your banner. #[cfg_attr(feature = "full", ts(optional))] pub banner: Option, diff --git a/crates/api_common/src/request.rs b/crates/api_common/src/request.rs index c6f86b8067..5b4d85a5e8 100644 --- a/crates/api_common/src/request.rs +++ b/crates/api_common/src/request.rs @@ -250,7 +250,8 @@ fn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult>, + #[serde(default)] + pub files: Vec, pub msg: String, } @@ -388,9 +389,8 @@ async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> L .json::() .await?; - let files = res.files.unwrap_or_default(); - - let image = files + let image = res + .files .first() .ok_or(LemmyErrorType::PictrsResponseError(res.msg))?; @@ -467,6 +467,7 @@ async fn is_image_content_type(client: &ClientWithMiddleware, url: &Url) -> Lemm } /// When adding a new avatar, banner or similar image, delete the old one. +/// TODO: remove this function pub async fn replace_image( new_image: &Option>, old_image: &Option, diff --git a/crates/routes/Cargo.toml b/crates/routes/Cargo.toml index 91c3ed6830..3bd85d0e5f 100644 --- a/crates/routes/Cargo.toml +++ b/crates/routes/Cargo.toml @@ -33,4 +33,5 @@ url = { workspace = true } tracing = { workspace = true } tokio = { workspace = true } http.workspace = true +reqwest-tracing = { workspace = true } rss = "2.0.10" diff --git a/crates/routes/src/images/mod.rs b/crates/routes/src/images/mod.rs index 0f98b330e3..c5f6a9c2c5 100644 --- a/crates/routes/src/images/mod.rs +++ b/crates/routes/src/images/mod.rs @@ -1,112 +1,33 @@ use actix_web::{ body::{BodyStream, BoxBody}, - http::{ - header::{HeaderName, ACCEPT_ENCODING, HOST}, - StatusCode, - }, + http::StatusCode, web::*, HttpRequest, HttpResponse, Responder, }; -use lemmy_api_common::{context::LemmyContext, request::PictrsResponse}; +use lemmy_api_common::{context::LemmyContext, SuccessResponse}; use lemmy_db_schema::source::{ - images::{LocalImage, LocalImageForm, RemoteImage}, + images::{LocalImage, RemoteImage}, local_site::LocalSite, }; use lemmy_db_views::structs::LocalUserView; -use lemmy_utils::{error::LemmyResult, REQWEST_TIMEOUT}; -use reqwest::Body; -use reqwest_middleware::RequestBuilder; +use lemmy_utils::error::LemmyResult; use serde::Deserialize; -use std::time::Duration; use url::Url; -use utils::{convert_header, convert_method, convert_status, make_send}; +use utils::{ + adapt_request, + convert_header, + do_upload_image, + PictrsGetParams, + ProcessUrl, + UploadType, + PICTRS_CLIENT, +}; +pub mod person; mod utils; -trait ProcessUrl { - /// If thumbnail or format is given, this uses the pictrs process endpoint. - /// Otherwise, it uses the normal pictrs url (IE image/original). - fn process_url(&self, image_url: &str, pictrs_url: &Url) -> String; -} - -#[derive(Deserialize, Clone)] -pub struct PictrsGetParams { - format: Option, - thumbnail: Option, -} - -impl ProcessUrl for PictrsGetParams { - fn process_url(&self, src: &str, pictrs_url: &Url) -> String { - if self.format.is_none() && self.thumbnail.is_none() { - format!("{}image/original/{}", pictrs_url, src) - } else { - // Take file type from name, or jpg if nothing is given - let format = self - .clone() - .format - .unwrap_or_else(|| src.split('.').last().unwrap_or("jpg").to_string()); - - let mut url = format!("{}image/process.{}?src={}", pictrs_url, format, src); - - if let Some(size) = self.thumbnail { - url = format!("{url}&thumbnail={size}",); - } - url - } - } -} - -#[derive(Deserialize, Clone)] -pub struct ImageProxyParams { - url: String, - format: Option, - thumbnail: Option, -} - -impl ProcessUrl for ImageProxyParams { - fn process_url(&self, proxy_url: &str, pictrs_url: &Url) -> String { - if self.format.is_none() && self.thumbnail.is_none() { - format!("{}image/original?proxy={}", pictrs_url, proxy_url) - } else { - // Take file type from name, or jpg if nothing is given - let format = self - .clone() - .format - .unwrap_or_else(|| proxy_url.split('.').last().unwrap_or("jpg").to_string()); - - let mut url = format!("{}image/process.{}?proxy={}", pictrs_url, format, proxy_url); - - if let Some(size) = self.thumbnail { - url = format!("{url}&thumbnail={size}",); - } - url - } - } -} -fn adapt_request(request: &HttpRequest, context: &LemmyContext, url: String) -> RequestBuilder { - // remove accept-encoding header so that pictrs doesn't compress the response - const INVALID_HEADERS: &[HeaderName] = &[ACCEPT_ENCODING, HOST]; - - let client_request = context - .client() - .request(convert_method(request.method()), url) - .timeout(REQWEST_TIMEOUT); - - request - .headers() - .iter() - .fold(client_request, |client_req, (key, value)| { - if INVALID_HEADERS.contains(key) { - client_req - } else { - // TODO: remove as_str and as_bytes conversions after actix-web upgrades to http 1.0 - client_req.header(key.as_str(), value.as_bytes()) - } - }) -} - pub async fn upload_image( req: HttpRequest, body: Payload, @@ -114,40 +35,9 @@ pub async fn upload_image( local_user_view: LocalUserView, context: Data, ) -> LemmyResult { - let pictrs_config = context.settings().pictrs_config()?; - let image_url = format!("{}image", pictrs_config.url); - - let mut client_req = adapt_request(&req, &context, image_url); - - if let Some(addr) = req.head().peer_addr { - client_req = client_req.header("X-Forwarded-For", addr.to_string()) - }; - let res = client_req - .timeout(Duration::from_secs(pictrs_config.upload_timeout)) - .body(Body::wrap_stream(make_send(body))) - .send() - .await?; - - let status = res.status(); - let images = res.json::().await?; - if let Some(images) = &images.files { - for image in images { - let form = LocalImageForm { - local_user_id: Some(local_user_view.local_user.id), - pictrs_alias: image.file.to_string(), - pictrs_delete_token: image.delete_token.to_string(), - }; - - let protocol_and_hostname = context.settings().get_protocol_and_hostname(); - let thumbnail_url = image.thumbnail_url(&protocol_and_hostname)?; + let image = do_upload_image(req, body, UploadType::Other, &local_user_view, &context).await?; - // Also store the details for the image - let details_form = image.details.build_image_details_form(&thumbnail_url); - LocalImage::create(&mut context.pool(), &form, &details_form).await?; - } - } - - Ok(HttpResponse::build(convert_status(status)).json(images)) + Ok(HttpResponse::Ok().json(image)) } pub async fn get_full_res_image( @@ -169,11 +59,11 @@ pub async fn get_full_res_image( let processed_url = params.process_url(name, &pictrs_config.url); - image(processed_url, req, &context).await + image(processed_url, req).await } -async fn image(url: String, req: HttpRequest, context: &LemmyContext) -> LemmyResult { - let mut client_req = adapt_request(&req, context, url); +async fn image(url: String, req: HttpRequest) -> LemmyResult { + let mut client_req = adapt_request(&req, url); if let Some(addr) = req.head().peer_addr { client_req = client_req.header("X-Forwarded-For", addr.to_string()); @@ -198,47 +88,66 @@ async fn image(url: String, req: HttpRequest, context: &LemmyContext) -> LemmyRe Ok(client_res.body(BodyStream::new(res.bytes_stream()))) } +#[derive(Deserialize, Clone)] +pub struct DeleteImageParams { + file: String, + token: String, +} + pub async fn delete_image( - components: Path<(String, String)>, - req: HttpRequest, + data: Json, context: Data, // require login _local_user_view: LocalUserView, -) -> LemmyResult { - let (token, file) = components.into_inner(); - +) -> LemmyResult { let pictrs_config = context.settings().pictrs_config()?; - let url = format!("{}image/delete/{}/{}", pictrs_config.url, &token, &file); - - let mut client_req = adapt_request(&req, &context, url); - - if let Some(addr) = req.head().peer_addr { - client_req = client_req.header("X-Forwarded-For", addr.to_string()); - } + let url = format!( + "{}image/delete/{}/{}", + pictrs_config.url, &data.token, &data.file + ); - let res = client_req.send().await?; + PICTRS_CLIENT.delete(url).send().await?.error_for_status()?; - LocalImage::delete_by_alias(&mut context.pool(), &file).await?; + LocalImage::delete_by_alias(&mut context.pool(), &data.file).await?; - Ok(HttpResponse::build(convert_status(res.status())).body(BodyStream::new(res.bytes_stream()))) + Ok(SuccessResponse::default()) } -pub async fn pictrs_healthz( - req: HttpRequest, - context: Data, -) -> LemmyResult { +pub async fn pictrs_healthz(context: Data) -> LemmyResult { let pictrs_config = context.settings().pictrs_config()?; let url = format!("{}healthz", pictrs_config.url); - let mut client_req = adapt_request(&req, &context, url); + PICTRS_CLIENT.get(url).send().await?.error_for_status()?; - if let Some(addr) = req.head().peer_addr { - client_req = client_req.header("X-Forwarded-For", addr.to_string()); - } + Ok(SuccessResponse::default()) +} - let res = client_req.send().await?; +#[derive(Deserialize, Clone)] +pub struct ImageProxyParams { + url: String, + format: Option, + thumbnail: Option, +} - Ok(HttpResponse::build(convert_status(res.status())).body(BodyStream::new(res.bytes_stream()))) +impl ProcessUrl for ImageProxyParams { + fn process_url(&self, proxy_url: &str, pictrs_url: &Url) -> String { + if self.format.is_none() && self.thumbnail.is_none() { + format!("{}image/original?proxy={}", pictrs_url, proxy_url) + } else { + // Take file type from name, or jpg if nothing is given + let format = self + .clone() + .format + .unwrap_or_else(|| proxy_url.split('.').last().unwrap_or("jpg").to_string()); + + let mut url = format!("{}image/process.{}?proxy={}", pictrs_url, format, proxy_url); + + if let Some(size) = self.thumbnail { + url = format!("{url}&thumbnail={size}",); + } + url + } + } } pub async fn image_proxy( @@ -264,6 +173,6 @@ pub async fn image_proxy( Ok(Either::Left(Redirect::to(url.to_string()).respond_to(&req))) } else { // Proxy the image data through Lemmy - Ok(Either::Right(image(processed_url, req, &context).await?)) + Ok(Either::Right(image(processed_url, req).await?)) } } diff --git a/crates/routes/src/images/person.rs b/crates/routes/src/images/person.rs new file mode 100644 index 0000000000..edac1e41b2 --- /dev/null +++ b/crates/routes/src/images/person.rs @@ -0,0 +1,36 @@ +use super::utils::{delete_old_image, do_upload_image, UploadType}; +use actix_web::{self, web::*, HttpRequest}; +use lemmy_api_common::{context::LemmyContext, SuccessResponse}; +use lemmy_db_schema::{ + source::person::{Person, PersonUpdateForm}, + traits::Crud, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::LemmyResult; +use url::Url; + +pub async fn upload_avatar( + req: HttpRequest, + body: Payload, + local_user_view: LocalUserView, + context: Data, +) -> LemmyResult> { + let image = do_upload_image(req, body, UploadType::Avatar, &local_user_view, &context).await?; + + delete_old_image(&local_user_view.person.avatar, &context).await?; + + let avatar = format!( + "{}/api/v4/image/{}", + context.settings().get_protocol_and_hostname(), + image.file + ); + let avatar = Some(Some(Url::parse(&avatar)?.into())); + let person_form = PersonUpdateForm { + avatar, + ..Default::default() + }; + + Person::update(&mut context.pool(), local_user_view.person.id, &person_form).await?; + + Ok(Json(SuccessResponse::default())) +} diff --git a/crates/routes/src/images/utils.rs b/crates/routes/src/images/utils.rs index c951b4b014..28d144a99b 100644 --- a/crates/routes/src/images/utils.rs +++ b/crates/routes/src/images/utils.rs @@ -1,6 +1,97 @@ -use actix_web::http::{Method, StatusCode}; +use actix_web::{ + http::{ + header::{HeaderName, ACCEPT_ENCODING, HOST}, + Method, + StatusCode, + }, + web::{Data, Payload}, + HttpRequest, +}; use futures::stream::{Stream, StreamExt}; use http::HeaderValue; +use lemmy_api_common::{ + context::LemmyContext, + request::{client_builder, delete_image_from_pictrs, PictrsFile, PictrsResponse}, + LemmyErrorType, +}; +use lemmy_db_schema::{ + newtypes::DbUrl, + source::images::{LocalImage, LocalImageForm}, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::{error::LemmyResult, settings::SETTINGS, REQWEST_TIMEOUT}; +use reqwest::Body; +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, RequestBuilder}; +use reqwest_tracing::TracingMiddleware; +use serde::Deserialize; +use std::{sync::LazyLock, time::Duration}; +use url::Url; + +// Pictrs cannot use proxy +pub(super) static PICTRS_CLIENT: LazyLock = LazyLock::new(|| { + ClientBuilder::new( + client_builder(&SETTINGS) + .no_proxy() + .build() + .expect("build pictrs client"), + ) + .with(TracingMiddleware::default()) + .build() +}); + +#[derive(Deserialize, Clone)] +pub struct PictrsGetParams { + format: Option, + thumbnail: Option, +} + +pub(super) trait ProcessUrl { + /// If thumbnail or format is given, this uses the pictrs process endpoint. + /// Otherwise, it uses the normal pictrs url (IE image/original). + fn process_url(&self, image_url: &str, pictrs_url: &Url) -> String; +} + +impl ProcessUrl for PictrsGetParams { + fn process_url(&self, src: &str, pictrs_url: &Url) -> String { + if self.format.is_none() && self.thumbnail.is_none() { + format!("{}image/original/{}", pictrs_url, src) + } else { + // Take file type from name, or jpg if nothing is given + let format = self + .clone() + .format + .unwrap_or_else(|| src.split('.').last().unwrap_or("jpg").to_string()); + + let mut url = format!("{}image/process.{}?src={}", pictrs_url, format, src); + + if let Some(size) = self.thumbnail { + url = format!("{url}&thumbnail={size}",); + } + url + } + } +} + +pub(super) fn adapt_request(request: &HttpRequest, url: String) -> RequestBuilder { + // remove accept-encoding header so that pictrs doesn't compress the response + const INVALID_HEADERS: &[HeaderName] = &[ACCEPT_ENCODING, HOST]; + + let client_request = PICTRS_CLIENT + .request(convert_method(request.method()), url) + .timeout(REQWEST_TIMEOUT); + + request + .headers() + .iter() + .fold(client_request, |client_req, (key, value)| { + if INVALID_HEADERS.contains(key) { + client_req + } else { + // TODO: remove as_str and as_bytes conversions after actix-web upgrades to http 1.0 + client_req.header(key.as_str(), value.as_bytes()) + } + }) +} pub(super) fn make_send(mut stream: S) -> impl Stream + Send + Unpin + 'static where @@ -45,11 +136,6 @@ where } // TODO: remove these conversions after actix-web upgrades to http 1.0 -#[allow(clippy::expect_used)] -pub(super) fn convert_status(status: http::StatusCode) -> StatusCode { - StatusCode::from_u16(status.as_u16()).expect("status can be converted") -} - #[allow(clippy::expect_used)] pub(super) fn convert_method(method: &Method) -> http::Method { http::Method::from_bytes(method.as_str().as_bytes()).expect("method can be converted") @@ -61,3 +147,88 @@ pub(super) fn convert_header<'a>( ) -> (&'a str, &'a [u8]) { (name.as_str(), value.as_bytes()) } + +pub(super) enum UploadType { + Avatar, + Other, +} + +pub(super) async fn do_upload_image( + req: HttpRequest, + body: Payload, + upload_type: UploadType, + local_user_view: &LocalUserView, + context: &Data, +) -> LemmyResult { + let pictrs_config = context.settings().pictrs_config()?; + let image_url = format!("{}image", pictrs_config.url); + + let mut client_req = adapt_request(&req, image_url); + + client_req = match upload_type { + UploadType::Avatar => { + let max_size = context + .settings() + .pictrs_config()? + .max_thumbnail_size + .to_string(); + client_req.query(&[ + ("max_width", max_size.as_ref()), + ("max_height", max_size.as_ref()), + ("allow_animation", "false"), + ("allow_video", "false"), + ]) + } + _ => client_req, + }; + if let Some(addr) = req.head().peer_addr { + client_req = client_req.header("X-Forwarded-For", addr.to_string()) + }; + let res = client_req + .timeout(Duration::from_secs(pictrs_config.upload_timeout)) + .body(Body::wrap_stream(make_send(body))) + .send() + .await? + .error_for_status()?; + + let mut images = res.json::().await?; + for image in &images.files { + // Pictrs allows uploading multiple images in a single request. Lemmy doesnt need this, + // but still a user may upload multiple and so we need to store all links in db for + // to allow deletion via web ui. + let form = LocalImageForm { + local_user_id: Some(local_user_view.local_user.id), + pictrs_alias: image.file.to_string(), + pictrs_delete_token: image.delete_token.to_string(), + }; + + let protocol_and_hostname = context.settings().get_protocol_and_hostname(); + let thumbnail_url = image.thumbnail_url(&protocol_and_hostname)?; + + // Also store the details for the image + let details_form = image.details.build_image_details_form(&thumbnail_url); + LocalImage::create(&mut context.pool(), &form, &details_form).await?; + } + let image = images + .files + .pop() + .ok_or(LemmyErrorType::InvalidImageUpload)?; + + Ok(image) +} + +/// When adding a new avatar, banner or similar image, delete the old one. +pub(super) async fn delete_old_image( + old_image: &Option, + context: &Data, +) -> LemmyResult<()> { + if let Some(old_image) = old_image { + let image = LocalImage::delete_by_url(&mut context.pool(), old_image) + .await + .ok(); + if let Some(image) = image { + delete_image_from_pictrs(&image.pictrs_alias, &image.pictrs_delete_token, context).await?; + } + } + Ok(()) +} diff --git a/crates/utils/src/error.rs b/crates/utils/src/error.rs index f45bc271f6..d2605608e2 100644 --- a/crates/utils/src/error.rs +++ b/crates/utils/src/error.rs @@ -23,7 +23,6 @@ pub enum LemmyErrorType { CouldntUpdateComment, CouldntUpdatePrivateMessage, CannotLeaveAdmin, - // TODO: also remove the translations of unused errors PictrsResponseError(String), PictrsPurgeResponseError(String), ImageUrlMissingPathSegments, @@ -31,6 +30,7 @@ pub enum LemmyErrorType { PictrsApiKeyNotProvided, NoContentTypeHeader, NotAnImageType, + InvalidImageUpload, NotAModOrAdmin, NotTopMod, NotLoggedIn, diff --git a/src/api_routes_v3.rs b/src/api_routes_v3.rs index 452271dd1f..56e3721d7c 100644 --- a/src/api_routes_v3.rs +++ b/src/api_routes_v3.rs @@ -134,7 +134,13 @@ use lemmy_apub::api::{ search::search, user_settings_backup::{export_settings, import_settings}, }; -use lemmy_routes::images::{delete_image, get_full_res_image, image_proxy, pictrs_healthz, upload_image}; +use lemmy_routes::images::{ + delete_image, + get_full_res_image, + image_proxy, + pictrs_healthz, + upload_image, +}; use lemmy_utils::rate_limit::RateLimitCell; // Deprecated, use api v4 instead. diff --git a/src/api_routes_v4.rs b/src/api_routes_v4.rs index 48f4031d4b..e67c6d7d4f 100644 --- a/src/api_routes_v4.rs +++ b/src/api_routes_v4.rs @@ -160,11 +160,7 @@ use lemmy_apub::api::{ user_settings_backup::{export_settings, import_settings}, }; use lemmy_routes::images::{ - delete_image, - get_full_res_image, - image_proxy, - pictrs_healthz, - upload_image, + delete_image, get_full_res_image, image_proxy, person::upload_avatar, pictrs_healthz, upload_image }; use lemmy_utils::rate_limit::RateLimitCell; @@ -293,7 +289,8 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { .route("/change_password", put().to(change_password)) .route("/totp/generate", post().to(generate_totp_secret)) .route("/totp/update", post().to(update_totp)) - .route("/verify_email", post().to(verify_email)), + .route("/verify_email", post().to(verify_email)) + .route("/avatar", post().to(upload_avatar)), ) .route("/account/settings/save", put().to(save_user_settings)) .service( @@ -401,7 +398,8 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { .route(post().to(upload_image)), ) .route("/proxy", get().to(image_proxy)) - .route("/{filename}", get().to(get_full_res_image)) + .route("/image/{filename}", get().to(get_full_res_image)) + // TODO: params are a bit strange like this .route("{token}/{filename}", delete().to(delete_image)) .route("/healthz", get().to(pictrs_healthz)), ), diff --git a/src/lib.rs b/src/lib.rs index 5586b61592..c287487755 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -285,17 +285,12 @@ fn create_http_server( .build() .map_err(|e| LemmyErrorType::Unknown(format!("Should always be buildable: {e}")))?; - let context: LemmyContext = federation_config.deref().clone(); - let rate_limit_cell = federation_config.rate_limit_cell().clone(); - - // Pictrs cannot use proxy - let pictrs_client = ClientBuilder::new(client_builder(&SETTINGS).no_proxy().build()?) - .with(TracingMiddleware::default()) - .build(); - // Create Http server let bind = (settings.bind, settings.port); let server = HttpServer::new(move || { + let context: LemmyContext = federation_config.deref().clone(); + let rate_limit_cell = federation_config.rate_limit_cell().clone(); + let cors_config = cors_config(&settings); let app = App::new() .wrap(middleware::Logger::new( @@ -328,7 +323,6 @@ fn create_http_server( } }) .configure(feeds::config) - .configure(|cfg| images::config(cfg, pictrs_client.clone(), &rate_limit_cell)) .configure(nodeinfo::config) }) .disable_signals()