mirror of
https://github.com/LemmyNet/lemmy.git
synced 2024-12-23 03:11:32 +00:00
Upload avatar endpoint and other changes
This commit is contained in:
parent
05843fb7b5
commit
cfa866a534
13 changed files with 337 additions and 228 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -2773,6 +2773,7 @@ dependencies = [
|
||||||
"lemmy_utils",
|
"lemmy_utils",
|
||||||
"reqwest 0.12.8",
|
"reqwest 0.12.8",
|
||||||
"reqwest-middleware",
|
"reqwest-middleware",
|
||||||
|
"reqwest-tracing",
|
||||||
"rss",
|
"rss",
|
||||||
"serde",
|
"serde",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
@ -11,7 +11,7 @@ killall -s1 lemmy_server || true
|
||||||
popd
|
popd
|
||||||
|
|
||||||
pnpm i
|
pnpm i
|
||||||
pnpm api-test || true
|
pnpm api-test-image || true
|
||||||
|
|
||||||
killall -s1 lemmy_server || true
|
killall -s1 lemmy_server || true
|
||||||
killall -s1 pict-rs || true
|
killall -s1 pict-rs || true
|
||||||
|
|
|
@ -46,10 +46,6 @@ pub async fn save_user_settings(
|
||||||
.as_deref(),
|
.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())?;
|
let banner = diesel_url_update(data.banner.as_deref())?;
|
||||||
replace_image(&banner, &local_user_view.person.banner, &context).await?;
|
replace_image(&banner, &local_user_view.person.banner, &context).await?;
|
||||||
let banner = proxy_image_link_opt_api(banner, &context).await?;
|
let banner = proxy_image_link_opt_api(banner, &context).await?;
|
||||||
|
@ -108,7 +104,6 @@ pub async fn save_user_settings(
|
||||||
bio,
|
bio,
|
||||||
matrix_user_id,
|
matrix_user_id,
|
||||||
bot_account: data.bot_account,
|
bot_account: data.bot_account,
|
||||||
avatar,
|
|
||||||
banner,
|
banner,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
|
@ -114,9 +114,6 @@ pub struct SaveUserSettings {
|
||||||
/// The language of the lemmy interface
|
/// The language of the lemmy interface
|
||||||
#[cfg_attr(feature = "full", ts(optional))]
|
#[cfg_attr(feature = "full", ts(optional))]
|
||||||
pub interface_language: Option<String>,
|
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.
|
/// A URL for your banner.
|
||||||
#[cfg_attr(feature = "full", ts(optional))]
|
#[cfg_attr(feature = "full", ts(optional))]
|
||||||
pub banner: Option<String>,
|
pub banner: Option<String>,
|
||||||
|
|
|
@ -250,7 +250,8 @@ fn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult<OpenGraph
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
pub struct PictrsResponse {
|
pub struct PictrsResponse {
|
||||||
pub files: Option<Vec<PictrsFile>>,
|
#[serde(default)]
|
||||||
|
pub files: Vec<PictrsFile>,
|
||||||
pub msg: String,
|
pub msg: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -388,9 +389,8 @@ async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> L
|
||||||
.json::<PictrsResponse>()
|
.json::<PictrsResponse>()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let files = res.files.unwrap_or_default();
|
let image = res
|
||||||
|
.files
|
||||||
let image = files
|
|
||||||
.first()
|
.first()
|
||||||
.ok_or(LemmyErrorType::PictrsResponseError(res.msg))?;
|
.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.
|
/// When adding a new avatar, banner or similar image, delete the old one.
|
||||||
|
/// TODO: remove this function
|
||||||
pub async fn replace_image(
|
pub async fn replace_image(
|
||||||
new_image: &Option<Option<DbUrl>>,
|
new_image: &Option<Option<DbUrl>>,
|
||||||
old_image: &Option<DbUrl>,
|
old_image: &Option<DbUrl>,
|
||||||
|
|
|
@ -33,4 +33,5 @@ url = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
|
reqwest-tracing = { workspace = true }
|
||||||
rss = "2.0.10"
|
rss = "2.0.10"
|
||||||
|
|
|
@ -1,61 +1,125 @@
|
||||||
use actix_web::{
|
use actix_web::{
|
||||||
body::{BodyStream, BoxBody},
|
body::{BodyStream, BoxBody},
|
||||||
http::{
|
http::StatusCode,
|
||||||
header::{HeaderName, ACCEPT_ENCODING, HOST},
|
|
||||||
StatusCode,
|
|
||||||
},
|
|
||||||
web::*,
|
web::*,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
Responder,
|
Responder,
|
||||||
};
|
};
|
||||||
use lemmy_api_common::{context::LemmyContext, request::PictrsResponse};
|
use lemmy_api_common::{context::LemmyContext, SuccessResponse};
|
||||||
use lemmy_db_schema::source::{
|
use lemmy_db_schema::source::{
|
||||||
images::{LocalImage, LocalImageForm, RemoteImage},
|
images::{LocalImage, RemoteImage},
|
||||||
local_site::LocalSite,
|
local_site::LocalSite,
|
||||||
};
|
};
|
||||||
use lemmy_db_views::structs::LocalUserView;
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
use lemmy_utils::{error::LemmyResult, REQWEST_TIMEOUT};
|
use lemmy_utils::error::LemmyResult;
|
||||||
use reqwest::Body;
|
|
||||||
use reqwest_middleware::RequestBuilder;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::time::Duration;
|
|
||||||
use url::Url;
|
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;
|
mod utils;
|
||||||
|
|
||||||
trait ProcessUrl {
|
pub async fn upload_image(
|
||||||
/// If thumbnail or format is given, this uses the pictrs process endpoint.
|
req: HttpRequest,
|
||||||
/// Otherwise, it uses the normal pictrs url (IE image/original).
|
body: Payload,
|
||||||
fn process_url(&self, image_url: &str, pictrs_url: &Url) -> String;
|
// require login
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<HttpResponse> {
|
||||||
|
let image = do_upload_image(req, body, UploadType::Other, &local_user_view, &context).await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(image))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_full_res_image(
|
||||||
|
filename: Path<String>,
|
||||||
|
Query(params): Query<PictrsGetParams>,
|
||||||
|
req: HttpRequest,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: Option<LocalUserView>,
|
||||||
|
) -> LemmyResult<HttpResponse> {
|
||||||
|
// block access to images if instance is private and unauthorized, public
|
||||||
|
let local_site = LocalSite::read(&mut context.pool()).await?;
|
||||||
|
if local_site.private_instance && local_user_view.is_none() {
|
||||||
|
return Ok(HttpResponse::Unauthorized().finish());
|
||||||
|
}
|
||||||
|
let name = &filename.into_inner();
|
||||||
|
|
||||||
|
// If there are no query params, the URL is original
|
||||||
|
let pictrs_config = context.settings().pictrs_config()?;
|
||||||
|
|
||||||
|
let processed_url = params.process_url(name, &pictrs_config.url);
|
||||||
|
|
||||||
|
image(processed_url, req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(addr) = req.head().peer_addr {
|
||||||
|
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = client_req.send().await?;
|
||||||
|
|
||||||
|
if res.status() == http::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(HttpResponse::NotFound().finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut client_res = HttpResponse::build(StatusCode::from_u16(res.status().as_u16())?);
|
||||||
|
|
||||||
|
for (name, value) in res.headers().iter().filter(|(h, _)| *h != "connection") {
|
||||||
|
client_res.insert_header(convert_header(name, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(client_res.body(BodyStream::new(res.bytes_stream())))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct PictrsGetParams {
|
pub struct DeleteImageParams {
|
||||||
format: Option<String>,
|
file: String,
|
||||||
thumbnail: Option<i32>,
|
token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProcessUrl for PictrsGetParams {
|
pub async fn delete_image(
|
||||||
fn process_url(&self, src: &str, pictrs_url: &Url) -> String {
|
data: Json<DeleteImageParams>,
|
||||||
if self.format.is_none() && self.thumbnail.is_none() {
|
context: Data<LemmyContext>,
|
||||||
format!("{}image/original/{}", pictrs_url, src)
|
// require login
|
||||||
} else {
|
_local_user_view: LocalUserView,
|
||||||
// Take file type from name, or jpg if nothing is given
|
) -> LemmyResult<SuccessResponse> {
|
||||||
let format = self
|
let pictrs_config = context.settings().pictrs_config()?;
|
||||||
.clone()
|
let url = format!(
|
||||||
.format
|
"{}image/delete/{}/{}",
|
||||||
.unwrap_or_else(|| src.split('.').last().unwrap_or("jpg").to_string());
|
pictrs_config.url, &data.token, &data.file
|
||||||
|
);
|
||||||
|
|
||||||
let mut url = format!("{}image/process.{}?src={}", pictrs_url, format, src);
|
PICTRS_CLIENT.delete(url).send().await?.error_for_status()?;
|
||||||
|
|
||||||
if let Some(size) = self.thumbnail {
|
LocalImage::delete_by_alias(&mut context.pool(), &data.file).await?;
|
||||||
url = format!("{url}&thumbnail={size}",);
|
|
||||||
}
|
Ok(SuccessResponse::default())
|
||||||
url
|
}
|
||||||
}
|
|
||||||
}
|
pub async fn pictrs_healthz(context: Data<LemmyContext>) -> LemmyResult<SuccessResponse> {
|
||||||
|
let pictrs_config = context.settings().pictrs_config()?;
|
||||||
|
let url = format!("{}healthz", pictrs_config.url);
|
||||||
|
|
||||||
|
PICTRS_CLIENT.get(url).send().await?.error_for_status()?;
|
||||||
|
|
||||||
|
Ok(SuccessResponse::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone)]
|
||||||
|
@ -85,161 +149,6 @@ impl ProcessUrl for ImageProxyParams {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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)?;
|
|
||||||
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_full_res_image(
|
|
||||||
filename: Path<String>,
|
|
||||||
Query(params): Query<PictrsGetParams>,
|
|
||||||
req: HttpRequest,
|
|
||||||
context: Data<LemmyContext>,
|
|
||||||
local_user_view: Option<LocalUserView>,
|
|
||||||
) -> LemmyResult<HttpResponse> {
|
|
||||||
// block access to images if instance is private and unauthorized, public
|
|
||||||
let local_site = LocalSite::read(&mut context.pool()).await?;
|
|
||||||
if local_site.private_instance && local_user_view.is_none() {
|
|
||||||
return Ok(HttpResponse::Unauthorized().finish());
|
|
||||||
}
|
|
||||||
let name = &filename.into_inner();
|
|
||||||
|
|
||||||
// If there are no query params, the URL is original
|
|
||||||
let pictrs_config = context.settings().pictrs_config()?;
|
|
||||||
|
|
||||||
let processed_url = params.process_url(name, &pictrs_config.url);
|
|
||||||
|
|
||||||
image(processed_url, req, &context).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn image(url: String, req: HttpRequest, context: &LemmyContext) -> LemmyResult<HttpResponse> {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(addr) = req.head().peer_addr {
|
|
||||||
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = client_req.send().await?;
|
|
||||||
|
|
||||||
if res.status() == http::StatusCode::NOT_FOUND {
|
|
||||||
return Ok(HttpResponse::NotFound().finish());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut client_res = HttpResponse::build(StatusCode::from_u16(res.status().as_u16())?);
|
|
||||||
|
|
||||||
for (name, value) in res.headers().iter().filter(|(h, _)| *h != "connection") {
|
|
||||||
client_res.insert_header(convert_header(name, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(client_res.body(BodyStream::new(res.bytes_stream())))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_image(
|
|
||||||
components: Path<(String, String)>,
|
|
||||||
req: HttpRequest,
|
|
||||||
context: Data<LemmyContext>,
|
|
||||||
// require login
|
|
||||||
_local_user_view: LocalUserView,
|
|
||||||
) -> LemmyResult<HttpResponse> {
|
|
||||||
let (token, file) = components.into_inner();
|
|
||||||
|
|
||||||
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 res = client_req.send().await?;
|
|
||||||
|
|
||||||
LocalImage::delete_by_alias(&mut context.pool(), &file).await?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::build(convert_status(res.status())).body(BodyStream::new(res.bytes_stream())))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn pictrs_healthz(
|
|
||||||
req: HttpRequest,
|
|
||||||
context: Data<LemmyContext>,
|
|
||||||
) -> LemmyResult<HttpResponse> {
|
|
||||||
let pictrs_config = context.settings().pictrs_config()?;
|
|
||||||
let url = format!("{}healthz", pictrs_config.url);
|
|
||||||
|
|
||||||
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 res = client_req.send().await?;
|
|
||||||
|
|
||||||
Ok(HttpResponse::build(convert_status(res.status())).body(BodyStream::new(res.bytes_stream())))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn image_proxy(
|
pub async fn image_proxy(
|
||||||
Query(params): Query<ImageProxyParams>,
|
Query(params): Query<ImageProxyParams>,
|
||||||
|
@ -264,6 +173,6 @@ pub async fn image_proxy(
|
||||||
Ok(Either::Left(Redirect::to(url.to_string()).respond_to(&req)))
|
Ok(Either::Left(Redirect::to(url.to_string()).respond_to(&req)))
|
||||||
} else {
|
} else {
|
||||||
// Proxy the image data through Lemmy
|
// Proxy the image data through Lemmy
|
||||||
Ok(Either::Right(image(processed_url, req, &context).await?))
|
Ok(Either::Right(image(processed_url, req).await?))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
36
crates/routes/src/images/person.rs
Normal file
36
crates/routes/src/images/person.rs
Normal file
|
@ -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<LemmyContext>,
|
||||||
|
) -> LemmyResult<Json<SuccessResponse>> {
|
||||||
|
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()))
|
||||||
|
}
|
|
@ -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 futures::stream::{Stream, StreamExt};
|
||||||
use http::HeaderValue;
|
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<ClientWithMiddleware> = 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<String>,
|
||||||
|
thumbnail: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static
|
pub(super) fn make_send<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static
|
||||||
where
|
where
|
||||||
|
@ -45,11 +136,6 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: remove these conversions after actix-web upgrades to http 1.0
|
// 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)]
|
#[allow(clippy::expect_used)]
|
||||||
pub(super) fn convert_method(method: &Method) -> http::Method {
|
pub(super) fn convert_method(method: &Method) -> http::Method {
|
||||||
http::Method::from_bytes(method.as_str().as_bytes()).expect("method can be converted")
|
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]) {
|
) -> (&'a str, &'a [u8]) {
|
||||||
(name.as_str(), value.as_bytes())
|
(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<LemmyContext>,
|
||||||
|
) -> LemmyResult<PictrsFile> {
|
||||||
|
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::<PictrsResponse>().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<DbUrl>,
|
||||||
|
context: &Data<LemmyContext>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
|
|
|
@ -23,7 +23,6 @@ pub enum LemmyErrorType {
|
||||||
CouldntUpdateComment,
|
CouldntUpdateComment,
|
||||||
CouldntUpdatePrivateMessage,
|
CouldntUpdatePrivateMessage,
|
||||||
CannotLeaveAdmin,
|
CannotLeaveAdmin,
|
||||||
// TODO: also remove the translations of unused errors
|
|
||||||
PictrsResponseError(String),
|
PictrsResponseError(String),
|
||||||
PictrsPurgeResponseError(String),
|
PictrsPurgeResponseError(String),
|
||||||
ImageUrlMissingPathSegments,
|
ImageUrlMissingPathSegments,
|
||||||
|
@ -31,6 +30,7 @@ pub enum LemmyErrorType {
|
||||||
PictrsApiKeyNotProvided,
|
PictrsApiKeyNotProvided,
|
||||||
NoContentTypeHeader,
|
NoContentTypeHeader,
|
||||||
NotAnImageType,
|
NotAnImageType,
|
||||||
|
InvalidImageUpload,
|
||||||
NotAModOrAdmin,
|
NotAModOrAdmin,
|
||||||
NotTopMod,
|
NotTopMod,
|
||||||
NotLoggedIn,
|
NotLoggedIn,
|
||||||
|
|
|
@ -134,7 +134,13 @@ use lemmy_apub::api::{
|
||||||
search::search,
|
search::search,
|
||||||
user_settings_backup::{export_settings, import_settings},
|
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;
|
use lemmy_utils::rate_limit::RateLimitCell;
|
||||||
|
|
||||||
// Deprecated, use api v4 instead.
|
// Deprecated, use api v4 instead.
|
||||||
|
|
|
@ -160,11 +160,7 @@ use lemmy_apub::api::{
|
||||||
user_settings_backup::{export_settings, import_settings},
|
user_settings_backup::{export_settings, import_settings},
|
||||||
};
|
};
|
||||||
use lemmy_routes::images::{
|
use lemmy_routes::images::{
|
||||||
delete_image,
|
delete_image, get_full_res_image, image_proxy, person::upload_avatar, pictrs_healthz, upload_image
|
||||||
get_full_res_image,
|
|
||||||
image_proxy,
|
|
||||||
pictrs_healthz,
|
|
||||||
upload_image,
|
|
||||||
};
|
};
|
||||||
use lemmy_utils::rate_limit::RateLimitCell;
|
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("/change_password", put().to(change_password))
|
||||||
.route("/totp/generate", post().to(generate_totp_secret))
|
.route("/totp/generate", post().to(generate_totp_secret))
|
||||||
.route("/totp/update", post().to(update_totp))
|
.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))
|
.route("/account/settings/save", put().to(save_user_settings))
|
||||||
.service(
|
.service(
|
||||||
|
@ -401,7 +398,8 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) {
|
||||||
.route(post().to(upload_image)),
|
.route(post().to(upload_image)),
|
||||||
)
|
)
|
||||||
.route("/proxy", get().to(image_proxy))
|
.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("{token}/{filename}", delete().to(delete_image))
|
||||||
.route("/healthz", get().to(pictrs_healthz)),
|
.route("/healthz", get().to(pictrs_healthz)),
|
||||||
),
|
),
|
||||||
|
|
12
src/lib.rs
12
src/lib.rs
|
@ -285,17 +285,12 @@ fn create_http_server(
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| LemmyErrorType::Unknown(format!("Should always be buildable: {e}")))?;
|
.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
|
// Create Http server
|
||||||
let bind = (settings.bind, settings.port);
|
let bind = (settings.bind, settings.port);
|
||||||
let server = HttpServer::new(move || {
|
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 cors_config = cors_config(&settings);
|
||||||
let app = App::new()
|
let app = App::new()
|
||||||
.wrap(middleware::Logger::new(
|
.wrap(middleware::Logger::new(
|
||||||
|
@ -328,7 +323,6 @@ fn create_http_server(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.configure(feeds::config)
|
.configure(feeds::config)
|
||||||
.configure(|cfg| images::config(cfg, pictrs_client.clone(), &rate_limit_cell))
|
|
||||||
.configure(nodeinfo::config)
|
.configure(nodeinfo::config)
|
||||||
})
|
})
|
||||||
.disable_signals()
|
.disable_signals()
|
||||||
|
|
Loading…
Reference in a new issue