~vpzom/lotide

a96082d8df9ebc153278cf60d26551abeccb5bc2 — Colin Reeder 4 months ago 4003b8a
Initial work on polls
A migrations/20220123180503_polls/down.sql => migrations/20220123180503_polls/down.sql +6 -0
@@ 0,0 1,6 @@
BEGIN;
	ALTER TABLE post DROP COLUMN poll_id;
	DROP TABLE poll_vote;
	DROP TABLE poll_option;
	DROP TABLE poll;
COMMIT;

A migrations/20220123180503_polls/up.sql => migrations/20220123180503_polls/up.sql +22 -0
@@ 0,0 1,22 @@
BEGIN;
	CREATE TABLE poll (
		id BIGSERIAL PRIMARY KEY,
		multiple BOOLEAN NOT NULL
	);
	CREATE TABLE poll_option (
		id BIGSERIAL PRIMARY KEY,
		poll_id BIGINT NOT NULL REFERENCES poll ON DELETE CASCADE,
		name TEXT NOT NULL,
		position INTEGER,
		remote_vote_count INTEGER,
		UNIQUE (poll_id, name)
	);
	CREATE TABLE poll_vote (
		poll_id BIGINT REFERENCES poll ON DELETE CASCADE,
		option_id BIGINT REFERENCES poll_option ON DELETE CASCADE,
		person BIGINT REFERENCES person ON DELETE CASCADE,
		PRIMARY KEY (poll_id, option_id, person)
	);

	ALTER TABLE post ADD COLUMN poll_id BIGINT REFERENCES poll;
COMMIT;

M res/lang/en.ftl => res/lang/en.ftl +2 -0
@@ 29,12 29,14 @@ not_admin = You are not a site admin
notification_title_post_reply = Reply to your post { $post_title }
notification_title_reply_reply = Reply to your comment on post { $post_title }
password_incorrect = Incorrect password
post_conflict_href_poll = Cannot specify both a link and a poll
post_content_conflict = content_markdown and content_text are mutually exclusive
post_href_invalid = Specified URL is not valid
post_needs_content = Post must contain one of href, content_text, or content_markdown
post_not_in_community = That post is not in this community
post_not_link = That post is not a link
post_not_yours = That's not your post
post_poll_empty = Cannot create a poll without options
root = lotide is running. Note that lotide itself does not include a frontend, and you'll need to install one separately.
sort_relevant_not_search = Sorting by relevance is only allowed when searching
user_email_invalid = Specified email address is invalid

M src/apub_util/ingest.rs => src/apub_util/ingest.rs +142 -12
@@ 1,6 1,7 @@
use super::{FollowLike, KnownObject, Verified};
use crate::types::{CommentLocalID, CommunityLocalID, PostLocalID, ThingLocalRef, UserLocalID};
use activitystreams::prelude::*;
use serde::Deserialize;
use std::borrow::Cow;
use std::future::Future;
use std::ops::Deref;


