mirror of
https://activitypub.software/TransFem-org/Sharkey.git
synced 2025-04-29 07:24:58 +02:00
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/918 Closes #738 and #948 Approved-by: dakkar <dakkar@thenautilus.net> Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
commit
9fa3be8246
4 changed files with 295 additions and 3 deletions
packages/backend
src
test/unit
|
@ -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 [];
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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; };
|
||||
|
|
|
@ -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, () => {
|
||||
|
|
Loading…
Add table
Reference in a new issue