From 87f61e714ad3b17856a6a5ac66051707badb3bd0 Mon Sep 17 00:00:00 2001 From: syuilo <syuilotan@yahoo.co.jp> Date: Mon, 19 Oct 2020 19:29:04 +0900 Subject: [PATCH] Resolve #6087 --- locales/ja-JP.yml | 7 + .../1603094348345-refine-abuse-user-report.ts | 38 ++++ ...1603095701770-refine-abuse-user-report2.ts | 20 +++ src/client/components/abuse-report-window.vue | 85 +++++++++ src/client/components/note.vue | 17 +- src/client/components/page-window.vue | 8 +- src/client/components/sidebar.vue | 7 +- src/client/components/ui/window.vue | 9 +- src/client/os.ts | 6 +- src/client/pages/instance/abuses.vue | 163 ++++++++++++++++++ src/client/router.ts | 1 + src/client/scripts/get-user-menu.ts | 14 +- src/models/entities/abuse-user-report.ts | 41 ++++- src/models/repositories/abuse-user-report.ts | 9 +- src/remote/activitypub/kernel/flag/index.ts | 4 +- .../api/endpoints/admin/abuse-user-reports.ts | 38 ++++ ...report.ts => resolve-abuse-user-report.ts} | 7 +- .../api/endpoints/users/report-abuse.ts | 10 +- 18 files changed, 461 insertions(+), 23 deletions(-) create mode 100644 migration/1603094348345-refine-abuse-user-report.ts create mode 100644 migration/1603095701770-refine-abuse-user-report2.ts create mode 100644 src/client/components/abuse-report-window.vue create mode 100644 src/client/pages/instance/abuses.vue rename src/server/api/endpoints/admin/{remove-abuse-user-report.ts => resolve-abuse-user-report.ts} (77%) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 83c7add3b2..bccb82e51b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -586,6 +586,13 @@ setMultipleBySeparatingWithSpace: "スペースで区切って複数設定でき fileIdOrUrl: "ファイルIDまたはURL" chatOpenBehavior: "チャットを開くときの動作" sample: "サンプル" +abuseReports: "通報" +reportAbuse: "通報" +reportAbuseOf: "{name}を通報する" +fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。" +abuseReported: "内容が送信されました。ご報告ありがとうございました。" +send: "送信" +abuseMarkAsResolved: "対応済みにする" _serverDisconnectedBehavior: reload: "自動でリロード" diff --git a/migration/1603094348345-refine-abuse-user-report.ts b/migration/1603094348345-refine-abuse-user-report.ts new file mode 100644 index 0000000000..2c5c4b39c5 --- /dev/null +++ b/migration/1603094348345-refine-abuse-user-report.ts @@ -0,0 +1,38 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class refineAbuseUserReport1603094348345 implements MigrationInterface { + name = 'refineAbuseUserReport1603094348345' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_d049123c413e68ca52abe734203"`); + await queryRunner.query(`DROP INDEX "IDX_d049123c413e68ca52abe73420"`); + await queryRunner.query(`DROP INDEX "IDX_5cd442c3b2e74fdd99dae20243"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "userId"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "targetUserId" character varying(32) NOT NULL`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "assigneeId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "resolved" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "comment"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "comment" character varying(2048) NOT NULL`); + await queryRunner.query(`CREATE INDEX "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId") `); + await queryRunner.query(`CREATE INDEX "IDX_2b15aaf4a0dc5be3499af7ab6a" ON "abuse_user_report" ("resolved") `); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_08b883dd5fdd6f9c4c1572b36de" FOREIGN KEY ("assigneeId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_08b883dd5fdd6f9c4c1572b36de"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`); + await queryRunner.query(`DROP INDEX "IDX_2b15aaf4a0dc5be3499af7ab6a"`); + await queryRunner.query(`DROP INDEX "IDX_a9021cc2e1feb5f72d3db6e9f5"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "comment"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "comment" character varying(512) NOT NULL`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "resolved"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "assigneeId"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "targetUserId"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "userId" character varying(32) NOT NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_5cd442c3b2e74fdd99dae20243" ON "abuse_user_report" ("userId", "reporterId") `); + await queryRunner.query(`CREATE INDEX "IDX_d049123c413e68ca52abe73420" ON "abuse_user_report" ("userId") `); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_d049123c413e68ca52abe734203" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + +} diff --git a/migration/1603095701770-refine-abuse-user-report2.ts b/migration/1603095701770-refine-abuse-user-report2.ts new file mode 100644 index 0000000000..18e0c05ac2 --- /dev/null +++ b/migration/1603095701770-refine-abuse-user-report2.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class refineAbuseUserReport21603095701770 implements MigrationInterface { + name = 'refineAbuseUserReport21603095701770' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "targetUserHost" character varying(128)`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "reporterHost" character varying(128)`); + await queryRunner.query(`CREATE INDEX "IDX_4ebbf7f93cdc10e8d1ef2fc6cd" ON "abuse_user_report" ("targetUserHost") `); + await queryRunner.query(`CREATE INDEX "IDX_f8d8b93740ad12c4ce8213a199" ON "abuse_user_report" ("reporterHost") `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`DROP INDEX "IDX_f8d8b93740ad12c4ce8213a199"`); + await queryRunner.query(`DROP INDEX "IDX_4ebbf7f93cdc10e8d1ef2fc6cd"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "reporterHost"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "targetUserHost"`); + } + +} diff --git a/src/client/components/abuse-report-window.vue b/src/client/components/abuse-report-window.vue new file mode 100644 index 0000000000..1d87cb1802 --- /dev/null +++ b/src/client/components/abuse-report-window.vue @@ -0,0 +1,85 @@ +<template> +<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')"> + <template #header> + <Fa :icon="faExclamationCircle" style="margin-right: 0.5em;"/> + <i18n-t keypath="reportAbuseOf" tag="span"> + <template #name> + <b><MkAcct :user="user"/></b> + </template> + </i18n-t> + </template> + <div class="dpvffvvy"> + <div class="_section"> + <div class="_content"> + <MkTextarea v-model:value="comment"> + <span>{{ $t('details') }}</span> + <template #desc>{{ $t('fillAbuseReportDescription') }}</template> + </MkTextarea> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkButton @click="send" primary full :disabled="comment.length === 0">{{ $t('send') }}</MkButton> + </div> + </div> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import XWindow from '@/components/ui/window.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XWindow, + MkTextarea, + MkButton, + }, + + props: { + user: { + type: Object, + required: true, + }, + initialComment: { + type: String, + required: false, + }, + }, + + emits: ['closed'], + + data() { + return { + comment: this.initialComment || '', + faExclamationCircle, + }; + }, + + methods: { + send() { + os.apiWithDialog('users/report-abuse', { + userId: this.user.id, + comment: this.comment, + }, undefined, res => { + os.dialog({ + type: 'success', + text: this.$t('abuseReported') + }); + this.$refs.window.close(); + }); + } + }, +}); +</script> + +<style lang="scss" scoped> +.dpvffvvy { + --section-padding: 16px; +} +</style> diff --git a/src/client/components/note.vue b/src/client/components/note.vue index cb364a04c9..85bdb9c6fb 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -101,7 +101,7 @@ <script lang="ts"> import { computed, defineAsyncComponent, defineComponent, markRaw, ref } from 'vue'; -import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; +import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; import { parse } from '../../mfm/parse'; import { sum, unique } from '../../prelude/array'; @@ -637,6 +637,21 @@ export default defineComponent({ }] : [] ), + ...(this.appearNote.userId != this.$store.state.i.id ? [ + null, + { + icon: faExclamationCircle, + text: this.$t('reportAbuse'), + action: () => { + const u = `${url}/notes/${this.appearNote.id}`; + os.popup(defineAsyncComponent(() => import('@/components/abuse-report-window.vue')), { + user: this.appearNote.user, + initialComment: `Note: ${u}\n-----\n` + }, {}, 'closed'); + } + }] + : [] + ), ...(this.appearNote.userId == this.$store.state.i.id || this.$store.state.i.isModerator || this.$store.state.i.isAdmin ? [ null, this.appearNote.userId == this.$store.state.i.id ? { diff --git a/src/client/components/page-window.vue b/src/client/components/page-window.vue index 4a605bde69..2673b3f8ec 100644 --- a/src/client/components/page-window.vue +++ b/src/client/components/page-window.vue @@ -7,7 +7,7 @@ <button class="_button" @click="expand" v-tooltip="$t('showInPage')"><Fa :icon="faExpandAlt"/></button> <button class="_button" @click="popout" v-tooltip="$t('popout')"><Fa :icon="faExternalLinkAlt"/></button> </template> - <div style="min-height: 100%; background: var(--bg);"> + <div class="yrolvcoq" style="min-height: 100%; background: var(--bg);"> <component :is="component" v-bind="props" :ref="changePage"/> </div> </XWindow> @@ -84,3 +84,9 @@ export default defineComponent({ }, }); </script> + +<style lang="scss" scoped> +.yrolvcoq { + --section-padding: 16px; +} +</style> diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue index 7548b136ea..383378241b 100644 --- a/src/client/components/sidebar.vue +++ b/src/client/components/sidebar.vue @@ -46,7 +46,7 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram, faStream } from '@fortawesome/free-solid-svg-icons'; +import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram, faStream, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons'; import { host, instanceName } from '@/config'; import { search } from '@/scripts/search'; @@ -217,6 +217,11 @@ export default defineComponent({ text: this.$t('announcements'), to: '/instance/announcements', icon: faBroadcastTower, + }, { + type: 'link', + text: this.$t('abuseReports'), + to: '/instance/abuses', + icon: faExclamationCircle, }, { type: 'link', text: this.$t('logs'), diff --git a/src/client/components/ui/window.vue b/src/client/components/ui/window.vue index 4a51a4d8e3..781c6ef151 100644 --- a/src/client/components/ui/window.vue +++ b/src/client/components/ui/window.vue @@ -7,7 +7,9 @@ <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> <slot name="header"></slot> </span> - <slot name="buttons"></slot> + <slot name="buttons"> + <button class="_button" style="pointer-events: none;"></button> + </slot> </div> <div class="body" v-if="padding"> <div class="_section"> @@ -371,8 +373,6 @@ export default defineComponent({ width: 100%; height: 100%; - --section-padding: 16px; - > .header { $height: 50px; display: flex; @@ -380,7 +380,6 @@ export default defineComponent({ z-index: 1; flex-shrink: 0; box-shadow: 0px 1px var(--divider); - cursor: move; user-select: none; height: $height; @@ -400,6 +399,8 @@ export default defineComponent({ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + text-align: center; + cursor: move; } } diff --git a/src/client/os.ts b/src/client/os.ts index 6adfc28956..3241f82e5d 100644 --- a/src/client/os.ts +++ b/src/client/os.ts @@ -4,6 +4,7 @@ import Stream from '@/scripts/stream'; import { store } from '@/store'; import { apiUrl } from '@/config'; import MkPostFormDialog from '@/components/post-form-dialog.vue'; +import MkWaitingDialog from '@/components/waiting-dialog.vue'; const ua = navigator.userAgent.toLowerCase(); export const isMobile = /mobile|iphone|ipad|android/.test(ua); @@ -73,7 +74,7 @@ export function apiWithDialog( promiseDialog(promise, onSuccess, onFailure ? onFailure : (e) => { dialog({ type: 'error', - text: e.message + '\n' + (e as any).id, + text: e.message + '<br>' + (e as any).id, }); }); @@ -111,7 +112,8 @@ export function promiseDialog<T extends Promise<any>>( } }); - popup(defineAsyncComponent(() => import('@/components/waiting-dialog.vue')), { + // NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない) + popup(MkWaitingDialog, { success: success, showing: showing, text: text, diff --git a/src/client/pages/instance/abuses.vue b/src/client/pages/instance/abuses.vue new file mode 100644 index 0000000000..90a2656e10 --- /dev/null +++ b/src/client/pages/instance/abuses.vue @@ -0,0 +1,163 @@ +<template> +<div class=""> + <div class="_section reports"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model:value="state" style="margin: 0; flex: 1;"> + <template #label>{{ $t('state') }}</template> + <option value="all">{{ $t('all') }}</option> + <option value="unresolved">{{ $t('unresolved') }}</option> + <option value="resolved">{{ $t('resolved') }}</option> + </MkSelect> + <MkSelect v-model:value="targetUserOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ $t('targetUserOrigin') }}</template> + <option value="combined">{{ $t('all') }}</option> + <option value="local">{{ $t('local') }}</option> + <option value="remote">{{ $t('remote') }}</option> + </MkSelect> + <MkSelect v-model:value="reporterOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ $t('reporterOrigin') }}</template> + <option value="combined">{{ $t('all') }}</option> + <option value="local">{{ $t('local') }}</option> + <option value="remote">{{ $t('remote') }}</option> + </MkSelect> + </div> + <!-- TODO + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model:value="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.reports.reload()"> + <span>{{ $t('username') }}</span> + </MkInput> + <MkInput v-model:value="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.reports.reload()" :disabled="pagination.params().origin === 'local'"> + <span>{{ $t('host') }}</span> + </MkInput> + </div> + --> + + <MkPagination :pagination="pagination" #default="{items}" ref="reports" :auto-margin="false" style="margin-top: var(--margin);"> + <div class="bcekxzvu _card _vMargin" v-for="report in items" :key="report.id"> + <div class="_content target"> + <MkAvatar class="avatar" :user="report.targetUser"/> + <div class="info"> + <MkUserName class="name" :user="report.targetUser"/> + <div class="acct">@{{ acct(report.targetUser) }}</div> + </div> + </div> + <div class="_content"> + <div> + <Mfm :text="report.comment"/> + </div> + <hr> + <div>Reporter: <MkAcct :user="report.reporter"/></div> + <div><MkTime :time="report.createdAt"/></div> + </div> + <div class="_footer"> + <div v-if="report.assignee">Assignee: <MkAcct :user="report.assignee"/></div> + <MkButton @click="resolve(report)" primary v-if="!report.resolved">{{ $t('abuseMarkAsResolved') }}</MkButton> + </div> + </div> + </MkPagination> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faPlus, faUsers, faSearch, faBookmark, faMicrophoneSlash, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; +import parseAcct from '../../../misc/acct/parse'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { acct } from '../../filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + }, + + data() { + return { + INFO: { + header: [{ + title: this.$t('abuseReports'), + icon: faExclamationCircle + }], + }, + searchUsername: '', + searchHost: '', + state: 'unresolved', + reporterOrigin: 'combined', + targetUserOrigin: 'combined', + pagination: { + endpoint: 'admin/abuse-user-reports', + limit: 10, + params: () => ({ + state: this.state, + reporterOrigin: this.reporterOrigin, + targetUserOrigin: this.targetUserOrigin, + }), + }, + faPlus, faUsers, faSearch, faBookmark, farBookmark, faMicrophoneSlash, faSnowflake + } + }, + + watch: { + state() { + this.$refs.reports.reload(); + }, + + reporterOrigin() { + this.$refs.reports.reload(); + }, + + targetUserOrigin() { + this.$refs.reports.reload(); + }, + }, + + methods: { + acct, + + resolve(report) { + os.apiWithDialog('admin/resolve-abuse-user-report', { + reportId: report.id, + }).then(() => { + this.$refs.reports.removeItem(item => item.id === report.id); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.bcekxzvu { + > .target { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + > .avatar { + width: 42px; + height: 42px; + } + + > .info { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; + + > .name { + font-weight: bold; + } + } + } +} +</style> diff --git a/src/client/router.ts b/src/client/router.ts index fc67f6ecfd..c9c7a32835 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -86,6 +86,7 @@ export const router = createRouter({ { path: '/instance/federation', component: page('instance/federation') }, { path: '/instance/relays', component: page('instance/relays') }, { path: '/instance/announcements', component: page('instance/announcements') }, + { path: '/instance/abuses', component: page('instance/abuses') }, { path: '/notes/:note', name: 'note', component: page('note') }, { path: '/tags/:tag', component: page('tag') }, { path: '/auth/:token', component: page('auth') }, diff --git a/src/client/scripts/get-user-menu.ts b/src/client/scripts/get-user-menu.ts index 63c3ae43b6..cace2e1425 100644 --- a/src/client/scripts/get-user-menu.ts +++ b/src/client/scripts/get-user-menu.ts @@ -1,4 +1,4 @@ -import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug } from '@fortawesome/free-solid-svg-icons'; +import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons'; import { i18n } from '@/i18n'; import copyToClipboard from '@/scripts/copy-to-clipboard'; @@ -102,6 +102,12 @@ export function getUserMenu(user) { }); } + async function reportAbuse() { + os.popup(await import('@/components/abuse-report-window.vue'), { + user: user, + }, {}, 'closed'); + } + async function getConfirmed(text: string): Promise<boolean> { const confirm = await os.dialog({ type: 'warning', @@ -157,6 +163,12 @@ export function getUserMenu(user) { action: toggleBlock }]); + menu = menu.concat([null, { + icon: faExclamationCircle, + text: i18n.global.t('reportAbuse'), + action: reportAbuse + }]); + if (store.getters.isSignedIn && (store.state.i.isAdmin || store.state.i.isModerator)) { menu = menu.concat([null, { icon: faMicrophoneSlash, diff --git a/src/models/entities/abuse-user-report.ts b/src/models/entities/abuse-user-report.ts index 43ab56023a..c0cff139f6 100644 --- a/src/models/entities/abuse-user-report.ts +++ b/src/models/entities/abuse-user-report.ts @@ -3,7 +3,6 @@ import { User } from './user'; import { id } from '../id'; @Entity() -@Index(['userId', 'reporterId'], { unique: true }) export class AbuseUserReport { @PrimaryColumn(id()) public id: string; @@ -16,13 +15,13 @@ export class AbuseUserReport { @Index() @Column(id()) - public userId: User['id']; + public targetUserId: User['id']; @ManyToOne(type => User, { onDelete: 'CASCADE' }) @JoinColumn() - public user: User | null; + public targetUser: User | null; @Index() @Column(id()) @@ -34,8 +33,42 @@ export class AbuseUserReport { @JoinColumn() public reporter: User | null; + @Column({ + ...id(), + nullable: true + }) + public assigneeId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'SET NULL' + }) + @JoinColumn() + public assignee: User | null; + + @Index() + @Column('boolean', { + default: false + }) + public resolved: boolean; + @Column('varchar', { - length: 512, + length: 2048, }) public comment: string; + + //#region Denormalized fields + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]' + }) + public targetUserHost: string | null; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]' + }) + public reporterHost: string | null; + //#endregion } diff --git a/src/models/repositories/abuse-user-report.ts b/src/models/repositories/abuse-user-report.ts index bff64c770c..dbdaa5ee15 100644 --- a/src/models/repositories/abuse-user-report.ts +++ b/src/models/repositories/abuse-user-report.ts @@ -15,14 +15,19 @@ export class AbuseUserReportRepository extends Repository<AbuseUserReport> { id: report.id, createdAt: report.createdAt, comment: report.comment, + resolved: report.resolved, reporterId: report.reporterId, - userId: report.userId, + targetUserId: report.targetUserId, + assigneeId: report.assigneeId, reporter: Users.pack(report.reporter || report.reporterId, null, { detail: true }), - user: Users.pack(report.user || report.userId, null, { + targetUser: Users.pack(report.targetUser || report.targetUserId, null, { detail: true }), + assignee: report.assigneeId ? Users.pack(report.assignee || report.assigneeId, null, { + detail: true + }) : null, }); } diff --git a/src/remote/activitypub/kernel/flag/index.ts b/src/remote/activitypub/kernel/flag/index.ts index 9b3065b112..46ea789b4b 100644 --- a/src/remote/activitypub/kernel/flag/index.ts +++ b/src/remote/activitypub/kernel/flag/index.ts @@ -19,8 +19,10 @@ export default async (actor: IRemoteUser, activity: IFlag): Promise<string> => { await AbuseUserReports.insert({ id: genId(), createdAt: new Date(), - userId: users[0].id, + targetUserId: users[0].id, + targetUserHost: users[0].host, reporterId: actor.id, + reporterHost: actor.host, comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}` }); diff --git a/src/server/api/endpoints/admin/abuse-user-reports.ts b/src/server/api/endpoints/admin/abuse-user-reports.ts index d5a52184d1..6a7f380e16 100644 --- a/src/server/api/endpoints/admin/abuse-user-reports.ts +++ b/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -23,12 +23,50 @@ export const meta = { untilId: { validator: $.optional.type(ID), }, + + state: { + validator: $.optional.nullable.str, + default: null, + }, + + reporterOrigin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'combined' + }, + + targetUserOrigin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'combined' + }, } }; export default define(meta, async (ps) => { const query = makePaginationQuery(AbuseUserReports.createQueryBuilder('report'), ps.sinceId, ps.untilId); + switch (ps.state) { + case 'resolved': query.andWhere('report.resolved = TRUE'); break; + case 'unresolved': query.andWhere('report.resolved = FALSE'); break; + } + + switch (ps.reporterOrigin) { + case 'local': query.andWhere('report.reporterHost IS NULL'); break; + case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; + } + + switch (ps.targetUserOrigin) { + case 'local': query.andWhere('report.targetUserHost IS NULL'); break; + case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; + } + const reports = await query.take(ps.limit!).getMany(); return await AbuseUserReports.packMany(reports); diff --git a/src/server/api/endpoints/admin/remove-abuse-user-report.ts b/src/server/api/endpoints/admin/resolve-abuse-user-report.ts similarity index 77% rename from src/server/api/endpoints/admin/remove-abuse-user-report.ts rename to src/server/api/endpoints/admin/resolve-abuse-user-report.ts index 150de5f5d4..0a62b5f365 100644 --- a/src/server/api/endpoints/admin/remove-abuse-user-report.ts +++ b/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -16,12 +16,15 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const report = await AbuseUserReports.findOne(ps.reportId); if (report == null) { throw new Error('report not found'); } - await AbuseUserReports.delete(report.id); + await AbuseUserReports.update(report.id, { + resolved: true, + assigneeId: me.id, + }); }); diff --git a/src/server/api/endpoints/users/report-abuse.ts b/src/server/api/endpoints/users/report-abuse.ts index a9b5543f3c..eaa4cd6258 100644 --- a/src/server/api/endpoints/users/report-abuse.ts +++ b/src/server/api/endpoints/users/report-abuse.ts @@ -26,7 +26,7 @@ export const meta = { }, comment: { - validator: $.str.range(1, 3000), + validator: $.str.range(1, 2048), desc: { 'ja-JP': '迷惑行為の詳細' } @@ -72,9 +72,11 @@ export default define(meta, async (ps, me) => { const report = await AbuseUserReports.save({ id: genId(), createdAt: new Date(), - userId: user.id, + targetUserId: user.id, + targetUserHost: user.host, reporterId: me.id, - comment: ps.comment + reporterHost: null, + comment: ps.comment, }); // Publish event to moderators @@ -90,7 +92,7 @@ export default define(meta, async (ps, me) => { for (const moderator of moderators) { publishAdminStream(moderator.id, 'newAbuseUserReport', { id: report.id, - userId: report.userId, + targetUserId: report.targetUserId, reporterId: report.reporterId, comment: report.comment });