append default CW when rendering AP Note objects

This commit is contained in:
Hazelnoot 2025-02-12 14:13:00 -05:00
parent 563e32316f
commit 6c2034a373
5 changed files with 117 additions and 15 deletions

View file

@ -100,7 +100,7 @@ export class PollService {
if (user == null) throw new Error('note not found'); if (user == null) throw new Error('note not found');
if (this.userEntityService.isLocalUser(user)) { 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.apDeliverManagerService.deliverToFollowers(user, content);
this.relayService.deliverToRelays(user, content); this.relayService.deliverToRelays(user, content);
} }

View file

@ -28,6 +28,7 @@ import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFil
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js';
import { JsonLdService } from './JsonLdService.js'; import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js'; import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js'; import { CONTEXT } from './misc/contexts.js';
@ -339,7 +340,7 @@ export class ApRendererService {
} }
@bindThis @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[]> => { const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
if (ids.length === 0) return []; if (ids.length === 0) return [];
const items = await this.driveFilesRepository.findBy({ id: In(ids) }); const items = await this.driveFilesRepository.findBy({ id: In(ids) });
@ -353,14 +354,14 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) { 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) { if (inReplyToNote.uri) {
inReplyTo = inReplyToNote.uri; inReplyTo = inReplyToNote.uri;
} else { } else {
if (dive) { if (dive) {
inReplyTo = await this.renderNote(inReplyToNote, false); inReplyTo = await this.renderNote(inReplyToNote, inReplyToUser, false);
} else { } else {
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`; inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
} }
@ -423,7 +424,12 @@ export class ApRendererService {
apAppend += `\n\nRE: ${quote}`; 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); const { content } = this.apMfmService.getNoteHtml(note, apAppend);

View file

@ -156,11 +156,12 @@ export class Resolver {
case 'notes': case 'notes':
return this.notesRepository.findOneByOrFail({ id: parsed.id }) return this.notesRepository.findOneByOrFail({ id: parsed.id })
.then(async note => { .then(async note => {
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
if (parsed.rest === 'activity') { if (parsed.rest === 'activity') {
// this refers to the create activity and not the note itself // 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 { } else {
return this.apRendererService.renderNote(note); return this.apRendererService.renderNote(note, author);
} }
}); });
case 'users': case 'users':

View file

@ -103,15 +103,16 @@ export class ActivityPubServerService {
/** /**
* Pack Create<Note> or Announce Activity * Pack Create<Note> or Announce Activity
* @param note Note * @param note Note
* @param author Author of the note
*/ */
@bindThis @bindThis
private async packActivity(note: MiNote): Promise<any> { private async packActivity(note: MiNote, author: MiUser): Promise<any> {
if (isRenote(note) && !isQuote(note)) { if (isRenote(note) && !isQuote(note)) {
const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); 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.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 @bindThis
@ -506,7 +507,7 @@ export class ActivityPubServerService {
this.notesRepository.findOneByOrFail({ id: pining.noteId })))) this.notesRepository.findOneByOrFail({ id: pining.noteId }))))
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility)); .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( const rendered = this.apRendererService.renderOrderedCollection(
`${this.config.url}/users/${userId}/collections/featured`, `${this.config.url}/users/${userId}/collections/featured`,
@ -579,7 +580,7 @@ export class ActivityPubServerService {
if (sinceId) notes.reverse(); 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( const rendered = this.apRendererService.renderOrderedCollectionPage(
`${partOf}?${url.query({ `${partOf}?${url.query({
page: 'true', page: 'true',
@ -723,7 +724,9 @@ export class ActivityPubServerService {
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply); 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 // note activity
@ -746,7 +749,9 @@ export class ActivityPubServerService {
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply); 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 // outbox

View file

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { IdService } from '@/core/IdService.js';
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
@ -20,7 +22,7 @@ import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.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 { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js'; import { DownloadService } from '@/core/DownloadService.js';
@ -93,6 +95,7 @@ describe('ActivityPub', () => {
let rendererService: ApRendererService; let rendererService: ApRendererService;
let jsonLdService: JsonLdService; let jsonLdService: JsonLdService;
let resolver: MockResolver; let resolver: MockResolver;
let idService: IdService;
const metaInitial = { const metaInitial = {
cacheRemoteFiles: true, cacheRemoteFiles: true,
@ -140,6 +143,7 @@ describe('ActivityPub', () => {
imageService = app.get<ApImageService>(ApImageService); imageService = app.get<ApImageService>(ApImageService);
jsonLdService = app.get<JsonLdService>(JsonLdService); jsonLdService = app.get<JsonLdService>(JsonLdService);
resolver = new MockResolver(await app.resolve<LoggerService>(LoggerService)); 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 // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
const federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService); const federatedInstanceService = app.get<FederatedInstanceService>(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');
});
});
});
});
}); });