diff --git a/packages/backend/src/core/activitypub/ApUtilityService.ts b/packages/backend/src/core/activitypub/ApUtilityService.ts index e2e435080a..ae6e4997e4 100644 --- a/packages/backend/src/core/activitypub/ApUtilityService.ts +++ b/packages/backend/src/core/activitypub/ApUtilityService.ts @@ -45,25 +45,36 @@ export class ApUtilityService { } /** - * Searches a list of URLs or Links for the first one matching a given target URL's host authority. - * Returns null if none match. - * @param targetUrl URL with the target host authority - * @param searchUrls URL, Link, or array to search for matching URLs + * Finds the "best" URL for a given AP object. + * The list of URLs is first filtered via findSameAuthorityUrl, then further filtered based on mediaType, and finally sorted to select the best one. + * @throws {IdentifiableError} if object does not have an ID + * @returns the best URL, or null if none were found */ - public findSameAuthorityUrl(targetUrl: string, searchUrls: string | IObject | undefined | (string | IObject)[]): string | null { + public findBestObjectUrl(object: IObject): string | null { + const targetUrl = getApId(object); const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl); - const match = toArray(searchUrls) - .map(raw => getOneApHrefNullable(raw)) - .find(url => { + const rawUrls = toArray(object.url); + const acceptableUrls = rawUrls + .map(raw => ({ + url: getOneApHrefNullable(raw), + type: typeof(raw) === 'object' + ? raw.mediaType?.toLowerCase() + : undefined, + })) + .filter(({ url, type }) => { if (!url) return false; if (!this.checkHttps(url)) return false; + if (!isAcceptableUrlType(type)) return false; const urlAuthority = this.utilityService.punyHostPSLDomain(url); return urlAuthority === targetAuthority; + }) + .sort((a, b) => { + return rankUrlType(a.type) - rankUrlType(b.type); }); - return match ?? null; + return acceptableUrls[0]?.url ?? null; } /** @@ -78,3 +89,20 @@ export class ApUtilityService { return url.startsWith('https://') || (url.startsWith('http://') && isNonProd); } } + +function isAcceptableUrlType(type: string | undefined): boolean { + if (!type) return true; + if (type.startsWith('text/')) return true; + if (type.startsWith('application/ld+json')) return true; + if (type.startsWith('application/activity+json')) return true; + return false; +} + +function rankUrlType(type: string | undefined): number { + if (!type) return 2; + if (type === 'text/html') return 0; + if (type.startsWith('text/')) return 1; + if (type.startsWith('application/ld+json')) return 3; + if (type.startsWith('application/activity+json')) return 4; + return 5; +} diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index e55a07a949..63f9887a8d 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -180,7 +180,7 @@ export class ApNoteService { throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`); } - const url = this.apUtilityService.findSameAuthorityUrl(note.id, note.url); + const url = this.apUtilityService.findBestObjectUrl(note); this.logger.info(`Creating the Note: ${note.id}`); @@ -395,7 +395,7 @@ export class ApNoteService { throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`); } - const url = this.apUtilityService.findSameAuthorityUrl(note.id, note.url); + const url = this.apUtilityService.findBestObjectUrl(note); this.logger.info(`Creating the Note: ${note.id}`); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index de43defe61..da29a3c527 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -351,8 +351,7 @@ export class ApPersonService implements OnModuleInit { throw new UnrecoverableError(`Refusing to create person without id: ${uri}`); } - const personId = getApId(person); - const url = this.apUtilityService.findSameAuthorityUrl(personId, person.url); + const url = this.apUtilityService.findBestObjectUrl(person); // Create user let user: MiRemoteUser | null = null; @@ -561,8 +560,8 @@ export class ApPersonService implements OnModuleInit { if (person.id == null) { throw new UnrecoverableError(`Refusing to update person without id: ${uri}`); } - const personId = getApId(person); - const url = this.apUtilityService.findSameAuthorityUrl(personId, person.url); + + const url = this.apUtilityService.findBestObjectUrl(person); const updates = { lastFetchedAt: new Date(), diff --git a/packages/backend/test/unit/core/activitypub/ApUtilityService.ts b/packages/backend/test/unit/core/activitypub/ApUtilityService.ts index 4d66915883..325a94dc5a 100644 --- a/packages/backend/test/unit/core/activitypub/ApUtilityService.ts +++ b/packages/backend/test/unit/core/activitypub/ApUtilityService.ts @@ -95,83 +95,257 @@ describe(ApUtilityService, () => { }); }); - describe('findSameAuthorityUrl', () => { + describe('findBestObjectUrl', () => { it('should return null when input is undefined', () => { - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com', undefined); + const object = { + id: 'https://example.com', + url: undefined, + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBeNull(); }); it('should return null when input is empty array', () => { - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com', []); + const object = { + id: 'https://example.com', + url: [] as string[], + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBeNull(); }); it('should return return url if string input matches', () => { - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', 'https://example.com/2'); + const object = { + id: 'https://example.com/1', + url: 'https://example.com/2', + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBe('https://example.com/2'); }); it('should return return url if object input matches', () => { - const input = { - href: 'https://example.com/2', + const object = { + id: 'https://example.com/1', + url: { + href: 'https://example.com/2', + } as IObject, } as IObject; - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', input); + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBe('https://example.com/2'); }); it('should return return url if string[] input matches', () => { - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['https://example.com/2']); + const object = { + id: 'https://example.com/1', + url: ['https://example.com/2'], + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBe('https://example.com/2'); }); it('should return return url if object[] input matches', () => { - const input = { - href: 'https://example.com/2', + const object = { + id: 'https://example.com/1', + url: [{ + href: 'https://example.com/2', + } as IObject], } as IObject; - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', [input]); + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBe('https://example.com/2'); }); it('should skip invalid entries', () => { - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', [{} as IObject, 'https://example.com/2']); + const object = { + id: 'https://example.com/1', + url: [{} as IObject, 'https://example.com/2'], + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBe('https://example.com/2'); }); - it('should return first match', () => { - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['https://example.com/2', 'https://example.com/3']); + it('should allow empty mediaType', () => { + const object = { + id: 'https://example.com/1', + url: { + href: 'https://example.com/2', + } as IObject, + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); + + expect(result).toBe('https://example.com/2'); + }); + + it('should allow text/html mediaType', () => { + const object = { + id: 'https://example.com/1', + url: { + href: 'https://example.com/2', + mediaType: 'text/html', + } as IObject, + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); + + expect(result).toBe('https://example.com/2'); + }); + + it('should allow other text/ mediaTypes', () => { + const object = { + id: 'https://example.com/1', + url: { + href: 'https://example.com/2', + mediaType: 'text/imaginary', + } as IObject, + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); + + expect(result).toBe('https://example.com/2'); + }); + + it('should allow application/ld+json mediaType', () => { + const object = { + id: 'https://example.com/1', + url: { + href: 'https://example.com/2', + mediaType: 'application/ld+json;profile=https://www.w3.org/ns/activitystreams', + } as IObject, + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); + + expect(result).toBe('https://example.com/2'); + }); + + it('should allow application/activity+json mediaType', () => { + const object = { + id: 'https://example.com/1', + url: { + href: 'https://example.com/2', + mediaType: 'application/activity+json', + } as IObject, + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); + + expect(result).toBe('https://example.com/2'); + }); + + it('should reject other mediaTypes', () => { + const object = { + id: 'https://example.com/1', + url: [ + { + href: 'https://example.com/2', + mediaType: 'application/json', + } as IObject, + { + href: 'https://example.com/3', + mediaType: 'image/jpeg', + } as IObject, + ], + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); + + expect(result).toBeNull(); + }); + + it('should return best match', () => { + const object = { + id: 'https://example.com/1', + url: [ + 'https://example.com/2', + { + href: 'https://example.com/3', + } as IObject, + { + href: 'https://example.com/4', + mediaType: 'text/html', + } as IObject, + { + href: 'https://example.com/5', + mediaType: 'text/plain', + } as IObject, + { + href: 'https://example.com/6', + mediaType: 'application/ld+json', + } as IObject, + { + href: 'https://example.com/7', + mediaType: 'application/activity+json', + } as IObject, + { + href: 'https://example.com/8', + mediaType: 'image/jpeg', + } as IObject, + ], + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); + + expect(result).toBe('https://example.com/4'); + }); + + it('should return first match in case of ties', () => { + const object = { + id: 'https://example.com/1', + url: ['https://example.com/2', 'https://example.com/3'], + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBe('https://example.com/2'); }); it('should skip invalid scheme', () => { - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['file://example.com/1', 'https://example.com/2']); + const object = { + id: 'https://example.com/1', + url: ['file://example.com/1', 'https://example.com/2'], + } as IObject; + + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBe('https://example.com/2'); }); it('should skip HTTP in production', () => { + // noinspection HttpUrlsUsage + const object = { + id: 'https://example.com/1', + url: ['http://example.com/1', 'https://example.com/2'], + } as IObject; env.NODE_ENV = 'production'; - // noinspection HttpUrlsUsage - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['http://example.com/1', 'https://example.com/2']); + const result = serviceUnderTest.findBestObjectUrl(object); expect(result).toBe('https://example.com/2'); }); it('should allow HTTP in non-prod', () => { + // noinspection HttpUrlsUsage + const object = { + id: 'https://example.com/1', + url: ['http://example.com/1', 'https://example.com/2'], + } as IObject; env.NODE_ENV = 'test'; - // noinspection HttpUrlsUsage - const result = serviceUnderTest.findSameAuthorityUrl('https://example.com/1', ['http://example.com/1', 'https://example.com/2']); + const result = serviceUnderTest.findBestObjectUrl(object); // noinspection HttpUrlsUsage expect(result).toBe('http://example.com/1');