mirror of
https://iceshrimp.dev/crimekillz/iceshrimp-161sh.git
synced 2024-11-22 04:03:49 +01:00
[mastodon-client] GET /polls/:id, POST /polls/:id/votes
This commit is contained in:
parent
85a13d8bd7
commit
fb8b2ce0df
@ -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"]}`;
|
||||
|
@ -384,7 +384,7 @@ export class NoteHelpers {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static normalizeToArray<T>(subject: T | T[]) {
|
||||
public static normalizeToArray<T>(subject: T | T[]) {
|
||||
return Array.isArray(subject) ? subject : [subject];
|
||||
}
|
||||
}
|
||||
|
113
packages/backend/src/server/api/mastodon/helpers/poll.ts
Normal file
113
packages/backend/src/server/api/mastodon/helpers/poll.ts
Normal file
@ -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<MastodonEntity.Poll> {
|
||||
return populatePoll(note, user?.id ?? null).then(p => PollConverter.encode(p, note.id));
|
||||
}
|
||||
|
||||
public static async voteInPoll(choices: number[], note: Note, user: ILocalUser): Promise<MastodonEntity.Poll> {
|
||||
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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user