From 61aaa586feb8321d0fb6ee39dbae1370e02ebbc2 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 12 May 2025 13:12:37 -0400 Subject: [PATCH 001/110] add hasText type guard for notes --- packages/backend/src/models/Note.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 9328e9ebae..6b5ccf9e83 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -264,3 +264,7 @@ export type IMentionedRemoteUsers = { username: string; host: string; }[]; + +export function hasText(note: MiNote): note is MiNote & { text: string } { + return note.text != null; +} From 03b1960e635198ddc7c0add268799f88ededee77 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 12 May 2025 13:15:25 -0400 Subject: [PATCH 002/110] set common default values for RedisKVCache callbacks --- packages/backend/src/misc/cache.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index f9692ce5d5..48b8f43678 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -19,16 +19,16 @@ export class RedisKVCache { opts: { lifetime: RedisKVCache['lifetime']; memoryCacheLifetime: number; - fetcher: RedisKVCache['fetcher']; - toRedisConverter: RedisKVCache['toRedisConverter']; - fromRedisConverter: RedisKVCache['fromRedisConverter']; + fetcher?: RedisKVCache['fetcher']; + toRedisConverter?: RedisKVCache['toRedisConverter']; + fromRedisConverter?: RedisKVCache['fromRedisConverter']; }, ) { this.lifetime = opts.lifetime; this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); - this.fetcher = opts.fetcher; - this.toRedisConverter = opts.toRedisConverter; - this.fromRedisConverter = opts.fromRedisConverter; + this.fetcher = opts.fetcher ?? (() => { throw new Error('fetch not supported - use get/set directly'); }); + this.toRedisConverter = opts.toRedisConverter ?? ((value) => JSON.stringify(value)); + this.fromRedisConverter = opts.fromRedisConverter ?? ((value) => JSON.parse(value)); } @bindThis From 7db48ffa8d27c4be37f87ea12e8d65942d5f9cdc Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 12 May 2025 13:16:01 -0400 Subject: [PATCH 003/110] add redis cache for note translations * Partitioned by target language * Invalidated if the note is edited --- packages/backend/src/core/CacheService.ts | 47 ++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 822bb9d42c..1cf63221f9 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import { IsNull } from 'typeorm'; -import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiNote } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; @@ -22,6 +22,17 @@ export interface FollowStats { remoteFollowers: number; } +export interface CachedTranslation { + sourceLang: string | undefined; + text: string | undefined; +} + +interface CachedTranslationEntity { + l?: string; + t?: string; + u?: number; +} + @Injectable() export class CacheService implements OnApplicationShutdown { public userByIdCache: MemoryKVCache; @@ -35,6 +46,7 @@ export class CacheService implements OnApplicationShutdown { public renoteMutingsCache: RedisKVCache>; public userFollowingsCache: RedisKVCache | undefined>>; private readonly userFollowStatsCache = new MemoryKVCache(1000 * 60 * 10); // 10 minutes + private readonly translationsCache: RedisKVCache; constructor( @Inject(DI.redis) @@ -124,6 +136,11 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => JSON.parse(value), }); + this.translationsCache = new RedisKVCache(this.redisClient, 'translations', { + lifetime: 1000 * 60 * 60 * 24 * 7, // 1 week, + memoryCacheLifetime: 1000 * 60, // 1 minute + }); + // NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている this.redisForSub.on('message', this.onMessage); @@ -253,6 +270,34 @@ export class CacheService implements OnApplicationShutdown { }); } + @bindThis + public async getCachedTranslation(note: MiNote, targetLang: string): Promise { + const cacheKey = `${note.id}@${targetLang}`; + + // Use cached translation, if present and up-to-date + const cached = await this.translationsCache.get(cacheKey); + if (cached && cached.u === note.updatedAt?.valueOf()) { + return { + sourceLang: cached.l, + text: cached.t, + }; + } + + // No cache entry :( + return null; + } + + @bindThis + public async setCachedTranslation(note: MiNote, targetLang: string, translation: CachedTranslation): Promise { + const cacheKey = `${note.id}@${targetLang}`; + + await this.translationsCache.set(cacheKey, { + l: translation.sourceLang, + t: translation.text, + u: note.updatedAt?.valueOf(), + }); + } + @bindThis public dispose(): void { this.redisForSub.off('message', this.onMessage); From 871a4d3fb1a2dc3e657a601837275709958f4f95 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 12 May 2025 13:20:17 -0400 Subject: [PATCH 004/110] cache and re-use note translations --- .../server/api/endpoints/notes/translate.ts | 60 +++++++++++++++---- packages/misskey-js/src/autogen/types.ts | 8 +-- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 39119bc206..1566a8404b 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -10,22 +10,28 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; -import { ApiError } from '../../error.js'; -import { MiMeta } from '@/models/_.js'; +import type { MiMeta, MiNote } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { hasText } from '@/models/Note.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], + // TODO allow unauthenticated if default template allows? + // Maybe a value 'optional' that allows unauthenticated OR a token w/ appropriate role. + // This will allow unauthenticated requests without leaking post data to restricted clients. requireCredential: true, kind: 'read:account', res: { type: 'object', - optional: true, nullable: false, + optional: false, nullable: false, properties: { - sourceLang: { type: 'string' }, - text: { type: 'string' }, + sourceLang: { type: 'string', optional: true, nullable: false }, + text: { type: 'string', optional: true, nullable: false }, }, }, @@ -45,6 +51,11 @@ export const meta = { code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE', id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d', }, + translationFailed: { + message: 'Failed to translate note. Please try again later or contact an administrator for assistance.', + code: 'TRANSLATION_FAILED', + id: '4e7a1a4f-521c-4ba2-b10a-69e5e2987b2f', + }, }, // 10 calls per 5 seconds @@ -73,6 +84,8 @@ export default class extends Endpoint { // eslint- private getterService: GetterService, private httpRequestService: HttpRequestService, private roleService: RoleService, + private readonly cacheService: CacheService, + private readonly loggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me.id); @@ -89,8 +102,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.cannotTranslateInvisibleNote); } - if (note.text == null) { - return; + if (!hasText(note)) { + return {}; } const canDeeplFree = this.serverSettings.deeplFreeMode && !!this.serverSettings.deeplFreeInstance; @@ -101,13 +114,32 @@ export default class extends Endpoint { // eslint- let targetLang = ps.targetLang; if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; + let response = await this.cacheService.getCachedTranslation(note, targetLang); + if (!response) { + response = await this.fetchTranslation(note, targetLang); + if (!response) { + throw new ApiError(meta.errors.translationFailed); + } + + await this.cacheService.setCachedTranslation(note, targetLang, response); + } + return response; + }); + } + + private async fetchTranslation(note: MiNote & { text: string }, targetLang: string) { + // Load-bearing try/catch - removing this will shift indentation and cause ~80 lines of upstream merge conflicts + try { + // Ignore deeplFreeInstance unless deeplFreeMode is set + const deeplFreeInstance = this.serverSettings.deeplFreeMode ? this.serverSettings.deeplFreeInstance : null; + // DeepL/DeepLX handling - if (canDeepl) { + if (this.serverSettings.deeplAuthKey || deeplFreeInstance) { const params = new URLSearchParams(); if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey); params.append('text', note.text); params.append('target_lang', targetLang); - const endpoint = canDeeplFree ? this.serverSettings.deeplFreeInstance as string : this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + const endpoint = deeplFreeInstance ?? this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; const res = await this.httpRequestService.send(endpoint, { method: 'POST', @@ -151,8 +183,8 @@ export default class extends Endpoint { // eslint- } // LibreTranslate handling - if (canLibre) { - const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL as string, { + if (this.serverSettings.libreTranslateURL) { + const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -182,8 +214,10 @@ export default class extends Endpoint { // eslint- text: json.translatedText, }; } + } catch (e) { + this.loggerService.logger.error('Unhandled error from translation API: ', { e }); + } - return; - }); + return null; } } diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 2b4c39c280..271a0d0b52 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -28709,15 +28709,11 @@ export type operations = { 200: { content: { 'application/json': { - sourceLang: string; - text: string; + sourceLang?: string; + text?: string; }; }; }; - /** @description OK (without any results) */ - 204: { - content: never; - }; /** @description Client error */ 400: { content: { From 7cf766d44c27a8ce303e926d0548ee4abbee16d1 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 12 May 2025 13:20:30 -0400 Subject: [PATCH 005/110] fix infinite spinner when translation request fails --- packages/frontend/src/utility/get-note-menu.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index d796eb4fba..0326ebdac8 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -293,12 +293,12 @@ export function getNoteMenu(props: { async function translate(): Promise { if (props.translation.value != null) return; props.translating.value = true; - const res = await misskeyApi('notes/translate', { + props.translation.value = await misskeyApi('notes/translate', { noteId: appearNote.id, targetLang: miLocalStorage.getItem('lang') ?? navigator.language, + }).finally(() => { + props.translating.value = false; }); - props.translating.value = false; - props.translation.value = res; } const menuItems: MenuItem[] = []; From 4e8993bad72790777b837cd1dee940bcfba4daa2 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 12 May 2025 21:33:48 -0400 Subject: [PATCH 006/110] add debug logging for translation endpoint --- packages/backend/src/server/api/endpoints/notes/translate.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 1566a8404b..c17b1d784d 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -116,6 +116,7 @@ export default class extends Endpoint { // eslint- let response = await this.cacheService.getCachedTranslation(note, targetLang); if (!response) { + this.loggerService.logger.debug(`Fetching new translation for note=${note.id} lang=${targetLang}`); response = await this.fetchTranslation(note, targetLang); if (!response) { throw new ApiError(meta.errors.translationFailed); From 7ad772116b8d7364c8a42e07e0d75d994e65230b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 23:21:04 -0400 Subject: [PATCH 007/110] delete user reactions before notes --- .../DeleteAccountProcessorService.ts | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 46cee096cf..8922370104 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -65,6 +65,37 @@ export class DeleteAccountProcessorService { return; } + { // Delete reactions + let cursor: MiNoteReaction['id'] | null = null; + + while (true) { + const reactions = await this.noteReactionsRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }) as MiNoteReaction[]; + + if (reactions.length === 0) { + break; + } + + cursor = reactions.at(-1)?.id ?? null; + + for (const reaction of reactions) { + const note = await this.notesRepository.findOneBy({ id: reaction.noteId }) as MiNote; + + await this.reactionService.delete(user, note); + } + } + + this.logger.succ('All reactions have been deleted'); + } + { // Delete scheduled notes const scheduledNotes = await this.noteScheduleRepository.findBy({ userId: user.id, @@ -119,37 +150,6 @@ export class DeleteAccountProcessorService { this.logger.succ('All of notes deleted'); } - { // Delete reactions - let cursor: MiNoteReaction['id'] | null = null; - - while (true) { - const reactions = await this.noteReactionsRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }) as MiNoteReaction[]; - - if (reactions.length === 0) { - break; - } - - cursor = reactions.at(-1)?.id ?? null; - - for (const reaction of reactions) { - const note = await this.notesRepository.findOneBy({ id: reaction.noteId }) as MiNote; - - await this.reactionService.delete(user, note); - } - } - - this.logger.succ('All reactions have been deleted'); - } - { // Delete files let cursor: MiDriveFile['id'] | null = null; From 077096d04ee558c9fc883cde221ecf35ed938777 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 23:30:29 -0400 Subject: [PATCH 008/110] use deliverMany to reduce overhead of account deletion queue --- packages/backend/src/core/DeleteAccountService.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 48f27d558e..7d64c6f2ad 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -63,8 +63,6 @@ export class DeleteAccountService { // 知り得る全SharedInboxにDelete配信 const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - const queue: string[] = []; - const followings = await this.followingsRepository.find({ where: [ { followerSharedInbox: Not(IsNull()) }, @@ -73,15 +71,10 @@ export class DeleteAccountService { select: ['followerSharedInbox', 'followeeSharedInbox'], }); - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); + const inboxes = followings.map(x => [x.followerSharedInbox ?? x.followeeSharedInbox as string, true] as const); + const queue = new Map(inboxes); - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - this.queueService.deliver(user, content, inbox, true); - } + await this.queueService.deliverMany(user, content, queue); this.queueService.createDeleteAccountJob(user, { soft: false, From fe5def9de0c7d1f35bc895d2fe9b030496f95e27 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 23:31:06 -0400 Subject: [PATCH 009/110] await delete account in queue in case of errors --- packages/backend/src/core/DeleteAccountService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 7d64c6f2ad..efbe6a2d59 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -76,12 +76,12 @@ export class DeleteAccountService { await this.queueService.deliverMany(user, content, queue); - this.queueService.createDeleteAccountJob(user, { + await this.queueService.createDeleteAccountJob(user, { soft: false, }); } else { // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する - this.queueService.createDeleteAccountJob(user, { + await this.queueService.createDeleteAccountJob(user, { soft: true, }); } From a8a8c41a9b7db8b1c0f7a3b7ce4ffb56cf66d981 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:05:52 -0400 Subject: [PATCH 010/110] allow caller to pass in existing reaction hint to ReactionService.delete --- packages/backend/src/core/ReactionService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 0179b0680f..776dec7490 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -298,9 +298,9 @@ export class ReactionService { } @bindThis - public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) { + public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, exist?: MiNoteReaction | null) { // if already unreacted - const exist = await this.noteReactionsRepository.findOneBy({ + exist ??= await this.noteReactionsRepository.findOneBy({ noteId: note.id, userId: user.id, }); From 7cf293de94c9ad5afa616e6f6ce967c7fbb94ab9 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:06:53 -0400 Subject: [PATCH 011/110] add more manual steps to process account deletion in smaller chunks --- .../DeleteAccountProcessorService.ts | 176 +++++++++++++++++- 1 file changed, 169 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 8922370104..f7643a3beb 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -4,9 +4,9 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; +import { In, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule } from '@/models/_.js'; +import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, ClipsRepository, ClipNotesRepository, LatestNotesRepository, NoteEditRepository, NoteFavoritesRepository, PollVotesRepository, PollsRepository, SigninsRepository, UserIpsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; @@ -17,10 +17,10 @@ import { bindThis } from '@/decorators.js'; import { SearchService } from '@/core/SearchService.js'; import { ApLogService } from '@/core/ApLogService.js'; import { ReactionService } from '@/core/ReactionService.js'; +import { QueueService } from '@/core/QueueService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbUserDeleteJobData } from '../types.js'; -import { QueueService } from '@/core/QueueService.js'; @Injectable() export class DeleteAccountProcessorService { @@ -45,6 +45,45 @@ export class DeleteAccountProcessorService { @Inject(DI.noteScheduleRepository) private noteScheduleRepository: NoteScheduleRepository, + @Inject(DI.followingsRepository) + private readonly followingsRepository: FollowingsRepository, + + @Inject(DI.followRequestsRepository) + private readonly followRequestsRepository: FollowRequestsRepository, + + @Inject(DI.blockingsRepository) + private readonly blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private readonly mutingsRepository: MutingsRepository, + + @Inject(DI.clipsRepository) + private readonly clipsRepository: ClipsRepository, + + @Inject(DI.clipNotesRepository) + private readonly clipNotesRepository: ClipNotesRepository, + + @Inject(DI.latestNotesRepository) + private readonly latestNotesRepository: LatestNotesRepository, + + @Inject(DI.noteEditRepository) + private readonly noteEditRepository: NoteEditRepository, + + @Inject(DI.noteFavoritesRepository) + private readonly noteFavoritesRepository: NoteFavoritesRepository, + + @Inject(DI.pollVotesRepository) + private readonly pollVotesRepository: PollVotesRepository, + + @Inject(DI.pollsRepository) + private readonly pollsRepository: PollsRepository, + + @Inject(DI.signinsRepository) + private readonly signinsRepository: SigninsRepository, + + @Inject(DI.userIpsRepository) + private readonly userIpsRepository: UserIpsRepository, + private queueService: QueueService, private driveService: DriveService, private emailService: EmailService, @@ -65,6 +104,74 @@ export class DeleteAccountProcessorService { return; } + { // Delete user clips + const userClips = await this.clipsRepository.find({ + select: { + id: true, + }, + where: { + userId: user.id, + }, + }) as { id: string }[]; + + // Delete one-at-a-time because there can be a lot + for (const clip of userClips) { + await this.clipNotesRepository.delete({ + id: clip.id, + }); + } + + await this.clipsRepository.delete({ + userId: user.id, + }); + + this.logger.succ('All clips have been deleted.'); + } + + { // Delete favorites + await this.noteFavoritesRepository.delete({ + userId: user.id, + }); + + this.logger.succ('All favorites have been deleted.'); + } + + { // Delete user relations + await this.followingsRepository.delete({ + followerId: user.id, + }); + + await this.followingsRepository.delete({ + followeeId: user.id, + }); + + await this.followRequestsRepository.delete({ + followerId: user.id, + }); + + await this.followRequestsRepository.delete({ + followeeId: user.id, + }); + + await this.blockingsRepository.delete({ + blockerId: user.id, + }); + + await this.blockingsRepository.delete({ + blockeeId: user.id, + }); + + await this.mutingsRepository.delete({ + muterId: user.id, + }); + + await this.mutingsRepository.delete({ + muteeId: user.id, + }); + + this.logger.succ('All user relations have been deleted.'); + } + { // Delete reactions let cursor: MiNoteReaction['id'] | null = null; @@ -78,6 +185,9 @@ export class DeleteAccountProcessorService { order: { id: 1, }, + relations: { + note: true, + }, }) as MiNoteReaction[]; if (reactions.length === 0) { @@ -87,15 +197,47 @@ export class DeleteAccountProcessorService { cursor = reactions.at(-1)?.id ?? null; for (const reaction of reactions) { - const note = await this.notesRepository.findOneBy({ id: reaction.noteId }) as MiNote; - - await this.reactionService.delete(user, note); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const note = reaction.note!; + await this.reactionService.delete(user, note, reaction); } } this.logger.succ('All reactions have been deleted'); } + { // Poll votes + let cursor: MiNoteReaction['id'] | null = null; + + while (true) { + const votes = await this.pollVotesRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + select: { + id: true, + }, + take: 100, + order: { + id: 1, + }, + }) as { id: string }[]; + + if (votes.length === 0) { + break; + } + + cursor = votes.at(-1)?.id ?? null; + + await this.pollVotesRepository.delete({ + id: In(votes.map(v => v.id)), + }); + } + + this.logger.succ('All poll votes have been deleted'); + } + { // Delete scheduled notes const scheduledNotes = await this.noteScheduleRepository.findBy({ userId: user.id, @@ -113,6 +255,10 @@ export class DeleteAccountProcessorService { } { // Delete notes + await this.latestNotesRepository.delete({ + userId: user.id, + }); + let cursor: MiNote['id'] | null = null; while (true) { @@ -133,7 +279,23 @@ export class DeleteAccountProcessorService { cursor = notes.at(-1)?.id ?? null; - await this.notesRepository.delete(notes.map(note => note.id)); + // Delete associated polls one-at-a-time, since it can cascade to a LOT of vote entries + for (const note of notes) { + if (note.hasPoll) { + await this.pollsRepository.delete({ + noteId: note.id, + }); + } + } + + const ids = notes.map(note => note.id); + + await this.noteEditRepository.delete({ + noteId: In(ids), + }); + await this.notesRepository.delete({ + id: In(ids), + }); for (const note of notes) { await this.searchService.unindexNote(note); From fdf67f6fc759f0d049011c20263a977b6d507a28 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:07:21 -0400 Subject: [PATCH 012/110] don't sent account deletion notice until after it actually completes --- .../DeleteAccountProcessorService.ts | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index f7643a3beb..1591946b18 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -353,20 +353,38 @@ export class DeleteAccountProcessorService { this.logger.succ('All AP logs deleted'); } - { // Send email notification - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - if (profile.email && profile.emailVerified) { - this.emailService.sendEmail(profile.email, 'Account deleted', - 'Your account has been deleted.', - 'Your account has been deleted.'); + // Do this BEFORE deleting the account! + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + { // Delete the actual account + await this.userIpsRepository.delete({ + userId: user.id, + }); + + await this.signinsRepository.delete({ + userId: user.id, + }); + + // soft指定されている場合は物理削除しない + if (job.data.soft) { + // nop + } else { + await this.usersRepository.delete(user.id); } + + this.logger.succ('Account data deleted'); } - // soft指定されている場合は物理削除しない - if (job.data.soft) { - // nop - } else { - await this.usersRepository.delete(job.data.user.id); + { // Send email notification + if (profile.email && profile.emailVerified) { + try { + await this.emailService.sendEmail(profile.email, 'Account deleted', + 'Your account has been deleted.', + 'Your account has been deleted.'); + } catch (e) { + this.logger.warn('Failed to send account deletion message:', { e }); + } + } } return 'Account deleted'; From 0a7ef89a172c4dff2a97a246d9bd4c7789580920 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:11:44 -0400 Subject: [PATCH 013/110] delete user registry items --- .../queue/processors/DeleteAccountProcessorService.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 1591946b18..0ca4945948 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, MoreThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, ClipsRepository, ClipNotesRepository, LatestNotesRepository, NoteEditRepository, NoteFavoritesRepository, PollVotesRepository, PollsRepository, SigninsRepository, UserIpsRepository } from '@/models/_.js'; +import type { DriveFilesRepository, NoteReactionsRepository, NotesRepository, UserProfilesRepository, UsersRepository, NoteScheduleRepository, MiNoteSchedule, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, ClipsRepository, ClipNotesRepository, LatestNotesRepository, NoteEditRepository, NoteFavoritesRepository, PollVotesRepository, PollsRepository, SigninsRepository, UserIpsRepository, RegistryItemsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; @@ -84,6 +84,9 @@ export class DeleteAccountProcessorService { @Inject(DI.userIpsRepository) private readonly userIpsRepository: UserIpsRepository, + @Inject(DI.registryItemsRepository) + private readonly registryItemsRepository: RegistryItemsRepository, + private queueService: QueueService, private driveService: DriveService, private emailService: EmailService, @@ -365,6 +368,10 @@ export class DeleteAccountProcessorService { userId: user.id, }); + await this.registryItemsRepository.delete({ + userId: user.id, + }); + // soft指定されている場合は物理削除しない if (job.data.soft) { // nop From 7b54a3ca48318fd3cae69e8c67e6294b0d8978f7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 00:12:43 -0400 Subject: [PATCH 014/110] allow user to be deleted if profile is missing --- .../src/queue/processors/DeleteAccountProcessorService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index 0ca4945948..4e9779a41b 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -357,7 +357,7 @@ export class DeleteAccountProcessorService { } // Do this BEFORE deleting the account! - const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + const profile = await this.userProfilesRepository.findOneBy({ userId: user.id }); { // Delete the actual account await this.userIpsRepository.delete({ @@ -383,7 +383,7 @@ export class DeleteAccountProcessorService { } { // Send email notification - if (profile.email && profile.emailVerified) { + if (profile && profile.email && profile.emailVerified) { try { await this.emailService.sendEmail(profile.email, 'Account deleted', 'Your account has been deleted.', From fb63167d854f277ffeb82da720580b3e99f9cc8c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sun, 11 May 2025 23:22:41 -0400 Subject: [PATCH 015/110] allow private IP ranges to specify allowed ports --- packages/backend/src/config.ts | 33 +++++- .../backend/src/core/HttpRequestService.ts | 68 +++++------- .../test/unit/core/HttpRequestService.ts | 103 ++++++++++++++++++ 3 files changed, 161 insertions(+), 43 deletions(-) create mode 100644 packages/backend/test/unit/core/HttpRequestService.ts diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 92fc2b8a13..2a3184f9b4 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -8,9 +8,11 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; import { globSync } from 'glob'; +import ipaddr from 'ipaddr.js'; import type * as Sentry from '@sentry/node'; import type * as SentryVue from '@sentry/vue'; import type { RedisOptions } from 'ioredis'; +import type { IPv4, IPv6 } from 'ipaddr.js'; type RedisOptionsSource = Partial & { host?: string; @@ -152,6 +154,33 @@ type Source = { } }; +export type PrivateNetwork = { + /** + * CIDR IP/netmask definition of the IP range to match. + */ + cidr: [ip: IPv4 | IPv6, mask: number]; + + /** + * List of ports to match. + * If undefined, then all ports match. + * If empty, then NO ports match. + */ + ports?: number[]; +}; + +export function parsePrivateNetworks(patterns: string[]): PrivateNetwork[]; +export function parsePrivateNetworks(patterns: undefined): undefined; +export function parsePrivateNetworks(patterns: string[] | undefined): PrivateNetwork[] | undefined; +export function parsePrivateNetworks(patterns: string[] | undefined): PrivateNetwork[] | undefined { + return patterns?.map(e => { + const [ip, ports] = e.split('#') as [string, ...(string | undefined)[]]; + return { + cidr: ipaddr.parseCIDR(ip), + ports: ports?.split(',').map(p => parseInt(p)), + }; + }); +} + export type Config = { url: string; port: number; @@ -190,7 +219,7 @@ export type Config = { proxy: string | undefined; proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; - allowedPrivateNetworks: string[] | undefined; + allowedPrivateNetworks: PrivateNetwork[] | undefined; disallowExternalApRedirect: boolean; maxFileSize: number; maxNoteLength: number; @@ -382,7 +411,7 @@ export function loadConfig(): Config { proxy: config.proxy, proxySmtp: config.proxySmtp, proxyBypassHosts: config.proxyBypassHosts, - allowedPrivateNetworks: config.allowedPrivateNetworks, + allowedPrivateNetworks: parsePrivateNetworks(config.allowedPrivateNetworks), disallowExternalApRedirect: config.disallowExternalApRedirect ?? false, maxFileSize: config.maxFileSize ?? 262144000, maxNoteLength: config.maxNoteLength ?? 3000, diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 12047346fb..7c086c9976 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -12,7 +12,7 @@ import fetch from 'node-fetch'; import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; +import type { Config, PrivateNetwork } from '@/config.js'; import { StatusError } from '@/misc/status-error.js'; import { bindThis } from '@/decorators.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; @@ -20,12 +20,36 @@ import type { IObject, IObjectWithId } from '@/core/activitypub/type.js'; import { ApUtilityService } from './activitypub/ApUtilityService.js'; import type { Response } from 'node-fetch'; import type { URL } from 'node:url'; +import type { Socket } from 'node:net'; export type HttpRequestSendOptions = { throwErrorWhenResponseNotOk: boolean; validators?: ((res: Response) => void)[]; }; +export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean { + const parsedIp = ipaddr.parse(ip); + + for (const { cidr, ports } of allowedPrivateNetworks ?? []) { + if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(cidr)) { + if (port == null || ports == null || ports.includes(port)) { + return false; + } + } + } + + return parsedIp.range() !== 'unicast'; +} + +export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void { + const address = socket.remoteAddress; + if (address && ipaddr.isValid(address)) { + if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) { + socket.destroy(new Error(`Blocked address: ${address}`)); + } + } +} + declare module 'node:http' { interface Agent { createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket; @@ -44,31 +68,12 @@ class HttpRequestServiceAgent extends http.Agent { public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket { const socket = super.createConnection(options, callback) .on('connect', () => { - const address = socket.remoteAddress; if (process.env.NODE_ENV === 'production') { - if (address && ipaddr.isValid(address)) { - if (this.isPrivateIp(address)) { - socket.destroy(new Error(`Blocked address: ${address}`)); - } - } + validateSocketConnect(this.config.allowedPrivateNetworks, socket); } }); return socket; } - - @bindThis - private isPrivateIp(ip: string): boolean { - const parsedIp = ipaddr.parse(ip); - - for (const net of this.config.allowedPrivateNetworks ?? []) { - const cidr = ipaddr.parseCIDR(net); - if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) { - return false; - } - } - - return parsedIp.range() !== 'unicast'; - } } class HttpsRequestServiceAgent extends https.Agent { @@ -83,31 +88,12 @@ class HttpsRequestServiceAgent extends https.Agent { public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket { const socket = super.createConnection(options, callback) .on('connect', () => { - const address = socket.remoteAddress; if (process.env.NODE_ENV === 'production') { - if (address && ipaddr.isValid(address)) { - if (this.isPrivateIp(address)) { - socket.destroy(new Error(`Blocked address: ${address}`)); - } - } + validateSocketConnect(this.config.allowedPrivateNetworks, socket); } }); return socket; } - - @bindThis - private isPrivateIp(ip: string): boolean { - const parsedIp = ipaddr.parse(ip); - - for (const net of this.config.allowedPrivateNetworks ?? []) { - const cidr = ipaddr.parseCIDR(net); - if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) { - return false; - } - } - - return parsedIp.range() !== 'unicast'; - } } @Injectable() diff --git a/packages/backend/test/unit/core/HttpRequestService.ts b/packages/backend/test/unit/core/HttpRequestService.ts new file mode 100644 index 0000000000..3185b91567 --- /dev/null +++ b/packages/backend/test/unit/core/HttpRequestService.ts @@ -0,0 +1,103 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import type { Mock } from 'jest-mock'; +import type { PrivateNetwork } from '@/config.js'; +import type { Socket } from 'net'; +import { HttpRequestService, isPrivateIp, validateSocketConnect } from '@/core/HttpRequestService.js'; +import { parsePrivateNetworks } from '@/config.js'; + +describe(HttpRequestService, () => { + let allowedPrivateNetworks: PrivateNetwork[] | undefined; + + beforeEach(() => { + allowedPrivateNetworks = parsePrivateNetworks([ + '10.0.0.1/32', + '127.0.0.1/32#1', + '127.0.0.1/32#3,4,5', + ]); + }); + + describe('isPrivateIp', () => { + it('should return false when ip public', () => { + const result = isPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80); + expect(result).toBeFalsy(); + }); + + it('should return false when ip private and port matches', () => { + const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1); + expect(result).toBeFalsy(); + }); + + it('should return true when ip private and no ports specified', () => { + const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80); + expect(result).toBeTruthy(); + }); + + it('should return true when ip private and port does not match', () => { + const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80); + expect(result).toBeTruthy(); + }); + }); + + describe('validateSocketConnect', () => { + let fakeSocket: Socket; + let fakeSocketMutable: { + remoteAddress: string | undefined; + remotePort: number | undefined; + destroy: Mock<(error?: Error) => void>; + }; + + beforeEach(() => { + fakeSocketMutable = { + remoteAddress: '74.125.127.100', + remotePort: 80, + destroy: jest.fn<(error?: Error) => void>(), + }; + fakeSocket = fakeSocketMutable as unknown as Socket; + }); + + it('should accept when IP is empty', () => { + fakeSocketMutable.remoteAddress = undefined; + + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).not.toHaveBeenCalled(); + }); + + it('should accept when IP is invalid', () => { + fakeSocketMutable.remoteAddress = 'AB939ajd9jdajsdja8jj'; + + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).not.toHaveBeenCalled(); + }); + + it('should accept when IP is valid', () => { + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).not.toHaveBeenCalled(); + }); + + it('should accept when IP is private and port match', () => { + fakeSocketMutable.remoteAddress = '127.0.0.1'; + fakeSocketMutable.remotePort = 1; + + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).not.toHaveBeenCalled(); + }); + + it('should reject when IP is private and port no match', () => { + fakeSocketMutable.remoteAddress = '127.0.0.1'; + fakeSocketMutable.remotePort = 2; + + validateSocketConnect(allowedPrivateNetworks, fakeSocket); + + expect(fakeSocket.destroy).toHaveBeenCalled(); + }); + }); +}); From 7cd1d9ad93311cc3721c08b113133d15e0611689 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 20:01:01 -0400 Subject: [PATCH 016/110] return actual muted word from check-word-mute.ts --- .../frontend/src/utility/check-word-mute.ts | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/frontend/src/utility/check-word-mute.ts b/packages/frontend/src/utility/check-word-mute.ts index 26bf593f7b..2d8486760d 100644 --- a/packages/frontend/src/utility/check-word-mute.ts +++ b/packages/frontend/src/utility/check-word-mute.ts @@ -13,30 +13,37 @@ export function checkWordMute(note: string | Misskey.entities.Note, me: Misskey. if (text === '') return false; - const matched = mutedWords.filter(filter => { + const matched = mutedWords.reduce((matchedWords, filter) => { if (Array.isArray(filter)) { // Clean up const filteredFilter = filter.filter(keyword => keyword !== ''); - if (filteredFilter.length === 0) return false; - - return filteredFilter.every(keyword => text.includes(keyword)); + if (filteredFilter.length > 0 && filteredFilter.every(keyword => text.includes(keyword))) { + const fullFilter = filteredFilter.join(' '); + matchedWords.add(fullFilter); + } } else { // represents RegExp const regexp = filter.match(/^\/(.+)\/(.*)$/); // This should never happen due to input sanitisation. - if (!regexp) return false; - - try { - return new RegExp(regexp[1], regexp[2]).test(text); - } catch (err) { - // This should never happen due to input sanitisation. - return false; + if (regexp) { + try { + const flags = regexp[2].includes('g') ? regexp[2] : (regexp[2] + 'g'); + const matches = text.matchAll(new RegExp(regexp[1], flags)); + for (const match of matches) { + matchedWords.add(match[0]); + } + } catch { + // This should never happen due to input sanitisation. + } } } - }); - if (matched.length > 0) return matched; + return matchedWords; + }, new Set()); + + // Nested arrays are intentional, otherwise the note components will join with space (" ") and it's confusing. + if (matched.size > 0) return [[Array.from(matched).join(', ')]]; } return false; From 05e5be821885ea0a1d79f15b8aa7d26e542e9c2e Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 20:01:26 -0400 Subject: [PATCH 017/110] show muted words in NoteDetailed / NoteSub components --- packages/frontend-shared/js/i18n.ts | 1 + .../src/components/MkNoteDetailed.vue | 51 ++++++++++++++++++- .../frontend/src/components/MkNoteSub.vue | 49 ++++++++++++++++-- .../src/components/SkNoteDetailed.vue | 51 ++++++++++++++++++- .../frontend/src/components/SkNoteSub.vue | 49 ++++++++++++++++-- 5 files changed, 191 insertions(+), 10 deletions(-) diff --git a/packages/frontend-shared/js/i18n.ts b/packages/frontend-shared/js/i18n.ts index 480cfcd642..d38bad45d9 100644 --- a/packages/frontend-shared/js/i18n.ts +++ b/packages/frontend-shared/js/i18n.ts @@ -59,6 +59,7 @@ export class I18n { if (typeof value === 'string') { const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter); + // TODO add a flag to suppress this warning from uses of component if (parameters.length) { console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`); } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 5bf1cde456..f702c7c422 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -230,11 +230,28 @@ SPDX-License-Identifier: AGPL-3.0-only
- + + + + + + + +
@@ -245,6 +262,7 @@ import * as Misskey from 'misskey-js'; import { isLink } from '@@/js/is-link.js'; import { host } from '@@/js/config.js'; import { computeMergedCw } from '@@/js/compute-merged-cw.js'; +import type { Ref } from 'vue'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { Paging } from '@/components/MkPagination.vue'; import type { Keymap } from '@/utility/hotkey.js'; @@ -340,7 +358,6 @@ const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(prefer.s.uncollapseCW); const isDeleted = ref(false); const renoted = ref(false); -const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); const translation = ref(null); const translating = ref(false); const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; @@ -358,6 +375,36 @@ const mergedCW = computed(() => computeMergedCw(appearNote.value)); const renoteTooltip = computeRenoteTooltip(renoted); +const inTimeline = inject('inTimeline', false); +const tl_withSensitive = inject>('tl_withSensitive', ref(true)); +const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); +const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord); + +/* Overload FunctionにLintが対応していないのでコメントアウト +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array | undefined | null, checkOnly: true): boolean; +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array | undefined | null, checkOnly: false): Array | false | 'sensitiveMute'; +*/ +function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array | undefined | null, checkOnly = false): Array | false | 'sensitiveMute' { + if (mutedWords != null) { + const result = checkWordMute(noteToCheck, $i, mutedWords); + if (Array.isArray(result)) return result; + + const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords); + if (Array.isArray(replyResult)) return replyResult; + + const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords); + if (Array.isArray(renoteResult)) return renoteResult; + } + + if (checkOnly) return false; + + if (inTimeline && tl_withSensitive.value === false && noteToCheck.files?.some((v) => v.isSensitive)) { + return 'sensitiveMute'; + } + + return false; +} + watch(() => props.expandAllCws, (expandAllCws) => { if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 9751dbbc5c..c017efa3f3 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -73,19 +73,33 @@ SPDX-License-Identifier: AGPL-3.0-only
- + + + + + + + +
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 878c81384c..ab8a3ec4a6 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -172,24 +172,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - - - - - - - - +
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index bf746533af..165f477f21 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -569,5 +569,17 @@ bubbleTimelineMustBeEnabled: "Note: the bubble timeline is hidden by default, an popularUsersGlobal: "Users popular on the global network" popularUsersLocal: "Users popular on {name}" +silenced: "Silenced" +totalFollowers: "Total followers" +totalFollowing: "Total following" +localFollowers: "Local followers" +localFollowing: "Local following" +remoteFollowers: "Remote followers" +remoteFollowing: "Remote following" +updateRemoteUser: "Refresh remote data" +activityPub: "Activity Pub" +ip: "IP" +ipTip: "The date is the IP address was first acknowledged." + translationTimeoutLabel: "Translation timeout" translationTimeoutCaption: "Timeout in milliseconds for translation API requests." From 1ac8ceb8a600c8faf7dc01dad328f323f53f1d72 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 12:18:28 -0400 Subject: [PATCH 053/110] collapse moderation note if empty --- packages/frontend/src/pages/admin-user.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 100fb34655..bf88994f86 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -111,10 +111,14 @@ SPDX-License-Identifier: AGPL-3.0-only - + + - - + + + + + From d47d7c1b7d727ef83578974c416c95950a29f02c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Sat, 10 May 2025 12:20:45 -0400 Subject: [PATCH 054/110] remove extra