From 31a0d2c414a25fe20565837218a935fbac87c2e6 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 28 Nov 2023 14:08:59 +0900 Subject: [PATCH 1/6] feat: initial commit for public to home moderation --- packages/backend/src/core/FeaturedService.ts | 23 ++++ .../backend/src/core/FunoutTimelineService.ts | 5 + .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../endpoints/admin/note-public-to-home.ts | 118 ++++++++++++++++++ packages/backend/src/types.ts | 4 + 6 files changed, 156 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts diff --git a/packages/backend/src/core/FeaturedService.ts b/packages/backend/src/core/FeaturedService.ts index 372c89e312db..0b2ec09006d7 100644 --- a/packages/backend/src/core/FeaturedService.ts +++ b/packages/backend/src/core/FeaturedService.ts @@ -95,6 +95,29 @@ export class FeaturedService { return true; } + @bindThis + private removeNoteFromRankingOf(name: string, windowRange: number, element: string, redisPipeline: Redis.ChainableCommander) { + // removing from current & previous window is enough + const currentWindow = this.getCurrentWindow(windowRange); + const previousWindow = currentWindow - 1; + + redisPipeline.zrem(`${name}:${currentWindow}`, element); + redisPipeline.zrem(`${name}:${previousWindow}`, element); + } + + @bindThis + public async removeNote(note: MiNote): Promise { + const redisPipeline = this.redisClient.pipeline(); + this.removeNoteFromRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, note.id, redisPipeline); + this.removeNoteFromRankingOf(`featuredPerUserNotesRanking:${note.userId}`, PER_USER_NOTES_RANKING_WINDOW, note.id, redisPipeline); + + if (note.channelId) { + this.removeNoteFromRankingOf(`featuredInChannelNotesRanking:${note.channelId}`, GLOBAL_NOTES_RANKING_WINDOW, note.id, redisPipeline); + } + + await redisPipeline.exec(); + } + @bindThis public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise { return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score); diff --git a/packages/backend/src/core/FunoutTimelineService.ts b/packages/backend/src/core/FunoutTimelineService.ts index c633c329e53f..d15c00ab3066 100644 --- a/packages/backend/src/core/FunoutTimelineService.ts +++ b/packages/backend/src/core/FunoutTimelineService.ts @@ -19,6 +19,11 @@ export class FunoutTimelineService { ) { } + @bindThis + public remove(tl: string, id: string, pipeline: Redis.ChainableCommander) { + pipeline.lrem('list:' + tl, 0, id); + } + @bindThis public push(tl: string, id: string, maxlen: number, pipeline: Redis.ChainableCommander) { // リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、 diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 3797b46d04fd..28882ea898a2 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -62,6 +62,7 @@ import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; +import * as ep___admin_notePublicToHome from './endpoints/admin/note-public-to-home.js'; import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; @@ -423,6 +424,7 @@ const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default }; const $admin_relays_remove: Provider = { provide: 'ep:admin/relays/remove', useClass: ep___admin_relays_remove.default }; const $admin_resetPassword: Provider = { provide: 'ep:admin/reset-password', useClass: ep___admin_resetPassword.default }; +const $admin_notePublicToHome: Provider = { provide: 'ep:admin/note-public-to-home', useClass: ep___admin_notePublicToHome.default }; const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abuse-user-report', useClass: ep___admin_resolveAbuseUserReport.default }; const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default }; const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default }; @@ -788,6 +790,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_relays_list, $admin_relays_remove, $admin_resetPassword, + $admin_notePublicToHome, $admin_resolveAbuseUserReport, $admin_sendEmail, $admin_serverInfo, @@ -1147,6 +1150,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_relays_list, $admin_relays_remove, $admin_resetPassword, + $admin_notePublicToHome, $admin_resolveAbuseUserReport, $admin_sendEmail, $admin_serverInfo, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 4162ace337b4..befad1de7e53 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -62,6 +62,7 @@ import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; +import * as ep___admin_notePublicToHome from './endpoints/admin/note-public-to-home.js'; import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; @@ -421,6 +422,7 @@ const eps = [ ['admin/relays/list', ep___admin_relays_list], ['admin/relays/remove', ep___admin_relays_remove], ['admin/reset-password', ep___admin_resetPassword], + ['admin/note-public-to-home', ep___admin_notePublicToHome], ['admin/resolve-abuse-user-report', ep___admin_resolveAbuseUserReport], ['admin/send-email', ep___admin_sendEmail], ['admin/server-info', ep___admin_serverInfo], diff --git a/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts b/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts new file mode 100644 index 000000000000..9870f7082eb0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; +import type { NotesRepository } from '@/models/_.js'; +import { MiNote, MiPoll } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { ApiError } from '@/server/api/error.js'; +import type { IEndpointMeta } from '@/server/api/endpoints.js'; +import { FunoutTimelineService } from '@/core/FunoutTimelineService.js'; +import { FeaturedService } from '@/core/FeaturedService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + errors: { + noteNotFound: { + message: 'Note not found.', + code: 'NOTE_NOT_FOUND', + id: 'b107f543-27fb-4bac-9549-9bbb64d95e85', + }, + noteNotPublic: { + message: 'Note is not public', + code: 'NOTE_NOT_PUBLIC', + id: '561e3371-6ef1-457b-8fdc-736a6e914782', + }, + }, +} as const satisfies IEndpointMeta; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, + + @Inject(DI.db) + private db: DataSource, + + private moderationLogService: ModerationLogService, + private funoutTimelineService: FunoutTimelineService, + private featuredService: FeaturedService, + ) { + super(meta, paramDef, async (ps, me) => { + const note = await this.notesRepository.findOneBy({ id: ps.noteId }); + + if (note == null) { + throw new ApiError(meta.errors.noteNotFound); + } + + if (note.visibility !== 'public') { + throw new ApiError(meta.errors.noteNotPublic); + } + + // Note: by design, visibility of replies and quoted renotes are not changed + // replies and quoted renotes have their own text, so it's another moderation entity + + await moderationLogService.log(me, 'makeNoteHome', { targetNoteId: note.id }); + + // update basic note info + await this.db.transaction(async transactionalEntityManager => { + // change visibility of the note + await transactionalEntityManager.update(MiNote, { id: note.id }, { visibility: 'home' }); + await transactionalEntityManager.update(MiPoll, { noteId: note.id }, { noteVisibility: 'home' }); + + // change visibility of pure renotes + await transactionalEntityManager.update(MiNote, { + renoteId: note.id, + text: null, + fileIds: [], + hasPoll: false, + }, { visibility: 'home' }); + }); + + // collect renotes after changing visibility of original note + const renotes = await this.notesRepository.createQueryBuilder('note') + .where('note.renoteId = :renoteId', { renoteId: note.id }) + .andWhere('note.text IS NULL') + .andWhere('note.fileIds = \'{}\'') + .andWhere('note.hasPoll = false') + .getMany(); + + // remove from funout local timeline + const redisPipeline = this.redisForTimelines.pipeline(); + this.funoutTimelineService.remove('localTimeline', note.id, redisPipeline); + if (note.fileIds.length > 0) { + this.funoutTimelineService.remove('localTimelineWithFiles', note.id, redisPipeline); + } + for (const renote of renotes) { + this.funoutTimelineService.remove('localTimeline', renote.id, redisPipeline); + } + await redisPipeline.exec(); + + // remove from highlights + // since renotes are not included in featured, we don't need to remove them + await featuredService.removeNote(note); + }); + } +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index e6dfeb6f8c30..ce33ecb6788d 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -63,6 +63,7 @@ export const moderationLogTypes = [ 'createAvatarDecoration', 'updateAvatarDecoration', 'deleteAvatarDecoration', + 'makeNoteHome', ] as const; export type ModerationLogPayloads = { @@ -237,6 +238,9 @@ export type ModerationLogPayloads = { avatarDecorationId: string; avatarDecoration: any; }; + makeNoteHome: { + targetNoteId: string; + }; }; export type Serialized = { From 6b9238651be6278d4cd147e163ed3ecefa0e1b5f Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 28 Nov 2023 17:39:39 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(frontend):=20=E3=83=9B=E3=83=BC?= =?UTF-8?q?=E3=83=A0=E6=8A=95=E7=A8=BF=E3=81=AB=E3=81=99=E3=82=8B=E3=83=A2?= =?UTF-8?q?=E3=83=87=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/src/scripts/get-note-menu.ts | 19 ++++++++++++++++++- packages/misskey-js/src/api.types.ts | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index c0edce79e8d8..53cd18b96113 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -137,6 +137,17 @@ export function getNoteMenu(props: { const cleanups = [] as (() => void)[]; + function makeHome(): void { + os.confirm({ + type: 'warning', + text: '本当にホーム投稿にしますか?', + }).then(({ canceled }) => { + if (canceled) return; + + os.api('admin/note-public-to-home', { noteId: appearNote.id }); + }); + } + function del(): void { os.confirm({ type: 'warning', @@ -363,7 +374,13 @@ export function getNoteMenu(props: { text: i18n.ts.delete, danger: true, action: del, - }] + }, + $i.isModerator || $i.isAdmin ? { + icon: 'ti ti-home', + text: 'ホーム投稿にする', // めんどうなのでとりあえずハードコード + danger: true, + action: makeHome, + } : undefined] : [] )] .filter(x => x !== undefined); diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 5fa1dbb63cdd..01d8a3cbfde2 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -13,6 +13,7 @@ type ShowUserReq = { username: string; host?: string; } | { userId: User['id']; export type Endpoints = { // admin + 'admin/note-public-to-home': { req: { noteId: Note['id'] }; res: null }; 'admin/abuse-user-reports': { req: TODO; res: TODO; }; 'admin/delete-all-files-of-a-user': { req: { userId: User['id']; }; res: null; }; 'admin/delete-user-avatar': { req: { userId: User['id']; }; res: null; }; From 41a0ae644290a6bb8be367b5fead3124a05a3d4b Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 28 Nov 2023 17:48:35 +0900 Subject: [PATCH 3/6] =?UTF-8?q?docs(api.js):=20=E3=83=9B=E3=83=BC=E3=83=A0?= =?UTF-8?q?=E6=8A=95=E7=A8=BF=E3=81=AB=E3=81=99=E3=82=8B=E3=83=A2=E3=83=87?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/misskey-js/etc/misskey-js.api.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index d9e34b74925e..93e1ec907ccd 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -335,6 +335,12 @@ type DriveFolder = TODO_2; // @public (undocumented) export type Endpoints = { + 'admin/note-public-to-home': { + req: { + noteId: Note['id']; + }; + res: null; + }; 'admin/abuse-user-reports': { req: TODO; res: TODO; @@ -3047,9 +3053,9 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // Warnings were encountered during analysis: // -// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts -// src/api.types.ts:20:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts -// src/api.types.ts:635:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts +// src/api.types.ts:17:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts +// src/api.types.ts:21:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts +// src/api.types.ts:636:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts // src/entities.ts:117:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts // src/entities.ts:628:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts From c888fd1a9421a9bf761bdbf6a5763e4b719f1642 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 28 Nov 2023 18:21:24 +0900 Subject: [PATCH 4/6] fix: followers / direct renotes will become home renote --- .../src/server/api/endpoints/admin/note-public-to-home.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts b/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts index 9870f7082eb0..ffcb5b34e8d6 100644 --- a/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts +++ b/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts @@ -88,6 +88,7 @@ export default class extends Endpoint { // eslint- text: null, fileIds: [], hasPoll: false, + visibility: 'public', }, { visibility: 'home' }); }); @@ -97,6 +98,7 @@ export default class extends Endpoint { // eslint- .andWhere('note.text IS NULL') .andWhere('note.fileIds = \'{}\'') .andWhere('note.hasPoll = false') + .andWhere('note.visibility = \'home\'') .getMany(); // remove from funout local timeline From 45cffa88bc563d9f06d70fa7a38b37848701e54f Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 28 Nov 2023 18:48:33 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E3=81=AA=E3=82=93=E3=81=8Bquery?= =?UTF-8?q?=E3=81=8C=E5=8B=95=E3=81=8B=E3=81=AA=E3=81=8B=E3=81=A3=E3=81=9F?= =?UTF-8?q?=E3=81=AE=E3=81=A7=E6=89=8B=E6=9B=B8=E3=81=8D=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E3=81=88=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/endpoints/admin/note-public-to-home.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts b/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts index ffcb5b34e8d6..6d9595e79e79 100644 --- a/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts +++ b/packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts @@ -83,13 +83,16 @@ export default class extends Endpoint { // eslint- await transactionalEntityManager.update(MiPoll, { noteId: note.id }, { noteVisibility: 'home' }); // change visibility of pure renotes - await transactionalEntityManager.update(MiNote, { - renoteId: note.id, - text: null, - fileIds: [], - hasPoll: false, - visibility: 'public', - }, { visibility: 'home' }); + await transactionalEntityManager.createQueryBuilder() + .from(MiNote, 'note') + .update() + .where('renoteId = :renoteId', { renoteId: note.id }) + .andWhere('text IS NULL') + .andWhere('fileIds = \'{}\'') + .andWhere('hasPoll = false') + .andWhere('visibility = \'public\'') + .set({ visibility: 'home' }) + .execute(); }); // collect renotes after changing visibility of original note From 3060bca7e37f87f9e196d57aac1695b5ddd93ba0 Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Tue, 28 Nov 2023 19:54:57 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs(changelog):=20public=E3=83=8E=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=82=92home=E3=83=8E=E3=83=BC=E3=83=88=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=83=A2=E3=83=87=E3=83=AC=E3=83=BC=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f161748b7ca4..da9f45b1df38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ## 2023.x.x (unreleased) ### General +- publicノートをhomeノートにするモデレーションを追加 - Fix: 全体ハイライトでユーザーミュートが正常に機能しない問題 ### Client