diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index efef777fb..114a39b9c 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -24,7 +24,7 @@ import { QueueService } from '@/core/QueueService.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { RemoteUser } from '@/models/entities/User.js'; -import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isOrderedCollectionPage, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -86,10 +86,10 @@ export class ApInboxService { } @bindThis - public async performActivity(actor: RemoteUser, activity: IObject) { - if (isCollectionOrOrderedCollection(activity)) { + public async performActivity(actor: RemoteUser, activity: IObject, limit = Infinity) { + if (isCollectionOrOrderedCollection(activity) || isOrderedCollectionPage(activity)) { const resolver = this.apResolverService.createResolver(); - for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { + for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems).slice(0, limit)) { const act = await resolver.resolve(item); try { await this.performOneActivity(actor, act); @@ -366,7 +366,7 @@ export class ApInboxService { }); if (isPost(object)) { - this.createNote(resolver, actor, object, false, activity); + await this.createNote(resolver, actor, object, false, activity); } else { this.logger.warn(`Unknown type: ${getApType(object)}`); } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index d3e0345c9..62dcad68d 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -10,11 +10,11 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; -import { isCollectionOrOrderedCollection } from './type.js'; +import { isCollectionOrOrderedCollection, isOrderedCollectionPage } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; -import type { IObject, ICollection, IOrderedCollection } from './type.js'; +import type { IObject, ICollection, IOrderedCollection, IOrderedCollectionPage } from './type.js'; export class Resolver { private history: Set; @@ -59,6 +59,18 @@ export class Resolver { } } + public async resolveOrderedCollectionPage(value: string | IObject): Promise { + const collection = typeof value === 'string' + ? await this.resolve(value) + : value; + + if (isOrderedCollectionPage(collection)) { + return collection; + } else { + throw new Error(`unrecognized collection type: ${collection.type}`); + } + } + @bindThis public async resolve(value: string | IObject): Promise { if (value == null) { diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index f52ebed10..8fb83b553 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -34,7 +34,8 @@ import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { checkHttps } from '@/misc/check-https.js'; -import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; +import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage, isPropertyValue } from '../type.js'; +import { ApInboxService } from '../ApInboxService.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; import type { ApNoteService } from './ApNoteService.js'; @@ -62,6 +63,7 @@ export class ApPersonService implements OnModuleInit { private apResolverService: ApResolverService; private apNoteService: ApNoteService; private apImageService: ApImageService; + private apInboxService: ApInboxService; private apMfmService: ApMfmService; private mfmService: MfmService; private hashtagService: HashtagService; @@ -128,6 +130,7 @@ export class ApPersonService implements OnModuleInit { this.apResolverService = this.moduleRef.get('ApResolverService'); this.apNoteService = this.moduleRef.get('ApNoteService'); this.apImageService = this.moduleRef.get('ApImageService'); + this.apInboxService = this.moduleRef.get('ApInboxService'); this.apMfmService = this.moduleRef.get('ApMfmService'); this.mfmService = this.moduleRef.get('MfmService'); this.hashtagService = this.moduleRef.get('HashtagService'); @@ -281,7 +284,7 @@ export class ApPersonService implements OnModuleInit { // Create user let user: RemoteUser; try { - // Start transaction + // Start transaction await this.db.transaction(async transactionalEntityManager => { user = await transactionalEntityManager.save(new User({ id: this.idService.genId(), @@ -327,9 +330,9 @@ export class ApPersonService implements OnModuleInit { } }); } catch (e) { - // duplicate key error + // duplicate key error if (isDuplicateKeyValueError(e)) { - // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 + // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 const u = await this.usersRepository.findOneBy({ uri: person.id, }); @@ -406,7 +409,10 @@ export class ApPersonService implements OnModuleInit { }); //#endregion - await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); + await Promise.all([ + this.updateFeatured(user!.id, resolver), + this.updateOutboxFirstPage(user!, person.outbox, resolver), + ]).catch(err => this.logger.error(err)); return user!; } @@ -415,7 +421,7 @@ export class ApPersonService implements OnModuleInit { * Personの情報を更新します。 * Misskeyに対象のPersonが登録されていなければ無視します。 * もしアカウントの移行が確認された場合、アカウント移行処理を行います。 - * + * * @param uri URI of Person * @param resolver Resolver * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) @@ -498,7 +504,7 @@ export class ApPersonService implements OnModuleInit { (!exist.movedToUri && updates.movedToUri) || // 移行先がある→別のもの (exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri); - // 移行先がある→ない、ない→ないは無視 + // 移行先がある→ない、ない→ないは無視 if (moving) updates.movedAt = new Date(); @@ -598,9 +604,9 @@ export class ApPersonService implements OnModuleInit { @bindThis public analyzeAttachments(attachments: IObject | IObject[] | undefined) { const fields: { - name: string, - value: string - }[] = []; + name: string, + value: string + }[] = []; if (Array.isArray(attachments)) { for (const attachment of attachments.filter(isPropertyValue)) { fields.push({ @@ -613,8 +619,35 @@ export class ApPersonService implements OnModuleInit { return { fields }; } + /** + * Retrieve outbox from an actor object. + * + * This only retrieves the first page for now. + */ + public async updateOutboxFirstPage(user: RemoteUser, outbox: IActor['outbox'], resolver: Resolver): Promise { + // https://www.w3.org/TR/activitypub/#actor-objects + // Outbox is a required property for all actors + if (!outbox) { + throw new Error('No outbox property'); + } + + this.logger.info(`Fetching the outbox for ${user.uri}: ${outbox}`); + + const collection = await resolver.resolveCollection(outbox); + if (!isOrderedCollection(collection)) { + throw new Error('Outbox must be an ordered collection'); + } + + const firstPage = collection.first ? + await resolver.resolveOrderedCollectionPage(collection.first) : + collection; + + // Perform activity but only the first 100 ones + await this.apInboxService.performActivity(user, firstPage, 100); + } + @bindThis - public async updateFeatured(userId: User['id'], resolver?: Resolver) { + public async updateFeatured(userId: User['id'], resolver?: Resolver): Promise { const user = await this.usersRepository.findOneByOrFail({ id: userId }); if (!this.userEntityService.isRemoteUser(user)) return; if (!user.featured) return; @@ -631,7 +664,7 @@ export class ApPersonService implements OnModuleInit { const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; const items = await Promise.all(toArray(unresolvedItems).map(x => _resolver.resolve(x))); - // Resolve and regist Notes + // Resolve and register Notes const limit = promiseLimit(2); const featuredNotes = await Promise.all(items .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも @@ -688,7 +721,7 @@ export class ApPersonService implements OnModuleInit { // (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする) dst = await this.resolvePerson(src.movedToUri); } - + if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ??? if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ??? if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri'; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 625135da6..58371c8fe 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -87,16 +87,37 @@ export interface IActivity extends IObject { }; } +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collection export interface ICollection extends IObject { type: 'Collection'; totalItems: number; + current?: ICollectionPage | string; + first?: ICollectionPage | string; + last?: ICollectionPage | string; items: ApObject; } -export interface IOrderedCollection extends IObject { +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection +export interface IOrderedCollection extends Omit { type: 'OrderedCollection'; - totalItems: number; - orderedItems: ApObject; + + // orderedItems is not defined well + // https://github.com/w3c/activitystreams/issues/494 + orderedItems?: ApObject; +} + +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-collectionpage +export interface ICollectionPage extends Omit { + type: 'CollectionPage'; + partOf?: ICollection | string; + next?: ICollectionPage | string; + prev?: ICollectionPage | string; +} + +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollectionpage +export interface IOrderedCollectionPage extends Omit, Omit { + type: 'OrderedCollectionPage'; + startIndex?: number, } export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; @@ -183,6 +204,9 @@ export const isCollection = (object: IObject): object is ICollection => export const isOrderedCollection = (object: IObject): object is IOrderedCollection => getApType(object) === 'OrderedCollection'; +export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage => + getApType(object) === 'OrderedCollectionPage'; + export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => isCollection(object) || isOrderedCollection(object); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 7cd740a2f..2b4343734 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -11,7 +11,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; -import type { IActor } from '@/core/activitypub/type.js'; +import type { IActor, ICreate, IObject, IOrderedCollection, IOrderedCollectionPage, IPost } from '@/core/activitypub/type.js'; import { Note } from '@/models/index.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { MockResolver } from '../misc/mock-resolver.js'; @@ -32,6 +32,59 @@ function createRandomActor(): IActor & { id: string } { }; } +function createRandomCreateActivity(actor: IActor, length: number): ICreate[] { + return new Array(length).fill(null).map((): ICreate => { + const id = secureRndstr(8); + const noteId = `${host}/notes/${id}`; + + return { + type: 'Create', + id: `${noteId}/activity`, + actor, + object: { + id: noteId, + type: 'Note', + attributedTo: actor.id, + content: 'test test foo', + } satisfies IPost, + }; + }); +} + +function createRandomNonPagedOutbox(actor: IActor, length: number): IOrderedCollection { + const orderedItems = createRandomCreateActivity(actor, length); + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollection', + id: actor.outbox as string, + totalItems: orderedItems.length, + orderedItems, + }; +} + +function createRandomOutboxPage(actor: IActor, id: string, length: number): IOrderedCollectionPage { + const orderedItems = createRandomCreateActivity(actor, length); + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollectionPage', + id, + totalItems: orderedItems.length, + orderedItems, + }; +} + +function createRandomPagedOutbox(actor: IActor): IOrderedCollection { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'OrderedCollection', + id: actor.outbox as string, + totalItems: 10, + first: `${actor.outbox}?first`, + }; +} + describe('ActivityPub', () => { let noteService: ApNoteService; let personService: ApPersonService; @@ -53,7 +106,7 @@ describe('ActivityPub', () => { // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error const federatedInstanceService = app.get(FederatedInstanceService); - jest.spyOn(federatedInstanceService, 'fetch').mockImplementation(() => new Promise(() => {})); + jest.spyOn(federatedInstanceService, 'fetch').mockImplementation(() => new Promise(() => { })); }); describe('Parse minimum object', () => { @@ -126,4 +179,74 @@ describe('ActivityPub', () => { } as Note); }); }); + + describe('Outbox', () => { + test('Fetch non-paged outbox from IActor', async () => { + const actor = createRandomActor(); + const outbox = createRandomNonPagedOutbox(actor, 10); + + resolver._register(actor.id, actor); + resolver._register(actor.outbox as string, outbox); + + // XXX: This shouldn't be needed as the collection already has the full information + // But somehow the resolver currently doesn't use it at all and always fetches again + for (const item of outbox.orderedItems as IObject[]) { + resolver._register(item.id!, item); + } + + await personService.createPerson(actor.id, resolver); + + for (const item of outbox.orderedItems as ICreate[]) { + const note = await noteService.fetchNote(item.object); + assert.ok(note); + assert.strictEqual(note.text, 'test test foo'); + assert.strictEqual(note.uri, (item.object as IObject).id); + } + }); + + test('Fetch paged outbox from IActor', async () => { + const actor = createRandomActor(); + const outbox = createRandomPagedOutbox(actor); + const page = createRandomOutboxPage(actor, outbox.id!, 10); + + resolver._register(actor.id, actor); + resolver._register(actor.outbox as string, outbox); + resolver._register(outbox.first as string, page); + + // XXX: This shouldn't be needed as the collection already has the full information + // But somehow the resolver currently doesn't use it at all and always fetches again + for (const item of page.orderedItems as IObject[]) { + resolver._register(item.id!, item); + } + + await personService.createPerson(actor.id, resolver); + + for (const item of page.orderedItems as ICreate[]) { + const note = await noteService.fetchNote(item.object); + assert.ok(note); + assert.strictEqual(note.text, 'test test foo'); + assert.strictEqual(note.uri, (item.object as IObject).id); + } + }); + + test('Fetch only the first 100 items', async () => { + const actor = createRandomActor(); + const outbox = createRandomNonPagedOutbox(actor, 200); + + resolver._register(actor.id, actor); + resolver._register(actor.outbox as string, outbox); + + // XXX: This shouldn't be needed as the collection already has the full information + // But somehow the resolver currently doesn't use it at all and always fetches again + for (const item of outbox.orderedItems as IObject[]) { + resolver._register(item.id!, item); + } + + await personService.createPerson(actor.id, resolver); + + const items = outbox.orderedItems as ICreate[]; + assert.ok(await noteService.fetchNote(items[99].object)); + assert.ok(!await noteService.fetchNote(items[100].object)); + }); + }); });