~vpzom/hitide

2d73518bc8b89b1e5f2cd638a9a3e2fd13785f92 — Colin Reeder 6 months ago 95a92e8
Support comment image attachments
5 files changed, 291 insertions(+), 16 deletions(-)

M res/lang/en.ftl
M src/components/mod.rs
M src/resp_types.rs
M src/routes/mod.rs
M src/routes/posts.rs
M res/lang/en.ftl => res/lang/en.ftl +3 -0
@@ 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

M src/components/mod.rs => src/components/mod.rs +13 -0
@@ 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() {

M src/resp_types.rs => src/resp_types.rs +7 -0
@@ 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>,

M src/routes/mod.rs => src/routes/mod.rs +143 -9
@@ 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
        }

M src/routes/posts.rs => src/routes/posts.rs +125 -7
@@ 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
        }