refactor(backend): `core/activitypub/models` (#11067)

* cleanup(`ApImageService.ts`)

* refactor(`ApImageService.ts`)

* cleanup(`check-https.ts`)

* cleanup(`ApMentionService.ts`)

* refactor(`ApMentionService.ts`)

* cleanup(`ApNoteService.ts`): unneeded `eslint-disable-next-line`

* cleanup(`ApNoteService.ts`)

* WIP(`ApImageService.ts`): `image.url`を`getApHrefNullable()`に通すかどうか悩んでいる

* refactor(`ApNoteService.ts`): function return type

* cleanup(`ApNoteService.ts`): deadcode

* refactor(`ApNoteService.ts`): `eslint-disable-next-line`

* refactor(`ApNoteService.ts`): non-null assertion

これまでは`getApId()`の方でエラーがスローされていた。

* cleanup(`ApNoteService.ts`): unneeded await

* refactor(`ApNoteService.ts`): note.attachment

- `toArray()`を使うように
- よくわからない条件式を整理
- `as`をなくすために`promiseLimit()`でジェネリクスを使うように

* cleanup(`ApNoteService.ts`)

* refactor(`ApNoteService.ts`): よりよい型定義

`res`が`null`でないことは確認されているようだったので`null`とのunionはなくした

* refactor(`ApNoteService.ts`): 不要な条件を削除

* cleanup(`ApNoteService.ts`)

* cleanup(`ApNoteService.ts`): 重要でない`as`を削除

* refactor(`ApNoteService.ts`): `eslint-disable-next-line`

* cleanup(`ApNoteService.ts`): deadcode

* cleanup(`ApNoteService.ts`): unneeded non-null assertion

* refactor(`ApNoteService.ts`): 不要な条件を削除

* WIP(`ApNoteService.ts`): `as`をなくす

エラーメッセージを考える

* cleanup(`ApNoteService.ts`): 不要な`as`を削除

* cleanup(`ApPersonService.ts`): `no-unused-vars`

* cleanup(`ApPersonService.ts`): deadcode

* refactor(`ApPersonService.ts`): function return type

* cleanup(`ApPersonService.ts`): deadcode

* cleanup(`ApPersonService.ts`): deadcode

* WIP(`ApPersonService.ts`): `as`を調整

`null`でないか確認する処理が続いていたので型アサーションは`null`とのunionにした。
より本質的な改善の余地があるように感じるのでひとまずWIPとしてコミット。

* refactor(`ApPersonService.ts`): `eslint-disable-next-line`

* WIP(`ApPersonService.ts`): `as any`をなくした

エラーをスローするようにせざるを得なかったのでエラーメッセージを考える必要がある。

* WIP(`ApNoteService.ts`): non-null assertion

non-nullアサーションを減らすために事前に存在確認をするようにした。
エラーをスローするようにしたのでメッセージを考えなければならない。

* refactor(`ApNoteService.ts`): non-null assertion -> optional chaining

* refactor(`ApPersonService.ts`): `eslint-disable-next-line`

* refactor(`ApPersonService.ts`): `eslint-disable-next-line`

* refactor(`ApPersonService.ts`): function return type

* refactor(`ApPersonService.ts`): type guardによるnon-null assertionの削除

* WIP(`ApPersonService.ts`): `analyzeAttachments`

- Field型を事前に定義しておくように

- `attachments`が`IObject`だった場合、返り値が`{ fields: [] }`になるようだが構わないのか?
- `toArray()`を通すべきでは?

* Revert "WIP(`ApImageService.ts`): `image.url`を`getApHrefNullable()`に通すかどうか悩んでいる"

This reverts commit aeefb843a8a688f8a356794e8981c58f8a2733af.

* cleanup(`ApImageService.ts`): `import`

* refactor(`ApImageService.ts`): 冗長だった部分を短く

* cleanup(`ApMentionService.ts`): `import`

* refactor(`ApImageService.ts`): `JSON.stringify()`でのindentationを追加

* cleanup(`ApNoteService.ts`): `import`

* cleanup(`ApNoteService.ts`)

* cleanup(`ApNoteService.ts`)

* cleanup(`ApNoteService.ts`)

* cleanup(`ApNoteService.ts`): `any`に対するnon-null assertion

* refactor(`ApNoteService.ts`): 添付ファイル

* cleanup(`ApPersonService.ts`): `import`

* refactor(`ApPersonService.ts`): より実情に即した`as`に

* cleanup(`ApPersonService.ts`)

* refactor(`ApPersonService.ts`): 冗長だった部分を修正

* cleanup(`ApPersonService.ts`): deadcode

* cleanup(`ApPersonService.ts`)

* cleanup(`ApQuestionService.ts`): `import`

* refactor(`ApQuestionService.ts`): `eslint-disable-next-line`

* refactor(`ApQuestionService.ts`): `eslint-disable-next-line`

* cleanup(`ApQuestionService.ts`)

* refactor(`ApQuestionService.ts`): non-null assertionを消した

* cleanup(`ApQuestionService.ts`)

* WIP(`ApQuestionService.ts`): non-null assertionを消す

エラーメッセージを考える必要がある。

* refactor(`ApQuestionService.ts`): `any`を消す

* refactor(`ApQuestionService.ts`): function return type

* WIP(`ApPersonService.ts`): 可読性の低い三項演算子を削除しつつnon-null assertionを回避

エラーメッセージを考える必要がある。

* cleanup(`ApPersonService.ts`): 不必要な三項演算子を削除

* cleanup(`ApPersonService.ts`): 不要な`as`

* cleanup(`ApPersonService.ts`)

* refactor(`ApPersonService.ts`)

* refactor(`ApPersonService.ts`): 可読性の低い三項演算子を削除

元の実装が悪いと判断し`null`かどうかの確認をより厳密に行うようにした。

* cleanup(`ApPersonService.ts`)

* cleanup(`ApPersonService.ts`)

* refactor(`ApPersonService.ts`): 返り値を`void`に統一

この返り値を参照しているコードは見当たらなかった。
また、普通に意味がない値であるように見受けられた。

* fixup! refactor(`ApPersonService.ts`): 返り値を`void`に統一

* refactor(`ApNoteService.ts`)

* refactor(`ApPersonService.ts`)

* cleanup(`ApPersonService.ts`)

* cleanup(`ApPersonService.ts`)

* refactor(`ApPersonService.ts`): 返り値の`void`統一と条件式の調整

この返り値を参照しているコードは見当たらなかった。
また、普通に意味がない値であるように見受けられた。

* cleanup(`ApQuestionService.ts`)

* refactor(`ApQuestionService.ts`)

* refactor(`ApQuestionService.ts`)

* refactor(`tag.ts`): function return type

* fixup! enhance: account migration (#10592)

* fixup! WIP(`ApPersonService.ts`): 可読性の低い三項演算子を削除しつつnon-null assertionを回避

* fixup! cleanup(`ApPersonService.ts`): 不要な`as`

* refactor: エラーメッセージを見繕った

* Revert "cleanup(`ApImageService.ts`): `import`"

This reverts commit 1454d04c377eaf46013b0f3c3ce664a4034fd53a.

* Revert "cleanup(`ApMentionService.ts`): `import`"

This reverts commit 244f6720c134a3434e33c1caf6e3e0c2c87b58f5.

* Revert "cleanup(`ApNoteService.ts`): `import`"

This reverts commit d8f0d769733c4cb0629821b04e557a0ae6f5ff5b.

* Revert "cleanup(`ApPersonService.ts`): `import`"

This reverts commit 5190ef954caf376da46c707f52e02208d53caafd.

# Conflicts:
#	packages/backend/src/core/activitypub/models/ApPersonService.ts

* Revert "cleanup(`ApQuestionService.ts`): `import`"

This reverts commit 778585e2882477fec5f11fabf398b4b89cf26da2.

* processRemoteMoveはそのままにしてほしい

* Revert "fixup! refactor(`ApPersonService.ts`): 返り値を`void`に統一"

This reverts commit 083cd678abcd64325b9628895366c03b893e42ca.

* Revert "refactor(`ApPersonService.ts`): 返り値を`void`に統一"

This reverts commit bfa0fcd6f01a6e519ea0c68017358f9980d2ed96.

---------

Co-authored-by: tamaina <tamaina@hotmail.co.jp>
This commit is contained in:
okayurisotto 2023-07-08 08:57:13 +09:00 committed by GitHub
parent 3c6175d959
commit 4f876c9e8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 189 additions and 247 deletions

View File

@ -10,9 +10,10 @@ import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js';
import { DriveService } from '@/core/DriveService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { ApResolverService } from '../ApResolverService.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { checkHttps } from '@/misc/check-https.js';
import type { IObject } from '../type.js';
@Injectable()
export class ApImageService {
@ -37,18 +38,22 @@ export class ApImageService {
* Imageを作成します
*/
@bindThis
public async createImage(actor: RemoteUser, value: any): Promise<DriveFile> {
public async createImage(actor: RemoteUser, value: string | IObject): Promise<DriveFile> {
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new Error('actor has been suspended');
}
const image = await this.apResolverService.createResolver().resolve(value) as any;
const image = await this.apResolverService.createResolver().resolve(value);
if (image.url == null) {
throw new Error('invalid image: url not privided');
}
if (typeof image.url !== 'string') {
throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2));
}
if (!checkHttps(image.url)) {
throw new Error('invalid image: unexpected schema of url: ' + image.url);
}
@ -57,29 +62,19 @@ export class ApImageService {
const instance = await this.metaService.fetch();
let file = await this.driveService.uploadFromUrl({
const file = await this.driveService.uploadFromUrl({
url: image.url,
user: actor,
uri: image.url,
sensitive: image.sensitive,
isLink: !instance.cacheRemoteFiles,
comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH),
comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH),
});
if (!file.isLink || file.url === image.url) return file;
if (file.isLink) {
// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、
// URLを更新する
if (file.url !== image.url) {
await this.driveFilesRepository.update({ id: file.id }, {
url: image.url,
uri: image.url,
});
file = await this.driveFilesRepository.findOneByOrFail({ id: file.id });
}
}
return file;
// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、URLを更新する
await this.driveFilesRepository.update({ id: file.id }, { url: image.url, uri: image.url });
return await this.driveFilesRepository.findOneByOrFail({ id: file.id });
}
/**
@ -89,7 +84,7 @@ export class ApImageService {
* Misskeyに登録しそれを返します
*/
@bindThis
public async resolveImage(actor: RemoteUser, value: any): Promise<DriveFile> {
public async resolveImage(actor: RemoteUser, value: string | IObject): Promise<DriveFile> {
// TODO
// リモートサーバーからフェッチしてきて登録

View File

@ -22,8 +22,8 @@ export class ApMentionService {
}
@bindThis
public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver) {
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string));
public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise<User[]> {
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href));
const limit = promiseLimit<User | null>(2);
const mentionedUsers = (await Promise.all(

View File

@ -20,7 +20,6 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js';
import { ApDbResolverService } from '../ApDbResolverService.js';
@ -72,13 +71,9 @@ export class ApNoteService {
}
@bindThis
public validateNote(object: IObject, uri: string) {
public validateNote(object: IObject, uri: string): Error | null {
const expectHost = this.utilityService.extractDbHost(uri);
if (object == null) {
return new Error('invalid Note: object is null');
}
if (!validPost.includes(getApType(object))) {
return new Error(`invalid Note: invalid object type ${getApType(object)}`);
}
@ -110,6 +105,7 @@ export class ApNoteService {
*/
@bindThis
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<Note | null> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value);
@ -117,12 +113,10 @@ export class ApNoteService {
const entryUri = getApId(value);
const err = this.validateNote(object, entryUri);
if (err) {
this.logger.error(`${err.message}`, {
resolver: {
history: resolver.getHistory(),
},
value: value,
object: object,
this.logger.error(err.message, {
resolver: { history: resolver.getHistory() },
value,
object,
});
throw new Error('invalid note');
}
@ -144,7 +138,11 @@ export class ApNoteService {
this.logger.info(`Creating the Note: ${note.id}`);
// 投稿者をフェッチ
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo!), resolver) as RemoteUser;
if (note.attributedTo == null) {
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
}
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as RemoteUser;
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
@ -164,59 +162,49 @@ export class ApNoteService {
}
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
const apHashtags = await extractApHashtags(note.tag);
const apHashtags = extractApHashtags(note.tag);
// 添付ファイル
// TODO: attachmentは必ずしもImageではない
// TODO: attachmentは必ずしも配列ではない
// Noteがsensitiveなら添付もsensitiveにする
const limit = promiseLimit(2);
note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : [];
const files = note.attachment
.map(attach => attach.sensitive = note.sensitive)
? (await Promise.all(note.attachment.map(x => limit(() => this.apImageService.resolveImage(actor, x)) as Promise<DriveFile>)))
.filter(image => image != null)
: [];
const limit = promiseLimit<DriveFile>(2);
const files = (await Promise.all(toArray(note.attachment).map(attach => (
limit(() => this.apImageService.resolveImage(actor, {
...attach,
sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする
}))
))));
// リプライ
const reply: Note | null = note.inReplyTo
? await this.resolveNote(note.inReplyTo, resolver).then(x => {
if (x == null) {
this.logger.warn('Specified inReplyTo, but not found');
throw new Error('inReplyTo not found');
} else {
? await this.resolveNote(note.inReplyTo, resolver)
.then(x => {
if (x == null) {
this.logger.warn('Specified inReplyTo, but not found');
throw new Error('inReplyTo not found');
}
return x;
}
}).catch(async err => {
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
throw err;
})
})
.catch(async err => {
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
throw err;
})
: null;
// 引用
let quote: Note | undefined | null;
let quote: Note | undefined | null = null;
if (note._misskey_quote || note.quoteUrl) {
const tryResolveNote = async (uri: string): Promise<{
status: 'ok';
res: Note | null;
} | {
status: 'permerror' | 'temperror';
}> => {
if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' };
const tryResolveNote = async (uri: string): Promise<
| { status: 'ok'; res: Note }
| { status: 'permerror' | 'temperror' }
> => {
if (!uri.match(/^https?:/)) return { status: 'permerror' };
try {
const res = await this.resolveNote(uri);
if (res) {
return {
status: 'ok',
res,
};
} else {
return {
status: 'permerror',
};
}
if (res == null) return { status: 'permerror' };
return { status: 'ok', res };
} catch (e) {
return {
status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror',
@ -225,9 +213,9 @@ export class ApNoteService {
};
const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string'));
const results = await Promise.all(uris.map(uri => tryResolveNote(uri)));
const results = await Promise.all(uris.map(tryResolveNote));
quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x);
quote = results.filter((x): x is { status: 'ok', res: Note } => x.status === 'ok').map(x => x.res).at(0);
if (!quote) {
if (results.some(x => x.status === 'temperror')) {
throw new Error('quote resolve failed');
@ -271,7 +259,7 @@ export class ApNoteService {
const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => {
this.logger.info(`extractEmojis: ${e}`);
return [] as Emoji[];
return [];
});
const apEmojis = emojis.map(emoji => emoji.name);
@ -309,19 +297,18 @@ export class ApNoteService {
const uri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('missing uri');
// ブロックしてたら中断
// ブロックしてたら中断
const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw new StatusError('blocked host', 451);
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) {
throw new StatusError('blocked host', 451);
}
const unlock = await this.appLockService.getApLock(uri);
try {
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.fetchNote(uri);
if (exist) {
return exist;
}
if (exist) return exist;
//#endregion
if (uri.startsWith(this.config.url)) {
@ -339,43 +326,41 @@ export class ApNoteService {
@bindThis
public async extractEmojis(tags: IObject | IObject[], host: string): Promise<Emoji[]> {
// eslint-disable-next-line no-param-reassign
host = this.utilityService.toPuny(host);
if (!tags) return [];
const eomjiTags = toArray(tags).filter(isEmoji);
const existingEmojis = await this.emojisRepository.findBy({
host,
name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))),
name: In(eomjiTags.map(tag => tag.name.replaceAll(':', ''))),
});
return await Promise.all(eomjiTags.map(async tag => {
const name = tag.name!.replaceAll(':', '');
const name = tag.name.replaceAll(':', '');
tag.icon = toSingle(tag.icon);
const exists = existingEmojis.find(x => x.name === name);
if (exists) {
if ((tag.updated != null && exists.updatedAt == null)
if ((exists.updatedAt == null)
|| (tag.id != null && exists.uri == null)
|| (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt)
|| (tag.icon!.url !== exists.originalUrl)
|| (new Date(tag.updated) > exists.updatedAt)
|| (tag.icon.url !== exists.originalUrl)
) {
await this.emojisRepository.update({
host,
name,
}, {
uri: tag.id,
originalUrl: tag.icon!.url,
publicUrl: tag.icon!.url,
originalUrl: tag.icon.url,
publicUrl: tag.icon.url,
updatedAt: new Date(),
});
return await this.emojisRepository.findOneBy({
host,
name,
}) as Emoji;
const emoji = await this.emojisRepository.findOneBy({ host, name });
if (emoji == null) throw new Error('emoji update failed');
return emoji;
}
return exists;
@ -388,11 +373,11 @@ export class ApNoteService {
host,
name,
uri: tag.id,
originalUrl: tag.icon!.url,
publicUrl: tag.icon!.url,
originalUrl: tag.icon.url,
publicUrl: tag.icon.url,
updatedAt: new Date(),
aliases: [],
} as Partial<Emoji>).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
}));
}
}

View File

@ -3,7 +3,7 @@ import promiseLimit from 'promise-limit';
import { DataSource } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { User } from '@/models/entities/User.js';
@ -15,7 +15,6 @@ import type Logger from '@/logger.js';
import type { Note } from '@/models/entities/Note.js';
import type { IdService } from '@/core/IdService.js';
import type { MfmService } from '@/core/MfmService.js';
import type { Emoji } from '@/models/entities/Emoji.js';
import { toArray } from '@/misc/prelude/array.js';
import type { GlobalEventService } from '@/core/GlobalEventService.js';
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@ -48,6 +47,8 @@ import type { IActor, IObject } from '../type.js';
const nameLength = 128;
const summaryLength = 2048;
type Field = Record<'name' | 'value', string>;
@Injectable()
export class ApPersonService implements OnModuleInit {
private utilityService: UtilityService;
@ -94,28 +95,10 @@ export class ApPersonService implements OnModuleInit {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
//private utilityService: UtilityService,
//private userEntityService: UserEntityService,
//private idService: IdService,
//private globalEventService: GlobalEventService,
//private metaService: MetaService,
//private federatedInstanceService: FederatedInstanceService,
//private fetchInstanceMetadataService: FetchInstanceMetadataService,
//private cacheService: CacheService,
//private apResolverService: ApResolverService,
//private apNoteService: ApNoteService,
//private apImageService: ApImageService,
//private apMfmService: ApMfmService,
//private mfmService: MfmService,
//private hashtagService: HashtagService,
//private usersChart: UsersChart,
//private instanceChart: InstanceChart,
//private apLoggerService: ApLoggerService,
) {
}
onModuleInit() {
onModuleInit(): void {
this.utilityService = this.moduleRef.get('UtilityService');
this.userEntityService = this.moduleRef.get('UserEntityService');
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
@ -153,10 +136,6 @@ export class ApPersonService implements OnModuleInit {
private validateActor(x: IObject, uri: string): IActor {
const expectHost = this.punyHost(uri);
if (x == null) {
throw new Error('invalid Actor: object is null');
}
if (!isActor(x)) {
throw new Error(`invalid Actor type '${x.type}'`);
}
@ -218,21 +197,19 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
public async fetchPerson(uri: string): Promise<LocalUser | RemoteUser | null> {
if (typeof uri !== 'string') throw new Error('uri is not string');
const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null;
const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null | undefined;
if (cached) return cached;
// URIがこのサーバーを指しているならデータベースからフェッチ
if (uri.startsWith(`${this.config.url}/`)) {
const id = uri.split('/').pop();
const u = await this.usersRepository.findOneBy({ id }) as LocalUser;
const u = await this.usersRepository.findOneBy({ id }) as LocalUser | null;
if (u) this.cacheService.uriPersonCache.set(uri, u);
return u;
}
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser;
const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser | null;
if (exist) {
this.cacheService.uriPersonCache.set(uri, exist);
@ -254,9 +231,11 @@ export class ApPersonService implements OnModuleInit {
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
}
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri) as any;
const object = await resolver.resolve(uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
const person = this.validateActor(object, uri);
@ -264,9 +243,9 @@ export class ApPersonService implements OnModuleInit {
const host = this.punyHost(object.id);
const { fields } = this.analyzeAttachments(person.attachment ?? []);
const fields = this.analyzeAttachments(person.attachment ?? []);
const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
const isBot = getApType(object) === 'Service';
@ -279,7 +258,7 @@ export class ApPersonService implements OnModuleInit {
}
// Create user
let user: RemoteUser;
let user: RemoteUser | null = null;
try {
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
@ -290,16 +269,16 @@ export class ApPersonService implements OnModuleInit {
createdAt: new Date(),
lastFetchedAt: new Date(),
name: truncate(person.name, nameLength),
isLocked: !!person.manuallyApprovesFollowers,
isLocked: person.manuallyApprovesFollowers,
movedToUri: person.movedTo,
movedAt: person.movedTo ? new Date() : null,
alsoKnownAs: person.alsoKnownAs,
isExplorable: !!person.discoverable,
isExplorable: person.discoverable,
username: person.preferredUsername,
usernameLower: person.preferredUsername!.toLowerCase(),
usernameLower: person.preferredUsername?.toLowerCase(),
host,
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured ? getApId(person.featured) : undefined,
uri: person.id,
@ -311,9 +290,9 @@ export class ApPersonService implements OnModuleInit {
await transactionalEntityManager.save(new UserProfile({
userId: user.id,
description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
url: url,
url,
fields,
birthday: bday ? bday[0] : null,
birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null,
userHost: host,
}));
@ -330,21 +309,18 @@ export class ApPersonService implements OnModuleInit {
// duplicate key error
if (isDuplicateKeyValueError(e)) {
// /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応
const u = await this.usersRepository.findOneBy({
uri: person.id,
});
const u = await this.usersRepository.findOneBy({ uri: person.id });
if (u == null) throw new Error('already registered');
if (u) {
user = u as RemoteUser;
} else {
throw new Error('already registered');
}
user = u as RemoteUser;
} else {
this.logger.error(e instanceof Error ? e : new Error(e as string));
throw e;
}
}
if (user == null) throw new Error('failed to create user: user is null');
// Register host
this.federatedInstanceService.fetch(host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
@ -354,29 +330,26 @@ export class ApPersonService implements OnModuleInit {
}
});
this.usersChart.update(user!, true);
this.usersChart.update(user, true);
// ハッシュタグ更新
this.hashtagService.updateUsertags(user!, tags);
this.hashtagService.updateUsertags(user, tags);
//#region アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([
person.icon,
person.image,
].map(img =>
img == null
? Promise.resolve(null)
: this.apImageService.resolveImage(user!, img).catch(() => null),
));
const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => {
if (img == null) return null;
if (user == null) throw new Error('failed to create user: user is null');
return this.apImageService.resolveImage(user, img).catch(() => null);
}));
const avatarId = avatar ? avatar.id : null;
const bannerId = banner ? banner.id : null;
const avatarId = avatar?.id ?? null;
const bannerId = banner?.id ?? null;
const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null;
const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null;
const avatarBlurhash = avatar ? avatar.blurhash : null;
const bannerBlurhash = banner ? banner.blurhash : null;
const avatarBlurhash = avatar?.blurhash ?? null;
const bannerBlurhash = banner?.blurhash ?? null;
await this.usersRepository.update(user!.id, {
await this.usersRepository.update(user.id, {
avatarId,
bannerId,
avatarUrl,
@ -385,30 +358,28 @@ export class ApPersonService implements OnModuleInit {
bannerBlurhash,
});
user!.avatarId = avatarId;
user!.bannerId = bannerId;
user!.avatarUrl = avatarUrl;
user!.bannerUrl = bannerUrl;
user!.avatarBlurhash = avatarBlurhash;
user!.bannerBlurhash = bannerBlurhash;
user.avatarId = avatarId;
user.bannerId = bannerId;
user.avatarUrl = avatarUrl;
user.bannerUrl = bannerUrl;
user.avatarBlurhash = avatarBlurhash;
user.bannerBlurhash = bannerBlurhash;
//#endregion
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
this.logger.info(`extractEmojis: ${err}`);
return [] as Emoji[];
return [];
});
const emojiNames = emojis.map(emoji => emoji.name);
await this.usersRepository.update(user!.id, {
emojis: emojiNames,
});
await this.usersRepository.update(user.id, { emojis: emojiNames });
//#endregion
await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err));
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err));
return user!;
return user;
}
/**
@ -426,18 +397,14 @@ export class ApPersonService implements OnModuleInit {
if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(`${this.config.url}/`)) {
return;
}
if (uri.startsWith(`${this.config.url}/`)) return;
//#region このサーバーに既に登録されているか
const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null;
if (exist === null) {
return;
}
if (exist === null) return;
//#endregion
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = hint ?? await resolver.resolve(uri);
@ -447,26 +414,22 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Updating the Person: ${person.id}`);
// アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([
person.icon,
person.image,
].map(img =>
img == null
? Promise.resolve(null)
: this.apImageService.resolveImage(exist, img).catch(() => null),
));
const [avatar, banner] = await Promise.all([person.icon, person.image].map(img => {
if (img == null) return null;
return this.apImageService.resolveImage(exist, img).catch(() => null);
}));
// カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => {
this.logger.info(`extractEmojis: ${e}`);
return [] as Emoji[];
return [];
});
const emojiNames = emojis.map(emoji => emoji.name);
const { fields } = this.analyzeAttachments(person.attachment ?? []);
const fields = this.analyzeAttachments(person.attachment ?? []);
const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
@ -479,7 +442,7 @@ export class ApPersonService implements OnModuleInit {
const updates = {
lastFetchedAt: new Date(),
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured,
emojis: emojiNames,
@ -487,18 +450,29 @@ export class ApPersonService implements OnModuleInit {
tags,
isBot: getApType(object) === 'Service',
isCat: (person as any).isCat === true,
isLocked: !!person.manuallyApprovesFollowers,
isLocked: person.manuallyApprovesFollowers,
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: !!person.discoverable,
isExplorable: person.discoverable,
} as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
const moving =
const moving = ((): boolean => {
// 移行先がない→ある
(!exist.movedToUri && updates.movedToUri) ||
if (
exist.movedToUri === null &&
updates.movedToUri
) return true;
// 移行先がある→別のもの
(exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri);
if (
exist.movedToUri !== null &&
updates.movedToUri !== null &&
exist.movedToUri !== updates.movedToUri
) return true;
// 移行先がある→ない、ない→ないは無視
return false;
})();
if (moving) updates.movedAt = new Date();
@ -525,10 +499,10 @@ export class ApPersonService implements OnModuleInit {
}
await this.userProfilesRepository.update({ userId: exist.id }, {
url: url,
url,
fields,
description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
birthday: bday ? bday[0] : null,
birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null,
});
@ -538,11 +512,10 @@ export class ApPersonService implements OnModuleInit {
this.hashtagService.updateUsertags(exist, tags);
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
await this.followingsRepository.update({
followerId: exist.id,
}, {
followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
});
await this.followingsRepository.update(
{ followerId: exist.id },
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox },
);
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
@ -580,27 +553,22 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
public async resolvePerson(uri: string, resolver?: Resolver): Promise<LocalUser | RemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.fetchPerson(uri);
if (exist) {
return exist;
}
if (exist) return exist;
//#endregion
// リモートサーバーからフェッチしてきて登録
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
return await this.createPerson(uri, resolver);
}
@bindThis
public analyzeAttachments(attachments: IObject | IObject[] | undefined) {
const fields: {
name: string,
value: string
}[] = [];
// TODO: `attachments`が`IObject`だった場合、返り値が`[]`になるようだが構わないのか?
public analyzeAttachments(attachments: IObject | IObject[] | undefined): Field[] {
const fields: Field[] = [];
if (Array.isArray(attachments)) {
for (const attachment of attachments.filter(isPropertyValue)) {
fields.push({
@ -610,11 +578,11 @@ export class ApPersonService implements OnModuleInit {
}
}
return { fields };
return fields;
}
@bindThis
public async updateFeatured(userId: User['id'], resolver?: Resolver) {
public async updateFeatured(userId: User['id'], resolver?: Resolver): Promise<void> {
const user = await this.usersRepository.findOneByOrFail({ id: userId });
if (!this.userEntityService.isRemoteUser(user)) return;
if (!user.featured) return;
@ -643,13 +611,13 @@ export class ApPersonService implements OnModuleInit {
// とりあえずidを別の時間で生成して順番を維持
let td = 0;
for (const note of featuredNotes.filter(note => note != null)) {
for (const note of featuredNotes.filter((note): note is Note => note != null)) {
td -= 1000;
transactionalEntityManager.insert(UserNotePining, {
id: this.idService.genId(new Date(Date.now() + td)),
createdAt: new Date(),
userId: user.id,
noteId: note!.id,
noteId: note.id,
});
}
});

View File

@ -4,12 +4,12 @@ import type { NotesRepository, PollsRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { IPoll } from '@/models/entities/Poll.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { isQuestion } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApResolverService } from '../ApResolverService.js';
import type { Resolver } from '../ApResolverService.js';
import type { IObject, IQuestion } from '../type.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class ApQuestionService {
@ -33,33 +33,25 @@ export class ApQuestionService {
@bindThis
public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(source);
if (!isQuestion(question)) throw new Error('invalid type');
if (!isQuestion(question)) {
throw new Error('invalid type');
}
const multiple = question.oneOf === undefined;
if (multiple && question.anyOf === undefined) throw new Error('invalid question');
const multiple = !question.oneOf;
const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null;
if (multiple && !question.anyOf) {
throw new Error('invalid question');
}
const choices = question[multiple ? 'anyOf' : 'oneOf']
?.map((x) => x.name)
.filter((x): x is string => typeof x === 'string')
?? [];
const choices = question[multiple ? 'anyOf' : 'oneOf']!
.map((x, i) => x.name!);
const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0);
const votes = question[multiple ? 'anyOf' : 'oneOf']!
.map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0);
return {
choices,
votes,
multiple,
expiresAt,
};
return { choices, votes, multiple, expiresAt };
}
/**
@ -68,8 +60,9 @@ export class ApQuestionService {
* @returns true if updated
*/
@bindThis
public async updateQuestion(value: any, resolver?: Resolver) {
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
const uri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('uri is null');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
@ -83,6 +76,7 @@ export class ApQuestionService {
//#endregion
// resolve new Question object
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(value) as IQuestion;
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
@ -90,12 +84,14 @@ export class ApQuestionService {
if (question.type !== 'Question') throw new Error('object is not a Question');
const apChoices = question.oneOf ?? question.anyOf;
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
let changed = false;
for (const choice of poll.choices) {
const oldCount = poll.votes[poll.choices.indexOf(choice)];
const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems;
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
if (oldCount !== newCount) {
changed = true;
@ -103,9 +99,7 @@ export class ApQuestionService {
}
}
await this.pollsRepository.update({ noteId: note.id }, {
votes: poll.votes,
});
await this.pollsRepository.update({ noteId: note.id }, { votes: poll.votes });
return changed;
}

View File

@ -2,7 +2,7 @@ import { toArray } from '@/misc/prelude/array.js';
import { isHashtag } from '../type.js';
import type { IObject, IApHashtag } from '../type.js';
export function extractApHashtags(tags: IObject | IObject[] | null | undefined) {
export function extractApHashtags(tags: IObject | IObject[] | null | undefined): string[] {
if (tags == null) return [];
const hashtags = extractApHashtagObjects(tags);

View File

@ -1,4 +1,4 @@
export function checkHttps(url: string) {
return url.startsWith('https://') ||
(url.startsWith('http://') && process.env.NODE_ENV !== 'production');
export function checkHttps(url: string): boolean {
return url.startsWith('https://') ||
(url.startsWith('http://') && process.env.NODE_ENV !== 'production');
}