diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts index 2b7da2b70d..159f0f6771 100644 --- a/packages/backend/src/core/NoteDraftService.ts +++ b/packages/backend/src/core/NoteDraftService.ts @@ -4,39 +4,58 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import type { noteVisibilities, noteReactionAcceptances } from '@/types.js'; import { DI } from '@/di-symbols.js'; -import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel } from '@/models/_.js'; +import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { IPoll } from '@/models/Poll.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { isRenote, isQuote } from '@/misc/is-renote.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; export type NoteDraftOptions = { - reply?: MiNote | null; - renote?: MiNote | null; + replyId?: MiNote['id'] | null; + renoteId?: MiNote['id'] | null; text?: string | null; cw?: string | null; localOnly?: boolean | null; reactionAcceptance?: typeof noteReactionAcceptances[number]; visibility?: typeof noteVisibilities[number]; - files?: MiDriveFile[]; - visibleUsers?: MiUser[]; + fileIds?: MiDriveFile['id'][]; + visibleUserIds?: MiUser['id'][]; hashtag?: string; - channel?: MiChannel | null; + channelId?: MiChannel['id'] | null; poll?: (IPoll & { expiredAfter?: number | null }) | null; }; @Injectable() export class NoteDraftService { constructor( + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + @Inject(DI.noteDraftsRepository) private noteDraftsRepository: NoteDraftsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + private roleService: RoleService, private idService: IdService, + private noteEntityService: NoteEntityService, ) { } @@ -52,82 +71,59 @@ export class NoteDraftService { @bindThis public async create(me: MiLocalUser, data: NoteDraftOptions): Promise { - if (data.visibility == null) data.visibility = 'public'; - if (data.localOnly == null) data.localOnly = false; - if (data.reactionAcceptance === undefined) data.reactionAcceptance = null; - if (data.channel != null) { - data.visibility = 'public'; - data.visibleUsers = []; - data.localOnly = true; - } + //#region check draft limit const currentCount = await this.noteDraftsRepository.countBy({ userId: me.id, }); if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteDraftLimit) { - throw new IdentifiableError('c9a2c1d8-d153-40be-9cac-9fc2eb56b581', 'Too many drafts'); + throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts'); + } + //#endregion + + if (data.poll) { + if (typeof data.poll.expiresAt === 'number') { + if (data.poll.expiresAt < Date.now()) { + throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); + } + } else if (typeof data.poll.expiredAfter === 'number') { + data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); + } } - const draft = await this.noteDraftsRepository.insertOne({ - id: this.idService.gen(), - visibility: data.visibility, - cw: data.cw, - fileIds: data.files ? data.files.map(file => file.id) : [], - replyId: data.reply ? data.reply.id : null, - renoteId: data.renote ? data.renote.id : null, - channelId: data.channel ? data.channel.id : null, - text: data.text ?? null, - hashtag: data.hashtag, - hasPoll: data.poll != null, - pollChoices: data.poll ? data.poll.choices : [], - pollMultiple: data.poll ? data.poll.multiple : false, - pollExpiresAt: data.poll ? data.poll.expiresAt : null, - pollExpiredAfter: data.poll ? data.poll.expiredAfter : null, - localOnly: data.localOnly, - visibleUserIds: data.visibleUsers ? data.visibleUsers.map(user => user.id) : [], - userId: me.id, - }); + const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data); + + appliedDraft.id = this.idService.gen(); + appliedDraft.userId = me.id; + const draft = this.noteDraftsRepository.save(appliedDraft); return draft; } @bindThis - public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise { - if (data.visibility == null) data.visibility = 'public'; - if (data.localOnly == null) data.localOnly = false; - if (data.reactionAcceptance === undefined) data.reactionAcceptance = null; - if (data.channel != null) { - data.visibility = 'public'; - data.visibleUsers = []; - data.localOnly = true; - } - + public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise { const draft = await this.noteDraftsRepository.findOneBy({ id: draftId, userId: me.id, }); if (draft == null) { - throw new IdentifiableError('03a9514d-ff73-4c48-a55a-0282c8311ec6', 'No such note draft'); + throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft'); } - await this.noteDraftsRepository.update(draft.id, { - visibility: data.visibility, - cw: data.cw, - fileIds: data.files ? data.files.map(file => file.id) : [], - replyId: data.reply ? data.reply.id : null, - renoteId: data.renote ? data.renote.id : null, - channelId: data.channel ? data.channel.id : null, - text: data.text ?? null, - hashtag: data.hashtag, - hasPoll: data.poll != null, - pollChoices: data.poll ? data.poll.choices : [], - pollMultiple: data.poll ? data.poll.multiple : false, - pollExpiresAt: data.poll ? data.poll.expiresAt : null, - pollExpiredAfter: data.poll ? data.poll.expiredAfter : null, - visibleUserIds: data.visibleUsers ? data.visibleUsers.map(user => user.id) : [], - localOnly: data.localOnly, - }); + if (data.poll) { + if (typeof data.poll.expiresAt === 'number') { + if (data.poll.expiresAt < Date.now()) { + throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); + } + } else if (typeof data.poll.expiredAfter === 'number') { + data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); + } + } + + const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data); + + return await this.noteDraftsRepository.save(appliedDraft); } @bindThis @@ -138,9 +134,183 @@ export class NoteDraftService { }); if (draft == null) { - throw new IdentifiableError('03a9514d-ff73-4c48-a55a-0282c8311ec6', 'No such note draft'); + throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft'); } await this.noteDraftsRepository.delete(draft.id); } + + @bindThis + public async getDraft(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise { + // Errorは仮置きで置換が簡単になるようにしておく + + const draft = await this.noteDraftsRepository.findOneBy({ + id: draftId, + userId: me.id, + }); + + if (draft == null) { + throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft'); + } + + return draft; + } + + // 関連エンティティを取得し紐づける部分を共通化する + @bindThis + public async checkAndSetDraftNoteOptions( + me: MiLocalUser, + draft: MiNoteDraft, + data: NoteDraftOptions, + ): Promise { + data.visibility ??= 'public'; + data.localOnly ??= false; + if (data.reactionAcceptance === undefined) data.reactionAcceptance = null; + if (data.channelId != null) { + data.visibility = 'public'; + data.visibleUserIds = []; + data.localOnly = true; + } + + let appliedDraft = draft; + + //#region visibleUsers + let visibleUsers: MiUser[] = []; + if (data.visibleUserIds != null) { + visibleUsers = await this.usersRepository.findBy({ + id: In(data.visibleUserIds), + }); + } + //#endregion + + //#region files + let files: MiDriveFile[] = []; + const fileIds = data.fileIds ?? null; + if (fileIds != null) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: me.id, + fileIds: fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + + if (files.length !== fileIds.length) { + throw new IdentifiableError('b6992544-63e7-67f0-fa7f-32444b1b5306', 'No such drive file'); + } + } + //#endregion + + //#region renote + let renote: MiNote | null = null; + if (data.renoteId != null) { + renote = await this.notesRepository.findOneBy({ id: data.renoteId }); + + if (renote == null) { + throw new IdentifiableError('64929870-2540-4d11-af41-3b484d78c956', 'No such renote'); + } else if (isRenote(renote) && !isQuote(renote)) { + throw new IdentifiableError('76cc5583-5a14-4ad3-8717-0298507e32db', 'Cannot renote'); + } + + // Check blocking + if (renote.userId !== me.id) { + const blockExist = await this.blockingsRepository.exists({ + where: { + blockerId: renote.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new IdentifiableError('075ca298-e6e7-485a-b570-51a128bb5168', 'You have been blocked by the user'); + } + } + + if (renote.visibility === 'followers' && renote.userId !== me.id) { + // 他人のfollowers noteはreject + throw new IdentifiableError('81eb8188-aea1-4e35-9a8f-3334a3be9855', 'Cannot Renote Due to Visibility'); + } else if (renote.visibility === 'specified') { + // specified / direct noteはreject + throw new IdentifiableError('81eb8188-aea1-4e35-9a8f-3334a3be9855', 'Cannot Renote Due to Visibility'); + } + + if (renote.channelId && renote.channelId !== data.channelId) { + // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック + // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する + const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId }); + if (renoteChannel == null) { + // リノートしたいノートが書き込まれているチャンネルがない + throw new IdentifiableError('6815399a-6f13-4069-b60d-ed5156249d12', 'No such channel'); + } else if (!renoteChannel.allowRenoteToExternal) { + // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 + throw new IdentifiableError('ed1952ac-2d26-4957-8b30-2deda76bedf7', 'Cannot Renote to External'); + } + } + } + //#endregion + + //#region reply + let reply: MiNote | null = null; + if (data.replyId != null) { + // Fetch reply + reply = await this.notesRepository.findOneBy({ id: data.replyId }); + + if (reply == null) { + throw new IdentifiableError('c4721841-22fc-4bb7-ad3d-897ef1d375b5', 'No such reply'); + } else if (isRenote(reply) && !isQuote(reply)) { + throw new IdentifiableError('e6c10b57-2c09-4da3-bd4d-eda05d51d140', 'Cannot reply To Pure Renote'); + } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { + throw new IdentifiableError('593c323c-6b6a-4501-a25c-2f36bd2a93d6', 'Cannot reply To Invisible Note'); + } else if (reply.visibility === 'specified' && data.visibility !== 'specified') { + throw new IdentifiableError('215dbc76-336c-4d2a-9605-95766ba7dab0', 'Cannot reply To Specified Note With Extended Visibility'); + } + + // Check blocking + if (reply.userId !== me.id) { + const blockExist = await this.blockingsRepository.exists({ + where: { + blockerId: reply.userId, + blockeeId: me.id, + }, + }); + if (blockExist) { + throw new IdentifiableError('075ca298-e6e7-485a-b570-51a128bb5168', 'You have been blocked by the user'); + } + } + } + //#endregion + + //#region channel + let channel: MiChannel | null = null; + if (data.channelId != null) { + channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false }); + + if (channel == null) { + throw new IdentifiableError('6815399a-6f13-4069-b60d-ed5156249d12', 'No such channel'); + } + } + //#endregion + + appliedDraft = { + ...appliedDraft, + visibility: data.visibility, + cw: data.cw ?? null, + fileIds: fileIds ?? [], + replyId: data.replyId ?? null, + renoteId: data.renoteId ?? null, + channelId: data.channelId ?? null, + text: data.text ?? null, + hashtag: data.hashtag ?? null, + hasPoll: data.poll != null, + pollChoices: data.poll ? data.poll.choices : [], + pollMultiple: data.poll ? data.poll.multiple : false, + pollExpiresAt: data.poll ? data.poll.expiresAt : null, + pollExpiredAfter: data.poll ? data.poll.expiredAfter ?? null : null, + visibleUserIds: data.visibleUserIds ?? [], + localOnly: data.localOnly, + reactionAcceptance: data.reactionAcceptance, + } satisfies MiNoteDraft; + + return appliedDraft; + } } diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts index a62c79cb82..edae254bb8 100644 --- a/packages/backend/src/models/NoteDraft.ts +++ b/packages/backend/src/models/NoteDraft.ts @@ -104,7 +104,7 @@ export class MiNoteDraft { @Column('varchar', { length: 128, nullable: true, }) - public hashtag: string; + public hashtag: string | null; @Index() @Column({ diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts index 1029f21ee7..1c28ec22d0 100644 --- a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts @@ -3,18 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import ms from 'ms'; -import { In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteDraftService } from '@/core/NoteDraftService.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import type { MiNote, MiChannel, MiDriveFile, UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; -import { MiUser } from '@/models/_.js'; import { ApiError } from '@/server/api/error.js'; -import { isQuote, isRenote } from '@/misc/is-renote.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteDraftEntityService } from '@/core/entities/NoteDraftEntityService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -129,6 +123,12 @@ export const meta = { code: 'TOO_MANY_DRAFTS', id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', }, + + cannotRenoteToExternal: { + message: 'Cannot Renote to External.', + code: 'CANNOT_RENOTE_TO_EXTERNAL', + id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7', + }, }, limit: { @@ -190,147 +190,12 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - private noteEntityService: NoteEntityService, private noteDraftService: NoteDraftService, private noteDraftEntityService: NoteDraftEntityService, ) { super(meta, paramDef, async (ps, me) => { - // TODO: ノートendpointのバリデーションとServiceとして共通化 - let visibleUsers: MiUser[] = []; - if (ps.visibleUserIds) { - visibleUsers = await this.usersRepository.findBy({ - id: In(ps.visibleUserIds), - }); - } - - let files: MiDriveFile[] = []; - const fileIds = ps.fileIds ?? null; - if (fileIds != null) { - files = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.userId = :userId AND file.id IN (:...fileIds)', { - userId: me.id, - fileIds, - }) - .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') - .setParameters({ fileIds }) - .getMany(); - - if (files.length !== fileIds.length) { - throw new ApiError(meta.errors.noSuchFile); - } - } - - let renote: MiNote | null = null; - if (ps.renoteId != null) { - // Fetch renote to note - renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); - - if (renote == null) { - throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (isRenote(renote) && !isQuote(renote)) { - throw new ApiError(meta.errors.cannotReRenote); - } - - // Check blocking - if (renote.userId !== me.id) { - const blockExist = await this.blockingsRepository.exists({ - where: { - blockerId: renote.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - - if (renote.visibility === 'followers' && renote.userId !== me.id) { - // 他人のfollowers noteはreject - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } else if (renote.visibility === 'specified') { - // specified / direct noteはreject - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } - - if (renote.channelId && renote.channelId !== ps.channelId) { - // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック - // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する - const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId }); - if (renoteChannel == null) { - // リノートしたいノートが書き込まれているチャンネルが無い - throw new ApiError(meta.errors.noSuchChannel); - } else if (!renoteChannel.allowRenoteToExternal) { - // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 - throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); - } - } - } - - let reply: MiNote | null = null; - if (ps.replyId != null) { - // Fetch reply - reply = await this.notesRepository.findOneBy({ id: ps.replyId }); - - if (reply == null) { - throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (isRenote(reply) && !isQuote(reply)) { - throw new ApiError(meta.errors.cannotReplyToPureRenote); - } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { - throw new ApiError(meta.errors.cannotReplyToInvisibleNote); - } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { - throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); - } - - // Check blocking - if (reply.userId !== me.id) { - const blockExist = await this.blockingsRepository.exists({ - where: { - blockerId: reply.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - 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; - } - } - - let channel: MiChannel | null = null; - if (ps.channelId != null) { - channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - } - const draft = await this.noteDraftService.create(me, { - files: files, + fileIds: ps.fileIds, poll: ps.poll ? { choices: ps.poll.choices, multiple: ps.poll.multiple ?? false, @@ -338,18 +203,47 @@ export default class extends Endpoint { // eslint- expiredAfter: ps.poll.expiredAfter ?? null, } : undefined, text: ps.text ?? null, - reply, - renote, + replyId: ps.replyId ?? undefined, + renoteId: ps.renoteId ?? undefined, cw: ps.cw ?? null, ...(ps.hashtag ? { hashtag: ps.hashtag } : {}), localOnly: ps.localOnly, reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, - visibleUsers, - channel, + visibleUserIds: ps.visibleUserIds ?? [], + channelId: ps.channelId ?? undefined, }).catch((err) => { - if (err instanceof IdentifiableError && err.id === 'c9a2c1d8-d153-40be-9cac-9fc2eb56b581') { - throw new ApiError(meta.errors.tooManyDrafts); + if (err instanceof IdentifiableError) { + switch (err.id) { + case '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8': + throw new ApiError(meta.errors.tooManyDrafts); + case '04da457d-b083-4055-9082-955525eda5a5': + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + case 'b6992544-63e7-67f0-fa7f-32444b1b5306': + throw new ApiError(meta.errors.noSuchFile); + case '64929870-2540-4d11-af41-3b484d78c956': + throw new ApiError(meta.errors.noSuchRenoteTarget); + case '76cc5583-5a14-4ad3-8717-0298507e32db': + throw new ApiError(meta.errors.cannotReRenote); + case '075ca298-e6e7-485a-b570-51a128bb5168': + throw new ApiError(meta.errors.youHaveBeenBlocked); + case '81eb8188-aea1-4e35-9a8f-3334a3be9855': + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + case '6815399a-6f13-4069-b60d-ed5156249d12': + throw new ApiError(meta.errors.noSuchChannel); + case 'ed1952ac-2d26-4957-8b30-2deda76bedf7': + throw new ApiError(meta.errors.cannotRenoteToExternal); + case 'c4721841-22fc-4bb7-ad3d-897ef1d375b5': + throw new ApiError(meta.errors.noSuchReplyTarget); + case 'e6c10b57-2c09-4da3-bd4d-eda05d51d140': + throw new ApiError(meta.errors.cannotReplyToPureRenote); + case '593c323c-6b6a-4501-a25c-2f36bd2a93d6': + throw new ApiError(meta.errors.cannotReplyToInvisibleNote); + case '215dbc76-336c-4d2a-9605-95766ba7dab0': + throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); + default: + throw err; + } } throw err; }); diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts index fbe6b5f993..ee221fb765 100644 --- a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts +++ b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts @@ -5,17 +5,11 @@ import { Injectable } from '@nestjs/common'; import ms from 'ms'; -import { In } from 'typeorm'; -import { Inject } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteDraftService } from '@/core/NoteDraftService.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import type { MiNote, MiChannel, MiDriveFile, UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; -import { isRenote, isQuote } from '@/misc/is-renote.js'; -import type { MiUser } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteDraftEntityService } from '@/core/entities/NoteDraftEntityService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -76,9 +70,9 @@ export const meta = { id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15', }, - cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: { + cannotReplyToSpecifiedNoteWithExtendedVisibility: { message: 'You cannot reply to a specified visibility note with extended visibility.', - code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY', + code: 'CANNOT_REPLY_TO_SPECIFIED_NOTE_WITH_EXTENDED_VISIBILITY', id: 'ed940410-535c-4d5e-bfa3-af798671e93c', }, @@ -135,6 +129,36 @@ export const meta = { code: 'ACCESS_DENIED', id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e', }, + + noSuchRenote: { + message: 'No such renote.', + code: 'NO_SUCH_RENOTE', + id: '64929870-2540-4d11-af41-3b484d78c956', + }, + + cannotRenote: { + message: 'Cannot renote.', + code: 'CANNOT_RENOTE', + id: '76cc5583-5a14-4ad3-8717-0298507e32db', + }, + + cannotRenoteToExternal: { + message: 'Cannot Renote to External.', + code: 'CANNOT_RENOTE_TO_EXTERNAL', + id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7', + }, + + noSuchReply: { + message: 'No such reply.', + code: 'NO_SUCH_REPLY', + id: 'c4721841-22fc-4bb7-ad3d-897ef1d375b5', + }, + + cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: { + message: 'You cannot reply to a specified visibility note with extended visibility.', + code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY', + id: '215dbc76-336c-4d2a-9605-95766ba7dab0', + }, }, limit: { @@ -198,157 +222,12 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - - private noteEntityService: NoteEntityService, private noteDraftService: NoteDraftService, private noteDraftEntityService: NoteDraftEntityService, ) { super(meta, paramDef, async (ps, me) => { - const draft = await this.noteDraftService.get(me, ps.draftId); - - if (draft == null) { - throw new ApiError(meta.errors.noSuchNoteDraft); - } - - if (draft.userId !== me.id) { - throw new ApiError(meta.errors.accessDenied); - } - - // TODO: ノートendpointのバリデーションとServiceとして共通化 - let visibleUsers: MiUser[] = []; - if (ps.visibleUserIds) { - visibleUsers = await this.usersRepository.findBy({ - id: In(ps.visibleUserIds), - }); - } - - let files: MiDriveFile[] = []; - const fileIds = ps.fileIds ?? null; - if (fileIds != null) { - files = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.userId = :userId AND file.id IN (:...fileIds)', { - userId: me.id, - fileIds, - }) - .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') - .setParameters({ fileIds }) - .getMany(); - - if (files.length !== fileIds.length) { - throw new ApiError(meta.errors.noSuchFile); - } - } - - let renote: MiNote | null = null; - if (ps.renoteId != null) { - // Fetch renote to note - renote = await this.notesRepository.findOneBy({ id: ps.renoteId }); - - if (renote == null) { - throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (isRenote(renote) && !isQuote(renote)) { - throw new ApiError(meta.errors.cannotReRenote); - } - - // Check blocking - if (renote.userId !== me.id) { - const blockExist = await this.blockingsRepository.exists({ - where: { - blockerId: renote.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - - if (renote.visibility === 'followers' && renote.userId !== me.id) { - // 他人のfollowers noteはreject - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } else if (renote.visibility === 'specified') { - // specified / direct noteはreject - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } - - if (renote.channelId && renote.channelId !== ps.channelId) { - // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック - // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する - const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId }); - if (renoteChannel == null) { - // リノートしたいノートが書き込まれているチャンネルが無い - throw new ApiError(meta.errors.noSuchChannel); - } else if (!renoteChannel.allowRenoteToExternal) { - // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 - throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); - } - } - } - - let reply: MiNote | null = null; - if (ps.replyId != null) { - // Fetch reply - reply = await this.notesRepository.findOneBy({ id: ps.replyId }); - - if (reply == null) { - throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (isRenote(reply) && !isQuote(reply)) { - throw new ApiError(meta.errors.cannotReplyToPureRenote); - } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { - throw new ApiError(meta.errors.cannotReplyToInvisibleNote); - } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { - throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); - } - - // Check blocking - if (reply.userId !== me.id) { - const blockExist = await this.blockingsRepository.exists({ - where: { - blockerId: reply.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - 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; - } - } - - let channel: MiChannel | null = null; - if (ps.channelId != null) { - channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - } - - await this.noteDraftService.update(me, draft.id, { - files: files, + const draft = await this.noteDraftService.update(me, ps.draftId, { + fileIds: ps.fileIds, poll: ps.poll ? { choices: ps.poll.choices, multiple: ps.poll.multiple ?? false, @@ -356,15 +235,61 @@ export default class extends Endpoint { // eslint- expiredAfter: ps.poll.expiredAfter ?? null, } : undefined, text: ps.text ?? null, - reply, - renote, + replyId: ps.replyId ?? undefined, + renoteId: ps.renoteId ?? undefined, cw: ps.cw ?? null, ...(ps.hashtag ? { hashtag: ps.hashtag } : {}), localOnly: ps.localOnly, reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, - visibleUsers, - channel, + visibleUserIds: ps.visibleUserIds ?? [], + channelId: ps.channelId ?? undefined, + }).catch((err) => { + if (err instanceof IdentifiableError) { + switch (err.id) { + case '49cd6b9d-848e-41ee-b0b9-adaca711a6b1': + throw new ApiError(meta.errors.noSuchNoteDraft); + case '04da457d-b083-4055-9082-955525eda5a5': + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + case 'b6992544-63e7-67f0-fa7f-32444b1b5306': + throw new ApiError(meta.errors.noSuchFile); + case '64929870-2540-4d11-af41-3b484d78c956': + throw new ApiError(meta.errors.noSuchRenote); + case '76cc5583-5a14-4ad3-8717-0298507e32db': + throw new ApiError(meta.errors.cannotRenote); + case '075ca298-e6e7-485a-b570-51a128bb5168': + throw new ApiError(meta.errors.youHaveBeenBlocked); + case '81eb8188-aea1-4e35-9a8f-3334a3be9855': + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + case '6815399a-6f13-4069-b60d-ed5156249d12': + throw new ApiError(meta.errors.noSuchChannel); + case 'ed1952ac-2d26-4957-8b30-2deda76bedf7': + throw new ApiError(meta.errors.cannotRenoteToExternal); + case 'c4721841-22fc-4bb7-ad3d-897ef1d375b5': + throw new ApiError(meta.errors.noSuchReply); + case 'e6c10b57-2c09-4da3-bd4d-eda05d51d140': + throw new ApiError(meta.errors.cannotReplyToPureRenote); + case '593c323c-6b6a-4501-a25c-2f36bd2a93d6': + throw new ApiError(meta.errors.cannotReplyToInvisibleNote); + case '215dbc76-336c-4d2a-9605-95766ba7dab0': + throw new ApiError(meta.errors.cannotReplyToSpecifiedNoteWithExtendedVisibility); + case 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4': + throw new ApiError(meta.errors.noSuchRenoteTarget); + case 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a': + throw new ApiError(meta.errors.cannotReRenote); + case '749ee0f6-d3da-459a-bf02-282e2da4292c': + throw new ApiError(meta.errors.noSuchReplyTarget); + case '33510210-8452-094c-6227-4a6c05d99f00': + throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); + case 'aa6e01d3-a85c-669d-758a-76aab43af334': + throw new ApiError(meta.errors.containsProhibitedWords); + case '4de0363a-3046-481b-9b0f-feff3e211025': + throw new ApiError(meta.errors.containsTooManyMentions); + default: + throw err; + } + } + throw err; }); const updatedDraft = await this.noteDraftEntityService.pack(draft, me);