diff options
author | Colin Reeder <vpzomtrrfrt@gmail.com> | 2020-10-22 17:43:59 -0600 |
---|---|---|
committer | Colin Reeder <vpzomtrrfrt@gmail.com> | 2020-10-22 17:43:59 -0600 |
commit | 2d73518bc8b89b1e5f2cd638a9a3e2fd13785f92 (patch) | |
tree | 6f6cd8517edfe71a38ce425dd908611c297eab58 | |
parent | 95a92e842e448446f353be1ae375e79d50ac9d59 (diff) |
Support comment image attachments
-rw-r--r-- | res/lang/en.ftl | 3 | ||||
-rw-r--r-- | src/components/mod.rs | 13 | ||||
-rw-r--r-- | src/resp_types.rs | 7 | ||||
-rw-r--r-- | src/routes/mod.rs | 152 | ||||
-rw-r--r-- | src/routes/posts.rs | 132 |
5 files changed, 291 insertions, 16 deletions
diff --git a/res/lang/en.ftl b/res/lang/en.ftl index ae90509..52c6764 100644 --- a/res/lang/en.ftl +++ b/res/lang/en.ftl @@ -12,8 +12,11 @@ all_title = The Whole Known Network and_more = …and more by = by comment = Comment +comments = Comments +comment_attachment_prefix = Attachment: comment_delete_title = Delete Comment comment_delete_question = Delete this comment? +comment_reply_image_prompt = Attach Image (optional): comment_submit = Post Comment communities = Communities community_add_moderator = Add Moderator diff --git a/src/components/mod.rs b/src/components/mod.rs index 9f7db1f..f045023 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -53,6 +53,19 @@ pub fn Comment<'a>( <TimeAgo since={chrono::DateTime::parse_from_rfc3339(&comment.created).unwrap()} lang /> </small> <Content src={comment} /> + { + comment.attachments.iter().map(|attachment| { + let href = &attachment.url; + render::rsx! { + <div> + <strong>{lang.tr("comment_attachment_prefix", None)}</strong> + {" "} + <em><a href={href.as_ref()}>{abbreviate_link(&href)}{" ↗"}</a></em> + </div> + } + }) + .collect::<Vec<_>>() + } <div class={"actionList"}> { if base_data.login.is_some() { diff --git a/src/resp_types.rs b/src/resp_types.rs index c8e715e..d10dfc7 100644 --- a/src/resp_types.rs +++ b/src/resp_types.rs @@ -68,10 +68,17 @@ impl<'a> AsRef<RespMinimalCommentInfo<'a>> for RespThingComment<'a> { } #[derive(Deserialize, Debug)] +pub struct JustURL<'a> { + pub url: Cow<'a, str>, +} + +#[derive(Deserialize, Debug)] pub struct RespPostCommentInfo<'a> { #[serde(flatten)] pub base: RespMinimalCommentInfo<'a>, + pub attachments: Vec<JustURL<'a>>, + #[serde(borrow)] pub author: Option<RespMinimalAuthorInfo<'a>>, pub created: Cow<'a, str>, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 5621345..aab5813 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -8,10 +8,10 @@ use crate::components::{ PostItem, ThingItem, UserLink, }; use crate::resp_types::{ - RespCommentInfo, RespInstanceInfo, RespNotification, RespPostCommentInfo, RespPostListPost, - RespThingInfo, RespUserInfo, + JustStringID, RespCommentInfo, RespInstanceInfo, RespNotification, RespPostCommentInfo, + RespPostListPost, RespThingInfo, RespUserInfo, }; -use crate::util::author_is_me; +use crate::util::{abbreviate_link, author_is_me}; use crate::PageBaseData; mod communities; @@ -202,7 +202,7 @@ async fn page_comment_inner( cookies: &CookieMap<'_>, ctx: Arc<crate::RouteContext>, display_error: Option<String>, - prev_values: Option<&serde_json::Value>, + prev_values: Option<&HashMap<Cow<'_, str>, serde_json::Value>>, ) -> Result<hyper::Response<hyper::Body>, crate::Error> { let lang = crate::get_lang_for_headers(headers); let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, headers, &cookies).await?; @@ -284,6 +284,19 @@ async fn page_comment_inner( } <small><cite><UserLink user={comment.as_ref().author.as_ref()} /></cite>{":"}</small> <Content src={&comment} /> + { + comment.as_ref().attachments.iter().map(|attachment| { + let href = &attachment.url; + render::rsx! { + <div> + <strong>{lang.tr("comment_attachment_prefix", None)}</strong> + {" "} + <em><a href={href.as_ref()}>{abbreviate_link(&href)}{" ↗"}</a></em> + </div> + } + }) + .collect::<Vec<_>>() + } </p> <div class={"actionList"}> { @@ -306,10 +319,17 @@ async fn page_comment_inner( { if base_data.login.is_some() { Some(render::rsx! { - <form method={"POST"} action={format!("/comments/{}/submit_reply", comment.as_ref().as_ref().id)}> + <form method={"POST"} action={format!("/comments/{}/submit_reply", comment.as_ref().as_ref().id)} enctype={"multipart/form-data"}> <div> <MaybeFillTextArea values={&prev_values} name={"content_markdown"} default_value={None} /> </div> + <div> + <label> + {lang.tr("comment_reply_image_prompt", None)} + {" "} + <input type={"file"} accept={"image/*"} name={"attachment_media"} /> + </label> + </div> <button r#type={"submit"}>{lang.tr("reply_submit", None)}</button> </form> }) @@ -554,10 +574,124 @@ async fn handler_comment_submit_reply( let (req_parts, body) = req.into_parts(); + let lang = crate::get_lang_for_headers(&req_parts.headers); let cookies = get_cookie_map_for_headers(&req_parts.headers)?; - let body = hyper::body::to_bytes(body).await?; - let body: serde_json::Value = serde_urlencoded::from_bytes(&body)?; + let content_type = req_parts + .headers + .get(hyper::header::CONTENT_TYPE) + .ok_or_else(|| { + crate::Error::InternalStr("missing content-type header in form submission".to_owned()) + })?; + let content_type = std::str::from_utf8(content_type.as_ref())?; + let boundary = multer::parse_boundary(&content_type)?; + + let mut multipart = multer::Multipart::new(body, boundary); + + let mut body_values: HashMap<Cow<'_, str>, serde_json::Value> = HashMap::new(); + + { + let mut error = None; + + loop { + let field = multipart.next_field().await?; + let field = match field { + None => break, + Some(field) => field, + }; + + if field.name().is_none() { + continue; + } + + if field.name().unwrap() == "attachment_media" { + use futures_util::StreamExt; + let mut stream = field.peekable(); + + let first_chunk = std::pin::Pin::new(&mut stream).peek().await; + let is_empty = match first_chunk { + None => true, + Some(Ok(chunk)) => chunk.is_empty(), + Some(Err(err)) => { + return Err(crate::Error::InternalStr(format!( + "failed parsing form: {:?}", + err + ))); + } + }; + if is_empty { + continue; + } + + match stream.get_ref().content_type() { + None => { + error = Some( + lang.tr("comment_reply_attachment_missing_content_type", None) + .into_owned(), + ); + } + Some(mime) => { + let res = res_to_error( + ctx.http_client + .request(for_client( + hyper::Request::post(format!( + "{}/api/unstable/media", + ctx.backend_host, + )) + .header(hyper::header::CONTENT_TYPE, mime.as_ref()) + .body(hyper::Body::wrap_stream(stream))?, + &req_parts.headers, + &cookies, + )?) + .await?, + ) + .await; + + match res { + Err(crate::Error::RemoteError((_, message))) => { + error = Some(message); + } + Err(other) => { + return Err(other); + } + Ok(res) => { + let res = hyper::body::to_bytes(res.into_body()).await?; + let res: JustStringID = serde_json::from_slice(&res)?; + + body_values.insert( + "attachment".into(), + format!("local-media://{}", res.id).into(), + ); + } + } + + println!("finished media upload"); + } + } + } else { + let name = field.name().unwrap(); + if name == "href" && body_values.contains_key("href") && body_values["href"] != "" { + error = Some(lang.tr("post_new_href_conflict", None).into_owned()); + } else { + let name = name.to_owned(); + let value = field.text().await?; + body_values.insert(name.into(), value.into()); + } + } + } + + if let Some(error) = error { + return page_comment_inner( + comment_id, + &req_parts.headers, + &cookies, + ctx, + Some(error), + Some(&body_values), + ) + .await; + } + } let api_res = res_to_error( ctx.http_client @@ -566,7 +700,7 @@ async fn handler_comment_submit_reply( "{}/api/unstable/comments/{}/replies", ctx.backend_host, comment_id )) - .body(serde_json::to_vec(&body)?.into())?, + .body(serde_json::to_vec(&body_values)?.into())?, &req_parts.headers, &cookies, )?) @@ -586,7 +720,7 @@ async fn handler_comment_submit_reply( &cookies, ctx, Some(message), - Some(&body), + Some(&body_values), ) .await } diff --git a/src/routes/posts.rs b/src/routes/posts.rs index c2d9f3b..356a7be 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -1,3 +1,4 @@ +use super::JustStringID; use super::{ fetch_base_data, for_client, get_cookie_map_for_headers, get_cookie_map_for_req, html_response, res_to_error, CookieMap, @@ -7,6 +8,7 @@ use crate::components::{ }; use crate::resp_types::{JustUser, RespCommunityInfoMaybeYour, RespList, RespPostInfo}; use crate::util::author_is_me; +use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; @@ -28,7 +30,7 @@ async fn page_post_inner( cookies: &CookieMap<'_>, ctx: Arc<crate::RouteContext>, display_error: Option<String>, - prev_values: Option<&HashMap<&str, serde_json::Value>>, + prev_values: Option<&HashMap<Cow<'_, str>, serde_json::Value>>, ) -> Result<hyper::Response<hyper::Body>, crate::Error> { let lang = crate::get_lang_for_headers(headers); @@ -172,7 +174,7 @@ async fn page_post_inner( } } <div> - <h2>{"Comments"}</h2> + <h2>{lang.tr("comments", None)}</h2> { display_error.map(|msg| { render::rsx! { @@ -183,10 +185,17 @@ async fn page_post_inner( { if base_data.login.is_some() { Some(render::rsx! { - <form method={"POST"} action={format!("/posts/{}/submit_reply", post.as_ref().as_ref().id)}> + <form method={"POST"} action={format!("/posts/{}/submit_reply", post.as_ref().as_ref().id)} enctype={"multipart/form-data"}> <div> <MaybeFillTextArea name={"content_markdown"} values={&prev_values} default_value={None} /> </div> + <div> + <label> + {lang.tr("comment_reply_image_prompt", None)} + {" "} + <input type={"file"} accept={"image/*"} name={"attachment_media"} /> + </label> + </div> <button r#type={"submit"}>{lang.tr("comment_submit", None)}</button> </form> }) @@ -419,10 +428,119 @@ async fn handler_post_submit_reply( let (post_id,) = params; let (req_parts, body) = req.into_parts(); + let lang = crate::get_lang_for_headers(&req_parts.headers); let cookies = get_cookie_map_for_headers(&req_parts.headers)?; - let body = hyper::body::to_bytes(body).await?; - let body: HashMap<&str, serde_json::Value> = serde_urlencoded::from_bytes(&body)?; + let content_type = req_parts + .headers + .get(hyper::header::CONTENT_TYPE) + .ok_or_else(|| { + crate::Error::InternalStr("missing content-type header in form submission".to_owned()) + })?; + let content_type = std::str::from_utf8(content_type.as_ref())?; + let boundary = multer::parse_boundary(&content_type)?; + + let mut multipart = multer::Multipart::new(body, boundary); + + let mut body_values: HashMap<Cow<'_, str>, serde_json::Value> = HashMap::new(); + + { + let mut error = None; + + loop { + let field = multipart.next_field().await?; + let field = match field { + None => break, + Some(field) => field, + }; + + if field.name().is_none() { + continue; + } + + if field.name().unwrap() == "attachment_media" { + use futures_util::StreamExt; + let mut stream = field.peekable(); + + let first_chunk = std::pin::Pin::new(&mut stream).peek().await; + let is_empty = match first_chunk { + None => true, + Some(Ok(chunk)) => chunk.is_empty(), + Some(Err(err)) => { + return Err(crate::Error::InternalStr(format!( + "failed parsing form: {:?}", + err + ))); + } + }; + if is_empty { + continue; + } + + match stream.get_ref().content_type() { + None => { + error = Some( + lang.tr("comment_reply_attachment_missing_content_type", None) + .into_owned(), + ); + } + Some(mime) => { + let res = res_to_error( + ctx.http_client + .request(for_client( + hyper::Request::post(format!( + "{}/api/unstable/media", + ctx.backend_host, + )) + .header(hyper::header::CONTENT_TYPE, mime.as_ref()) + .body(hyper::Body::wrap_stream(stream))?, + &req_parts.headers, + &cookies, + )?) + .await?, + ) + .await; + + match res { + Err(crate::Error::RemoteError((_, message))) => { + error = Some(message); + } + Err(other) => { + return Err(other); + } + Ok(res) => { + let res = hyper::body::to_bytes(res.into_body()).await?; + let res: JustStringID = serde_json::from_slice(&res)?; + + body_values.insert( + "attachment".into(), + format!("local-media://{}", res.id).into(), + ); + } + } + + println!("finished media upload"); + } + } + } else { + let name = field.name().unwrap().to_owned(); + let value = field.text().await?; + body_values.insert(name.into(), value.into()); + } + } + + if let Some(error) = error { + return page_post_inner( + post_id, + &req_parts.headers, + &cookies, + ctx, + Some(error), + Some(&body_values), + ) + .await; + } + } let api_res = res_to_error( ctx.http_client @@ -431,7 +549,7 @@ async fn handler_post_submit_reply( "{}/api/unstable/posts/{}/replies", ctx.backend_host, post_id )) - .body(serde_json::to_vec(&body)?.into())?, + .body(serde_json::to_vec(&body_values)?.into())?, &req_parts.headers, &cookies, )?) @@ -447,7 +565,7 @@ async fn handler_post_submit_reply( &cookies, ctx, Some(message), - Some(&body), + Some(&body_values), ) .await } |