@@ 473,6 474,9 @@ pub async fn ingest_object(
            ingest_postlike(Verified(KnownObject::Page(obj)), found_from, ctx).await
        }
        KnownObject::Person(person) => ingest_personlike(Verified(person), false, ctx).await,
        KnownObject::Question(obj) => {
            ingest_postlike(Verified(KnownObject::Question(obj)), found_from, ctx).await
        }
        KnownObject::Remove(activity) => {
            let activity_id = activity
                .id_unchecked()


@@ 806,25 810,66 @@ pub async fn ingest_create(
    Ok(())
}

struct PollIngestInfo {
    multiple: bool,
    options: Vec<(String, Option<i32>)>,
}

/// Ingestion flow for Page, Image, Article, and Note. Should not be called with any other objects.
async fn ingest_postlike(
    obj: Verified<KnownObject>,
    found_from: FoundFrom,
    ctx: Arc<crate::RouteContext>,
) -> Result<Option<IngestResult>, crate::Error> {
    let (ext, to, in_reply_to, obj_id) = match obj.deref() {
        KnownObject::Page(obj) => (&obj.ext_one, obj.to(), None, obj.id_unchecked()),
        KnownObject::Image(obj) => (&obj.ext_one, obj.to(), None, obj.id_unchecked()),
        KnownObject::Article(obj) => (&obj.ext_one, obj.to(), None, obj.id_unchecked()),
    let (ext, to, in_reply_to, obj_id, poll_info) = match obj.deref() {
        KnownObject::Page(obj) => (Some(&obj.ext_one), obj.to(), None, obj.id_unchecked(), None),
        KnownObject::Image(obj) => (Some(&obj.ext_one), obj.to(), None, obj.id_unchecked(), None),
        KnownObject::Article(obj) => (Some(&obj.ext_one), obj.to(), None, obj.id_unchecked(), None),
        KnownObject::Note(obj) => (
            &obj.ext_one,
            Some(&obj.ext_one),
            obj.to(),
            obj.in_reply_to(),
            obj.id_unchecked(),
            None,
        ),
        KnownObject::Question(obj) => (
            None,
            obj.to(),
            obj.in_reply_to(),
            obj.id_unchecked(),
            Some({
                #[derive(Deserialize)]
                struct OptionObject {
                    name: String,
                    replies: Option<crate::apub_util::AnyCollection>,
                }

                let (multiple, options) = if let Some(any_of) = obj.any_of() {
                    (true, any_of)
                } else if let Some(one_of) = obj.one_of() {
                    (false, one_of)
                } else {
                    return Err(crate::Error::InternalStrStatic("Invalid poll"));
                };

                let options = options
                    .iter()
                    .map(|value| serde_json::from_value(serde_json::to_value(value)?))
                    .collect::<Result<Vec<OptionObject>, _>>()?;
                let options = options
                    .into_iter()
                    .map(|value| {
                        let remote_count = value.replies.and_then(|coll| coll.total_items());
                        (value.name, remote_count.map(|x| x as i32))
                    })
                    .collect();

                PollIngestInfo { multiple, options }
            }),
        ),
        _ => return Ok(None), // shouldn't happen?
    };
    let target = &ext.target;
    let target = ext.as_ref().and_then(|x| x.target.as_ref());

    let community_found = match target
        .as_ref()


@@ 873,6 918,7 @@ async fn ingest_postlike(
                community_local_id,
                community_is_local,
                found_from.as_announce(),
                poll_info,
                Verified(obj),
                ctx,
            )


@@ 882,6 928,7 @@ async fn ingest_postlike(
                community_local_id,
                community_is_local,
                found_from.as_announce(),
                poll_info,
                Verified(obj),
                ctx,
            )


@@ 891,6 938,7 @@ async fn ingest_postlike(
                community_local_id,
                community_is_local,
                found_from.as_announce(),
                poll_info,
                Verified(obj),
                ctx,
            )


@@ 992,6 1040,7 @@ async fn ingest_postlike(
                                community_local_id,
                                community_is_local,
                                found_from.as_announce(),
                                poll_info,
                                ctx,
                            )
                            .await?,


@@ 1339,6 1388,7 @@ async fn handle_received_page_for_community<Kind: Clone + std::fmt::Debug>(
    community_local_id: CommunityLocalID,
    community_is_local: bool,
    is_announce: Option<&url::Url>,
    poll_info: Option<PollIngestInfo>,
    obj: Verified<super::ExtendedPostlike<activitystreams::object::Object<Kind>>>,
    ctx: Arc<crate::RouteContext>,
) -> Result<Option<PostLocalID>, crate::Error> {


@@ 1386,6 1436,7 @@ async fn handle_received_page_for_community<Kind: Clone + std::fmt::Debug>(
                community_local_id,
                community_is_local,
                is_announce,
                poll_info,
                ctx,
            )
            .await?,


@@ 1406,9 1457,10 @@ async fn handle_recieved_post(
    community_local_id: CommunityLocalID,
    community_is_local: bool,
    is_announce: Option<&url::Url>,
    poll_info: Option<PollIngestInfo>,
    ctx: Arc<crate::RouteContext>,
) -> Result<PostLocalID, crate::Error> {
    let db = ctx.db_pool.get().await?;
    let mut db = ctx.db_pool.get().await?;
    let author = match author {
        Some(author) => Some(super::get_or_fetch_user_local_id(author, &db, &ctx).await?),
        None => None,


@@ 1423,12 1475,90 @@ async fn handle_recieved_post(

    let approved = is_announce.is_some() || community_is_local;

    let row = db.query_one(
        "INSERT INTO post (author, href, content_text, content_html, title, created, community, local, ap_id, approved, approved_ap_id) VALUES ($1, $2, $3, $4, $5, COALESCE($6, current_timestamp), $7, FALSE, $8, $9, $10) ON CONFLICT (ap_id) DO UPDATE SET approved=$9, approved_ap_id=$10 RETURNING id",
        &[&author, &href, &content_text, &content_html, &title, &created, &community_local_id, &object_id.as_str(), &approved, &is_announce.map(|x| x.as_str())],
    ).await?;
    let post_local_id = {
        let trans = db.transaction().await?;
        let row = trans.query_one(
            "INSERT INTO post (author, href, content_text, content_html, title, created, community, local, ap_id, approved, approved_ap_id) VALUES ($1, $2, $3, $4, $5, COALESCE($6, current_timestamp), $7, FALSE, $8, $9, $10) ON CONFLICT (ap_id) DO UPDATE SET approved=$9, approved_ap_id=$10 RETURNING id, poll_id",
            &[&author, &href, &content_text, &content_html, &title, &created, &community_local_id, &object_id.as_str(), &approved, &is_announce.map(|x| x.as_str())],
        ).await?;
        let post_local_id = PostLocalID(row.get(0));
        let existing_poll_id: Option<i64> = row.get(1);

        if let Some(poll_id) = existing_poll_id {
            if let Some(poll_info) = poll_info {
                let names: Vec<&str> = poll_info
                    .options
                    .iter()
                    .map(|(name, _)| name.deref())
                    .collect();
                let counts: Vec<Option<i32>> = poll_info
                    .options
                    .iter()
                    .map(|(_, count)| count.clone())
                    .collect();
                let indices: Vec<i32> = (0..(poll_info.options.len() as i32)).collect();

                trans
                    .execute(
                        "UPDATE poll SET multiple=$1 WHERE id=$2",
                        &[&poll_info.multiple, &poll_id],
                    )
                    .await?;
                trans
                    .execute(
                        "DELETE FROM poll_option WHERE poll_id=$1 AND NOT (name = ANY($1::TEXT[]))",
                        &[&names],
                    )
                    .await?;

                trans.execute("INSERT INTO poll_option (poll_id, name, position, remote_vote_count) SELECT $1, * FROM UNNEST($2::TEXT[], $3::INTEGER[], $4::INTEGER[]) ON CONFLICT (poll_id, name) DO UPDATE SET position = excluded.position, remote_vote_count = excluded.remote_vote_count", &[&poll_id, &names, &indices, &counts]).await?;
            } else {
                trans
                    .execute(
                        "UPDATE post SET poll_id=NULL WHERE id=$1",
                        &[&post_local_id],
                    )
                    .await?;
                trans
                    .execute("DELETE FROM poll WHERE id=$1", &[&poll_id])
                    .await?;
            }
        } else {
            if let Some(poll_info) = poll_info {
                let names: Vec<&str> = poll_info
                    .options
                    .iter()
                    .map(|(name, _)| name.deref())
                    .collect();
                let counts: Vec<Option<i32>> = poll_info
                    .options
                    .iter()
                    .map(|(_, count)| count.clone())
                    .collect();
                let indices: Vec<i32> = (0..(poll_info.options.len() as i32)).collect();

                let row = trans
                    .query_one(
                        "INSERT INTO poll (multiple) VALUES ($1) RETURNING id",
                        &[&poll_info.multiple],
                    )
                    .await?;
                let poll_id: i64 = row.get(0);

                trans.execute("INSERT INTO poll_option (poll_id, name, position, remote_vote_count) SELECT $1, * FROM UNNEST($2::TEXT[], $3::INTEGER[], $4::INTEGER[])", &[&poll_id, &names, &indices, &counts]).await?;
                trans
                    .execute(
                        "UPDATE post SET poll_id=$1 WHERE id=$2",
                        &[&poll_id, &post_local_id],
                    )
                    .await?;
            }
        }

        trans.commit().await?;

    let post_local_id = PostLocalID(row.get(0));
        post_local_id
    };

    if community_is_local {
        crate::on_local_community_add_post(community_local_id, post_local_id, object_id, ctx);

M src/apub_util/mod.rs => src/apub_util/mod.rs +57 -3
@@ 90,6 90,7 @@ pub enum KnownObject {
    Image(ExtendedPostlike<activitystreams::object::Image>),
    Page(ExtendedPostlike<activitystreams::object::Page>),
    Note(ExtendedPostlike<activitystreams::object::Note>),
    Question(activitystreams::activity::Question),
}

#[derive(Deserialize)]


@@ 139,6 140,15 @@ pub enum AnyCollection {
    Ordered(activitystreams::collection::OrderedCollection),
}

impl AnyCollection {
    pub fn total_items(&self) -> Option<u64> {
        match self {
            AnyCollection::Unordered(coll) => coll.total_items(),
            AnyCollection::Ordered(coll) => coll.total_items(),
        }
    }
}

#[derive(Clone)]
pub enum FollowLike {
    Follow(activitystreams::activity::Follow),


@@ 1195,8 1205,52 @@ pub fn post_to_ap(
        Ok(())
    }

    match post.href {
        Some(href) => {
    match (post.poll.as_ref(), post.href) {
        (Some(poll), _) => {
            // theoretically href and poll are mutually exclusive

            let mut post_ap = activitystreams::activity::Question::new();

            let options: Vec<activitystreams::base::AnyBase> = poll
                .options
                .iter()
                .map(|option| {
                    let mut option_ap = activitystreams::object::Note::new();
                    option_ap.set_name(option.name);

                    let mut replies_ap = activitystreams::collection::UnorderedCollection::new();
                    replies_ap.set_total_items(option.votes);
                    option_ap.set_reply(replies_ap.into_any_base()?);

                    option_ap.into_any_base()
                })
                .collect::<Result<_, _>>()?;

            if poll.multiple {
                post_ap.set_many_any_ofs(options);
            } else {
                post_ap.set_many_one_ofs(options);
            }

            let mut post_ap = ExtendedPostlike::new(
                activitystreams::object::ApObject::new(post_ap),
                Default::default(),
            );

            apply_properties(
                &mut post_ap,
                post,
                community_ap_id,
                community_ap_outbox,
                community_ap_followers,
                &ctx,
            )?;

            Ok(activitystreams::base::AnyBase::from_arbitrary_json(
                post_ap,
            )?)
        }
        (None, Some(href)) => {
            if href.starts_with("local-media://") {
                let mut attachment = activitystreams::object::Image::new();
                attachment.set_url(ctx.process_href(href, post.id).into_owned());


@@ 1252,7 1306,7 @@ pub fn post_to_ap(
                )?)
            }
        }
        None => {
        (None, None) => {
            let mut post_ap = activitystreams::object::Note::new();

            post_ap.set_summary(post.title).set_name(post.title);

M src/main.rs => src/main.rs +47 -0
@@ 339,6 339,7 @@ pub struct PostInfo<'a> {
    created: &'a chrono::DateTime<chrono::FixedOffset>,
    #[allow(dead_code)]
    community: CommunityLocalID,
    poll: Option<Cow<'a, PollInfo<'a>>>,
}

pub struct PostInfoOwned {


@@ 351,6 352,7 @@ pub struct PostInfoOwned {
    title: String,
    created: chrono::DateTime<chrono::FixedOffset>,
    community: CommunityLocalID,
    poll: Option<PollInfoOwned>,
}

impl<'a> From<&'a PostInfoOwned> for PostInfo<'a> {


@@ 365,6 367,51 @@ impl<'a> From<&'a PostInfoOwned> for PostInfo<'a> {
            title: &src.title,
            created: &src.created,
            community: src.community,
            poll: src.poll.as_ref().map(|x| Cow::Owned(x.into())),
        }
    }
}

#[derive(Debug, Clone)]
pub struct PollInfo<'a> {
    multiple: bool,
    options: Cow<'a, [PollOption<'a>]>,
}

pub struct PollInfoOwned {
    multiple: bool,
    options: Vec<PollOptionOwned>,
}

impl<'a> From<&'a PollInfoOwned> for PollInfo<'a> {
    fn from(src: &'a PollInfoOwned) -> Self {
        PollInfo {
            multiple: src.multiple,
            options: src.options.iter().map(Into::into).collect(),
        }
    }
}

#[derive(Debug, Clone)]
pub struct PollOption<'a> {
    id: i64,
    name: &'a str,
    votes: u32,
}

#[derive(Clone)]
pub struct PollOptionOwned {
    id: i64,
    name: String,
    votes: u32,
}

impl<'a> From<&'a PollOptionOwned> for PollOption<'a> {
    fn from(src: &'a PollOptionOwned) -> Self {
        PollOption {
            id: src.id,
            name: &src.name,
            votes: src.votes,
        }
    }
}

M src/routes/api/posts.rs => src/routes/api/posts.rs +83 -7
@@ 782,19 782,26 @@ async fn route_unstable_posts_create(
    req: hyper::Request<hyper::Body>,
) -> Result<hyper::Response<hyper::Body>, crate::Error> {
    let lang = crate::get_lang_for_req(&req);
    let db = ctx.db_pool.get().await?;
    let mut db = ctx.db_pool.get().await?;

    let user = crate::require_login(&req, &db).await?;

    let body = hyper::body::to_bytes(req.into_body()).await?;

    #[derive(Deserialize)]
    struct PollCreateInfo {
        multiple: bool,
        options: Vec<String>,
    }

    #[derive(Deserialize)]
    struct PostsCreateBody {
        community: CommunityLocalID,
        href: Option<String>,
        content_markdown: Option<String>,
        content_text: Option<String>,
        title: String,
        poll: Option<PollCreateInfo>,
    }

    let body: PostsCreateBody = serde_json::from_slice(&body)?;


@@ 813,6 820,22 @@ async fn route_unstable_posts_create(
        )));
    }

    if body.href.is_some() && body.poll.is_some() {
        return Err(crate::Error::UserError(crate::simple_response(
            hyper::StatusCode::BAD_REQUEST,
            lang.tr(&lang::post_conflict_href_poll()).into_owned(),
        )));
    }

    if let Some(poll) = &body.poll {
        if poll.options.is_empty() {
            return Err(crate::Error::UserError(crate::simple_response(
                hyper::StatusCode::BAD_REQUEST,
                lang.tr(&lang::post_poll_empty()).into_owned(),
            )));
        }
    }

    if let Some(href) = &body.href {
        if url::Url::parse(href).is_err() {
            return Err(crate::Error::UserError(crate::simple_response(


@@ 852,13 875,65 @@ async fn route_unstable_posts_create(
    let community_local: bool = community_row.get(0);
    let already_approved = community_local;

    let res_row = db.query_one(
        "INSERT INTO post (author, href, title, created, community, local, content_text, content_markdown, content_html, approved) VALUES ($1, $2, $3, current_timestamp, $4, TRUE, $5, $6, $7, $8) RETURNING id, created",
        &[&user, &body.href, &body.title, &body.community, &content_text, &content_markdown, &content_html, &already_approved],
    ).await?;
    let (id, created, poll) = {
        let trans = db.transaction().await?;

    let id = PostLocalID(res_row.get(0));
    let created = res_row.get(1);
        let poll_data = if let Some(poll) = body.poll {
            Some({
                let row = trans
                    .query_one(
                        "INSERT INTO poll (multiple) VALUES ($1) RETURNING id",
                        &[&poll.multiple],
                    )
                    .await?;
                let poll_id: i64 = row.get(0);

                let indices: Vec<i32> = (0..(poll.options.len() as i32)).collect();
                let mut names: Vec<Option<String>> = poll.options.into_iter().map(Some).collect();

                let rows = trans.query("INSERT INTO poll_option (poll_id, name, position) SELECT $1, * FROM UNNEST($2::TEXT[], $3::INTEGER[]) RETURNING id, position", &[&poll_id, &names, &indices]).await?;

                assert_eq!(names.len(), rows.len());

                let mut options = vec![None; rows.len()];

                for row in rows {
                    let idx: i32 = row.get(1);
                    let idx = idx as usize;

                    options[idx] = Some(crate::PollOptionOwned {
                        id: row.get(0),
                        name: names[idx].take().unwrap(),
                        votes: 0,
                    });
                }

                (
                    crate::PollInfoOwned {
                        multiple: poll.multiple,
                        options: options.into_iter().map(Option::unwrap).collect(),
                    },
                    poll_id,
                )
            })
        } else {
            None
        };

        let poll_id = poll_data.as_ref().map(|(_, poll_id)| *poll_id);

        let res_row = trans.query_one(
            "INSERT INTO post (author, href, title, created, community, local, content_text, content_markdown, content_html, approved, poll_id) VALUES ($1, $2, $3, current_timestamp, $4, TRUE, $5, $6, $7, $8, $9) RETURNING id, created",
            &[&user, &body.href, &body.title, &body.community, &content_text, &content_markdown, &content_html, &already_approved, &poll_id],
        ).await?;

        let id = PostLocalID(res_row.get(0));
        let created = res_row.get(1);

        trans.commit().await?;

        (id, created, poll_data.map(|(info, _)| info))
    };

    let post = crate::PostInfoOwned {
        id,


@@ 870,6 945,7 @@ async fn route_unstable_posts_create(
        title: body.title,
        created,
        community: body.community,
        poll,
    };

    crate::spawn_task(async move {

M src/routes/apub/mod.rs => src/routes/apub/mod.rs +24 -1
@@ 298,7 298,7 @@ async fn handler_users_outbox_page_get(
        }
    };

    let sql: &str = &format!("(SELECT TRUE, post.id, post.href, post.title, post.created, post.content_text, post.content_markdown, post.content_html, community.id, community.local, community.ap_id, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, community.ap_outbox, community.ap_followers FROM post, community WHERE post.community = community.id AND post.author = $1 AND NOT post.deleted{}) UNION ALL (SELECT FALSE, reply.id, reply.content_text, reply.content_html, reply.created, parent_or_post_author.ap_id, reply.content_markdown, parent_reply.ap_id, post.id, post.local, post.ap_id, parent_reply.id, parent_reply.local, parent_or_post_author.id, parent_or_post_author.local, community.id, community.local, community.ap_id, reply.attachment_href, community.ap_outbox, community.ap_followers FROM reply INNER JOIN post ON (post.id = reply.post) INNER JOIN community ON (post.community = community.id) LEFT OUTER JOIN reply AS parent_reply ON (parent_reply.id = reply.parent) LEFT OUTER JOIN person AS parent_or_post_author ON (parent_or_post_author.id = COALESCE(parent_reply.author, post.author)) WHERE reply.author = $1 AND NOT reply.deleted{}) ORDER BY created DESC LIMIT $2", extra_conditions_posts, extra_conditions_comments);
    let sql: &str = &format!("(SELECT TRUE, post.id, post.href, post.title, post.created, post.content_text, post.content_markdown, post.content_html, community.id, community.local, community.ap_id, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, community.ap_outbox, community.ap_followers, poll.multiple, (SELECT array_agg(jsonb_build_array(id, name, (SELECT COUNT(*) FROM poll_vote WHERE poll_id = poll.id AND option_id = poll_option.id)) ORDER BY position ASC) FROM poll_option WHERE poll_id=poll.id) FROM post INNER JOIN community ON (post.community = community.id) LEFT OUTER JOIN poll ON (poll.id = post.poll_id) WHERE post.author = $1 AND NOT post.deleted{}) UNION ALL (SELECT FALSE, reply.id, reply.content_text, reply.content_html, reply.created, parent_or_post_author.ap_id, reply.content_markdown, parent_reply.ap_id, post.id, post.local, post.ap_id, parent_reply.id, parent_reply.local, parent_or_post_author.id, parent_or_post_author.local, community.id, community.local, community.ap_id, reply.attachment_href, community.ap_outbox, community.ap_followers, NULL, NULL FROM reply INNER JOIN post ON (post.id = reply.post) INNER JOIN community ON (post.community = community.id) LEFT OUTER JOIN reply AS parent_reply ON (parent_reply.id = reply.parent) LEFT OUTER JOIN person AS parent_or_post_author ON (parent_or_post_author.id = COALESCE(parent_reply.author, post.author)) WHERE reply.author = $1 AND NOT reply.deleted{}) ORDER BY created DESC LIMIT $2", extra_conditions_posts, extra_conditions_comments);

    let rows = db.query(sql, &values[..]).await?;



@@ 339,6 339,28 @@ async fn handler_users_outbox_page_get(
                        .transpose()?
                };

                let poll = if let Some(multiple) = row.get(21) {
                    Some({
                        let options: Vec<_> = row
                            .get::<_, Vec<postgres_types::Json<(i64, &str, i64)>>>(22)
                            .into_iter()
                            .map(|x| x.0)
                            .map(|(id, name, votes): (i64, &str, i64)| crate::PollOption {
                                id,
                                name,
                                votes: votes as u32,
                            })
                            .collect();

                        Cow::Owned(crate::PollInfo {
                            multiple,
                            options: Cow::Owned(options),
                        })
                    })
                } else {
                    None
                };

                let post_info = crate::PostInfo {
                    id: PostLocalID(row.get(1)),
                    author: Some(user),


@@ 349,6 371,7 @@ async fn handler_users_outbox_page_get(
                    title: row.get(3),
                    created: &created,
                    community: community_id,
                    poll,
                };

                let res = crate::apub_util::local_post_to_create_ap(

M src/routes/apub/posts.rs => src/routes/apub/posts.rs +51 -2
@@ 1,5 1,6 @@
use crate::{CommunityLocalID, PostLocalID, UserLocalID};
use activitystreams::prelude::*;
use std::borrow::Cow;
use std::sync::Arc;

pub fn route_posts() -> crate::RouteNode<()> {


@@ 37,7 38,7 @@ async fn handler_posts_get(

    match db
        .query_opt(
            "SELECT post.author, post.href, post.title, post.created, post.community, post.local, post.deleted, post.had_href, post.content_text, post.content_markdown, post.content_html, community.ap_id, community.ap_outbox, community.local, community.ap_followers FROM post, community WHERE post.id=$1 AND post.community = community.id",
            "SELECT post.author, post.href, post.title, post.created, post.community, post.local, post.deleted, post.had_href, post.content_text, post.content_markdown, post.content_html, community.ap_id, community.ap_outbox, community.local, community.ap_followers, poll.multiple, (SELECT array_agg(jsonb_build_array(id, name, (SELECT COUNT(*) FROM poll_vote WHERE poll_id = poll.id AND option_id = poll_option.id)) ORDER BY position ASC) FROM poll_option WHERE poll_id=poll.id) FROM post INNER JOIN community ON (post.community = community.id) LEFT OUTER JOIN poll ON (poll.id = post.poll_id) WHERE post.id=$1",
            &[&post_id.raw()],
        )
        .await?


@@ 115,6 116,29 @@ async fn handler_posts_get(
                }
            };

            let poll = if let Some(multiple) = row.get(15) {
                Some({
                    let options: Vec<_> = row.get::<_, Vec<postgres_types::Json<(i64, &str, i64)>>>(16)
                        .into_iter()
                        .map(|x| x.0)
                        .map(|(id, name, votes): (i64, &str, i64)| {
                            crate::PollOption {
                                id,
                                name,
                                votes: votes as u32,
                            }
                        })
                        .collect();

                    Cow::Owned(crate::PollInfo {
                        multiple,
                        options: Cow::Owned(options),
                    })
                })
            } else {
                None
            };

            let post_info = crate::PostInfo {
                author: Some(UserLocalID(row.get(0))),
                community: community_local_id,


@@ 125,6 149,7 @@ async fn handler_posts_get(
                content_html: row.get(10),
                id: post_id,
                title: row.get(2),
                poll,
            };

            let body = crate::apub_util::post_to_ap(&post_info, community_ap_id.into(), community_ap_outbox.map(Into::into), community_ap_followers.map(Into::into), &ctx)?;


@@ 153,7 178,7 @@ async fn handler_posts_create_get(

    match db
        .query_opt(
            "SELECT post.author, post.href, post.title, post.created, post.community, post.local, post.deleted, post.content_text, post.content_markdown, post.content_html, community.ap_id, community.ap_outbox, community.local, community.ap_followers FROM post, community WHERE community.id = post.community AND post.id=$1",
            "SELECT post.author, post.href, post.title, post.created, post.community, post.local, post.deleted, post.content_text, post.content_markdown, post.content_html, community.ap_id, community.ap_outbox, community.local, community.ap_followers, poll.multiple, (SELECT array_agg(jsonb_build_array(id, name, (SELECT COUNT(*) FROM poll_vote WHERE poll_id = poll.id AND option_id = poll_option.id)) ORDER BY position ASC) FROM poll_option WHERE poll_id=poll.id) FROM post INNER JOIN community ON (community.id = post.community) LEFT OUTER JOIN poll ON (poll.id = post.poll_id) WHERE post.id=$1",
            &[&post_id.raw()],
        )
        .await?


@@ 215,6 240,29 @@ async fn handler_posts_create_get(
                }
            };

            let poll = if let Some(multiple) = row.get(14) {
                Some({
                    let options: Vec<_> = row.get::<_, Vec<postgres_types::Json<(i64, &str, i64)>>>(15)
                        .into_iter()
                        .map(|x| x.0)
                        .map(|(id, name, votes): (i64, &str, i64)| {
                            crate::PollOption {
                                id,
                                name,
                                votes: votes as u32,
                            }
                        })
                        .collect();

                    Cow::Owned(crate::PollInfo {
                        multiple,
                        options: Cow::Owned(options),
                    })
                })
            } else {
                None
            };

            let post_info = crate::PostInfo {
                author: Some(UserLocalID(row.get(0))),
                community: community_local_id,


@@ 225,6 273,7 @@ async fn handler_posts_create_get(
                content_html: row.get(9),
                id: post_id,
                title: row.get(2),
                poll,
            };

            let body = crate::apub_util::local_post_to_create_ap(&post_info, community_ap_id.into(), community_ap_outbox.map(Into::into), community_ap_followers.map(Into::into), &ctx)?;