Files
Curse/packages/backend/src/core/FetchInstanceMetadataService.ts
tamaina 5f88d56d96 perf(federation): Ed25519署名に対応する (#13464)
* 1. ed25519キーペアを発行・Personとして公開鍵を送受信

* validate additionalPublicKeys

* getAuthUserFromApIdはmainを選ぶ

* ✌️

* fix

* signatureAlgorithm

* set publicKeyCache lifetime

* refresh

* httpMessageSignatureAcceptable

* ED25519_SIGNED_ALGORITHM

* ED25519_PUBLIC_KEY_SIGNATURE_ALGORITHM

* remove sign additionalPublicKeys signature requirements

* httpMessageSignaturesSupported

* httpMessageSignaturesImplementationLevel

* httpMessageSignaturesImplementationLevel: '01'

* perf(federation): Use hint for getAuthUserFromApId (#13470)

* Hint for getAuthUserFromApId

* とどのつまりこれでいいのか?

* use @misskey-dev/node-http-message-signatures

* fix

* signedPost, signedGet

* ap-request.tsを復活させる

* remove digest prerender

* fix test?

* fix test

* add httpMessageSignaturesImplementationLevel to FederationInstance

* ManyToOne

* fetchPersonWithRenewal

* exactKey

* ✌️

* use const

* use gen-key-pair fn. from  '@misskey-dev/node-http-message-signatures'

* update node-http-message-signatures

* fix

* @misskey-dev/node-http-message-signatures@0.0.0-alpha.11

* getAuthUserFromApIdでupdatePersonの頻度を増やす

* cacheRaw.date

* use requiredInputs
https://github.com/misskey-dev/misskey/pull/13464#discussion_r1509964359

* update @misskey-dev/node-http-message-signatures

* clean up

* err msg

* fix(backend): fetchInstanceMetadataのLockが永遠に解除されない問題を修正

Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>

* fix httpMessageSignaturesImplementationLevel validation

* fix test

* fix

* comment

* comment

* improve test

* fix

* use Promise.all in genRSAAndEd25519KeyPair

* refreshAndprepareEd25519KeyPair

* refreshAndfindKey

* commetn

* refactor public keys add

* digestプリレンダを復活させる

RFC実装時にどうするか考える

* fix, async

* fix

* !== true

* use save

* Deliver update person when new key generated (not tested)
https://github.com/misskey-dev/misskey/pull/13464#issuecomment-1977049061

* 循環参照で落ちるのを解消?

* fix?

* Revert "fix?"

This reverts commit 0082f6f8e8.

* a

* logger

* log

* change logger

* 秘密鍵の変更は、フラグではなく鍵を引き回すようにする

* addAllKnowingSharedInboxRecipe

* nanka meccha kaeta

* delivre

* キャッシュ有効チェックはロック取得前に行う

* @misskey-dev/node-http-message-signatures@0.0.3

* PrivateKeyPem

* getLocalUserPrivateKey

* fix test

* if

* fix ap-request

* update node-http-message-signatures

* fix type error

* update package

* fix type

* update package

* retry no key

* @misskey-dev/node-http-message-signatures@0.0.8

* fix type error

* log keyid

* logger

* db-resolver

* JSON.stringify

* HTTP Signatureがなかったり使えなかったりしそうな場合にLD Signatureを活用するように

* inbox-delayed use actor if no signature

* ユーザーとキーの同一性チェックはhostの一致にする

* log signature parse err

* save array

* とりあえずtryで囲っておく

* fetchPersonWithRenewalでエラーが起きたら古いデータを返す

* use transactionalEntityManager

* fix spdx

* @misskey-dev/node-http-message-signatures@0.0.10

* add comment

* fix

* publicKeyに配列が入ってもいいようにする
https://github.com/misskey-dev/misskey/pull/13950

* define additionalPublicKeys

* fix

* merge fix

* refreshAndprepareEd25519KeyPair → refreshAndPrepareEd25519KeyPair

* remove gen-key-pair.ts

* defaultMaxListeners = 512

* Revert "defaultMaxListeners = 512"

This reverts commit f2c412c180.

* genRSAAndEd25519KeyPairではキーを直列に生成する?

* maxConcurrency: 8

* maxConcurrency: 16

* maxConcurrency: 8

* Revert "genRSAAndEd25519KeyPairではキーを直列に生成する?"

This reverts commit d0aada55c1.

* maxWorkers: '90%'

* Revert "maxWorkers: '90%'"

This reverts commit 9e0a93f110.

* e2e/timelines.tsで個々のテストに対するtimeoutを削除, maxConcurrency: 32

* better error handling of this.userPublickeysRepository.delete

* better comment

* set result to keypairEntityCache

* deliverJobConcurrency: 16, deliverJobPerSec: 1024, inboxJobConcurrency: 4

* inboxJobPerSec: 64

* delete request.headers['host'];

* fix

* // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!

* move delete host

* modify comment

* modify comment

* fix correct → collect

* refreshAndfindKey → refreshAndFindKey

* modify comment

* modify attachLdSignature

* getApId, InboxProcessorService

* TODO

* [skip ci] add CHANGELOG

---------

Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>
Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
2024-07-18 01:28:17 +09:00

348 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
import tinycolor from 'tinycolor2';
import * as Redis from 'ioredis';
import type { MiInstance } from '@/models/Instance.js';
import type Logger from '@/logger.js';
import { DI } from '@/di-symbols.js';
import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { REMOTE_SERVER_CACHE_TTL } from '@/const.js';
import type { DOMWindow } from 'jsdom';
type NodeInfo = {
openRegistrations?: unknown;
software?: {
name?: unknown;
version?: unknown;
};
metadata?: {
httpMessageSignaturesImplementationLevel?: unknown,
name?: unknown;
nodeName?: unknown;
nodeDescription?: unknown;
description?: unknown;
maintainer?: {
name?: unknown;
email?: unknown;
};
themeColor?: unknown;
};
};
@Injectable()
export class FetchInstanceMetadataService {
private logger: Logger;
private httpColon = 'https://';
constructor(
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
private federatedInstanceService: FederatedInstanceService,
@Inject(DI.redis)
private redisClient: Redis.Redis,
) {
this.logger = this.loggerService.getLogger('metadata', 'cyan');
this.httpColon = process.env.MISSKEY_USE_HTTP?.toLowerCase() === 'true' ? 'http://' : 'https://';
}
@bindThis
// public for test
public async tryLock(host: string): Promise<string | null> {
// TODO: マイグレーションなのであとで消す (2024.3.1)
this.redisClient.del(`fetchInstanceMetadata:mutex:${host}`);
return await this.redisClient.set(
`fetchInstanceMetadata:mutex:v2:${host}`, '1',
'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395
'GET', // 古い値を返すなかったらnull
);
}
@bindThis
// public for test
public unlock(host: string): Promise<number> {
return this.redisClient.del(`fetchInstanceMetadata:mutex:v2:${host}`);
}
@bindThis
public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> {
const host = instance.host;
if (!force) {
// キャッシュ有効チェックはロック取得前に行う
const _instance = await this.federatedInstanceService.fetch(host);
const now = Date.now();
if (_instance && _instance.infoUpdatedAt != null && (now - _instance.infoUpdatedAt.getTime() < REMOTE_SERVER_CACHE_TTL)) {
this.logger.debug(`Skip because updated recently ${_instance.infoUpdatedAt.toJSON()}`);
return;
}
// finallyでunlockされてしまうのでtry内でロックチェックをしない
// returnであってもfinallyは実行される
if (await this.tryLock(host) === '1') {
// 1が返ってきていたら他にロックされているという意味なので、何もしない
return;
}
}
try {
this.logger.info(`Fetching metadata of ${instance.host} ...`);
const [info, dom, manifest] = await Promise.all([
this.fetchNodeinfo(instance).catch(() => null),
this.fetchDom(instance).catch(() => null),
this.fetchManifest(instance).catch(() => null),
]);
const [favicon, icon, themeColor, name, description] = await Promise.all([
this.fetchFaviconUrl(instance, dom).catch(() => null),
this.fetchIconUrl(instance, dom, manifest).catch(() => null),
this.getThemeColor(info, dom, manifest).catch(() => null),
this.getSiteName(info, dom, manifest).catch(() => null),
this.getDescription(info, dom, manifest).catch(() => null),
]);
this.logger.succ(`Successfuly fetched metadata of ${instance.host}`);
const updates = {
infoUpdatedAt: new Date(),
} as Record<string, any>;
if (info) {
updates.softwareName = typeof info.software?.name === 'string' ? info.software.name.toLowerCase() : '?';
updates.softwareVersion = info.software?.version;
updates.openRegistrations = info.openRegistrations;
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
if (info.metadata && info.metadata.httpMessageSignaturesImplementationLevel && (
info.metadata.httpMessageSignaturesImplementationLevel === '01' ||
info.metadata.httpMessageSignaturesImplementationLevel === '11'
)) {
updates.httpMessageSignaturesImplementationLevel = info.metadata.httpMessageSignaturesImplementationLevel;
} else {
updates.httpMessageSignaturesImplementationLevel = '00';
}
}
if (name) updates.name = name;
if (description) updates.description = description;
if (icon ?? favicon) updates.iconUrl = (icon && !icon.includes('data:image/png;base64')) ? icon : favicon;
if (favicon) updates.faviconUrl = favicon;
if (themeColor) updates.themeColor = themeColor;
await this.federatedInstanceService.update(instance.id, updates);
this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
this.logger.debug('Updated metadata:', {
info: !!info,
dom: !!dom,
manifest: !!manifest,
updates,
});
} catch (e) {
this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
} finally {
await this.unlock(host);
}
}
@bindThis
private async fetchNodeinfo(instance: MiInstance): Promise<NodeInfo> {
this.logger.info(`Fetching nodeinfo of ${instance.host} ...`);
try {
const wellknown = await this.httpRequestService.getJson(this.httpColon + instance.host + '/.well-known/nodeinfo')
.catch(err => {
if (err.statusCode === 404) {
throw new Error('No nodeinfo provided');
} else {
throw err.statusCode ?? err.message;
}
}) as Record<string, unknown>;
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
throw new Error('No wellknown links');
}
const links = wellknown.links as ({ rel: string, href: string; })[];
const link1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0');
const link2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0');
const link2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1');
const link = link2_1 ?? link2_0 ?? link1_0;
if (link == null) {
throw new Error('No nodeinfo link provided');
}
const info = await this.httpRequestService.getJson(link.href)
.catch(err => {
throw err.statusCode ?? err.message;
});
this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`);
return info as NodeInfo;
} catch (err) {
this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`);
throw err;
}
}
@bindThis
private async fetchDom(instance: MiInstance): Promise<DOMWindow['document']> {
this.logger.info(`Fetching HTML of ${instance.host} ...`);
const url = this.httpColon + instance.host;
const html = await this.httpRequestService.getHtml(url);
const { window } = new JSDOM(html);
const doc = window.document;
return doc;
}
@bindThis
private async fetchManifest(instance: MiInstance): Promise<Record<string, unknown> | null> {
const url = this.httpColon + instance.host;
const manifestUrl = url + '/manifest.json';
const manifest = await this.httpRequestService.getJson(manifestUrl) as Record<string, unknown>;
return manifest;
}
@bindThis
private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise<string | null> {
const url = this.httpColon + instance.host;
if (doc) {
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href;
if (href) {
return (new URL(href, url)).href;
}
}
const faviconUrl = url + '/favicon.ico';
const favicon = await this.httpRequestService.send(faviconUrl, {
method: 'HEAD',
}, { throwErrorWhenResponseNotOk: false });
if (favicon.ok) {
return faviconUrl;
}
return null;
}
@bindThis
private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
const url = this.httpColon + instance.host;
return (new URL(manifest.icons[0].src, url)).href;
}
if (doc) {
const url = this.httpColon + instance.host;
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const links = Array.from(doc.getElementsByTagName('link')).reverse();
// https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559
const href =
[
links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href,
links.find(link => link.relList.contains('apple-touch-icon'))?.href,
links.find(link => link.relList.contains('icon'))?.href,
]
.find(href => href);
if (href) {
return (new URL(href, url)).href;
}
}
return null;
}
@bindThis
private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
if (themeColor) {
const color = new tinycolor(themeColor);
if (color.isValid()) return color.toHexString();
}
return null;
}
@bindThis
private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (typeof info.metadata.nodeName === 'string') {
return info.metadata.nodeName;
} else if (typeof info.metadata.name === 'string') {
return info.metadata.name;
}
}
if (doc) {
const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest.name ?? manifest.short_name;
}
return null;
}
@bindThis
private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (typeof info.metadata.nodeDescription === 'string') {
return info.metadata.nodeDescription;
} else if (typeof info.metadata.description === 'string') {
return info.metadata.description;
}
}
if (doc) {
const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content');
if (meta) {
return meta;
}
const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest.name ?? manifest.short_name;
}
return null;
}
}