From fb8b2ce0df6fb1414ce834f9286012b0982124c4 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Sat, 30 Sep 2023 19:45:05 +0200 Subject: [PATCH] [mastodon-client] GET /polls/:id, POST /polls/:id/votes --- .../server/api/mastodon/endpoints/status.ts | 65 ++++++---- .../src/server/api/mastodon/helpers/note.ts | 2 +- .../src/server/api/mastodon/helpers/poll.ts | 113 ++++++++++++++++++ 3 files changed, 156 insertions(+), 24 deletions(-) create mode 100644 packages/backend/src/server/api/mastodon/helpers/poll.ts diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 43b3d56e4..0b01f0c09 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -1,7 +1,4 @@ import Router from "@koa/router"; -import { getClient } from "../index.js"; -import querystring from "node:querystring"; -import qs from "qs"; import { convertId, IdType } from "../../index.js"; import { convertAccount, convertPoll, convertStatus, convertStatusEdit, } from "../converters.js"; import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; @@ -15,6 +12,9 @@ import { UserConverter } from "@/server/api/mastodon/converters/user.js"; import { Cache } from "@/misc/cache.js"; import AsyncLock from "async-lock"; import { ILocalUser } from "@/models/entities/user.js"; +import { PollHelpers } from "@/server/api/mastodon/helpers/poll.js"; +import querystring from "node:querystring"; +import qs from "qs"; const postIdempotencyCache = new Cache<{status?: MastodonEntity.Status}>('postIdempotencyCache', 60 * 60); const postIdempotencyLocks = new AsyncLock(); @@ -577,14 +577,20 @@ export function setupEndpointsStatus(router: Router): void { }, ); router.get<{ Params: { id: string } }>("/v1/polls/:id", async (ctx) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const data = await client.getPoll( - convertId(ctx.params.id, IdType.IceshrimpId), - ); - ctx.body = convertPoll(data.data); + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null || !note.hasPoll) { + ctx.status = 404; + return; + } + + const data = await PollHelpers.getPoll(note, user); + ctx.body = convertPoll(data); } catch (e: any) { console.error(e); ctx.status = 401; @@ -594,15 +600,33 @@ export function setupEndpointsStatus(router: Router): void { router.post<{ Params: { id: string } }>( "/v1/polls/:id/votes", async (ctx) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const data = await client.votePoll( - convertId(ctx.params.id, IdType.IceshrimpId), - (ctx.request.body as any).choices, - ); - ctx.body = convertPoll(data.data); + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null || !note.hasPoll) { + ctx.status = 404; + return; + } + + const body: any = qs.parse(querystring.stringify(ctx.request.body as any)); + const choices = NoteHelpers.normalizeToArray(body.choices ?? []).map(p => parseInt(p)); + if (choices.length < 1) { + ctx.status = 400; + ctx.body = { error: 'Must vote for at least one option' }; + return; + } + + const data = await PollHelpers.voteInPoll(choices, note, user); + ctx.body = convertPoll(data); } catch (e: any) { console.error(e); ctx.status = 401; @@ -612,11 +636,6 @@ export function setupEndpointsStatus(router: Router): void { ); } -function normalizeQuery(data: any) { - const str = querystring.stringify(data); - return qs.parse(str); -} - function getIdempotencyKey(headers: any, user: ILocalUser): string | null { if (headers["idempotency-key"] === undefined || headers["idempotency-key"] === null) return null; return `${user.id}-${Array.isArray(headers["idempotency-key"]) ? headers["idempotency-key"].at(-1)! : headers["idempotency-key"]}`; diff --git a/packages/backend/src/server/api/mastodon/helpers/note.ts b/packages/backend/src/server/api/mastodon/helpers/note.ts index 1e8a104ea..bf2e5d85e 100644 --- a/packages/backend/src/server/api/mastodon/helpers/note.ts +++ b/packages/backend/src/server/api/mastodon/helpers/note.ts @@ -384,7 +384,7 @@ export class NoteHelpers { return result; } - private static normalizeToArray(subject: T | T[]) { + public static normalizeToArray(subject: T | T[]) { return Array.isArray(subject) ? subject : [subject]; } } diff --git a/packages/backend/src/server/api/mastodon/helpers/poll.ts b/packages/backend/src/server/api/mastodon/helpers/poll.ts new file mode 100644 index 000000000..bc472f0f0 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/poll.ts @@ -0,0 +1,113 @@ +import { Note } from "@/models/entities/note.js"; +import { populatePoll } from "@/models/repositories/note.js"; +import { PollConverter } from "@/server/api/mastodon/converters/poll.js"; +import { ILocalUser, IRemoteUser } from "@/models/entities/user.js"; +import { getNote } from "@/server/api/common/getters.js"; +import { ApiError } from "@/server/api/error.js"; +import { Blockings, NoteWatchings, Polls, PollVotes, Users } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { publishNoteStream } from "@/services/stream.js"; +import { createNotification } from "@/services/create-notification.js"; +import { deliver } from "@/queue/index.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderVote from "@/remote/activitypub/renderer/vote.js"; +import { meta } from "@/server/api/endpoints/notes/polls/vote.js"; +import { Not } from "typeorm"; + +export class PollHelpers { + public static async getPoll(note: Note, user: ILocalUser | null): Promise { + return populatePoll(note, user?.id ?? null).then(p => PollConverter.encode(p, note.id)); + } + + public static async voteInPoll(choices: number[], note: Note, user: ILocalUser): Promise { + for (const choice of choices) { + const createdAt = new Date(); + + if (!note.hasPoll) throw new Error('Note has no poll'); + + // Check blocking + if (note.userId !== user.id) { + const block = await Blockings.findOneBy({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) throw new Error('You are blocked by the poll author'); + } + + const poll = await Polls.findOneByOrFail({ noteId: note.id }); + + if (poll.expiresAt && poll.expiresAt < createdAt) throw new Error('Poll is expired'); + + if (poll.choices[choice] == null) throw new Error('Invalid choice'); + + // if already voted + const exist = await PollVotes.findBy({ + noteId: note.id, + userId: user.id, + }); + + if (exist.length) { + if (poll.multiple) { + if (exist.some((x) => x.choice === choice)) throw new Error('You already voted for this option'); + } else { + throw new Error('You already voted in this poll'); + } + } + + // Create vote + const vote = await PollVotes.insert({ + id: genId(), + createdAt, + noteId: note.id, + userId: user.id, + choice: choice, + }).then((x) => PollVotes.findOneByOrFail(x.identifiers[0])); + + // Increment votes count + const index = choice + 1; // In SQL, array index is 1 based + await Polls.query( + `UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`, + ); + + publishNoteStream(note.id, "pollVoted", { + choice: choice, + userId: user.id, + }); + + // Notify + createNotification(note.userId, "pollVote", { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + + // Fetch watchers + NoteWatchings.findBy({ + noteId: note.id, + userId: Not(user.id), + }).then((watchers) => { + for (const watcher of watchers) { + createNotification(watcher.userId, "pollVote", { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + } + }); + + // リモート投票の場合リプライ送信 + if (note.userHost != null) { + const pollOwner = (await Users.findOneByOrFail({ + id: note.userId, + })) as IRemoteUser; + + deliver( + user, + renderActivity(await renderVote(user, vote, note, poll, pollOwner)), + pollOwner.inbox, + ); + } + } + return this.getPoll(note, user); + } +}