summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api/endpoints/notes/schedule
diff options
context:
space:
mode:
authorNoriDev <m1nthing2322@gmail.com>2024-10-31 13:52:01 +0900
committerMarie <github@yuugi.dev>2024-12-09 05:31:03 +0100
commit2528508cff9d8c90abd33e46b15220a49a00e2e2 (patch)
tree1a7aa5717656fc29e67eed0f86feb5fec33d8f1e /packages/backend/src/server/api/endpoints/notes/schedule
parentmerge: Implement new SkRateLimiterServer with Leaky Bucket rate limits (resol... (diff)
downloadsharkey-2528508cff9d8c90abd33e46b15220a49a00e2e2.tar.gz
sharkey-2528508cff9d8c90abd33e46b15220a49a00e2e2.tar.bz2
sharkey-2528508cff9d8c90abd33e46b15220a49a00e2e2.zip
feat: 노트 게시를 예약할 수 있음 (yojo-art/cherrypick#483, [Type4ny-Project/Type4ny@271c872c](https://github.com/Type4ny-Project/Type4ny/commit/271c872c97f215ef5d8e0be62251dd422a52e5b1))
Diffstat (limited to 'packages/backend/src/server/api/endpoints/notes/schedule')
-rw-r--r--packages/backend/src/server/api/endpoints/notes/schedule/create.ts393
-rw-r--r--packages/backend/src/server/api/endpoints/notes/schedule/delete.ts67
-rw-r--r--packages/backend/src/server/api/endpoints/notes/schedule/list.ts128
3 files changed, 588 insertions, 0 deletions
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
new file mode 100644
index 0000000000..ecdfa4bf2e
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
@@ -0,0 +1,393 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import ms from 'ms';
+import { In } from 'typeorm';
+import { Inject, Injectable } from '@nestjs/common';
+import { isPureRenote } from 'cherrypick-js/note.js';
+import type { MiUser } from '@/models/User.js';
+import type {
+ UsersRepository,
+ NotesRepository,
+ BlockingsRepository,
+ DriveFilesRepository,
+ ChannelsRepository,
+ NoteScheduleRepository,
+} from '@/models/_.js';
+import type { MiDriveFile } from '@/models/DriveFile.js';
+import type { MiNote } from '@/models/Note.js';
+import type { MiChannel } from '@/models/Channel.js';
+import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { QueueService } from '@/core/QueueService.js';
+import { IdService } from '@/core/IdService.js';
+import { MiScheduleNoteType } from '@/models/NoteSchedule.js';
+import { RoleService } from '@/core/RoleService.js';
+import { ApiError } from '../../../error.js';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true,
+
+ prohibitMoved: true,
+
+ limit: {
+ duration: ms('1hour'),
+ max: 300,
+ },
+
+ kind: 'write:notes-schedule',
+
+ errors: {
+ scheduleNoteMax: {
+ message: 'Schedule note max.',
+ code: 'SCHEDULE_NOTE_MAX',
+ id: '168707c3-e7da-4031-989e-f42aa3a274b2',
+ },
+ 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',
+ },
+
+ cannotRenoteDueToVisibility: {
+ message: 'You can not Renote due to target visibility.',
+ code: 'CANNOT_RENOTE_DUE_TO_VISIBILITY',
+ id: 'be9529e9-fe72-4de0-ae43-0b363c4938af',
+ },
+
+ 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',
+ },
+
+ cannotCreateAlreadyExpiredPoll: {
+ message: 'Poll is already expired.',
+ code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
+ id: '04da457d-b083-4055-9082-955525eda5a5',
+ },
+
+ cannotCreateAlreadyExpiredSchedule: {
+ message: 'Schedule is already expired.',
+ code: 'CANNOT_CREATE_ALREADY_EXPIRED_SCHEDULE',
+ id: '8a9bfb90-fc7e-4878-a3e8-d97faaf5fb07',
+ },
+
+ noSuchChannel: {
+ message: 'No such channel.',
+ code: 'NO_SUCH_CHANNEL',
+ id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb',
+ },
+ noSuchSchedule: {
+ message: 'No such schedule.',
+ code: 'NO_SUCH_SCHEDULE',
+ id: '44dee229-8da1-4a61-856d-e3a4bbc12032',
+ },
+ youHaveBeenBlocked: {
+ message: 'You have been blocked by this user.',
+ code: 'YOU_HAVE_BEEN_BLOCKED',
+ id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
+ },
+
+ noSuchFile: {
+ message: 'Some files are not found.',
+ code: 'NO_SUCH_FILE',
+ id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
+ },
+
+ cannotRenoteOutsideOfChannel: {
+ message: 'Cannot renote outside of channel.',
+ code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL',
+ id: '33510210-8452-094c-6227-4a6c05d99f00',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
+ visibleUserIds: { type: 'array', uniqueItems: true, items: {
+ type: 'string', format: 'misskey:id',
+ } },
+ cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
+ reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
+ disableRightClick: { type: 'boolean', default: false },
+ noExtractMentions: { type: 'boolean', default: false },
+ noExtractHashtags: { type: 'boolean', default: false },
+ noExtractEmojis: { type: 'boolean', default: false },
+ replyId: { type: 'string', format: 'misskey:id', nullable: true },
+ renoteId: { type: 'string', format: 'misskey:id', nullable: true },
+
+ // anyOf内にバリデーションを書いても最初の一つしかチェックされない
+ // See https://github.com/misskey-dev/misskey/pull/10082
+ text: {
+ type: 'string',
+ minLength: 1,
+ maxLength: MAX_NOTE_TEXT_LENGTH,
+ nullable: true,
+ },
+ fileIds: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 1,
+ maxItems: 16,
+ items: { type: 'string', format: 'misskey:id' },
+ },
+ mediaIds: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 1,
+ maxItems: 16,
+ items: { type: 'string', format: 'misskey:id' },
+ },
+ poll: {
+ type: 'object',
+ nullable: true,
+ properties: {
+ choices: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 2,
+ maxItems: 10,
+ items: { type: 'string', minLength: 1, maxLength: 50 },
+ },
+ multiple: { type: 'boolean' },
+ expiresAt: { type: 'integer', nullable: true },
+ expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
+ },
+ required: ['choices'],
+ },
+ event: {
+ type: 'object',
+ nullable: true,
+ properties: {
+ title: { type: 'string', minLength: 1, maxLength: 128, nullable: false },
+ start: { type: 'integer', nullable: false },
+ end: { type: 'integer', nullable: true },
+ metadata: { type: 'object' },
+ },
+ },
+ scheduleNote: {
+ type: 'object',
+ nullable: false,
+ properties: {
+ scheduledAt: { type: 'integer', nullable: false },
+ },
+ },
+ },
+ // (re)note with text, files and poll are optional
+ anyOf: [
+ { required: ['text'] },
+ { required: ['renoteId'] },
+ { required: ['fileIds'] },
+ { required: ['mediaIds'] },
+ { required: ['poll'] },
+ ],
+ required: ['scheduleNote'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.notesRepository)
+ private notesRepository: NotesRepository,
+
+ @Inject(DI.noteScheduleRepository)
+ private noteScheduleRepository: NoteScheduleRepository,
+
+ @Inject(DI.blockingsRepository)
+ private blockingsRepository: BlockingsRepository,
+
+ @Inject(DI.driveFilesRepository)
+ private driveFilesRepository: DriveFilesRepository,
+
+ @Inject(DI.channelsRepository)
+ private channelsRepository: ChannelsRepository,
+
+ private queueService: QueueService,
+ private roleService: RoleService,
+ private idService: IdService,
+ ) {
+ super({
+ ...meta,
+ }, paramDef, async (ps, me) => {
+ const scheduleNoteCount = await this.noteScheduleRepository.countBy({ userId: me.id });
+ const scheduleNoteMax = (await this.roleService.getUserPolicies(me.id)).scheduleNoteMax;
+ if (scheduleNoteCount >= scheduleNoteMax) {
+ throw new ApiError(meta.errors.scheduleNoteMax);
+ }
+ let visibleUsers: MiUser[] = [];
+ if (ps.visibleUserIds) {
+ visibleUsers = await this.usersRepository.findBy({
+ id: In(ps.visibleUserIds),
+ });
+ }
+
+ let files: MiDriveFile[] = [];
+ const fileIds = ps.fileIds ?? ps.mediaIds ?? 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 (isPureRenote(renote)) {
+ throw new ApiError(meta.errors.cannotReRenote);
+ }
+
+ // Check blocking
+ if (renote.userId !== me.id) {
+ const blockExist = await this.blockingsRepository.exist({
+ 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);
+ }
+ }
+
+ 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 (isPureRenote(reply)) {
+ throw new ApiError(meta.errors.cannotReplyToPureRenote);
+ }
+
+ // Check blocking
+ if (reply.userId !== me.id) {
+ const blockExist = await this.blockingsRepository.exist({
+ where: {
+ blockerId: reply.userId,
+ blockeeId: me.id,
+ },
+ });
+ if (blockExist) {
+ throw new ApiError(meta.errors.youHaveBeenBlocked);
+ }
+ }
+ }
+
+ if (ps.poll) {
+ let scheduleNote_scheduledAt = Date.now();
+ if (typeof ps.scheduleNote.scheduledAt === 'number') {
+ scheduleNote_scheduledAt = ps.scheduleNote.scheduledAt;
+ }
+ if (typeof ps.poll.expiresAt === 'number') {
+ if (ps.poll.expiresAt < scheduleNote_scheduledAt) {
+ throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
+ }
+ } else if (typeof ps.poll.expiredAfter === 'number') {
+ ps.poll.expiresAt = scheduleNote_scheduledAt + ps.poll.expiredAfter;
+ }
+ }
+ if (typeof ps.scheduleNote.scheduledAt === 'number') {
+ if (ps.scheduleNote.scheduledAt < Date.now()) {
+ throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
+ }
+ } else {
+ throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
+ }
+ const note:MiScheduleNoteType = {
+ createdAt: new Date(ps.scheduleNote.scheduledAt!).toISOString(),
+ files: files.map(f => f.id),
+ poll: ps.poll ? {
+ choices: ps.poll.choices,
+ multiple: ps.poll.multiple ?? false,
+ expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt).toISOString() : null,
+ } : undefined,
+ text: ps.text ?? undefined,
+ reply: reply?.id,
+ renote: renote?.id,
+ cw: ps.cw,
+ localOnly: false,
+ reactionAcceptance: ps.reactionAcceptance,
+ visibility: ps.visibility,
+ visibleUsers,
+ apMentions: ps.noExtractMentions ? [] : undefined,
+ apHashtags: ps.noExtractHashtags ? [] : undefined,
+ apEmojis: ps.noExtractEmojis ? [] : undefined,
+ event: ps.event ? {
+ start: new Date(ps.event.start!).toISOString(),
+ end: ps.event.end ? new Date(ps.event.end).toISOString() : null,
+ title: ps.event.title!,
+ metadata: ps.event.metadata ?? {},
+ } : undefined,
+ disableRightClick: ps.disableRightClick,
+ };
+
+ if (ps.scheduleNote.scheduledAt) {
+ me.token = null;
+ const noteId = this.idService.gen(new Date().getTime());
+ await this.noteScheduleRepository.insert({
+ id: noteId,
+ note: note,
+ userId: me.id,
+ scheduledAt: new Date(ps.scheduleNote.scheduledAt),
+ });
+
+ const delay = new Date(ps.scheduleNote.scheduledAt).getTime() - Date.now();
+ await this.queueService.ScheduleNotePostQueue.add(String(delay), {
+ scheduleNoteId: noteId,
+ }, {
+ delay,
+ removeOnComplete: true,
+ jobId: noteId,
+ });
+ }
+
+ return '';
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
new file mode 100644
index 0000000000..df406f99f0
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
@@ -0,0 +1,67 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import ms from 'ms';
+import { Inject, Injectable } from '@nestjs/common';
+import type { NoteScheduleRepository } from '@/models/_.js';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { QueueService } from '@/core/QueueService.js';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true,
+ kind: 'write:notes-schedule',
+
+ limit: {
+ duration: ms('1hour'),
+ max: 300,
+ },
+
+ errors: {
+ noSuchNote: {
+ message: 'No such note.',
+ code: 'NO_SUCH_NOTE',
+ id: 'a58056ba-8ba1-4323-8ebf-e0b585bc244f',
+ },
+ permissionDenied: {
+ message: 'Permission denied.',
+ code: 'PERMISSION_DENIED',
+ id: 'c0da2fed-8f61-4c47-a41d-431992607b5c',
+ httpStatusCode: 403,
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ noteId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['noteId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.noteScheduleRepository)
+ private noteScheduleRepository: NoteScheduleRepository,
+ private queueService: QueueService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const note = await this.noteScheduleRepository.findOneBy({ id: ps.noteId });
+ if (note === null) {
+ throw new ApiError(meta.errors.noSuchNote);
+ }
+ if (note.userId !== me.id) {
+ throw new ApiError(meta.errors.permissionDenied);
+ }
+ await this.noteScheduleRepository.delete({ id: ps.noteId });
+ await this.queueService.ScheduleNotePostQueue.remove(ps.noteId);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
new file mode 100644
index 0000000000..88da4f4043
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
@@ -0,0 +1,128 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import ms from 'ms';
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { DI } from '@/di-symbols.js';
+import type { MiNote, MiNoteSchedule, NoteScheduleRepository } from '@/models/_.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { Packed } from '@/misc/json-schema.js';
+import { noteVisibilities } from '@/types.js';
+
+export const meta = {
+ tags: ['notes'],
+
+ requireCredential: true,
+ kind: 'read:notes-schedule',
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ id: { type: 'string', format: 'misskey:id', optional: false, nullable: false },
+ note: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ createdAt: { type: 'string', optional: false, nullable: false },
+ text: { type: 'string', optional: true, nullable: false },
+ cw: { type: 'string', optional: true, nullable: true },
+ fileIds: { type: 'array', optional: false, nullable: false, items: { type: 'string', format: 'misskey:id', optional: false, nullable: false } },
+ visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], optional: false, nullable: false },
+ visibleUsers: {
+ type: 'array', optional: false, nullable: false, items: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'UserLite',
+ },
+ },
+ user: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'User',
+ },
+ reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
+ isSchedule: { type: 'boolean', optional: false, nullable: false },
+ },
+ },
+ userId: { type: 'string', optional: false, nullable: false },
+ scheduledAt: { type: 'string', optional: false, nullable: false },
+ },
+ },
+ },
+ limit: {
+ duration: ms('1hour'),
+ max: 300,
+ },
+
+ errors: {
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ },
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.noteScheduleRepository)
+ private noteScheduleRepository: NoteScheduleRepository,
+
+ private userEntityService: UserEntityService,
+ private queryService: QueryService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const query = this.queryService.makePaginationQuery(this.noteScheduleRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
+ .andWhere('note.userId = :userId', { userId: me.id });
+ const scheduleNotes = await query.limit(ps.limit).getMany();
+ const user = await this.userEntityService.pack(me, me);
+ const scheduleNotesPack: {
+ id: string;
+ note: {
+ text?: string;
+ cw?: string|null;
+ fileIds: string[];
+ visibility: typeof noteVisibilities[number];
+ visibleUsers: Packed<'UserLite'>[];
+ reactionAcceptance: MiNote['reactionAcceptance'];
+ user: Packed<'User'>;
+ createdAt: string;
+ isSchedule: boolean;
+ };
+ userId: string;
+ scheduledAt: string;
+ }[] = await Promise.all(scheduleNotes.map(async (item: MiNoteSchedule) => {
+ return {
+ ...item,
+ scheduledAt: item.scheduledAt.toISOString(),
+ note: {
+ ...item.note,
+ text: item.note.text ?? '',
+ user: user,
+ visibility: item.note.visibility ?? 'public',
+ reactionAcceptance: item.note.reactionAcceptance ?? null,
+ visibleUsers: item.note.visibleUsers ? await userEntityService.packMany(item.note.visibleUsers.map(u => u.id), me) : [],
+ fileIds: item.note.files ? item.note.files : [],
+ createdAt: item.scheduledAt.toISOString(),
+ isSchedule: true,
+ id: item.id,
+ },
+ };
+ }));
+
+ return scheduleNotesPack;
+ });
+ }
+}