diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 19bbec2de9..3e315153cb 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1529,6 +1529,12 @@ admin/views/moderators.vue: added: "モデレーターを登録しました" remove: "解除" removed: "モデレーター登録を解除しました" + logs: + title: "ログ" + moderator: "モデレーター" + type: "操作" + at: "日時" + info: "情報" admin/views/emoji.vue: add-emoji: diff --git a/migration/1562869971568-ModerationLog.ts b/migration/1562869971568-ModerationLog.ts new file mode 100644 index 0000000000..b37f38ee5d --- /dev/null +++ b/migration/1562869971568-ModerationLog.ts @@ -0,0 +1,17 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class ModerationLog1562869971568 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "moderation_log" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "type" character varying(128) NOT NULL, "info" jsonb NOT NULL, CONSTRAINT "PK_d0adca6ecfd068db83e4526cc26" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_a08ad074601d204e0f69da9a95" ON "moderation_log" ("userId") `); + await queryRunner.query(`ALTER TABLE "moderation_log" ADD CONSTRAINT "FK_a08ad074601d204e0f69da9a954" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "moderation_log" DROP CONSTRAINT "FK_a08ad074601d204e0f69da9a954"`); + await queryRunner.query(`DROP INDEX "IDX_a08ad074601d204e0f69da9a95"`); + await queryRunner.query(`DROP TABLE "moderation_log"`); + } + +} diff --git a/src/client/app/admin/views/moderators.vue b/src/client/app/admin/views/moderators.vue index bf7d951fc7..8ceab02d97 100644 --- a/src/client/app/admin/views/moderators.vue +++ b/src/client/app/admin/views/moderators.vue @@ -12,6 +12,31 @@ + + + +
+ +
+ + + {{ $t('logs.moderator') }} + + + {{ $t('logs.type') }} + + + {{ $t('logs.at') }} + + + + {{ $t('logs.info') }} + +
+
+ {{ $t('@.load-more') }} +
+
@@ -26,10 +51,17 @@ export default Vue.extend({ data() { return { username: '', - changing: false + changing: false, + logs: [], + untilLogId: null, + existMoreLogs: false }; }, + created() { + this.fetchLogs(); + }, + methods: { async add() { this.changing = true; @@ -74,6 +106,22 @@ export default Vue.extend({ this.changing = false; }, + + fetchLogs() { + this.$root.api('admin/show-moderation-logs', { + untilId: this.untilId, + limit: 10 + 1 + }).then(logs => { + if (logs.length == 10 + 1) { + logs.pop(); + this.existMoreLogs = true; + } else { + this.existMoreLogs = false; + } + this.logs = this.logs.concat(logs); + this.untilLogId = this.logs[this.logs.length - 1].id; + }); + }, } }); diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 638d5720b7..16cfbd2b2f 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -47,6 +47,7 @@ import { UserSecurityKey } from '../models/entities/user-security-key'; import { AttestationChallenge } from '../models/entities/attestation-challenge'; import { Page } from '../models/entities/page'; import { PageLike } from '../models/entities/page-like'; +import { ModerationLog } from '../models/entities/moderation-log'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -124,6 +125,7 @@ export const entities = [ RegistrationTicket, MessagingMessage, Signin, + ModerationLog, ReversiGame, ReversiMatching, ...charts as any diff --git a/src/models/entities/moderation-log.ts b/src/models/entities/moderation-log.ts new file mode 100644 index 0000000000..33d3d683ae --- /dev/null +++ b/src/models/entities/moderation-log.ts @@ -0,0 +1,32 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class ModerationLog { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the ModerationLog.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + }) + public type: string; + + @Column('jsonb') + public info: Record; +} diff --git a/src/models/index.ts b/src/models/index.ts index 888fd53f36..388bdc8f6f 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -42,6 +42,7 @@ import { UserSecurityKey } from './entities/user-security-key'; import { HashtagRepository } from './repositories/hashtag'; import { PageRepository } from './repositories/page'; import { PageLikeRepository } from './repositories/page-like'; +import { ModerationLogRepository } from './repositories/moderation-logs'; export const Apps = getCustomRepository(AppRepository); export const Notes = getCustomRepository(NoteRepository); @@ -86,3 +87,4 @@ export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository); export const Logs = getRepository(Log); export const Pages = getCustomRepository(PageRepository); export const PageLikes = getCustomRepository(PageLikeRepository); +export const ModerationLogs = getCustomRepository(ModerationLogRepository); diff --git a/src/models/repositories/moderation-logs.ts b/src/models/repositories/moderation-logs.ts new file mode 100644 index 0000000000..d6e04795bb --- /dev/null +++ b/src/models/repositories/moderation-logs.ts @@ -0,0 +1,31 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Users } from '..'; +import { ModerationLog } from '../entities/moderation-log'; +import { ensure } from '../../prelude/ensure'; +import { awaitAll } from '../../prelude/await-all'; + +@EntityRepository(ModerationLog) +export class ModerationLogRepository extends Repository { + public async pack( + src: ModerationLog['id'] | ModerationLog, + ) { + const log = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + + return await awaitAll({ + id: log.id, + createdAt: log.createdAt, + type: log.type, + info: log.info, + userId: log.userId, + user: Users.pack(log.user || log.userId, null, { + detail: true + }), + }); + } + + public packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + } +} diff --git a/src/server/api/endpoints/admin/emoji/add.ts b/src/server/api/endpoints/admin/emoji/add.ts index 5ba00afde8..8c21b1c73e 100644 --- a/src/server/api/endpoints/admin/emoji/add.ts +++ b/src/server/api/endpoints/admin/emoji/add.ts @@ -4,6 +4,7 @@ import { detectUrlMine } from '../../../../../misc/detect-url-mine'; import { Emojis } from '../../../../../models'; import { genId } from '../../../../../misc/gen-id'; import { getConnection } from 'typeorm'; +import { insertModerationLog } from '../../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -31,7 +32,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const type = await detectUrlMine(ps.url); const emoji = await Emojis.save({ @@ -46,6 +47,10 @@ export default define(meta, async (ps) => { await getConnection().queryResultCache!.remove(['meta_emojis']); + insertModerationLog(me, 'addEmoji', { + emojiId: emoji.id + }); + return { id: emoji.id }; diff --git a/src/server/api/endpoints/admin/emoji/remove.ts b/src/server/api/endpoints/admin/emoji/remove.ts index 3ebf933bc6..92c5f5f8c6 100644 --- a/src/server/api/endpoints/admin/emoji/remove.ts +++ b/src/server/api/endpoints/admin/emoji/remove.ts @@ -3,6 +3,7 @@ import define from '../../../define'; import { ID } from '../../../../../misc/cafy-id'; import { Emojis } from '../../../../../models'; import { getConnection } from 'typeorm'; +import { insertModerationLog } from '../../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -21,7 +22,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const emoji = await Emojis.findOne(ps.id); if (emoji == null) throw new Error('emoji not found'); @@ -29,4 +30,8 @@ export default define(meta, async (ps) => { await Emojis.delete(emoji.id); await getConnection().queryResultCache!.remove(['meta_emojis']); + + insertModerationLog(me, 'removeEmoji', { + emoji: emoji + }); }); diff --git a/src/server/api/endpoints/admin/queue/clear.ts b/src/server/api/endpoints/admin/queue/clear.ts index f0fd00f1ad..03c1ae8463 100644 --- a/src/server/api/endpoints/admin/queue/clear.ts +++ b/src/server/api/endpoints/admin/queue/clear.ts @@ -1,5 +1,6 @@ import define from '../../../define'; import { destroy } from '../../../../../queue'; +import { insertModerationLog } from '../../../../../services/insert-moderation-log'; export const meta = { tags: ['admin'], @@ -10,8 +11,8 @@ export const meta = { params: {} }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { destroy(); - return; + insertModerationLog(me, 'clearQueue'); }); diff --git a/src/server/api/endpoints/admin/show-moderation-logs.ts b/src/server/api/endpoints/admin/show-moderation-logs.ts new file mode 100644 index 0000000000..bc67b3e55b --- /dev/null +++ b/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ModerationLogs } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(ModerationLogs.createQueryBuilder('report'), ps.sinceId, ps.untilId); + + const reports = await query.take(ps.limit!).getMany(); + + return await ModerationLogs.packMany(reports); +}); diff --git a/src/server/api/endpoints/admin/silence-user.ts b/src/server/api/endpoints/admin/silence-user.ts index 83aa88012a..8cc84aa1cc 100644 --- a/src/server/api/endpoints/admin/silence-user.ts +++ b/src/server/api/endpoints/admin/silence-user.ts @@ -2,6 +2,7 @@ import $ from 'cafy'; import { ID } from '../../../../misc/cafy-id'; import define from '../../define'; import { Users } from '../../../../models'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -25,7 +26,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const user = await Users.findOne(ps.userId as string); if (user == null) { @@ -39,4 +40,8 @@ export default define(meta, async (ps) => { await Users.update(user.id, { isSilenced: true }); + + insertModerationLog(me, 'silence', { + targetId: user.id, + }); }); diff --git a/src/server/api/endpoints/admin/suspend-user.ts b/src/server/api/endpoints/admin/suspend-user.ts index fa4d378708..09fdbb070e 100644 --- a/src/server/api/endpoints/admin/suspend-user.ts +++ b/src/server/api/endpoints/admin/suspend-user.ts @@ -4,6 +4,7 @@ import define from '../../define'; import deleteFollowing from '../../../../services/following/delete'; import { Users, Followings } from '../../../../models'; import { User } from '../../../../models/entities/user'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -27,7 +28,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const user = await Users.findOne(ps.userId as string); if (user == null) { @@ -46,6 +47,10 @@ export default define(meta, async (ps) => { isSuspended: true }); + insertModerationLog(me, 'suspend', { + targetId: user.id, + }); + unFollowAll(user); }); diff --git a/src/server/api/endpoints/admin/unsilence-user.ts b/src/server/api/endpoints/admin/unsilence-user.ts index f9b173366b..607c9b699a 100644 --- a/src/server/api/endpoints/admin/unsilence-user.ts +++ b/src/server/api/endpoints/admin/unsilence-user.ts @@ -2,6 +2,7 @@ import $ from 'cafy'; import { ID } from '../../../../misc/cafy-id'; import define from '../../define'; import { Users } from '../../../../models'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -25,7 +26,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const user = await Users.findOne(ps.userId as string); if (user == null) { @@ -35,4 +36,8 @@ export default define(meta, async (ps) => { await Users.update(user.id, { isSilenced: false }); + + insertModerationLog(me, 'unsilence', { + targetId: user.id, + }); }); diff --git a/src/server/api/endpoints/admin/unsuspend-user.ts b/src/server/api/endpoints/admin/unsuspend-user.ts index 08dae034d3..a1c80d3121 100644 --- a/src/server/api/endpoints/admin/unsuspend-user.ts +++ b/src/server/api/endpoints/admin/unsuspend-user.ts @@ -2,6 +2,7 @@ import $ from 'cafy'; import { ID } from '../../../../misc/cafy-id'; import define from '../../define'; import { Users } from '../../../../models'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -25,7 +26,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const user = await Users.findOne(ps.userId as string); if (user == null) { @@ -35,4 +36,8 @@ export default define(meta, async (ps) => { await Users.update(user.id, { isSuspended: false }); + + insertModerationLog(me, 'unsuspend', { + targetId: user.id, + }); }); diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts index 8e98d203ff..834faa42b9 100644 --- a/src/server/api/endpoints/admin/update-meta.ts +++ b/src/server/api/endpoints/admin/update-meta.ts @@ -2,6 +2,7 @@ import $ from 'cafy'; import define from '../../define'; import { getConnection } from 'typeorm'; import { Meta } from '../../../../models/entities/meta'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -401,7 +402,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const set = {} as Partial; if (ps.announcements) { @@ -653,4 +654,6 @@ export default define(meta, async (ps) => { await transactionalEntityManager.save(Meta, set); } }); + + insertModerationLog(me, 'updateMeta'); }); diff --git a/src/server/api/endpoints/admin/vacuum.ts b/src/server/api/endpoints/admin/vacuum.ts index 6990706282..4921e228e5 100644 --- a/src/server/api/endpoints/admin/vacuum.ts +++ b/src/server/api/endpoints/admin/vacuum.ts @@ -1,6 +1,7 @@ import $ from 'cafy'; import define from '../../define'; import { getConnection } from 'typeorm'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { tags: ['admin'], @@ -18,7 +19,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const params: string[] = []; if (ps.full) { @@ -30,4 +31,6 @@ export default define(meta, async (ps) => { } getConnection().query('VACUUM ' + params.join(' ')); + + insertModerationLog(me, 'vacuum', ps); }); diff --git a/src/services/insert-moderation-log.ts b/src/services/insert-moderation-log.ts new file mode 100644 index 0000000000..33dab97259 --- /dev/null +++ b/src/services/insert-moderation-log.ts @@ -0,0 +1,13 @@ +import { ILocalUser } from '../models/entities/user'; +import { ModerationLogs } from '../models'; +import { genId } from '../misc/gen-id'; + +export async function insertModerationLog(moderator: ILocalUser, type: string, info?: Record) { + await ModerationLogs.save({ + id: genId(), + createdAt: new Date(), + userId: moderator.id, + type: type, + info: info || {} + }); +}