@ -3,9 +3,10 @@ use crate::{
lemmy_db_schema ::traits ::Crud ,
lemmy_db_schema ::traits ::Crud ,
post ::{ LinkMetadata , OpenGraphData } ,
post ::{ LinkMetadata , OpenGraphData } ,
send_activity ::{ ActivityChannel , SendActivityData } ,
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 activitypub_federation ::config ::Data ;
use chrono ::{ DateTime , Utc } ;
use encoding_rs ::{ Encoding , UTF_8 } ;
use encoding_rs ::{ Encoding , UTF_8 } ;
use lemmy_db_schema ::{
use lemmy_db_schema ::{
newtypes ::DbUrl ,
newtypes ::DbUrl ,
@ -18,14 +19,13 @@ use lemmy_db_schema::{
use lemmy_utils ::{
use lemmy_utils ::{
error ::{ LemmyError , LemmyErrorType , LemmyResult } ,
error ::{ LemmyError , LemmyErrorType , LemmyResult } ,
settings ::structs ::{ PictrsImageMode , Settings } ,
settings ::structs ::{ PictrsImageMode , Settings } ,
spawn_try_task ,
REQWEST_TIMEOUT ,
REQWEST_TIMEOUT ,
VERSION ,
VERSION ,
} ;
} ;
use mime ::Mime ;
use mime ::Mime ;
use reqwest ::{ header ::CONTENT_TYPE , Client , ClientBuilder } ;
use reqwest ::{ header ::CONTENT_TYPE , Client , ClientBuilder } ;
use reqwest_middleware ::ClientWithMiddleware ;
use reqwest_middleware ::ClientWithMiddleware ;
use serde ::Deserialize ;
use serde ::{ Deserialize , Serialize } ;
use tracing ::info ;
use tracing ::info ;
use url ::Url ;
use url ::Url ;
use urlencoding ::encode ;
use urlencoding ::encode ;
@ -65,95 +65,70 @@ pub async fn fetch_link_metadata(url: &Url, context: &LemmyContext) -> LemmyResu
} )
} )
}
}
/// Generate post thumbnail in background task, because some sites can be very slow to respond .
/// Generate s and saves a post thumbnail and metadata .
///
///
/// Takes a callback to generate a send activity task, so that post can be federated with metadata.
/// Takes a callback to generate a send activity task, so that post can be federated with metadata.
///
///
/// TODO: `federated_thumbnail` param can be removed once we federate full metadata and can
/// TODO: `federated_thumbnail` param can be removed once we federate full metadata and can
/// write it to db directly, without calling this function.
/// write it to db directly, without calling this function.
/// https://github.com/LemmyNet/lemmy/issues/4598
/// https://github.com/LemmyNet/lemmy/issues/4598
pub fn generate_post_link_metadata (
pub async fn generate_post_link_metadata (
post : Post ,
post : Post ,
custom_thumbnail : Option < Url > ,
custom_thumbnail : Option < Url > ,
federated_thumbnail : Option < Url > ,
send_activity : impl FnOnce ( Post ) -> Option < SendActivityData > + Send + ' static ,
send_activity : impl FnOnce ( Post ) -> Option < SendActivityData > + Send + ' static ,
local_site : Option < LocalSite > ,
local_site : Option < LocalSite > ,
context : Data < LemmyContext > ,
context : Data < LemmyContext > ,
) {
) -> LemmyResult < ( ) > {
spawn_try_task ( async move {
let metadata = match & post . url {
let metadata = match & post . url {
Some ( url ) = > fetch_link_metadata ( url , & context ) . await . unwrap_or_default ( ) ,
Some ( url ) = > fetch_link_metadata ( url , & context ) . await . unwrap_or_default ( ) ,
_ = > Default ::default ( ) ,
_ = > Default ::default ( ) ,
} ;
} ;
let is_image_post = metadata
let is_image_post = metadata
. content_type
. content_type
. as_ref ( )
. as_ref ( )
. is_some_and ( | content_type | content_type . starts_with ( "image" ) ) ;
. is_some_and ( | content_type | content_type . starts_with ( "image" ) ) ;
// Decide if we are allowed to generate local thumbnail
// Decide if we are allowed to generate local thumbnail
let allow_sensitive = local_site_opt_to_sensitive ( & local_site ) ;
let allow_sensitive = local_site_opt_to_sensitive ( & local_site ) ;
let allow_generate_thumbnail = allow_sensitive | | ! post . nsfw ;
let allow_generate_thumbnail = allow_sensitive | | ! post . nsfw ;
let image_url = if is_image_post {
// Use custom thumbnail if available and its not an image post
post . url
let thumbnail_url = if ! is_image_post & & custom_thumbnail . is_some ( ) {
} else {
custom_thumbnail
metadata . opengraph_data . image . clone ( )
}
} ;
// Use federated thumbnail if available
else if federated_thumbnail . is_some ( ) {
let thumbnail_url = if let ( false , Some ( url ) ) = ( is_image_post , custom_thumbnail ) {
federated_thumbnail
proxy_image_link ( url , & context ) . await . ok ( )
}
} else if let ( true , Some ( url ) ) = ( allow_generate_thumbnail , image_url ) {
// Generate local thumbnail if allowed
generate_pictrs_thumbnail ( & url , & context )
else if allow_generate_thumbnail {
. await
match post
. ok ( )
. url
. map ( Into ::into )
. filter ( | _ | is_image_post )
} else {
. or ( metadata . opengraph_data . image )
metadata . opengraph_data . image . clone ( )
{
} ;
Some ( url ) = > generate_pictrs_thumbnail ( & url , & context ) . await . ok ( ) ,
None = > None ,
let form = PostUpdateForm {
}
embed_title : Some ( metadata . opengraph_data . title ) ,
}
embed_description : Some ( metadata . opengraph_data . description ) ,
// Otherwise use opengraph preview image directly
embed_video_url : Some ( metadata . opengraph_data . embed_video_url ) ,
else {
thumbnail_url : Some ( thumbnail_url ) ,
metadata . opengraph_data . image . map ( Into ::into )
url_content_type : Some ( metadata . content_type ) ,
} ;
.. Default ::default ( )
} ;
// Proxy the image fetch if necessary
let updated_post = Post ::update ( & mut context . pool ( ) , post . id , & form ) . await ? ;
let proxied_thumbnail_url = proxy_image_link_opt_apub ( thumbnail_url , & context ) . await ? ;
if let Some ( send_activity ) = send_activity ( updated_post ) {
ActivityChannel ::submit_activity ( send_activity , & context ) . await ? ;
let form = PostUpdateForm {
}
embed_title : Some ( metadata . opengraph_data . title ) ,
Ok ( ( ) )
embed_description : Some ( metadata . opengraph_data . description ) ,
embed_video_url : Some ( metadata . opengraph_data . embed_video_url ) ,
thumbnail_url : Some ( proxied_thumbnail_url ) ,
url_content_type : Some ( metadata . content_type ) ,
.. Default ::default ( )
} ;
let updated_post = Post ::update ( & mut context . pool ( ) , post . id , & form ) . await ? ;
if let Some ( send_activity ) = send_activity ( updated_post ) {
ActivityChannel ::submit_activity ( send_activity , & context ) . await ? ;
}
Ok ( ( ) )
} ) ;
}
}
/// Extract site metadata from HTML Opengraph attributes.
/// Extract site metadata from HTML Opengraph attributes.
fn extract_opengraph_data ( html_bytes : & [ u8 ] , url : & Url ) -> LemmyResult < OpenGraphData > {
fn extract_opengraph_data ( html_bytes : & [ u8 ] , url : & Url ) -> LemmyResult < OpenGraphData > {
let html = String ::from_utf8_lossy ( html_bytes ) ;
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 ) ? ;
let mut page = HTML ::from_string ( html . to_string ( ) , None ) ? ;
// If the web page specifies that it isn't actually UTF-8, re-decode the received bytes with the
// If the web page specifies that it isn't actually UTF-8, re-decode the received bytes with the
@ -201,19 +176,40 @@ fn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult<OpenGraph
} )
} )
}
}
#[ derive(Deserialize, Debug) ]
#[ derive(Deserialize, Serialize, Debug) ]
struct PictrsResponse {
pub struct PictrsResponse {
files : Vec < PictrsFile > ,
pub files : Option < Vec < PictrsFile > > ,
msg : String ,
pub msg : String ,
}
#[ derive(Deserialize, Serialize, Debug) ]
pub struct PictrsFile {
pub file : String ,
pub delete_token : String ,
pub details : PictrsFileDetails ,
}
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, Debug) ]
/// Stores extra details about a Pictrs image.
struct PictrsFile {
#[ derive(Deserialize, Serialize, Debug) ]
file : String ,
pub struct PictrsFileDetails {
delete_token : String ,
/// In pixels
pub width : u16 ,
/// In pixels
pub height : u16 ,
pub content_type : String ,
pub created_at : DateTime < Utc > ,
}
}
#[ derive(Deserialize, Debug) ]
#[ derive(Deserialize, Serialize, Debug)]
struct PictrsPurgeResponse {
struct PictrsPurgeResponse {
msg : String ,
msg : String ,
}
}
@ -295,33 +291,34 @@ async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> L
encode ( image_url . as_str ( ) )
encode ( image_url . as_str ( ) )
) ;
) ;
let res ponse = context
let res = context
. client ( )
. client ( )
. get ( & fetch_url )
. get ( & fetch_url )
. timeout ( REQWEST_TIMEOUT )
. timeout ( REQWEST_TIMEOUT )
. send ( )
. send ( )
. await ?
. json ::< PictrsResponse > ( )
. await ? ;
. await ? ;
let response : PictrsResponse = response . json ( ) . await ? ;
let files = res . files . unwrap_or_default ( ) ;
if response . msg = = "ok" {
let image = files
let thumbnail_url = Url ::parse ( & format! (
. first ( )
"{}/pictrs/image/{}" ,
. ok_or ( LemmyErrorType ::PictrsResponseError ( res . msg ) ) ? ;
context . settings ( ) . get_protocol_and_hostname ( ) ,
response . files . first ( ) . expect ( "missing pictrs file" ) . file
let form = LocalImageForm {
) ) ? ;
// This is none because its an internal request.
for uploaded_image in response . files {
// IE, a local user shouldn't get to delete the thumbnails for their link posts
let form = LocalImageForm {
local_user_id : None ,
local_user_id : None ,
pictrs_alias : image . file . clone ( ) ,
pictrs_alias : uploaded_image . file . to_string ( ) ,
pictrs_delete_token : image . delete_token . clone ( ) ,
pictrs_delete_token : uploaded_image . delete_token . to_string ( ) ,
} ;
} ;
let protocol_and_hostname = context . settings ( ) . get_protocol_and_hostname ( ) ;
LocalImage ::create ( & mut context . pool ( ) , & form ) . await ? ;
let thumbnail_url = image . thumbnail_url ( & protocol_and_hostname ) ? ;
}
Ok ( thumbnail_url )
LocalImage ::create ( & mut context . pool ( ) , & form ) . await ? ;
} else {
Err ( LemmyErrorType ::PictrsResponseError ( response . msg ) ) ?
Ok ( thumbnail_url )
}
}
}
// TODO: get rid of this by reading content type from db
// TODO: get rid of this by reading content type from db