add user-level "force content warning" moderation feature

This commit is contained in:
Hazelnoot 2025-01-28 01:47:03 -05:00
parent 2bf8648ebc
commit ea89348b62
11 changed files with 534 additions and 407 deletions

8
locales/index.d.ts vendored
View file

@ -12089,6 +12089,14 @@ export interface Locale extends ILocale {
* ID
*/
"id": string;
/**
* Force content warning
*/
"mandatoryCW": string;
/**
* Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end.
*/
"mandatoryCWDescription": string;
}
declare const locales: {
[lang: string]: Locale;

View file

@ -0,0 +1,11 @@
export class AddUserMandatoryCW1738043621143 {
name = 'AddUserCW1738043621143'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "mandatoryCW" text`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "mandatoryCW"`);
}
}

View file

@ -234,6 +234,7 @@ export class NoteCreateService implements OnApplicationShutdown {
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
mandatoryCW: MiUser['mandatoryCW'];
}, data: Option, silent = false): Promise<MiNote> {
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
@ -368,6 +369,15 @@ export class NoteCreateService implements OnApplicationShutdown {
data.cw = null;
}
// Apply mandatory CW, if applicable
if (user.mandatoryCW) {
if (data.cw) {
data.cw += `, ${user.mandatoryCW}`;
} else {
data.cw = user.mandatoryCW;
}
}
let tags = data.apHashtags;
let emojis = data.apEmojis;
let mentionedUsers = data.apMentions;
@ -441,6 +451,7 @@ export class NoteCreateService implements OnApplicationShutdown {
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
mandatoryCW: MiUser['mandatoryCW'];
}, data: Option): Promise<MiNote> {
return this.create(user, data, true);
}

View file

@ -230,6 +230,7 @@ export class NoteEditService implements OnApplicationShutdown {
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
mandatoryCW: MiUser['mandatoryCW'];
}, editid: MiNote['id'], data: Option, silent = false): Promise<MiNote> {
if (!editid) {
throw new Error('fail');
@ -396,6 +397,15 @@ export class NoteEditService implements OnApplicationShutdown {
data.cw = null;
}
// Apply mandatory CW, if applicable
if (user.mandatoryCW) {
if (data.cw) {
data.cw += `, ${user.mandatoryCW}`;
} else {
data.cw = user.mandatoryCW;
}
}
let tags = data.apHashtags;
let emojis = data.apEmojis;
let mentionedUsers = data.apMentions;

View file

@ -339,6 +339,15 @@ export class MiUser {
})
public enableRss: boolean;
/**
* Specifies a Content Warning that should be forcibly applied to all notes by this user.
* If null (default), then no Content Warning is applied.
*/
@Column('text', {
nullable: true,
})
public mandatoryCW: string | null;
constructor(data: Partial<MiUser>) {
if (data == null) return;

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:cw-user',
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
cw: { type: 'string', nullable: true },
},
required: ['userId', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private readonly usersRepository: UsersRepository,
private readonly globalEventService: GlobalEventService,
) {
super(meta, paramDef, async ps => {
const result = await this.usersRepository.update(ps.userId, {
// Collapse empty strings to null
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
mandatoryCW: ps.cw || null,
});
if (result.affected && result.affected < 1) {
throw new Error('No such user');
}
// Synchronize caches and other processes
this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId });
});
}
}

View file

@ -144,6 +144,10 @@ export const meta = {
type: 'string',
optional: false, nullable: false,
},
mandatoryCW: {
type: 'string',
optional: false, nullable: true,
},
signins: {
type: 'array',
optional: false, nullable: false,
@ -260,6 +264,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
isHibernated: user.isHibernated,
lastActiveDate: user.lastActiveDate ? user.lastActiveDate.toISOString() : null,
moderationNote: profile.moderationNote ?? '',
mandatoryCW: user.mandatoryCW,
signins,
policies: await this.roleService.getUserPolicies(user.id),
roles: await this.roleEntityService.packMany(roles, me),

View file

@ -83,6 +83,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
<MkSwitch v-model="markedAsNSFW" @update:modelValue="toggleNSFW">{{ i18n.ts.markAsNSFW }}</MkSwitch>
<MkInput v-model="mandatoryCW" type="text" manualSave>
<template #label>{{ i18n.ts.mandatoryCW }}</template>
<template #caption>{{ i18n.ts.mandatoryCWDescription }}</template>
</MkInput>
<div>
<MkButton v-if="user.host == null && !isSystem" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton>
</div>
@ -222,6 +227,7 @@ import { i18n } from '@/i18n.js';
import { iAmAdmin, $i, iAmModerator } from '@/account.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkInput from '@/components/MkInput.vue';
const props = withDefaults(defineProps<{
userId: string;
@ -243,6 +249,7 @@ const approved = ref(false);
const suspended = ref(false);
const markedAsNSFW = ref(false);
const moderationNote = ref('');
const mandatoryCW = ref<string | null>(null);
const isSystem = computed(() => info.value?.isSystem ?? false);
const filesPagination = {
endpoint: 'admin/drive/files' as const,
@ -281,6 +288,15 @@ function createFetcher() {
markedAsNSFW.value = info.value.alwaysMarkNsfw;
suspended.value = info.value.isSuspended;
moderationNote.value = info.value.moderationNote;
mandatoryCW.value = info.value.mandatoryCW;
// These watch statements work because they're lazy-initialized.
// The watched values are already set, so they don't trigger any "change" just from refreshing the user.
watch(mandatoryCW, async () => {
await os.apiWithDialog('admin/cw-user', { userId: props.userId, cw: mandatoryCW.value });
refreshUser();
});
watch(moderationNote, async () => {
await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value });

File diff suppressed because it is too large Load diff

View file

@ -83,6 +83,7 @@ export const permissions = [
'write:admin:decline-user',
'write:admin:nsfw-user',
'write:admin:unnsfw-user',
'write:admin:cw-user',
'write:admin:silence-user',
'write:admin:unsilence-user',
'write:admin:unset-user-avatar',

View file

@ -471,3 +471,6 @@ _noteSearch:
flash: "Flash"
id: "ID"
mandatoryCW: "Force content warning"
mandatoryCWDescription: "Applies a content warning to all posts created by this user. If the post already has a CW, then this is appended to the end."