Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: public to home moderation #107

Merged
merged 7 commits into from
Nov 28, 2023
23 changes: 23 additions & 0 deletions packages/backend/src/core/FeaturedService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/core/FunoutTimelineService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
// リモートから遅れて届いた(もしくは後から追加された)投稿日時が古い投稿が追加されるとページネーション時に問題を引き起こすため、
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/server/api/EndpointsModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/server/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down
118 changes: 118 additions & 0 deletions packages/backend/src/server/api/endpoints/admin/note-public-to-home.ts
Original file line number Diff line number Diff line change
@@ -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<typeof meta, typeof paramDef> { // 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' });
Sayamame-beans marked this conversation as resolved.
Show resolved Hide resolved
});

// 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);
Sayamame-beans marked this conversation as resolved.
Show resolved Hide resolved
}
await redisPipeline.exec();

// remove from highlights
// since renotes are not included in featured, we don't need to remove them
await featuredService.removeNote(note);
});
}
}
4 changes: 4 additions & 0 deletions packages/backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const moderationLogTypes = [
'createAvatarDecoration',
'updateAvatarDecoration',
'deleteAvatarDecoration',
'makeNoteHome',
] as const;

export type ModerationLogPayloads = {
Expand Down Expand Up @@ -237,6 +238,9 @@ export type ModerationLogPayloads = {
avatarDecorationId: string;
avatarDecoration: any;
};
makeNoteHome: {
targetNoteId: string;
};
};

export type Serialized<T> = {
Expand Down
19 changes: 18 additions & 1 deletion packages/frontend/src/scripts/get-note-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 9 additions & 3 deletions packages/misskey-js/etc/misskey-js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/misskey-js/src/api.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; };
Expand Down
Loading