diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2026-03-05 10:56:50 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-05 10:56:50 +0000 |
| commit | fe3dd8edb5f30104cd0a7ed755eb254feda2922d (patch) | |
| tree | af6cf5fa4ca75302ac2de5db742cead00bc13d21 /packages/backend/src | |
| parent | Merge pull request #16998 from misskey-dev/develop (diff) | |
| parent | Release: 2026.3.0 (diff) | |
| download | misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.gz misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.bz2 misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.zip | |
Merge pull request #17217 from misskey-dev/develop
Release: 2026.3.0
Diffstat (limited to 'packages/backend/src')
171 files changed, 1857 insertions, 1548 deletions
diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index da585ad68d..3a33d198a5 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -86,6 +86,18 @@ if (!envOption.disableClustering) { ev.mount(); } +process.on('message', msg => { + if (msg === 'gc') { + if (global.gc != null) { + logger.info('Manual GC triggered'); + global.gc(); + if (process.send != null) process.send('gc ok'); + } else { + logger.warn('Manual GC requested but gc is not available. Start the process with --expose-gc to enable this feature.'); + } + } +}); + readyRef.value = true; // ユニットテスト時にMisskeyが子プロセスで起動された時のため diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 4776d0d412..041f58e509 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -4,8 +4,6 @@ */ import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; import * as os from 'node:os'; import cluster from 'node:cluster'; import chalk from 'chalk'; @@ -17,20 +15,15 @@ import { showMachineInfo } from '@/misc/show-machine-info.js'; import { envOption } from '@/env.js'; import { jobQueue, server } from './common.js'; -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8')); - const logger = new Logger('core', 'cyan'); const bootLogger = logger.createSubLogger('boot', 'magenta'); const themeColor = chalk.hex('#86b300'); -function greet() { +function greet(props: { version: string }) { if (!envOption.quiet) { //#region Misskey logo - const v = `v${meta.version}`; + const v = `v${props.version}`; console.log(themeColor(' _____ _ _ ')); console.log(themeColor(' | |_|___ ___| |_ ___ _ _ ')); console.log(themeColor(' | | | | |_ -|_ -| \'_| -_| | |')); @@ -46,7 +39,7 @@ function greet() { } bootLogger.info('Welcome to Misskey!'); - bootLogger.info(`Misskey v${meta.version}`, null, true); + bootLogger.info(`Misskey v${props.version}`, null, true); } /** @@ -57,15 +50,15 @@ export async function masterMain() { // initialize app try { - greet(); + config = loadConfigBoot(); + greet({ version: config.version }); showEnvironment(); await showMachineInfo(bootLogger); showNodejsVersion(); - config = loadConfigBoot(); //await connectDb(); if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString()); } catch (e) { - bootLogger.error('Fatal error occurred during initialization', null, true); + bootLogger.error('Fatal error occurred during initialization: ' + e, null, true); process.exit(1); } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 657d7869fa..4cd82bed87 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -219,24 +219,42 @@ export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -const compiledConfigFilePathForTest = resolve(_dirname, '../../../built/._config_.json'); +/** Path of repository root directory */ +let rootDir = _dirname; +// 見つかるまで上に遡る +while (!fs.existsSync(resolve(rootDir, 'packages'))) { + const parentDir = dirname(rootDir); + if (parentDir === rootDir) { + throw new Error('Cannot find root directory'); + } + rootDir = parentDir; +} + +/** Path of configuration directory */ +const configDir = resolve(rootDir, '.config'); +/** Path of built directory */ +const projectBuiltDir = resolve(rootDir, 'built'); + +const compiledConfigFilePathForTest = resolve(projectBuiltDir, '._config_.json'); -export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) ? compiledConfigFilePathForTest : resolve(_dirname, '../../../built/.config.json'); +export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) + ? compiledConfigFilePathForTest + : resolve(projectBuiltDir, '.config.json'); export function loadConfig(): Config { if (!fs.existsSync(compiledConfigFilePath)) { throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.'); } - const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); + const meta = JSON.parse(fs.readFileSync(resolve(projectBuiltDir, 'meta.json'), 'utf-8')); - const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json'); - const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json'); + const frontendManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json')); + const frontendEmbedManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json')); const frontendManifest = frontendManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8')) + JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json'), 'utf-8')) : { 'src/_boot_.ts': { file: null } }; const frontendEmbedManifest = frontendEmbedManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) + JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json'), 'utf-8')) : { 'src/boot.ts': { file: null } }; const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source; @@ -334,7 +352,7 @@ export function loadConfig(): Config { function tryCreateUrl(url: string) { try { return new URL(url); - } catch (e) { + } catch (_) { throw new Error(`url="${url}" is not a valid URL.`); } } diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index f8e3eaf01f..5d668bc582 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -75,7 +75,7 @@ export class AccountMoveService { */ @bindThis public async moveFromLocal(src: MiLocalUser, dst: MiLocalUser | MiRemoteUser): Promise<unknown> { - const srcUri = this.userEntityService.getUserUri(src); + const _srcUri = this.userEntityService.getUserUri(src); const dstUri = this.userEntityService.getUserUri(dst); // add movedToUri to indicate that the user has moved diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index a9f6731977..f750ca212a 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -205,7 +205,7 @@ export class AnnouncementService { announcementId: announcementId, userId: user.id, }); - } catch (e) { + } catch (_) { return; } diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 4efd6122b1..70a50a0175 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -39,7 +39,7 @@ export class AvatarDecorationService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; + const { type, body: _ } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'avatarDecorationCreated': case 'avatarDecorationUpdated': diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 87575ca59a..f075671d93 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -141,7 +141,7 @@ import { ApLoggerService } from './activitypub/ApLoggerService.js'; import { ApMfmService } from './activitypub/ApMfmService.js'; import { ApRendererService } from './activitypub/ApRendererService.js'; import { ApRequestService } from './activitypub/ApRequestService.js'; -import { ApResolverService } from './activitypub/ApResolverService.js'; +import { ApResolverService, Resolver } from './activitypub/ApResolverService.js'; import { JsonLdService } from './activitypub/JsonLdService.js'; import { RemoteLoggerService } from './RemoteLoggerService.js'; import { RemoteUserResolveService } from './RemoteUserResolveService.js'; @@ -447,6 +447,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, + Resolver, JsonLdService, RemoteLoggerService, RemoteUserResolveService, @@ -745,6 +746,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, + Resolver, JsonLdService, RemoteLoggerService, RemoteUserResolveService, diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index c7be0f7843..384704b252 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -366,7 +366,7 @@ export class EmailService { valid: true, reason: null, }; - } catch (error) { + } catch (_) { return { valid: false, reason: 'network', diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index af4d0b8c6b..c7c9f8037d 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -484,25 +484,13 @@ export class FileInfoService { * Calculate blurhash string of image */ @bindThis - private getBlurhash(path: string, type: string): Promise<string> { - return new Promise(async (resolve, reject) => { - (await sharpBmp(path, type)) - .raw() - .ensureAlpha() - .resize(64, 64, { fit: 'inside' }) - .toBuffer((err, buffer, info) => { - if (err) return reject(err); - - let hash; - - try { - hash = blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5); - } catch (e) { - return reject(e); - } - - resolve(hash); - }); - }); + private async getBlurhash(path: string, type: string): Promise<string> { + const sharp = await sharpBmp(path, type); + const { data: buffer, info } = await sharp + .raw() + .ensureAlpha() + .resize(64, 64, { fit: 'inside' }) + .toBuffer({ resolveWithObject: true }); + return blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5); } } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index f4c747b139..da5982abf6 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -38,11 +38,7 @@ export interface BroadcastTypes { emojis: Packed<'EmojiDetailed'>[]; }; emojiDeleted: { - emojis: { - id?: string; - name: string; - [other: string]: any; - }[]; + emojis: Packed<'EmojiDetailed'>[]; }; announcementCreated: { announcement: Packed<'Announcement'>; diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index b9f1c62d9d..274966d921 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -308,7 +308,7 @@ export class MfmService { try { const date = new Date(parseInt(text, 10) * 1000); return `<time datetime="${escapeHtml(date.toISOString())}">${escapeHtml(date.toISOString())}</time>`; - } catch (err) { + } catch (_) { return fnDefault(node); } } @@ -376,7 +376,7 @@ export class MfmService { try { const url = new URL(node.props.url); return `<a href="${escapeHtml(url.href)}">${toHtml(node.children)}</a>`; - } catch (err) { + } catch (_) { return `[${toHtml(node.children)}](${escapeHtml(node.props.url)})`; } }, @@ -390,7 +390,7 @@ export class MfmService { try { const url = new URL(href); return `<a href="${escapeHtml(url.href)}" class="u-url mention">${escapeHtml(acct)}</a>`; - } catch (err) { + } catch (_) { return escapeHtml(acct); } }, @@ -419,7 +419,7 @@ export class MfmService { try { const url = new URL(node.props.url); return `<a href="${escapeHtml(url.href)}">${escapeHtml(node.props.url)}</a>`; - } catch (err) { + } catch (_) { return escapeHtml(node.props.url); } }, diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts index a346ff7618..e144138c2c 100644 --- a/packages/backend/src/core/NoteDraftService.ts +++ b/packages/backend/src/core/NoteDraftService.ts @@ -187,9 +187,9 @@ export class NoteDraftService { } //#region visibleUsers - let visibleUsers: MiUser[] = []; + let _visibleUsers: MiUser[] = []; if (data.visibleUserIds != null && data.visibleUserIds.length > 0) { - visibleUsers = await this.usersRepository.findBy({ + _visibleUsers = await this.usersRepository.findBy({ id: In(data.visibleUserIds), }); } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 42782167bb..f90ae80731 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -6,7 +6,6 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import { MetricsTime, type JobType } from 'bullmq'; -import { parse as parseRedisInfo } from 'redis-info'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; @@ -86,6 +85,19 @@ const REPEATABLE_SYSTEM_JOB_DEF = [{ pattern: '0 4 * * *', }]; +function parseRedisInfo(infoText: string): Record<string, string> { + const fields = infoText + .split('\n') + .filter(line => line.length > 0 && !line.startsWith('#')) + .map(line => line.trim().split(':')); + + const result: Record<string, string> = {}; + for (const [key, value] of fields) { + result[key] = value; + } + return result; +} + @Injectable() export class QueueService { constructor( @@ -890,7 +902,7 @@ export class QueueService { }, db: { version: db.redis_version, - mode: db.redis_mode, + mode: db.redis_mode as 'cluster' | 'standalone' | 'sentinel', runId: db.run_id, processId: db.process_id, port: parseInt(db.tcp_port), diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index f2f7480dfa..2ffee69c21 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -314,7 +314,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { default: return false; } - } catch (err) { + } catch (_) { // TODO: log error return false; } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 71dc718916..87097ada93 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -190,8 +190,7 @@ export class SearchService { return this.searchNoteByMeiliSearch(q, me, opts, pagination); } default: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const typeCheck: never = this.provider; + const _: never = this.provider; return []; } } diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 7920e58e36..3ecb912a64 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -49,8 +49,8 @@ export class UserSuspendService { }); (async () => { - await this.postSuspend(user).catch(e => {}); - await this.unFollowAll(user).catch(e => {}); + await this.postSuspend(user).catch(_ => {}); + await this.unFollowAll(user).catch(_ => {}); })(); } @@ -67,7 +67,7 @@ export class UserSuspendService { }); (async () => { - await this.postUnsuspend(user).catch(e => {}); + await this.postUnsuspend(user).catch(_ => {}); })(); } diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 21ea9b9983..e3ceebccae 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -98,7 +98,7 @@ export class UtilityService { try { // TODO: RE2インスタンスをキャッシュ return new RE2(regexp[1], regexp[2]).test(text); - } catch (err) { + } catch (_) { // This should never happen due to input sanitisation. return false; } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 81637580e3..ff47ca930d 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -95,7 +95,7 @@ export class ApInboxService { if (isCollectionOrOrderedCollection(activity)) { const results = [] as [string, string | void][]; // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems); if (items.length >= resolver.getRecursionLimit()) { @@ -221,7 +221,7 @@ export class ApInboxService { this.logger.info(`Accept: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(err => { this.logger.error(`Resolution failed: ${err}`); @@ -284,7 +284,7 @@ export class ApInboxService { this.logger.info(`Announce: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); if (!activity.object) return 'skip: activity has no object property'; const targetUri = getApId(activity.object); @@ -406,7 +406,7 @@ export class ApInboxService { } // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -575,7 +575,7 @@ export class ApInboxService { this.logger.info(`Reject: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -642,7 +642,7 @@ export class ApInboxService { this.logger.info(`Undo: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -774,7 +774,7 @@ export class ApInboxService { this.logger.debug('Update'); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 4570977c5d..8c461b6031 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -515,7 +515,7 @@ export class ApRendererService { const restPart = maybeUrl.slice(match[0].length); return `<a href="${urlPartParsed.href}" rel="me nofollow noopener" target="_blank">${urlPart}</a>${restPart}`; - } catch (e) { + } catch (_) { return maybeUrl; } }; diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 49298a1d22..d14b82dc92 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -226,7 +226,7 @@ export class ApRequestService { return await this.signedGet(href, user, allowSoftfail, false); } } - } catch (e) { + } catch (_) { // something went wrong parsing the HTML, ignore the whole thing } } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 646150455b..0f51b1ce8d 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -3,10 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; +import type { + FollowRequestsRepository, + MiMeta, + NoteReactionsRepository, + NotesRepository, + PollsRepository, + UsersRepository +} from '@/models/_.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { DI } from '@/di-symbols.js'; @@ -16,26 +23,43 @@ import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { ICollection, IObject, IOrderedCollection } from './type.js'; import { isCollectionOrOrderedCollection } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; import { FetchAllowSoftFailMask } from './misc/check-against-url.js'; -import type { IObject, ICollection, IOrderedCollection } from './type.js'; +import { ModuleRef } from '@nestjs/core'; +@Injectable({ scope: Scope.TRANSIENT }) export class Resolver { private history: Set<string>; private user?: MiLocalUser; private logger: Logger; + private recursionLimit = 256; constructor( + @Inject(DI.config) private config: Config, + + @Inject(DI.meta) private meta: MiMeta, + + @Inject(DI.usersRepository) private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) private notesRepository: NotesRepository, + + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, + + @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, + private utilityService: UtilityService, private systemAccountService: SystemAccountService, private apRequestService: ApRequestService, @@ -43,7 +67,6 @@ export class Resolver { private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, private loggerService: LoggerService, - private recursionLimit = 256, ) { this.history = new Set(); this.logger = this.loggerService.getLogger('ap-resolve'); @@ -180,54 +203,12 @@ export class Resolver { @Injectable() export class ApResolverService { constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.meta) - private meta: MiMeta, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - private utilityService: UtilityService, - private systemAccountService: SystemAccountService, - private apRequestService: ApRequestService, - private httpRequestService: HttpRequestService, - private apRendererService: ApRendererService, - private apDbResolverService: ApDbResolverService, - private loggerService: LoggerService, + private moduleRef: ModuleRef, ) { } @bindThis - public createResolver(): Resolver { - return new Resolver( - this.config, - this.meta, - this.usersRepository, - this.notesRepository, - this.pollsRepository, - this.noteReactionsRepository, - this.followRequestsRepository, - this.utilityService, - this.systemAccountService, - this.apRequestService, - this.httpRequestService, - this.apRendererService, - this.apDbResolverService, - this.loggerService, - ); + public async createResolver(): Promise<Resolver> { + return await this.moduleRef.create(Resolver); } } diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index e7ece87b01..0496774c19 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -46,7 +46,7 @@ export class ApImageService { throw new Error('actor has been suspended'); } - const image = await this.apResolverService.createResolver().resolve(value); + const image = await (await this.apResolverService.createResolver()).resolve(value); if (!isDocument(image)) return null; diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 214d32f67f..1fc5728c98 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -128,7 +128,7 @@ export class ApNoteService { @bindThis public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> { // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const object = await resolver.resolve(value); diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index e52078ed0f..ebe8e9c964 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -310,7 +310,7 @@ export class ApPersonService implements OnModuleInit { } // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const object = await resolver.resolve(uri); if (object.id == null) throw new Error('invalid object.id: ' + object.id); @@ -500,7 +500,7 @@ export class ApPersonService implements OnModuleInit { //#endregion // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const object = hint ?? await resolver.resolve(uri); @@ -678,7 +678,7 @@ export class ApPersonService implements OnModuleInit { // リモートサーバーからフェッチしてきて登録 // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); return await this.createPerson(uri, resolver); } @@ -707,7 +707,7 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Updating the featured: ${user.uri}`); - const _resolver = resolver ?? this.apResolverService.createResolver(); + const _resolver = resolver ?? await this.apResolverService.createResolver(); // Resolve to (Ordered)Collection Object const collection = await _resolver.resolveCollection(user.featured); diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index a2cdaf02ca..8ac2f21e26 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -45,7 +45,7 @@ export class ApQuestionService { @bindThis public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> { // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const question = await resolver.resolve(source); if (!isQuestion(question)) throw new Error('invalid type'); @@ -91,7 +91,7 @@ export class ApQuestionService { // resolve new Question object // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const question = await resolver.resolve(value); this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts index cfa983e766..f69a484398 100644 --- a/packages/backend/src/core/entities/ChatEntityService.ts +++ b/packages/backend/src/core/entities/ChatEntityService.ts @@ -138,7 +138,7 @@ export class ChatEntityService { const reactions: { reaction: string; }[] = []; for (const record of message.reactions) { - const [userId, reaction] = record.split('/'); + const [, reaction] = record.split('/'); reactions.push({ reaction, }); diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index a6f7f369a6..1865d494c4 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -17,6 +17,7 @@ import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { IdService } from '@/core/IdService.js'; +import { uniqueByKey } from '@/misc/unique-by-key.js'; import { UtilityService } from '../UtilityService.js'; import { VideoProcessingService } from '../VideoProcessingService.js'; import { UserEntityService } from './UserEntityService.js'; @@ -226,6 +227,7 @@ export class DriveFileEntityService { options?: PackOptions, hint?: { packedUser?: Packed<'UserLite'> + packedFolder?: Packed<'DriveFolder'> }, ): Promise<Packed<'DriveFile'> | null> { const opts = Object.assign({ @@ -250,9 +252,9 @@ export class DriveFileEntityService { thumbnailUrl: this.getThumbnailUrl(file), comment: file.comment, folderId: file.folderId, - folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { + folder: opts.detail && file.folderId ? (hint?.packedFolder ?? this.driveFolderEntityService.pack(file.folderId, { detail: true, - }) : null, + })) : null, userId: file.userId, user: (opts.withUser && file.userId) ? hint?.packedUser ?? this.userEntityService.pack(file.userId) : null, }); @@ -263,10 +265,41 @@ export class DriveFileEntityService { files: MiDriveFile[], options?: PackOptions, ): Promise<Packed<'DriveFile'>[]> { - const _user = files.map(({ user, userId }) => user ?? userId).filter(x => x != null); - const _userMap = await this.userEntityService.packMany(_user) - .then(users => new Map(users.map(user => [user.id, user]))); - const items = await Promise.all(files.map(f => this.packNullable(f, options, f.userId ? { packedUser: _userMap.get(f.userId) } : {}))); + // -- ユーザ情報の事前取得 -- + + let userMap: Map<string, Packed<'UserLite'>> | null = null; + if (options?.withUser) { + const users = files + .map(({ user, userId }) => user ?? userId) + .filter(x => x != null); + + const uniqueUsers = uniqueByKey(users, (user) => typeof user === 'string' ? user : user.id); + const packedUsers = await this.userEntityService.packMany(uniqueUsers); + userMap = new Map(packedUsers.map(user => [user.id, user])); + } + + // -- フォルダ情報の事前取得 -- + + let folderMap: Map<string, Packed<'DriveFolder'>> | null = null; + if (options?.detail) { + const folders = files + .map(({ folder, folderId }) => folder ?? folderId) + .filter(x => x != null); + + const uniqueFolders = uniqueByKey(folders, (folder) => typeof folder === 'string' ? folder : folder.id); + const packedFolders = await this.driveFolderEntityService.packMany(uniqueFolders, { detail: true }); + folderMap = new Map(packedFolders.map(folder => [folder.id, folder])); + } + + const items = await Promise.all(files.map(f => this.packNullable( + f, + options, + { + packedUser: f.userId ? userMap?.get(f.userId) : undefined, + packedFolder: f.folderId ? folderMap?.get(f.folderId) : undefined, + }, + ))); + return items.filter(x => x != null); } diff --git a/packages/backend/src/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts index 299f23ad38..326421e149 100644 --- a/packages/backend/src/core/entities/DriveFolderEntityService.ts +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -12,6 +12,9 @@ import type { } from '@/models/Blocking.js'; import type { MiDriveFolder } from '@/models/DriveFolder.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { In } from 'typeorm'; +import { uniqueByKey } from '@/misc/unique-by-key.js'; +import { splitIdAndObjects } from '@/misc/split-id-and-objects.js'; @Injectable() export class DriveFolderEntityService { @@ -32,12 +35,20 @@ export class DriveFolderEntityService { options?: { detail: boolean }, + hint?: { + folderMap?: Map<string, MiDriveFolder>; + foldersCountMap?: Map<string, number> | null; + filesCountMap?: Map<string, number> | null; + parentPacker?: (id: string) => Promise<Packed<'DriveFolder'>>; + }, ): Promise<Packed<'DriveFolder'>> { const opts = Object.assign({ detail: false, }, options); - const folder = typeof src === 'object' ? src : await this.driveFoldersRepository.findOneByOrFail({ id: src }); + const folder = typeof src === 'object' + ? src + : hint?.folderMap?.get(src) ?? await this.driveFoldersRepository.findOneByOrFail({ id: src }); return await awaitAll({ id: folder.id, @@ -46,20 +57,141 @@ export class DriveFolderEntityService { parentId: folder.parentId, ...(opts.detail ? { - foldersCount: this.driveFoldersRepository.countBy({ - parentId: folder.id, - }), - filesCount: this.driveFilesRepository.countBy({ - folderId: folder.id, - }), + foldersCount: hint?.foldersCountMap?.get(folder.id) + ?? this.driveFoldersRepository.countBy({ + parentId: folder.id, + }), + filesCount: hint?.filesCountMap?.get(folder.id) + ?? this.driveFilesRepository.countBy({ + folderId: folder.id, + }), ...(folder.parentId ? { - parent: this.pack(folder.parentId, { - detail: true, - }), + parent: hint?.parentPacker + ? hint.parentPacker(folder.parentId) + : this.pack(folder.parentId, { detail: true }, hint), } : {}), } : {}), }); } -} + public async packMany( + src: Array<MiDriveFolder['id'] | MiDriveFolder>, + options?: { + detail: boolean + }, + ): Promise<Array<Packed<'DriveFolder'>>> { + /** + * 重複を除去しつつ、必要なDriveFolderオブジェクトをすべて取得する + */ + const collectUniqueObjects = async (src: Array<MiDriveFolder['id'] | MiDriveFolder>) => { + const uniqueSrc = uniqueByKey( + src, + (s) => typeof s === 'string' ? s : s.id, + ); + const { ids, objects } = splitIdAndObjects(uniqueSrc); + + const uniqueObjects = new Map<string, MiDriveFolder>(objects.map(s => [s.id, s])); + const needsFetchIds = ids.filter(id => !uniqueObjects.has(id)); + + if (needsFetchIds.length > 0) { + const fetchedObjects = await this.driveFoldersRepository.find({ + where: { + id: In(needsFetchIds), + }, + }); + for (const obj of fetchedObjects) { + uniqueObjects.set(obj.id, obj); + } + } + + return uniqueObjects; + }; + + /** + * 親フォルダーを再帰的に収集する + */ + const collectAncestors = async (folderMap: Map<string, MiDriveFolder>) => { + for (;;) { + const parentIds = new Set<string>(); + for (const folder of folderMap.values()) { + if (folder.parentId != null && !folderMap.has(folder.parentId)) { + parentIds.add(folder.parentId); + } + } + + if (parentIds.size === 0) break; + + const fetchedParents = await this.driveFoldersRepository.find({ + where: { + id: In([...parentIds]), + }, + }); + + if (fetchedParents.length === 0) break; + + for (const parent of fetchedParents) { + folderMap.set(parent.id, parent); + } + } + }; + + const opts = Object.assign({ + detail: false, + }, options); + + const folderMap = await collectUniqueObjects(src); + + let foldersCountMap: Map<string, number> | null = null; + let filesCountMap: Map<string, number> | null = null; + if (opts.detail) { + await collectAncestors(folderMap); + + const ids = [...folderMap.keys()]; + if (ids.length > 0) { + const folderCounts = await this.driveFoldersRepository.createQueryBuilder('folder') + .select('folder.parentId', 'parentId') + .addSelect('COUNT(*)', 'count') + .where('folder.parentId IN (:...ids)', { ids }) + .groupBy('folder.parentId') + .getRawMany<{ parentId: string; count: string }>(); + + const fileCounts = await this.driveFilesRepository.createQueryBuilder('file') + .select('file.folderId', 'folderId') + .addSelect('COUNT(*)', 'count') + .where('file.folderId IN (:...ids)', { ids }) + .groupBy('file.folderId') + .getRawMany<{ folderId: string; count: string }>(); + + foldersCountMap = new Map(folderCounts.map(row => [row.parentId, Number(row.count)])); + filesCountMap = new Map(fileCounts.map(row => [row.folderId, Number(row.count)])); + } else { + foldersCountMap = new Map(); + filesCountMap = new Map(); + } + } + + const packedMap = new Map<string, Promise<Packed<'DriveFolder'>>>(); + const packFromId = (id: string): Promise<Packed<'DriveFolder'>> => { + const cached = packedMap.get(id); + if (cached) return cached; + + const folder = folderMap.get(id); + if (!folder) { + throw new Error(`DriveFolder not found: ${id}`); + } + + const packedPromise = this.pack(folder, options, { + folderMap, + foldersCountMap, + filesCountMap, + parentPacker: packFromId, + }); + packedMap.set(id, packedPromise); + + return packedPromise; + }; + + return Promise.all(src.map(s => packFromId(typeof s === 'string' ? s : s.id))); + } +} diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 490d3f2511..309de3b08f 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -41,7 +41,7 @@ export class EmojiEntityService { @bindThis public packSimpleMany( - emojis: any[], + emojis: (MiEmoji['id'] | MiEmoji)[], ) { return Promise.all(emojis.map(x => this.packSimple(x))); } @@ -69,7 +69,7 @@ export class EmojiEntityService { @bindThis public packDetailedMany( - emojis: any[], + emojis: (MiEmoji['id'] | MiEmoji)[], ): Promise<Packed<'EmojiDetailed'>[]> { return Promise.all(emojis.map(x => this.packDetailed(x))); } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 2da614a120..8e56ddbc02 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -55,13 +55,13 @@ export class MetaEntityService { if (instance.defaultLightTheme) { try { defaultLightTheme = JSON.stringify(JSON5.parse(instance.defaultLightTheme)); - } catch (e) { + } catch (_) { } } if (instance.defaultDarkTheme) { try { defaultDarkTheme = JSON.stringify(JSON5.parse(instance.defaultDarkTheme)); - } catch (e) { + } catch (_) { } } diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index 54ce4d472a..fe4926bfe3 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -54,7 +54,7 @@ export class NoteReactionEntityService implements OnModuleInit { packedUser?: Packed<'UserLite'> }, ): Promise<Packed<'NoteReaction'>> { - const opts = Object.assign({ + const _opts = Object.assign({ }, options); const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); @@ -90,7 +90,7 @@ export class NoteReactionEntityService implements OnModuleInit { packedUser?: Packed<'UserLite'> }, ): Promise<Packed<'NoteReactionWithNote'>> { - const opts = Object.assign({ + const _opts = Object.assign({ }, options); const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts index df042e75c1..21099bad3e 100644 --- a/packages/backend/src/core/entities/ReversiGameEntityService.ts +++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts @@ -14,6 +14,10 @@ import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { UserEntityService } from './UserEntityService.js'; +function assertBw(bw: string): bw is Packed<'ReversiGameDetailed'>['bw'] { + return ['random', '1', '2'].includes(bw); +} + @Injectable() export class ReversiGameEntityService { constructor( @@ -58,7 +62,7 @@ export class ReversiGameEntityService { surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, - bw: game.bw, + bw: assertBw(game.bw) ? game.bw : 'random', isLlotheo: game.isLlotheo, canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, @@ -116,7 +120,7 @@ export class ReversiGameEntityService { surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, - bw: game.bw, + bw: assertBw(game.bw) ? game.bw : 'random', isLlotheo: game.isLlotheo, canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index ac5b855096..0f4051e7b8 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -720,7 +720,7 @@ export class UserEntityService implements OnModuleInit { me, { ...options, - userProfile: profilesMap.get(u.id), + userProfile: profilesMap?.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes, diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index c50f2b723c..0d1c7ee46e 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -56,7 +56,7 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi try { return new RE2(regexp[1], regexp[2]).test(text); - } catch (err) { + } catch (_) { // This should never happen due to input sanitisation. return false; } diff --git a/packages/backend/src/misc/get-ip-hash.ts b/packages/backend/src/misc/get-ip-hash.ts index e132fa8f31..571996973b 100644 --- a/packages/backend/src/misc/get-ip-hash.ts +++ b/packages/backend/src/misc/get-ip-hash.ts @@ -12,7 +12,7 @@ export function getIpHash(ip: string): string { // (this means for IPv4 the entire address is used) const prefix = IPCIDR.createAddress(ip).mask(64); return 'ip-' + BigInt('0b' + prefix).toString(36); - } catch (e) { + } catch (_) { const prefix = IPCIDR.createAddress(ip.replace(/:[0-9]+$/, '')).mask(64); return 'ip-' + BigInt('0b' + prefix).toString(36); } diff --git a/packages/backend/src/misc/i18n.ts b/packages/backend/src/misc/i18n.ts index 6cbbdef74c..40067cacf5 100644 --- a/packages/backend/src/misc/i18n.ts +++ b/packages/backend/src/misc/i18n.ts @@ -26,7 +26,7 @@ export class I18n<T extends Record<string, any>> { } } return str; - } catch (e) { + } catch (_) { console.warn(`missing localization '${key}'`); return key; } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index ed7d5bfc3a..cf233defd9 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -64,6 +64,7 @@ import { packedMetaDetailedOnlySchema, packedMetaDetailedSchema, packedMetaLiteSchema, + packedMetaClientOptionsSchema, } from '@/models/json-schema/meta.js'; import { packedUserWebhookSchema } from '@/models/json-schema/user-webhook.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; @@ -135,6 +136,7 @@ export const refs = { MetaLite: packedMetaLiteSchema, MetaDetailedOnly: packedMetaDetailedOnlySchema, MetaDetailed: packedMetaDetailedSchema, + MetaClientOptions: packedMetaClientOptionsSchema, UserWebhook: packedUserWebhookSchema, SystemWebhook: packedSystemWebhookSchema, AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, @@ -262,8 +264,6 @@ type ObjectSchemaTypeDef<p extends Schema> = never : any; -type ObjectSchemaType<p extends Schema> = NullOrUndefined<p, ObjectSchemaTypeDef<p>>; - export type SchemaTypeDef<p extends Schema> = p['type'] extends 'null' ? null : p['type'] extends 'integer' ? number : diff --git a/packages/backend/src/misc/split-id-and-objects.ts b/packages/backend/src/misc/split-id-and-objects.ts new file mode 100644 index 0000000000..d23bb93695 --- /dev/null +++ b/packages/backend/src/misc/split-id-and-objects.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * idとオブジェクトを分離する + * @param input idまたはオブジェクトの配列 + * @returns idの配列とオブジェクトの配列 + */ +export function splitIdAndObjects<T extends { id: string }>(input: (T | string)[]): { ids: string[]; objects: T[] } { + const ids: string[] = []; + const objects : T[] = []; + + for (const item of input) { + if (typeof item === 'string') { + ids.push(item); + } else { + objects.push(item); + } + } + + return { + ids, + objects, + }; +} diff --git a/packages/backend/src/misc/unique-by-key.ts b/packages/backend/src/misc/unique-by-key.ts new file mode 100644 index 0000000000..4308e29d21 --- /dev/null +++ b/packages/backend/src/misc/unique-by-key.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * itemsの中でkey関数が返す値が重複しないようにした配列を返す + * @param items 重複を除去したい配列 + * @param key 重複判定に使うキーを返す関数 + * @returns 重複を除去した配列 + */ +export function uniqueByKey<TItem, TKey = string>(items: Iterable<TItem>, key: (item: TItem) => TKey): TItem[] { + const map = new Map<TKey, TItem>(); + for (const item of items) { + const k = key(item); + if (!map.has(k)) { + map.set(k, item); + } + } + return [...map.values()]; +} diff --git a/packages/backend/src/models/AbuseReportNotificationRecipient.ts b/packages/backend/src/models/AbuseReportNotificationRecipient.ts index 17ec6abed5..daed81c174 100644 --- a/packages/backend/src/models/AbuseReportNotificationRecipient.ts +++ b/packages/backend/src/models/AbuseReportNotificationRecipient.ts @@ -67,7 +67,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザ. */ - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'userId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId1' }) @@ -76,7 +76,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザプロフィール. */ - @ManyToOne(type => MiUserProfile, { + @ManyToOne(() => MiUserProfile, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' }) @@ -96,7 +96,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のシステムWebhook. */ - @ManyToOne(type => MiSystemWebhook, { + @ManyToOne(() => MiSystemWebhook, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'systemWebhookId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_systemWebhookId' }) diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts index d43ebf9342..cd49fcddfe 100644 --- a/packages/backend/src/models/AbuseUserReport.ts +++ b/packages/backend/src/models/AbuseUserReport.ts @@ -18,7 +18,7 @@ export class MiAbuseUserReport { @Column(id()) public targetUserId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiAbuseUserReport { @Column(id()) public reporterId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -40,7 +40,7 @@ export class MiAbuseUserReport { }) public assigneeId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/AccessToken.ts b/packages/backend/src/models/AccessToken.ts index 6f98c14ec1..a853dcc6cb 100644 --- a/packages/backend/src/models/AccessToken.ts +++ b/packages/backend/src/models/AccessToken.ts @@ -41,7 +41,7 @@ export class MiAccessToken { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -53,7 +53,7 @@ export class MiAccessToken { }) public appId: MiApp['id'] | null; - @ManyToOne(type => MiApp, { + @ManyToOne(() => MiApp, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts index d0c59fff50..f664c75262 100644 --- a/packages/backend/src/models/Announcement.ts +++ b/packages/backend/src/models/Announcement.ts @@ -79,7 +79,7 @@ export class MiAnnouncement { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/AnnouncementRead.ts b/packages/backend/src/models/AnnouncementRead.ts index 47de8dd180..2133cff140 100644 --- a/packages/backend/src/models/AnnouncementRead.ts +++ b/packages/backend/src/models/AnnouncementRead.ts @@ -18,7 +18,7 @@ export class MiAnnouncementRead { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiAnnouncementRead { @Column(id()) public announcementId: MiAnnouncement['id']; - @ManyToOne(type => MiAnnouncement, { + @ManyToOne(() => MiAnnouncement, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index ccc8823703..3433cf20af 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -24,7 +24,7 @@ export class MiAntenna { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -45,7 +45,7 @@ export class MiAntenna { }) public userListId: MiUserList['id'] | null; - @ManyToOne(type => MiUserList, { + @ManyToOne(() => MiUserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/App.ts b/packages/backend/src/models/App.ts index 0185e2995c..bbb80b99ef 100644 --- a/packages/backend/src/models/App.ts +++ b/packages/backend/src/models/App.ts @@ -20,7 +20,7 @@ export class MiApp { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', nullable: true, }) diff --git a/packages/backend/src/models/AuthSession.ts b/packages/backend/src/models/AuthSession.ts index 03050ba955..a7273e63bf 100644 --- a/packages/backend/src/models/AuthSession.ts +++ b/packages/backend/src/models/AuthSession.ts @@ -25,7 +25,7 @@ export class MiAuthSession { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', nullable: true, }) @@ -35,7 +35,7 @@ export class MiAuthSession { @Column(id()) public appId: MiApp['id']; - @ManyToOne(type => MiApp, { + @ManyToOne(() => MiApp, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Blocking.ts b/packages/backend/src/models/Blocking.ts index 34a6efe5a6..49b584f509 100644 --- a/packages/backend/src/models/Blocking.ts +++ b/packages/backend/src/models/Blocking.ts @@ -20,7 +20,7 @@ export class MiBlocking { }) public blockeeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiBlocking { }) public blockerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/BubbleGameRecord.ts b/packages/backend/src/models/BubbleGameRecord.ts index 686e39c118..5dd7009fc6 100644 --- a/packages/backend/src/models/BubbleGameRecord.ts +++ b/packages/backend/src/models/BubbleGameRecord.ts @@ -18,7 +18,7 @@ export class MiBubbleGameRecord { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Channel.ts b/packages/backend/src/models/Channel.ts index f5e9b17e3e..5a5b914eb1 100644 --- a/packages/backend/src/models/Channel.ts +++ b/packages/backend/src/models/Channel.ts @@ -27,7 +27,7 @@ export class MiChannel { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', }) @JoinColumn() @@ -52,7 +52,7 @@ export class MiChannel { }) public bannerId: MiDriveFile['id'] | null; - @ManyToOne(type => MiDriveFile, { + @ManyToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/ChannelFavorite.ts b/packages/backend/src/models/ChannelFavorite.ts index 167f41cf16..4f49468598 100644 --- a/packages/backend/src/models/ChannelFavorite.ts +++ b/packages/backend/src/models/ChannelFavorite.ts @@ -20,7 +20,7 @@ export class MiChannelFavorite { }) public channelId: MiChannel['id']; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChannelFavorite { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChannelFollowing.ts b/packages/backend/src/models/ChannelFollowing.ts index c7afdd05b0..7597e704a8 100644 --- a/packages/backend/src/models/ChannelFollowing.ts +++ b/packages/backend/src/models/ChannelFollowing.ts @@ -21,7 +21,7 @@ export class MiChannelFollowing { }) public followeeId: MiChannel['id']; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiChannelFollowing { }) public followerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChannelMuting.ts b/packages/backend/src/models/ChannelMuting.ts index 11ac7e5cef..b7054c9c5f 100644 --- a/packages/backend/src/models/ChannelMuting.ts +++ b/packages/backend/src/models/ChannelMuting.ts @@ -20,7 +20,7 @@ export class MiChannelMuting { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChannelMuting { }) public channelId: MiChannel['id']; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatApproval.ts b/packages/backend/src/models/ChatApproval.ts index 55c9f07e9a..bd2509b67f 100644 --- a/packages/backend/src/models/ChatApproval.ts +++ b/packages/backend/src/models/ChatApproval.ts @@ -19,7 +19,7 @@ export class MiChatApproval { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -31,7 +31,7 @@ export class MiChatApproval { }) public otherId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts index 3d2b64268e..530ef9b842 100644 --- a/packages/backend/src/models/ChatMessage.ts +++ b/packages/backend/src/models/ChatMessage.ts @@ -20,7 +20,7 @@ export class MiChatMessage { }) public fromUserId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChatMessage { }) public toUserId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -44,7 +44,7 @@ export class MiChatMessage { }) public toRoomId: MiChatRoom['id'] | null; - @ManyToOne(type => MiChatRoom, { + @ManyToOne(() => MiChatRoom, { onDelete: 'CASCADE', }) @JoinColumn() @@ -72,7 +72,7 @@ export class MiChatMessage { }) public fileId: MiDriveFile['id'] | null; - @ManyToOne(type => MiDriveFile, { + @ManyToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatRoom.ts b/packages/backend/src/models/ChatRoom.ts index ad2a910b78..c148b16af8 100644 --- a/packages/backend/src/models/ChatRoom.ts +++ b/packages/backend/src/models/ChatRoom.ts @@ -23,7 +23,7 @@ export class MiChatRoom { }) public ownerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatRoomInvitation.ts b/packages/backend/src/models/ChatRoomInvitation.ts index 36ce12bc92..5827d0401d 100644 --- a/packages/backend/src/models/ChatRoomInvitation.ts +++ b/packages/backend/src/models/ChatRoomInvitation.ts @@ -20,7 +20,7 @@ export class MiChatRoomInvitation { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChatRoomInvitation { }) public roomId: MiChatRoom['id']; - @ManyToOne(type => MiChatRoom, { + @ManyToOne(() => MiChatRoom, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatRoomMembership.ts b/packages/backend/src/models/ChatRoomMembership.ts index 3cb5524859..d59b4426df 100644 --- a/packages/backend/src/models/ChatRoomMembership.ts +++ b/packages/backend/src/models/ChatRoomMembership.ts @@ -20,7 +20,7 @@ export class MiChatRoomMembership { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChatRoomMembership { }) public roomId: MiChatRoom['id']; - @ManyToOne(type => MiChatRoom, { + @ManyToOne(() => MiChatRoom, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Clip.ts b/packages/backend/src/models/Clip.ts index 6295a329fb..ddd0298f44 100644 --- a/packages/backend/src/models/Clip.ts +++ b/packages/backend/src/models/Clip.ts @@ -25,7 +25,7 @@ export class MiClip { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ClipFavorite.ts b/packages/backend/src/models/ClipFavorite.ts index 40bdb9f4aa..2d46fd0f0e 100644 --- a/packages/backend/src/models/ClipFavorite.ts +++ b/packages/backend/src/models/ClipFavorite.ts @@ -18,7 +18,7 @@ export class MiClipFavorite { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiClipFavorite { @Column(id()) public clipId: MiClip['id']; - @ManyToOne(type => MiClip, { + @ManyToOne(() => MiClip, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ClipNote.ts b/packages/backend/src/models/ClipNote.ts index 6e1d2bec4c..23df66c4e0 100644 --- a/packages/backend/src/models/ClipNote.ts +++ b/packages/backend/src/models/ClipNote.ts @@ -21,7 +21,7 @@ export class MiClipNote { }) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiClipNote { }) public clipId: MiClip['id']; - @ManyToOne(type => MiClip, { + @ManyToOne(() => MiClip, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/DriveFile.ts b/packages/backend/src/models/DriveFile.ts index 7b03e3e494..79189b10eb 100644 --- a/packages/backend/src/models/DriveFile.ts +++ b/packages/backend/src/models/DriveFile.ts @@ -22,7 +22,7 @@ export class MiDriveFile { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', }) @JoinColumn() @@ -142,7 +142,7 @@ export class MiDriveFile { }) public folderId: MiDriveFolder['id'] | null; - @ManyToOne(type => MiDriveFolder, { + @ManyToOne(() => MiDriveFolder, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/DriveFolder.ts b/packages/backend/src/models/DriveFolder.ts index 07046d6e11..7e34c07f46 100644 --- a/packages/backend/src/models/DriveFolder.ts +++ b/packages/backend/src/models/DriveFolder.ts @@ -26,7 +26,7 @@ export class MiDriveFolder { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -40,7 +40,7 @@ export class MiDriveFolder { }) public parentId: MiDriveFolder['id'] | null; - @ManyToOne(type => MiDriveFolder, { + @ManyToOne(() => MiDriveFolder, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/Flash.ts b/packages/backend/src/models/Flash.ts index 5db7dca992..ed677a9de3 100644 --- a/packages/backend/src/models/Flash.ts +++ b/packages/backend/src/models/Flash.ts @@ -38,7 +38,7 @@ export class MiFlash { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/FlashLike.ts b/packages/backend/src/models/FlashLike.ts index a9fb48123e..0d99c2a9ae 100644 --- a/packages/backend/src/models/FlashLike.ts +++ b/packages/backend/src/models/FlashLike.ts @@ -18,7 +18,7 @@ export class MiFlashLike { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiFlashLike { @Column(id()) public flashId: MiFlash['id']; - @ManyToOne(type => MiFlash, { + @ManyToOne(() => MiFlash, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/FollowRequest.ts b/packages/backend/src/models/FollowRequest.ts index 3ff5e7a478..468829b7e8 100644 --- a/packages/backend/src/models/FollowRequest.ts +++ b/packages/backend/src/models/FollowRequest.ts @@ -20,7 +20,7 @@ export class MiFollowRequest { }) public followeeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiFollowRequest { }) public followerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 62cbc29f26..fe62166287 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -21,7 +21,7 @@ export class MiFollowing { }) public followeeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiFollowing { }) public followerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/GalleryLike.ts b/packages/backend/src/models/GalleryLike.ts index ed0963122d..787b38e46d 100644 --- a/packages/backend/src/models/GalleryLike.ts +++ b/packages/backend/src/models/GalleryLike.ts @@ -18,7 +18,7 @@ export class MiGalleryLike { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiGalleryLike { @Column(id()) public postId: MiGalleryPost['id']; - @ManyToOne(type => MiGalleryPost, { + @ManyToOne(() => MiGalleryPost, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/GalleryPost.ts b/packages/backend/src/models/GalleryPost.ts index 04d8823e37..f66956628b 100644 --- a/packages/backend/src/models/GalleryPost.ts +++ b/packages/backend/src/models/GalleryPost.ts @@ -36,7 +36,7 @@ export class MiGalleryPost { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 205c9eeb89..620853450c 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -21,7 +21,7 @@ export class MiMeta { }) public rootUserId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', nullable: true, }) @@ -725,7 +725,11 @@ export class MiMeta { @Column('jsonb', { default: { }, }) - public clientOptions: Record<string, any>; + public clientOptions: { + entrancePageStyle: 'classic' | 'simple'; + showTimelineForVisitor: boolean; + showActivitiesForVisitor: boolean; + }; } export type SoftwareSuspension = { diff --git a/packages/backend/src/models/ModerationLog.ts b/packages/backend/src/models/ModerationLog.ts index edde315fdf..c22114a36d 100644 --- a/packages/backend/src/models/ModerationLog.ts +++ b/packages/backend/src/models/ModerationLog.ts @@ -16,7 +16,7 @@ export class MiModerationLog { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Muting.ts b/packages/backend/src/models/Muting.ts index e1240b9c4e..9406b97a62 100644 --- a/packages/backend/src/models/Muting.ts +++ b/packages/backend/src/models/Muting.ts @@ -26,7 +26,7 @@ export class MiMuting { }) public muteeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -39,7 +39,7 @@ export class MiMuting { }) public muterId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 23e5960b60..089fe8f188 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -35,7 +35,7 @@ export class MiNote { }) public replyId: MiNote['id'] | null; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -49,7 +49,7 @@ export class MiNote { }) public renoteId: MiNote['id'] | null; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -83,7 +83,7 @@ export class MiNote { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -208,7 +208,7 @@ export class MiNote { }) public channelId: MiChannel['id'] | null; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts index f078e8c21b..5bfd9699fe 100644 --- a/packages/backend/src/models/NoteDraft.ts +++ b/packages/backend/src/models/NoteDraft.ts @@ -27,7 +27,7 @@ export class MiNoteDraft { public replyId: MiNote['id'] | null; // There is a possibility that replyId is not null but reply is null when the reply note is deleted. - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -42,7 +42,7 @@ export class MiNoteDraft { public renoteId: MiNote['id'] | null; // There is a possibility that renoteId is not null but renote is null when the renote note is deleted. - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -66,7 +66,7 @@ export class MiNoteDraft { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -120,7 +120,7 @@ export class MiNoteDraft { // There is a possibility that channelId is not null but channel is null when the channel is deleted. // (deleting channel is not implemented so it's not happening now but may happen in the future) - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { createForeignKeyConstraints: false, }) @JoinColumn() diff --git a/packages/backend/src/models/NoteFavorite.ts b/packages/backend/src/models/NoteFavorite.ts index cf76c767b0..0e498eb70d 100644 --- a/packages/backend/src/models/NoteFavorite.ts +++ b/packages/backend/src/models/NoteFavorite.ts @@ -18,7 +18,7 @@ export class MiNoteFavorite { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiNoteFavorite { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/NoteReaction.ts b/packages/backend/src/models/NoteReaction.ts index 42dfcaa9ad..98263081ab 100644 --- a/packages/backend/src/models/NoteReaction.ts +++ b/packages/backend/src/models/NoteReaction.ts @@ -18,7 +18,7 @@ export class MiNoteReaction { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiNoteReaction { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/NoteThreadMuting.ts b/packages/backend/src/models/NoteThreadMuting.ts index e7bd39f348..32bb829c0b 100644 --- a/packages/backend/src/models/NoteThreadMuting.ts +++ b/packages/backend/src/models/NoteThreadMuting.ts @@ -19,7 +19,7 @@ export class MiNoteThreadMuting { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index d46f6e9d16..8811200801 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -56,7 +56,7 @@ export class MiPage { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -68,7 +68,7 @@ export class MiPage { }) public eyeCatchingImageId: MiDriveFile['id'] | null; - @ManyToOne(type => MiDriveFile, { + @ManyToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/PageLike.ts b/packages/backend/src/models/PageLike.ts index 05ca22cf2c..cf3025ae1c 100644 --- a/packages/backend/src/models/PageLike.ts +++ b/packages/backend/src/models/PageLike.ts @@ -18,7 +18,7 @@ export class MiPageLike { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiPageLike { @Column(id()) public pageId: MiPage['id']; - @ManyToOne(type => MiPage, { + @ManyToOne(() => MiPage, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PasswordResetRequest.ts b/packages/backend/src/models/PasswordResetRequest.ts index fdaf21056b..3379b540ee 100644 --- a/packages/backend/src/models/PasswordResetRequest.ts +++ b/packages/backend/src/models/PasswordResetRequest.ts @@ -24,7 +24,7 @@ export class MiPasswordResetRequest { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Poll.ts b/packages/backend/src/models/Poll.ts index ca985c8b24..d82e29fb85 100644 --- a/packages/backend/src/models/Poll.ts +++ b/packages/backend/src/models/Poll.ts @@ -15,7 +15,7 @@ export class MiPoll { @PrimaryColumn(id()) public noteId: MiNote['id']; - @OneToOne(type => MiNote, { + @OneToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PollVote.ts b/packages/backend/src/models/PollVote.ts index b5c780293c..600ca8ea41 100644 --- a/packages/backend/src/models/PollVote.ts +++ b/packages/backend/src/models/PollVote.ts @@ -18,7 +18,7 @@ export class MiPollVote { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiPollVote { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PromoNote.ts b/packages/backend/src/models/PromoNote.ts index ae27adec9e..871f7471fc 100644 --- a/packages/backend/src/models/PromoNote.ts +++ b/packages/backend/src/models/PromoNote.ts @@ -13,7 +13,7 @@ export class MiPromoNote { @PrimaryColumn(id()) public noteId: MiNote['id']; - @OneToOne(type => MiNote, { + @OneToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PromoRead.ts b/packages/backend/src/models/PromoRead.ts index b2a698cc7b..15a3573ef3 100644 --- a/packages/backend/src/models/PromoRead.ts +++ b/packages/backend/src/models/PromoRead.ts @@ -18,7 +18,7 @@ export class MiPromoRead { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiPromoRead { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RegistrationTicket.ts b/packages/backend/src/models/RegistrationTicket.ts index 0a4e4b9189..07216599d3 100644 --- a/packages/backend/src/models/RegistrationTicket.ts +++ b/packages/backend/src/models/RegistrationTicket.ts @@ -23,7 +23,7 @@ export class MiRegistrationTicket { }) public expiresAt: Date | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -36,7 +36,7 @@ export class MiRegistrationTicket { }) public createdById: MiUser['id'] | null; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RegistryItem.ts b/packages/backend/src/models/RegistryItem.ts index 335e8b9eab..869980bbff 100644 --- a/packages/backend/src/models/RegistryItem.ts +++ b/packages/backend/src/models/RegistryItem.ts @@ -25,7 +25,7 @@ export class MiRegistryItem { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RenoteMuting.ts b/packages/backend/src/models/RenoteMuting.ts index 448a0b7663..b760a09c53 100644 --- a/packages/backend/src/models/RenoteMuting.ts +++ b/packages/backend/src/models/RenoteMuting.ts @@ -20,7 +20,7 @@ export class MiRenoteMuting { }) public muteeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiRenoteMuting { }) public muterId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts index 6b29a0ce8c..fbbf24792f 100644 --- a/packages/backend/src/models/ReversiGame.ts +++ b/packages/backend/src/models/ReversiGame.ts @@ -27,7 +27,7 @@ export class MiReversiGame { @Column(id()) public user1Id: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -36,7 +36,7 @@ export class MiReversiGame { @Column(id()) public user2Id: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RoleAssignment.ts b/packages/backend/src/models/RoleAssignment.ts index 37755d631b..cb96377f66 100644 --- a/packages/backend/src/models/RoleAssignment.ts +++ b/packages/backend/src/models/RoleAssignment.ts @@ -21,7 +21,7 @@ export class MiRoleAssignment { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiRoleAssignment { }) public roleId: MiRole['id']; - @ManyToOne(type => MiRole, { + @ManyToOne(() => MiRole, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Signin.ts b/packages/backend/src/models/Signin.ts index f8ff9c57d7..59cbad735d 100644 --- a/packages/backend/src/models/Signin.ts +++ b/packages/backend/src/models/Signin.ts @@ -16,7 +16,7 @@ export class MiSignin { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/SwSubscription.ts b/packages/backend/src/models/SwSubscription.ts index 0c531132b3..a95aede44f 100644 --- a/packages/backend/src/models/SwSubscription.ts +++ b/packages/backend/src/models/SwSubscription.ts @@ -16,7 +16,7 @@ export class MiSwSubscription { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/SystemAccount.ts b/packages/backend/src/models/SystemAccount.ts index f32880b81d..2a48e62ed1 100644 --- a/packages/backend/src/models/SystemAccount.ts +++ b/packages/backend/src/models/SystemAccount.ts @@ -18,7 +18,7 @@ export class MiSystemAccount { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index a6e9edcf5f..084dd35485 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -99,7 +99,7 @@ export class MiUser { }) public avatarId: MiDriveFile['id'] | null; - @OneToOne(type => MiDriveFile, { + @OneToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() @@ -112,7 +112,7 @@ export class MiUser { }) public bannerId: MiDriveFile['id'] | null; - @OneToOne(type => MiDriveFile, { + @OneToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/UserKeypair.ts b/packages/backend/src/models/UserKeypair.ts index f5252d126c..894739c84c 100644 --- a/packages/backend/src/models/UserKeypair.ts +++ b/packages/backend/src/models/UserKeypair.ts @@ -12,7 +12,7 @@ export class MiUserKeypair { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserList.ts b/packages/backend/src/models/UserList.ts index 5fb991a87d..05fd833b6f 100644 --- a/packages/backend/src/models/UserList.ts +++ b/packages/backend/src/models/UserList.ts @@ -25,7 +25,7 @@ export class MiUserList { }) public isPublic: boolean; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserListFavorite.ts b/packages/backend/src/models/UserListFavorite.ts index 80b2d61eb7..67ab92d98c 100644 --- a/packages/backend/src/models/UserListFavorite.ts +++ b/packages/backend/src/models/UserListFavorite.ts @@ -18,7 +18,7 @@ export class MiUserListFavorite { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiUserListFavorite { @Column(id()) public userListId: MiUserList['id']; - @ManyToOne(type => MiUserList, { + @ManyToOne(() => MiUserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserListMembership.ts b/packages/backend/src/models/UserListMembership.ts index af659d071d..1a2b3fffc1 100644 --- a/packages/backend/src/models/UserListMembership.ts +++ b/packages/backend/src/models/UserListMembership.ts @@ -21,7 +21,7 @@ export class MiUserListMembership { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiUserListMembership { }) public userListId: MiUserList['id']; - @ManyToOne(type => MiUserList, { + @ManyToOne(() => MiUserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserMemo.ts b/packages/backend/src/models/UserMemo.ts index 29e28d290a..facc8c6b1c 100644 --- a/packages/backend/src/models/UserMemo.ts +++ b/packages/backend/src/models/UserMemo.ts @@ -20,7 +20,7 @@ export class MiUserMemo { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiUserMemo { }) public targetUserId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserNotePining.ts b/packages/backend/src/models/UserNotePining.ts index 92c5cd55d0..950da2ad22 100644 --- a/packages/backend/src/models/UserNotePining.ts +++ b/packages/backend/src/models/UserNotePining.ts @@ -18,7 +18,7 @@ export class MiUserNotePining { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiUserNotePining { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 501b539210..b05bf14ef9 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -17,7 +17,7 @@ export class MiUserProfile { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -215,7 +215,7 @@ export class MiUserProfile { }) public pinnedPageId: MiPage['id'] | null; - @OneToOne(type => MiPage, { + @OneToOne(() => MiPage, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/UserPublickey.ts b/packages/backend/src/models/UserPublickey.ts index 6bcd785304..8c23d368e9 100644 --- a/packages/backend/src/models/UserPublickey.ts +++ b/packages/backend/src/models/UserPublickey.ts @@ -12,7 +12,7 @@ export class MiUserPublickey { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserSecurityKey.ts b/packages/backend/src/models/UserSecurityKey.ts index 0babbe1abe..577ec359e4 100644 --- a/packages/backend/src/models/UserSecurityKey.ts +++ b/packages/backend/src/models/UserSecurityKey.ts @@ -18,7 +18,7 @@ export class MiUserSecurityKey { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Webhook.ts b/packages/backend/src/models/Webhook.ts index b4cab4edc8..5f833115cc 100644 --- a/packages/backend/src/models/Webhook.ts +++ b/packages/backend/src/models/Webhook.ts @@ -22,7 +22,7 @@ export class MiWebhook { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index a0e7d490b3..0c3ec141bc 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -72,8 +72,7 @@ export const packedMetaLiteSchema = { optional: false, nullable: true, }, clientOptions: { - type: 'object', - optional: false, nullable: false, + ref: 'MetaClientOptions', }, disableRegistration: { type: 'boolean', @@ -397,3 +396,23 @@ export const packedMetaDetailedSchema = { }, ], } as const; + +export const packedMetaClientOptionsSchema = { + type: 'object', + optional: false, nullable: false, + properties: { + entrancePageStyle: { + type: 'string', + enum: ['classic', 'simple'], + optional: false, nullable: false, + }, + showTimelineForVisitor: { + type: 'boolean', + optional: false, nullable: false, + }, + showActivitiesForVisitor: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/reversi-game.ts b/packages/backend/src/models/json-schema/reversi-game.ts index cb37200384..378ae41cb5 100644 --- a/packages/backend/src/models/json-schema/reversi-game.ts +++ b/packages/backend/src/models/json-schema/reversi-game.ts @@ -81,6 +81,7 @@ export const packedReversiGameLiteSchema = { bw: { type: 'string', optional: false, nullable: false, + enum: ['random', '1', '2'], }, noIrregularRules: { type: 'boolean', @@ -199,6 +200,7 @@ export const packedReversiGameDetailedSchema = { bw: { type: 'string', optional: false, nullable: false, + enum: ['random', '1', '2'], }, noIrregularRules: { type: 'boolean', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index b5fd38a7d7..f71ec1d023 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -618,6 +618,9 @@ export const packedMeDetailedOnlySchema = { achievementEarned: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, + login: { optional: true, ...notificationRecieveConfig }, + createToken: { optional: true, ...notificationRecieveConfig }, + exportCompleted: { optional: true, ...notificationRecieveConfig }, }, }, emailNotificationTypes: { diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts index e237cd4975..53ecd2d180 100644 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -123,8 +123,8 @@ export class ExportCustomEmojisProcessorService { metaStream.end(); // Create archive - await new Promise<void>(async (resolve) => { - const [archivePath, archiveCleanup] = await createTemp(); + const [archivePath, archiveCleanup] = await createTemp(); + await new Promise<void>((resolve) => { const archiveStream = fs.createWriteStream(archivePath); const archive = archiver('zip', { zlib: { level: 0 }, diff --git a/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts index d0eaeee090..719a09980c 100644 --- a/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts +++ b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts @@ -63,7 +63,7 @@ export class PostScheduledNoteProcessorService { this.notificationService.createNotification(draft.userId, 'scheduledNotePosted', { noteId: note.id, }); - } catch (err) { + } catch (_) { this.notificationService.createNotification(draft.userId, 'scheduledNotePostFailed', { noteDraftId: draft.id, }); diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index a5fb5b82e3..54ffeecc6b 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -116,7 +116,7 @@ export class ActivityPubServerService { try { signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' }); - } catch (e) { + } catch (_) { reply.code(401); return; } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 772c37094c..f5034d0733 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -7,27 +7,22 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; -import rename from 'rename'; -import sharp from 'sharp'; -import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import type { Config } from '@/config.js'; -import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { StatusError } from '@/misc/status-error.js'; import type Logger from '@/logger.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; -import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { InternalStorageService } from '@/core/InternalStorageService.js'; -import { contentDisposition } from '@/misc/content-disposition.js'; import { FileInfoService } from '@/core/FileInfoService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { isMimeImage } from '@/misc/is-mime-image.js'; -import { correctFilename } from '@/misc/correct-filename.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; +import { FileServerDriveHandler } from './file/FileServerDriveHandler.js'; +import { FileServerFileResolver } from './file/FileServerFileResolver.js'; +import { FileServerProxyHandler } from './file/FileServerProxyHandler.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; const _filename = fileURLToPath(import.meta.url); @@ -38,6 +33,9 @@ const assets = `${_dirname}/../../server/file/assets/`; @Injectable() export class FileServerService { private logger: Logger; + private driveHandler: FileServerDriveHandler; + private proxyHandler: FileServerProxyHandler; + private fileResolver: FileServerFileResolver; constructor( @Inject(DI.config) @@ -54,6 +52,24 @@ export class FileServerService { private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('server', 'gray'); + this.fileResolver = new FileServerFileResolver( + this.driveFilesRepository, + this.fileInfoService, + this.downloadService, + this.internalStorageService, + ); + this.driveHandler = new FileServerDriveHandler( + this.config, + this.fileResolver, + assets, + this.videoProcessingService, + ); + this.proxyHandler = new FileServerProxyHandler( + this.config, + this.fileResolver, + assets, + this.imageProcessingService, + ); //this.createServer = this.createServer.bind(this); } @@ -78,7 +94,7 @@ export class FileServerService { }); fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => { - return await this.sendDriveFile(request, reply) + return await this.driveHandler.handle(request, reply) .catch(err => this.errorHandler(request, reply, err)); }); fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => { @@ -91,7 +107,7 @@ export class FileServerService { Params: { url: string; }; Querystring: { url?: string; }; }>('/proxy/:url*', async (request, reply) => { - return await this.proxyHandler(request, reply) + return await this.proxyHandler.handle(request, reply) .catch(err => this.errorHandler(request, reply, err)); }); @@ -116,462 +132,4 @@ export class FileServerService { reply.code(500); return; } - - @bindThis - private async sendDriveFile(request: FastifyRequest<{ Params: { key: string; } }>, reply: FastifyReply) { - const key = request.params.key; - const file = await this.getFileFromKey(key).then(); - - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - if (file.state === 'remote') { - let image: IImageStreamable | null = null; - - if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/static.webp`); - url.searchParams.set('url', file.url); - url.searchParams.set('static', '1'); - - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } else if (file.mime.startsWith('video/')) { - const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); - if (externalThumbnail) { - file.cleanup(); - return await reply.redirect(externalThumbnail, 301); - } - - image = await this.videoProcessingService.generateVideoThumbnail(file.path); - } - } - - if (file.fileRole === 'webpublic') { - if (['image/svg+xml'].includes(file.mime)) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/svg.webp`); - url.searchParams.set('url', file.url); - - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } - } - - if (!image) { - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - } - - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } - - if (file.fileRole !== 'original') { - const filename = rename(file.filename, { - suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : '.unknown', - }).toString(); - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; - } - - return fs.createReadStream(file.path); - } else { - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; - } - - return fs.createReadStream(file.path); - } - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { - const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; - - if (typeof url !== 'string') { - reply.code(400); - return; - } - - // アバタークロップなど、どうしてもオリジンである必要がある場合 - const mustOrigin = 'origin' in request.query; - - if (this.config.externalMediaProxyEnabled && !mustOrigin) { - // 外部のメディアプロキシが有効なら、そちらにリダイレクト - - reply.header('Cache-Control', 'public, max-age=259200'); // 3 days - - const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); - - for (const [key, value] of Object.entries(request.query)) { - url.searchParams.append(key, value); - } - - return await reply.redirect( - url.toString(), - 301, - ); - } - - if (!request.headers['user-agent']) { - throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); - } else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { - throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); - } - - // Create temp file - const file = await this.getStreamAndTypeFromUrl(url); - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); - const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); - - if ( - 'emoji' in request.query || - 'avatar' in request.query || - 'static' in request.query || - 'preview' in request.query || - 'badge' in request.query - ) { - if (!isConvertibleImage) { - // 画像でないなら404でお茶を濁す - throw new StatusError('Unexpected mime', 404); - } - } - - let image: IImageStreamable | null = null; - if ('emoji' in request.query || 'avatar' in request.query) { - if (!isAnimationConvertibleImage && !('static' in request.query)) { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } else { - const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) - .resize({ - height: 'emoji' in request.query ? 128 : 320, - withoutEnlargement: true, - }) - .webp(webpDefault); - - image = { - data, - ext: 'webp', - type: 'image/webp', - }; - } - } else if ('static' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); - } else if ('preview' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); - } else if ('badge' in request.query) { - const mask = (await sharpBmp(file.path, file.mime)) - .resize(96, 96, { - fit: 'contain', - position: 'centre', - withoutEnlargement: false, - }) - .greyscale() - .normalise() - .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast - .flatten({ background: '#000' }) - .toColorspace('b-w'); - - const stats = await mask.clone().stats(); - - if (stats.entropy < 0.1) { - // エントロピーがあまりない場合は404にする - throw new StatusError('Skip to provide badge', 404); - } - - const data = sharp({ - create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }) - .pipelineColorspace('b-w') - .boolean(await mask.png().toBuffer(), 'eor'); - - image = { - data: await data.png().toBuffer(), - ext: 'png', - type: 'image/png', - }; - } else if (file.mime === 'image/svg+xml') { - image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); - } else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { - throw new StatusError('Rejected type', 403, 'Rejected type'); - } - - if (!image) { - if (request.headers.range && file.file && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - } - - if ('cleanup' in file) { - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - } - - reply.header('Content-Type', image.type); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - if (url.startsWith(`${this.config.url}/files/`)) { - const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); - if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); - - return await this.getFileFromKey(key); - } - - return await this.downloadAndDetectTypeFromUrl(url); - } - - @bindThis - private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - > { - const [path, cleanup] = await createTemp(); - try { - const { filename } = await this.downloadService.downloadUrl(url, path); - - const { mime, ext } = await this.fileInfoService.detectType(path); - - return { - state: 'remote', - mime, ext, - path, cleanup, - filename, - }; - } catch (e) { - cleanup(); - throw e; - } - } - - @bindThis - private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - // Fetch drive file - const file = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.accessKey = :accessKey', { accessKey: key }) - .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) - .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) - .getOne(); - - if (file == null) return '404'; - - const isThumbnail = file.thumbnailAccessKey === key; - const isWebpublic = file.webpublicAccessKey === key; - - if (!file.storedInternal) { - if (!(file.isLink && file.uri)) return '204'; - const result = await this.downloadAndDetectTypeFromUrl(file.uri); - file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので - return { - ...result, - url: file.uri, - fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', - file, - filename: file.name, - }; - } - - const path = this.internalStorageService.resolvePath(key); - - if (isThumbnail || isWebpublic) { - const { mime, ext } = await this.fileInfoService.detectType(path); - return { - state: 'stored_internal', - fileRole: isThumbnail ? 'thumbnail' : 'webpublic', - file, - filename: file.name, - mime, ext, - path, - }; - } - - return { - state: 'stored_internal', - fileRole: 'original', - file, - filename: file.name, - // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる - mime: this.fileInfoService.fixMime(file.type), - ext: null, - path, - }; - } } diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 239ef82dec..93c36f5365 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -48,8 +48,6 @@ export class NodeinfoServerService { @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { const nodeinfo2 = async (version: number) => { - const now = Date.now(); - const notesChart = await this.notesChart.getChart('hour', 1, null); const localPosts = notesChart.local.total[0]; diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 111421472d..8259a2a9e4 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -13,7 +13,6 @@ import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ServerService } from './ServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; import { GetterService } from './api/GetterService.js'; -import { ChannelsService } from './api/stream/ChannelsService.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { ApiLoggerService } from './api/ApiLoggerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -31,24 +30,25 @@ import { UrlPreviewService } from './web/UrlPreviewService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; -import { MainChannelService } from './api/stream/channels/main.js'; -import { AdminChannelService } from './api/stream/channels/admin.js'; -import { AntennaChannelService } from './api/stream/channels/antenna.js'; -import { ChannelChannelService } from './api/stream/channels/channel.js'; -import { DriveChannelService } from './api/stream/channels/drive.js'; -import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; -import { HashtagChannelService } from './api/stream/channels/hashtag.js'; -import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; -import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; -import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; -import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; -import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; -import { UserListChannelService } from './api/stream/channels/user-list.js'; -import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; -import { ChatUserChannelService } from './api/stream/channels/chat-user.js'; -import { ChatRoomChannelService } from './api/stream/channels/chat-room.js'; -import { ReversiChannelService } from './api/stream/channels/reversi.js'; -import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; +import MainStreamConnection from '@/server/api/stream/Connection.js'; +import { MainChannel } from './api/stream/channels/main.js'; +import { AdminChannel } from './api/stream/channels/admin.js'; +import { AntennaChannel } from './api/stream/channels/antenna.js'; +import { ChannelChannel } from './api/stream/channels/channel.js'; +import { DriveChannel } from './api/stream/channels/drive.js'; +import { GlobalTimelineChannel } from './api/stream/channels/global-timeline.js'; +import { HashtagChannel } from './api/stream/channels/hashtag.js'; +import { HomeTimelineChannel } from './api/stream/channels/home-timeline.js'; +import { HybridTimelineChannel } from './api/stream/channels/hybrid-timeline.js'; +import { LocalTimelineChannel } from './api/stream/channels/local-timeline.js'; +import { QueueStatsChannel } from './api/stream/channels/queue-stats.js'; +import { ServerStatsChannel } from './api/stream/channels/server-stats.js'; +import { UserListChannel } from './api/stream/channels/user-list.js'; +import { RoleTimelineChannel } from './api/stream/channels/role-timeline.js'; +import { ChatUserChannel } from './api/stream/channels/chat-user.js'; +import { ChatRoomChannel } from './api/stream/channels/chat-room.js'; +import { ReversiChannel } from './api/stream/channels/reversi.js'; +import { ReversiGameChannel } from './api/stream/channels/reversi-game.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @Module({ @@ -69,7 +69,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ServerService, WellKnownServerService, GetterService, - ChannelsService, + MainStreamConnection, ApiCallService, ApiLoggerService, ApiServerService, @@ -80,24 +80,24 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j SigninService, SignupApiService, StreamingApiServerService, - MainChannelService, - AdminChannelService, - AntennaChannelService, - ChannelChannelService, - DriveChannelService, - GlobalTimelineChannelService, - HashtagChannelService, - RoleTimelineChannelService, - ChatUserChannelService, - ChatRoomChannelService, - ReversiChannelService, - ReversiGameChannelService, - HomeTimelineChannelService, - HybridTimelineChannelService, - LocalTimelineChannelService, - QueueStatsChannelService, - ServerStatsChannelService, - UserListChannelService, + MainChannel, + AdminChannel, + AntennaChannel, + ChannelChannel, + DriveChannel, + GlobalTimelineChannel, + HashtagChannel, + RoleTimelineChannel, + ChatUserChannel, + ChatRoomChannel, + ReversiChannel, + ReversiGameChannel, + HomeTimelineChannel, + HybridTimelineChannel, + LocalTimelineChannel, + QueueStatsChannel, + ServerStatsChannel, + UserListChannel, OpenApiServerService, OAuth2ProviderService, ], diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 8bae46d9fb..0ccb3df631 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -426,7 +426,7 @@ export class ApiCallService implements OnApplicationShutdown { if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { try { data[k] = JSON.parse(data[k]); - } catch (e) { + } catch (_) { throw new ApiError({ message: 'Invalid param.', code: 'INVALID_PARAM', diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 00e8828242..5c9d16a95a 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -231,7 +231,7 @@ export class SigninApiService { try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { return await fail(403, { id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', }); diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index 920f9d0b3a..6feb4c3afa 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -93,7 +93,7 @@ export class SigninWithPasskeyApiService { // Not more than 1 API call per 250ms and not more than 100 attempts per 30min // NOTE: 1 Sign-in require 2 API calls await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); - } catch (err) { + } catch (_) { reply.code(429); return { error: { diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 53336a087d..b419c51ef1 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -255,7 +255,7 @@ export class SignupApiService { throw new FastifyReplyError(400, 'EXPIRED'); } - const { account, secret } = await this.signupService.signup({ + const { account } = await this.signupService.signup({ username: pendingUser.username, passwordHash: pendingUser.password, }); diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 21f2f0b7e2..8a317bdc4e 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -8,18 +8,14 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, MiAccessToken } from '@/models/_.js'; -import { NotificationService } from '@/core/NotificationService.js'; +import type { MiAccessToken } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; import { UserService } from '@/core/UserService.js'; -import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; -import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; -import MainStreamConnection from './stream/Connection.js'; -import { ChannelsService } from './stream/ChannelsService.js'; +import MainStreamConnection, { ConnectionRequest } from './stream/Connection.js'; import type * as http from 'node:http'; +import { ContextIdFactory, ModuleRef } from '@nestjs/core'; @Injectable() export class StreamingApiServerService { @@ -31,16 +27,9 @@ export class StreamingApiServerService { @Inject(DI.redisForSub) private redisForSub: Redis.Redis, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private cacheService: CacheService, + private moduleRef: ModuleRef, private authenticateService: AuthenticateService, - private channelsService: ChannelsService, - private notificationService: NotificationService, private usersService: UserService, - private channelFollowingService: ChannelFollowingService, - private channelMutingService: ChannelMutingService, ) { } @@ -94,14 +83,12 @@ export class StreamingApiServerService { return; } - const stream = new MainStreamConnection( - this.channelsService, - this.notificationService, - this.cacheService, - this.channelFollowingService, - this.channelMutingService, - user, app, - ); + const contextId = ContextIdFactory.create(); + this.moduleRef.registerRequestByContextId<ConnectionRequest>({ + user, + token: app, + }, contextId); + const stream = await this.moduleRef.create(MainStreamConnection, contextId); await stream.init(); @@ -124,7 +111,7 @@ export class StreamingApiServerService { user: MiLocalUser | null; app: MiAccessToken | null }) => { - const { stream, user, app } = ctx; + const { stream, user } = ctx; const ev = new EventEmitter(); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 9aecc0f0fd..6679005c3c 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -391,6 +391,7 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js'; export * as 'users/flashs' from './endpoints/users/flashs.js'; export * as 'users/followers' from './endpoints/users/followers.js'; export * as 'users/following' from './endpoints/users/following.js'; +export * as 'users/get-following-users-by-birthday' from './endpoints/users/get-following-users-by-birthday.js'; export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js'; export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js'; export * as 'users/lists/create' from './endpoints/users/lists/create.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index b8bfda73a4..74462b302a 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - const { raw, packed } = await this.announcementService.create({ + const { packed } = await this.announcementService.create({ updatedAt: null, title: ps.title, text: ps.text, diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 804bd5d9b9..aeebceed5a 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -51,11 +51,13 @@ export const meta = { }, icon: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, + enum: ['info', 'warning', 'error', 'success'], }, display: { type: 'string', optional: false, nullable: false, + enum: ['normal', 'banner', 'dialog'], }, isActive: { type: 'boolean', diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index cf03859ce5..d4305e7d7c 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -76,7 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { // Create file driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); - } catch (e) { + } catch (_) { // TODO: need to return Drive Error throw new ApiError(); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts index 660aa55bf8..b9448b4bc2 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -24,39 +24,7 @@ export const meta = { optional: false, nullable: false, items: { type: 'object', - optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - host: { - type: 'string', - optional: false, nullable: true, - description: 'The local host is represented with `null`.', - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - }, + ref: 'EmojiDetailed', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 34d200455e..658367409c 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -24,39 +24,7 @@ export const meta = { optional: false, nullable: false, items: { type: 'object', - optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - host: { - type: 'string', - optional: false, nullable: true, - description: 'The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files.', - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - }, + ref: 'EmojiDetailed', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 7bde10af46..e20bc21f6b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -117,7 +117,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists); } // 網羅性チェック - const mustBeNever: never = error; + const _mustBeNever: never = error; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts index b7781b8c99..bdd0ee6cac 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'read:admin:user-ips', res: { type: 'array', diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 2c7f793584..5beed3a7e8 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -428,8 +428,7 @@ export const meta = { optional: false, nullable: true, }, clientOptions: { - type: 'object', - optional: false, nullable: false, + ref: 'MetaClientOptions', }, description: { type: 'string', diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index b3c2cecc67..372fe3a25f 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; import type { MiMeta } from '@/models/Meta.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -67,7 +68,14 @@ export const paramDef = { description: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true }, - clientOptions: { type: 'object', nullable: false }, + clientOptions: { + type: 'object', nullable: false, + properties: { + entrancePageStyle: { type: 'string', nullable: false, enum: ['classic', 'simple'] }, + showTimelineForVisitor: { type: 'boolean', nullable: false }, + showActivitiesForVisitor: { type: 'boolean', nullable: false }, + }, + }, cacheRemoteFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' }, @@ -217,6 +225,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private metaService: MetaService, private moderationLogService: ModerationLogService, ) { @@ -329,7 +340,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- } if (ps.clientOptions !== undefined) { - set.clientOptions = ps.clientOptions; + set.clientOptions = { + ...this.serverSettings.clientOptions, + ...ps.clientOptions, + }; } if (ps.cacheRemoteFiles !== undefined) { diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 14286bc23e..ff03fce72b 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -43,7 +43,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private apResolverService: ApResolverService, ) { super(meta, paramDef, async (ps, me) => { - const resolver = this.apResolverService.createResolver(); + const resolver = await this.apResolverService.createResolver(); const object = await resolver.resolve(ps.uri); return object; }); diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index fe48e7497a..47da6b4fbd 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -148,7 +148,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- if (this.utilityService.isSelfHost(host)) return null; // リモートから一旦オブジェクトフェッチ - const resolver = this.apResolverService.createResolver(); + const resolver = await this.apResolverService.createResolver(); // allow ap/show exclusively to lookup URLs that are cross-origin or non-canonical (like https://alice.example.com/@bob@bob.example.com -> https://bob.example.com/@bob) const object = await resolver.resolve(uri, FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId).catch((err) => { if (err instanceof IdentifiableError) { @@ -215,7 +215,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- type: 'Note', object, }; - } catch (e) { + } catch (_) { return null; } } diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts index 30f0c1b0c8..7b2c137bd4 100644 --- a/packages/backend/src/server/api/endpoints/hashtags/users.ts +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -32,6 +32,7 @@ export const paramDef = { properties: { tag: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, @@ -74,7 +75,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; } - const users = await query.limit(ps.limit).getMany(); + const users = await query + .limit(ps.limit) + .offset(ps.offset) + .getMany(); return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' }); }); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 65eece5b97..8dc5cafb56 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 9391aee5e0..050dbaf49e 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -212,7 +212,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index a54c598213..b6c837eda7 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index c350136eae..6e5d9943de 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index b5a53cc889..23b577dc18 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -57,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index bb78d47149..19ea187ee8 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -45,7 +45,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index bfa0b4605d..42324c7778 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -49,7 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index f933eaab00..4fe39bb8e8 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -71,7 +71,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { - const EXTRA_LIMIT = 100; const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : undefined); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : undefined); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index da1faee30d..c2f4281f36 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -91,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 9971a1ea4d..5207d9f2b0 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -323,7 +323,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- try { new RE2(regexp[1], regexp[2]); - } catch (err) { + } catch (_) { throw new ApiError(meta.errors.invalidRegexp); } } @@ -587,7 +587,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- }) .execute(); } - } catch (err) { + } catch (_) { // なにもしない } } diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index 29c6aa7434..7c0dddb827 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -59,7 +59,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw err; }); - const mutedNotes = await this.notesRepository.find({ + const _mutedNotes = await this.notesRepository.find({ where: [{ id: note.threadId ?? note.id, }, { diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 047f9a053b..4defcc9dcf 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -86,7 +86,7 @@ export const paramDef = { sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - birthday: { ...birthdaySchema, nullable: true }, + birthday: { ...birthdaySchema, nullable: true, description: '@deprecated use get-following-users-by-birthday instead.' }, }, }, ], @@ -146,15 +146,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- .andWhere('following.followerId = :userId', { userId: user.id }) .innerJoinAndSelect('following.followee', 'followee'); + // @deprecated use get-following-users-by-birthday instead. if (ps.birthday) { - try { - const birthday = ps.birthday.substring(5, 10); - const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile'); - birthdayUserQuery.select('user_profile.userId') - .where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`); + query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId'); - query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`); - } catch (err) { + try { + const birthday = ps.birthday.split('-'); + birthday.shift(); // 年の部分を削除 + // なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応 + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) }); + } catch (_) { throw new ApiError(meta.errors.birthdayInvalid); } } diff --git a/packages/backend/src/server/api/endpoints/users/get-following-users-by-birthday.ts b/packages/backend/src/server/api/endpoints/users/get-following-users-by-birthday.ts new file mode 100644 index 0000000000..947c19d81e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/get-following-users-by-birthday.ts @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + FollowingsRepository, + UserProfilesRepository, +} from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { Packed } from '@/misc/json-schema.js'; + +export const meta = { + tags: ['users'], + + requireCredential: true, + kind: 'read:account', + + description: 'Retrieve users who have a birthday on the specified range.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + birthday: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, + birthday: { + oneOf: [{ + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, { + type: 'object', + properties: { + begin: { + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, + end: { + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, + }, + required: ['begin', 'end'], + }], + }, + }, + required: ['birthday'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.followingsRepository + .createQueryBuilder('following') + .andWhere('following.followerId = :userId', { userId: me.id }) + .innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId'); + + if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) { + const range = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; }; + + // 誕生日は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)でインデックスが効くようになっているので、その形式に変換 + const begin = range.begin.month * 100 + range.begin.day; + const end = range.end.month * 100 + range.end.day; + + if (begin <= end) { + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin, end }); + } else { + // 12/31 から 1/1 の範囲を取得するために OR で対応 + query.andWhere(new Brackets(qb => { + qb.where('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND 1231', { begin }); + qb.orWhere('get_birthday_date(followeeProfile.birthday) BETWEEN 101 AND :end', { end }); + })); + } + } else { + const { month, day } = ps.birthday as { month: number; day: number }; + // なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応 + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day }); + } + + query.select('following.followeeId', 'user_id'); + query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date'); + query.orderBy('birthday_date', 'ASC'); + + const birthdayUsers = await query + .offset(ps.offset).limit(ps.limit) + .getRawMany<{ birthday_date: number; user_id: string }>(); + + const users = new Map<string, Packed<'UserLite'>>(( + await this.userEntityService.packMany( + birthdayUsers.map(u => u.user_id), + me, + { schema: 'UserLite' }, + ) + ).map(u => [u.id, u])); + + return birthdayUsers + .map(item => { + const birthday = new Date(); + birthday.setHours(0, 0, 0, 0); + // item.birthday_date は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)で出力されるので、日付に戻してDateオブジェクトに設定 + birthday.setMonth(Math.floor(item.birthday_date / 100) - 1, item.birthday_date % 100); + + if (birthday.getTime() < new Date().setHours(0, 0, 0, 0)) { + birthday.setFullYear(new Date().getFullYear() + 1); + } + + const birthdayStr = `${birthday.getFullYear()}-${(birthday.getMonth() + 1).toString().padStart(2, '0')}-${(birthday.getDate()).toString().padStart(2, '0')}`; + return { + id: item.user_id, + birthday: birthdayStr, + user: users.get(item.user_id), + }; + }) + .filter(item => item.user != null) + .map(item => item as { id: string; birthday: string; user: Packed<'UserLite'> }); + }); + } +} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 1cdcbebd1a..0714f61294 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -9,9 +9,8 @@ import { refs } from '@/misc/json-schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema, type: 'param' | 'res', includeSelfRef: boolean): any { // optional, nullable, refはスキーマ定義に含まれないので分離しておく - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { optional, nullable, ref, selfRef, ..._res }: any = schema; - const res = deepClone(_res); + const { optional, nullable, ref, selfRef, ...res1 }: any = schema; + const res = deepClone(res1); if (schema.type === 'object' && schema.properties) { if (type === 'res') { diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index c0ef589dea..63ad9281b2 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -4,72 +4,54 @@ */ import { Injectable } from '@nestjs/common'; +import { HybridTimelineChannel } from './channels/hybrid-timeline.js'; +import { LocalTimelineChannel } from './channels/local-timeline.js'; +import { HomeTimelineChannel } from './channels/home-timeline.js'; +import { GlobalTimelineChannel } from './channels/global-timeline.js'; +import { MainChannel } from './channels/main.js'; +import { ChannelChannel } from './channels/channel.js'; +import { AdminChannel } from './channels/admin.js'; +import { ServerStatsChannel } from './channels/server-stats.js'; +import { QueueStatsChannel } from './channels/queue-stats.js'; +import { UserListChannel } from './channels/user-list.js'; +import { AntennaChannel } from './channels/antenna.js'; +import { DriveChannel } from './channels/drive.js'; +import { HashtagChannel } from './channels/hashtag.js'; +import { RoleTimelineChannel } from './channels/role-timeline.js'; +import { ChatUserChannel } from './channels/chat-user.js'; +import { ChatRoomChannel } from './channels/chat-room.js'; +import { ReversiChannel } from './channels/reversi.js'; +import { ReversiGameChannel } from './channels/reversi-game.js'; +import type { ChannelConstructor } from './channel.js'; import { bindThis } from '@/decorators.js'; -import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; -import { LocalTimelineChannelService } from './channels/local-timeline.js'; -import { HomeTimelineChannelService } from './channels/home-timeline.js'; -import { GlobalTimelineChannelService } from './channels/global-timeline.js'; -import { MainChannelService } from './channels/main.js'; -import { ChannelChannelService } from './channels/channel.js'; -import { AdminChannelService } from './channels/admin.js'; -import { ServerStatsChannelService } from './channels/server-stats.js'; -import { QueueStatsChannelService } from './channels/queue-stats.js'; -import { UserListChannelService } from './channels/user-list.js'; -import { AntennaChannelService } from './channels/antenna.js'; -import { DriveChannelService } from './channels/drive.js'; -import { HashtagChannelService } from './channels/hashtag.js'; -import { RoleTimelineChannelService } from './channels/role-timeline.js'; -import { ChatUserChannelService } from './channels/chat-user.js'; -import { ChatRoomChannelService } from './channels/chat-room.js'; -import { ReversiChannelService } from './channels/reversi.js'; -import { ReversiGameChannelService } from './channels/reversi-game.js'; -import { type MiChannelService } from './channel.js'; @Injectable() export class ChannelsService { constructor( - private mainChannelService: MainChannelService, - private homeTimelineChannelService: HomeTimelineChannelService, - private localTimelineChannelService: LocalTimelineChannelService, - private hybridTimelineChannelService: HybridTimelineChannelService, - private globalTimelineChannelService: GlobalTimelineChannelService, - private userListChannelService: UserListChannelService, - private hashtagChannelService: HashtagChannelService, - private roleTimelineChannelService: RoleTimelineChannelService, - private antennaChannelService: AntennaChannelService, - private channelChannelService: ChannelChannelService, - private driveChannelService: DriveChannelService, - private serverStatsChannelService: ServerStatsChannelService, - private queueStatsChannelService: QueueStatsChannelService, - private adminChannelService: AdminChannelService, - private chatUserChannelService: ChatUserChannelService, - private chatRoomChannelService: ChatRoomChannelService, - private reversiChannelService: ReversiChannelService, - private reversiGameChannelService: ReversiGameChannelService, ) { } @bindThis - public getChannelService(name: string): MiChannelService<boolean> { + public getChannelConstructor(name: string): ChannelConstructor<boolean> { switch (name) { - case 'main': return this.mainChannelService; - case 'homeTimeline': return this.homeTimelineChannelService; - case 'localTimeline': return this.localTimelineChannelService; - case 'hybridTimeline': return this.hybridTimelineChannelService; - case 'globalTimeline': return this.globalTimelineChannelService; - case 'userList': return this.userListChannelService; - case 'hashtag': return this.hashtagChannelService; - case 'roleTimeline': return this.roleTimelineChannelService; - case 'antenna': return this.antennaChannelService; - case 'channel': return this.channelChannelService; - case 'drive': return this.driveChannelService; - case 'serverStats': return this.serverStatsChannelService; - case 'queueStats': return this.queueStatsChannelService; - case 'admin': return this.adminChannelService; - case 'chatUser': return this.chatUserChannelService; - case 'chatRoom': return this.chatRoomChannelService; - case 'reversi': return this.reversiChannelService; - case 'reversiGame': return this.reversiGameChannelService; + case 'main': return MainChannel; + case 'homeTimeline': return HomeTimelineChannel; + case 'localTimeline': return LocalTimelineChannel; + case 'hybridTimeline': return HybridTimelineChannel; + case 'globalTimeline': return GlobalTimelineChannel; + case 'userList': return UserListChannel; + case 'hashtag': return HashtagChannel; + case 'roleTimeline': return RoleTimelineChannel; + case 'antenna': return AntennaChannel; + case 'channel': return ChannelChannel; + case 'drive': return DriveChannel; + case 'serverStats': return ServerStatsChannel; + case 'queueStats': return QueueStatsChannel; + case 'admin': return AdminChannel; + case 'chatUser': return ChatUserChannel; + case 'chatRoom': return ChatRoomChannel; + case 'reversi': return ReversiChannel; + case 'reversiGame': return ReversiGameChannel; default: throw new Error(`no such channel: ${name}`); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 222086c960..5989409997 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -6,19 +6,39 @@ import * as WebSocket from 'ws'; import type { MiUser } from '@/models/User.js'; import type { MiAccessToken } from '@/models/AccessToken.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { NotificationService } from '@/core/NotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { MiFollowing, MiUserProfile } from '@/models/_.js'; import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ChannelMutingService } from '@/core/ChannelMutingService.js'; -import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import type { ChannelsService } from './ChannelsService.js'; +import { isJsonObject } from '@/misc/json-value.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; +import type { ChannelConstructor } from './channel.js'; +import type { ChannelRequest } from './channel.js'; +import { ContextIdFactory, ModuleRef, REQUEST } from '@nestjs/core'; +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { MainChannel } from '@/server/api/stream/channels/main.js'; +import { HomeTimelineChannel } from '@/server/api/stream/channels/home-timeline.js'; +import { LocalTimelineChannel } from '@/server/api/stream/channels/local-timeline.js'; +import { HybridTimelineChannel } from '@/server/api/stream/channels/hybrid-timeline.js'; +import { GlobalTimelineChannel } from '@/server/api/stream/channels/global-timeline.js'; +import { UserListChannel } from '@/server/api/stream/channels/user-list.js'; +import { HashtagChannel } from '@/server/api/stream/channels/hashtag.js'; +import { RoleTimelineChannel } from '@/server/api/stream/channels/role-timeline.js'; +import { AntennaChannel } from '@/server/api/stream/channels/antenna.js'; +import { ChannelChannel } from '@/server/api/stream/channels/channel.js'; +import { DriveChannel } from '@/server/api/stream/channels/drive.js'; +import { ServerStatsChannel } from '@/server/api/stream/channels/server-stats.js'; +import { QueueStatsChannel } from '@/server/api/stream/channels/queue-stats.js'; +import { AdminChannel } from '@/server/api/stream/channels/admin.js'; +import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js'; +import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js'; +import { ReversiChannel } from '@/server/api/stream/channels/reversi.js'; +import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js'; const MAX_CHANNELS_PER_CONNECTION = 32; @@ -26,6 +46,7 @@ const MAX_CHANNELS_PER_CONNECTION = 32; * Main stream connection */ // eslint-disable-next-line import/no-default-export +@Injectable({ scope: Scope.TRANSIENT }) export default class Connection { public user?: MiUser; public token?: MiAccessToken; @@ -44,16 +65,16 @@ export default class Connection { private fetchIntervalId: NodeJS.Timeout | null = null; constructor( - private channelsService: ChannelsService, + private moduleRef: ModuleRef, private notificationService: NotificationService, private cacheService: CacheService, private channelFollowingService: ChannelFollowingService, private channelMutingService: ChannelMutingService, - user: MiUser | null | undefined, - token: MiAccessToken | null | undefined, + @Inject(REQUEST) + request: ConnectionRequest, ) { - if (user) this.user = user; - if (token) this.token = token; + if (request.user) this.user = request.user; + if (request.token) this.token = request.token; } @bindThis @@ -118,7 +139,7 @@ export default class Connection { try { obj = JSON.parse(data.toString()); - } catch (e) { + } catch (_) { return; } @@ -232,28 +253,34 @@ export default class Connection { * チャンネルに接続 */ @bindThis - public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { + public async connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) { return; } - const channelService = this.channelsService.getChannelService(channel); + const channelConstructor = this.getChannelConstructor(channel); - if (channelService.requireCredential && this.user == null) { + if (channelConstructor.requireCredential && this.user == null) { return; } - if (this.token && ((channelService.kind && !this.token.permission.some(p => p === channelService.kind)) - || (!channelService.kind && channelService.requireCredential))) { + if (this.token && ((channelConstructor.kind && !this.token.permission.some(p => p === channelConstructor.kind)) + || (!channelConstructor.kind && channelConstructor.requireCredential))) { return; } // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 - if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) { + if (channelConstructor.shouldShare && this.channels.some(c => c.chName === channel)) { return; } - const ch: Channel = channelService.create(id, this); + const contextId = ContextIdFactory.create(); + this.moduleRef.registerRequestByContextId<ChannelRequest>({ + id: id, + connection: this, + }, contextId); + const ch: Channel = await this.moduleRef.create<Channel>(channelConstructor, contextId); + this.channels.push(ch); ch.init(params ?? {}); @@ -264,6 +291,33 @@ export default class Connection { } } + @bindThis + public getChannelConstructor(name: string): ChannelConstructor<boolean> { + switch (name) { + case 'main': return MainChannel; + case 'homeTimeline': return HomeTimelineChannel; + case 'localTimeline': return LocalTimelineChannel; + case 'hybridTimeline': return HybridTimelineChannel; + case 'globalTimeline': return GlobalTimelineChannel; + case 'userList': return UserListChannel; + case 'hashtag': return HashtagChannel; + case 'roleTimeline': return RoleTimelineChannel; + case 'antenna': return AntennaChannel; + case 'channel': return ChannelChannel; + case 'drive': return DriveChannel; + case 'serverStats': return ServerStatsChannel; + case 'queueStats': return QueueStatsChannel; + case 'admin': return AdminChannel; + case 'chatUser': return ChatUserChannel; + case 'chatRoom': return ChatRoomChannel; + case 'reversi': return ReversiChannel; + case 'reversiGame': return ReversiGameChannel; + + default: + throw new Error(`no such channel: ${name}`); + } + } + /** * チャンネルから切断 * @param id チャンネルコネクションID @@ -306,3 +360,8 @@ export default class Connection { } } } + +export interface ConnectionRequest { + user: MiUser | null | undefined, + token: MiAccessToken | null | undefined, +} diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 465ed4238c..86b073414d 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -22,7 +22,7 @@ export default abstract class Channel { public abstract readonly chName: string; public static readonly shouldShare: boolean; public static readonly requireCredential: boolean; - public static readonly kind?: string | null; + public static readonly kind: string | null; protected get user() { return this.connection.user; @@ -85,9 +85,9 @@ export default abstract class Channel { return false; } - constructor(id: string, connection: Connection) { - this.id = id; - this.connection = connection; + constructor(request: ChannelRequest) { + this.id = request.id; + this.connection = request.connection; } public send(payload: { type: string, body: JsonValue }): void; @@ -111,9 +111,14 @@ export default abstract class Channel { public onMessage?(type: string, body: JsonValue): void; } -export type MiChannelService<T extends boolean> = { +export interface ChannelRequest { + id: string, + connection: Connection, +} + +export interface ChannelConstructor<T extends boolean> { + new(...args: any[]): Channel; shouldShare: boolean; requireCredential: T; kind: T extends true ? string : string | null | undefined; - create: (id: string, connection: Connection) => Channel; -}; +} diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts index 355d5dba21..821888cca0 100644 --- a/packages/backend/src/server/api/stream/channels/admin.ts +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -3,17 +3,26 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class AdminChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class AdminChannel extends Channel { public readonly chName = 'admin'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:admin:stream'; + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); + } + @bindThis public async init(params: JsonObject) { // Subscribe admin stream @@ -22,22 +31,3 @@ class AdminChannel extends Channel { }); } } - -@Injectable() -export class AdminChannelService implements MiChannelService<true> { - public readonly shouldShare = AdminChannel.shouldShare; - public readonly requireCredential = AdminChannel.requireCredential; - public readonly kind = AdminChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): AdminChannel { - return new AdminChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index e08562fdf9..ece9d2c8b1 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -3,14 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class AntennaChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class AntennaChannel extends Channel { public readonly chName = 'antenna'; public static shouldShare = false; public static requireCredential = true as const; @@ -18,12 +20,12 @@ class AntennaChannel extends Channel { private antennaId: string; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onEvent = this.onEvent.bind(this); } @@ -55,24 +57,3 @@ class AntennaChannel extends Channel { this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent); } } - -@Injectable() -export class AntennaChannelService implements MiChannelService<true> { - public readonly shouldShare = AntennaChannel.shouldShare; - public readonly requireCredential = AntennaChannel.requireCredential; - public readonly kind = AntennaChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): AntennaChannel { - return new AntennaChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index c07eaac98d..1706b17526 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -11,20 +11,23 @@ import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ChannelChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ChannelChannel extends Channel { public readonly chName = 'channel'; public static shouldShare = false; public static requireCredential = false as const; private channelId: string; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private noteEntityService: NoteEntityService, - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -92,24 +95,3 @@ class ChannelChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class ChannelChannelService implements MiChannelService<false> { - public readonly shouldShare = ChannelChannel.shouldShare; - public readonly requireCredential = ChannelChannel.requireCredential; - public readonly kind = ChannelChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChannelChannel { - return new ChannelChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts index eda333dd30..7f949032e2 100644 --- a/packages/backend/src/server/api/stream/channels/chat-room.ts +++ b/packages/backend/src/server/api/stream/channels/chat-room.ts @@ -3,14 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; import { ChatService } from '@/core/ChatService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ChatRoomChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ChatRoomChannel extends Channel { public readonly chName = 'chatRoom'; public static shouldShare = false; public static requireCredential = true as const; @@ -18,12 +20,12 @@ class ChatRoomChannel extends Channel { private roomId: string; constructor( - private chatService: ChatService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private chatService: ChatService, ) { - super(id, connection); + super(request); } @bindThis @@ -55,24 +57,3 @@ class ChatRoomChannel extends Channel { this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent); } } - -@Injectable() -export class ChatRoomChannelService implements MiChannelService<true> { - public readonly shouldShare = ChatRoomChannel.shouldShare; - public readonly requireCredential = ChatRoomChannel.requireCredential; - public readonly kind = ChatRoomChannel.kind; - - constructor( - private chatService: ChatService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChatRoomChannel { - return new ChatRoomChannel( - this.chatService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/chat-user.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts index 5323484ed7..36f3f67b28 100644 --- a/packages/backend/src/server/api/stream/channels/chat-user.ts +++ b/packages/backend/src/server/api/stream/channels/chat-user.ts @@ -3,14 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; import { ChatService } from '@/core/ChatService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ChatUserChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ChatUserChannel extends Channel { public readonly chName = 'chatUser'; public static shouldShare = false; public static requireCredential = true as const; @@ -18,12 +20,12 @@ class ChatUserChannel extends Channel { private otherId: string; constructor( - private chatService: ChatService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private chatService: ChatService, ) { - super(id, connection); + super(request); } @bindThis @@ -55,24 +57,3 @@ class ChatUserChannel extends Channel { this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); } } - -@Injectable() -export class ChatUserChannelService implements MiChannelService<true> { - public readonly shouldShare = ChatUserChannel.shouldShare; - public readonly requireCredential = ChatUserChannel.requireCredential; - public readonly kind = ChatUserChannel.kind; - - constructor( - private chatService: ChatService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChatUserChannel { - return new ChatUserChannel( - this.chatService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts index 03768f3d23..6f2eb2c8f9 100644 --- a/packages/backend/src/server/api/stream/channels/drive.ts +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -3,17 +3,26 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class DriveChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class DriveChannel extends Channel { public readonly chName = 'drive'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:account'; + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); + } + @bindThis public async init(params: JsonObject) { // Subscribe drive stream @@ -22,22 +31,3 @@ class DriveChannel extends Channel { }); } } - -@Injectable() -export class DriveChannelService implements MiChannelService<true> { - public readonly shouldShare = DriveChannel.shouldShare; - public readonly requireCredential = DriveChannel.requireCredential; - public readonly kind = DriveChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): DriveChannel { - return new DriveChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index d7c781ad12..be6be1b1e7 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class GlobalTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; public static shouldShare = false; public static requireCredential = false as const; @@ -21,14 +23,14 @@ class GlobalTimelineChannel extends Channel { private withFiles: boolean; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -74,28 +76,3 @@ class GlobalTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class GlobalTimelineChannelService implements MiChannelService<false> { - public readonly shouldShare = GlobalTimelineChannel.shouldShare; - public readonly requireCredential = GlobalTimelineChannel.requireCredential; - public readonly kind = GlobalTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): GlobalTimelineChannel { - return new GlobalTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index c911d63642..1456b4f262 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -3,28 +3,30 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class HashtagChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class HashtagChannel extends Channel { public readonly chName = 'hashtag'; public static shouldShare = false; public static requireCredential = false as const; private q: string[][]; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -62,24 +64,3 @@ class HashtagChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HashtagChannelService implements MiChannelService<false> { - public readonly shouldShare = HashtagChannel.shouldShare; - public readonly requireCredential = HashtagChannel.requireCredential; - public readonly kind = HashtagChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HashtagChannel { - return new HashtagChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index eb5b4a8c6c..665c11b692 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -3,15 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class HomeTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; public static shouldShare = false; public static requireCredential = true as const; @@ -20,12 +22,12 @@ class HomeTimelineChannel extends Channel { private withFiles: boolean; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -98,24 +100,3 @@ class HomeTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HomeTimelineChannelService implements MiChannelService<true> { - public readonly shouldShare = HomeTimelineChannel.shouldShare; - public readonly requireCredential = HomeTimelineChannel.requireCredential; - public readonly kind = HomeTimelineChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HomeTimelineChannel { - return new HomeTimelineChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 2155e02012..54250d2a90 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class HybridTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; public static shouldShare = false; public static requireCredential = true as const; @@ -23,14 +25,14 @@ class HybridTimelineChannel extends Channel { private withFiles: boolean; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -118,28 +120,3 @@ class HybridTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HybridTimelineChannelService implements MiChannelService<true> { - public readonly shouldShare = HybridTimelineChannel.shouldShare; - public readonly requireCredential = HybridTimelineChannel.requireCredential; - public readonly kind = HybridTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HybridTimelineChannel { - return new HybridTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 3d7ed6acdb..b394e9663f 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,25 +11,27 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class LocalTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; - public static shouldShare = false; + public static shouldShare = false as const; public static requireCredential = false as const; private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -84,28 +86,3 @@ class LocalTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class LocalTimelineChannelService implements MiChannelService<false> { - public readonly shouldShare = LocalTimelineChannel.shouldShare; - public readonly requireCredential = LocalTimelineChannel.requireCredential; - public readonly kind = LocalTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): LocalTimelineChannel { - return new LocalTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 525f24c105..2ce53ac288 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -3,26 +3,28 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class MainChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class MainChannel extends Channel { public readonly chName = 'main'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:account'; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); } @bindThis @@ -61,24 +63,3 @@ class MainChannel extends Channel { }); } } - -@Injectable() -export class MainChannelService implements MiChannelService<true> { - public readonly shouldShare = MainChannel.shouldShare; - public readonly requireCredential = MainChannel.requireCredential; - public readonly kind = MainChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): MainChannel { - return new MainChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index 91b62255b4..a87863f26c 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -4,21 +4,26 @@ */ import Xev from 'xev'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; const ev = new Xev(); -class QueueStatsChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class QueueStatsChannel extends Channel { public readonly chName = 'queueStats'; public static shouldShare = true; public static requireCredential = false as const; - constructor(id: string, connection: Channel['connection']) { - super(id, connection); + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); //this.onStats = this.onStats.bind(this); //this.onMessage = this.onMessage.bind(this); } @@ -56,22 +61,3 @@ class QueueStatsChannel extends Channel { ev.removeListener('queueStats', this.onStats); } } - -@Injectable() -export class QueueStatsChannelService implements MiChannelService<false> { - public readonly shouldShare = QueueStatsChannel.shouldShare; - public readonly requireCredential = QueueStatsChannel.requireCredential; - public readonly kind = QueueStatsChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): QueueStatsChannel { - return new QueueStatsChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index 7597a1cfa3..58fc16e98c 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -3,31 +3,32 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { MiReversiGame } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { ReversiService } from '@/core/ReversiService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; import { reversiUpdateKeys } from 'misskey-js'; +import { REQUEST } from '@nestjs/core'; -class ReversiGameChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ReversiGameChannel extends Channel { public readonly chName = 'reversiGame'; public static shouldShare = false; public static requireCredential = false as const; private gameId: MiReversiGame['id'] | null = null; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private reversiService: ReversiService, private reversiGameEntityService: ReversiGameEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); } @bindThis @@ -107,25 +108,3 @@ class ReversiGameChannel extends Channel { } } -@Injectable() -export class ReversiGameChannelService implements MiChannelService<false> { - public readonly shouldShare = ReversiGameChannel.shouldShare; - public readonly requireCredential = ReversiGameChannel.requireCredential; - public readonly kind = ReversiGameChannel.kind; - - constructor( - private reversiService: ReversiService, - private reversiGameEntityService: ReversiGameEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ReversiGameChannel { - return new ReversiGameChannel( - this.reversiService, - this.reversiGameEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/reversi.ts b/packages/backend/src/server/api/stream/channels/reversi.ts index 6e88939724..5eff73eeef 100644 --- a/packages/backend/src/server/api/stream/channels/reversi.ts +++ b/packages/backend/src/server/api/stream/channels/reversi.ts @@ -3,22 +3,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ReversiChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ReversiChannel extends Channel { public readonly chName = 'reversi'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:account'; constructor( - id: string, - connection: Channel['connection'], + @Inject(REQUEST) + request: ChannelRequest, ) { - super(id, connection); + super(request); } @bindThis @@ -32,22 +34,3 @@ class ReversiChannel extends Channel { this.subscriber.off(`reversiStream:${this.user!.id}`, this.send); } } - -@Injectable() -export class ReversiChannelService implements MiChannelService<true> { - public readonly shouldShare = ReversiChannel.shouldShare; - public readonly requireCredential = ReversiChannel.requireCredential; - public readonly kind = ReversiChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ReversiChannel { - return new ReversiChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index fcfa26c38b..99e0b69023 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -3,28 +3,30 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class RoleTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class RoleTimelineChannel extends Channel { public readonly chName = 'roleTimeline'; public static shouldShare = false; public static requireCredential = false as const; private roleId: string; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private noteEntityService: NoteEntityService, private roleservice: RoleService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -60,26 +62,3 @@ class RoleTimelineChannel extends Channel { this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent); } } - -@Injectable() -export class RoleTimelineChannelService implements MiChannelService<false> { - public readonly shouldShare = RoleTimelineChannel.shouldShare; - public readonly requireCredential = RoleTimelineChannel.requireCredential; - public readonly kind = RoleTimelineChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - private roleservice: RoleService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): RoleTimelineChannel { - return new RoleTimelineChannel( - this.noteEntityService, - this.roleservice, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index ec5352d12d..aece5435b0 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -4,21 +4,26 @@ */ import Xev from 'xev'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; const ev = new Xev(); -class ServerStatsChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ServerStatsChannel extends Channel { public readonly chName = 'serverStats'; public static shouldShare = true; public static requireCredential = false as const; - constructor(id: string, connection: Channel['connection']) { - super(id, connection); + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); //this.onStats = this.onStats.bind(this); //this.onMessage = this.onMessage.bind(this); } @@ -54,22 +59,3 @@ class ServerStatsChannel extends Channel { ev.removeListener('serverStats', this.onStats); } } - -@Injectable() -export class ServerStatsChannelService implements MiChannelService<false> { - public readonly shouldShare = ServerStatsChannel.shouldShare; - public readonly requireCredential = ServerStatsChannel.requireCredential; - public readonly kind = ServerStatsChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ServerStatsChannel { - return new ServerStatsChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 5bfd8fa68c..2f7345e150 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,9 +11,11 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class UserListChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class UserListChannel extends Channel { public readonly chName = 'userList'; public static shouldShare = false; public static requireCredential = false as const; @@ -24,14 +26,18 @@ class UserListChannel extends Channel { private withRenotes: boolean; constructor( + @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, + + @Inject(DI.userListMembershipsRepository) private userListMembershipsRepository: UserListMembershipsRepository, - private noteEntityService: NoteEntityService, - id: string, - connection: Channel['connection'], + @Inject(REQUEST) + request: ChannelRequest, + + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.updateListUsers = this.updateListUsers.bind(this); //this.onNote = this.onNote.bind(this); } @@ -130,32 +136,3 @@ class UserListChannel extends Channel { clearInterval(this.listUsersClock); } } - -@Injectable() -export class UserListChannelService implements MiChannelService<false> { - public readonly shouldShare = UserListChannel.shouldShare; - public readonly requireCredential = UserListChannel.requireCredential; - public readonly kind = UserListChannel.kind; - - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): UserListChannel { - return new UserListChannel( - this.userListsRepository, - this.userListMembershipsRepository, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/file/FileServerDriveHandler.ts b/packages/backend/src/server/file/FileServerDriveHandler.ts new file mode 100644 index 0000000000..51b527b146 --- /dev/null +++ b/packages/backend/src/server/file/FileServerDriveHandler.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import rename from 'rename'; +import type { Config } from '@/config.js'; +import type { IImageStreamable } from '@/core/ImageProcessingService.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { attachStreamCleanup, handleRangeRequest, setFileResponseHeaders, getSafeContentType, needsCleanup } from './FileServerUtils.js'; +import type { FileServerFileResolver } from './FileServerFileResolver.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +export class FileServerDriveHandler { + constructor( + private config: Config, + private fileResolver: FileServerFileResolver, + private assetsPath: string, + private videoProcessingService: VideoProcessingService, + ) {} + + public async handle(request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) { + const key = request.params.key; + const file = await this.fileResolver.resolveFileByAccessKey(key); + + if (file.kind === 'not-found') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', this.assetsPath); + } + + if (file.kind === 'unavailable') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + if (file.kind === 'remote') { + let image: IImageStreamable | null = null; + + if (file.fileRole === 'thumbnail') { + if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { + reply.header('Cache-Control', 'max-age=31536000, immutable'); + + const url = new URL(`${this.config.mediaProxy}/static.webp`); + url.searchParams.set('url', file.url); + url.searchParams.set('static', '1'); + + file.cleanup(); + return await reply.redirect(url.toString(), 301); + } else if (file.mime.startsWith('video/')) { + const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); + if (externalThumbnail) { + file.cleanup(); + return await reply.redirect(externalThumbnail, 301); + } + + image = await this.videoProcessingService.generateVideoThumbnail(file.path); + } + } + + if (file.fileRole === 'webpublic') { + if (['image/svg+xml'].includes(file.mime)) { + reply.header('Cache-Control', 'max-age=31536000, immutable'); + + const url = new URL(`${this.config.mediaProxy}/svg.webp`); + url.searchParams.set('url', file.url); + + file.cleanup(); + return await reply.redirect(url.toString(), 301); + } + } + + image ??= { + data: handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path), + ext: file.ext, + type: file.mime, + }; + + attachStreamCleanup(image.data, file.cleanup); + + reply.header('Content-Type', getSafeContentType(image.type)); + reply.header('Content-Length', file.file.size); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext), + ), + ); + return image.data; + } + + if (file.fileRole !== 'original') { + const filename = rename(file.filename, { + suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', + extname: file.ext ? `.${file.ext}` : '.unknown', + }).toString(); + + setFileResponseHeaders(reply, { mime: file.mime, filename }); + return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + } else { + setFileResponseHeaders(reply, { mime: file.file.type, filename: file.filename, size: file.file.size }); + return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + } + } catch (e) { + if (file.kind === 'remote') file.cleanup(); + throw e; + } + } +} diff --git a/packages/backend/src/server/file/FileServerFileResolver.ts b/packages/backend/src/server/file/FileServerFileResolver.ts new file mode 100644 index 0000000000..687d486efd --- /dev/null +++ b/packages/backend/src/server/file/FileServerFileResolver.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js'; +import { createTemp } from '@/misc/create-temp.js'; +import type { DownloadService } from '@/core/DownloadService.js'; +import type { FileInfoService } from '@/core/FileInfoService.js'; +import type { InternalStorageService } from '@/core/InternalStorageService.js'; + +export type DownloadedFileResult = { + kind: 'downloaded'; + mime: string; + ext: string | null; + path: string; + cleanup: () => void; + filename: string; +}; + +export type FileResolveResult = + | { kind: 'not-found' } + | { kind: 'unavailable' } + | { + kind: 'stored'; + fileRole: 'thumbnail' | 'webpublic' | 'original'; + file: MiDriveFile; + filename: string; + mime: string; + ext: string | null; + path: string; + } + | { + kind: 'remote'; + fileRole: 'thumbnail' | 'webpublic' | 'original'; + file: MiDriveFile; + filename: string; + url: string; + mime: string; + ext: string | null; + path: string; + cleanup: () => void; + }; + +export class FileServerFileResolver { + constructor( + private driveFilesRepository: DriveFilesRepository, + private fileInfoService: FileInfoService, + private downloadService: DownloadService, + private internalStorageService: InternalStorageService, + ) {} + + public async downloadAndDetectTypeFromUrl(url: string): Promise<DownloadedFileResult> { + const [path, cleanup] = await createTemp(); + try { + const { filename } = await this.downloadService.downloadUrl(url, path); + + const { mime, ext } = await this.fileInfoService.detectType(path); + + return { + kind: 'downloaded', + mime, ext, + path, cleanup, + filename, + }; + } catch (e) { + cleanup(); + throw e; + } + } + + public async resolveFileByAccessKey(key: string): Promise<FileResolveResult> { + // Fetch drive file + const file = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.accessKey = :accessKey', { accessKey: key }) + .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) + .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) + .getOne(); + + if (file == null) return { kind: 'not-found' }; + + const isThumbnail = file.thumbnailAccessKey === key; + const isWebpublic = file.webpublicAccessKey === key; + + if (!file.storedInternal) { + if (!(file.isLink && file.uri)) return { kind: 'unavailable' }; + const result = await this.downloadAndDetectTypeFromUrl(file.uri); + const { kind: _kind, ...downloaded } = result; + file.size = (await fs.promises.stat(downloaded.path)).size; // DB file.sizeは正確とは限らないので + return { + kind: 'remote', + ...downloaded, + url: file.uri, + fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', + file, + filename: file.name, + }; + } + + const path = this.internalStorageService.resolvePath(key); + + if (isThumbnail || isWebpublic) { + const { mime, ext } = await this.fileInfoService.detectType(path); + return { + kind: 'stored', + fileRole: isThumbnail ? 'thumbnail' : 'webpublic', + file, + filename: file.name, + mime, ext, + path, + }; + } + + return { + kind: 'stored', + fileRole: 'original', + file, + filename: file.name, + // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる + mime: this.fileInfoService.fixMime(file.type), + ext: null, + path, + }; + } +} diff --git a/packages/backend/src/server/file/FileServerProxyHandler.ts b/packages/backend/src/server/file/FileServerProxyHandler.ts new file mode 100644 index 0000000000..41e8e47ba5 --- /dev/null +++ b/packages/backend/src/server/file/FileServerProxyHandler.ts @@ -0,0 +1,272 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import sharp from 'sharp'; +import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; +import type { Config } from '@/config.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { StatusError } from '@/misc/status-error.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; +import { createRangeStream, attachStreamCleanup, needsCleanup } from './FileServerUtils.js'; +import type { DownloadedFileResult, FileResolveResult, FileServerFileResolver } from './FileServerFileResolver.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +type ProxySource = DownloadedFileResult | FileResolveResult; +type CleanupableFile = ProxySource & { cleanup: () => void }; +type AvailableFile = Exclude<ProxySource, { kind: 'not-found' | 'unavailable' }>; +type ProxyQuery = { + emoji?: string; + avatar?: string; + static?: string; + preview?: string; + badge?: string; + origin?: string; + url?: string; +}; + +export class FileServerProxyHandler { + constructor( + private config: Config, + private fileResolver: FileServerFileResolver, + private assetsPath: string, + private imageProcessingService: ImageProcessingService, + ) {} + + public async handle(request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, reply: FastifyReply) { + const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; + + if (typeof url !== 'string') { + reply.code(400); + return; + } + + // アバタークロップなど、どうしてもオリジンである必要がある場合 + const mustOrigin = 'origin' in request.query; + + if (this.config.externalMediaProxyEnabled && !mustOrigin) { + return await this.redirectToExternalProxy(request, reply); + } + + this.validateUserAgent(request); + + // Create temp file + const file = await this.getStreamAndTypeFromUrl(url); + if (file.kind === 'not-found') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', this.assetsPath); + } + + if (file.kind === 'unavailable') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + const image = await this.processImage(file, request, reply); + + if (needsCleanup(file)) { + attachStreamCleanup(image.data, file.cleanup); + } + + reply.header('Content-Type', image.type); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext), + ), + ); + return image.data; + } catch (e) { + if (needsCleanup(file)) file.cleanup(); + throw e; + } + } + + /** + * 外部メディアプロキシにリダイレクトする + */ + private async redirectToExternalProxy( + request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, + reply: FastifyReply, + ) { + reply.header('Cache-Control', 'public, max-age=259200'); // 3 days + + const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); + + for (const [key, value] of Object.entries(request.query)) { + url.searchParams.append(key, value); + } + + return reply.redirect(url.toString(), 301); + } + + /** + * User-Agent を検証する + */ + private validateUserAgent(request: FastifyRequest): void { + if (!request.headers['user-agent']) { + throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); + } + if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { + throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); + } + } + + /** + * 画像を処理してストリーム可能な形式に変換する + */ + private async processImage( + file: AvailableFile, + request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, + reply: FastifyReply, + ): Promise<IImageStreamable> { + const query = request.query; + + const requiresImageConversion = 'emoji' in query || 'avatar' in query || 'static' in query || 'preview' in query || 'badge' in query; + const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); + if (requiresImageConversion && !isConvertibleImage) { + throw new StatusError('Unexpected mime', 404); + } + + if ('emoji' in query || 'avatar' in query) { + return this.processEmojiOrAvatar(file, query); + } + + if ('static' in query) { + return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); + } + + if ('preview' in query) { + return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); + } + + if ('badge' in query) { + return this.processBadge(file); + } + + if (file.mime === 'image/svg+xml') { + return this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); + } + + if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { + throw new StatusError('Rejected type', 403, 'Rejected type'); + } + + return this.createDefaultStream(file, request, reply); + } + + /** + * 絵文字またはアバター用の画像を処理する + */ + private async processEmojiOrAvatar( + file: AvailableFile, + query: Pick<ProxyQuery, 'emoji' | 'avatar' | 'static'>, + ): Promise<IImageStreamable> { + const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); + if (!isAnimationConvertibleImage && !('static' in query)) { + return { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } + + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in query) })) + .resize({ + height: 'emoji' in query ? 128 : 320, + withoutEnlargement: true, + }) + .webp(webpDefault); + + return { + data, + ext: 'webp', + type: 'image/webp', + }; + } + + /** + * バッジ用の画像を処理する + */ + private async processBadge(file: AvailableFile): Promise<IImageStreamable> { + const mask = (await sharpBmp(file.path, file.mime)) + .resize(96, 96, { + fit: 'contain', + position: 'centre', + withoutEnlargement: false, + }) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .toColorspace('b-w'); + + const stats = await mask.clone().stats(); + + if (stats.entropy < 0.1) { + throw new StatusError('Skip to provide badge', 404); + } + + const data = sharp({ + create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(await mask.png().toBuffer(), 'eor'); + + return { + data: await data.png().toBuffer(), + ext: 'png', + type: 'image/png', + }; + } + + /** + * デフォルトのストリームを作成する(Range リクエスト対応) + */ + private createDefaultStream( + file: AvailableFile, + request: FastifyRequest, + reply: FastifyReply, + ): IImageStreamable { + if (request.headers.range && 'file' in file && file.file.size > 0) { + const { stream, start, end, chunksize } = createRangeStream(request.headers.range as string, file.file.size, file.path); + + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + + return { + data: stream, + ext: file.ext, + type: file.mime, + }; + } + + return { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } + + private async getStreamAndTypeFromUrl(url: string): Promise<ProxySource> { + if (url.startsWith(`${this.config.url}/files/`)) { + const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); + if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); + + return await this.fileResolver.resolveFileByAccessKey(key); + } + + return await this.fileResolver.downloadAndDetectTypeFromUrl(url); + } +} diff --git a/packages/backend/src/server/file/FileServerUtils.ts b/packages/backend/src/server/file/FileServerUtils.ts new file mode 100644 index 0000000000..c5995a2cca --- /dev/null +++ b/packages/backend/src/server/file/FileServerUtils.ts @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import type { IImageStreamable } from '@/core/ImageProcessingService.js'; +import type { FastifyReply } from 'fastify'; + +export type RangeStream = { + stream: fs.ReadStream; + start: number; + end: number; + chunksize: number; +}; + +/** + * Range リクエストに対応したストリームを作成する + */ +export function createRangeStream(rangeHeader: string, size: number, path: string): RangeStream { + const parts = rangeHeader.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : size - 1; + if (end > size) { + end = size - 1; + } + const chunksize = end - start + 1; + + return { + stream: fs.createReadStream(path, { start, end }), + start, + end, + chunksize, + }; +} + +/** + * ストリームにcleanupハンドラを設定する + * ストリームでない場合は即座にcleanupを実行する + */ +export function attachStreamCleanup(data: IImageStreamable['data'], cleanup: () => void): void { + if ('pipe' in data && typeof data.pipe === 'function') { + data.on('end', cleanup); + data.on('close', cleanup); + } else { + cleanup(); + } +} + +/** + * MIME タイプがブラウザセーフかどうかに応じて Content-Type を返す + */ +export function getSafeContentType(mime: string): string { + return FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'; +} + +/** + * Range リクエストを処理してストリームを返す + * Range ヘッダーがない場合は通常のストリームを返す + */ +export function handleRangeRequest( + reply: FastifyReply, + rangeHeader: string | undefined, + size: number, + path: string, +): fs.ReadStream { + if (rangeHeader && size > 0) { + const { stream, start, end, chunksize } = createRangeStream(rangeHeader, size, path); + reply.header('Content-Range', `bytes ${start}-${end}/${size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + return stream; + } + return fs.createReadStream(path); +} + +export type FileResponseOptions = { + mime: string; + filename: string; + size?: number; + cacheControl?: string; +}; + +/** + * ファイルレスポンス用の共通ヘッダーを設定する + */ +export function setFileResponseHeaders( + reply: FastifyReply, + options: FileResponseOptions, +): void { + reply.header('Content-Type', getSafeContentType(options.mime)); + reply.header('Cache-Control', options.cacheControl ?? 'max-age=31536000, immutable'); + reply.header('Content-Disposition', contentDisposition('inline', options.filename)); + if (options.size !== undefined) { + reply.header('Content-Length', options.size); + } +} + +/** + * cleanup が必要なファイルかどうかを判定する型ガード + */ +export function needsCleanup<T extends { kind?: string; cleanup?: () => void }>(file: T): file is T & { cleanup: () => void } { + return 'cleanup' in file && typeof file.cleanup === 'function'; +} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index d2391c43ab..840c34b806 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -123,41 +123,86 @@ function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: str return { name, logo }; } -// https://indieauth.spec.indieweb.org/#client-information-discovery -// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, -// and if there is an [h-app] with a url property matching the client_id URL, -// then it should use the name and icon and display them on the authorization prompt." -// (But we don't display any icon for now) -// https://indieauth.spec.indieweb.org/#redirect-url -// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute -// of redirect_uri at the client_id URL. -// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST -// look for an exact match of the given redirect_uri in the request against the list of -// redirect_uris discovered after resolving any relative URLs." async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> { try { const res = await httpRequestService.send(id); + const redirectUris: string[] = []; + let name = id; + let logo: string | null = null; + // https://indieauth.spec.indieweb.org/#redirect-url + // "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute + // of redirect_uri at the client_id URL. + // Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST + // look for an exact match of the given redirect_uri in the request against the list of + // redirect_uris discovered after resolving any relative URLs." const linkHeader = res.headers.get('link'); if (linkHeader) { redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); } - const text = await res.text(); - const doc = htmlParser.parse(`<div>${text}</div>`); + const contentType = res.headers.get('content-type'); + const mediaType = contentType ? contentType.split(';')[0].trim() : null; + if (mediaType === 'application/json') { + // Client discovery via JSON document (11 July 2024 spec) + // https://indieauth.spec.indieweb.org/#client-metadata + // "Clients SHOULD have a JSON [RFC7159] document at their client_id URL containing + // client metadata defined in [RFC7591], the minimum properties for an IndieAuth + // client defined below." - redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href)); + const json = await res.json() as { + client_id: string; + client_name?: string; + client_uri: string; + logo_uri?: string; + redirect_uris?: string[]; + }; - let name = id; - let logo: string | null = null; - if (text) { - const microformats = parseMicroformats(doc, res.url, id); - if (typeof microformats.name === 'string') { - name = microformats.name; + // https://indieauth.spec.indieweb.org/#client-metadata-li-1 + // "The authorization server MUST verify that the client_id in the document matches the + // client_id of the URL where the document was retrieved." + if (json.client_id !== id) { + throw new AuthorizationError('client_id in the document does not match the client_id URL', 'invalid_request'); + } + + // https://indieauth.spec.indieweb.org/#client-metadata-li-1 + // "The client_uri MUST be a prefix of the client_id." + if (!json.client_uri || !id.startsWith(json.client_uri)) { + throw new AuthorizationError('client_uri is not a prefix of client_id', 'invalid_request'); + } + + if (typeof json.client_name === 'string') { + name = json.client_name; } - if (typeof microformats.logo === 'string') { - logo = microformats.logo; + + if (typeof json.logo_uri === 'string') { + // Since uri can be relative, resolve it against the document URL + logo = new URL(json.logo_uri, res.url).toString(); + } + + if (Array.isArray(json.redirect_uris)) { + redirectUris.push(...json.redirect_uris.filter((uri): uri is string => typeof uri === 'string')); + } + } else { + // Client discovery via HTML microformats (12 February 2022 spec) + // https://indieauth.spec.indieweb.org/20220212/#client-information-discovery + // "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, + // and if there is an [h-app] with a url property matching the client_id URL, + // then it should use the name and icon and display them on the authorization prompt." + const text = await res.text(); + const doc = htmlParser.parse(`<div>${text}</div>`); + + redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href)); + + if (text) { + const microformats = parseMicroformats(doc, res.url, id); + if (typeof microformats.name === 'string') { + name = microformats.name; + } + if (typeof microformats.logo === 'string') { + logo = microformats.logo; + } } } @@ -172,6 +217,8 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt logger.error('Error while fetching client information', { err }); if (err instanceof StatusError) { throw new AuthorizationError('Failed to fetch client information', 'invalid_request'); + } else if (err instanceof AuthorizationError) { + throw err; } else { throw new AuthorizationError('Failed to parse client information', 'server_error'); } diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index bcea935409..24bc619e79 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -4,8 +4,9 @@ */ import { randomUUID } from 'node:crypto'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import sharp from 'sharp'; @@ -69,13 +70,28 @@ import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -const staticAssets = `${_dirname}/../../../assets/`; -const clientAssets = `${_dirname}/../../../../frontend/assets/`; -const assets = `${_dirname}/../../../../../built/_frontend_dist_/`; -const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; -const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`; -const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`; -const tarball = `${_dirname}/../../../../../built/tarball/`; +let rootDir = _dirname; +// 見つかるまで上に遡る +while (!fs.existsSync(resolve(rootDir, 'packages'))) { + const parentDir = dirname(rootDir); + if (parentDir === rootDir) { + throw new Error('Cannot find root directory'); + } + rootDir = parentDir; +} + +const backendRootDir = resolve(rootDir, 'packages/backend'); +const frontendRootDir = resolve(rootDir, 'packages/frontend'); + +const staticAssets = resolve(backendRootDir, 'assets'); +const clientAssets = resolve(frontendRootDir, 'assets'); +const assets = resolve(rootDir, 'built/_frontend_dist_'); +const swAssets = resolve(rootDir, 'built/_sw_dist_'); +const fluentEmojisDir = resolve(rootDir, 'fluent-emojis/dist'); +const twemojiDir = resolve(backendRootDir, 'node_modules/@discordapp/twemoji/dist/svg'); +const frontendViteOut = resolve(rootDir, 'built/_frontend_vite_'); +const frontendEmbedViteOut = resolve(rootDir, 'built/_frontend_embed_vite_'); +const tarball = resolve(rootDir, 'built/tarball'); @Injectable() export class ClientServerService { @@ -207,6 +223,7 @@ export class ClientServerService { //#region vite assets if (this.config.frontendEmbedManifestExists) { + console.log(`[ClientServerService] Using built frontend vite assets. ${frontendViteOut}`); fastify.register((fastify, options, done) => { fastify.register(fastifyStatic, { root: frontendViteOut, @@ -226,6 +243,7 @@ export class ClientServerService { done(); }); } else { + console.log('[ClientServerService] Proxying to Vite dev server.'); const urlOriginWithoutPort = configUrl.origin.replace(/:\d+$/, ''); const port = (process.env.VITE_PORT ?? '5173'); @@ -297,7 +315,7 @@ export class ClientServerService { reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - return await reply.sendFile(path, `${_dirname}/../../../../../fluent-emojis/dist/`, { + return reply.sendFile(path, fluentEmojisDir, { maxAge: ms('30 days'), }); }); @@ -312,7 +330,7 @@ export class ClientServerService { reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - return await reply.sendFile(path, `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, { + return reply.sendFile(path, twemojiDir, { maxAge: ms('30 days'), }); }); @@ -326,7 +344,7 @@ export class ClientServerService { } const mask = await sharp( - `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`, + `${twemojiDir}/${path.replace('.png', '')}.svg`, { density: 1000 }, ) .resize(488, 488) @@ -854,9 +872,6 @@ export class ClientServerService { })); }); - const override = (source: string, target: string, depth = 0) => - [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); - fastify.get('/flush', async (request, reply) => { let sendHeader = true; |