diff options
| author | dakkar <dakkar@thenautilus.net> | 2025-02-20 10:20:49 +0000 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2025-02-20 10:20:49 +0000 |
| commit | 534c35cca2b2492dea05b7c7b23802af5435291a (patch) | |
| tree | d3dec52a465dc2797919e03a7d2d049960a1c86e | |
| parent | merge: Add separate redis for rate limit (!908) (diff) | |
| parent | add `admin/cw-user` to new endpoints list (diff) | |
| download | sharkey-534c35cca2b2492dea05b7c7b23802af5435291a.tar.gz sharkey-534c35cca2b2492dea05b7c7b23802af5435291a.tar.bz2 sharkey-534c35cca2b2492dea05b7c7b23802af5435291a.zip | |
merge: Add "force content warning" setting for user moderation (resolves #905) (!876)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/876
Closes #905
Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
46 files changed, 843 insertions, 108 deletions
diff --git a/locales/index.d.ts b/locales/index.d.ts index 9624b48b42..bf49869bf8 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -8555,6 +8555,10 @@ export interface Locale extends ILocale { */ "write:admin:unnsfw-user": string; /** + * Apply mandatory CW on users + */ + "write:admin:cw-user": string; + /** * Silence users */ "write:admin:silence-user": string; @@ -10215,6 +10219,14 @@ export interface Locale extends ILocale { */ "approve": string; /** + * Declined + */ + "decline": string; + /** + * Set content warning for user + */ + "setMandatoryCW": string; + /** * Set remote instance as NSFW */ "setRemoteInstanceNSFW": string; @@ -12089,6 +12101,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; diff --git a/packages/backend/migration/1738043621143-add_user_mandatoryCW.js b/packages/backend/migration/1738043621143-add_user_mandatoryCW.js new file mode 100644 index 0000000000..dd05076dd2 --- /dev/null +++ b/packages/backend/migration/1738043621143-add_user_mandatoryCW.js @@ -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"`); + } +} diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 3bfced1d80..8291db9b42 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -228,7 +228,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public async create(user: { + public async create(user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -435,7 +435,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public async import(user: { + public async import(user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -486,10 +486,10 @@ export class NoteCreateService implements OnApplicationShutdown { // should really not happen, but better safe than sorry if (data.reply?.id === insert.id) { - throw new Error("A note can't reply to itself"); + throw new Error('A note can\'t reply to itself'); } if (data.renote?.id === insert.id) { - throw new Error("A note can't renote itself"); + throw new Error('A note can\'t renote itself'); } if (data.uri != null) insert.uri = data.uri; @@ -552,7 +552,7 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private async postNoteCreated(note: MiNote, user: { + private async postNoteCreated(note: MiNote, user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -753,7 +753,7 @@ export class NoteCreateService implements OnApplicationShutdown { //#region AP deliver if (!data.localOnly && this.userEntityService.isLocalUser(user)) { (async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note); + const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); // メンションされたリモートユーザーに配送 @@ -899,12 +899,12 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { + private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) { if (data.localOnly) return null; const content = this.isRenote(data) && !this.isQuote(data) ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) - : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); + : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note); return this.apRendererService.addContext(content); } diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 453ad5d9d0..24a99156d2 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -224,7 +224,7 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis - public async edit(user: { + public async edit(user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -309,7 +309,7 @@ export class NoteEditService implements OnApplicationShutdown { if (this.isRenote(data)) { if (data.renote.id === oldnote.id) { - throw new Error("A note can't renote itself"); + throw new Error('A note can\'t renote itself'); } switch (data.renote.visibility) { @@ -584,7 +584,7 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis - private async postNoteEdited(note: MiNote, oldNote: MiNote, user: { + private async postNoteEdited(note: MiNote, oldNote: MiNote, user: MiUser & { id: MiUser['id']; username: MiUser['username']; host: MiUser['host']; @@ -703,7 +703,7 @@ export class NoteEditService implements OnApplicationShutdown { //#region AP deliver if (!data.localOnly && this.userEntityService.isLocalUser(user)) { (async () => { - const noteActivity = await this.renderNoteOrRenoteActivity(data, note); + const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); // メンションされたリモートユーザーに配送 @@ -834,14 +834,12 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis - private async renderNoteOrRenoteActivity(data: Option, note: MiNote) { + private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) { if (data.localOnly) return null; - const user = await this.usersRepository.findOneBy({ id: note.userId }); - if (user == null) throw new Error('user not found'); const content = this.isRenote(data) && !this.isQuote(data) ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note) - : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, false), user); + : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user); return this.apRendererService.addContext(content); } diff --git a/packages/backend/src/core/PollService.ts b/packages/backend/src/core/PollService.ts index 6c96ab16cf..d6364613bd 100644 --- a/packages/backend/src/core/PollService.ts +++ b/packages/backend/src/core/PollService.ts @@ -100,7 +100,7 @@ export class PollService { if (user == null) throw new Error('note not found'); if (this.userEntityService.isLocalUser(user)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user)); + const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, user, false), user)); this.apDeliverManagerService.deliverToFollowers(user, content); this.relayService.deliverToRelays(user, content); } diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index dfe7a259c4..93693216cb 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -99,6 +99,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser { signupReason: null, noindex: false, enableRss: true, + mandatoryCW: null, ...override, }; } @@ -216,6 +217,7 @@ function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<' isSystem: false, isSilenced: user.isSilenced, enableRss: true, + mandatoryCW: null, ...override, }; } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 721cb77b2f..cb9b74f6d7 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -28,6 +28,7 @@ import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFil import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { IdService } from '@/core/IdService.js'; +import { appendContentWarning } from '@/misc/append-content-warning.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; @@ -339,7 +340,7 @@ export class ApRendererService { } @bindThis - public async renderNote(note: MiNote, dive = true): Promise<IPost> { + public async renderNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> { const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => { if (ids.length === 0) return []; const items = await this.driveFilesRepository.findBy({ id: In(ids) }); @@ -353,14 +354,14 @@ export class ApRendererService { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); if (inReplyToNote != null) { - const inReplyToUserExist = await this.usersRepository.exists({ where: { id: inReplyToNote.userId } }); + const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - if (inReplyToUserExist) { + if (inReplyToUser) { if (inReplyToNote.uri) { inReplyTo = inReplyToNote.uri; } else { if (dive) { - inReplyTo = await this.renderNote(inReplyToNote, false); + inReplyTo = await this.renderNote(inReplyToNote, inReplyToUser, false); } else { inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; } @@ -423,7 +424,12 @@ export class ApRendererService { apAppend += `\n\nRE: ${quote}`; } - const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + + // Apply mandatory CW, if applicable + if (author.mandatoryCW) { + summary = appendContentWarning(summary, author.mandatoryCW); + } const { content } = this.apMfmService.getNoteHtml(note, apAppend); @@ -636,7 +642,7 @@ export class ApRendererService { } @bindThis - public async renderUpNote(note: MiNote, dive = true): Promise<IPost> { + public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> { const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => { if (ids.length === 0) return []; const items = await this.driveFilesRepository.findBy({ id: In(ids) }); @@ -650,14 +656,14 @@ export class ApRendererService { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); if (inReplyToNote != null) { - const inReplyToUserExist = await this.usersRepository.exists({ where: { id: inReplyToNote.userId } }); + const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - if (inReplyToUserExist) { + if (inReplyToUser) { if (inReplyToNote.uri) { inReplyTo = inReplyToNote.uri; } else { if (dive) { - inReplyTo = await this.renderUpNote(inReplyToNote, false); + inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false); } else { inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; } @@ -720,7 +726,12 @@ export class ApRendererService { apAppend += `\n\nRE: ${quote}`; } - const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + + // Apply mandatory CW, if applicable + if (author.mandatoryCW) { + summary = appendContentWarning(summary, author.mandatoryCW); + } const { content } = this.apMfmService.getNoteHtml(note, apAppend); diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 410803609c..f5b63a2827 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -209,11 +209,12 @@ export class Resolver { case 'notes': return this.notesRepository.findOneByOrFail({ id: parsed.id }) .then(async note => { + const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); if (parsed.rest === 'activity') { // this refers to the create activity and not the note itself - return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note), note)); + return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note)); } else { - return this.apRendererService.renderNote(note); + return this.apRendererService.renderNote(note, author); } }); case 'users': diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index ef0b5213c8..4fbbbdd379 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -592,6 +592,7 @@ export class UserEntityService implements OnModuleInit { isCat: user.isCat, noindex: user.noindex, enableRss: user.enableRss, + mandatoryCW: user.mandatoryCW, isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), speakAsCat: user.speakAsCat ?? false, approved: user.approved, diff --git a/packages/backend/src/misc/append-content-warning.ts b/packages/backend/src/misc/append-content-warning.ts new file mode 100644 index 0000000000..152cd6760e --- /dev/null +++ b/packages/backend/src/misc/append-content-warning.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* + * Important Note: this file must be kept in sync with packages/frontend-shared/js/append-content-warning.ts + */ + +/** + * Appends an additional content warning onto an existing one. + * The additional value will not be added if it already exists within the original input. + * @param original Existing content warning + * @param additional Content warning to append + * @param reverse If true, then the additional CW will be prepended instead of appended. + */ +export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string { + // Easy case - if original is empty, then additional replaces it. + if (!original) { + return additional; + } + + // Easy case - if the additional CW is empty, then don't append it. + if (!additional) { + return original; + } + + // If the additional CW already exists in the input, then we *don't* append another copy! + if (includesWholeWord(original, additional)) { + return original; + } + + return reverse + ? `${additional}, ${original}` + : `${original}, ${additional}`; +} + +/** + * Emulates a regular expression like /\b(pattern)\b/, but with a raw non-regex pattern. + * We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries on either side. + * @param input Input string to search + * @param target Target word / phrase to search for + */ +function includesWholeWord(input: string, target: string): boolean { + const parts = input.split(target); + + // The additional string could appear multiple times within the original input. + // We need to check each occurrence, since any of them could potentially match. + for (let i = 0; i + 1 < parts.length; i++) { + const before = parts[i]; + const after = parts[i + 1]; + + // If either the preceding or following tokens are a "word", then this "match" is actually just part of a longer word. + // Likewise, if *neither* token is a word, then this is a real match and the CW already exists in the input. + if (!/\w$/.test(before) && !/^\w/.test(after)) { + return true; + } + } + + // If we don't match, then there is no existing CW. + return false; +} diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index 60dddee9a2..be2d3ea98d 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { appendContentWarning } from './append-content-warning.js'; import type { Packed } from './json-schema.js'; /** @@ -20,9 +21,15 @@ export const getNoteSummary = (note: Packed<'Note'>): string => { let summary = ''; + // Append mandatory CW, if applicable + let cw = note.cw; + if (note.user.mandatoryCW) { + cw = appendContentWarning(cw, note.user.mandatoryCW); + } + // 本文 - if (note.cw != null) { - summary += `CW: ${note.cw}`; + if (cw != null) { + summary += `CW: ${cw}`; } else if (note.text) { summary += note.text; } diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 3a825d36a7..8a3ad1003d 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -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; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 93b031e9c5..1c2ba538c1 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -134,6 +134,10 @@ export const packedUserLiteSchema = { type: 'boolean', nullable: false, optional: false, }, + mandatoryCW: { + type: 'string', + nullable: true, optional: false, + }, isBot: { type: 'boolean', nullable: false, optional: true, diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 72faa3318c..10dba1660f 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -103,15 +103,16 @@ export class ActivityPubServerService { /** * Pack Create<Note> or Announce Activity * @param note Note + * @param author Author of the note */ @bindThis - private async packActivity(note: MiNote): Promise<any> { + private async packActivity(note: MiNote, author: MiUser): Promise<any> { if (isRenote(note) && !isQuote(note)) { const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); } - return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); + return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note); } @bindThis @@ -506,7 +507,7 @@ export class ActivityPubServerService { this.notesRepository.findOneByOrFail({ id: pining.noteId })))) .filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility)); - const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note))); + const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note, user))); const rendered = this.apRendererService.renderOrderedCollection( `${this.config.url}/users/${userId}/collections/featured`, @@ -579,7 +580,7 @@ export class ActivityPubServerService { if (sinceId) notes.reverse(); - const activities = await Promise.all(notes.map(note => this.packActivity(note))); + const activities = await Promise.all(notes.map(note => this.packActivity(note, user))); const rendered = this.apRendererService.renderOrderedCollectionPage( `${partOf}?${url.query({ page: 'true', @@ -723,7 +724,9 @@ export class ActivityPubServerService { if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); - return this.apRendererService.addContext(await this.apRendererService.renderNote(note, false)); + + const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); + return this.apRendererService.addContext(await this.apRendererService.renderNote(note, author, false)); }); // note activity @@ -746,7 +749,9 @@ export class ActivityPubServerService { if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.packActivity(note))); + + const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); + return (this.apRendererService.addContext(await this.packActivity(note, author))); }); // outbox diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 6dce7f1a3d..551d7b17c2 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -34,6 +34,7 @@ export * as 'admin/avatar-decorations/list' from './endpoints/admin/avatar-decor export * as 'admin/avatar-decorations/update' from './endpoints/admin/avatar-decorations/update.js'; export * as 'admin/captcha/current' from './endpoints/admin/captcha/current.js'; export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js'; +export * as 'admin/cw-user' from './endpoints/admin/cw-user.js'; export * as 'admin/decline-user' from './endpoints/admin/decline-user.js'; export * as 'admin/delete-account' from './endpoints/admin/delete-account.js'; export * as 'admin/delete-all-files-of-a-user' from './endpoints/admin/delete-all-files-of-a-user.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/cw-user.ts b/packages/backend/src/server/api/endpoints/admin/cw-user.ts new file mode 100644 index 0000000000..bdcfa6a0d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/cw-user.ts @@ -0,0 +1,67 @@ +/* + * 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'; +import { ModerationLogService } from '@/core/ModerationLogService.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, + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const user = await this.cacheService.findUserById(ps.userId); + + // Skip if there's nothing to do + if (user.mandatoryCW === ps.cw) return; + + // Log event first. + // This ensures that we don't "lose" the log if an error occurs + await this.moderationLogService.log(me, 'setMandatoryCW', { + newCW: ps.cw, + oldCW: user.mandatoryCW, + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + + await this.usersRepository.update(ps.userId, { + // Collapse empty strings to null + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + mandatoryCW: ps.cw || null, + }); + + // Synchronize caches and other processes + this.globalEventService.publishInternalEvent('localUserUpdated', { id: ps.userId }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts index d3fa4251dd..f64ba7f48a 100644 --- a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['admin'], @@ -28,10 +29,12 @@ export const paramDef = { export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + private readonly usersRepository: UsersRepository, @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + private readonly userProfilesRepository: UserProfilesRepository, + + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -43,6 +46,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- await this.userProfilesRepository.update(user.id, { alwaysMarkNsfw: true, }); + + await this.cacheService.userProfileCache.refresh(ps.userId); }); } } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 067481d9da..b359fa5a39 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -100,6 +100,7 @@ export const moderationLogTypes = [ 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'resetPassword', + 'setMandatoryCW', 'setRemoteInstanceNSFW', 'unsetRemoteInstanceNSFW', 'suspendRemoteInstance', @@ -261,6 +262,13 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + setMandatoryCW: { + newCW: string | null; + oldCW: string | null; + userId: string; + userUsername: string; + userHost: string | null; + }; setRemoteInstanceNSFW: { id: string; host: string; diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 73d6186edf..435dbb5bea 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { IdService } from '@/core/IdService.js'; + process.env.NODE_ENV = 'test'; import * as assert from 'assert'; @@ -20,7 +22,7 @@ import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js'; -import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js'; +import { MiMeta, MiNote, MiUser, UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DownloadService } from '@/core/DownloadService.js'; @@ -93,6 +95,7 @@ describe('ActivityPub', () => { let rendererService: ApRendererService; let jsonLdService: JsonLdService; let resolver: MockResolver; + let idService: IdService; const metaInitial = { cacheRemoteFiles: true, @@ -140,6 +143,7 @@ describe('ActivityPub', () => { imageService = app.get<ApImageService>(ApImageService); jsonLdService = app.get<JsonLdService>(JsonLdService); resolver = new MockResolver(await app.resolve<LoggerService>(LoggerService)); + idService = app.get<IdService>(IdService); // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService); @@ -477,4 +481,143 @@ describe('ActivityPub', () => { }); }); }); + + describe(ApRendererService, () => { + let note: MiNote; + let author: MiUser; + + beforeEach(() => { + author = new MiUser({ + id: idService.gen(), + }); + note = new MiNote({ + id: idService.gen(), + userId: author.id, + visibility: 'public', + localOnly: false, + text: 'Note text', + cw: null, + renoteCount: 0, + repliesCount: 0, + clippedCount: 0, + reactions: {}, + fileIds: [], + attachedFileTypes: [], + visibleUserIds: [], + mentions: [], + // This is fucked tbh - it's JSON stored in a TEXT column that gets parsed/serialized all over the place + mentionedRemoteUsers: '[]', + reactionAndUserPairCache: [], + emojis: [], + tags: [], + hasPoll: false, + }); + }); + + describe('renderNote', () => { + describe('summary', () => { + // I actually don't know why it does this, but the logic was already there so I've preserved it. + it('should be zero-width space when CW is empty string', async () => { + note.cw = ''; + + const result = await rendererService.renderNote(note, author, false); + + expect(result.summary).toBe(String.fromCharCode(0x200B)); + }); + + it('should be undefined when CW is null', async () => { + const result = await rendererService.renderNote(note, author, false); + + expect(result.summary).toBeUndefined(); + }); + + it('should be CW when present without mandatoryCW', async () => { + note.cw = 'original'; + + const result = await rendererService.renderNote(note, author, false); + + expect(result.summary).toBe('original'); + }); + + it('should be mandatoryCW when present without CW', async () => { + author.mandatoryCW = 'mandatory'; + + const result = await rendererService.renderNote(note, author, false); + + expect(result.summary).toBe('mandatory'); + }); + + it('should be merged when CW and mandatoryCW are both present', async () => { + note.cw = 'original'; + author.mandatoryCW = 'mandatory'; + + const result = await rendererService.renderNote(note, author, false); + + expect(result.summary).toBe('original, mandatory'); + }); + + it('should be CW when CW includes mandatoryCW', async () => { + note.cw = 'original and mandatory'; + author.mandatoryCW = 'mandatory'; + + const result = await rendererService.renderNote(note, author, false); + + expect(result.summary).toBe('original and mandatory'); + }); + }); + }); + + describe('renderUpnote', () => { + describe('summary', () => { + // I actually don't know why it does this, but the logic was already there so I've preserved it. + it('should be zero-width space when CW is empty string', async () => { + note.cw = ''; + + const result = await rendererService.renderUpNote(note, author, false); + + expect(result.summary).toBe(String.fromCharCode(0x200B)); + }); + + it('should be undefined when CW is null', async () => { + const result = await rendererService.renderUpNote(note, author, false); + + expect(result.summary).toBeUndefined(); + }); + + it('should be CW when present without mandatoryCW', async () => { + note.cw = 'original'; + + const result = await rendererService.renderUpNote(note, author, false); + + expect(result.summary).toBe('original'); + }); + + it('should be mandatoryCW when present without CW', async () => { + author.mandatoryCW = 'mandatory'; + + const result = await rendererService.renderUpNote(note, author, false); + + expect(result.summary).toBe('mandatory'); + }); + + it('should be merged when CW and mandatoryCW are both present', async () => { + note.cw = 'original'; + author.mandatoryCW = 'mandatory'; + + const result = await rendererService.renderUpNote(note, author, false); + + expect(result.summary).toBe('original, mandatory'); + }); + + it('should be CW when CW includes mandatoryCW', async () => { + note.cw = 'original and mandatory'; + author.mandatoryCW = 'mandatory'; + + const result = await rendererService.renderUpNote(note, author, false); + + expect(result.summary).toBe('original and mandatory'); + }); + }); + }); + }); }); diff --git a/packages/backend/test/unit/misc/append-content-warning.ts b/packages/backend/test/unit/misc/append-content-warning.ts new file mode 100644 index 0000000000..d25d7c4925 --- /dev/null +++ b/packages/backend/test/unit/misc/append-content-warning.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { appendContentWarning } from '@/misc/append-content-warning.js'; + +describe(appendContentWarning, () => { + it('should return additional when original is null', () => { + const result = appendContentWarning(null, 'additional'); + + expect(result).toBe('additional'); + }); + + it('should return additional when original is undefined', () => { + const result = appendContentWarning(undefined, 'additional'); + + expect(result).toBe('additional'); + }); + + it('should return additional when original is empty', () => { + const result = appendContentWarning('', 'additional'); + + expect(result).toBe('additional'); + }); + + it('should return original when additional is empty', () => { + const result = appendContentWarning('original', ''); + + expect(result).toBe('original'); + }); + + it('should append additional when it does not exist in original', () => { + const result = appendContentWarning('original', 'additional'); + + expect(result).toBe('original, additional'); + }); + + it('should append additional when it exists in original but has preceeding word', () => { + const result = appendContentWarning('notadditional', 'additional'); + + expect(result).toBe('notadditional, additional'); + }); + + it('should append additional when it exists in original but has following word', () => { + const result = appendContentWarning('additionalnot', 'additional'); + + expect(result).toBe('additionalnot, additional'); + }); + + it('should append additional when it exists in original multiple times but has preceeding or following word', () => { + const result = appendContentWarning('notadditional additionalnot', 'additional'); + + expect(result).toBe('notadditional additionalnot, additional'); + }); + + it('should not append additional when it exists in original', () => { + const result = appendContentWarning('an additional word', 'additional'); + + expect(result).toBe('an additional word'); + }); + + it('should not append additional when original starts with it', () => { + const result = appendContentWarning('additional word', 'additional'); + + expect(result).toBe('additional word'); + }); + + it('should not append additional when original ends with it', () => { + const result = appendContentWarning('an additional', 'additional'); + + expect(result).toBe('an additional'); + }); + + it('should not append additional when it appears multiple times', () => { + const result = appendContentWarning('an additional additional word', 'additional'); + + expect(result).toBe('an additional additional word'); + }); + + it('should not append additional when it appears multiple times but some have preceeding or following', () => { + const result = appendContentWarning('a notadditional additional additionalnot word', 'additional'); + + expect(result).toBe('a notadditional additional additionalnot word'); + }); + + it('should prepend additional when reverse is true', () => { + const result = appendContentWarning('original', 'additional', true); + + expect(result).toBe('additional, original'); + }); +}); diff --git a/packages/frontend-embed/src/components/EmNote.vue b/packages/frontend-embed/src/components/EmNote.vue index 025c4c0734..bf96c557ea 100644 --- a/packages/frontend-embed/src/components/EmNote.vue +++ b/packages/frontend-embed/src/components/EmNote.vue @@ -46,11 +46,11 @@ SPDX-License-Identifier: AGPL-3.0-only <EmNoteHeader :note="appearNote" :mini="true"/> <EmInstanceTicker v-if="appearNote.user.instance != null" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> - <p v-if="appearNote.cw != null" :class="$style.cw"> - <EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/> + <p v-if="mergedCW != null" :class="$style.cw"> + <EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/> <button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> </p> - <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> + <div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA> @@ -109,6 +109,7 @@ import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { shouldCollapsed } from '@@/js/collapsed.js'; import { url } from '@@/js/config.js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import I18n from '@/components/I18n.vue'; import EmNoteSub from '@/components/EmNoteSub.vue'; import EmNoteHeader from '@/components/EmNoteHeader.vue'; @@ -154,6 +155,8 @@ const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value const isLong = shouldCollapsed(appearNote.value, []); const collapsed = ref(appearNote.value.cw == null && isLong); const isDeleted = ref(false); + +const mergedCW = computed(() => computeMergedCw(appearNote.value)); </script> <style lang="scss" module> diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue index c4ea9b4f2e..0961b36e35 100644 --- a/packages/frontend-embed/src/components/EmNoteDetailed.vue +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -58,11 +58,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </header> <div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]"> - <p v-if="appearNote.cw != null" :class="$style.cw"> - <EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/> + <p v-if="mergedCW != null" :class="$style.cw"> + <EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :isBlock="true"/> <button style="display: block; width: 100%; margin: 4px 0;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> </p> - <div v-show="appearNote.cw == null || showContent"> + <div v-show="mergedCW == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA> <EmMfm @@ -130,6 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, inject, ref } from 'vue'; import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import I18n from '@/components/I18n.vue'; import EmMediaList from '@/components/EmMediaList.vue'; import EmNoteSub from '@/components/EmNoteSub.vue'; @@ -175,6 +176,8 @@ const isDeleted = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const isLong = shouldCollapsed(appearNote.value, []); const collapsed = ref(appearNote.value.cw == null && isLong); + +const mergedCW = computed(() => computeMergedCw(appearNote.value)); </script> <style lang="scss" module> diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue index 83e73f9870..688758edb6 100644 --- a/packages/frontend-embed/src/components/EmNoteSimple.vue +++ b/packages/frontend-embed/src/components/EmNoteSimple.vue @@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.main"> <EmNoteHeader :class="$style.header" :note="note" :mini="true"/> <div> - <p v-if="note.cw != null" :class="$style.cw"> - <EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis" :isBlock="true"/> + <p v-if="mergedCW != null" :class="$style.cw"> + <EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis" :isBlock="true"/> <button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> </p> - <div v-show="note.cw == null || showContent"> + <div v-show="mergedCW == null || showContent"> <EmSubNoteContent :class="$style.text" :note="note"/> </div> </div> @@ -22,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import { i18n } from '@/i18n.js'; import EmAvatar from '@/components/EmAvatar.vue'; import EmNoteHeader from '@/components/EmNoteHeader.vue'; @@ -35,6 +36,8 @@ const props = defineProps<{ }>(); const showContent = ref(false); + +const mergedCW = computed(() => computeMergedCw(props.note)); </script> <style lang="scss" module> diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue index cc379e8281..629f0bffcd 100644 --- a/packages/frontend-embed/src/components/EmNoteSub.vue +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.body"> <EmNoteHeader :class="$style.header" :note="note" :mini="true"/> <div> - <p v-if="note.cw != null" :class="$style.cw"> - <EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :isBlock="true"/> + <p v-if="mergedCW != null" :class="$style.cw"> + <EmMfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :author="note.user" :nyaize="'respect'" :isBlock="true"/> <button style="display: block; width: 100%;" class="_buttonGray _buttonRounded" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</button> </p> - <div v-show="note.cw == null || showContent"> + <div v-show="mergedCW == null || showContent"> <EmSubNoteContent :class="$style.text" :note="note"/> </div> </div> @@ -31,8 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import EmA from '@/components/EmA.vue'; import EmAvatar from '@/components/EmAvatar.vue'; import EmNoteHeader from '@/components/EmNoteHeader.vue'; @@ -55,6 +56,8 @@ const props = withDefaults(defineProps<{ const showContent = ref(false); const replies = ref<Misskey.entities.Note[]>([]); +const mergedCW = computed(() => computeMergedCw(props.note)); + if (props.detail) { misskeyApi('notes/children', { noteId: props.note.id, diff --git a/packages/frontend-shared/js/append-content-warning.ts b/packages/frontend-shared/js/append-content-warning.ts new file mode 100644 index 0000000000..7f24a66f23 --- /dev/null +++ b/packages/frontend-shared/js/append-content-warning.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* + * Important Note: this file must be kept in sync with packages/backend/src/misc/append-content-warning.ts + */ + +/** + * Appends an additional content warning onto an existing one. + * The additional value will not be added if it already exists within the original input. + * @param original Existing content warning + * @param additional Content warning to append + * @param reverse If true, then the additional CW will be prepended instead of appended. + */ +export function appendContentWarning(original: string | null | undefined, additional: string, reverse = false): string { + // Easy case - if original is empty, then additional replaces it. + if (!original) { + return additional; + } + + // Easy case - if the additional CW is empty, then don't append it. + if (!additional) { + return original; + } + + // If the additional CW already exists in the input, then we *don't* append another copy! + if (includesWholeWord(original, additional)) { + return original; + } + + return reverse + ? `${additional}, ${original}` + : `${original}, ${additional}`; +} + +/** + * Emulates a regular expression like /\b(pattern)\b/, but with a raw non-regex pattern. + * We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries on either side. + * @param input Input string to search + * @param target Target word / phrase to search for + */ +function includesWholeWord(input: string, target: string): boolean { + const parts = input.split(target); + + // The additional string could appear multiple times within the original input. + // We need to check each occurrence, since any of them could potentially match. + for (let i = 0; i + 1 < parts.length; i++) { + const before = parts[i]; + const after = parts[i + 1]; + + // If either the preceding or following tokens are a "word", then this "match" is actually just part of a longer word. + // Likewise, if *neither* token is a word, then this is a real match and the CW already exists in the input. + if (!/\w$/.test(before) && !/^\w/.test(after)) { + return true; + } + } + + // If we don't match, then there is no existing CW. + return false; +} diff --git a/packages/frontend-shared/js/compute-merged-cw.ts b/packages/frontend-shared/js/compute-merged-cw.ts new file mode 100644 index 0000000000..dfea57fdce --- /dev/null +++ b/packages/frontend-shared/js/compute-merged-cw.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { appendContentWarning } from '@@/js/append-content-warning.js'; + +export function computeMergedCw(note: Misskey.entities.Note): string | null { + let cw = note.cw; + + if (note.user.mandatoryCW) { + cw = appendContentWarning(cw, note.user.mandatoryCW); + } + + return cw ?? null; +} diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 9271e9e4b7..0bac6a67b9 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -59,10 +59,10 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> <bdi> - <p v-if="appearNote.cw != null" :class="$style.cw"> + <p v-if="mergedCW != null" :class="$style.cw"> <Mfm - v-if="appearNote.cw != ''" - :text="appearNote.cw" + v-if="mergedCW != ''" + :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :enableEmojiMenu="true" @@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only /> <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/> </p> - <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> + <div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> @@ -212,6 +212,7 @@ import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { shouldCollapsed } from '@@/js/collapsed.js'; import { host } from '@@/js/config.js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import type { MenuItem } from '@/types/menu.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; @@ -350,6 +351,8 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ url: `https://${host}/notes/${appearNote.value.id}`, })); +const mergedCW = computed(() => computeMergedCw(appearNote.value)); + const renoteTooltip = computeRenoteTooltip(renoted); /* Overload FunctionにLintが対応していないのでコメントアウト diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 6c52714f46..582838ebca 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -75,10 +75,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </header> <div :class="$style.noteContent"> - <p v-if="appearNote.cw != null" :class="$style.cw"> + <p v-if="mergedCW != null" :class="$style.cw"> <Mfm - v-if="appearNote.cw != ''" - :text="appearNote.cw" + v-if="mergedCW != ''" + :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :enableEmojiMenu="true" @@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only /> <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/> </p> - <div v-show="appearNote.cw == null || showContent"> + <div v-show="mergedCW == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <Mfm @@ -245,6 +245,7 @@ import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { host } from '@@/js/config.js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; @@ -348,6 +349,8 @@ const quotes = ref<Misskey.entities.Note[]>([]); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const mergedCW = computed(() => computeMergedCw(appearNote.value)); + const renoteTooltip = computeRenoteTooltip(renoted); watch(() => props.expandAllCws, (expandAllCws) => { diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 9ef45a0ec3..3720aa7493 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.main"> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <div> - <p v-if="note.cw != null" :class="$style.cw"> - <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> + <p v-if="mergedCW != null" :class="$style.cw"> + <Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> </p> - <div v-show="note.cw == null || showContent"> + <div v-show="mergedCW == null || showContent"> <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> <div v-if="note.isSchedule" style="margin-top: 10px;"> <MkButton :class="$style.button" inline @click.stop.prevent="editScheduleNote()"><i class="ti ti-eraser"></i> {{ i18n.ts.edit }}</MkButton> @@ -26,8 +26,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import * as os from '@/os.js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; @@ -48,6 +49,8 @@ const props = defineProps<{ let showContent = ref(defaultStore.state.uncollapseCW); const isDeleted = ref(false); +const mergedCW = computed(() => computeMergedCw(props.note)); + const emit = defineEmits<{ (ev: 'editScheduleNote'): void; }>(); diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 9febc1219c..61006cfdcc 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -11,11 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.body"> <MkNoteHeader :class="$style.header" :note="note" :mini="true"/> <div :class="$style.content"> - <p v-if="note.cw != null" :class="$style.cw"> - <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/> + <p v-if="mergedCW != null" :class="$style.cw"> + <Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'"/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> </p> - <div v-show="note.cw == null || showContent"> + <div v-show="mergedCW == null || showContent"> <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/> </div> </div> @@ -86,6 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; @@ -142,6 +143,8 @@ let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const replies = ref<Misskey.entities.Note[]>([]); +const mergedCW = computed(() => computeMergedCw(appearNote.value)); + const isRenote = ( props.note.renote != null && props.note.text == null && diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 059de8011c..c5f5f4514d 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -112,6 +112,7 @@ import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { toASCII } from 'punycode.js'; import { host, url } from '@@/js/config.js'; +import { appendContentWarning } from '@@/js/append-content-warning.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -373,18 +374,8 @@ if ($i.defaultCW) { if (!cw.value || $i.defaultCWPriority === 'default') { cw.value = $i.defaultCW; } else if ($i.defaultCWPriority !== 'parent') { - // This is a fancy way of simulating /\bsearch\b/ without a regular expression. - // We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries. - const parts = cw.value.split($i.defaultCW); - const hasExistingDefaultCW = parts.length === 2 && !/\w$/.test(parts[0]) && !/^\w/.test(parts[1]); - if (!hasExistingDefaultCW) { - // We need to merge the CWs - if ($i.defaultCWPriority === 'defaultParent') { - cw.value = `${$i.defaultCW}, ${cw.value}`; - } else if ($i.defaultCWPriority === 'parentDefault') { - cw.value = `${cw.value}, ${$i.defaultCW}`; - } - } + const putDefaultFirst = $i.defaultCWPriority === 'defaultParent'; + cw.value = appendContentWarning(cw.value, $i.defaultCW, putDefaultFirst); } // else { do nothing, because existing CW takes priority. } } diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 323ca283bf..777e95593d 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -62,10 +62,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="[{ [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> <div style="container-type: inline-size;"> - <p v-if="appearNote.cw != null" :class="$style.cw"> + <p v-if="mergedCW != null" :class="$style.cw"> <Mfm - v-if="appearNote.cw != ''" - :text="appearNote.cw" + v-if="mergedCW != ''" + :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :enableEmojiMenu="true" @@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only /> <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/> </p> - <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> + <div v-show="mergedCW == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <Mfm @@ -213,6 +213,7 @@ import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { shouldCollapsed } from '@@/js/collapsed.js'; import { host } from '@@/js/config.js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import type { MenuItem } from '@/types/menu.js'; import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteHeader from '@/components/SkNoteHeader.vue'; @@ -345,6 +346,8 @@ const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state. const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); +const mergedCW = computed(() => computeMergedCw(appearNote.value)); + const renoteTooltip = computeRenoteTooltip(renoted); const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({ diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index dc8a5f59b2..471a4ce7d7 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -81,10 +81,10 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </header> <div :class="$style.noteContent"> - <p v-if="appearNote.cw != null" :class="$style.cw"> + <p v-if="mergedCW != null" :class="$style.cw"> <Mfm - v-if="appearNote.cw != ''" - :text="appearNote.cw" + v-if="mergedCW != ''" + :text="mergedCW" :author="appearNote.user" :nyaize="'respect'" :enableEmojiMenu="true" @@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only /> <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/> </p> - <div v-show="appearNote.cw == null || showContent"> + <div v-show="mergedCW == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <Mfm v-if="appearNote.text" @@ -250,6 +250,7 @@ import * as mfm from '@transfem-org/sfm-js'; import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { host } from '@@/js/config.js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import SkNoteSub from '@/components/SkNoteSub.vue'; import SkNoteSimple from '@/components/SkNoteSimple.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; @@ -354,6 +355,8 @@ const quotes = ref<Misskey.entities.Note[]>([]); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const mergedCW = computed(() => computeMergedCw(appearNote.value)); + const renoteTooltip = computeRenoteTooltip(renoted); watch(() => props.expandAllCws, (expandAllCws) => { diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue index b9895305f2..71a5bd4df8 100644 --- a/packages/frontend/src/components/SkNoteSimple.vue +++ b/packages/frontend/src/components/SkNoteSimple.vue @@ -9,11 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.main"> <MkNoteHeader :class="$style.header" :classic="true" :note="note" :mini="true"/> <div> - <p v-if="note.cw != null" :class="$style.cw"> - <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> + <p v-if="mergedCW != null" :class="$style.cw"> + <Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> </p> - <div v-show="note.cw == null || showContent"> + <div v-show="mergedCW == null || showContent"> <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note" :expandAllCws="props.expandAllCws"/> </div> </div> @@ -22,8 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, ref } from 'vue'; +import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; @@ -37,6 +38,8 @@ const props = defineProps<{ let showContent = ref(defaultStore.state.uncollapseCW); +const mergedCW = computed(() => computeMergedCw(props.note)); + watch(() => props.expandAllCws, (expandAllCws) => { if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index a3d27c9e16..060e66be16 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -19,11 +19,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.body"> <SkNoteHeader :class="$style.header" :note="note" :classic="true" :mini="true"/> <div :class="$style.content"> - <p v-if="note.cw != null" :class="$style.cw"> - <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :isBlock="true" :author="note.user" :nyaize="'respect'"/> + <p v-if="mergedCW != null" :class="$style.cw"> + <Mfm v-if="mergedCW != ''" style="margin-right: 8px;" :text="mergedCW" :isBlock="true" :author="note.user" :nyaize="'respect'"/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> </p> - <div v-show="note.cw == null || showContent"> + <div v-show="mergedCW == null || showContent"> <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation" :expandAllCws="props.expandAllCws"/> </div> </div> @@ -94,6 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import { computeMergedCw } from '@@/js/compute-merged-cw.js'; import SkNoteHeader from '@/components/SkNoteHeader.vue'; import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; @@ -156,6 +157,8 @@ let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const replies = ref<Misskey.entities.Note[]>([]); +const mergedCW = computed(() => computeMergedCw(appearNote.value)); + const isRenote = ( props.note.renote != null && props.note.text == null && diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 11a34d34ef..229f581672 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkKeyValue> </div> - <MkTextarea v-model="moderationNote" manualSave> + <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged"> <template #label>{{ i18n.ts.moderationNote }}</template> <template #caption>{{ i18n.ts.moderationNoteDescription }}</template> </MkTextarea> @@ -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 @update:modelValue="onMandatoryCWChanged"> + <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,11 +288,7 @@ function createFetcher() { markedAsNSFW.value = info.value.alwaysMarkNsfw; suspended.value = info.value.isSuspended; moderationNote.value = info.value.moderationNote; - - watch(moderationNote, async () => { - await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value }); - await refreshUser(); - }); + mandatoryCW.value = user.value.mandatoryCW; }); } @@ -293,6 +296,16 @@ function refreshUser() { init.value = createFetcher(); } +async function onMandatoryCWChanged(value: string) { + await os.apiWithDialog('admin/cw-user', { userId: props.userId, cw: value }); + refreshUser(); +} + +async function onModerationNoteChanged(value: string) { + await misskeyApi('admin/update-user-note', { userId: props.userId, text: value }); + refreshUser(); +} + async function updateRemoteUser() { await os.apiWithDialog('federation/update-remote-user', { userId: user.value.id }); refreshUser(); diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 37a9cc83e7..741de875bc 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.logYellow]: [ 'markSensitiveDriveFile', 'resetPassword', + 'setMandatoryCW', 'suspendRemoteInstance', 'setRemoteInstanceNSFW', 'unsetRemoteInstanceNSFW', @@ -55,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="log.type === 'decline'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> + <span v-else-if="log.type === 'setMandatoryCW'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'assignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-arrow-right"></i> {{ log.info.roleName }}</span> <span v-else-if="log.type === 'unassignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-equal-not"></i> {{ log.info.roleName }}</span> <span v-else-if="log.type === 'createRole'">: {{ log.info.role.name }}</span> @@ -93,7 +95,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="log.type === 'deleteFlash'">: @{{ log.info.flashUserUsername }}</span> <span v-else-if="log.type === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span> </template> - <template #icon> + <template v-if="log.user" #icon> <MkAvatar :user="log.user" :class="$style.avatar"/> </template> <template #suffix> @@ -123,6 +125,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-else-if="log.type === 'approve'"> <div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div> </template> + <template v-else-if="log.type === 'setMandatoryCW'"> + <div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div> + <div :class="$style.diff"> + <CodeDiff :context="0" :hideHeader="true" :oldString="log.info.oldCW ?? ''" :newString="log.info.newCW ?? ''" maxHeight="150px"/> + </div> + </template> <template v-else-if="log.type === 'unsuspend'"> <div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div> </template> diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts index 58d486bf9b..4e093bcf4c 100644 --- a/packages/frontend/src/scripts/get-note-summary.ts +++ b/packages/frontend/src/scripts/get-note-summary.ts @@ -4,6 +4,7 @@ */ import * as Misskey from 'misskey-js'; +import { appendContentWarning } from '@@/js/append-content-warning.js'; import { i18n } from '@/i18n.js'; /** @@ -25,9 +26,15 @@ export const getNoteSummary = (note?: Misskey.entities.Note | null): string => { let summary = ''; + // Append mandatory CW, if applicable + let cw = note.cw; + if (note.user.mandatoryCW) { + cw = appendContentWarning(cw, note.user.mandatoryCW); + } + // 本文 - if (note.cw != null) { - summary += `CW: ${note.cw}`; + if (cw != null) { + summary += `CW: ${cw}`; } else if (note.text) { summary += note.text; } diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index e564600ef1..cc3dcaa765 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -147,6 +147,9 @@ type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['resp type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json']; // @public (undocumented) +type AdminCwUserRequest = operations['admin___cw-user']['requestBody']['content']['application/json']; + +// @public (undocumented) type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json']; // @public (undocumented) @@ -1307,6 +1310,7 @@ declare namespace entities { AdminAvatarDecorationsUpdateRequest, AdminCaptchaCurrentResponse, AdminCaptchaSaveRequest, + AdminCwUserRequest, AdminDeclineUserRequest, AdminDeleteAccountRequest, AdminDeleteAllFilesOfAUserRequest, @@ -2610,6 +2614,15 @@ type ModerationLog = { type: 'deleteUserAnnouncement'; info: ModerationLogPayloads['deleteUserAnnouncement']; } | { + type: 'setMandatoryCW'; + info: ModerationLogPayloads['setMandatoryCW']; +} | { + type: 'setRemoteInstanceNSFW'; + info: ModerationLogPayloads['setRemoteInstanceNSFW']; +} | { + type: 'unsetRemoteInstanceNSFW'; + info: ModerationLogPayloads['unsetRemoteInstanceNSFW']; +} | { type: 'resetPassword'; info: ModerationLogPayloads['resetPassword']; } | { @@ -2619,6 +2632,12 @@ type ModerationLog = { type: 'unsuspendRemoteInstance'; info: ModerationLogPayloads['unsuspendRemoteInstance']; } | { + type: 'rejectRemoteInstanceReports'; + info: ModerationLogPayloads['rejectRemoteInstanceReports']; +} | { + type: 'acceptRemoteInstanceReports'; + info: ModerationLogPayloads['acceptRemoteInstanceReports']; +} | { type: 'updateRemoteInstanceNote'; info: ModerationLogPayloads['updateRemoteInstanceNote']; } | { @@ -2696,7 +2715,7 @@ type ModerationLog = { }); // @public (undocumented) -export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost"]; +export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "decline", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "setMandatoryCW", "setRemoteInstanceNSFW", "unsetRemoteInstanceNSFW", "suspendRemoteInstance", "unsuspendRemoteInstance", "rejectRemoteInstanceReports", "acceptRemoteInstanceReports", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "forwardAbuseReport", "updateAbuseReportNote", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost"]; // @public (undocumented) type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; @@ -3006,7 +3025,7 @@ type PartialRolePolicyOverride = Partial<{ }>; // @public (undocumented) -export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; +export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "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", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; // @public (undocumented) type PingResponse = operations['ping']['responses']['200']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index f4120b3afc..99e202a1b6 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -275,6 +275,17 @@ declare module '../api.js' { /** * No description provided. * + * **Credential required**: *Yes* / **Permission**: *write:admin:cw-user* + */ + request<E extends 'admin/cw-user', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** + * No description provided. + * * **Credential required**: *Yes* / **Permission**: *write:admin:decline-user* */ request<E extends 'admin/decline-user', P extends Endpoints[E]['req']>( diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index c8000fc1d0..b31dd2d8f1 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -38,6 +38,7 @@ import type { AdminAvatarDecorationsUpdateRequest, AdminCaptchaCurrentResponse, AdminCaptchaSaveRequest, + AdminCwUserRequest, AdminDeclineUserRequest, AdminDeleteAccountRequest, AdminDeleteAllFilesOfAUserRequest, @@ -633,6 +634,7 @@ export type Endpoints = { 'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse }; 'admin/captcha/current': { req: EmptyRequest; res: AdminCaptchaCurrentResponse }; 'admin/captcha/save': { req: AdminCaptchaSaveRequest; res: EmptyResponse }; + 'admin/cw-user': { req: AdminCwUserRequest; res: EmptyResponse }; 'admin/decline-user': { req: AdminDeclineUserRequest; res: EmptyResponse }; 'admin/delete-account': { req: AdminDeleteAccountRequest; res: EmptyResponse }; 'admin/delete-all-files-of-a-user': { req: AdminDeleteAllFilesOfAUserRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 3db711f9a4..c65669d056 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -41,6 +41,7 @@ export type AdminAvatarDecorationsListResponse = operations['admin___avatar-deco export type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json']; export type AdminCaptchaCurrentResponse = operations['admin___captcha___current']['responses']['200']['content']['application/json']; export type AdminCaptchaSaveRequest = operations['admin___captcha___save']['requestBody']['content']['application/json']; +export type AdminCwUserRequest = operations['admin___cw-user']['requestBody']['content']['application/json']; export type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json']; export type AdminDeleteAccountRequest = operations['admin___delete-account']['requestBody']['content']['application/json']; export type AdminDeleteAllFilesOfAUserRequest = operations['admin___delete-all-files-of-a-user']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 9bac7a812c..46356613ac 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -233,6 +233,15 @@ export type paths = { */ post: operations['admin___captcha___save']; }; + '/admin/cw-user': { + /** + * admin/cw-user + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:cw-user* + */ + post: operations['admin___cw-user']; + }; '/admin/decline-user': { /** * admin/decline-user @@ -3968,6 +3977,7 @@ export type components = { isSystem?: boolean; noindex: boolean; enableRss: boolean; + mandatoryCW: string | null; isBot?: boolean; isCat?: boolean; speakAsCat?: boolean; @@ -6888,6 +6898,59 @@ export type operations = { }; }; /** + * admin/cw-user + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:cw-user* + */ + 'admin___cw-user': { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + userId: string; + cw: string | null; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** * admin/decline-user * @description No description provided. * diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index 0faf3dddc4..96da0a8fad 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -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', @@ -124,6 +125,7 @@ export const moderationLogTypes = [ 'updateServerSettings', 'suspend', 'approve', + 'decline', 'unsuspend', 'updateUserNote', 'addCustomEmoji', @@ -145,8 +147,13 @@ export const moderationLogTypes = [ 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'resetPassword', + 'setMandatoryCW', + 'setRemoteInstanceNSFW', + 'unsetRemoteInstanceNSFW', 'suspendRemoteInstance', 'unsuspendRemoteInstance', + 'rejectRemoteInstanceReports', + 'acceptRemoteInstanceReports', 'updateRemoteInstanceNote', 'markSensitiveDriveFile', 'unmarkSensitiveDriveFile', @@ -186,7 +193,14 @@ export const reversiUpdateKeys = [ export type ReversiUpdateKey = typeof reversiUpdateKeys[number]; -type AvatarDecoration = UserLite['avatarDecorations'][number]; +interface AvatarDecoration { + id: string; + updatedAt: string | null; + url: string; + name: string; + description: string; + roleIdsThatCanBeUsedThisDecoration: string[]; +} type ReceivedAbuseReport = { reportId: AbuseReportNotificationRecipient['id']; @@ -322,6 +336,21 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + setMandatoryCW: { + newCW: string | null; + oldCW: string | null; + userId: string; + userUsername: string; + userHost: string | null; + }; + setRemoteInstanceNSFW: { + id: string; + host: string; + }; + unsetRemoteInstanceNSFW: { + id: string; + host: string; + }; suspendRemoteInstance: { id: string; host: string; @@ -330,6 +359,14 @@ export type ModerationLogPayloads = { id: string; host: string; }; + rejectRemoteInstanceReports: { + id: string; + host: string; + }; + acceptRemoteInstanceReports: { + id: string; + host: string; + }; updateRemoteInstanceNote: { id: string; host: string; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index efe5ba19fb..3e88eae275 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -118,6 +118,15 @@ export type ModerationLog = { type: 'deleteUserAnnouncement'; info: ModerationLogPayloads['deleteUserAnnouncement']; } | { + type: 'setMandatoryCW'; + info: ModerationLogPayloads['setMandatoryCW']; +} | { + type: 'setRemoteInstanceNSFW'; + info: ModerationLogPayloads['setRemoteInstanceNSFW']; +} | { + type: 'unsetRemoteInstanceNSFW'; + info: ModerationLogPayloads['unsetRemoteInstanceNSFW']; +} | { type: 'resetPassword'; info: ModerationLogPayloads['resetPassword']; } | { @@ -127,6 +136,12 @@ export type ModerationLog = { type: 'unsuspendRemoteInstance'; info: ModerationLogPayloads['unsuspendRemoteInstance']; } | { + type: 'rejectRemoteInstanceReports'; + info: ModerationLogPayloads['rejectRemoteInstanceReports']; +} | { + type: 'acceptRemoteInstanceReports'; + info: ModerationLogPayloads['acceptRemoteInstanceReports']; +} | { type: 'updateRemoteInstanceNote'; info: ModerationLogPayloads['updateRemoteInstanceNote']; } | { diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 86eafc8a33..af2db91286 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -303,6 +303,8 @@ _abuseReport: webhook: "Send a notification to the SystemWebhook when an abuse report is received or resolved." _moderationLogTypes: approve: "Approved" + decline: "Declined" + setMandatoryCW: "Set content warning for user" setRemoteInstanceNSFW: "Set remote instance as NSFW" unsetRemoteInstanceNSFW: "Set remote instance as NSFW" rejectRemoteInstanceReports: "Rejected reports from remote instance" @@ -436,6 +438,7 @@ _permissions: "write:admin:decline-user": "Decline new users" "write:admin:nsfw-user": "Mark users as NSFW" "write:admin:unnsfw-user": "Mark users an not NSFW" + "write:admin:cw-user": "Apply mandatory CW on users" "write:admin:silence-user": "Silence users" "write:admin:unsilence-user": "Un-silence users" "read:notes-schedule": "View your list of scheduled notes" @@ -471,3 +474,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." |