diff --git a/CHANGELOG.md b/CHANGELOG.md index bade07c9d3..da204c9198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,26 @@ +## Unreleased + +### General +- + +### Client +- + +### Server +- + + ## 2024.8.0 ### General - Enhance: モデレーターはすべてのユーザーのフォロー・フォロワーの一覧を見られるように - Enhance: アカウントの削除のモデレーションログを残すように +- Enhance: 不適切なページ、ギャラリー、Playを管理者権限で削除できるように - Fix: リモートユーザのフォロー・フォロワーの一覧が非公開設定の場合も表示できてしまう問題を修正 ### Client - Enhance: 「自分のPlay」ページにおいてPlayが非公開かどうかが一目でわかるように +- Enhance: 不適切なページ、ギャラリー、Playを通報できるように - Fix: Play編集時に公開範囲が「パブリック」にリセットされる問題を修正 - Fix: ページ遷移に失敗することがある問題を修正 - Fix: iOSでユーザー名などがリンクとして誤検知される現象を抑制 @@ -15,6 +29,7 @@ - Fix: 特定の条件下でノートの削除ボタンが出ないのを修正 ### Server +- Enhance: 照会時にURLがhtmlかつheadタグ内に`rel="alternate"`, `type="application/activity+json"`の`link`タグがある場合に追ってリンク先を照会できるように - Enhance: 凍結されたアカウントのフォローリクエストを表示しないように - Fix: WSの`readAllNotifications` メッセージが `body` を持たない場合に動作しない問題 #14374 - 通知ページや通知カラム(デッキ)を開いている状態において、新たに発生した通知が既読されない問題が修正されます。 @@ -30,6 +45,8 @@ - Fix: 無制限にストリーミングのチャンネルに接続できる問題を修正 - Fix: ベースロールのポリシーを変更した際にモデログに記録されないのを修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/700) +- Fix: Prevent memory leak from memory caches (#14310) +- Fix: More reliable memory cache eviction (#14311) ## 2024.7.0 diff --git a/locales/en-US.yml b/locales/en-US.yml index 32356ab4e5..c82ea3c9a2 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -2316,6 +2316,7 @@ _pages: eyeCatchingImageSet: "Set thumbnail" eyeCatchingImageRemove: "Delete thumbnail" chooseBlock: "Add a block" + enterSectionTitle: "Enter a section title" selectType: "Select a type" contentBlocks: "Content" inputBlocks: "Input" @@ -2499,6 +2500,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "Create a recipient for abuse reports" updateAbuseReportNotificationRecipient: "Update recipients for abuse reports" deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports" + deleteAccount: "Delete the account" + deletePage: "Delete the page" + deleteFlash: "Delete Play" + deleteGalleryPost: "Delete the gallery post" _fileViewer: title: "File details" type: "File type" diff --git a/locales/index.d.ts b/locales/index.d.ts index 2ab5f5797e..75e1703b4a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2829,7 +2829,7 @@ export interface Locale extends ILocale { */ "reportAbuseOf": ParameterizedString<"name">; /** - * 通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。 + * 通報理由の詳細を記入してください。対象のノートやページなどがある場合はそのURLも記入してください。 */ "fillAbuseReportDescription": string; /** @@ -9687,6 +9687,18 @@ export interface Locale extends ILocale { * アカウントを削除 */ "deleteAccount": string; + /** + * ページを削除 + */ + "deletePage": string; + /** + * Playを削除 + */ + "deleteFlash": string; + /** + * ギャラリーの投稿を削除 + */ + "deleteGalleryPost": string; }; "_fileViewer": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index fac5b1e533..98e3cbfa41 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -703,7 +703,7 @@ abuseReports: "通報" reportAbuse: "通報" reportAbuseRenote: "リノートを通報" reportAbuseOf: "{name}を通報する" -fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートがある場合はそのURLも記入してください。" +fillAbuseReportDescription: "通報理由の詳細を記入してください。対象のノートやページなどがある場合はそのURLも記入してください。" abuseReported: "内容が送信されました。ご報告ありがとうございました。" reporter: "通報者" reporteeOrigin: "通報先" @@ -2569,6 +2569,9 @@ _moderationLogTypes: updateAbuseReportNotificationRecipient: "通報の通知先を更新" deleteAbuseReportNotificationRecipient: "通報の通知先を削除" deleteAccount: "アカウントを削除" + deletePage: "ページを削除" + deleteFlash: "Playを削除" + deleteGalleryPost: "ギャラリーの投稿を削除" _fileViewer: title: "ファイルの詳細" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 7b68a5cfdb..d422c3afc5 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -2316,6 +2316,7 @@ _pages: eyeCatchingImageSet: "设置封面图片" eyeCatchingImageRemove: "删除封面图片" chooseBlock: "添加块" + enterSectionTitle: "输入会话标题" selectType: "选择类型" contentBlocks: "内容" inputBlocks: "输入" @@ -2499,6 +2500,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "新建了举报通知" updateAbuseReportNotificationRecipient: "更新了举报通知" deleteAbuseReportNotificationRecipient: "删除了举报通知" + deleteAccount: "删除了账户" + deletePage: "删除了页面" + deleteFlash: "删除了 Play" + deleteGalleryPost: "删除了图库稿件" _fileViewer: title: "文件信息" type: "文件类型" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 3f66818669..57da480d28 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -2316,6 +2316,7 @@ _pages: eyeCatchingImageSet: "設定封面影像" eyeCatchingImageRemove: "刪除封面影像" chooseBlock: "新增方塊" + enterSectionTitle: "輸入區段的標題" selectType: "選擇類型" contentBlocks: "內容" inputBlocks: "輸入" @@ -2499,6 +2500,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "建立接收檢舉的通知對象" updateAbuseReportNotificationRecipient: "更新接收檢舉的通知對象" deleteAbuseReportNotificationRecipient: "刪除接收檢舉的通知對象" + deleteAccount: "刪除帳戶" + deletePage: "刪除頁面" + deleteFlash: "刪除 Play" + deleteGalleryPost: "刪除相簿的貼文" _fileViewer: title: "檔案詳細資訊" type: "檔案類型 " diff --git a/package.json b/package.json index ccc83f91c3..310ea98214 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2024.8.0-beta.2", + "version": "2024.8.0", "codename": "nasubi", "repository": { "type": "git", diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 8b54bbe012..4efd6122b1 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -29,7 +29,7 @@ export class AvatarDecorationService implements OnApplicationShutdown { private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, ) { - this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); + this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); // 30s this.redisForSub.on('message', this.onMessage); } diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index d008e7ec52..6725ebe75b 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -56,10 +56,10 @@ export class CacheService implements OnApplicationShutdown { ) { //this.onMessage = this.onMessage.bind(this); - this.userByIdCache = new MemoryKVCache<MiUser>(Infinity); - this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(Infinity); - this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(Infinity); - this.uriPersonCache = new MemoryKVCache<MiUser | null>(Infinity); + this.userByIdCache = new MemoryKVCache<MiUser>(1000 * 60 * 5); // 5m + this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(1000 * 60 * 5); // 5m + this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m + this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', { lifetime: 1000 * 60 * 30, // 30m @@ -135,14 +135,14 @@ export class CacheService implements OnApplicationShutdown { if (user == null) { this.userByIdCache.delete(body.id); this.localUserByIdCache.delete(body.id); - for (const [k, v] of this.uriPersonCache.cache.entries()) { + for (const [k, v] of this.uriPersonCache.entries) { if (v.value?.id === body.id) { this.uriPersonCache.delete(k); } } } else { this.userByIdCache.set(user.id, user); - for (const [k, v] of this.uriPersonCache.cache.entries()) { + for (const [k, v] of this.uriPersonCache.entries) { if (v.value?.id === user.id) { this.uriPersonCache.set(k, user); } diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 7e11b9cdca..5db3c5b980 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -24,7 +24,7 @@ const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; @Injectable() export class CustomEmojiService implements OnApplicationShutdown { - private cache: MemoryKVCache<MiEmoji | null>; + private emojisCache: MemoryKVCache<MiEmoji | null>; public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>; constructor( @@ -40,7 +40,7 @@ export class CustomEmojiService implements OnApplicationShutdown { private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, ) { - this.cache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); + this.emojisCache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); // 12h this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', { lifetime: 1000 * 60 * 30, // 30m @@ -334,7 +334,7 @@ export class CustomEmojiService implements OnApplicationShutdown { host, })) ?? null; - const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); + const emoji = await this.emojisCache.fetch(`${name} ${host}`, queryOrNull); if (emoji == null) return null; return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) @@ -361,7 +361,7 @@ export class CustomEmojiService implements OnApplicationShutdown { */ @bindThis public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { - const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null); + const notCachedEmojis = emojis.filter(emoji => this.emojisCache.get(`${emoji.name} ${emoji.host}`) == null); const emojisQuery: any[] = []; const hosts = new Set(notCachedEmojis.map(e => e.host)); for (const host of hosts) { @@ -376,7 +376,7 @@ export class CustomEmojiService implements OnApplicationShutdown { select: ['name', 'host', 'originalUrl', 'publicUrl'], }) : []; for (const emoji of _emojis) { - this.cache.set(`${emoji.name} ${emoji.host}`, emoji); + this.emojisCache.set(`${emoji.name} ${emoji.host}`, emoji); } } @@ -401,7 +401,7 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public dispose(): void { - this.cache.dispose(); + this.emojisCache.dispose(); } @bindThis diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index ad01f98902..cb30756f9d 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -38,7 +38,7 @@ export class RelayService { private apRendererService: ApRendererService, private userKeypairService: UserKeypairService, ) { - this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); + this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); // 10m } @bindThis diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 7966774673..0210012a03 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -127,10 +127,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private moderationLogService: ModerationLogService, private fanoutTimelineService: FanoutTimelineService, ) { - //this.onMessage = this.onMessage.bind(this); - - this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1); - this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1); + this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h + this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m this.redisForSub.on('message', this.onMessage); } diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts index aa90f1e209..fac0e19b3f 100644 --- a/packages/backend/src/core/UserKeypairService.ts +++ b/packages/backend/src/core/UserKeypairService.ts @@ -34,7 +34,7 @@ export class UserKeypairService implements OnApplicationShutdown { ) { this.keypairEntityCache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', { lifetime: 1000 * 60 * 60 * 24, // 24h - memoryCacheLifetime: Infinity, + memoryCacheLifetime: 1000 * 60 * 60, // 1h fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), toRedisConverter: (value) => JSON.stringify(value), fromRedisConverter: (value) => JSON.parse(value), diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index b822f86185..79a8fcc5d1 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -64,7 +64,7 @@ export class ApDbResolverService implements OnApplicationShutdown { private apLoggerService: ApLoggerService, private utilityService: UtilityService, ) { - this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey[] | null>(Infinity); + this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey[] | null>(1000 * 60 * 60 * 12); // 12h this.logger = this.apLoggerService.logger.createSubLogger('db-resolver'); this.redisForSub.on('message', this.onMessage); } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 0cae91316b..323e9fd6ea 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -6,6 +6,7 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import { genRFC3230DigestHeader, signAsDraftToRequest } from '@misskey-dev/node-http-message-signatures'; +import { Window } from 'happy-dom'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; @@ -126,7 +127,9 @@ export class ApRequestService { * @param url URL to fetch */ @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }, level: string): Promise<unknown> { + public async signedGet(url: string, user: { id: MiUser['id'] }, level: string, followAlternate?: boolean): Promise<unknown> { + const _followAlternate = followAlternate ?? true; + const key = await this.userKeypairService.getLocalUserPrivateKey(user.id, level); const req = await createSignedGet({ level, @@ -152,9 +155,29 @@ export class ApRequestService { headers: req.request.headers, }, { throwErrorWhenResponseNotOk: true, - validators: [validateContentTypeSetAsActivityPub], }); + //#region リクエスト先がhtmlかつactivity+jsonへのalternate linkタグがあるとき + const contentType = res.headers.get('content-type'); + + if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) { + const html = await res.text(); + const window = new Window(); + const document = window.document; + document.documentElement.innerHTML = html; + + const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); + if (alternate) { + const href = alternate.getAttribute('href'); + if (href) { + return await this.signedGet(href, user, level, false); + } + } + } + //#endregion + + validateContentTypeSetAsActivityPub(res); + return await res.json(); } } diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f498c110bf..4fa3693995 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -7,23 +7,23 @@ import * as Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; export class RedisKVCache<T> { - private redisClient: Redis.Redis; - private name: string; - private lifetime: number; - private memoryCache: MemoryKVCache<T>; - private fetcher: (key: string) => Promise<T>; - private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; + private readonly lifetime: number; + private readonly memoryCache: MemoryKVCache<T>; + private readonly fetcher: (key: string) => Promise<T>; + private readonly toRedisConverter: (value: T) => string; + private readonly fromRedisConverter: (value: string) => T | undefined; - constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: { - lifetime: RedisKVCache<T>['lifetime']; - memoryCacheLifetime: number; - fetcher: RedisKVCache<T>['fetcher']; - toRedisConverter: RedisKVCache<T>['toRedisConverter']; - fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; - }) { - this.redisClient = redisClient; - this.name = name; + constructor( + private redisClient: Redis.Redis, + private name: string, + opts: { + lifetime: RedisKVCache<T>['lifetime']; + memoryCacheLifetime: number; + fetcher: RedisKVCache<T>['fetcher']; + toRedisConverter: RedisKVCache<T>['toRedisConverter']; + fromRedisConverter: RedisKVCache<T>['fromRedisConverter']; + }, + ) { this.lifetime = opts.lifetime; this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); this.fetcher = opts.fetcher; @@ -55,7 +55,13 @@ export class RedisKVCache<T> { const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); if (cached == null) return undefined; - return this.fromRedisConverter(cached); + + const value = this.fromRedisConverter(cached); + if (value !== undefined) { + this.memoryCache.set(key, value); + } + + return value; } @bindThis @@ -66,6 +72,10 @@ export class RedisKVCache<T> { /** * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons: + * * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster. + * * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value. + * * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses. */ @bindThis public async fetch(key: string): Promise<T> { @@ -77,14 +87,14 @@ export class RedisKVCache<T> { // Cache MISS const value = await this.fetcher(key); - this.set(key, value); + await this.set(key, value); return value; } @bindThis public async refresh(key: string) { const value = await this.fetcher(key); - this.set(key, value); + await this.set(key, value); // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする } @@ -101,23 +111,23 @@ export class RedisKVCache<T> { } export class RedisSingleCache<T> { - private redisClient: Redis.Redis; - private name: string; - private lifetime: number; - private memoryCache: MemorySingleCache<T>; - private fetcher: () => Promise<T>; - private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; + private readonly lifetime: number; + private readonly memoryCache: MemorySingleCache<T>; + private readonly fetcher: () => Promise<T>; + private readonly toRedisConverter: (value: T) => string; + private readonly fromRedisConverter: (value: string) => T | undefined; - constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: { - lifetime: RedisSingleCache<T>['lifetime']; - memoryCacheLifetime: number; - fetcher: RedisSingleCache<T>['fetcher']; - toRedisConverter: RedisSingleCache<T>['toRedisConverter']; - fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; - }) { - this.redisClient = redisClient; - this.name = name; + constructor( + private redisClient: Redis.Redis, + private name: string, + opts: { + lifetime: number; + memoryCacheLifetime: number; + fetcher: RedisSingleCache<T>['fetcher']; + toRedisConverter: RedisSingleCache<T>['toRedisConverter']; + fromRedisConverter: RedisSingleCache<T>['fromRedisConverter']; + }, + ) { this.lifetime = opts.lifetime; this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); this.fetcher = opts.fetcher; @@ -149,7 +159,18 @@ export class RedisSingleCache<T> { const cached = await this.redisClient.get(`singlecache:${this.name}`); if (cached == null) return undefined; - return this.fromRedisConverter(cached); + + const value = this.fromRedisConverter(cached); + if (value !== undefined) { + this.memoryCache.set(value); + } + + return value; + } + + @bindThis + public getRaw(): T | undefined { + return this.memoryCache.get(); } @bindThis @@ -160,6 +181,10 @@ export class RedisSingleCache<T> { /** * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons: + * * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster. + * * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value. + * * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses. */ @bindThis public async fetch(): Promise<T> { @@ -171,14 +196,14 @@ export class RedisSingleCache<T> { // Cache MISS const value = await this.fetcher(); - this.set(value); + await this.set(value); return value; } @bindThis public async refresh() { const value = await this.fetcher(); - this.set(value); + await this.set(value); // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする } @@ -187,25 +212,12 @@ export class RedisSingleCache<T> { // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? export class MemoryKVCache<T> { - /** - * データを持つマップ - * @deprecated これを直接操作するべきではない - */ - public cache: Map<string, { date: number; value: T; }>; - private lifetime: number; - private gcIntervalHandle: NodeJS.Timeout; + private readonly cache = new Map<string, { date: number; value: T; }>(); + private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m - /** - * @param lifetime キャッシュの生存期間 (ms) - */ - constructor(lifetime: MemoryKVCache<never>['lifetime']) { - this.cache = new Map(); - this.lifetime = lifetime; - - this.gcIntervalHandle = setInterval(() => { - this.gc(); - }, 1000 * 60 * 3); - } + constructor( + private readonly lifetime: number, + ) {} @bindThis /** @@ -290,10 +302,14 @@ export class MemoryKVCache<T> { @bindThis public gc(): void { const now = Date.now(); + for (const [key, { date }] of this.cache.entries()) { - if ((now - date) > this.lifetime) { - this.cache.delete(key); - } + // The map is ordered from oldest to youngest. + // We can stop once we find an entry that's still active, because all following entries must *also* be active. + const age = now - date; + if (age < this.lifetime) break; + + this.cache.delete(key); } } @@ -301,16 +317,19 @@ export class MemoryKVCache<T> { public dispose(): void { clearInterval(this.gcIntervalHandle); } + + public get entries() { + return this.cache.entries(); + } } export class MemorySingleCache<T> { private cachedAt: number | null = null; private value: T | undefined; - private lifetime: number; - constructor(lifetime: MemorySingleCache<never>['lifetime']) { - this.lifetime = lifetime; - } + constructor( + private lifetime: number, + ) {} @bindThis public set(value: T): void { diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 2ca1e5497c..0c2d49fdb7 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -45,7 +45,7 @@ export class DeliverProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); - this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); + this.suspendedHostsCache = new MemorySingleCache<MiInstance[]>(1000 * 60 * 60); // 1h } @bindThis diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index c0f8084768..f75cee8d85 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -141,7 +141,7 @@ export class NodeinfoServerService { return document; }; - const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); + const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10); // 10m fastify.get(nodeinfo2_1path, async (request, reply) => { const base = await cache.fetch(() => nodeinfo2(21)); diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index ddef8db987..690ff2e022 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -37,7 +37,7 @@ export class AuthenticateService implements OnApplicationShutdown { private cacheService: CacheService, ) { - this.appCache = new MemoryKVCache<MiApp>(Infinity); + this.appCache = new MemoryKVCache<MiApp>(1000 * 60 * 60 * 24 * 7); // 1w } @bindThis diff --git a/packages/backend/src/server/api/endpoints/flash/delete.ts b/packages/backend/src/server/api/endpoints/flash/delete.ts index d3d47e5deb..6912450abf 100644 --- a/packages/backend/src/server/api/endpoints/flash/delete.ts +++ b/packages/backend/src/server/api/endpoints/flash/delete.ts @@ -4,9 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { FlashsRepository } from '@/models/_.js'; +import type { FlashsRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -44,17 +46,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.flashsRepository) private flashsRepository: FlashsRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private moderationLogService: ModerationLogService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); + if (flash == null) { throw new ApiError(meta.errors.noSuchFlash); } - if (flash.userId !== me.id) { + + if (!await this.roleService.isModerator(me) && flash.userId !== me.id) { throw new ApiError(meta.errors.accessDenied); } await this.flashsRepository.delete(flash.id); + + if (flash.userId !== me.id) { + const user = await this.usersRepository.findOneByOrFail({ id: flash.userId }); + this.moderationLogService.log(me, 'deleteFlash', { + flashId: flash.id, + flashUserId: flash.userId, + flashUserUsername: user.username, + flash, + }); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts index 527e3fb52d..b6b94db161 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -5,8 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { GalleryPostsRepository } from '@/models/_.js'; +import type { GalleryPostsRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -22,6 +24,12 @@ export const meta = { code: 'NO_SUCH_POST', id: 'ae52f367-4bd7-4ecd-afc6-5672fff427f5', }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: 'c86e09de-1c48-43ac-a435-1c7e42ed4496', + }, }, } as const; @@ -38,18 +46,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.galleryPostsRepository) private galleryPostsRepository: GalleryPostsRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private moderationLogService: ModerationLogService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const post = await this.galleryPostsRepository.findOneBy({ - id: ps.postId, - userId: me.id, - }); + const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId }); if (post == null) { throw new ApiError(meta.errors.noSuchPost); } + if (!await this.roleService.isModerator(me) && post.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + await this.galleryPostsRepository.delete(post.id); + + if (post.userId !== me.id) { + const user = await this.usersRepository.findOneByOrFail({ id: post.userId }); + this.moderationLogService.log(me, 'deleteGalleryPost', { + postId: post.id, + postUserId: post.userId, + postUserUsername: user.username, + post, + }); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts index aa2ba75a41..f2bc946788 100644 --- a/packages/backend/src/server/api/endpoints/pages/delete.ts +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -4,9 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository } from '@/models/_.js'; +import type { PagesRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -44,17 +46,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- constructor( @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private moderationLogService: ModerationLogService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); + if (page == null) { throw new ApiError(meta.errors.noSuchPage); } - if (page.userId !== me.id) { + + if (!await this.roleService.isModerator(me) && page.userId !== me.id) { throw new ApiError(meta.errors.accessDenied); } await this.pagesRepository.delete(page.id); + + if (page.userId !== me.id) { + const user = await this.usersRepository.findOneByOrFail({ id: page.userId }); + this.moderationLogService.log(me, 'deletePage', { + pageId: page.id, + pageUserId: page.userId, + pageUserUsername: user.username, + page, + }); + } }); } } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index dbeb232926..e852cf5ae2 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -97,6 +97,9 @@ export const moderationLogTypes = [ 'updateAbuseReportNotificationRecipient', 'deleteAbuseReportNotificationRecipient', 'deleteAccount', + 'deletePage', + 'deleteFlash', + 'deleteGalleryPost', ] as const; export type ModerationLogPayloads = { @@ -320,6 +323,24 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + deletePage: { + pageId: string; + pageUserId: string; + pageUserUsername: string; + page: any; + }; + deleteFlash: { + flashId: string; + flashUserId: string; + flashUserUsername: string; + flash: any; + }; + deleteGalleryPost: { + postId: string; + postUserId: string; + postUserUsername: string; + post: any; + }; }; export type Serialized<T> = { diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index b09c7bb3fb..a634a748e9 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -39,7 +39,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - user: Misskey.entities.UserDetailed; + user: Misskey.entities.UserLite; initialComment?: string; }>(); diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index d950d66c6e..649dee2fdb 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.preview"> - <div :class="$style.preview__content1"> + <div> <MkInput v-model="text"> <template #label>Text</template> </MkInput> diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index c1b922198f..ce17ae08e0 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -4,25 +4,32 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA v-adaptive-bg :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }"> - <div :class="$style.title"> - <span :class="$style.icon"> - <template v-if="role.iconUrl"> - <img :class="$style.badge" :src="role.iconUrl"/> +<MkA :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }"> + <template v-if="forModeration"> + <i v-if="role.isPublic" class="ti ti-world" :class="$style.icon" style="color: var(--success)"></i> + <i v-else class="ti ti-lock" :class="$style.icon" style="color: var(--warn)"></i> + </template> + + <div v-adaptive-bg class="_panel" :class="$style.body"> + <div :class="$style.bodyTitle"> + <span :class="$style.bodyIcon"> + <template v-if="role.iconUrl"> + <img :class="$style.bodyBadge" :src="role.iconUrl"/> + </template> + <template v-else> + <i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i> + <i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i> + <i v-else class="ti ti-user" style="opacity: 0.7;"></i> + </template> + </span> + <span :class="$style.bodyName">{{ role.name }}</span> + <template v-if="detailed"> + <span v-if="role.target === 'manual'" :class="$style.bodyUsers">{{ role.usersCount }} users</span> + <span v-else-if="role.target === 'conditional'" :class="$style.bodyUsers">? users</span> </template> - <template v-else> - <i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i> - <i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i> - <i v-else class="ti ti-user" style="opacity: 0.7;"></i> - </template> - </span> - <span :class="$style.name">{{ role.name }}</span> - <template v-if="detailed"> - <span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span> - <span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span> - </template> + </div> + <div :class="$style.bodyDescription">{{ role.description }}</div> </div> - <div :class="$style.description">{{ role.description }}</div> </MkA> </template> @@ -42,34 +49,44 @@ const props = withDefaults(defineProps<{ <style lang="scss" module> .root { - display: block; - padding: 16px 20px; - border-left: solid 6px var(--color); -} - -.title { display: flex; + align-items: center; } .icon { + margin: 0 12px; +} + +.body { + display: block; + padding: 16px 20px; + flex: 1; + border-left: solid 6px var(--color); +} + +.bodyTitle { + display: flex; +} + +.bodyIcon { margin-right: 8px; } -.badge { +.bodyBadge { height: 1.3em; vertical-align: -20%; } -.name { +.bodyName { font-weight: bold; } -.users { +.bodyUsers { margin-left: auto; opacity: 0.7; } -.description { +.bodyDescription { opacity: 0.7; font-size: 85%; } diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 8cb082585b..bbcb070803 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -21,7 +21,7 @@ import { host as hostRaw } from '@/config.js'; import { defaultStore } from '@/store.js'; defineProps<{ - user: Misskey.entities.User; + user: Misskey.entities.UserLite; detail?: boolean; }>(); diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index 9b5b4a5000..a16c1eeacc 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -72,10 +72,6 @@ SPDX-License-Identifier: AGPL-3.0-only <img src="https://avatars.githubusercontent.com/u/4439005?v=4" :class="$style.contributorAvatar"> <span :class="$style.contributorUsername">@syuilo</span> </a> - <a href="https://github.com/tamaina" target="_blank" :class="$style.contributor"> - <img src="https://avatars.githubusercontent.com/u/7973572?v=4" :class="$style.contributorAvatar"> - <span :class="$style.contributorUsername">@tamaina</span> - </a> <a href="https://github.com/acid-chicken" target="_blank" :class="$style.contributor"> <img src="https://avatars.githubusercontent.com/u/20679825?v=4" :class="$style.contributorAvatar"> <span :class="$style.contributorUsername">@acid-chicken</span> @@ -267,6 +263,9 @@ const patronsWithIcon = [{ }, { name: 'Macop', icon: 'https://assets.misskey-hub.net/patrons/ee052bf550014d36a643ce3dce595640.jpg', +}, { + name: 'なっかあ', + icon: 'https://assets.misskey-hub.net/patrons/c2f5f3e394e74a64912284a2f4ca710e.jpg', }]; const patrons = [ diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index eb50d068d9..64d7f25845 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -37,6 +37,9 @@ SPDX-License-Identifier: AGPL-3.0-only 'deleteSystemWebhook', 'deleteAbuseReportNotificationRecipient', 'deleteAccount', + 'deletePage', + 'deleteFlash', + 'deleteGalleryPost', ].includes(log.type) }" >{{ i18n.ts._moderationLogTypes[log.type] }}</b> @@ -74,6 +77,9 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span> <span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span> <span v-else-if="log.type === 'deleteAccount'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> + <span v-else-if="log.type === 'deletePage'">: @{{ log.info.pageUserUsername }}</span> + <span v-else-if="log.type === 'deleteFlash'">: @{{ log.info.flashUserUsername }}</span> + <span v-else-if="log.type === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span> </template> <template #icon> <MkAvatar :user="log.user" :class="$style.avatar"/> diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index 020463a133..a6a99ba633 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton> <MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton> + <MkButton v-if="$i && $i.id !== flash.user.id" class="button" rounded @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></MkButton> </div> </div> </div> @@ -61,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onDeactivated, onUnmounted, Ref, ref, watch, shallowRef } from 'vue'; +import { computed, onDeactivated, onUnmounted, Ref, ref, watch, shallowRef, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import { Interpreter, Parser, values } from '@syuilo/aiscript'; import MkButton from '@/components/MkButton.vue'; @@ -79,6 +80,7 @@ import { defaultStore } from '@/store.js'; import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { MenuItem } from '@/types/menu'; import { pleaseLogin } from '@/scripts/please-login.js'; const props = defineProps<{ @@ -229,6 +231,53 @@ async function run() { } } +function reportAbuse() { + if (!flash.value) return; + + const pageUrl = `${url}/play/${flash.value.id}`; + + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: flash.value.user, + initialComment: `Play: ${pageUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); +} + +function showMenu(ev: MouseEvent) { + if (!flash.value) return; + + const menu: MenuItem[] = [ + ...($i && $i.id !== flash.value.userId ? [ + { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }, + ...($i.isModerator || $i.isAdmin ? [ + { + type: 'divider' as const, + }, + { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: () => os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }).then(({ canceled }) => { + if (canceled || !flash.value) return; + + os.apiWithDialog('flash/delete', { flashId: flash.value.id }); + }), + }, + ] : []), + ] : []), + ]; + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + function reset() { if (aiscript.value) aiscript.value.abort(); started.value = false; diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index fc2b5f810c..5a9c978dab 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -31,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> + <button v-if="$i && $i.id !== post.user.id" v-click-anime class="_button" @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></button> </div> </div> <div class="user"> @@ -62,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, ref } from 'vue'; +import { computed, watch, ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; @@ -79,6 +80,7 @@ import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { useRouter } from '@/router/supplier.js'; +import { MenuItem } from '@/types/menu'; const router = useRouter(); @@ -153,13 +155,56 @@ function edit() { router.push(`/gallery/${post.value.id}/edit`); } +function reportAbuse() { + if (!post.value) return; + + const pageUrl = `${url}/gallery/${post.value.id}`; + + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: post.value.user, + initialComment: `Post: ${pageUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); +} + +function showMenu(ev: MouseEvent) { + if (!post.value) return; + + const menu: MenuItem[] = [ + ...($i && $i.id !== post.value.userId ? [ + { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }, + ...($i.isModerator || $i.isAdmin ? [ + { + type: 'divider' as const, + }, + { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: () => os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }).then(({ canceled }) => { + if (canceled || !post.value) return; + + os.apiWithDialog('gallery/posts/delete', { postId: post.value.id }); + }), + }, + ] : []), + ] : []), + ]; + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + watch(() => props.postId, fetchPost, { immediate: true }); -const headerActions = computed(() => [{ - icon: 'ti ti-pencil', - text: i18n.ts.edit, - handler: edit, -}]); +const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 20b776aaa2..cb1ce9b918 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -62,8 +62,10 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> </div> <div :class="$style.other"> + <MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA> <button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button> + <button v-if="$i" v-click-anime class="_button" :class="$style.generalActionButton" @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></button> </div> </div> <div :class="$style.pageUser"> @@ -78,14 +80,6 @@ SPDX-License-Identifier: AGPL-3.0-only <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> </div> - <div :class="$style.pageLinks"> - <MkA v-if="!$i || $i.id !== page.userId" :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> - <template v-if="$i && $i.id === page.userId"> - <MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA> - <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button> - <button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button> - </template> - </div> </div> <MkAd :prefer="['horizontal', 'horizontal-big']"/> <MkContainer :max-height="300" :foldable="true" class="other"> @@ -104,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, ref } from 'vue'; +import { computed, watch, ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import XPage from '@/components/page/page.vue'; import MkButton from '@/components/MkButton.vue'; @@ -126,6 +120,10 @@ import { isSupportShare } from '@/scripts/navigator.js'; import { instance } from '@/instance.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { useRouter } from '@/router/supplier.js'; +import { MenuItem } from '@/types/menu'; + +const router = useRouter(); const props = defineProps<{ pageName: string; @@ -242,6 +240,69 @@ function pin(pin) { }); } +function reportAbuse() { + if (!page.value) return; + + const pageUrl = `${url}/@${props.username}/pages/${props.pageName}`; + + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: page.value.user, + initialComment: `Page: ${pageUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); +} + +function showMenu(ev: MouseEvent) { + if (!page.value) return; + + const menu: MenuItem[] = [ + ...($i && $i.id === page.value.userId ? [ + { + icon: 'ti ti-code', + text: i18n.ts._pages.viewSource, + action: () => router.push(`/@${props.username}/pages/${props.pageName}/view-source`), + }, + ...($i.pinnedPageId === page.value.id ? [{ + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => pin(false), + }] : [{ + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => pin(true), + }]), + ] : []), + ...($i && $i.id !== page.value.userId ? [ + { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }, + ...($i.isModerator || $i.isAdmin ? [ + { + type: 'divider' as const, + }, + { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: () => os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }).then(({ canceled }) => { + if (canceled || !page.value) return; + + os.apiWithDialog('pages/delete', { pageId: page.value.id }); + }), + }, + ] : []), + ] : []), + ]; + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + watch(() => path.value, fetchPage, { immediate: true }); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index c4bff3a0b8..d5943e8fbc 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -240,7 +240,7 @@ function closeTutorial(): void { } function switchTlIfNeeded() { - if (isBasicTimeline(src.value) && !availableBasicTimelines().includes(src.value)) { + if (isBasicTimeline(src.value) && !isAvailableBasicTimeline(src.value)) { src.value = availableBasicTimelines()[0]; } } diff --git a/packages/frontend/src/scripts/focus-trap.ts b/packages/frontend/src/scripts/focus-trap.ts index a5df36f520..fb7caea830 100644 --- a/packages/frontend/src/scripts/focus-trap.ts +++ b/packages/frontend/src/scripts/focus-trap.ts @@ -16,21 +16,57 @@ function containsFocusTrappedElements(el: HTMLElement): boolean { }); } +function getZIndex(el: HTMLElement): number { + const zIndex = parseInt(window.getComputedStyle(el).zIndex || '0', 10); + if (isNaN(zIndex)) { + return 0; + } + return zIndex; +} + +function getHighestZIndexElement(): { el: HTMLElement; zIndex: number; } | null { + let highestZIndexElement: HTMLElement | null = null; + let highestZIndex = -Infinity; + + focusTrapElements.forEach((el) => { + const zIndex = getZIndex(el); + if (zIndex > highestZIndex) { + highestZIndex = zIndex; + highestZIndexElement = el; + } + }); + + return highestZIndexElement == null ? null : { + el: highestZIndexElement, + zIndex: highestZIndex, + }; +} + function releaseFocusTrap(el: HTMLElement): void { focusTrapElements.delete(el); if (el.inert === true) { el.inert = false; } + + const highestZIndexElement = getHighestZIndexElement(); + if (el.parentElement != null && el !== document.body) { el.parentElement.childNodes.forEach((siblingNode) => { const siblingEl = getHTMLElementOrNull(siblingNode); if (!siblingEl) return; - if (siblingEl !== el && (focusTrapElements.has(siblingEl) || containsFocusTrappedElements(siblingEl) || focusTrapElements.size === 0)) { + if ( + siblingEl !== el && + ( + highestZIndexElement == null || + siblingEl === highestZIndexElement.el || + siblingEl.contains(highestZIndexElement.el) + ) + ) { siblingEl.inert = false; } else if ( - focusTrapElements.size > 0 && - !containsFocusTrappedElements(siblingEl) && - !focusTrapElements.has(siblingEl) && + highestZIndexElement != null && + siblingEl !== highestZIndexElement.el && + !siblingEl.contains(highestZIndexElement.el) && !ignoreElements.includes(siblingEl.tagName.toLowerCase()) ) { siblingEl.inert = true; @@ -45,9 +81,29 @@ function releaseFocusTrap(el: HTMLElement): void { export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void; export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; }; export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void { + const highestZIndexElement = getHighestZIndexElement(); + + const highestZIndex = highestZIndexElement == null ? -Infinity : highestZIndexElement.zIndex; + const zIndex = getZIndex(el); + + // If the element has a lower z-index than the highest z-index element, focus trap the highest z-index element instead + // Focus trapping for this element will be done in the release function + if (!parent && zIndex < highestZIndex) { + focusTrapElements.add(el); + if (highestZIndexElement) { + focusTrap(highestZIndexElement.el, hasInteractionWithOtherFocusTrappedEls); + } + return { + release: () => { + releaseFocusTrap(el); + }, + }; + } + if (el.inert === true) { el.inert = false; } + if (el.parentElement != null && el !== document.body) { el.parentElement.childNodes.forEach((siblingNode) => { const siblingEl = getHTMLElementOrNull(siblingNode); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 1edb9203c4..aaaa0493ca 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2464,6 +2464,9 @@ type ModerationLog = { } | { type: 'unsetUserAvatar'; info: ModerationLogPayloads['unsetUserAvatar']; +} | { + type: 'unsetUserBanner'; + info: ModerationLogPayloads['unsetUserBanner']; } | { type: 'createSystemWebhook'; info: ModerationLogPayloads['createSystemWebhook']; @@ -2485,10 +2488,19 @@ type ModerationLog = { } | { type: 'deleteAccount'; info: ModerationLogPayloads['deleteAccount']; +} | { + type: 'deletePage'; + info: ModerationLogPayloads['deletePage']; +} | { + type: 'deleteFlash'; + info: ModerationLogPayloads['deleteFlash']; +} | { + type: 'deleteGalleryPost'; + info: ModerationLogPayloads['deleteGalleryPost']; }); // @public (undocumented) -export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount"]; +export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient", "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost"]; // @public (undocumented) type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index 4ec52a040d..39e687d4af 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2024.8.0-beta.2", + "version": "2024.8.0", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index d738948491..b4fbcffa97 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -4,9 +4,12 @@ import type { Ad, Announcement, EmojiDetailed, + Flash, + GalleryPost, InviteCode, MetaDetailed, Note, + Page, Role, ReversiGameDetailed, SystemWebhook, @@ -155,6 +158,9 @@ export const moderationLogTypes = [ 'updateAbuseReportNotificationRecipient', 'deleteAbuseReportNotificationRecipient', 'deleteAccount', + 'deletePage', + 'deleteFlash', + 'deleteGalleryPost', ] as const; // See: packages/backend/src/core/ReversiService.ts@L410 @@ -398,4 +404,22 @@ export type ModerationLogPayloads = { userUsername: string; userHost: string | null; }; + deletePage: { + pageId: string; + pageUserId: string; + pageUserUsername: string; + page: Page; + }; + deleteFlash: { + flashId: string; + flashUserId: string; + flashUserUsername: string; + flash: Flash; + }; + deleteGalleryPost: { + postId: string; + postUserId: string; + postUserUsername: string; + post: GalleryPost; + }; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 2a3625f56e..08d3dc5c6d 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -155,6 +155,9 @@ export type ModerationLog = { } | { type: 'unsetUserAvatar'; info: ModerationLogPayloads['unsetUserAvatar']; +} | { + type: 'unsetUserBanner'; + info: ModerationLogPayloads['unsetUserBanner']; } | { type: 'createSystemWebhook'; info: ModerationLogPayloads['createSystemWebhook']; @@ -176,6 +179,15 @@ export type ModerationLog = { } | { type: 'deleteAccount'; info: ModerationLogPayloads['deleteAccount']; +} | { + type: 'deletePage'; + info: ModerationLogPayloads['deletePage']; +} | { + type: 'deleteFlash'; + info: ModerationLogPayloads['deleteFlash']; +} | { + type: 'deleteGalleryPost'; + info: ModerationLogPayloads['deleteGalleryPost']; }); export type ServerStats = {