Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,7 @@ detachAll: "全て外す"
angle: "角度"
flip: "反転"
showAvatarDecorations: "アイコンのデコレーションを表示"
showRemoteAvatarDecorations: "リモートユーザーのアイコンデコレーションを表示"
releaseToRefresh: "離してリロード"
refreshing: "リロード中"
pullDownToRefresh: "引っ張ってリロード"
Expand Down
1 change: 1 addition & 0 deletions locales/ko-KR.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,7 @@ detachAll: "모두 빼기"
angle: "각도"
flip: "플립"
showAvatarDecorations: "아바타 장식 표시"
showRemoteAvatarDecorations: "리모트 유저의 아바타 장식 표시"
releaseToRefresh: "놓아서 새로고침"
refreshing: "새로고침 중"
pullDownToRefresh: "아래로 내려서 새로고침"
Expand Down
Original file line number Diff line number Diff line change
@@ -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"`);
}
}

127 changes: 124 additions & 3 deletions packages/backend/src/core/AvatarDecorationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,48 @@

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';
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<MiAvatarDecoration[]>;
public cacheWithRemote: MemorySingleCache<MiAvatarDecoration[]>;

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<MiAvatarDecoration[]>(1000 * 60 * 30); // 30s
this.cacheWithRemote = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30);

this.redisForSub.on('message', this.onMessage);
}
Expand Down Expand Up @@ -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<UserDetailedNotMe> | undefined;
const userAvatarDecorations = userData?.avatarDecorations;

if (!userAvatarDecorations || userAvatarDecorations.length === 0) {
const updates = {} as Partial<MiUser>;
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<MiAvatarDecoration[]> | undefined) ?? [];
const updates = {} as Partial<MiUser>;
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<void> {
const avatarDecoration = await this.avatarDecorationsRepository.findOneByOrFail({ id });
Expand All @@ -110,11 +226,16 @@ export class AvatarDecorationService implements OnApplicationShutdown {
}

@bindThis
public async getAll(noCache = false): Promise<MiAvatarDecoration[]> {
public async getAll(noCache = false, withRemote = false): Promise<MiAvatarDecoration[]> {
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
Expand Down
142 changes: 76 additions & 66 deletions packages/backend/src/core/activitypub/models/ApPersonService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -104,6 +105,7 @@ export class ApPersonService implements OnModuleInit {
private followingsRepository: FollowingsRepository,

private roleService: RoleService,
private avatarDecorationService: AvatarDecorationService,
) {
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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を解決します。
*
Expand Down
Loading