From 12c2078c8971b59b01862c6e4bb922602f2b7ccf Mon Sep 17 00:00:00 2001 From: fdb-hiroshima <35889323+fdb-hiroshima@users.noreply.github.com> Date: Sat, 6 Apr 2019 19:20:33 +0200 Subject: [PATCH] Hide cw pictures behind a summary/details (#483) * Hide cw pictures behind a summary/details * refactor md_to_html a bit and add cw support * use random id for cw checkbox --- plume-common/src/utils.rs | 146 +++++++++++++++++++++++--------- plume-models/src/comments.rs | 7 +- plume-models/src/instance.rs | 15 +++- plume-models/src/medias.rs | 18 +++- plume-models/src/posts.rs | 18 ++-- plume-models/src/safe_string.rs | 20 ++++- plume-models/src/users.rs | 10 ++- src/routes/blogs.rs | 16 +++- src/routes/comments.rs | 5 +- src/routes/posts.rs | 14 +++ static/css/_article.scss | 33 ++++++++ 11 files changed, 239 insertions(+), 63 deletions(-) diff --git a/plume-common/src/utils.rs b/plume-common/src/utils.rs index ac37c92a..0abb8adc 100644 --- a/plume-common/src/utils.rs +++ b/plume-common/src/utils.rs @@ -56,53 +56,117 @@ fn to_inline(tag: Tag) -> Tag { } } +fn flatten_text<'a>(state: &mut Option, evt: Event<'a>) -> Option>> { + let (s, res) = match evt { + Event::Text(txt) => match state.take() { + Some(mut prev_txt) => { + prev_txt.push_str(&txt); + (Some(prev_txt), vec![]) + } + None => (Some(txt.into_owned()), vec![]), + }, + e => match state.take() { + Some(prev) => (None, vec![Event::Text(Cow::Owned(prev)), e]), + None => (None, vec![e]), + }, + }; + *state = s; + Some(res) +} + +fn inline_tags<'a>( + (state, inline): &mut (Vec>, bool), + evt: Event<'a>, +) -> Option> { + if *inline { + let new_evt = match evt { + Event::Start(t) => { + let tag = to_inline(t); + state.push(tag.clone()); + Event::Start(tag) + } + Event::End(t) => match state.pop() { + Some(other) => Event::End(other), + None => Event::End(t), + }, + e => e, + }; + Some(new_evt) + } else { + Some(evt) + } +} + +pub type MediaProcessor<'a> = Box<'a + Fn(i32) -> Option<(String, Option)>>; + +fn process_image<'a, 'b>( + evt: Event<'a>, + inline: bool, + processor: &Option>, +) -> Event<'a> { + if let Some(ref processor) = *processor { + match evt { + Event::Start(Tag::Image(id, title)) => { + if let Some((url, cw)) = id.parse::().ok().and_then(processor.as_ref()) { + if inline || cw.is_none() { + Event::Start(Tag::Image(Cow::Owned(url), title)) + } else { + // there is a cw, and where are not inline + Event::Html(Cow::Owned(format!( + r#""#, + )) + } + } else { + Event::End(Tag::Image(id, title)) + } + } + e => e, + } + } else { + evt + } +} + /// Returns (HTML, mentions, hashtags) -pub fn md_to_html( +pub fn md_to_html<'a>( md: &str, base_url: &str, inline: bool, + media_processor: Option>, ) -> (String, HashSet, HashSet) { let parser = Parser::new_ext(md, Options::all()); let (parser, mentions, hashtags): (Vec, Vec, Vec) = parser - .scan(None, |state: &mut Option, evt| { - let (s, res) = match evt { - Event::Text(txt) => match state.take() { - Some(mut prev_txt) => { - prev_txt.push_str(&txt); - (Some(prev_txt), vec![]) - } - None => (Some(txt.into_owned()), vec![]), - }, - e => match state.take() { - Some(prev) => (None, vec![Event::Text(Cow::Owned(prev)), e]), - None => (None, vec![e]), - }, - }; - *state = s; - Some(res) - }) + // Flatten text because pulldown_cmark break #hashtag in two individual text elements + .scan(None, flatten_text) .flat_map(IntoIterator::into_iter) + .map(|evt| process_image(evt, inline, &media_processor)) // Ignore headings, images, and tables if inline = true - .scan(vec![], |state: &mut Vec, evt| { - if inline { - let new_evt = match evt { - Event::Start(t) => { - let tag = to_inline(t); - state.push(tag.clone()); - Event::Start(tag) - } - Event::End(t) => match state.pop() { - Some(other) => Event::End(other), - None => Event::End(t), - }, - e => e, - }; - Some(new_evt) - } else { - Some(evt) - } - }) + .scan((vec![], inline), inline_tags) .map(|evt| match evt { Event::Text(txt) => { let (evts, _, _, _, new_mentions, new_hashtags) = txt.chars().fold( @@ -273,7 +337,7 @@ mod tests { for (md, mentions) in tests { assert_eq!( - md_to_html(md, "", false).1, + md_to_html(md, "", false, None).1, mentions .into_iter() .map(|s| s.to_string()) @@ -298,7 +362,7 @@ mod tests { for (md, mentions) in tests { assert_eq!( - md_to_html(md, "", false).2, + md_to_html(md, "", false, None).2, mentions .into_iter() .map(|s| s.to_string()) @@ -310,11 +374,11 @@ mod tests { #[test] fn test_inline() { assert_eq!( - md_to_html("# Hello", "", false).0, + md_to_html("# Hello", "", false, None).0, String::from("

Hello

\n") ); assert_eq!( - md_to_html("# Hello", "", true).0, + md_to_html("# Hello", "", true, None).0, String::from("

Hello

\n") ); } diff --git a/plume-models/src/comments.rs b/plume-models/src/comments.rs index 8e646540..e9d5d89f 100644 --- a/plume-models/src/comments.rs +++ b/plume-models/src/comments.rs @@ -11,6 +11,7 @@ use std::collections::HashSet; use comment_seers::{CommentSeers, NewCommentSeers}; use instance::Instance; +use medias::Media; use mentions::Mention; use notifications::*; use plume_common::activity_pub::{ @@ -102,14 +103,16 @@ impl Comment { .unwrap_or(false) } - pub fn to_activity(&self, conn: &Connection) -> Result { + pub fn to_activity<'b>(&self, conn: &'b Connection) -> Result { + let author = User::get(conn, self.author_id)?; + let (html, mentions, _hashtags) = utils::md_to_html( self.content.get().as_ref(), &Instance::get_local(conn)?.public_domain, true, + Some(Media::get_media_processor(conn, vec![&author])), ); - let author = User::get(conn, self.author_id)?; let mut note = Note::default(); let to = vec![Id::new(PUBLIC_VISIBILTY.to_string())]; diff --git a/plume-models/src/instance.rs b/plume-models/src/instance.rs index c3758082..d7c527e5 100644 --- a/plume-models/src/instance.rs +++ b/plume-models/src/instance.rs @@ -3,6 +3,7 @@ use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl}; use std::iter::Iterator; use ap_url; +use medias::Media; use plume_common::utils::md_to_html; use safe_string::SafeString; use schema::{instances, users}; @@ -128,8 +129,18 @@ impl Instance { short_description: SafeString, long_description: SafeString, ) -> Result<()> { - let (sd, _, _) = md_to_html(short_description.as_ref(), &self.public_domain, true); - let (ld, _, _) = md_to_html(long_description.as_ref(), &self.public_domain, false); + let (sd, _, _) = md_to_html( + short_description.as_ref(), + &self.public_domain, + true, + Some(Media::get_media_processor(conn, vec![])), + ); + let (ld, _, _) = md_to_html( + long_description.as_ref(), + &self.public_domain, + false, + Some(Media::get_media_processor(conn, vec![])), + ); diesel::update(self) .set(( instances::name.eq(name), diff --git a/plume-models/src/medias.rs b/plume-models/src/medias.rs index c1522469..a1c3897a 100644 --- a/plume-models/src/medias.rs +++ b/plume-models/src/medias.rs @@ -5,7 +5,7 @@ use guid_create::GUID; use reqwest; use std::{fs, path::Path}; -use plume_common::activity_pub::Id; +use plume_common::{activity_pub::Id, utils::MediaProcessor}; use instance::Instance; use safe_string::SafeString; @@ -124,10 +124,9 @@ impl Media { } pub fn markdown(&self, conn: &Connection) -> Result { - let url = self.url(conn)?; Ok(match self.category() { MediaCategory::Image => { - SafeString::new(&format!("![{}]({})", escape(&self.alt_text), url)) + SafeString::new(&format!("![{}]({})", escape(&self.alt_text), self.id)) } MediaCategory::Audio | MediaCategory::Video => self.html(conn)?, MediaCategory::Unknown => SafeString::new(""), @@ -225,6 +224,19 @@ impl Media { }, ) } + + pub fn get_media_processor<'a>(conn: &'a Connection, user: Vec<&User>) -> MediaProcessor<'a> { + let uid = user.iter().map(|u| u.id).collect::>(); + Box::new(move |id| { + let media = Media::get(conn, id).ok()?; + // if owner is user or check is disabled + if uid.contains(&media.owner_id) || uid.is_empty() { + Some((media.url(conn).ok()?, media.content_warning)) + } else { + None + } + }) + } } #[cfg(test)] diff --git a/plume-models/src/posts.rs b/plume-models/src/posts.rs index 1a42a208..098f501c 100644 --- a/plume-models/src/posts.rs +++ b/plume-models/src/posts.rs @@ -207,17 +207,19 @@ impl<'a> Provider<(&'a Connection, &'a Worker, &'a Searcher, Option)> for P let domain = &Instance::get_local(&conn) .map_err(|_| ApiError::NotFound("posts::update: Error getting local instance".into()))? .public_domain; - let (content, mentions, hashtags) = md_to_html( - query.source.clone().unwrap_or_default().clone().as_ref(), - domain, - false, - ); - let author = User::get( conn, user_id.expect("::create: no user_id error"), ) .map_err(|_| ApiError::NotFound("Author not found".into()))?; + + let (content, mentions, hashtags) = md_to_html( + query.source.clone().unwrap_or_default().clone().as_ref(), + domain, + false, + Some(Media::get_media_processor(conn, vec![&author])), + ); + let blog = match query.blog_id { Some(x) => x, None => { @@ -757,7 +759,7 @@ impl Post { post.license = license; } - let mut txt_hashtags = md_to_html(&post.source, "", false) + let mut txt_hashtags = md_to_html(&post.source, "", false, None) .2 .into_iter() .map(|s| s.to_camel_case()) @@ -995,7 +997,7 @@ impl<'a> FromActivity for Post } // save mentions and tags - let mut hashtags = md_to_html(&post.source, "", false) + let mut hashtags = md_to_html(&post.source, "", false, None) .2 .into_iter() .map(|s| s.to_camel_case()) diff --git a/plume-models/src/safe_string.rs b/plume-models/src/safe_string.rs index 6527b86b..7aca88d9 100644 --- a/plume-models/src/safe_string.rs +++ b/plume-models/src/safe_string.rs @@ -19,7 +19,7 @@ lazy_static! { static ref CLEAN: Builder<'static> = { let mut b = Builder::new(); b.add_generic_attributes(iter::once("id")) - .add_tags(&["iframe", "video", "audio"]) + .add_tags(&["iframe", "video", "audio", "label", "input"]) .id_prefix(Some("postcontent-")) .url_relative(UrlRelative::Custom(Box::new(url_add_prefix))) .add_tag_attributes( @@ -27,7 +27,23 @@ lazy_static! { ["width", "height", "src", "frameborder"].iter().cloned(), ) .add_tag_attributes("video", ["src", "title", "controls"].iter()) - .add_tag_attributes("audio", ["src", "title", "controls"].iter()); + .add_tag_attributes("audio", ["src", "title", "controls"].iter()) + .add_tag_attributes("label", ["for"].iter()) + .add_tag_attributes("input", ["type", "checked"].iter()) + .add_allowed_classes("input", ["cw-checkbox"].iter()) + .add_allowed_classes("span", ["cw-container", "cw-text"].iter()) + .attribute_filter(|elem, att, val| match (elem, att) { + ("input", "type") => Some("checkbox".into()), + ("input", "checked") => Some("checked".into()), + ("label", "for") => { + if val.starts_with("postcontent-cw-") { + Some(val.into()) + } else { + None + } + } + _ => Some(val.into()), + }); b }; } diff --git a/plume-models/src/users.rs b/plume-models/src/users.rs index 1ee836f8..32d162ca 100644 --- a/plume-models/src/users.rs +++ b/plume-models/src/users.rs @@ -209,7 +209,13 @@ impl User { .set(( users::display_name.eq(name), users::email.eq(email), - users::summary_html.eq(utils::md_to_html(&summary, "", false).0), + users::summary_html.eq(utils::md_to_html( + &summary, + "", + false, + Some(Media::get_media_processor(conn, vec![self])), + ) + .0), users::summary.eq(summary), )) .execute(conn)?; @@ -868,7 +874,7 @@ impl NewUser { display_name, is_admin, summary: summary.to_owned(), - summary_html: SafeString::new(&utils::md_to_html(&summary, "", false).0), + summary_html: SafeString::new(&utils::md_to_html(&summary, "", false, None).0), email: Some(email), hashed_password: Some(password), instance_id: Instance::get_local(conn)?.id, diff --git a/src/routes/blogs.rs b/src/routes/blogs.rs index d421248d..588450ea 100644 --- a/src/routes/blogs.rs +++ b/src/routes/blogs.rs @@ -280,7 +280,21 @@ pub fn update( blog.title = form.title.clone(); blog.summary = form.summary.clone(); - blog.summary_html = SafeString::new(&utils::md_to_html(&form.summary, "", true).0); + blog.summary_html = SafeString::new( + &utils::md_to_html( + &form.summary, + "", + true, + Some(Media::get_media_processor( + &conn, + blog.list_authors(&conn) + .expect("Couldn't get list of authors") + .iter() + .collect(), + )), + ) + .0, + ); blog.icon_id = form.icon; blog.banner_id = form.banner; blog.save_changes::(&*conn) diff --git a/src/routes/comments.rs b/src/routes/comments.rs index d233468a..24e679b9 100644 --- a/src/routes/comments.rs +++ b/src/routes/comments.rs @@ -15,8 +15,8 @@ use plume_common::{ utils, }; use plume_models::{ - blogs::Blog, comments::*, db_conn::DbConn, instance::Instance, mentions::Mention, posts::Post, - safe_string::SafeString, tags::Tag, users::User, + blogs::Blog, comments::*, db_conn::DbConn, instance::Instance, medias::Media, + mentions::Mention, posts::Post, safe_string::SafeString, tags::Tag, users::User, }; use routes::errors::ErrorPage; use Worker; @@ -49,6 +49,7 @@ pub fn create( .expect("comments::create: local instance error") .public_domain, true, + Some(Media::get_media_processor(&conn, vec![&user])), ); let comm = Comment::insert( &*conn, diff --git a/src/routes/posts.rs b/src/routes/posts.rs index d4316779..c6264711 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -264,6 +264,13 @@ pub fn update( .expect("posts::update: Error getting local instance") .public_domain, false, + Some(Media::get_media_processor( + &conn, + b.list_authors(&conn) + .expect("Could not get author list") + .iter() + .collect(), + )), ); // update publication date if when this article is no longer a draft @@ -424,6 +431,13 @@ pub fn create( .expect("post::create: local instance error") .public_domain, false, + Some(Media::get_media_processor( + &conn, + blog.list_authors(&conn) + .expect("Could not get author list") + .iter() + .collect(), + )), ); let searcher = rockets.searcher; diff --git a/static/css/_article.scss b/static/css/_article.scss index bcb253af..40028963 100644 --- a/static/css/_article.scss +++ b/static/css/_article.scss @@ -322,3 +322,36 @@ main .article-meta { right: 0px; bottom: 0px; } + + +// content warning +.cw-container { + position: relative; + display: inline-block; +} + +.cw-text { + display: none; +} + +input[type="checkbox"].cw-checkbox { + display: none; +} + +input:checked ~ .cw-container:before { + content: " "; + position: absolute; + height: 100%; + width: 100%; + background: rgba(0, 0, 0, 1); +} + +input:checked ~ .cw-container > .cw-text { + display: inline; + position: absolute; + color: white; + width: 100%; + text-align: center; + top: 50%; + transform: translateY(-50%); +}