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 @@ -8554,6 +8554,10 @@ export interface Locale extends ILocale { * Mark users an not NSFW */ "write:admin:unnsfw-user": string; + /** + * Apply mandatory CW on users + */ + "write:admin:cw-user": string; /** * Silence users */ @@ -10214,6 +10218,14 @@ export interface Locale extends ILocale { * Approved */ "approve": string; + /** + * Declined + */ + "decline": string; + /** + * Set content warning for user + */ + "setMandatoryCW": string; /** * Set remote instance as NSFW */ @@ -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 { 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 { + public async renderNote(note: MiNote, author: MiUser, dive = true): Promise { const getPromisedFiles = async (ids: string[]): Promise => { 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 { + public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise { const getPromisedFiles = async (ids: string[]): Promise => { 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) { 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 or Announce Activity * @param note Note + * @param author Author of the note */ @bindThis - private async packActivity(note: MiNote): Promise { + private async packActivity(note: MiNote, author: MiUser): Promise { 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 { // 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 { // 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 { // 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); jsonLdService = app.get(JsonLdService); resolver = new MockResolver(await app.resolve(LoggerService)); + idService = app.get(IdService); // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error const federatedInstanceService = app.get(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
-

- +

+

-
+
({{ i18n.ts.private }}) @@ -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));