mirror of
https://github.com/LemmyNet/lemmy.git
synced 2024-11-23 12:51:18 +00:00
Adding an image_details table to store image dimensions.
- Adds an image_details table, which stores the height, width, and content_type for local and remote images. - For LocalImages, this information already comes back with the upload. - For RemoteImages, it calls the pictrs details endpoint. - Fixed some issues with proxying non-image urls. - Fixes #3328 - Also fixes #4703
This commit is contained in:
parent
b152be7951
commit
ee7df0dc35
15 changed files with 332 additions and 128 deletions
|
@ -15,7 +15,7 @@ export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queu
|
|||
|
||||
# pictrs setup
|
||||
if [ ! -f "api_tests/pict-rs" ]; then
|
||||
curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.0-beta.2/pict-rs-linux-amd64" -o api_tests/pict-rs
|
||||
curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.13/pict-rs-linux-amd64" -o api_tests/pict-rs
|
||||
chmod +x api_tests/pict-rs
|
||||
fi
|
||||
./api_tests/pict-rs \
|
||||
|
|
|
@ -29,7 +29,6 @@ import {
|
|||
unfollows,
|
||||
getPost,
|
||||
waitUntil,
|
||||
randomString,
|
||||
createPostWithThumbnail,
|
||||
} from "./shared";
|
||||
const downloadFileSync = require("download-file-sync");
|
||||
|
|
|
@ -3,14 +3,15 @@ use crate::{
|
|||
lemmy_db_schema::traits::Crud,
|
||||
post::{LinkMetadata, OpenGraphData},
|
||||
send_activity::{ActivityChannel, SendActivityData},
|
||||
utils::{local_site_opt_to_sensitive, proxy_image_link, proxy_image_link_opt_apub},
|
||||
utils::{local_site_opt_to_sensitive, proxy_image_link},
|
||||
};
|
||||
use activitypub_federation::config::Data;
|
||||
use chrono::{DateTime, Utc};
|
||||
use encoding_rs::{Encoding, UTF_8};
|
||||
use lemmy_db_schema::{
|
||||
newtypes::DbUrl,
|
||||
source::{
|
||||
images::{LocalImage, LocalImageForm},
|
||||
images::{ImageDetails, ImageDetailsForm, LocalImage, LocalImageForm},
|
||||
local_site::LocalSite,
|
||||
post::{Post, PostUpdateForm},
|
||||
},
|
||||
|
@ -25,7 +26,7 @@ use lemmy_utils::{
|
|||
use mime::Mime;
|
||||
use reqwest::{header::CONTENT_TYPE, Client, ClientBuilder};
|
||||
use reqwest_middleware::ClientWithMiddleware;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
use url::Url;
|
||||
use urlencoding::encode;
|
||||
|
@ -95,38 +96,48 @@ pub fn generate_post_link_metadata(
|
|||
let allow_sensitive = local_site_opt_to_sensitive(&local_site);
|
||||
let allow_generate_thumbnail = allow_sensitive || !post.nsfw;
|
||||
|
||||
let thumbnail_url = if is_image_post {
|
||||
if allow_generate_thumbnail {
|
||||
match post.url {
|
||||
Some(url) => generate_pictrs_thumbnail(&url, &context)
|
||||
.await
|
||||
.ok()
|
||||
.map(Into::into),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
// Use custom thumbnail if available and its not an image post
|
||||
let thumbnail_url = if !is_image_post && custom_thumbnail.is_some() {
|
||||
custom_thumbnail
|
||||
if let Some(custom_thumbnail) = custom_thumbnail {
|
||||
proxy_image_link(custom_thumbnail, &context).await.ok()
|
||||
}
|
||||
// Use federated thumbnail if available
|
||||
else if federated_thumbnail.is_some() {
|
||||
federated_thumbnail
|
||||
else if let Some(federated_thumbnail) = federated_thumbnail {
|
||||
proxy_image_link(federated_thumbnail, &context).await.ok()
|
||||
}
|
||||
// Generate local thumbnail if allowed
|
||||
else if allow_generate_thumbnail {
|
||||
match post
|
||||
.url
|
||||
.filter(|_| is_image_post)
|
||||
.or(metadata.opengraph_data.image)
|
||||
{
|
||||
Some(url) => generate_pictrs_thumbnail(&url, &context).await.ok(),
|
||||
match metadata.opengraph_data.image {
|
||||
Some(url) => generate_pictrs_thumbnail(&url, &context)
|
||||
.await
|
||||
.ok()
|
||||
.map(Into::into),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
// Otherwise use opengraph preview image directly
|
||||
else {
|
||||
metadata.opengraph_data.image.map(Into::into)
|
||||
metadata.opengraph_data.image
|
||||
}
|
||||
};
|
||||
|
||||
// Proxy the image fetch if necessary
|
||||
let proxied_thumbnail_url = proxy_image_link_opt_apub(thumbnail_url, &context).await?;
|
||||
|
||||
let form = PostUpdateForm {
|
||||
embed_title: Some(metadata.opengraph_data.title),
|
||||
embed_description: Some(metadata.opengraph_data.description),
|
||||
embed_video_url: Some(metadata.opengraph_data.embed_video_url),
|
||||
thumbnail_url: Some(proxied_thumbnail_url),
|
||||
thumbnail_url: Some(thumbnail_url),
|
||||
url_content_type: Some(metadata.content_type),
|
||||
..Default::default()
|
||||
};
|
||||
|
@ -142,17 +153,6 @@ pub fn generate_post_link_metadata(
|
|||
fn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult<OpenGraphData> {
|
||||
let html = String::from_utf8_lossy(html_bytes);
|
||||
|
||||
// Make sure the first line is doctype html
|
||||
let first_line = html
|
||||
.trim_start()
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or(LemmyErrorType::NoLinesInHtml)?
|
||||
.to_lowercase();
|
||||
|
||||
if !first_line.starts_with("<!doctype html") {
|
||||
Err(LemmyErrorType::SiteMetadataPageIsNotDoctypeHtml)?
|
||||
}
|
||||
|
||||
let mut page = HTML::from_string(html.to_string(), None)?;
|
||||
|
||||
|
@ -201,19 +201,50 @@ fn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult<OpenGraph
|
|||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct PictrsResponse {
|
||||
files: Vec<PictrsFile>,
|
||||
msg: String,
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct PictrsResponse {
|
||||
pub files: Option<Vec<PictrsFile>>,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct PictrsFile {
|
||||
file: String,
|
||||
delete_token: String,
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct PictrsFile {
|
||||
pub file: String,
|
||||
pub delete_token: String,
|
||||
pub details: PictrsFileDetails,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
impl PictrsFile {
|
||||
pub fn thumbnail_url(&self, protocol_and_hostname: &str) -> Result<Url, url::ParseError> {
|
||||
Url::parse(&format!(
|
||||
"{protocol_and_hostname}/pictrs/image/{}",
|
||||
self.file
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct PictrsFileDetails {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub content_type: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl PictrsFileDetails {
|
||||
/// Builds the image form. This should always use the thumbnail_url,
|
||||
/// Because the post_view joins to it
|
||||
pub fn build_image_details_form(&self, thumbnail_url: &Url) -> ImageDetailsForm {
|
||||
ImageDetailsForm {
|
||||
link: thumbnail_url.clone().into(),
|
||||
width: self.width.into(),
|
||||
height: self.height.into(),
|
||||
content_type: self.content_type.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
struct PictrsPurgeResponse {
|
||||
msg: String,
|
||||
}
|
||||
|
@ -295,35 +326,78 @@ async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> L
|
|||
encode(image_url.as_str())
|
||||
);
|
||||
|
||||
let response = context
|
||||
let res: PictrsResponse = context
|
||||
.client()
|
||||
.get(&fetch_url)
|
||||
.timeout(REQWEST_TIMEOUT)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let response: PictrsResponse = response.json().await?;
|
||||
|
||||
if response.msg == "ok" {
|
||||
let thumbnail_url = Url::parse(&format!(
|
||||
"{}/pictrs/image/{}",
|
||||
context.settings().get_protocol_and_hostname(),
|
||||
response.files.first().expect("missing pictrs file").file
|
||||
))?;
|
||||
for uploaded_image in response.files {
|
||||
if let Some(image) = res.files.unwrap_or_default().first() {
|
||||
let form = LocalImageForm {
|
||||
// This is none because its an internal request.
|
||||
// IE, a local user shouldn't get to delete the thumbnails for their link posts
|
||||
local_user_id: None,
|
||||
pictrs_alias: uploaded_image.file.to_string(),
|
||||
pictrs_delete_token: uploaded_image.delete_token.to_string(),
|
||||
pictrs_alias: image.file.clone(),
|
||||
pictrs_delete_token: image.delete_token.clone(),
|
||||
};
|
||||
LocalImage::create(&mut context.pool(), &form).await?;
|
||||
}
|
||||
|
||||
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);
|
||||
ImageDetails::create(&mut context.pool(), &details_form).await?;
|
||||
|
||||
Ok(thumbnail_url)
|
||||
} else {
|
||||
Err(LemmyErrorType::PictrsResponseError(response.msg))?
|
||||
Err(LemmyErrorType::PictrsResponseError(res.msg))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the image details for pictrs proxied images
|
||||
///
|
||||
/// We don't need to check for image mode, as that's already been done
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn fetch_pictrs_proxied_image_details(
|
||||
image_url: &Url,
|
||||
context: &LemmyContext,
|
||||
) -> LemmyResult<PictrsFileDetails> {
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
|
||||
// Pictrs needs you to fetch the proxied image before you can fetch the details
|
||||
let proxy_url = format!(
|
||||
"{}image/original?proxy={}",
|
||||
context.settings().pictrs_config()?.url,
|
||||
encode(image_url.as_str())
|
||||
);
|
||||
|
||||
let res = context.client().get(&proxy_url).send().await?.status();
|
||||
if !res.is_success() {
|
||||
Err(LemmyErrorType::NotAnImageType)?
|
||||
}
|
||||
|
||||
let details_url = format!(
|
||||
"{}image/details/original?proxy={}",
|
||||
pictrs_config.url,
|
||||
encode(image_url.as_str())
|
||||
);
|
||||
|
||||
let res = context
|
||||
.client()
|
||||
.get(&details_url)
|
||||
.timeout(REQWEST_TIMEOUT)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
// TODO: get rid of this by reading content type from db
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn is_image_content_type(client: &ClientWithMiddleware, url: &Url) -> LemmyResult<()> {
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
use crate::{
|
||||
context::LemmyContext,
|
||||
request::{delete_image_from_pictrs, purge_image_from_pictrs},
|
||||
request::{
|
||||
delete_image_from_pictrs,
|
||||
fetch_pictrs_proxied_image_details,
|
||||
purge_image_from_pictrs,
|
||||
},
|
||||
site::{FederatedInstances, InstanceWithFederationState},
|
||||
};
|
||||
use chrono::{DateTime, Days, Local, TimeZone, Utc};
|
||||
|
@ -12,7 +16,7 @@ use lemmy_db_schema::{
|
|||
community::{Community, CommunityModerator, CommunityUpdateForm},
|
||||
community_block::CommunityBlock,
|
||||
email_verification::{EmailVerification, EmailVerificationForm},
|
||||
images::RemoteImage,
|
||||
images::{ImageDetails, RemoteImage},
|
||||
instance::Instance,
|
||||
instance_block::InstanceBlock,
|
||||
local_site::LocalSite,
|
||||
|
@ -931,7 +935,20 @@ pub async fn process_markdown(
|
|||
|
||||
if context.settings().pictrs_config()?.image_mode() == PictrsImageMode::ProxyAllImages {
|
||||
let (text, links) = markdown_rewrite_image_links(text);
|
||||
RemoteImage::create(&mut context.pool(), links).await?;
|
||||
|
||||
// Create images and image detail rows
|
||||
for link in links {
|
||||
RemoteImage::create(&mut context.pool(), &link).await?;
|
||||
|
||||
// Insert image details for the remote image
|
||||
let details_res = fetch_pictrs_proxied_image_details(&link, context).await;
|
||||
if let Ok(details) = details_res {
|
||||
let proxied =
|
||||
build_proxied_image_url(&link, &context.settings().get_protocol_and_hostname())?;
|
||||
let details_form = details.build_image_details_form(&proxied);
|
||||
ImageDetails::create(&mut context.pool(), &details_form).await?;
|
||||
}
|
||||
}
|
||||
Ok(text)
|
||||
} else {
|
||||
Ok(text)
|
||||
|
@ -965,13 +982,19 @@ async fn proxy_image_link_internal(
|
|||
if link.domain() == Some(&context.settings().hostname) {
|
||||
Ok(link.into())
|
||||
} else if image_mode == PictrsImageMode::ProxyAllImages {
|
||||
let proxied = format!(
|
||||
"{}/api/v3/image_proxy?url={}",
|
||||
context.settings().get_protocol_and_hostname(),
|
||||
encode(link.as_str())
|
||||
);
|
||||
RemoteImage::create(&mut context.pool(), vec![link]).await?;
|
||||
Ok(Url::parse(&proxied)?.into())
|
||||
let proxied = build_proxied_image_url(&link, &context.settings().get_protocol_and_hostname())?;
|
||||
|
||||
RemoteImage::create(&mut context.pool(), &link).await?;
|
||||
|
||||
// This should fail softly, since pictrs might not even be running
|
||||
let details_res = fetch_pictrs_proxied_image_details(&link, context).await;
|
||||
|
||||
if let Ok(details) = details_res {
|
||||
let details_form = details.build_image_details_form(&proxied);
|
||||
ImageDetails::create(&mut context.pool(), &details_form).await?;
|
||||
}
|
||||
|
||||
Ok(proxied.into())
|
||||
} else {
|
||||
Ok(link.into())
|
||||
}
|
||||
|
@ -1025,6 +1048,17 @@ pub async fn proxy_image_link_opt_apub(
|
|||
}
|
||||
}
|
||||
|
||||
fn build_proxied_image_url(
|
||||
link: &Url,
|
||||
protocol_and_hostname: &str,
|
||||
) -> Result<Url, url::ParseError> {
|
||||
Url::parse(&format!(
|
||||
"{}/api/v3/image_proxy?url={}",
|
||||
protocol_and_hostname,
|
||||
encode(link.as_str())
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
#[allow(clippy::indexing_slicing)]
|
||||
|
|
|
@ -14,7 +14,6 @@ use lemmy_api_common::{
|
|||
local_site_to_slur_regex,
|
||||
mark_post_as_read,
|
||||
process_markdown_opt,
|
||||
proxy_image_link_opt_apub,
|
||||
EndpointType,
|
||||
},
|
||||
};
|
||||
|
@ -75,7 +74,6 @@ pub async fn create_post(
|
|||
is_url_blocked(&url, &url_blocklist)?;
|
||||
check_url_scheme(&url)?;
|
||||
check_url_scheme(&custom_thumbnail)?;
|
||||
let url = proxy_image_link_opt_apub(url, &context).await?;
|
||||
|
||||
check_community_user_action(
|
||||
&local_user_view.person,
|
||||
|
@ -125,7 +123,7 @@ pub async fn create_post(
|
|||
|
||||
let post_form = PostInsertForm::builder()
|
||||
.name(data.name.trim().to_string())
|
||||
.url(url)
|
||||
.url(url.map(Into::into))
|
||||
.body(body)
|
||||
.alt_text(data.alt_text.clone())
|
||||
.community_id(data.community_id)
|
||||
|
|
|
@ -11,7 +11,6 @@ use lemmy_api_common::{
|
|||
get_url_blocklist,
|
||||
local_site_to_slur_regex,
|
||||
process_markdown_opt,
|
||||
proxy_image_link_opt_apub,
|
||||
},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
|
@ -86,10 +85,6 @@ pub async fn update_post(
|
|||
Err(LemmyErrorType::NoPostEditAllowed)?
|
||||
}
|
||||
|
||||
let url = match url {
|
||||
Some(url) => Some(proxy_image_link_opt_apub(Some(url), &context).await?),
|
||||
_ => Default::default(),
|
||||
};
|
||||
|
||||
let language_id = data.language_id;
|
||||
CommunityLanguage::is_allowed_community_language(
|
||||
|
@ -101,7 +96,7 @@ pub async fn update_post(
|
|||
|
||||
let post_form = PostUpdateForm {
|
||||
name: data.name.clone(),
|
||||
url,
|
||||
url: Some(url.map(Into::into)),
|
||||
body: diesel_option_overwrite(body),
|
||||
alt_text: diesel_option_overwrite(data.alt_text.clone()),
|
||||
nsfw: data.nsfw,
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
use crate::{
|
||||
newtypes::DbUrl,
|
||||
schema::{local_image, remote_image},
|
||||
source::images::{LocalImage, LocalImageForm, RemoteImage, RemoteImageForm},
|
||||
schema::{image_details, local_image, remote_image},
|
||||
source::images::{
|
||||
ImageDetails,
|
||||
ImageDetailsForm,
|
||||
LocalImage,
|
||||
LocalImageForm,
|
||||
RemoteImage,
|
||||
RemoteImageForm,
|
||||
},
|
||||
utils::{get_conn, DbPool},
|
||||
};
|
||||
use diesel::{
|
||||
|
@ -39,14 +46,13 @@ impl LocalImage {
|
|||
}
|
||||
|
||||
impl RemoteImage {
|
||||
pub async fn create(pool: &mut DbPool<'_>, links: Vec<Url>) -> Result<usize, Error> {
|
||||
pub async fn create(pool: &mut DbPool<'_>, link_: &Url) -> Result<usize, Error> {
|
||||
let conn = &mut get_conn(pool).await?;
|
||||
let forms = links
|
||||
.into_iter()
|
||||
.map(|url| RemoteImageForm { link: url.into() })
|
||||
.collect::<Vec<_>>();
|
||||
let form = RemoteImageForm {
|
||||
link: link_.clone().into(),
|
||||
};
|
||||
insert_into(remote_image::table)
|
||||
.values(forms)
|
||||
.values(form)
|
||||
.on_conflict_do_nothing()
|
||||
.execute(conn)
|
||||
.await
|
||||
|
@ -67,3 +73,13 @@ impl RemoteImage {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageDetails {
|
||||
pub async fn create(pool: &mut DbPool<'_>, form: &ImageDetailsForm) -> Result<Self, Error> {
|
||||
let conn = &mut get_conn(pool).await?;
|
||||
insert_into(image_details::table)
|
||||
.values(form)
|
||||
.get_result::<Self>(conn)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
@ -309,6 +309,16 @@ diesel::table! {
|
|||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
image_details (link) {
|
||||
link -> Text,
|
||||
width -> Int4,
|
||||
height -> Int4,
|
||||
content_type -> Text,
|
||||
published -> Timestamptz,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
instance (id) {
|
||||
id -> Int4,
|
||||
|
@ -849,8 +859,7 @@ diesel::table! {
|
|||
}
|
||||
|
||||
diesel::table! {
|
||||
remote_image (id) {
|
||||
id -> Int4,
|
||||
remote_image (link) {
|
||||
link -> Text,
|
||||
published -> Timestamptz,
|
||||
}
|
||||
|
@ -1055,6 +1064,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||
federation_allowlist,
|
||||
federation_blocklist,
|
||||
federation_queue_state,
|
||||
image_details,
|
||||
instance,
|
||||
instance_block,
|
||||
language,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::newtypes::{DbUrl, LocalUserId};
|
||||
#[cfg(feature = "full")]
|
||||
use crate::schema::{local_image, remote_image};
|
||||
use crate::schema::{image_details, local_image, remote_image};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::skip_serializing_none;
|
||||
|
@ -22,7 +22,7 @@ use typed_builder::TypedBuilder;
|
|||
diesel(belongs_to(crate::source::local_user::LocalUser))
|
||||
)]
|
||||
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[cfg_attr(feature = "full", diesel(primary_key(local_user_id)))]
|
||||
#[cfg_attr(feature = "full", diesel(primary_key(pictrs_alias)))]
|
||||
pub struct LocalImage {
|
||||
pub local_user_id: Option<LocalUserId>,
|
||||
pub pictrs_alias: String,
|
||||
|
@ -41,11 +41,14 @@ pub struct LocalImageForm {
|
|||
|
||||
/// Stores all images which are hosted on remote domains. When attempting to proxy an image, it
|
||||
/// is checked against this table to avoid Lemmy being used as a general purpose proxy.
|
||||
#[derive(PartialEq, Eq, Debug, Clone)]
|
||||
#[cfg_attr(feature = "full", derive(Queryable, Identifiable))]
|
||||
#[skip_serializing_none]
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable, TS))]
|
||||
#[cfg_attr(feature = "full", ts(export))]
|
||||
#[cfg_attr(feature = "full", diesel(table_name = remote_image))]
|
||||
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[cfg_attr(feature = "full", diesel(primary_key(link)))]
|
||||
pub struct RemoteImage {
|
||||
pub id: i32,
|
||||
pub link: DbUrl,
|
||||
pub published: DateTime<Utc>,
|
||||
}
|
||||
|
@ -56,3 +59,28 @@ pub struct RemoteImage {
|
|||
pub struct RemoteImageForm {
|
||||
pub link: DbUrl,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "full", derive(Queryable, Selectable, Identifiable, TS))]
|
||||
#[cfg_attr(feature = "full", ts(export))]
|
||||
#[cfg_attr(feature = "full", diesel(table_name = image_details))]
|
||||
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[cfg_attr(feature = "full", diesel(primary_key(link)))]
|
||||
pub struct ImageDetails {
|
||||
pub link: DbUrl,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub content_type: String,
|
||||
pub published: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, TypedBuilder)]
|
||||
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
|
||||
#[cfg_attr(feature = "full", diesel(table_name = image_details))]
|
||||
pub struct ImageDetailsForm {
|
||||
pub link: DbUrl,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub content_type: String,
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ use lemmy_db_schema::{
|
|||
community_follower,
|
||||
community_moderator,
|
||||
community_person_ban,
|
||||
image_details,
|
||||
instance_block,
|
||||
local_user,
|
||||
local_user_language,
|
||||
|
@ -237,10 +238,12 @@ fn queries<'a>() -> Queries<
|
|||
.inner_join(person::table)
|
||||
.inner_join(community::table)
|
||||
.inner_join(post::table)
|
||||
.left_join(image_details::table.on(post::thumbnail_url.eq(image_details::link.nullable())))
|
||||
.select((
|
||||
post::all_columns,
|
||||
person::all_columns,
|
||||
community::all_columns,
|
||||
image_details::all_columns.nullable(),
|
||||
is_creator_banned_from_community,
|
||||
is_local_user_banned_from_community_selection,
|
||||
creator_is_moderator,
|
||||
|
@ -1666,6 +1669,7 @@ mod tests {
|
|||
public_key: inserted_person.public_key.clone(),
|
||||
last_refreshed_at: inserted_person.last_refreshed_at,
|
||||
},
|
||||
image_details: None,
|
||||
creator_banned_from_community: false,
|
||||
banned_from_community: false,
|
||||
creator_is_moderator: false,
|
||||
|
|
|
@ -8,7 +8,7 @@ use lemmy_db_schema::{
|
|||
community::Community,
|
||||
custom_emoji::CustomEmoji,
|
||||
custom_emoji_keyword::CustomEmojiKeyword,
|
||||
images::LocalImage,
|
||||
images::{ImageDetails, LocalImage},
|
||||
local_site::LocalSite,
|
||||
local_site_rate_limit::LocalSiteRateLimit,
|
||||
local_user::LocalUser,
|
||||
|
@ -130,6 +130,7 @@ pub struct PostView {
|
|||
pub post: Post,
|
||||
pub creator: Person,
|
||||
pub community: Community,
|
||||
pub image_details: Option<ImageDetails>,
|
||||
pub creator_banned_from_community: bool,
|
||||
pub banned_from_community: bool,
|
||||
pub creator_is_moderator: bool,
|
||||
|
@ -216,6 +217,7 @@ pub struct VoteView {
|
|||
pub score: i16,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[cfg_attr(feature = "full", derive(TS, Queryable))]
|
||||
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
|
|
|
@ -1,18 +1,16 @@
|
|||
use actix_web::{
|
||||
body::BodyStream,
|
||||
error,
|
||||
http::{
|
||||
header::{HeaderName, ACCEPT_ENCODING, HOST},
|
||||
StatusCode,
|
||||
},
|
||||
web,
|
||||
web::Query,
|
||||
Error,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
};
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use lemmy_api_common::context::LemmyContext;
|
||||
use lemmy_api_common::{context::LemmyContext, request::PictrsFileDetails};
|
||||
use lemmy_db_schema::source::{
|
||||
images::{LocalImage, LocalImageForm, RemoteImage},
|
||||
local_site::LocalSite,
|
||||
|
@ -40,6 +38,7 @@ pub fn config(
|
|||
)
|
||||
// This has optional query params: /image/{filename}?format=jpg&thumbnail=256
|
||||
.service(web::resource("/pictrs/image/{filename}").route(web::get().to(full_res)))
|
||||
.service(web::resource("/pictrs/image/details/original").route(web::get().to(details)))
|
||||
.service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete)));
|
||||
}
|
||||
|
||||
|
@ -56,11 +55,17 @@ struct Images {
|
|||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PictrsParams {
|
||||
struct PictrsGetParams {
|
||||
format: Option<String>,
|
||||
thumbnail: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PictrsDetailsParams {
|
||||
alias: Option<String>,
|
||||
proxy: Option<String>,
|
||||
}
|
||||
|
||||
fn adapt_request(
|
||||
request: &HttpRequest,
|
||||
client: &ClientWithMiddleware,
|
||||
|
@ -92,7 +97,7 @@ async fn upload(
|
|||
local_user_view: LocalUserView,
|
||||
client: web::Data<ClientWithMiddleware>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
// TODO: check rate limit here
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
let image_url = format!("{}image", pictrs_config.url);
|
||||
|
@ -106,11 +111,10 @@ async fn upload(
|
|||
.timeout(Duration::from_secs(pictrs_config.upload_timeout))
|
||||
.body(Body::wrap_stream(make_send(body)))
|
||||
.send()
|
||||
.await
|
||||
.map_err(error::ErrorBadRequest)?;
|
||||
.await?;
|
||||
|
||||
let status = res.status();
|
||||
let images = res.json::<Images>().await.map_err(error::ErrorBadRequest)?;
|
||||
let images = res.json::<Images>().await?;
|
||||
if let Some(images) = &images.files {
|
||||
for uploaded_image in images {
|
||||
let form = LocalImageForm {
|
||||
|
@ -118,9 +122,7 @@ async fn upload(
|
|||
pictrs_alias: uploaded_image.file.to_string(),
|
||||
pictrs_delete_token: uploaded_image.delete_token.to_string(),
|
||||
};
|
||||
LocalImage::create(&mut context.pool(), &form)
|
||||
.await
|
||||
.map_err(error::ErrorBadRequest)?;
|
||||
LocalImage::create(&mut context.pool(), &form).await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,16 +131,14 @@ async fn upload(
|
|||
|
||||
async fn full_res(
|
||||
filename: web::Path<String>,
|
||||
web::Query(params): web::Query<PictrsParams>,
|
||||
web::Query(params): web::Query<PictrsGetParams>,
|
||||
req: HttpRequest,
|
||||
client: web::Data<ClientWithMiddleware>,
|
||||
context: web::Data<LemmyContext>,
|
||||
local_user_view: Option<LocalUserView>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
// block access to images if instance is private and unauthorized, public
|
||||
let local_site = LocalSite::read(&mut context.pool())
|
||||
.await
|
||||
.map_err(error::ErrorBadRequest)?;
|
||||
let local_site = LocalSite::read(&mut context.pool()).await?;
|
||||
if local_site.private_instance && local_user_view.is_none() {
|
||||
return Ok(HttpResponse::Unauthorized().finish());
|
||||
}
|
||||
|
@ -169,7 +169,7 @@ async fn image(
|
|||
url: String,
|
||||
req: HttpRequest,
|
||||
client: &ClientWithMiddleware,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
let mut client_req = adapt_request(&req, client, url);
|
||||
|
||||
if let Some(addr) = req.head().peer_addr {
|
||||
|
@ -180,7 +180,7 @@ async fn image(
|
|||
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
||||
}
|
||||
|
||||
let res = client_req.send().await.map_err(error::ErrorBadRequest)?;
|
||||
let res = client_req.send().await?;
|
||||
|
||||
if res.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(HttpResponse::NotFound().finish());
|
||||
|
@ -202,7 +202,7 @@ async fn delete(
|
|||
context: web::Data<LemmyContext>,
|
||||
// require login
|
||||
_local_user_view: LocalUserView,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
let (token, file) = components.into_inner();
|
||||
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
|
@ -214,11 +214,9 @@ async fn delete(
|
|||
client_req = client_req.header("X-Forwarded-For", addr.to_string());
|
||||
}
|
||||
|
||||
let res = client_req.send().await.map_err(error::ErrorBadRequest)?;
|
||||
let res = client_req.send().await?;
|
||||
|
||||
LocalImage::delete_by_alias(&mut context.pool(), &file)
|
||||
.await
|
||||
.map_err(error::ErrorBadRequest)?;
|
||||
LocalImage::delete_by_alias(&mut context.pool(), &file).await?;
|
||||
|
||||
Ok(HttpResponse::build(res.status()).body(BodyStream::new(res.bytes_stream())))
|
||||
}
|
||||
|
@ -228,8 +226,33 @@ pub struct ImageProxyParams {
|
|||
url: String,
|
||||
}
|
||||
|
||||
async fn details(
|
||||
web::Query(params): web::Query<PictrsDetailsParams>,
|
||||
client: web::Data<ClientWithMiddleware>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
|
||||
let mut url = format!("{}image/details/original", pictrs_config.url);
|
||||
if let Some(alias) = params.alias {
|
||||
url = format!("{url}?alias={alias}");
|
||||
};
|
||||
|
||||
if let Some(proxy) = params.proxy {
|
||||
url = format!("{url}?proxy={proxy}");
|
||||
};
|
||||
|
||||
let res = client.get(url).send().await?;
|
||||
let status = res.status();
|
||||
let json = res.json::<PictrsFileDetails>().await?;
|
||||
|
||||
Ok(HttpResponse::build(status).json(json))
|
||||
}
|
||||
|
||||
pub async fn image_proxy(
|
||||
Query(params): Query<ImageProxyParams>,
|
||||
req: HttpRequest,
|
||||
client: web::Data<ClientWithMiddleware>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> LemmyResult<HttpResponse> {
|
||||
let url = Url::parse(&decode(¶ms.url)?)?;
|
||||
|
@ -240,9 +263,8 @@ pub async fn image_proxy(
|
|||
|
||||
let pictrs_config = context.settings().pictrs_config()?;
|
||||
let url = format!("{}image/original?proxy={}", pictrs_config.url, ¶ms.url);
|
||||
let image_response = context.client().get(url).send().await?;
|
||||
|
||||
Ok(HttpResponse::Ok().streaming(image_response.bytes_stream()))
|
||||
image(url, req, &client).await
|
||||
}
|
||||
|
||||
fn make_send<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static
|
||||
|
|
|
@ -75,7 +75,7 @@ services:
|
|||
init: true
|
||||
|
||||
pictrs:
|
||||
image: asonix/pictrs:0.5.0-rc.2
|
||||
image: asonix/pictrs:0.5.13
|
||||
# this needs to match the pictrs url in lemmy.hjson
|
||||
hostname: pictrs
|
||||
# we can set options to pictrs like this, here we set max. image size and forced format for conversion
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
ALTER TABLE remote_image
|
||||
ADD UNIQUE (link),
|
||||
DROP CONSTRAINT remote_image_pkey,
|
||||
ADD COLUMN id serial PRIMARY KEY;
|
||||
|
||||
DROP TABLE image_details;
|
||||
|
15
migrations/2024-05-05-162540_add_image_detail_table/up.sql
Normal file
15
migrations/2024-05-05-162540_add_image_detail_table/up.sql
Normal file
|
@ -0,0 +1,15 @@
|
|||
-- Drop the id column from the remote_image table, just use link
|
||||
ALTER TABLE remote_image
|
||||
DROP COLUMN id,
|
||||
ADD PRIMARY KEY (link),
|
||||
DROP CONSTRAINT remote_image_link_key;
|
||||
|
||||
-- No good way to do references here unfortunately, unless we combine the images tables
|
||||
-- The link should be the URL, not the pictrs_alias, to allow joining from post.thumbnail_url
|
||||
CREATE TABLE image_details (
|
||||
link text PRIMARY KEY,
|
||||
width integer NOT NULL,
|
||||
height integer NOT NULL,
|
||||
content_type text NOT NULL,
|
||||
published timestamptz DEFAULT now() NOT NULL
|
||||
);
|
Loading…
Reference in a new issue