implement replies collection for posts

This commit is contained in:
Hazelnoot 2025-02-22 20:33:45 -05:00
parent 62d35a56c1
commit 5182f17d32
4 changed files with 295 additions and 3 deletions

View file

@ -29,10 +29,11 @@ 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 { appendContentWarning } from '@/misc/append-content-warning.js';
import { QueryService } from '@/core/QueryService.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';
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'; 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() @Injectable()
@ -70,6 +71,7 @@ export class ApRendererService {
private apMfmService: ApMfmService, private apMfmService: ApMfmService,
private mfmService: MfmService, private mfmService: MfmService,
private idService: IdService, private idService: IdService,
private readonly queryService: QueryService,
) { ) {
} }
@ -388,13 +390,16 @@ export class ApRendererService {
let to: string[] = []; let to: string[] = [];
let cc: string[] = []; let cc: string[] = [];
let isPublic = false;
if (note.visibility === 'public') { if (note.visibility === 'public') {
to = ['https://www.w3.org/ns/activitystreams#Public']; to = ['https://www.w3.org/ns/activitystreams#Public'];
cc = [`${attributedTo}/followers`].concat(mentions); cc = [`${attributedTo}/followers`].concat(mentions);
isPublic = true;
} else if (note.visibility === 'home') { } else if (note.visibility === 'home') {
to = [`${attributedTo}/followers`]; to = [`${attributedTo}/followers`];
cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
isPublic = true;
} else if (note.visibility === 'followers') { } else if (note.visibility === 'followers') {
to = [`${attributedTo}/followers`]; to = [`${attributedTo}/followers`];
cc = mentions; cc = mentions;
@ -455,6 +460,10 @@ export class ApRendererService {
})), })),
} as const : {}; } 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 { return {
id: `${this.config.url}/notes/${note.id}`, id: `${this.config.url}/notes/${note.id}`,
type: 'Note', type: 'Note',
@ -473,6 +482,7 @@ export class ApRendererService {
to, to,
cc, cc,
inReplyTo, inReplyTo,
replies,
attachment: files.map(x => this.renderDocument(x)), attachment: files.map(x => this.renderDocument(x)),
sensitive: note.cw != null || files.some(file => file.isSensitive), sensitive: note.cw != null || files.some(file => file.isSensitive),
tag, tag,
@ -909,6 +919,67 @@ export class ApRendererService {
return page; 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 @bindThis
private async getEmojis(names: string[]): Promise<MiEmoji[]> { private async getEmojis(names: string[]): Promise<MiEmoji[]> {
if (names.length === 0) return []; if (names.length === 0) return [];

View file

@ -26,7 +26,7 @@ export interface IObject {
attributedTo?: ApObject; attributedTo?: ApObject;
attachment?: any[]; attachment?: any[];
inReplyTo?: any; inReplyTo?: any;
replies?: ICollection; replies?: ICollection | IOrderedCollection | string;
content?: string | null; content?: string | null;
startTime?: Date; startTime?: Date;
endTime?: Date; endTime?: Date;
@ -125,6 +125,8 @@ export interface ICollection extends IObject {
type: 'Collection'; type: 'Collection';
totalItems: number; totalItems: number;
first?: IObject | string; first?: IObject | string;
last?: IObject | string;
current?: IObject | string;
items?: ApObject; items?: ApObject;
} }
@ -132,6 +134,32 @@ export interface IOrderedCollection extends IObject {
type: 'OrderedCollection'; type: 'OrderedCollection';
totalItems: number; totalItems: number;
first?: IObject | string; 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; orderedItems?: ApObject;
} }
@ -231,8 +259,14 @@ export const isCollection = (object: IObject): object is ICollection =>
export const isOrderedCollection = (object: IObject): object is IOrderedCollection => export const isOrderedCollection = (object: IObject): object is IOrderedCollection =>
getApType(object) === 'OrderedCollection'; 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 => 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 { export interface IApPropertyValue extends IObject {
type: 'PropertyValue'; type: 'PropertyValue';

View file

@ -783,6 +783,52 @@ export class ActivityPubServerService {
return (this.apRendererService.addContext(await this.packActivity(note, author))); 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 // outbox
fastify.get<{ fastify.get<{
Params: { user: string; }; Params: { user: string; };

View file

@ -9,6 +9,7 @@ import { generateKeyPair } from 'crypto';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
import type { Config } from '@/config.js';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import { ApImageService } from '@/core/activitypub/models/ApImageService.js'; import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
@ -99,6 +100,7 @@ describe('ActivityPub', () => {
let idService: IdService; let idService: IdService;
let userPublickeysRepository: UserPublickeysRepository; let userPublickeysRepository: UserPublickeysRepository;
let userKeypairService: UserKeypairService; let userKeypairService: UserKeypairService;
let config: Config;
const metaInitial = { const metaInitial = {
cacheRemoteFiles: true, cacheRemoteFiles: true,
@ -149,6 +151,7 @@ describe('ActivityPub', () => {
idService = app.get<IdService>(IdService); idService = app.get<IdService>(IdService);
userPublickeysRepository = app.get<UserPublickeysRepository>(DI.userPublickeysRepository); userPublickeysRepository = app.get<UserPublickeysRepository>(DI.userPublickeysRepository);
userKeypairService = app.get<UserKeypairService>(UserKeypairService); userKeypairService = app.get<UserKeypairService>(UserKeypairService);
config = app.get<Config>(DI.config);
// 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);
@ -612,6 +615,40 @@ describe('ActivityPub', () => {
expect(result.summary).toBe('original and mandatory'); 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', () => { describe('renderUpnote', () => {
@ -695,6 +732,110 @@ describe('ActivityPub', () => {
expect(result.name).toBeUndefined(); 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, () => { describe(ApPersonService, () => {