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/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 721cb77b2f..22cb6b8282 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); diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index a0c3a4846c..dfeee5ac7f 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -156,11 +156,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/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/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 73d6186edf..105a3292bf 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,90 @@ describe('ActivityPub', () => { }); }); }); + + describe(ApRendererService, () => { + describe('renderNote', () => { + 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('summary', () => { + // I actually don't know why it does this, but the logic was already there so I've preserved it. + it('should be special character 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'); + }); + }); + }); + }); });