puyoskey/packages/backend/src/server/api/endpoints/ap/show.ts

252 lines
8 KiB
TypeScript

/*
* 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<typeof meta, typeof paramDef> { // 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<SchemaType<typeof meta['res']> | 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<SchemaType<typeof meta.res> | 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<string> {
const user = await this.instanceActorService.getInstanceActor();
const res = await this.apRequestService.signedGet(uri, user, true);
return getNullableApId(res) ?? uri;
}
}