/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiNote } from '@/models/Note.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { isActor, isPost, getApId, getNullableApId } from '@/core/activitypub/type.js'; import type { SchemaType } from '@/misc/json-schema.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; import { ApiError } from '../../error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; export const meta = { tags: ['federation'], requireCredential: true, kind: 'read:account', // Up to 30 calls, then 1 per 1/2 second limit: { max: 30, dripRate: 500, }, errors: { federationNotAllowed: { message: 'Federation for this host is not allowed.', code: 'FEDERATION_NOT_ALLOWED', id: '974b799e-1a29-4889-b706-18d4dd93e266', }, uriInvalid: { message: 'URI is invalid.', code: 'URI_INVALID', id: '1a5eab56-e47b-48c2-8d5e-217b897d70db', }, requestFailed: { message: 'Request failed.', code: 'REQUEST_FAILED', id: '81b539cf-4f57-4b29-bc98-032c33c0792e', }, responseInvalid: { message: 'Response from remote server is invalid.', code: 'RESPONSE_INVALID', id: '70193c39-54f3-4813-82f0-70a680f7495b', }, responseInvalidIdHostNotMatch: { message: 'Requested URI and response URI host does not match.', code: 'RESPONSE_INVALID_ID_HOST_NOT_MATCH', id: 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a', }, noSuchObject: { message: 'No such object.', code: 'NO_SUCH_OBJECT', id: 'dc94d745-1262-4e63-a17d-fecaa57efc82', }, }, res: { optional: false, nullable: false, oneOf: [ { type: 'object', properties: { type: { type: 'string', optional: false, nullable: false, enum: ['User'], }, object: { type: 'object', optional: false, nullable: false, ref: 'UserDetailedNotMe', }, }, }, { type: 'object', properties: { type: { type: 'string', optional: false, nullable: false, enum: ['Note'], }, object: { type: 'object', optional: false, nullable: false, ref: 'Note', }, }, }, ], }, } as const; export const paramDef = { type: 'object', properties: { uri: { type: 'string' }, }, required: ['uri'], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private utilityService: UtilityService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private apResolverService: ApResolverService, private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, private apNoteService: ApNoteService, private readonly apRequestService: ApRequestService, private readonly instanceActorService: InstanceActorService, ) { super(meta, paramDef, async (ps, me) => { const object = await this.fetchAny(ps.uri, me); if (object) { return object; } else { throw new ApiError(meta.errors.noSuchObject); } }); } /*** * URIからUserかNoteを解決する */ @bindThis private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise | null> { if (!this.utilityService.isFederationAllowedUri(uri)) { throw new ApiError(meta.errors.federationNotAllowed); } let local = await this.mergePack(me, ...await Promise.all([ this.apDbResolverService.getUserFromApId(uri), this.apDbResolverService.getNoteFromApId(uri), ])); if (local != null) return local; // No local object found with that uri. // Before we fetch, resolve the URI in case it has a cross-origin redirect or anything like that. // Resolver.resolve() uses strict verification, which is overly paranoid for a user-provided lookup. uri = await this.resolveCanonicalUri(uri); // eslint-disable-line no-param-reassign if (!this.utilityService.isFederationAllowedUri(uri)) { throw new ApiError(meta.errors.federationNotAllowed); } const host = this.utilityService.extractDbHost(uri); // local object, not found in db? fail if (this.utilityService.isSelfHost(host)) return null; // リモートから一旦オブジェクトフェッチ const resolver = this.apResolverService.createResolver(); const object = await resolver.resolve(uri).catch((err) => { if (err instanceof IdentifiableError) { switch (err.id) { // resolve case 'b94fd5b1-0e3b-4678-9df2-dad4cd515ab2': throw new ApiError(meta.errors.uriInvalid); case '0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5': case 'd592da9f-822f-4d91-83d7-4ceefabcf3d2': throw new ApiError(meta.errors.requestFailed); case '09d79f9e-64f1-4316-9cfa-e75c4d091574': throw new ApiError(meta.errors.federationNotAllowed); case '72180409-793c-4973-868e-5a118eb5519b': case 'ad2dc287-75c1-44c4-839d-3d2e64576675': throw new ApiError(meta.errors.responseInvalid); case 'fd93c2fa-69a8-440f-880b-bf178e0ec877': throw new ApiError(meta.errors.responseInvalidIdHostNotMatch); // resolveLocal case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8': throw new ApiError(meta.errors.uriInvalid); case 'a9d946e5-d276-47f8-95fb-f04230289bb0': case '06ae3170-1796-4d93-a697-2611ea6d83b6': throw new ApiError(meta.errors.noSuchObject); case '7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0': throw new ApiError(meta.errors.responseInvalid); } } throw new ApiError(meta.errors.requestFailed); }); if (object.id == null) { throw new ApiError(meta.errors.responseInvalid); } // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する // これはDBに存在する可能性があるため再度DB検索 if (uri !== object.id) { local = await this.mergePack(me, ...await Promise.all([ this.apDbResolverService.getUserFromApId(object.id), this.apDbResolverService.getNoteFromApId(object.id), ])); if (local != null) return local; } // 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない return await this.mergePack( me, isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null, isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null, ); } @bindThis private async mergePack(me: MiLocalUser | null | undefined, user: MiUser | null | undefined, note: MiNote | null | undefined): Promise | null> { if (user != null) { return { type: 'User', object: await this.userEntityService.pack(user, me, { schema: 'UserDetailedNotMe' }), }; } else if (note != null) { try { const object = await this.noteEntityService.pack(note, me, { detail: true }); return { type: 'Note', object, }; } catch (e) { return null; } } return null; } /** * Resolves an arbitrary URI to its canonical, post-redirect form. */ private async resolveCanonicalUri(uri: string): Promise { const user = await this.instanceActorService.getInstanceActor(); const res = await this.apRequestService.signedGet(uri, user, true); return getNullableApId(res) ?? uri; } }