Skip to content

Commit

Permalink
Upload avatar endpoint and other changes
Browse files Browse the repository at this point in the history
  • Loading branch information
Nutomic committed Dec 13, 2024
1 parent 05843fb commit cfa866a
Show file tree
Hide file tree
Showing 13 changed files with 301 additions and 192 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion api_tests/run-federation-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions crates/api/src/local_user/save_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -108,7 +104,6 @@ pub async fn save_user_settings(
bio,
matrix_user_id,
bot_account: data.bot_account,
avatar,
banner,
..Default::default()
};
Expand Down
3 changes: 0 additions & 3 deletions crates/api_common/src/person.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,6 @@ pub struct SaveUserSettings {
/// The language of the lemmy interface
#[cfg_attr(feature = "full", ts(optional))]
pub interface_language: Option<String>,
/// A URL for your avatar.
#[cfg_attr(feature = "full", ts(optional))]
pub avatar: Option<String>,
/// A URL for your banner.
#[cfg_attr(feature = "full", ts(optional))]
pub banner: Option<String>,
Expand Down
9 changes: 5 additions & 4 deletions crates/api_common/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@ fn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult<OpenGraph

#[derive(Deserialize, Serialize, Debug)]
pub struct PictrsResponse {
pub files: Option<Vec<PictrsFile>>,
#[serde(default)]
pub files: Vec<PictrsFile>,
pub msg: String,
}

Expand Down Expand Up @@ -388,9 +389,8 @@ async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> L
.json::<PictrsResponse>()
.await?;

let files = res.files.unwrap_or_default();

let image = files
let image = res
.files
.first()
.ok_or(LemmyErrorType::PictrsResponseError(res.msg))?;

Expand Down Expand Up @@ -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<Option<DbUrl>>,
old_image: &Option<DbUrl>,
Expand Down
1 change: 1 addition & 0 deletions crates/routes/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ url = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }
http.workspace = true
reqwest-tracing = { workspace = true }
rss = "2.0.10"
219 changes: 64 additions & 155 deletions crates/routes/src/images/mod.rs
Original file line number Diff line number Diff line change
@@ -1,153 +1,43 @@
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<String>,
thumbnail: Option<i32>,
}

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<String>,
thumbnail: Option<i32>,
}

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,
// require login
local_user_view: LocalUserView,
context: Data<LemmyContext>,
) -> LemmyResult<HttpResponse> {
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::<PictrsResponse>().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(
Expand All @@ -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<HttpResponse> {
let mut client_req = adapt_request(&req, context, url);
async fn image(url: String, req: HttpRequest) -> LemmyResult<HttpResponse> {
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());
Expand All @@ -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<DeleteImageParams>,
context: Data<LemmyContext>,
// require login
_local_user_view: LocalUserView,
) -> LemmyResult<HttpResponse> {
let (token, file) = components.into_inner();

) -> LemmyResult<SuccessResponse> {
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<LemmyContext>,
) -> LemmyResult<HttpResponse> {
pub async fn pictrs_healthz(context: Data<LemmyContext>) -> LemmyResult<SuccessResponse> {
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<String>,
thumbnail: Option<i32>,
}

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(
Expand All @@ -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?))
}
}
Loading

0 comments on commit cfa866a

Please sign in to comment.