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 = {