From 2695c9755c368da68285e6b66763c4908350c95d Mon Sep 17 00:00:00 2001 From: kimbabclub Date: Mon, 27 Oct 2025 15:45:35 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=EB=AA=A8=ED=8A=B8=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=EC=9D=98=20=EC=95=84=EB=B0=94=ED=83=80=20=EC=9E=A5?= =?UTF-8?q?=EC=8B=9D=20=EC=97=B0=ED=95=A9=20=EC=A7=80=EC=9B=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 리모트 유저의 여러 아바타 장식을 자동으로 동기화 - AvatarDecoration 엔티티에 host, remoteId 필드 추가 - ApPersonService에서 사용자 생성/업데이트 시 자동 동기화 - 리모트 아바타 장식용 별도 캐시 추가 (cacheWithRemote) - 환경설정에 '리모트 유저의 아바타 장식 표시' 토글 추가 - Misskey/CherryPick 인스턴스 사용자 지원 --- locales/en-US.yml | 1 + locales/ja-JP.yml | 1 + locales/ko-KR.yml | 1 + ...761558000000-AvatarDecorationRemoteHost.js | 19 +++ .../src/core/AvatarDecorationService.ts | 127 +++++++++++++++- .../activitypub/models/ApPersonService.ts | 142 ++++++++++-------- .../src/core/entities/UserEntityService.ts | 20 +-- .../backend/src/models/AvatarDecoration.ts | 10 ++ .../src/components/global/MkAvatar.vue | 10 +- .../src/pages/settings/preferences.vue | 25 ++- packages/frontend/src/preferences/def.ts | 3 + packages/frontend/src/preferences/manager.ts | 13 ++ packages/frontend/src/store.ts | 4 + 13 files changed, 287 insertions(+), 89 deletions(-) create mode 100644 packages/backend/migration/1761558000000-AvatarDecorationRemoteHost.js diff --git a/locales/en-US.yml b/locales/en-US.yml index 1d96d9fa366..b365bd29402 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1255,6 +1255,7 @@ detachAll: "Remove All" angle: "Angle" flip: "Flip" showAvatarDecorations: "Show avatar decorations" +showRemoteAvatarDecorations: "Show remote users' avatar decorations" releaseToRefresh: "Release to refresh" refreshing: "Refreshing..." pullDownToRefresh: "Pull down to refresh" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 0c541056425..2d833a8c228 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1257,6 +1257,7 @@ detachAll: "全て外す" angle: "角度" flip: "反転" showAvatarDecorations: "アイコンのデコレーションを表示" +showRemoteAvatarDecorations: "リモートユーザーのアイコンデコレーションを表示" releaseToRefresh: "離してリロード" refreshing: "リロード中" pullDownToRefresh: "引っ張ってリロード" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 09b15830fbd..9776f97c232 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1257,6 +1257,7 @@ detachAll: "모두 빼기" angle: "각도" flip: "플립" showAvatarDecorations: "아바타 장식 표시" +showRemoteAvatarDecorations: "리모트 유저의 아바타 장식 표시" releaseToRefresh: "놓아서 새로고침" refreshing: "새로고침 중" pullDownToRefresh: "아래로 내려서 새로고침" diff --git a/packages/backend/migration/1761558000000-AvatarDecorationRemoteHost.js b/packages/backend/migration/1761558000000-AvatarDecorationRemoteHost.js new file mode 100644 index 00000000000..b3046935f0f --- /dev/null +++ b/packages/backend/migration/1761558000000-AvatarDecorationRemoteHost.js @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AvatarDecorationRemoteHost1761558000000 { + name = 'AvatarDecorationRemoteHost1761558000000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "remoteId" varchar(32)`); + await queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "host" varchar(128)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "host"`); + await queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "remoteId"`); + } +} + diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 4efd6122b18..a0f01d96c13 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -5,7 +5,9 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { AvatarDecorationsRepository, MiAvatarDecoration, MiUser } from '@/models/_.js'; +import { IsNull } from 'typeorm'; +import { UserDetailedNotMe } from 'misskey-js/entities.js'; +import type { AvatarDecorationsRepository, InstancesRepository, UsersRepository, MiAvatarDecoration, MiUser } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -13,23 +15,38 @@ import { bindThis } from '@/decorators.js'; import { MemorySingleCache } from '@/misc/cache.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { appendQuery, query } from '@/misc/prelude/url.js'; +import type { Config } from '@/config.js'; @Injectable() export class AvatarDecorationService implements OnApplicationShutdown { public cache: MemorySingleCache; + public cacheWithRemote: MemorySingleCache; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.avatarDecorationsRepository) private avatarDecorationsRepository: AvatarDecorationsRepository, + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private idService: IdService, private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, + private httpRequestService: HttpRequestService, ) { this.cache = new MemorySingleCache(1000 * 60 * 30); // 30s + this.cacheWithRemote = new MemorySingleCache(1000 * 60 * 30); this.redisForSub.on('message', this.onMessage); } @@ -94,6 +111,105 @@ export class AvatarDecorationService implements OnApplicationShutdown { } } + @bindThis + private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string { + return appendQuery( + `${this.config.mediaProxy}/${mode ?? 'image'}.webp`, + query({ + url, + ...(mode ? { [mode]: '1' } : {}), + }), + ); + } + + @bindThis + public async remoteUserUpdate(user: MiUser) { + const userHost = user.host ?? ''; + const instance = await this.instancesRepository.findOneBy({ host: userHost }); + const userHostUrl = `https://${user.host}`; + const showUserApiUrl = `${userHostUrl}/api/users/show`; + + if (instance?.softwareName !== 'misskey' && instance?.softwareName !== 'cherrypick') { + return; + } + + const res = await this.httpRequestService.send(showUserApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 'username': user.username }), + }); + if (!res.ok) { + return; + } + + const userData = await res.json() as Partial | undefined; + const userAvatarDecorations = userData?.avatarDecorations; + + if (!userAvatarDecorations || userAvatarDecorations.length === 0) { + const updates = {} as Partial; + updates.avatarDecorations = []; + await this.usersRepository.update({ id: user.id }, updates); + return; + } + + const instanceHost = instance.host; + const decorationApiUrl = `https://${instanceHost}/api/get-avatar-decorations`; + const allDecoRes = await this.httpRequestService.send(decorationApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!allDecoRes.ok) { + return; + } + const remoteDecorations = (await allDecoRes.json() as Partial | undefined) ?? []; + const updates = {} as Partial; + updates.avatarDecorations = []; + for (const userAvatarDecoration of userAvatarDecorations) { + let name; + let description; + const userAvatarDecorationId = userAvatarDecoration.id; + for (const remoteDecoration of remoteDecorations) { + if (remoteDecoration?.id === userAvatarDecorationId) { + name = remoteDecoration.name; + description = remoteDecoration.description; + break; + } + } + const existingDecoration = await this.avatarDecorationsRepository.findOneBy({ + host: userHost, + remoteId: userAvatarDecorationId, + }); + const decorationData = { + name: name, + description: description, + url: this.getProxiedUrl(userAvatarDecoration.url), + remoteId: userAvatarDecorationId, + host: userHost, + }; + if (existingDecoration == null) { + await this.create(decorationData); + this.cacheWithRemote.delete(); + } else { + await this.update(existingDecoration.id, decorationData); + this.cacheWithRemote.delete(); + } + const findDecoration = await this.avatarDecorationsRepository.findOneBy({ + host: userHost, + remoteId: userAvatarDecorationId, + }); + + updates.avatarDecorations.push({ + id: findDecoration?.id ?? '', + angle: userAvatarDecoration.angle ?? 0, + flipH: userAvatarDecoration.flipH ?? false, + offsetX: userAvatarDecoration.offsetX ?? 0, + offsetY: userAvatarDecoration.offsetY ?? 0, + }); + } + await this.usersRepository.update({ id: user.id }, updates); + } + @bindThis public async delete(id: MiAvatarDecoration['id'], moderator?: MiUser): Promise { const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id }); @@ -110,11 +226,16 @@ export class AvatarDecorationService implements OnApplicationShutdown { } @bindThis - public async getAll(noCache = false): Promise { + public async getAll(noCache = false, withRemote = false): Promise { if (noCache) { this.cache.delete(); + this.cacheWithRemote.delete(); + } + if (!withRemote) { + return this.cache.fetch(() => this.avatarDecorationsRepository.find({ where: { host: IsNull() } })); + } else { + return this.cacheWithRemote.fetch(() => this.avatarDecorationsRepository.find()); } - return this.cache.fetch(() => this.avatarDecorationsRepository.find()); } @bindThis diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index e52078ed0f2..da2d97dfc5f 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -37,6 +37,7 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { checkHttps } from '@/misc/check-https.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; @@ -104,6 +105,7 @@ export class ApPersonService implements OnModuleInit { private followingsRepository: FollowingsRepository, private roleService: RoleService, + private avatarDecorationService: AvatarDecorationService, ) { } @@ -459,6 +461,8 @@ export class ApPersonService implements OnModuleInit { // ハッシュタグ更新 this.hashtagService.updateUsertags(user, tags); + await this.avatarDecorationService.remoteUserUpdate(user); + //#region アバターとヘッダー画像をフェッチ try { const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image); @@ -587,82 +591,88 @@ export class ApPersonService implements OnModuleInit { exist.movedToUri !== updates.movedToUri ) return true; - // 移行先がある→ない、ない→ないは無視 - return false; - })(); - - if (moving) updates.movedAt = new Date(); - - // Update user - if (!(await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates)).affected) { - return 'skip'; - } + // 移行先がある→ない、ない→ないは無視 + return false; + })(); - if (person.publicKey) { - await this.userPublickeysRepository.update({ userId: exist.id }, { - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, - }); - } + if (moving) updates.movedAt = new Date(); - let _description: string | null = null; + // Update user + if (!(await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates)).affected) { + return 'skip'; + } - if (person._misskey_summary) { - _description = truncate(person._misskey_summary, summaryLength); - } else if (person.summary) { - _description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); - } + const user = await this.usersRepository.findOneBy({ id: exist.id, isDeleted: false }); + if (!user) { + return 'skip'; + } + await this.avatarDecorationService.remoteUserUpdate(user); - await this.userProfilesRepository.update({ userId: exist.id }, { - url, - fields, - description: _description, - followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, - followingVisibility, - followersVisibility, - birthday: bday?.[0] ?? null, - location: person['vcard:Address'] ?? null, + if (person.publicKey) { + await this.userPublickeysRepository.update({ userId: exist.id }, { + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, }); + } - this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id }); - - // ハッシュタグ更新 - this.hashtagService.updateUsertags(exist, tags); - - // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする - await this.followingsRepository.update( - { followerId: exist.id }, - { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null }, - ); + let _description: string | null = null; - await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); - - const updated = { ...exist, ...updates }; - - this.cacheService.uriPersonCache.set(uri, updated); - - // 移行処理を行う - if (updated.movedAt && ( - // 初めて移行する場合はmovedAtがnullなので移行処理を許可 - exist.movedAt == null || - // 以前のmovingから14日以上経過した場合のみ移行処理を許可 - // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) - exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() - )) { - this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); - return this.processRemoteMove(updated, movePreventUris) - .then(result => { - this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`); - return result; - }) - .catch(e => { - this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e }); - }); - } + if (person._misskey_summary) { + _description = truncate(person._misskey_summary, summaryLength); + } else if (person.summary) { + _description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag); + } - return 'skip'; + await this.userProfilesRepository.update({ userId: exist.id }, { + url, + fields, + description: _description, + followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, + followingVisibility, + followersVisibility, + birthday: bday?.[0] ?? null, + location: person['vcard:Address'] ?? null, + }); + + this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: exist.id }); + + // ハッシュタグ更新 + this.hashtagService.updateUsertags(exist, tags); + + // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする + await this.followingsRepository.update( + { followerId: exist.id }, + { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null }, + ); + + await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); + + const updated = { ...exist, ...updates }; + + this.cacheService.uriPersonCache.set(uri, updated); + + // 移行処理を行う + if (updated.movedAt && ( + // 初めて移行する場合はmovedAtがnullなので移行処理を許可 + exist.movedAt == null || + // 以前のmovingから14日以上経過した場合のみ移行処理を許可 + // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) + exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() + )) { + this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); + return this.processRemoteMove(updated, movePreventUris) + .then(result => { + this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`); + return result; + }) + .catch(e => { + this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e }); + }); } + return 'skip'; +} + /** * Personを解決します。 * diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 371983925dc..adf5e02ef26 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -487,16 +487,16 @@ export class UserEntityService implements OnModuleInit { name: user.name, username: user.username, host: user.host, - avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user), - avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash), - avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ - id: ud.id, - angle: ud.angle || undefined, - flipH: ud.flipH || undefined, - offsetX: ud.offsetX || undefined, - offsetY: ud.offsetY || undefined, - url: decorations.find(d => d.id === ud.id)!.url, - }))) : [], + avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user), + avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash), + avatarDecorations: user.avatarDecorations && user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll(false, true).then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ + id: ud.id, + angle: ud.angle || undefined, + flipH: ud.flipH || undefined, + offsetX: ud.offsetX || undefined, + offsetY: ud.offsetY || undefined, + url: decorations.find(d => d.id === ud.id)!.url, + }))) : [], isBot: user.isBot, isCat: user.isCat, requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true, diff --git a/packages/backend/src/models/AvatarDecoration.ts b/packages/backend/src/models/AvatarDecoration.ts index 13f0b056674..ccfcb91f5a0 100644 --- a/packages/backend/src/models/AvatarDecoration.ts +++ b/packages/backend/src/models/AvatarDecoration.ts @@ -36,4 +36,14 @@ export class MiAvatarDecoration { array: true, length: 128, default: '{}', }) public roleIdsThatCanBeUsedThisDecoration: string[]; + + @Column('varchar', { + length: 32, nullable: true, + }) + public remoteId: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public host: string | null; } diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 3c9984864d5..48a4128bda4 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only