diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /packages/backend/src/server/api/endpoints/notes | |
| parent | update deps (diff) | |
| download | sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'packages/backend/src/server/api/endpoints/notes')
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); +}); |