Resolve #6087
This commit is contained in:
		
							parent
							
								
									5762e2d9ba
								
							
						
					
					
						commit
						87f61e714a
					
				
					 18 changed files with 461 additions and 23 deletions
				
			
		|  | @ -586,6 +586,13 @@ setMultipleBySeparatingWithSpace: "スペースで区切って複数設定でき | |||
| fileIdOrUrl: "ファイルIDまたはURL" | ||||
| chatOpenBehavior: "チャットを開くときの動作" | ||||
| sample: "サンプル" | ||||
| abuseReports: "通報" | ||||
| reportAbuse: "通報" | ||||
| reportAbuseOf: "{name}を通報する" | ||||
| fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。" | ||||
| abuseReported: "内容が送信されました。ご報告ありがとうございました。" | ||||
| send: "送信" | ||||
| abuseMarkAsResolved: "対応済みにする" | ||||
| 
 | ||||
| _serverDisconnectedBehavior: | ||||
|   reload: "自動でリロード" | ||||
|  |  | |||
							
								
								
									
										38
									
								
								migration/1603094348345-refine-abuse-user-report.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								migration/1603094348345-refine-abuse-user-report.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										20
									
								
								migration/1603095701770-refine-abuse-user-report2.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								migration/1603095701770-refine-abuse-user-report2.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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"`); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										85
									
								
								src/client/components/abuse-report-window.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/client/components/abuse-report-window.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||
|  | @ -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 ? { | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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'), | ||||
|  |  | |||
|  | @ -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; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
							
								
								
									
										163
									
								
								src/client/pages/instance/abuses.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								src/client/pages/instance/abuses.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||
|  | @ -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') }, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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
 | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
| 		}); | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -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)}` | ||||
| 	}); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -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, | ||||
| 	}); | ||||
| }); | ||||
|  | @ -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 | ||||
| 			}); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue