diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index c7f8b97a5a..61878c60e8 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -29,10 +29,11 @@ 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 { QueryService } from '@/core/QueryService.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; -import { getApId } from './type.js'; +import { getApId, IOrderedCollection, IOrderedCollectionPage } from './type.js'; import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; @Injectable() @@ -70,6 +71,7 @@ export class ApRendererService { private apMfmService: ApMfmService, private mfmService: MfmService, private idService: IdService, + private readonly queryService: QueryService, ) { } @@ -388,13 +390,16 @@ export class ApRendererService { let to: string[] = []; let cc: string[] = []; + let isPublic = false; if (note.visibility === 'public') { to = ['https://www.w3.org/ns/activitystreams#Public']; cc = [`${attributedTo}/followers`].concat(mentions); + isPublic = true; } else if (note.visibility === 'home') { to = [`${attributedTo}/followers`]; cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); + isPublic = true; } else if (note.visibility === 'followers') { to = [`${attributedTo}/followers`]; cc = mentions; @@ -455,6 +460,10 @@ export class ApRendererService { })), } as const : {}; + // Render the outer replies collection wrapper, which contains the count but not the actual URLs. + // This saves one hop (request) when de-referencing the replies. + const replies = isPublic ? await this.renderRepliesCollection(note.id) : undefined; + return { id: `${this.config.url}/notes/${note.id}`, type: 'Note', @@ -473,6 +482,7 @@ export class ApRendererService { to, cc, inReplyTo, + replies, attachment: files.map(x => this.renderDocument(x)), sensitive: note.cw != null || files.some(file => file.isSensitive), tag, @@ -909,6 +919,67 @@ export class ApRendererService { return page; } + /** + * Renders the reply collection wrapper object for a note + * @param noteId Note whose reply collection to render. + */ + @bindThis + public async renderRepliesCollection(noteId: string): Promise { + const replyCount = await this.notesRepository.countBy({ + replyId: noteId, + visibility: In(['public', 'home']), + localOnly: false, + }); + + return { + type: 'OrderedCollection', + id: `${this.config.url}/notes/${noteId}/replies`, + first: `${this.config.url}/notes/${noteId}/replies?page=true`, + totalItems: replyCount, + }; + } + + /** + * Renders a page of the replies collection for a note + * @param noteId Return notes that are inReplyTo this value. + * @param untilId If set, return only notes that are *older* than this value. + */ + @bindThis + public async renderRepliesCollectionPage(noteId: string, untilId: string | undefined): Promise { + const replyCount = await this.notesRepository.countBy({ + replyId: noteId, + visibility: In(['public', 'home']), + localOnly: false, + }); + + const limit = 50; + const results = await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), undefined, untilId) + .andWhere({ + replyId: noteId, + visibility: In(['public', 'home']), + localOnly: false, + }) + .select(['note.id', 'note.uri']) + .limit(limit) + .getRawMany<{ note_id: string, note_uri: string | null }>(); + + const hasNextPage = results.length >= limit; + const baseId = `${this.config.url}/notes/${noteId}/replies?page=true`; + + return { + type: 'OrderedCollectionPage', + id: untilId == null ? baseId : `${baseId}&until_id=${untilId}`, + partOf: `${this.config.url}/notes/${noteId}/replies`, + first: baseId, + next: hasNextPage ? `${baseId}&until_id=${results.at(-1)?.note_id}` : undefined, + totalItems: replyCount, + orderedItems: results.map(r => { + // Remote notes have a URI, local have just an ID. + return r.note_uri ?? `${this.config.url}/notes/${r.note_id}`; + }), + }; + } + @bindThis private async getEmojis(names: string[]): Promise { if (names.length === 0) return []; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index d8e7b3c9c3..5b93543f1e 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -26,7 +26,7 @@ export interface IObject { attributedTo?: ApObject; attachment?: any[]; inReplyTo?: any; - replies?: ICollection; + replies?: ICollection | IOrderedCollection | string; content?: string | null; startTime?: Date; endTime?: Date; @@ -125,6 +125,8 @@ export interface ICollection extends IObject { type: 'Collection'; totalItems: number; first?: IObject | string; + last?: IObject | string; + current?: IObject | string; items?: ApObject; } @@ -132,6 +134,32 @@ export interface IOrderedCollection extends IObject { type: 'OrderedCollection'; totalItems: number; first?: IObject | string; + last?: IObject | string; + current?: IObject | string; + orderedItems?: ApObject; +} + +export interface ICollectionPage extends IObject { + type: 'CollectionPage'; + totalItems: number; + first?: IObject | string; + last?: IObject | string; + current?: IObject | string; + partOf?: IObject | string; + next?: IObject | string; + prev?: IObject | string; + items?: ApObject; +} + +export interface IOrderedCollectionPage extends IObject { + type: 'OrderedCollectionPage'; + totalItems: number; + first?: IObject | string; + last?: IObject | string; + current?: IObject | string; + partOf?: IObject | string; + next?: IObject | string; + prev?: IObject | string; orderedItems?: ApObject; } @@ -231,8 +259,14 @@ export const isCollection = (object: IObject): object is ICollection => export const isOrderedCollection = (object: IObject): object is IOrderedCollection => getApType(object) === 'OrderedCollection'; +export const isCollectionPage = (object: IObject): object is ICollectionPage => + getApType(object) === 'CollectionPage'; + +export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage => + getApType(object) === 'OrderedCollectionPage'; + export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => - isCollection(object) || isOrderedCollection(object); + isCollection(object) || isOrderedCollection(object) || isCollectionPage(object) || isOrderedCollectionPage(object); export interface IApPropertyValue extends IObject { type: 'PropertyValue'; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index ba112ca59a..ea534af458 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -783,6 +783,52 @@ export class ActivityPubServerService { return (this.apRendererService.addContext(await this.packActivity(note, author))); }); + // replies + fastify.get<{ + Params: { note: string; }; + Querystring: { page?: unknown; until_id?: unknown; }; + }>('/notes/:note/replies', async (request, reply) => { + vary(reply.raw, 'Accept'); + this.setResponseType(request, reply); + + // Raw query to avoid fetching the while entity just to check access and get the user ID + const note = await this.notesRepository + .createQueryBuilder('note') + .andWhere({ + id: request.params.note, + userHost: IsNull(), + visibility: In(['public', 'home']), + localOnly: false, + }) + .select(['note.id', 'note.userId']) + .getRawOne<{ note_id: string, note_userId: string }>(); + + const { reject } = await this.checkAuthorizedFetch(request, reply, note?.note_userId); + if (reject) return; + + if (note == null) { + reply.code(404); + return; + } + + const untilId = request.query.until_id; + if (untilId != null && typeof(untilId) !== 'string') { + reply.code(400); + return; + } + + // If page is unset, then we just provide the outer wrapper. + // This is because the spec doesn't allow the wrapper to contain both elements *and* pages. + // We could technically do it anyway, but that may break other instances. + if (request.query.page !== 'true') { + const collection = await this.apRendererService.renderRepliesCollection(note.note_id); + return this.apRendererService.addContext(collection); + } + + const page = await this.apRendererService.renderRepliesCollectionPage(note.note_id, untilId ?? undefined); + return this.apRendererService.addContext(page); + }); + // outbox fastify.get<{ Params: { user: string; }; diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index d3d27e182f..5767089109 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -9,6 +9,7 @@ import { generateKeyPair } from 'crypto'; import { Test } from '@nestjs/testing'; import { jest } from '@jest/globals'; +import type { Config } from '@/config.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { ApImageService } from '@/core/activitypub/models/ApImageService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; @@ -99,6 +100,7 @@ describe('ActivityPub', () => { let idService: IdService; let userPublickeysRepository: UserPublickeysRepository; let userKeypairService: UserKeypairService; + let config: Config; const metaInitial = { cacheRemoteFiles: true, @@ -149,6 +151,7 @@ describe('ActivityPub', () => { idService = app.get(IdService); userPublickeysRepository = app.get(DI.userPublickeysRepository); userKeypairService = app.get(UserKeypairService); + config = app.get(DI.config); // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error const federatedInstanceService = app.get(FederatedInstanceService); @@ -612,6 +615,40 @@ describe('ActivityPub', () => { expect(result.summary).toBe('original and mandatory'); }); }); + + describe('replies', () => { + it('should be included when visibility=public', async () => { + note.visibility = 'public'; + + const rendered = await rendererService.renderNote(note, author, false); + + expect(rendered.replies).toBeDefined(); + }); + + it('should be included when visibility=home', async () => { + note.visibility = 'home'; + + const rendered = await rendererService.renderNote(note, author, false); + + expect(rendered.replies).toBeDefined(); + }); + + it('should be excluded when visibility=followers', async () => { + note.visibility = 'followers'; + + const rendered = await rendererService.renderNote(note, author, false); + + expect(rendered.replies).not.toBeDefined(); + }); + + it('should be excluded when visibility=specified', async () => { + note.visibility = 'specified'; + + const rendered = await rendererService.renderNote(note, author, false); + + expect(rendered.replies).not.toBeDefined(); + }); + }); }); describe('renderUpnote', () => { @@ -695,6 +732,110 @@ describe('ActivityPub', () => { expect(result.name).toBeUndefined(); }); }); + + describe('renderRepliesCollection', () => { + it('should include type', async () => { + const collection = await rendererService.renderRepliesCollection(note.id); + + expect(collection.type).toBe('OrderedCollection'); + }); + + it('should include id', async () => { + const collection = await rendererService.renderRepliesCollection(note.id); + + expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies`); + }); + + it('should include first', async () => { + const collection = await rendererService.renderRepliesCollection(note.id); + + expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`); + }); + + it('should include totalItems', async () => { + const collection = await rendererService.renderRepliesCollection(note.id); + + expect(collection.totalItems).toBe(0); + }); + }); + + describe('renderRepliesCollectionPage', () => { + describe('with untilId', () => { + it('should include type', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); + + expect(collection.type).toBe('OrderedCollectionPage'); + }); + + it('should include id', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); + + expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies?page=true&until_id=abc123`); + }); + + it('should include partOf', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); + + expect(collection.partOf).toBe(`${config.url}/notes/${note.id}/replies`); + }); + + it('should include first', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); + + expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`); + }); + + it('should include totalItems', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); + + expect(collection.totalItems).toBe(0); + }); + + it('should include orderedItems', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, 'abc123'); + + expect(collection.orderedItems).toBeDefined(); + }); + }); + + describe('without untilId', () => { + it('should include type', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.type).toBe('OrderedCollectionPage'); + }); + + it('should include id', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.id).toBe(`${config.url}/notes/${note.id}/replies?page=true`); + }); + + it('should include partOf', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.partOf).toBe(`${config.url}/notes/${note.id}/replies`); + }); + + it('should include first', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.first).toBe(`${config.url}/notes/${note.id}/replies?page=true`); + }); + + it('should include totalItems', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.totalItems).toBe(0); + }); + + it('should include orderedItems', async () => { + const collection = await rendererService.renderRepliesCollectionPage(note.id, undefined); + + expect(collection.orderedItems).toBeDefined(); + }); + }); + }); }); describe(ApPersonService, () => {