diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 2b0da52332..0c3b8359ea 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -109,13 +109,22 @@ export class ApInboxService { resolver ??= this.apResolverService.createResolver(); const items = await resolver.resolveCollectionItems(activity); - for (const act of items) { - if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { - this.logger.debug('skipping activity: activity id is null or mismatching'); - continue; + for (let i = 0; i < items.length; i++) { + const act = items[i]; + if (act.id != null) { + if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { + this.logger.warn('skipping activity: activity id mismatch'); + continue; + } + } else { + // Activity ID should only be string or undefined. + act.id = undefined; } + try { - results.push([getApId(act), await this.performOneActivity(actor, act, resolver)]); + const id = getNullableApId(act) ?? `${getNullableApId(activity)}#${i}`; + const result = await this.performOneActivity(actor, act, resolver); + results.push([id, result]); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index cf370c730c..7997eccced 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -25,7 +25,7 @@ import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, i import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; -import type { IObject, ApObject } from './type.js'; +import type { IObject, ApObject, IAnonymousObject } from './type.js'; export class Resolver { private history: Set; @@ -83,6 +83,9 @@ export class Resolver { } } + public async resolveCollectionItems(collection: IAnonymousObject, limit?: number | null, allowAnonymousItems?: true, concurrency?: number): Promise; + public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise; + public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise; /** * Recursively resolves items from a collection. * Stops when reaching the resolution limit or an optional item limit - whichever is lower. @@ -94,11 +97,11 @@ export class Resolver { * @param concurrency Maximum number of items to resolve at once. (default: 4) */ @bindThis - public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise { - const resolvedItems: IObjectWithId[] = []; + public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise { + const resolvedItems: IObject[] = []; // This is pulled up to avoid code duplication below - const iterate = async(items: ApObject, current: AnyCollection & IObjectWithId) => { + const iterate = async(items: ApObject, current: AnyCollection) => { const sentFrom = current.id; const itemArr = toArray(items); const itemLimit = limit ?? Number.MAX_SAFE_INTEGER; @@ -106,7 +109,7 @@ export class Resolver { await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems); }; - let current: (AnyCollection & IObjectWithId) | null = await this.resolveCollection(collection); + let current: AnyCollection | null = await this.resolveCollection(collection); do { // Iterate all items in the current page if (current.items) { @@ -137,16 +140,27 @@ export class Resolver { return resolvedItems; } - private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise { + private async resolveItemArray(source: (string | IObject)[], sentFrom: undefined, itemLimit: number, concurrency: number, allowAnonymousItems: true, destination: IAnonymousObject[]): Promise; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise; + private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise { const recursionLimit = this.recursionLimit - this.history.size; const batchLimit = Math.min(source.length, recursionLimit, itemLimit); - const limiter = promiseLimit(concurrency); + const limiter = promiseLimit(concurrency); const batch = await Promise.all(source .slice(0, batchLimit) .map(item => limiter(async () => { - // Use secureResolve to avoid re-fetching items that were included inline. - return await this.secureResolve(item, sentFrom, allowAnonymousItems); + if (sentFrom) { + // Use secureResolve to avoid re-fetching items that were included inline. + return await this.secureResolve(item, sentFrom, allowAnonymousItems); + } else if (allowAnonymousItems) { + return await this.resolveAnonymous(item); + } else { + // ID is required if we have neither sentFrom not allowAnonymousItems + const id = getApId(item); + return await this.resolve(id); + } }))); destination.push(...batch); @@ -161,12 +175,9 @@ export class Resolver { * @param allowAnonymous If true, anonymous objects are allowed and will have their ID set to sentFromUri. If false (default) then anonymous objects will be rejected with an error. */ @bindThis - public async secureResolve(input: ApObject, sentFromUri: string, allowAnonymous?: boolean): Promise { + public async secureResolve(input: string | IObject | [string | IObject], sentFromUri: string, allowAnonymous?: boolean): Promise { // Unpack arrays to get the value element. const value = fromTuple(input); - if (value == null) { - throw new IdentifiableError('20058164-9de1-4573-8715-425753a21c1d', 'Cannot resolve null input'); - } // If anonymous input is allowed, then any object is automatically valid if we set the ID. // We can short-circuit here and avoid un-necessary checks. @@ -189,6 +200,21 @@ export class Resolver { return await this.resolve(id, allowAnonymous); } + /** + * Resolves an anonymous object. + * The returned value will not have any ID present. + * If one is provided in the response, it will be removed automatically. + */ + @bindThis + public async resolveAnonymous(value: string | IObject | [string | IObject]): Promise { + value = fromTuple(value); + + const object = await this.resolve(value); + object.id = undefined; + + return object as IAnonymousObject; + } + public async resolve(value: string | [string], allowAnonymous?: boolean): Promise; public async resolve(value: string | IObjectWithId | [string | IObjectWithId], allowAnonymous?: boolean): Promise; public async resolve(value: string | IObject | [string | IObject], allowAnonymous?: boolean): Promise; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index e33dec18d7..ae9fe118bc 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -43,6 +43,18 @@ export interface IObjectWithId extends IObject { id: string; } +export function isObjectWithId(object: IObject): object is IObjectWithId { + return typeof(object.id) === 'string'; +} + +export interface IAnonymousObject extends IObject { + id: undefined; +} + +export function isAnonymousObject(object: IObject): object is IAnonymousObject { + return object.id === undefined; +} + /** * Get array of ActivityStreams Objects id */