summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts73
-rw-r--r--packages/backend/src/core/activitypub/type.ts38
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts46
-rw-r--r--packages/backend/test/unit/activitypub.ts141
4 files changed, 295 insertions, 3 deletions
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<IOrderedCollection> {
+ 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<IOrderedCollectionPage> {
+ 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<MiEmoji[]> {
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>(IdService);
userPublickeysRepository = app.get<UserPublickeysRepository>(DI.userPublickeysRepository);
userKeypairService = app.get<UserKeypairService>(UserKeypairService);
+ config = app.get<Config>(DI.config);
// Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
const federatedInstanceService = app.get<FederatedInstanceService>(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, () => {