diff options
Diffstat (limited to 'packages/backend/src/core')
16 files changed, 299 insertions, 141 deletions
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 0e72545934..05930350fa 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -171,13 +171,15 @@ export class AntennaService implements OnApplicationShutdown { .filter(xs => xs.length > 0); if (keywords.length > 0) { - if (note.text == null) return false; + if (note.text == null && note.cw == null) return false; + + const _text = (note.text ?? '') + '\n' + (note.cw ?? ''); const matched = keywords.some(and => and.every(keyword => antenna.caseSensitive - ? note.text!.includes(keyword) - : note.text!.toLowerCase().includes(keyword.toLowerCase()), + ? _text.includes(keyword) + : _text.toLowerCase().includes(keyword.toLowerCase()), )); if (!matched) return false; @@ -189,13 +191,15 @@ export class AntennaService implements OnApplicationShutdown { .filter(xs => xs.length > 0); if (excludeKeywords.length > 0) { - if (note.text == null) return false; - + if (note.text == null && note.cw == null) return false; + + const _text = (note.text ?? '') + '\n' + (note.cw ?? ''); + const matched = excludeKeywords.some(and => and.every(keyword => antenna.caseSensitive - ? note.text!.includes(keyword) - : note.text!.toLowerCase().includes(keyword.toLowerCase()), + ? _text.includes(keyword) + : _text.toLowerCase().includes(keyword.toLowerCase()), )); if (matched) return false; diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts index cd47844a75..eba7171fb6 100644 --- a/packages/backend/src/core/CreateNotificationService.ts +++ b/packages/backend/src/core/CreateNotificationService.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { setTimeout } from 'node:timers/promises'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import type { Notification } from '@/models/entities/Notification.js'; @@ -10,7 +11,9 @@ import { PushNotificationService } from '@/core/PushNotificationService.js'; import { bindThis } from '@/decorators.js'; @Injectable() -export class CreateNotificationService { +export class CreateNotificationService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -40,11 +43,11 @@ export class CreateNotificationService { if (data.notifierId && (notifieeId === data.notifierId)) { return null; } - + const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); - + const isMuted = profile?.mutingNotificationTypes.includes(type); - + // Create notification const notification = await this.notificationsRepository.insert({ id: this.idService.genId(), @@ -56,18 +59,18 @@ export class CreateNotificationService { ...data, } as Partial<Notification>) .then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0])); - + const packed = await this.notificationEntityService.pack(notification, {}); - + // Publish notification event this.globalEventService.publishMainStream(notifieeId, 'notification', packed); - + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する - setTimeout(async () => { + setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); if (fresh == null) return; // 既に削除されているかもしれない if (fresh.isRead) return; - + //#region ただしミュートしているユーザーからの通知なら無視 const mutings = await this.mutingsRepository.findBy({ muterId: notifieeId, @@ -76,14 +79,14 @@ export class CreateNotificationService { return; } //#endregion - + this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); - + if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); - }, 2000); - + }, () => { /* aborted, ignore it */ }); + return notification; } @@ -103,7 +106,7 @@ export class CreateNotificationService { sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); */ } - + @bindThis private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { /* @@ -115,4 +118,8 @@ export class CreateNotificationService { sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); */ } + + onApplicationShutdown(signal?: string | undefined): void { + this.#shutdownController.abort(); + } } diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 852c1f32e3..bd999c67da 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr'; import PrivateIp from 'private-ip'; import chalk from 'chalk'; import got, * as Got from 'got'; +import { parse } from 'content-disposition'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -32,13 +33,18 @@ export class DownloadService { } @bindThis - public async downloadUrl(url: string, path: string): Promise<void> { + public async downloadUrl(url: string, path: string): Promise<{ + filename: string; + }> { this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); const timeout = 30 * 1000; const operationTimeout = 60 * 1000; const maxSize = this.config.maxFileSize ?? 262144000; + const urlObj = new URL(url); + let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; + const req = got.stream(url, { headers: { 'User-Agent': this.config.userAgent, @@ -77,6 +83,14 @@ export class DownloadService { req.destroy(); } } + + const contentDisposition = res.headers['content-disposition']; + if (contentDisposition != null) { + const parsed = parse(contentDisposition); + if (parsed.parameters.filename) { + filename = parsed.parameters.filename; + } + } }).on('downloadProgress', (progress: Got.Progress) => { if (progress.transferred > maxSize) { this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); @@ -95,6 +109,10 @@ export class DownloadService { } this.logger.succ(`Download finished: ${chalk.cyan(url)}`); + + return { + filename, + }; } @bindThis diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index b15c967c85..f4a06faebb 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -34,6 +34,7 @@ import { FileInfoService } from '@/core/FileInfoService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type S3 from 'aws-sdk/clients/s3.js'; +import { correctFilename } from '@/misc/correct-filename.js'; type AddFileArgs = { /** User who wish to add file */ @@ -168,7 +169,7 @@ export class DriveService { //#region Uploads this.registerLogger.info(`uploading original: ${key}`); const uploads = [ - this.upload(key, fs.createReadStream(path), type, name), + this.upload(key, fs.createReadStream(path), type, ext, name), ]; if (alts.webpublic) { @@ -176,7 +177,7 @@ export class DriveService { webpublicUrl = `${ baseUrl }/${ webpublicKey }`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); - uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); + uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name)); } if (alts.thumbnail) { @@ -184,7 +185,7 @@ export class DriveService { thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); + uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext)); } await Promise.all(uploads); @@ -360,7 +361,7 @@ export class DriveService { * Upload to ObjectStorage */ @bindThis - private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { + private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) { if (type === 'image/apng') type = 'image/png'; if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; @@ -374,7 +375,12 @@ export class DriveService { CacheControl: 'max-age=31536000, immutable', } as S3.PutObjectRequest; - if (filename) params.ContentDisposition = contentDisposition('inline', filename); + if (filename) params.ContentDisposition = contentDisposition( + 'inline', + // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 + // 許可されているファイル形式でしか拡張子をつけない + ext ? correctFilename(filename, ext) : filename, + ); if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; const s3 = this.s3Service.getS3(meta); @@ -466,7 +472,12 @@ export class DriveService { //} // detect name - const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); + const detectedName = correctFilename( + // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 + // extを付加してデータベースの文字数制限に当たることはまずない + (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', + info.type.ext + ); if (user && !force) { // Check if there is a file with the same hash @@ -736,24 +747,19 @@ export class DriveService { requestIp = null, requestHeaders = null, }: UploadFromUrlArgs): Promise<DriveFile> { - let name = new URL(url).pathname.split('/').pop() ?? null; - if (name == null || !this.driveFileEntityService.validateFileName(name)) { - name = null; - } - - // If the comment is same as the name, skip comment - // (image.name is passed in when receiving attachment) - if (comment !== null && name === comment) { - comment = null; - } - // Create temp file const [path, cleanup] = await createTemp(); try { // write content at URL to temp file - await this.downloadService.downloadUrl(url, path); - + const { filename: name } = await this.downloadService.downloadUrl(url, path); + + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name === comment) { + comment = null; + } + const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); this.downloaderLogger.succ(`Got: ${driveFile.id}`); return driveFile!; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 54c135a7c5..4c4261ba79 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1,6 +1,7 @@ +import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; import { In, DataSource } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; @@ -137,7 +138,9 @@ type Option = { }; @Injectable() -export class NoteCreateService { +export class NoteCreateService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( @Inject(DI.config) private config: Config, @@ -313,7 +316,10 @@ export class NoteCreateService { const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); - setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!)); + setImmediate('post created', { signal: this.#shutdownController.signal }).then( + () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), + () => { /* aborted, ignore this */ }, + ); return note; } @@ -756,4 +762,8 @@ export class NoteCreateService { return mentionedUsers; } + + onApplicationShutdown(signal?: string | undefined) { + this.#shutdownController.abort(); + } } diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 84983d600e..d23fb8238b 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { setTimeout } from 'node:timers/promises'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In, IsNull, Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; @@ -15,7 +16,9 @@ import { AntennaService } from './AntennaService.js'; import { PushNotificationService } from './PushNotificationService.js'; @Injectable() -export class NoteReadService { +export class NoteReadService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -60,14 +63,14 @@ export class NoteReadService { }); if (mute.map(m => m.muteeId).includes(note.userId)) return; //#endregion - + // スレッドミュート const threadMute = await this.noteThreadMutingsRepository.findOneBy({ userId: userId, threadId: note.threadId ?? note.id, }); if (threadMute) return; - + const unread = { id: this.idService.genId(), noteId: note.id, @@ -77,15 +80,15 @@ export class NoteReadService { noteChannelId: note.channelId, noteUserId: note.userId, }; - + await this.noteUnreadsRepository.insert(unread); - + // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する - setTimeout(async () => { + setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id }); - + if (exist == null) return; - + if (params.isMentioned) { this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); } @@ -95,8 +98,8 @@ export class NoteReadService { if (note.channelId) { this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id); } - }, 2000); - } + }, () => { /* aborted, ignore it */ }); + } @bindThis public async read( @@ -113,24 +116,24 @@ export class NoteReadService { }, select: ['followeeId'], })).map(x => x.followeeId)); - + const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const readMentions: (Note | Packed<'Note'>)[] = []; const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; const readChannelNotes: (Note | Packed<'Note'>)[] = []; const readAntennaNotes: (Note | Packed<'Note'>)[] = []; - + for (const note of notes) { if (note.mentions && note.mentions.includes(userId)) { readMentions.push(note); } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { readSpecifiedNotes.push(note); } - + if (note.channelId && followingChannels.has(note.channelId)) { readChannelNotes.push(note); } - + if (note.user != null) { // たぶんnullになることは無いはずだけど一応 for (const antenna of myAntennas) { if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) { @@ -139,14 +142,14 @@ export class NoteReadService { } } } - + if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { // Remove the record await this.noteUnreadsRepository.delete({ userId: userId, noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), }); - + // TODO: ↓まとめてクエリしたい this.noteUnreadsRepository.countBy({ @@ -183,7 +186,7 @@ export class NoteReadService { noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), }); } - + if (readAntennaNotes.length > 0) { await this.antennaNotesRepository.update({ antennaId: In(myAntennas.map(a => a.id)), @@ -191,14 +194,14 @@ export class NoteReadService { }, { read: true, }); - + // TODO: まとめてクエリしたい for (const antenna of myAntennas) { const count = await this.antennaNotesRepository.countBy({ antennaId: antenna.id, read: false, }); - + if (count === 0) { this.globalEventService.publishMainStream(userId, 'readAntenna', antenna); this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id }); @@ -213,4 +216,8 @@ export class NoteReadService { }); } } + + onApplicationShutdown(signal?: string | undefined): void { + this.#shutdownController.abort(); + } } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index b84d5e7585..7149591198 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -11,6 +11,8 @@ import { UserCacheService } from '@/core/UserCacheService.js'; import type { RoleCondFormulaValue } from '@/models/entities/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { @@ -56,6 +58,9 @@ export class RoleService implements OnApplicationShutdown { private rolesCache: Cache<Role[]>; private roleAssignmentByUserIdCache: Cache<RoleAssignment[]>; + public static AlreadyAssignedError = class extends Error {}; + public static NotAssignedError = class extends Error {}; + constructor( @Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis, @@ -72,6 +77,8 @@ export class RoleService implements OnApplicationShutdown { private metaService: MetaService, private userCacheService: UserCacheService, private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + private idService: IdService, ) { //this.onMessage = this.onMessage.bind(this); @@ -128,6 +135,7 @@ export class RoleService implements OnApplicationShutdown { cached.push({ ...body, createdAt: new Date(body.createdAt), + expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, }); } break; @@ -193,7 +201,10 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getUserRoles(userId: User['id']) { - const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + const now = Date.now(); + let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + // 期限切れのロールを除外 + assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); @@ -207,7 +218,10 @@ export class RoleService implements OnApplicationShutdown { */ @bindThis public async getUserBadgeRoles(userId: User['id']) { - const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + const now = Date.now(); + let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + // 期限切れのロールを除外 + assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); @@ -317,6 +331,65 @@ export class RoleService implements OnApplicationShutdown { } @bindThis + public async assign(userId: User['id'], roleId: Role['id'], expiresAt: Date | null = null): Promise<void> { + const now = new Date(); + + const existing = await this.roleAssignmentsRepository.findOneBy({ + roleId: roleId, + userId: userId, + }); + + if (existing) { + if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) { + await this.roleAssignmentsRepository.delete({ + roleId: roleId, + userId: userId, + }); + } else { + throw new RoleService.AlreadyAssignedError(); + } + } + + const created = await this.roleAssignmentsRepository.insert({ + id: this.idService.genId(), + createdAt: now, + expiresAt: expiresAt, + roleId: roleId, + userId: userId, + }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); + + this.rolesRepository.update(roleId, { + lastUsedAt: new Date(), + }); + + this.globalEventService.publishInternalEvent('userRoleAssigned', created); + } + + @bindThis + public async unassign(userId: User['id'], roleId: Role['id']): Promise<void> { + const now = new Date(); + + const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId }); + if (existing == null) { + throw new RoleService.NotAssignedError(); + } else if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) { + await this.roleAssignmentsRepository.delete({ + roleId: roleId, + userId: userId, + }); + throw new RoleService.NotAssignedError(); + } + + await this.roleAssignmentsRepository.delete(existing.id); + + this.rolesRepository.update(roleId, { + lastUsedAt: now, + }); + + this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); + } + + @bindThis public onApplicationShutdown(signal?: string | undefined) { this.redisSubscriber.off('message', this.onMessage); } diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 30caa9682c..ac1e413de6 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -47,6 +47,7 @@ export class WebhookService implements OnApplicationShutdown { this.webhooks.push({ ...body, createdAt: new Date(body.createdAt), + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, }); } break; @@ -57,11 +58,13 @@ export class WebhookService implements OnApplicationShutdown { this.webhooks[i] = { ...body, createdAt: new Date(body.createdAt), + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, }; } else { this.webhooks.push({ ...body, createdAt: new Date(body.createdAt), + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, }); } } else { diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts index dbde757676..03e3612658 100644 --- a/packages/backend/src/core/chart/ChartManagementService.ts +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -62,8 +62,10 @@ export class ChartManagementService implements OnApplicationShutdown { async onApplicationShutdown(signal: string): Promise<void> { clearInterval(this.saveIntervalId); - await Promise.all( - this.charts.map(chart => chart.save()), - ); + if (process.env.NODE_ENV !== 'test') { + await Promise.all( + this.charts.map(chart => chart.save()), + ); + } } } diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts index 1e2a579dfa..d8966f34c1 100644 --- a/packages/backend/src/core/chart/charts/per-user-notes.ts +++ b/packages/backend/src/core/chart/charts/per-user-notes.ts @@ -45,8 +45,8 @@ export default class PerUserNotesChart extends Chart<typeof schema> { } @bindThis - public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean): Promise<void> { - await this.commit({ + public update(user: { id: User['id'] }, note: Note, isAdditional: boolean): void { + this.commit({ 'total': isAdditional ? 1 : -1, 'inc': isAdditional ? 1 : 0, 'dec': isAdditional ? 0 : 1, diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 158fafa9d5..f769ddd5e9 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -1,5 +1,5 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -21,6 +21,7 @@ type PackOptions = { }; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; +import { isNotNull } from '@/misc/is-not-null.js'; @Injectable() export class DriveFileEntityService { @@ -255,10 +256,33 @@ export class DriveFileEntityService { @bindThis public async packMany( - files: (DriveFile['id'] | DriveFile)[], + files: DriveFile[], options?: PackOptions, ): Promise<Packed<'DriveFile'>[]> { const items = await Promise.all(files.map(f => this.packNullable(f, options))); return items.filter((x): x is Packed<'DriveFile'> => x != null); } + + @bindThis + public async packManyByIdsMap( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> { + const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); + const packedFiles = await this.packMany(files, options); + const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f])); + for (const id of fileIds) { + if (!map.has(id)) map.set(id, null); + } + return map; + } + + @bindThis + public async packManyByIds( + fileIds: DriveFile['id'][], + options?: PackOptions, + ): Promise<Packed<'DriveFile'>[]> { + const filesMap = await this.packManyByIdsMap(fileIds, options); + return fileIds.map(id => filesMap.get(id)).filter(isNotNull); + } } diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index ab29e7dba1..fb147ae181 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -41,7 +41,8 @@ export class GalleryPostEntityService { title: post.title, description: post.description, fileIds: post.fileIds, - files: this.driveFileEntityService.packMany(post.fileIds), + // TODO: packMany causes N+1 queries + files: this.driveFileEntityService.packManyByIds(post.fileIds), tags: post.tags.length > 0 ? post.tags : undefined, isSensitive: post.isSensitive, likedCount: post.likedCount, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2ffe5f1c21..4ec10df9a6 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -249,6 +250,21 @@ export class NoteEntityService implements OnModuleInit { } @bindThis + public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> { + const missingIds = []; + for (const id of fileIds) { + if (!packedFiles.has(id)) missingIds.push(id); + } + if (missingIds.length) { + const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds); + for (const [k, v] of additionalMap) { + packedFiles.set(k, v); + } + } + return fileIds.map(id => packedFiles.get(id)).filter(isNotNull); + } + + @bindThis public async pack( src: Note['id'] | Note, me?: { id: User['id'] } | null | undefined, @@ -257,6 +273,7 @@ export class NoteEntityService implements OnModuleInit { skipHide?: boolean; _hint_?: { myReactions: Map<Note['id'], NoteReaction | null>; + packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>; }; }, ): Promise<Packed<'Note'>> { @@ -284,6 +301,7 @@ export class NoteEntityService implements OnModuleInit { const reactionEmojiNames = Object.keys(note.reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); + const packedFiles = options?._hint_?.packedFiles; const packed: Packed<'Note'> = await awaitAll({ id: note.id, @@ -304,7 +322,7 @@ export class NoteEntityService implements OnModuleInit { emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, tags: note.tags.length > 0 ? note.tags : undefined, fileIds: note.fileIds, - files: this.driveFileEntityService.packMany(note.fileIds), + files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds), replyId: note.replyId, renoteId: note.renoteId, channelId: note.channelId ?? undefined, @@ -388,11 +406,15 @@ export class NoteEntityService implements OnModuleInit { } await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく + const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); + const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { myReactions: myReactionsMap, + packedFiles, }, }))); } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 33c76c6937..be88a213f4 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -1,19 +1,21 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Notification } from '@/models/entities/Notification.js'; -import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { Note } from '@/models/entities/Note.js'; import type { Packed } from '@/misc/schema.js'; import { bindThis } from '@/decorators.js'; +import { isNotNull } from '@/misc/is-not-null.js'; +import { notificationTypes } from '@/types.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]); + @Injectable() export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; @@ -48,13 +50,20 @@ export class NotificationEntityService implements OnModuleInit { public async pack( src: Notification['id'] | Notification, options: { - _hintForEachNotes_?: { - myReactions: Map<Note['id'], NoteReaction | null>; + _hint_?: { + packedNotes: Map<Note['id'], Packed<'Note'>>; }; }, ): Promise<Packed<'Notification'>> { const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; + const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( + options._hint_?.packedNotes != null + ? options._hint_.packedNotes.get(notification.noteId) + : this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + detail: true, + }) + ) : undefined; return await awaitAll({ id: notification.id, @@ -63,43 +72,10 @@ export class NotificationEntityService implements OnModuleInit { isRead: notification.isRead, userId: notification.notifierId, user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, - ...(notification.type === 'mention' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'reply' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'renote' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), - ...(notification.type === 'quote' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), + ...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(notification.type === 'reaction' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), reaction: notification.reaction, } : {}), - ...(notification.type === 'pollEnded' ? { - note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { - detail: true, - _hint_: options._hintForEachNotes_, - }), - } : {}), ...(notification.type === 'achievementEarned' ? { achievement: notification.achievement, } : {}), @@ -111,32 +87,32 @@ export class NotificationEntityService implements OnModuleInit { }); } + /** + * @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId + */ @bindThis public async packMany( notifications: Notification[], meId: User['id'], ) { if (notifications.length === 0) return []; - - const notes = notifications.filter(x => x.note != null).map(x => x.note!); - const noteIds = notes.map(n => n.id); - const myReactionsMap = new Map<Note['id'], NoteReaction | null>(); - const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); - const targets = [...noteIds, ...renoteIds]; - const myReactions = await this.noteReactionsRepository.findBy({ - userId: meId, - noteId: In(targets), - }); - - for (const target of targets) { - myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); + + for (const notification of notifications) { + if (meId !== notification.notifieeId) { + // because we call note packMany with meId, all notifieeId should be same as meId + throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION'); + } } - await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + const notes = notifications.map(x => x.note).filter(isNotNull); + const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { + detail: true, + }); + const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); return await Promise.all(notifications.map(x => this.pack(x, { - _hintForEachNotes_: { - myReactions: myReactionsMap, + _hint_: { + packedNotes, }, }))); } diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 80ef5ac1fa..2f1d51fa1a 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; @@ -28,9 +29,13 @@ export class RoleEntityService { ) { const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); - const assigns = await this.roleAssignmentsRepository.findBy({ - roleId: role.id, - }); + const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign') + .where('assign.roleId = :roleId', { roleId: role.id }) + .andWhere(new Brackets(qb => { qb + .where('assign.expiresAt IS NULL') + .orWhere('assign.expiresAt > :now', { now: new Date() }); + })) + .getCount(); const policies = { ...role.policies }; for (const [k, v] of Object.entries(DEFAULT_POLICIES)) { @@ -57,7 +62,7 @@ export class RoleEntityService { asBadge: role.asBadge, canEditMembersByModerator: role.canEditMembersByModerator, policies: policies, - usersCount: assigns.length, + usersCount: assignedCount, }); } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 8c36e47f1b..3635643218 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -278,27 +278,27 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getAvatarUrl(user: User): Promise<string> { if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); } else if (user.avatarId) { const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); - return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user); } else { - return this.getIdenticonUrl(user.id); + return this.getIdenticonUrl(user); } } @bindThis public getAvatarUrlSync(user: User): string { if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); + return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); } else { - return this.getIdenticonUrl(user.id); + return this.getIdenticonUrl(user); } } @bindThis - public getIdenticonUrl(userId: User['id']): string { - return `${this.config.url}/identicon/${userId}`; + public getIdenticonUrl(user: User): string { + return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; } public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>( |