diff --git a/CHANGELOG.md b/CHANGELOG.md index b996216ac174..3d0f346342f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 翻訳の更新 - 依存関係の更新 + ### Client - Feat: ユーザーページから「このユーザーのノートを検索」できるように (#14128) - Feat: 検索ページはクエリを受け付けるようになりました (#14128) @@ -280,6 +281,58 @@ - `category`および`licence`が指定なしの時勝手にnullに上書きされる挙動を修正 - Fix: 通知の受信設定で「相互フォロー」が正しく動作しない問題を修正 +## 2024.3.1 + +### General +- + +### Client +- Fix: 絵文字関係の不具合を修正 (#13485) + - 履歴に残っている or ピン留めされた絵文字がコントロールパネルより削除されていた際にリアクションデッキが表示できなくなる + - Unicode絵文字が履歴に残っている or ピン留めされているとリアクションデッキが表示できなくなる +- Fix: カスタム絵文字の画像読み込みに失敗した際はテキストではなくダミー画像を表示 #13487 + +### Server +- + +## 2024.3.0 + +### General +- Enhance: 投稿者のロールに応じて、一つのノートに含むことのできるメンションとダイレクト投稿の宛先の人数に上限を設定できるように + * デフォルトのメンション上限は20アカウントに設定されます。(管理者はベースロールの設定で変更可能です。) + * 連合の問い合わせに応答しないサーバーのリモートユーザーへのメンションは、上限の人数に含めない実装になっています。 +- Enhance: 通知がミュート、凍結を考慮するようになりました +- Enhance: サーバーごとにモデレーションノートを残せるように +- Enhance: コンディショナルロールの条件に「マニュアルロールへのアサイン」を追加 +- Enhance: 通知の受信設定に「フォロー中またはフォロワー」を追加 +- Enhance: 通知の履歴をリセットできるように +- Fix: ダイレクトなノートに対してはダイレクトでしか返信できないように + +### Client +- Enhance: ノート作成画面のファイル添付メニューの区切り線の位置を調整 +- Fix: syuilo/misskeyの時代からあるインスタンスが改変されたバージョンであると誤認識される問題 +- Fix: MFMのオートコンプリートが出るべき状況で出ないことがある問題を修正 +- Fix: チャートのラベルが消えている問題を修正 +- Fix: 画面表示後最初の音声再生が爆音になることがある問題を修正 +- Fix: 設定のバックアップ作成時に名前を入力しなかった場合、ローカライゼーションがおかしくなる問題を修正 +- Fix: ページ`/admin/emojis`の絵文字編集ダイアログで「リアクションとして使えるロール」を追加する際に何も選択せずOKを押下すると画面が固まる問題を修正 +- Fix: 絵文字サジェストの順位で、絵文字自体の名前が同じものよりもタグで一致しているものが優先されてしまう問題を修正 +- Fix: ユーザの情報のポップアップが消えなくなることがある問題を修正 + +### Server +- Enhance: エンドポイント`flash/update`の`flashId`以外のパラメータは必須ではなくなりました +- Fix: nodeinfoにenableMcaptchaとenableTurnstileが無いのを修正 +- Fix: 破損した通知をクライアントに送信しないように + * 通知欄が無限にリロードされる問題が改善する可能性があります +- Fix: 禁止キーワードを含むノートがDelayed Queueに追加されて再処理される問題を修正 +- Fix: 自分がフォローしていないアカウントのフォロワー限定ノートが閲覧できることがある問題を修正 +- Fix: タイムラインのオプションで「リノートを表示」を無効にしている際、投票のみの引用リノートが流れてこない問題を修正 +- Fix: エンドポイント`admin/emoji/update`の各種修正 + - 必須パラメータを`id`または`name`のいずれかのみに + - `id`の代わりに`name`で絵文字を指定可能に(`id`・`name`両指定時は従来通り`name`を変更する挙動) + - `category`および`licence`が指定なしの時勝手にnullに上書きされる挙動を修正 +- Fix: 通知の受信設定で「相互フォロー」が正しく動作しない問題を修正 + ## 2024.2.0 ### Note diff --git a/packages/backend/migration/1699432324194-remoteAvaterDecoration.js b/packages/backend/migration/1699432324194-remoteAvaterDecoration.js new file mode 100644 index 000000000000..5b2762b47642 --- /dev/null +++ b/packages/backend/migration/1699432324194-remoteAvaterDecoration.js @@ -0,0 +1,13 @@ +export class RemoteAvaterDecoration1699432324194 { + name = 'RemoteAvaterDecoration1699432324194' + + async up(queryRunner) { + queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "remoteId" varchar(32)`); + queryRunner.query(`ALTER TABLE "avatar_decoration" ADD "host" varchar(128)`); + } + + async down(queryRunner) { + queryRunner.query(`ALTER TABLE "avatar_decoration" DROP COLUMN "host"`); + 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 8b54bbe01241..0f3cdc28c688 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { AvatarDecorationsRepository, MiAvatarDecoration, MiUser } from '@/models/_.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 +13,39 @@ 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'; +import {IsNull} from "typeorm"; @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); + this.cacheWithRemote = new MemorySingleCache(1000 * 60 * 30); this.redisForSub.on('message', this.onMessage); } @@ -94,6 +110,99 @@ 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 }), + }); + + const userData: any = await res.json(); + const userAvatarDecorations = userData.avatarDecorations ?? undefined; + + 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 allRes = await this.httpRequestService.send(decorationApiUrl, { + method: 'POST', + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({}), + }); + const allDecorations: any = await allRes.json(); + const updates = {} as Partial; + updates.avatarDecorations = []; + for (const avatarDecoration of userAvatarDecorations) { + let name; + let description; + const avatarDecorationId = avatarDecoration.id + for (const decoration of allDecorations) { + if (decoration.id == avatarDecorationId) { + name = decoration.name; + description = decoration.description; + break; + } + } + const existingDecoration = await this.avatarDecorationsRepository.findOneBy({ + host: userHost, + remoteId: avatarDecorationId + }); + const decorationData = { + name: name, + description: description, + url: this.getProxiedUrl(avatarDecoration.url, 'static'), + remoteId: avatarDecorationId, + 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: avatarDecorationId + }); + + updates.avatarDecorations.push({ + id: findDecoration?.id ?? '', + angle: avatarDecoration.angle ?? 0, + flipH: avatarDecoration.flipH ?? false, + offsetX: avatarDecoration.offsetX ?? 0, + offsetY: avatarDecoration.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 +219,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/RelayService.ts b/packages/backend/src/core/RelayService.ts index 8dd3d64f5b29..cbc53b5daa66 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -110,13 +110,18 @@ export class RelayService { return JSON.stringify(result); } + @bindThis + public async getAcceptedRelays(): Promise { + return this.relaysCache.fetch(() => this.relaysRepository.findBy({ + status: 'accepted', + })); + } + @bindThis public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any): Promise { if (activity == null) return; - const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ - status: 'accepted', - })); + const relays = await this.getAcceptedRelays(); if (relays.length === 0) return; const copy = deepClone(activity); diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index d594a223f4e2..692763f4dd07 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Not, IsNull } from 'typeorm'; -import type { FollowingsRepository } from '@/models/_.js'; +import type { UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; @@ -17,9 +17,8 @@ import { bindThis } from '@/decorators.js'; @Injectable() export class UserSuspendService { constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, private userEntityService: UserEntityService, private queueService: QueueService, private globalEventService: GlobalEventService, @@ -37,18 +36,14 @@ export class UserSuspendService { const queue: string[] = []; - const followings = await this.followingsRepository.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + const inboxes = await this.usersRepository.createQueryBuilder('user') + .select('user.sharedInbox') + .distinctOn(['user.sharedInbox']) + .where('user.sharedInbox IS NOT NULL') + .getMany(); for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + if (inbox.sharedInbox != null) queue.push(inbox.sharedInbox); } for (const inbox of queue) { @@ -67,18 +62,14 @@ export class UserSuspendService { const queue: string[] = []; - const followings = await this.followingsRepository.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + const inboxes = await this.usersRepository.createQueryBuilder('user') + .select('user.sharedInbox') + .distinctOn(['user.sharedInbox']) + .where('user.sharedInbox IS NOT NULL') + .getMany(); for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + if (inbox.sharedInbox != null) queue.push(inbox.sharedInbox); } for (const inbox of queue) { diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e2164fec1d93..2c7be7bb204f 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -293,12 +293,17 @@ export class ApInboxService { const meta = await this.metaService.fetch(); if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) return; + const relays = await this.relayService.getAcceptedRelays(); + const fromRelay = !!actor.inbox && relays.map(r => r.inbox).includes(actor.inbox); + const targetUri = getApId(activity.object); + const unlock = await this.appLockService.getApLock(uri); try { // 既に同じURIを持つものが登録されていないかチェック - const exist = await this.apNoteService.fetchNote(uri); + const exist = await this.apNoteService.fetchNote(fromRelay ? targetUri : uri); if (exist) { + this.logger.info(`Skip existing Note announce (fromRelay: ${fromRelay}, uri:${ fromRelay ? targetUri : uri})`); return; } @@ -319,7 +324,14 @@ export class ApInboxService { } if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { - return 'skip: invalid actor for this activity'; + this.logger.warn('skip: invalid actor for this activity'); + return; + } + + if (fromRelay) { + const noteObj = await this.noteEntityService.pack(renote, null, { skipHide: true }); + this.globalEventService.publishNotesStream(noteObj); + return; } this.logger.info(`Creating the (Re)Note: ${uri}`); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 457205e0238e..2bf513c64388 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -49,6 +49,7 @@ import type { ApLoggerService } from '../ApLoggerService.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { ApImageService } from './ApImageService.js'; import type { IActor, IObject } from '../type.js'; +import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; const nameLength = 128; const summaryLength = 2048; @@ -403,6 +404,8 @@ export class ApPersonService implements OnModuleInit { // ハッシュタグ更新 this.hashtagService.updateUsertags(user, tags); + this.avatarDecorationService.remoteUserUpdate(user); + //#region アバターとヘッダー画像をフェッチ try { const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image); @@ -511,6 +514,8 @@ export class ApPersonService implements OnModuleInit { if (moving) updates.movedAt = new Date(); // Update user + const user = await this.usersRepository.findOneByOrFail({ id: exist.id }); + await this.avatarDecorationService.remoteUserUpdate(user); await this.usersRepository.update(exist.id, updates); if (person.publicKey) { diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 7fd093c1913a..591f4a1377b4 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -480,7 +480,7 @@ export class UserEntityService implements OnModuleInit { host: user.host, avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), avatarBlurhash: 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 => ({ + 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, diff --git a/packages/backend/src/models/AvatarDecoration.ts b/packages/backend/src/models/AvatarDecoration.ts index 13f0b0566740..de9ef0a99ae2 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, + }) + public remoteId: string; + + @Column('varchar', { + length: 128, nullable: true + }) + public host: string | null; } diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 7bd74f3210f8..f94ef70c56a4 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -227,6 +227,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + if (config.sentryForBackend) { Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, { level: 'error', diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 244d117de231..75f19675ce5b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -118,7 +118,7 @@ "eslint-plugin-import": "2.29.1", "eslint-plugin-vue": "9.27.0", "fast-glob": "3.3.2", - "happy-dom": "10.0.3", + "happy-dom": "13.6.2", "intersection-observer": "0.12.2", "micromatch": "4.0.7", "msw": "2.3.4", diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 2feb79ef810f..e05fce63c6b4 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -36,13 +36,18 @@ background-color: var(--accent); } +@import url('https://cdn.jsdelivr.net/gh/sun-typeface/SUITE/fonts/static/woff2/SUITE.css'); + +@import url('https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic&display=swap'); + + html { background-color: var(--bg); color: var(--fg); accent-color: var(--accent); overflow: auto; overflow-wrap: break-word; - font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; + font-family: 'SUITE', 'Zen Maru Gothic', 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; font-size: 14px; line-height: 1.35; text-size-adjust: 100%;