diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index e827ffa68..ec9ace417 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -136,13 +136,17 @@ export class AntennaService implements OnApplicationShutdown { const { username, host } = Acct.parse(x); return this.utilityService.getFullApAccount(username, host).toLowerCase(); }); - if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + const matchUser = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase(); + const matchWildcard = this.utilityService.getFullApAccount('*', noteUser.host).toLowerCase(); + if (!accts.includes(matchUser) && !accts.includes(matchWildcard)) return false; } else if (antenna.src === 'users_blacklist') { const accts = antenna.users.map(x => { const { username, host } = Acct.parse(x); return this.utilityService.getFullApAccount(username, host).toLowerCase(); }); - if (accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + const matchUser = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase(); + const matchWildcard = this.utilityService.getFullApAccount('*', noteUser.host).toLowerCase(); + if (accts.includes(matchUser) || accts.includes(matchWildcard)) return false; } const keywords = antenna.keywords diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 79ca3baad..66d8f9075 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -227,25 +227,33 @@ export class DriveService { const thumbnailAccessKey = 'thumbnail-' + randomUUID(); const webpublicAccessKey = 'webpublic-' + randomUUID(); - const url = this.internalStorageService.saveFromPath(accessKey, path); - - let thumbnailUrl: string | null = null; - let webpublicUrl: string | null = null; + // Ugly type is just to help TS figure out that 2nd / 3rd promises are optional. + const promises: [Promise, ...(Promise | undefined)[]] = [ + this.internalStorageService.saveFromPath(accessKey, path), + ]; if (alts.thumbnail) { - thumbnailUrl = this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data); - this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`); + promises.push(this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data)); } if (alts.webpublic) { - webpublicUrl = this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data); + promises.push(this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data)); + } + + const [url, thumbnailUrl, webpublicUrl] = await Promise.all(promises); + + if (thumbnailUrl) { + this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`); + } + + if (webpublicUrl) { this.registerLogger.info(`web stored: ${webpublicAccessKey}`); } file.storedInternal = true; file.url = url; - file.thumbnailUrl = thumbnailUrl; - file.webpublicUrl = webpublicUrl; + file.thumbnailUrl = thumbnailUrl ?? null; + file.webpublicUrl = webpublicUrl ?? null; file.accessKey = accessKey; file.thumbnailAccessKey = thumbnailAccessKey; file.webpublicAccessKey = webpublicAccessKey; @@ -741,19 +749,19 @@ export class DriveService { @bindThis public async deleteFileSync(file: MiDriveFile, isExpired = false, deleter?: MiUser) { + const promises = []; + if (file.storedInternal) { - this.internalStorageService.del(file.accessKey!); + promises.push(this.internalStorageService.del(file.accessKey!)); if (file.thumbnailUrl) { - this.internalStorageService.del(file.thumbnailAccessKey!); + promises.push(this.internalStorageService.del(file.thumbnailAccessKey!)); } if (file.webpublicUrl) { - this.internalStorageService.del(file.webpublicAccessKey!); + promises.push(this.internalStorageService.del(file.webpublicAccessKey!)); } } else if (!file.isLink) { - const promises = []; - promises.push(this.deleteObjectStorageFile(file.accessKey!)); if (file.thumbnailUrl) { @@ -763,10 +771,10 @@ export class DriveService { if (file.webpublicUrl) { promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!)); } - - await Promise.all(promises); } + await Promise.all(promises); + this.deletePostProcess(file, isExpired, deleter); } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 04dc5682f..46b5d18dc 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -138,6 +138,10 @@ export interface NoteEventTypes { reaction: string; userId: MiUser['id']; }; + replied: { + id: MiNote['id']; + userId: MiUser['id']; + }; } type NoteStreamEventTypes = { [key in keyof NoteEventTypes]: { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 82245d847..b108320bb 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -52,8 +52,10 @@ import { FeaturedService } from '@/core/FeaturedService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { CollapsedQueue } from '@/misc/collapsed-queue.js'; @@ -217,6 +219,7 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, + private cacheService: CacheService, ) { this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount); } @@ -450,6 +453,14 @@ export class NoteCreateService implements OnApplicationShutdown { userHost: user.host, }); + // should really not happen, but better safe than sorry + if (data.reply?.id === insert.id) { + throw new Error("A note can't reply to itself"); + } + if (data.renote?.id === insert.id) { + throw new Error("A note can't renote itself"); + } + if (data.uri != null) insert.uri = data.uri; if (data.url != null) insert.url = data.url; @@ -630,6 +641,10 @@ export class NoteCreateService implements OnApplicationShutdown { // If has in reply to note if (data.reply) { + this.globalEventService.publishNoteStream(data.reply.id, 'replied', { + id: note.id, + userId: user.id, + }); // 通知 if (data.reply.userHost === null) { const isThreadMuted = await this.noteThreadMutingsRepository.exists({ @@ -639,7 +654,15 @@ export class NoteCreateService implements OnApplicationShutdown { }, }); - if (!isThreadMuted) { + const [ + userIdsWhoMeMuting, + ] = data.reply.userId ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(data.reply.userId), + ]) : [new Set()]; + + const muted = isUserRelated(note, userIdsWhoMeMuting); + + if (!isThreadMuted && !muted) { nm.push(data.reply.userId, 'reply'); this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); @@ -659,7 +682,24 @@ export class NoteCreateService implements OnApplicationShutdown { // Notify if (data.renote.userHost === null) { - nm.push(data.renote.userId, type); + const isThreadMuted = await this.noteThreadMutingsRepository.exists({ + where: { + userId: data.renote.userId, + threadId: data.renote.threadId ?? data.renote.id, + }, + }); + + const [ + userIdsWhoMeMuting, + ] = data.renote.userId ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(data.renote.userId), + ]) : [new Set()]; + + const muted = isUserRelated(note, userIdsWhoMeMuting); + + if (!isThreadMuted && !muted) { + nm.push(data.renote.userId, type); + } } // Publish event @@ -788,7 +828,15 @@ export class NoteCreateService implements OnApplicationShutdown { }, }); - if (isThreadMuted) { + const [ + userIdsWhoMeMuting, + ] = u.id ? await Promise.all([ + this.cacheService.userMutingsCache.fetch(u.id), + ]) : [new Set()]; + + const muted = isUserRelated(note, userIdsWhoMeMuting); + + if (isThreadMuted || muted) { continue; } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 4ecd2592b..f00cc739b 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -68,6 +68,13 @@ export class NoteDeleteService { await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1); } + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + await this.notesRepository.findOneBy({ id: note.renoteId }).then(async (renote) => { + if (!renote) return; + if (renote.userId !== user.id) await this.notesRepository.decrement({ id: renote.id }, 'renoteCount', 1); + }); + } + if (!quiet) { this.globalEventService.publishNoteStream(note.id, 'deleted', { deletedAt: deletedAt, @@ -106,15 +113,25 @@ export class NoteDeleteService { this.perUserNotesChart.update(user, note, false); } - if (this.meta.enableStatsForFederatedInstances) { - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { + if (note.renoteId && note.text) { + // Decrement notes count (user) + this.decNotesCountOfUser(user); + } else if (!note.renoteId) { + // Decrement notes count (user) + this.decNotesCountOfUser(user); + } + + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetch(user.host).then(async i => { + if (note.renoteId && note.text) { this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - if (this.meta.enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, false); - } - }); - } + } else if (!note.renoteId) { + this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); + } + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, false); + } + }); } } diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 181c9f764..320b23cc1 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -66,7 +66,11 @@ export class NoteReadService implements OnApplicationShutdown { noteUserId: note.userId, }; - await this.noteUnreadsRepository.insert(unread); + /* we may be called from NoteEditService, for a note that's + already present in the `note_unread` table: `upsert` makes sure + we don't throw a "duplicate key" error, while still updating + the other columns if they've changed */ + await this.noteUnreadsRepository.upsert(unread, ['userId', 'noteId']); // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 6f9fe5393..0179b0680 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta } from '@/models/_.js'; +import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, NoteThreadMutingsRepository, MiMeta } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -64,8 +64,8 @@ type DecodedReaction = { host?: string | null; }; -const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; -const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; +const isCustomEmojiRegexp = /^:([\p{Letter}\p{Number}\p{Mark}_+-]+)(?:@\.)?:$/u; +const decodeCustomEmojiRegexp = /^:([\p{Letter}\p{Number}\p{Mark}_+-]+)(?:@([\w.-]+))?:$/u; @Injectable() export class ReactionService { @@ -82,6 +82,9 @@ export class ReactionService { @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + @Inject(DI.noteThreadMutingsRepository) + private noteThreadMutingsRepository: NoteThreadMutingsRepository, + @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, @@ -256,10 +259,19 @@ export class ReactionService { // リアクションされたユーザーがローカルユーザーなら通知を作成 if (note.userHost === null) { - this.notificationService.createNotification(note.userId, 'reaction', { - noteId: note.id, - reaction: reaction, - }, user.id); + const isThreadMuted = await this.noteThreadMutingsRepository.exists({ + where: { + userId: note.userId, + threadId: note.threadId ?? note.id, + }, + }); + + if (!isThreadMuted) { + this.notificationService.createNotification(note.userId, 'reaction', { + noteId: note.id, + reaction: reaction, + }, user.id); + } } //#region 配信 diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 7920e58e3..ec40899c9 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -38,6 +38,7 @@ export class UserSuspendService { @bindThis public async suspend(user: MiUser, moderator: MiUser): Promise { + await this.usersRepository.update(user.id, { isSuspended: true, }); diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index 4036d2794..06fddf1db 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -35,7 +35,7 @@ export class ApMfmService { noMisskeyContent = true; } - const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers)); + const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : []); return { content, diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index b6026c4b8..2b91fa5b0 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -16,12 +16,12 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; -import { fromTuple } from '@/misc/from-tuple.js'; import { isCollectionOrOrderedCollection } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; import type { IObject, ICollection, IOrderedCollection } from './type.js'; +import { fromTuple } from '@/misc/from-tuple.js'; export class Resolver { private history: Set; diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index 3d6637242..60dddee9a 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -24,12 +24,12 @@ export const getNoteSummary = (note: Packed<'Note'>): string => { if (note.cw != null) { summary += `CW: ${note.cw}`; } else if (note.text) { - summary += note.text ? note.text : ''; + summary += note.text; } // ファイルが添付されているとき - if ((note.files ?? []).length !== 0) { - summary += ` (📎${note.files!.length})`; + if (note.files && note.files.length !== 0) { + summary += ` (📎${note.files.length})`; } // 投票が添付されているとき diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 3b764c227..a0def1a59 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -16,6 +16,7 @@ export class MiNote { public id: string; @Column('timestamp with time zone', { + comment: 'The update time of the Note.', default: null, }) public updatedAt: Date | null; diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 28a74bbb4..fa30f8478 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -69,7 +69,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string { // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする const currentAttempts = job.attemptsMade + (increment ? 1 : 0); - const maxAttempts = job.opts.attempts ?? 0; + const maxAttempts = job.opts ? job.opts.attempts : 0; return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; }