summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api/endpoints/notes
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend/src/server/api/endpoints/notes')
-rw-r--r--packages/backend/src/server/api/endpoints/notes/children.ts72
-rw-r--r--packages/backend/src/server/api/endpoints/notes/clips.ts55
-rw-r--r--packages/backend/src/server/api/endpoints/notes/conversation.ts81
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts299
-rw-r--r--packages/backend/src/server/api/endpoints/notes/delete.ts56
-rw-r--r--packages/backend/src/server/api/endpoints/notes/favorites/create.ts61
-rw-r--r--packages/backend/src/server/api/endpoints/notes/favorites/delete.ts55
-rw-r--r--packages/backend/src/server/api/endpoints/notes/featured.ts64
-rw-r--r--packages/backend/src/server/api/endpoints/notes/global-timeline.ts101
-rw-r--r--packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts158
-rw-r--r--packages/backend/src/server/api/endpoints/notes/local-timeline.ts129
-rw-r--r--packages/backend/src/server/api/endpoints/notes/mentions.ts88
-rw-r--r--packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts77
-rw-r--r--packages/backend/src/server/api/endpoints/notes/polls/vote.ts170
-rw-r--r--packages/backend/src/server/api/endpoints/notes/reactions.ts90
-rw-r--r--packages/backend/src/server/api/endpoints/notes/reactions/create.ts57
-rw-r--r--packages/backend/src/server/api/endpoints/notes/reactions/delete.ts52
-rw-r--r--packages/backend/src/server/api/endpoints/notes/renotes.ts76
-rw-r--r--packages/backend/src/server/api/endpoints/notes/replies.ts61
-rw-r--r--packages/backend/src/server/api/endpoints/notes/search-by-tag.ts134
-rw-r--r--packages/backend/src/server/api/endpoints/notes/search.ts152
-rw-r--r--packages/backend/src/server/api/endpoints/notes/show.ts43
-rw-r--r--packages/backend/src/server/api/endpoints/notes/state.ts69
-rw-r--r--packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts54
-rw-r--r--packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts40
-rw-r--r--packages/backend/src/server/api/endpoints/notes/timeline.ts150
-rw-r--r--packages/backend/src/server/api/endpoints/notes/translate.ts89
-rw-r--r--packages/backend/src/server/api/endpoints/notes/unrenote.ts52
-rw-r--r--packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts147
-rw-r--r--packages/backend/src/server/api/endpoints/notes/watching/create.ts37
-rw-r--r--packages/backend/src/server/api/endpoints/notes/watching/delete.ts37
31 files changed, 2806 insertions, 0 deletions
diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts
new file mode 100644
index 0000000000..68881fda9e
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/children.ts
@@ -0,0 +1,72 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { Brackets } from 'typeorm';
+import { Notes } from '@/models/index';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: false as const,
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
+ .andWhere(new Brackets(qb => { qb
+ .where(`note.replyId = :noteId`, { noteId: ps.noteId })
+ .orWhere(new Brackets(qb => { qb
+ .where(`note.renoteId = :noteId`, { noteId: ps.noteId })
+ .andWhere(new Brackets(qb => { qb
+ .where(`note.text IS NOT NULL`)
+ .orWhere(`note.fileIds != '{}'`)
+ .orWhere(`note.hasPoll = TRUE`);
+ }));
+ }));
+ }))
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ generateVisibilityQuery(query, user);
+ if (user) generateMutedUserQuery(query, user);
+ if (user) generateBlockedUserQuery(query, user);
+
+ const notes = await query.take(ps.limit!).getMany();
+
+ return await Notes.packMany(notes, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts
new file mode 100644
index 0000000000..6b303d87ec
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/clips.ts
@@ -0,0 +1,55 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { ClipNotes, Clips } from '@/models/index';
+import { getNote } from '../../common/getters';
+import { ApiError } from '../../error';
+import { In } from 'typeorm';
+
+export const meta = {
+ tags: ['clips', 'notes'],
+
+ requireCredential: false as const,
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '47db1a1c-b0af-458d-8fb4-986e4efafe1e'
+ }
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ const clipNotes = await ClipNotes.find({
+ noteId: note.id,
+ });
+
+ const clips = await Clips.find({
+ id: In(clipNotes.map(x => x.clipId)),
+ isPublic: true
+ });
+
+ return await Promise.all(clips.map(x => Clips.pack(x)));
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/conversation.ts b/packages/backend/src/server/api/endpoints/notes/conversation.ts
new file mode 100644
index 0000000000..0fe323ea00
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/conversation.ts
@@ -0,0 +1,81 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { getNote } from '../../common/getters';
+import { Note } from '@/models/entities/note';
+import { Notes } from '@/models/index';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: false as const,
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ offset: {
+ validator: $.optional.num.min(0),
+ default: 0
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: 'e1035875-9551-45ec-afa8-1ded1fcb53c8'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ const conversation: Note[] = [];
+ let i = 0;
+
+ async function get(id: any) {
+ i++;
+ const p = await Notes.findOne(id);
+ if (p == null) return;
+
+ if (i > ps.offset!) {
+ conversation.push(p);
+ }
+
+ if (conversation.length == ps.limit!) {
+ return;
+ }
+
+ if (p.replyId) {
+ await get(p.replyId);
+ }
+ }
+
+ if (note.replyId) {
+ await get(note.replyId);
+ }
+
+ return await Notes.packMany(conversation, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
new file mode 100644
index 0000000000..751673f955
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -0,0 +1,299 @@
+import $ from 'cafy';
+import * as ms from 'ms';
+import { length } from 'stringz';
+import create from '@/services/note/create';
+import define from '../../define';
+import { fetchMeta } from '@/misc/fetch-meta';
+import { ApiError } from '../../error';
+import { ID } from '@/misc/cafy-id';
+import { User } from '@/models/entities/user';
+import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index';
+import { DriveFile } from '@/models/entities/drive-file';
+import { Note } from '@/models/entities/note';
+import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits';
+import { noteVisibilities } from '../../../../types';
+import { Channel } from '@/models/entities/channel';
+
+let maxNoteTextLength = 500;
+
+setInterval(() => {
+ fetchMeta().then(m => {
+ maxNoteTextLength = m.maxNoteTextLength;
+ });
+}, 3000);
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ limit: {
+ duration: ms('1hour'),
+ max: 300
+ },
+
+ kind: 'write:notes',
+
+ params: {
+ visibility: {
+ validator: $.optional.str.or(noteVisibilities as unknown as string[]),
+ default: 'public',
+ },
+
+ visibleUserIds: {
+ validator: $.optional.arr($.type(ID)).unique().min(0),
+ },
+
+ text: {
+ validator: $.optional.nullable.str.pipe(text =>
+ text.trim() != ''
+ && length(text.trim()) <= maxNoteTextLength
+ && Array.from(text.trim()).length <= DB_MAX_NOTE_TEXT_LENGTH // DB limit
+ ),
+ default: null,
+ },
+
+ cw: {
+ validator: $.optional.nullable.str.pipe(Notes.validateCw),
+ },
+
+ viaMobile: {
+ validator: $.optional.bool,
+ default: false,
+ },
+
+ localOnly: {
+ validator: $.optional.bool,
+ default: false,
+ },
+
+ noExtractMentions: {
+ validator: $.optional.bool,
+ default: false,
+ },
+
+ noExtractHashtags: {
+ validator: $.optional.bool,
+ default: false,
+ },
+
+ noExtractEmojis: {
+ validator: $.optional.bool,
+ default: false,
+ },
+
+ fileIds: {
+ validator: $.optional.arr($.type(ID)).unique().range(1, 4),
+ },
+
+ mediaIds: {
+ validator: $.optional.arr($.type(ID)).unique().range(1, 4),
+ deprecated: true,
+ },
+
+ replyId: {
+ validator: $.optional.nullable.type(ID),
+ },
+
+ renoteId: {
+ validator: $.optional.nullable.type(ID),
+ },
+
+ channelId: {
+ validator: $.optional.nullable.type(ID),
+ },
+
+ poll: {
+ validator: $.optional.nullable.obj({
+ choices: $.arr($.str)
+ .unique()
+ .range(2, 10)
+ .each(c => c.length > 0 && c.length < 50),
+ multiple: $.optional.bool,
+ expiresAt: $.optional.nullable.num.int(),
+ expiredAfter: $.optional.nullable.num.int().min(1)
+ }).strict(),
+ ref: 'poll'
+ }
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ properties: {
+ createdNote: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ }
+ },
+
+ errors: {
+ noSuchRenoteTarget: {
+ message: 'No such renote target.',
+ code: 'NO_SUCH_RENOTE_TARGET',
+ id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4'
+ },
+
+ cannotReRenote: {
+ message: 'You can not Renote a pure Renote.',
+ code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE',
+ id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a'
+ },
+
+ noSuchReplyTarget: {
+ message: 'No such reply target.',
+ code: 'NO_SUCH_REPLY_TARGET',
+ id: '749ee0f6-d3da-459a-bf02-282e2da4292c'
+ },
+
+ cannotReplyToPureRenote: {
+ message: 'You can not reply to a pure Renote.',
+ code: 'CANNOT_REPLY_TO_A_PURE_RENOTE',
+ id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15'
+ },
+
+ contentRequired: {
+ message: 'Content required. You need to set text, fileIds, renoteId or poll.',
+ code: 'CONTENT_REQUIRED',
+ id: '6f57e42b-c348-439b-bc45-993995cc515a'
+ },
+
+ cannotCreateAlreadyExpiredPoll: {
+ message: 'Poll is already expired.',
+ code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
+ id: '04da457d-b083-4055-9082-955525eda5a5'
+ },
+
+ noSuchChannel: {
+ message: 'No such channel.',
+ code: 'NO_SUCH_CHANNEL',
+ id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb'
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ let visibleUsers: User[] = [];
+ if (ps.visibleUserIds) {
+ visibleUsers = (await Promise.all(ps.visibleUserIds.map(id => Users.findOne(id))))
+ .filter(x => x != null) as User[];
+ }
+
+ let files: DriveFile[] = [];
+ const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null;
+ if (fileIds != null) {
+ files = (await Promise.all(fileIds.map(fileId =>
+ DriveFiles.findOne({
+ id: fileId,
+ userId: user.id
+ })
+ ))).filter(file => file != null) as DriveFile[];
+ }
+
+ let renote: Note | undefined;
+ if (ps.renoteId != null) {
+ // Fetch renote to note
+ renote = await Notes.findOne(ps.renoteId);
+
+ if (renote == null) {
+ throw new ApiError(meta.errors.noSuchRenoteTarget);
+ } else if (renote.renoteId && !renote.text && !renote.fileIds) {
+ throw new ApiError(meta.errors.cannotReRenote);
+ }
+
+ // Check blocking
+ if (renote.userId !== user.id) {
+ const block = await Blockings.findOne({
+ blockerId: renote.userId,
+ blockeeId: user.id,
+ });
+ if (block) {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ }
+ }
+ }
+
+ let reply: Note | undefined;
+ if (ps.replyId != null) {
+ // Fetch reply
+ reply = await Notes.findOne(ps.replyId);
+
+ if (reply == null) {
+ throw new ApiError(meta.errors.noSuchReplyTarget);
+ }
+
+ // 返信対象が引用でないRenoteだったらエラー
+ if (reply.renoteId && !reply.text && !reply.fileIds) {
+ throw new ApiError(meta.errors.cannotReplyToPureRenote);
+ }
+
+ // Check blocking
+ if (reply.userId !== user.id) {
+ const block = await Blockings.findOne({
+ blockerId: reply.userId,
+ blockeeId: user.id,
+ });
+ if (block) {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ }
+ }
+ }
+
+ if (ps.poll) {
+ if (typeof ps.poll.expiresAt === 'number') {
+ if (ps.poll.expiresAt < Date.now())
+ throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
+ } else if (typeof ps.poll.expiredAfter === 'number') {
+ ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
+ }
+ }
+
+ // テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー
+ if (!(ps.text || files.length || renote || ps.poll)) {
+ throw new ApiError(meta.errors.contentRequired);
+ }
+
+ let channel: Channel | undefined;
+ if (ps.channelId != null) {
+ channel = await Channels.findOne(ps.channelId);
+
+ if (channel == null) {
+ throw new ApiError(meta.errors.noSuchChannel);
+ }
+ }
+
+ // 投稿を作成
+ const note = await create(user, {
+ createdAt: new Date(),
+ files: files,
+ poll: ps.poll ? {
+ choices: ps.poll.choices,
+ multiple: ps.poll.multiple || false,
+ expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null
+ } : undefined,
+ text: ps.text || undefined,
+ reply,
+ renote,
+ cw: ps.cw,
+ viaMobile: ps.viaMobile,
+ localOnly: ps.localOnly,
+ visibility: ps.visibility,
+ visibleUsers,
+ channel,
+ apMentions: ps.noExtractMentions ? [] : undefined,
+ apHashtags: ps.noExtractHashtags ? [] : undefined,
+ apEmojis: ps.noExtractEmojis ? [] : undefined,
+ });
+
+ return {
+ createdNote: await Notes.pack(note, user)
+ };
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/delete.ts b/packages/backend/src/server/api/endpoints/notes/delete.ts
new file mode 100644
index 0000000000..7163a2b9d2
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/delete.ts
@@ -0,0 +1,56 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import deleteNote from '@/services/note/delete';
+import define from '../../define';
+import * as ms from 'ms';
+import { getNote } from '../../common/getters';
+import { ApiError } from '../../error';
+import { Users } from '@/models/index';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:notes',
+
+ limit: {
+ duration: ms('1hour'),
+ max: 300,
+ minInterval: ms('1sec')
+ },
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '490be23f-8c1f-4796-819f-94cb4f9d1630'
+ },
+
+ accessDenied: {
+ message: 'Access denied.',
+ code: 'ACCESS_DENIED',
+ id: 'fe8d7103-0ea8-4ec3-814d-f8b401dc69e9'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ if (!user.isAdmin && !user.isModerator && (note.userId !== user.id)) {
+ throw new ApiError(meta.errors.accessDenied);
+ }
+
+ // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため
+ await deleteNote(await Users.findOneOrFail(note.userId), note);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts
new file mode 100644
index 0000000000..1bb25edd7f
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts
@@ -0,0 +1,61 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getNote } from '../../../common/getters';
+import { NoteFavorites } from '@/models/index';
+import { genId } from '@/misc/gen-id';
+
+export const meta = {
+ tags: ['notes', 'favorites'],
+
+ requireCredential: true as const,
+
+ kind: 'write:favorites',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '6dd26674-e060-4816-909a-45ba3f4da458'
+ },
+
+ alreadyFavorited: {
+ message: 'The note has already been marked as a favorite.',
+ code: 'ALREADY_FAVORITED',
+ id: 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ // Get favoritee
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ // if already favorited
+ const exist = await NoteFavorites.findOne({
+ noteId: note.id,
+ userId: user.id
+ });
+
+ if (exist != null) {
+ throw new ApiError(meta.errors.alreadyFavorited);
+ }
+
+ // Create favorite
+ await NoteFavorites.insert({
+ id: genId(),
+ createdAt: new Date(),
+ noteId: note.id,
+ userId: user.id
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts
new file mode 100644
index 0000000000..75eb9a359a
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts
@@ -0,0 +1,55 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getNote } from '../../../common/getters';
+import { NoteFavorites } from '@/models/index';
+
+export const meta = {
+ tags: ['notes', 'favorites'],
+
+ requireCredential: true as const,
+
+ kind: 'write:favorites',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '80848a2c-398f-4343-baa9-df1d57696c56'
+ },
+
+ notFavorited: {
+ message: 'You have not marked that note a favorite.',
+ code: 'NOT_FAVORITED',
+ id: 'b625fc69-635e-45e9-86f4-dbefbef35af5'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ // Get favoritee
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ // if already favorited
+ const exist = await NoteFavorites.findOne({
+ noteId: note.id,
+ userId: user.id
+ });
+
+ if (exist == null) {
+ throw new ApiError(meta.errors.notFavorited);
+ }
+
+ // Delete favorite
+ await NoteFavorites.delete(exist.id);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts
new file mode 100644
index 0000000000..8d33c0e73d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/featured.ts
@@ -0,0 +1,64 @@
+import $ from 'cafy';
+import define from '../../define';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { Notes } from '@/models/index';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: false as const,
+
+ params: {
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10,
+ },
+
+ offset: {
+ validator: $.optional.num.min(0),
+ default: 0
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const max = 30;
+ const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで
+
+ const query = Notes.createQueryBuilder('note')
+ .addSelect('note.score')
+ .where('note.userHost IS NULL')
+ .andWhere(`note.score > 0`)
+ .andWhere(`note.createdAt > :date`, { date: new Date(Date.now() - day) })
+ .andWhere(`note.visibility = 'public'`)
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ if (user) generateMutedUserQuery(query, user);
+ if (user) generateBlockedUserQuery(query, user);
+
+ let notes = await query
+ .orderBy('note.score', 'DESC')
+ .take(max)
+ .getMany();
+
+ notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+
+ notes = notes.slice(ps.offset, ps.offset + ps.limit);
+
+ return await Notes.packMany(notes, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
new file mode 100644
index 0000000000..5902c0415c
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts
@@ -0,0 +1,101 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { fetchMeta } from '@/misc/fetch-meta';
+import { ApiError } from '../../error';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { Notes } from '@/models/index';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { activeUsersChart } from '@/services/chart/index';
+import { generateRepliesQuery } from '../../common/generate-replies-query';
+import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ params: {
+ withFiles: {
+ validator: $.optional.bool,
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ sinceDate: {
+ validator: $.optional.num
+ },
+
+ untilDate: {
+ validator: $.optional.num
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+
+ errors: {
+ gtlDisabled: {
+ message: 'Global timeline has been disabled.',
+ code: 'GTL_DISABLED',
+ id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const m = await fetchMeta();
+ if (m.disableGlobalTimeline) {
+ if (user == null || (!user.isAdmin && !user.isModerator)) {
+ throw new ApiError(meta.errors.gtlDisabled);
+ }
+ }
+
+ //#region Construct query
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'),
+ ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+ .andWhere('note.visibility = \'public\'')
+ .andWhere('note.channelId IS NULL')
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ generateRepliesQuery(query, user);
+ if (user) generateMutedUserQuery(query, user);
+ if (user) generateMutedNoteQuery(query, user);
+ if (user) generateBlockedUserQuery(query, user);
+
+ if (ps.withFiles) {
+ query.andWhere('note.fileIds != \'{}\'');
+ }
+ //#endregion
+
+ const timeline = await query.take(ps.limit!).getMany();
+
+ process.nextTick(() => {
+ if (user) {
+ activeUsersChart.update(user);
+ }
+ });
+
+ return await Notes.packMany(timeline, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
new file mode 100644
index 0000000000..47f08f208b
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -0,0 +1,158 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { fetchMeta } from '@/misc/fetch-meta';
+import { ApiError } from '../../error';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { Followings, Notes } from '@/models/index';
+import { Brackets } from 'typeorm';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { activeUsersChart } from '@/services/chart/index';
+import { generateRepliesQuery } from '../../common/generate-replies-query';
+import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
+import { generateChannelQuery } from '../../common/generate-channel-query';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ params: {
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10,
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ sinceDate: {
+ validator: $.optional.num,
+ },
+
+ untilDate: {
+ validator: $.optional.num,
+ },
+
+ includeMyRenotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ includeRenotedMyNotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ includeLocalRenotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ withFiles: {
+ validator: $.optional.bool,
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+
+ errors: {
+ stlDisabled: {
+ message: 'Hybrid timeline has been disabled.',
+ code: 'STL_DISABLED',
+ id: '620763f4-f621-4533-ab33-0577a1a3c342'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const m = await fetchMeta();
+ if (m.disableLocalTimeline && !user.isAdmin && !user.isModerator) {
+ throw new ApiError(meta.errors.stlDisabled);
+ }
+
+ //#region Construct query
+ const followingQuery = Followings.createQueryBuilder('following')
+ .select('following.followeeId')
+ .where('following.followerId = :followerId', { followerId: user.id });
+
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'),
+ ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+ .andWhere(new Brackets(qb => {
+ qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id })
+ .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
+ }))
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .setParameters(followingQuery.getParameters());
+
+ generateChannelQuery(query, user);
+ generateRepliesQuery(query, user);
+ generateVisibilityQuery(query, user);
+ generateMutedUserQuery(query, user);
+ generateMutedNoteQuery(query, user);
+ generateBlockedUserQuery(query, user);
+
+ if (ps.includeMyRenotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.userId != :meId', { meId: user.id });
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ if (ps.includeRenotedMyNotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteUserId != :meId', { meId: user.id });
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ if (ps.includeLocalRenotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteUserHost IS NOT NULL');
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ if (ps.withFiles) {
+ query.andWhere('note.fileIds != \'{}\'');
+ }
+ //#endregion
+
+ const timeline = await query.take(ps.limit!).getMany();
+
+ process.nextTick(() => {
+ if (user) {
+ activeUsersChart.update(user);
+ }
+ });
+
+ return await Notes.packMany(timeline, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
new file mode 100644
index 0000000000..f670d478bf
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts
@@ -0,0 +1,129 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { fetchMeta } from '@/misc/fetch-meta';
+import { ApiError } from '../../error';
+import { Notes } from '@/models/index';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { activeUsersChart } from '@/services/chart/index';
+import { Brackets } from 'typeorm';
+import { generateRepliesQuery } from '../../common/generate-replies-query';
+import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
+import { generateChannelQuery } from '../../common/generate-channel-query';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ params: {
+ withFiles: {
+ validator: $.optional.bool,
+ },
+
+ fileType: {
+ validator: $.optional.arr($.str),
+ },
+
+ excludeNsfw: {
+ validator: $.optional.bool,
+ default: false,
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ sinceDate: {
+ validator: $.optional.num,
+ },
+
+ untilDate: {
+ validator: $.optional.num,
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+
+ errors: {
+ ltlDisabled: {
+ message: 'Local timeline has been disabled.',
+ code: 'LTL_DISABLED',
+ id: '45a6eb02-7695-4393-b023-dd3be9aaaefd'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const m = await fetchMeta();
+ if (m.disableLocalTimeline) {
+ if (user == null || (!user.isAdmin && !user.isModerator)) {
+ throw new ApiError(meta.errors.ltlDisabled);
+ }
+ }
+
+ //#region Construct query
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'),
+ ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+ .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ generateChannelQuery(query, user);
+ generateRepliesQuery(query, user);
+ generateVisibilityQuery(query, user);
+ if (user) generateMutedUserQuery(query, user);
+ if (user) generateMutedNoteQuery(query, user);
+ if (user) generateBlockedUserQuery(query, user);
+
+ if (ps.withFiles) {
+ query.andWhere('note.fileIds != \'{}\'');
+ }
+
+ if (ps.fileType != null) {
+ query.andWhere('note.fileIds != \'{}\'');
+ query.andWhere(new Brackets(qb => {
+ for (const type of ps.fileType!) {
+ const i = ps.fileType!.indexOf(type);
+ qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
+ }
+ }));
+
+ if (ps.excludeNsfw) {
+ query.andWhere('note.cw IS NULL');
+ query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
+ }
+ }
+ //#endregion
+
+ const timeline = await query.take(ps.limit!).getMany();
+
+ process.nextTick(() => {
+ if (user) {
+ activeUsersChart.update(user);
+ }
+ });
+
+ return await Notes.packMany(timeline, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts
new file mode 100644
index 0000000000..ffaebd6c95
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts
@@ -0,0 +1,88 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import read from '@/services/note/read';
+import { Notes, Followings } from '@/models/index';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { Brackets } from 'typeorm';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+import { generateMutedNoteThreadQuery } from '../../common/generate-muted-note-thread-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ params: {
+ following: {
+ validator: $.optional.bool,
+ default: false
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ visibility: {
+ validator: $.optional.str,
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const followingQuery = Followings.createQueryBuilder('following')
+ .select('following.followeeId')
+ .where('following.followerId = :followerId', { followerId: user.id });
+
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
+ .andWhere(new Brackets(qb => { qb
+ .where(`'{"${user.id}"}' <@ note.mentions`)
+ .orWhere(`'{"${user.id}"}' <@ note.visibleUserIds`);
+ }))
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ generateVisibilityQuery(query, user);
+ generateMutedUserQuery(query, user);
+ generateMutedNoteThreadQuery(query, user);
+ generateBlockedUserQuery(query, user);
+
+ if (ps.visibility) {
+ query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
+ }
+
+ if (ps.following) {
+ query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id });
+ query.setParameters(followingQuery.getParameters());
+ }
+
+ const mentions = await query.take(ps.limit!).getMany();
+
+ read(user.id, mentions);
+
+ return await Notes.packMany(mentions, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
new file mode 100644
index 0000000000..0763f0c8fd
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
@@ -0,0 +1,77 @@
+import $ from 'cafy';
+import define from '../../../define';
+import { Polls, Mutings, Notes, PollVotes } from '@/models/index';
+import { Brackets, In } from 'typeorm';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ params: {
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ offset: {
+ validator: $.optional.num.min(0),
+ default: 0
+ }
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const query = Polls.createQueryBuilder('poll')
+ .where('poll.userHost IS NULL')
+ .andWhere(`poll.userId != :meId`, { meId: user.id })
+ .andWhere(`poll.noteVisibility = 'public'`)
+ .andWhere(new Brackets(qb => { qb
+ .where('poll.expiresAt IS NULL')
+ .orWhere('poll.expiresAt > :now', { now: new Date() });
+ }));
+
+ //#region exclude arleady voted polls
+ const votedQuery = PollVotes.createQueryBuilder('vote')
+ .select('vote.noteId')
+ .where('vote.userId = :meId', { meId: user.id });
+
+ query
+ .andWhere(`poll.noteId NOT IN (${ votedQuery.getQuery() })`);
+
+ query.setParameters(votedQuery.getParameters());
+ //#endregion
+
+ //#region mute
+ const mutingQuery = Mutings.createQueryBuilder('muting')
+ .select('muting.muteeId')
+ .where('muting.muterId = :muterId', { muterId: user.id });
+
+ query
+ .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`);
+
+ query.setParameters(mutingQuery.getParameters());
+ //#endregion
+
+ const polls = await query.take(ps.limit!).skip(ps.offset).getMany();
+
+ if (polls.length === 0) return [];
+
+ const notes = await Notes.find({
+ id: In(polls.map(poll => poll.noteId))
+ });
+
+ return await Notes.packMany(notes, user, {
+ detail: true
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts
new file mode 100644
index 0000000000..f670501385
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts
@@ -0,0 +1,170 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import { publishNoteStream } from '@/services/stream';
+import { createNotification } from '@/services/create-notification';
+import define from '../../../define';
+import { ApiError } from '../../../error';
+import { getNote } from '../../../common/getters';
+import { deliver } from '@/queue/index';
+import { renderActivity } from '@/remote/activitypub/renderer/index';
+import renderVote from '@/remote/activitypub/renderer/vote';
+import { deliverQuestionUpdate } from '@/services/note/polls/update';
+import { PollVotes, NoteWatchings, Users, Polls, Blockings } from '@/models/index';
+import { Not } from 'typeorm';
+import { IRemoteUser } from '@/models/entities/user';
+import { genId } from '@/misc/gen-id';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:votes',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+
+ choice: {
+ validator: $.num
+ },
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: 'ecafbd2e-c283-4d6d-aecb-1a0a33b75396'
+ },
+
+ noPoll: {
+ message: 'The note does not attach a poll.',
+ code: 'NO_POLL',
+ id: '5f979967-52d9-4314-a911-1c673727f92f'
+ },
+
+ invalidChoice: {
+ message: 'Choice ID is invalid.',
+ code: 'INVALID_CHOICE',
+ id: 'e0cc9a04-f2e8-41e4-a5f1-4127293260cc'
+ },
+
+ alreadyVoted: {
+ message: 'You have already voted.',
+ code: 'ALREADY_VOTED',
+ id: '0963fc77-efac-419b-9424-b391608dc6d8'
+ },
+
+ alreadyExpired: {
+ message: 'The poll is already expired.',
+ code: 'ALREADY_EXPIRED',
+ id: '1022a357-b085-4054-9083-8f8de358337e'
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You cannot vote this poll because you have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: '85a5377e-b1e9-4617-b0b9-5bea73331e49'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const createdAt = new Date();
+
+ // Get votee
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ if (!note.hasPoll) {
+ throw new ApiError(meta.errors.noPoll);
+ }
+
+ // Check blocking
+ if (note.userId !== user.id) {
+ const block = await Blockings.findOne({
+ blockerId: note.userId,
+ blockeeId: user.id,
+ });
+ if (block) {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ }
+ }
+
+ const poll = await Polls.findOneOrFail({ noteId: note.id });
+
+ if (poll.expiresAt && poll.expiresAt < createdAt) {
+ throw new ApiError(meta.errors.alreadyExpired);
+ }
+
+ if (poll.choices[ps.choice] == null) {
+ throw new ApiError(meta.errors.invalidChoice);
+ }
+
+ // if already voted
+ const exist = await PollVotes.find({
+ noteId: note.id,
+ userId: user.id
+ });
+
+ if (exist.length) {
+ if (poll.multiple) {
+ if (exist.some(x => x.choice == ps.choice))
+ throw new ApiError(meta.errors.alreadyVoted);
+ } else {
+ throw new ApiError(meta.errors.alreadyVoted);
+ }
+ }
+
+ // Create vote
+ const vote = await PollVotes.insert({
+ id: genId(),
+ createdAt,
+ noteId: note.id,
+ userId: user.id,
+ choice: ps.choice
+ }).then(x => PollVotes.findOneOrFail(x.identifiers[0]));
+
+ // Increment votes count
+ const index = ps.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: ps.choice,
+ userId: user.id
+ });
+
+ // Notify
+ createNotification(note.userId, 'pollVote', {
+ notifierId: user.id,
+ noteId: note.id,
+ choice: ps.choice
+ });
+
+ // Fetch watchers
+ NoteWatchings.find({
+ 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: ps.choice
+ });
+ }
+ });
+
+ // リモート投票の場合リプライ送信
+ if (note.userHost != null) {
+ const pollOwner = await Users.findOneOrFail(note.userId) as IRemoteUser;
+
+ deliver(user, renderActivity(await renderVote(user, vote, note, poll, pollOwner)), pollOwner.inbox);
+ }
+
+ // リモートフォロワーにUpdate配信
+ deliverQuestionUpdate(note.id);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts
new file mode 100644
index 0000000000..09dd6b600b
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts
@@ -0,0 +1,90 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { getNote } from '../../common/getters';
+import { ApiError } from '../../error';
+import { NoteReactions } from '@/models/index';
+import { DeepPartial } from 'typeorm';
+import { NoteReaction } from '@/models/entities/note-reaction';
+
+export const meta = {
+ tags: ['notes', 'reactions'],
+
+ requireCredential: false as const,
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+
+ type: {
+ validator: $.optional.nullable.str,
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ offset: {
+ validator: $.optional.num,
+ default: 0
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'NoteReaction',
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '263fff3d-d0e1-4af4-bea7-8408059b451a'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ const query = {
+ noteId: note.id
+ } as DeepPartial<NoteReaction>;
+
+ if (ps.type) {
+ // ローカルリアクションはホスト名が . とされているが
+ // DB 上ではそうではないので、必要に応じて変換
+ const suffix = '@.:';
+ const type = ps.type.endsWith(suffix) ? ps.type.slice(0, ps.type.length - suffix.length) + ':' : ps.type;
+ query.reaction = type;
+ }
+
+ const reactions = await NoteReactions.find({
+ where: query,
+ take: ps.limit!,
+ skip: ps.offset,
+ order: {
+ id: -1
+ }
+ });
+
+ return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, user)));
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts
new file mode 100644
index 0000000000..24a73a8d4f
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts
@@ -0,0 +1,57 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import createReaction from '@/services/note/reaction/create';
+import define from '../../../define';
+import { getNote } from '../../../common/getters';
+import { ApiError } from '../../../error';
+
+export const meta = {
+ tags: ['reactions', 'notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:reactions',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+
+ reaction: {
+ validator: $.str,
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '033d0620-5bfe-4027-965d-980b0c85a3ea'
+ },
+
+ alreadyReacted: {
+ message: 'You are already reacting to that note.',
+ code: 'ALREADY_REACTED',
+ id: '71efcf98-86d6-4e2b-b2ad-9d032369366b'
+ },
+
+ youHaveBeenBlocked: {
+ message: 'You cannot react this note because you have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: '20ef5475-9f38-4e4c-bd33-de6d979498ec'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+ await createReaction(user, note, ps.reaction).catch(e => {
+ if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted);
+ if (e.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked);
+ throw e;
+ });
+ return;
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts
new file mode 100644
index 0000000000..69550f96de
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts
@@ -0,0 +1,52 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import * as ms from 'ms';
+import deleteReaction from '@/services/note/reaction/delete';
+import { getNote } from '../../../common/getters';
+import { ApiError } from '../../../error';
+
+export const meta = {
+ tags: ['reactions', 'notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:reactions',
+
+ limit: {
+ duration: ms('1hour'),
+ max: 60,
+ minInterval: ms('3sec')
+ },
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '764d9fce-f9f2-4a0e-92b1-6ceac9a7ad37'
+ },
+
+ notReacted: {
+ message: 'You are not reacting to that note.',
+ code: 'NOT_REACTED',
+ id: '92f4426d-4196-4125-aa5b-02943e2ec8fc'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+ await deleteReaction(user, note).catch(e => {
+ if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') throw new ApiError(meta.errors.notReacted);
+ throw e;
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts
new file mode 100644
index 0000000000..26bfc1657d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts
@@ -0,0 +1,76 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { getNote } from '../../common/getters';
+import { ApiError } from '../../error';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { Notes } from '@/models/index';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: false as const,
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ }
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '12908022-2e21-46cd-ba6a-3edaf6093f46'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
+ .andWhere(`note.renoteId = :renoteId`, { renoteId: note.id })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ generateVisibilityQuery(query, user);
+ if (user) generateMutedUserQuery(query, user);
+ if (user) generateBlockedUserQuery(query, user);
+
+ const renotes = await query.take(ps.limit!).getMany();
+
+ return await Notes.packMany(renotes, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts
new file mode 100644
index 0000000000..0bb62413ae
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/replies.ts
@@ -0,0 +1,61 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { Notes } from '@/models/index';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: false as const,
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
+ .andWhere('note.replyId = :replyId', { replyId: ps.noteId })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ generateVisibilityQuery(query, user);
+ if (user) generateMutedUserQuery(query, user);
+ if (user) generateBlockedUserQuery(query, user);
+
+ const timeline = await query.take(ps.limit!).getMany();
+
+ return await Notes.packMany(timeline, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
new file mode 100644
index 0000000000..40e1499736
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
@@ -0,0 +1,134 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { Notes } from '@/models/index';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { Brackets } from 'typeorm';
+import { safeForSql } from '@/misc/safe-for-sql';
+import { normalizeForSearch } from '@/misc/normalize-for-search';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes', 'hashtags'],
+
+ params: {
+ tag: {
+ validator: $.optional.str,
+ },
+
+ query: {
+ validator: $.optional.arr($.arr($.str)),
+ },
+
+ reply: {
+ validator: $.optional.nullable.bool,
+ default: null,
+ },
+
+ renote: {
+ validator: $.optional.nullable.bool,
+ default: null,
+ },
+
+ withFiles: {
+ validator: $.optional.bool,
+ },
+
+ poll: {
+ validator: $.optional.nullable.bool,
+ default: null,
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+};
+
+export default define(meta, async (ps, me) => {
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ generateVisibilityQuery(query, me);
+ if (me) generateMutedUserQuery(query, me);
+ if (me) generateBlockedUserQuery(query, me);
+
+ try {
+ if (ps.tag) {
+ if (!safeForSql(ps.tag)) throw 'Injection';
+ query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`);
+ } else {
+ query.andWhere(new Brackets(qb => {
+ for (const tags of ps.query!) {
+ qb.orWhere(new Brackets(qb => {
+ for (const tag of tags) {
+ if (!safeForSql(tag)) throw 'Injection';
+ qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`);
+ }
+ }));
+ }
+ }));
+ }
+ } catch (e) {
+ if (e === 'Injection') return [];
+ throw e;
+ }
+
+ if (ps.reply != null) {
+ if (ps.reply) {
+ query.andWhere('note.replyId IS NOT NULL');
+ } else {
+ query.andWhere('note.replyId IS NULL');
+ }
+ }
+
+ if (ps.renote != null) {
+ if (ps.renote) {
+ query.andWhere('note.renoteId IS NOT NULL');
+ } else {
+ query.andWhere('note.renoteId IS NULL');
+ }
+ }
+
+ if (ps.withFiles) {
+ query.andWhere('note.fileIds != \'{}\'');
+ }
+
+ if (ps.poll != null) {
+ if (ps.poll) {
+ query.andWhere('note.hasPoll = TRUE');
+ } else {
+ query.andWhere('note.hasPoll = FALSE');
+ }
+ }
+
+ // Search notes
+ const notes = await query.take(ps.limit!).getMany();
+
+ return await Notes.packMany(notes, me);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts
new file mode 100644
index 0000000000..eb832a6b31
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/search.ts
@@ -0,0 +1,152 @@
+import $ from 'cafy';
+import es from '../../../../db/elasticsearch';
+import define from '../../define';
+import { Notes } from '@/models/index';
+import { In } from 'typeorm';
+import { ID } from '@/misc/cafy-id';
+import config from '@/config/index';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: false as const,
+
+ params: {
+ query: {
+ validator: $.str
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10
+ },
+
+ host: {
+ validator: $.optional.nullable.str,
+ default: undefined
+ },
+
+ userId: {
+ validator: $.optional.nullable.type(ID),
+ default: null
+ },
+
+ channelId: {
+ validator: $.optional.nullable.type(ID),
+ default: null
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+
+ errors: {
+ }
+};
+
+export default define(meta, async (ps, me) => {
+ if (es == null) {
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId);
+
+ if (ps.userId) {
+ query.andWhere('note.userId = :userId', { userId: ps.userId });
+ } else if (ps.channelId) {
+ query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
+ }
+
+ query
+ .andWhere('note.text ILIKE :q', { q: `%${ps.query}%` })
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ generateVisibilityQuery(query, me);
+ if (me) generateMutedUserQuery(query, me);
+ if (me) generateBlockedUserQuery(query, me);
+
+ const notes = await query.take(ps.limit!).getMany();
+
+ return await Notes.packMany(notes, me);
+ } else {
+ const userQuery = ps.userId != null ? [{
+ term: {
+ userId: ps.userId
+ }
+ }] : [];
+
+ const hostQuery = ps.userId == null ?
+ ps.host === null ? [{
+ bool: {
+ must_not: {
+ exists: {
+ field: 'userHost'
+ }
+ }
+ }
+ }] : ps.host !== undefined ? [{
+ term: {
+ userHost: ps.host
+ }
+ }] : []
+ : [];
+
+ const result = await es.search({
+ index: config.elasticsearch.index || 'misskey_note',
+ body: {
+ size: ps.limit!,
+ from: ps.offset,
+ query: {
+ bool: {
+ must: [{
+ simple_query_string: {
+ fields: ['text'],
+ query: ps.query.toLowerCase(),
+ default_operator: 'and'
+ },
+ }, ...hostQuery, ...userQuery]
+ }
+ },
+ sort: [{
+ _doc: 'desc'
+ }]
+ }
+ });
+
+ const hits = result.body.hits.hits.map((hit: any) => hit._id);
+
+ if (hits.length === 0) return [];
+
+ // Fetch found notes
+ const notes = await Notes.find({
+ where: {
+ id: In(hits)
+ },
+ order: {
+ id: -1
+ }
+ });
+
+ return await Notes.packMany(notes, me);
+ }
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts
new file mode 100644
index 0000000000..fad63d6483
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/show.ts
@@ -0,0 +1,43 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { getNote } from '../../common/getters';
+import { ApiError } from '../../error';
+import { Notes } from '@/models/index';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: false as const,
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ return await Notes.pack(note, user, {
+ detail: true
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts
new file mode 100644
index 0000000000..b3913a5e79
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/state.ts
@@ -0,0 +1,69 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { NoteFavorites, Notes, NoteThreadMutings, NoteWatchings } from '@/models/index';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ properties: {
+ isFavorited: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isWatching: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ isMutedThread: {
+ type: 'boolean' as const,
+ optional: false as const, nullable: false as const
+ },
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await Notes.findOneOrFail(ps.noteId);
+
+ const [favorite, watching, threadMuting] = await Promise.all([
+ NoteFavorites.count({
+ where: {
+ userId: user.id,
+ noteId: note.id,
+ },
+ take: 1
+ }),
+ NoteWatchings.count({
+ where: {
+ userId: user.id,
+ noteId: note.id,
+ },
+ take: 1
+ }),
+ NoteThreadMutings.count({
+ where: {
+ userId: user.id,
+ threadId: note.threadId || note.id,
+ },
+ take: 1
+ }),
+ ]);
+
+ return {
+ isFavorited: favorite !== 0,
+ isWatching: watching !== 0,
+ isMutedThread: threadMuting !== 0,
+ };
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
new file mode 100644
index 0000000000..2010d54331
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts
@@ -0,0 +1,54 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { getNote } from '../../../common/getters';
+import { ApiError } from '../../../error';
+import { Notes, NoteThreadMutings } from '@/models';
+import { genId } from '@/misc/gen-id';
+import readNote from '@/services/note/read';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '5ff67ada-ed3b-2e71-8e87-a1a421e177d2'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ const mutedNotes = await Notes.find({
+ where: [{
+ id: note.threadId || note.id,
+ }, {
+ threadId: note.threadId || note.id,
+ }],
+ });
+
+ await readNote(user.id, mutedNotes);
+
+ await NoteThreadMutings.insert({
+ id: genId(),
+ createdAt: new Date(),
+ threadId: note.threadId || note.id,
+ userId: user.id,
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts
new file mode 100644
index 0000000000..05d5691870
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts
@@ -0,0 +1,40 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import { getNote } from '../../../common/getters';
+import { ApiError } from '../../../error';
+import { NoteThreadMutings } from '@/models';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: 'bddd57ac-ceb3-b29d-4334-86ea5fae481a'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ await NoteThreadMutings.delete({
+ threadId: note.threadId || note.id,
+ userId: user.id,
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts
new file mode 100644
index 0000000000..1bd0e57d34
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts
@@ -0,0 +1,150 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { Notes, Followings } from '@/models/index';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
+import { activeUsersChart } from '@/services/chart/index';
+import { Brackets } from 'typeorm';
+import { generateRepliesQuery } from '../../common/generate-replies-query';
+import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
+import { generateChannelQuery } from '../../common/generate-channel-query';
+import { generateBlockedUserQuery } from '../../common/generate-block-query';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ params: {
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10,
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ sinceDate: {
+ validator: $.optional.num,
+ },
+
+ untilDate: {
+ validator: $.optional.num,
+ },
+
+ includeMyRenotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ includeRenotedMyNotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ includeLocalRenotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ withFiles: {
+ validator: $.optional.bool,
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+};
+
+export default define(meta, async (ps, user) => {
+ const hasFollowing = (await Followings.count({
+ where: {
+ followerId: user.id,
+ },
+ take: 1
+ })) !== 0;
+
+ //#region Construct query
+ const followingQuery = Followings.createQueryBuilder('following')
+ .select('following.followeeId')
+ .where('following.followerId = :followerId', { followerId: user.id });
+
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'),
+ ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+ .andWhere(new Brackets(qb => { qb
+ .where('note.userId = :meId', { meId: user.id });
+ if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`);
+ }))
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .setParameters(followingQuery.getParameters());
+
+ generateChannelQuery(query, user);
+ generateRepliesQuery(query, user);
+ generateVisibilityQuery(query, user);
+ generateMutedUserQuery(query, user);
+ generateMutedNoteQuery(query, user);
+ generateBlockedUserQuery(query, user);
+
+ if (ps.includeMyRenotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.userId != :meId', { meId: user.id });
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ if (ps.includeRenotedMyNotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteUserId != :meId', { meId: user.id });
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ if (ps.includeLocalRenotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteUserHost IS NOT NULL');
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ if (ps.withFiles) {
+ query.andWhere('note.fileIds != \'{}\'');
+ }
+ //#endregion
+
+ const timeline = await query.take(ps.limit!).getMany();
+
+ process.nextTick(() => {
+ if (user) {
+ activeUsersChart.update(user);
+ }
+ });
+
+ return await Notes.packMany(timeline, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts
new file mode 100644
index 0000000000..b56b1debdd
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/translate.ts
@@ -0,0 +1,89 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { getNote } from '../../common/getters';
+import { ApiError } from '../../error';
+import fetch from 'node-fetch';
+import config from '@/config/index';
+import { getAgentByUrl } from '@/misc/fetch';
+import { URLSearchParams } from 'url';
+import { fetchMeta } from '@/misc/fetch-meta';
+import { Notes } from '@/models';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: false as const,
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ },
+ targetLang: {
+ validator: $.str,
+ },
+ },
+
+ res: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: 'bea9b03f-36e0-49c5-a4db-627a029f8971'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ if (!(await Notes.isVisibleForMe(note, user ? user.id : null))) {
+ return 204; // TODO: 良い感じのエラー返す
+ }
+
+ if (note.text == null) {
+ return 204;
+ }
+
+ const instance = await fetchMeta();
+
+ if (instance.deeplAuthKey == null) {
+ return 204; // TODO: 良い感じのエラー返す
+ }
+
+ let targetLang = ps.targetLang;
+ if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
+
+ const params = new URLSearchParams();
+ params.append('auth_key', instance.deeplAuthKey);
+ params.append('text', note.text);
+ params.append('target_lang', targetLang);
+
+ const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
+
+ const res = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'User-Agent': config.userAgent,
+ Accept: 'application/json, */*'
+ },
+ body: params,
+ timeout: 10000,
+ agent: getAgentByUrl,
+ });
+
+ const json = await res.json();
+
+ return {
+ sourceLang: json.translations[0].detected_source_language,
+ text: json.translations[0].text
+ };
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts
new file mode 100644
index 0000000000..dce43d9d9c
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts
@@ -0,0 +1,52 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import deleteNote from '@/services/note/delete';
+import define from '../../define';
+import * as ms from 'ms';
+import { getNote } from '../../common/getters';
+import { ApiError } from '../../error';
+import { Notes, Users } from '@/models/index';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:notes',
+
+ limit: {
+ duration: ms('1hour'),
+ max: 300,
+ minInterval: ms('1sec')
+ },
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: 'efd4a259-2442-496b-8dd7-b255aa1a160f'
+ },
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ const renotes = await Notes.find({
+ userId: user.id,
+ renoteId: note.id
+ });
+
+ for (const note of renotes) {
+ deleteNote(await Users.findOneOrFail(user.id), note);
+ }
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
new file mode 100644
index 0000000000..32c370004c
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
@@ -0,0 +1,147 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { UserLists, UserListJoinings, Notes } from '@/models/index';
+import { makePaginationQuery } from '../../common/make-pagination-query';
+import { generateVisibilityQuery } from '../../common/generate-visibility-query';
+import { activeUsersChart } from '@/services/chart/index';
+import { Brackets } from 'typeorm';
+
+export const meta = {
+ tags: ['notes', 'lists'],
+
+ requireCredential: true as const,
+
+ params: {
+ listId: {
+ validator: $.type(ID),
+ },
+
+ limit: {
+ validator: $.optional.num.range(1, 100),
+ default: 10,
+ },
+
+ sinceId: {
+ validator: $.optional.type(ID),
+ },
+
+ untilId: {
+ validator: $.optional.type(ID),
+ },
+
+ sinceDate: {
+ validator: $.optional.num,
+ },
+
+ untilDate: {
+ validator: $.optional.num,
+ },
+
+ includeMyRenotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ includeRenotedMyNotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ includeLocalRenotes: {
+ validator: $.optional.bool,
+ default: true,
+ },
+
+ withFiles: {
+ validator: $.optional.bool,
+ },
+ },
+
+ res: {
+ type: 'array' as const,
+ optional: false as const, nullable: false as const,
+ items: {
+ type: 'object' as const,
+ optional: false as const, nullable: false as const,
+ ref: 'Note',
+ }
+ },
+
+ errors: {
+ noSuchList: {
+ message: 'No such list.',
+ code: 'NO_SUCH_LIST',
+ id: '8fb1fbd5-e476-4c37-9fb0-43d55b63a2ff'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const list = await UserLists.findOne({
+ id: ps.listId,
+ userId: user.id
+ });
+
+ if (list == null) {
+ throw new ApiError(meta.errors.noSuchList);
+ }
+
+ //#region Construct query
+ const listQuery = UserListJoinings.createQueryBuilder('joining')
+ .select('joining.userId')
+ .where('joining.userListId = :userListId', { userListId: list.id });
+
+ const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
+ .andWhere(`note.userId IN (${ listQuery.getQuery() })`)
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser')
+ .setParameters(listQuery.getParameters());
+
+ generateVisibilityQuery(query, user);
+
+ if (ps.includeMyRenotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.userId != :meId', { meId: user.id });
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ if (ps.includeRenotedMyNotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteUserId != :meId', { meId: user.id });
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ if (ps.includeLocalRenotes === false) {
+ query.andWhere(new Brackets(qb => {
+ qb.orWhere('note.renoteUserHost IS NOT NULL');
+ qb.orWhere('note.renoteId IS NULL');
+ qb.orWhere('note.text IS NOT NULL');
+ qb.orWhere('note.fileIds != \'{}\'');
+ qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+ }));
+ }
+
+ if (ps.withFiles) {
+ query.andWhere('note.fileIds != \'{}\'');
+ }
+ //#endregion
+
+ const timeline = await query.take(ps.limit!).getMany();
+
+ activeUsersChart.update(user);
+
+ return await Notes.packMany(timeline, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/watching/create.ts b/packages/backend/src/server/api/endpoints/notes/watching/create.ts
new file mode 100644
index 0000000000..4d182d3715
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/watching/create.ts
@@ -0,0 +1,37 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import watch from '@/services/note/watch';
+import { getNote } from '../../../common/getters';
+import { ApiError } from '../../../error';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: 'ea0e37a6-90a3-4f58-ba6b-c328ca206fc7'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ await watch(user.id, note);
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/watching/delete.ts b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts
new file mode 100644
index 0000000000..dd58c52b57
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts
@@ -0,0 +1,37 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import define from '../../../define';
+import unwatch from '@/services/note/unwatch';
+import { getNote } from '../../../common/getters';
+import { ApiError } from '../../../error';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true as const,
+
+ kind: 'write:account',
+
+ params: {
+ noteId: {
+ validator: $.type(ID),
+ }
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: '09b3695c-f72c-4731-a428-7cff825fc82e'
+ }
+ }
+};
+
+export default define(meta, async (ps, user) => {
+ const note = await getNote(ps.noteId).catch(e => {
+ if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
+ throw e;
+ });
+
+ await unwatch(user.id, note);
+});