summaryrefslogtreecommitdiff
path: root/packages/backend
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2026-03-05 10:56:50 +0000
committerGitHub <noreply@github.com>2026-03-05 10:56:50 +0000
commitfe3dd8edb5f30104cd0a7ed755eb254feda2922d (patch)
treeaf6cf5fa4ca75302ac2de5db742cead00bc13d21 /packages/backend
parentMerge pull request #16998 from misskey-dev/develop (diff)
parentRelease: 2026.3.0 (diff)
downloadmisskey-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')
-rw-r--r--packages/backend/assets/misc/bios.js2
-rw-r--r--packages/backend/build.js121
-rw-r--r--packages/backend/eslint.config.js1
-rw-r--r--packages/backend/migration/1767169026317-birthday-index.js20
-rw-r--r--packages/backend/ormconfig.js4
-rw-r--r--packages/backend/package.json134
-rw-r--r--packages/backend/scripts/check_connect.js30
-rw-r--r--packages/backend/scripts/generate_api_json.js6
-rw-r--r--packages/backend/scripts/measure-memory.mjs163
-rw-r--r--packages/backend/scripts/watch.mjs2
-rw-r--r--packages/backend/src/boot/entry.ts12
-rw-r--r--packages/backend/src/boot/master.ts19
-rw-r--r--packages/backend/src/config.ts34
-rw-r--r--packages/backend/src/core/AccountMoveService.ts2
-rw-r--r--packages/backend/src/core/AnnouncementService.ts2
-rw-r--r--packages/backend/src/core/AvatarDecorationService.ts2
-rw-r--r--packages/backend/src/core/CoreModule.ts4
-rw-r--r--packages/backend/src/core/EmailService.ts2
-rw-r--r--packages/backend/src/core/FileInfoService.ts28
-rw-r--r--packages/backend/src/core/GlobalEventService.ts6
-rw-r--r--packages/backend/src/core/MfmService.ts8
-rw-r--r--packages/backend/src/core/NoteDraftService.ts4
-rw-r--r--packages/backend/src/core/QueueService.ts16
-rw-r--r--packages/backend/src/core/RoleService.ts2
-rw-r--r--packages/backend/src/core/SearchService.ts3
-rw-r--r--packages/backend/src/core/UserSuspendService.ts6
-rw-r--r--packages/backend/src/core/UtilityService.ts2
-rw-r--r--packages/backend/src/core/activitypub/ApInboxService.ts14
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts2
-rw-r--r--packages/backend/src/core/activitypub/ApRequestService.ts2
-rw-r--r--packages/backend/src/core/activitypub/ApResolverService.ts79
-rw-r--r--packages/backend/src/core/activitypub/models/ApImageService.ts2
-rw-r--r--packages/backend/src/core/activitypub/models/ApNoteService.ts2
-rw-r--r--packages/backend/src/core/activitypub/models/ApPersonService.ts8
-rw-r--r--packages/backend/src/core/activitypub/models/ApQuestionService.ts4
-rw-r--r--packages/backend/src/core/entities/ChatEntityService.ts2
-rw-r--r--packages/backend/src/core/entities/DriveFileEntityService.ts45
-rw-r--r--packages/backend/src/core/entities/DriveFolderEntityService.ts154
-rw-r--r--packages/backend/src/core/entities/EmojiEntityService.ts4
-rw-r--r--packages/backend/src/core/entities/MetaEntityService.ts4
-rw-r--r--packages/backend/src/core/entities/NoteReactionEntityService.ts4
-rw-r--r--packages/backend/src/core/entities/ReversiGameEntityService.ts8
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts2
-rw-r--r--packages/backend/src/misc/check-word-mute.ts2
-rw-r--r--packages/backend/src/misc/get-ip-hash.ts2
-rw-r--r--packages/backend/src/misc/i18n.ts2
-rw-r--r--packages/backend/src/misc/json-schema.ts4
-rw-r--r--packages/backend/src/misc/split-id-and-objects.ts27
-rw-r--r--packages/backend/src/misc/unique-by-key.ts21
-rw-r--r--packages/backend/src/models/AbuseReportNotificationRecipient.ts6
-rw-r--r--packages/backend/src/models/AbuseUserReport.ts6
-rw-r--r--packages/backend/src/models/AccessToken.ts4
-rw-r--r--packages/backend/src/models/Announcement.ts2
-rw-r--r--packages/backend/src/models/AnnouncementRead.ts4
-rw-r--r--packages/backend/src/models/Antenna.ts4
-rw-r--r--packages/backend/src/models/App.ts2
-rw-r--r--packages/backend/src/models/AuthSession.ts4
-rw-r--r--packages/backend/src/models/Blocking.ts4
-rw-r--r--packages/backend/src/models/BubbleGameRecord.ts2
-rw-r--r--packages/backend/src/models/Channel.ts4
-rw-r--r--packages/backend/src/models/ChannelFavorite.ts4
-rw-r--r--packages/backend/src/models/ChannelFollowing.ts4
-rw-r--r--packages/backend/src/models/ChannelMuting.ts4
-rw-r--r--packages/backend/src/models/ChatApproval.ts4
-rw-r--r--packages/backend/src/models/ChatMessage.ts8
-rw-r--r--packages/backend/src/models/ChatRoom.ts2
-rw-r--r--packages/backend/src/models/ChatRoomInvitation.ts4
-rw-r--r--packages/backend/src/models/ChatRoomMembership.ts4
-rw-r--r--packages/backend/src/models/Clip.ts2
-rw-r--r--packages/backend/src/models/ClipFavorite.ts4
-rw-r--r--packages/backend/src/models/ClipNote.ts4
-rw-r--r--packages/backend/src/models/DriveFile.ts4
-rw-r--r--packages/backend/src/models/DriveFolder.ts4
-rw-r--r--packages/backend/src/models/Flash.ts2
-rw-r--r--packages/backend/src/models/FlashLike.ts4
-rw-r--r--packages/backend/src/models/FollowRequest.ts4
-rw-r--r--packages/backend/src/models/Following.ts4
-rw-r--r--packages/backend/src/models/GalleryLike.ts4
-rw-r--r--packages/backend/src/models/GalleryPost.ts2
-rw-r--r--packages/backend/src/models/Meta.ts8
-rw-r--r--packages/backend/src/models/ModerationLog.ts2
-rw-r--r--packages/backend/src/models/Muting.ts4
-rw-r--r--packages/backend/src/models/Note.ts8
-rw-r--r--packages/backend/src/models/NoteDraft.ts8
-rw-r--r--packages/backend/src/models/NoteFavorite.ts4
-rw-r--r--packages/backend/src/models/NoteReaction.ts4
-rw-r--r--packages/backend/src/models/NoteThreadMuting.ts2
-rw-r--r--packages/backend/src/models/Page.ts4
-rw-r--r--packages/backend/src/models/PageLike.ts4
-rw-r--r--packages/backend/src/models/PasswordResetRequest.ts2
-rw-r--r--packages/backend/src/models/Poll.ts2
-rw-r--r--packages/backend/src/models/PollVote.ts4
-rw-r--r--packages/backend/src/models/PromoNote.ts2
-rw-r--r--packages/backend/src/models/PromoRead.ts4
-rw-r--r--packages/backend/src/models/RegistrationTicket.ts4
-rw-r--r--packages/backend/src/models/RegistryItem.ts2
-rw-r--r--packages/backend/src/models/RenoteMuting.ts4
-rw-r--r--packages/backend/src/models/ReversiGame.ts4
-rw-r--r--packages/backend/src/models/RoleAssignment.ts4
-rw-r--r--packages/backend/src/models/Signin.ts2
-rw-r--r--packages/backend/src/models/SwSubscription.ts2
-rw-r--r--packages/backend/src/models/SystemAccount.ts2
-rw-r--r--packages/backend/src/models/User.ts4
-rw-r--r--packages/backend/src/models/UserKeypair.ts2
-rw-r--r--packages/backend/src/models/UserList.ts2
-rw-r--r--packages/backend/src/models/UserListFavorite.ts4
-rw-r--r--packages/backend/src/models/UserListMembership.ts4
-rw-r--r--packages/backend/src/models/UserMemo.ts4
-rw-r--r--packages/backend/src/models/UserNotePining.ts4
-rw-r--r--packages/backend/src/models/UserProfile.ts4
-rw-r--r--packages/backend/src/models/UserPublickey.ts2
-rw-r--r--packages/backend/src/models/UserSecurityKey.ts2
-rw-r--r--packages/backend/src/models/Webhook.ts2
-rw-r--r--packages/backend/src/models/json-schema/meta.ts23
-rw-r--r--packages/backend/src/models/json-schema/reversi-game.ts2
-rw-r--r--packages/backend/src/models/json-schema/user.ts3
-rw-r--r--packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts4
-rw-r--r--packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts2
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts2
-rw-r--r--packages/backend/src/server/FileServerService.ts500
-rw-r--r--packages/backend/src/server/NodeinfoServerService.ts2
-rw-r--r--packages/backend/src/server/ServerModule.ts76
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts2
-rw-r--r--packages/backend/src/server/api/SigninApiService.ts2
-rw-r--r--packages/backend/src/server/api/SigninWithPasskeyApiService.ts2
-rw-r--r--packages/backend/src/server/api/SignupApiService.ts2
-rw-r--r--packages/backend/src/server/api/StreamingApiServerService.ts35
-rw-r--r--packages/backend/src/server/api/endpoint-list.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/admin/announcements/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/announcements/list.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/copy.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts34
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/list.ts34
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/get-user-ips.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts3
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts20
-rw-r--r--packages/backend/src/server/api/endpoints/ap/get.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/ap/show.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/hashtags/users.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/key-done.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register-key.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/register.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/2fa/unregister.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/change-password.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/delete-account.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/notifications-grouped.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/update-email.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/users/following.ts17
-rw-r--r--packages/backend/src/server/api/endpoints/users/get-following-users-by-birthday.ts167
-rw-r--r--packages/backend/src/server/api/openapi/schemas.ts5
-rw-r--r--packages/backend/src/server/api/stream/ChannelsService.ts94
-rw-r--r--packages/backend/src/server/api/stream/Connection.ts93
-rw-r--r--packages/backend/src/server/api/stream/channel.ts19
-rw-r--r--packages/backend/src/server/api/stream/channels/admin.ts34
-rw-r--r--packages/backend/src/server/api/stream/channels/antenna.ts37
-rw-r--r--packages/backend/src/server/api/stream/channels/channel.ts36
-rw-r--r--packages/backend/src/server/api/stream/channels/chat-room.ts37
-rw-r--r--packages/backend/src/server/api/stream/channels/chat-user.ts37
-rw-r--r--packages/backend/src/server/api/stream/channels/drive.ts34
-rw-r--r--packages/backend/src/server/api/stream/channels/global-timeline.ts41
-rw-r--r--packages/backend/src/server/api/stream/channels/hashtag.ts37
-rw-r--r--packages/backend/src/server/api/stream/channels/home-timeline.ts37
-rw-r--r--packages/backend/src/server/api/stream/channels/hybrid-timeline.ts41
-rw-r--r--packages/backend/src/server/api/stream/channels/local-timeline.ts43
-rw-r--r--packages/backend/src/server/api/stream/channels/main.ts37
-rw-r--r--packages/backend/src/server/api/stream/channels/queue-stats.ts34
-rw-r--r--packages/backend/src/server/api/stream/channels/reversi-game.ts39
-rw-r--r--packages/backend/src/server/api/stream/channels/reversi.ts33
-rw-r--r--packages/backend/src/server/api/stream/channels/role-timeline.ts39
-rw-r--r--packages/backend/src/server/api/stream/channels/server-stats.ts34
-rw-r--r--packages/backend/src/server/api/stream/channels/user-list.ts49
-rw-r--r--packages/backend/src/server/file/FileServerDriveHandler.ts116
-rw-r--r--packages/backend/src/server/file/FileServerFileResolver.ts126
-rw-r--r--packages/backend/src/server/file/FileServerProxyHandler.ts272
-rw-r--r--packages/backend/src/server/file/FileServerUtils.ts107
-rw-r--r--packages/backend/src/server/oauth/OAuth2ProviderService.ts91
-rw-r--r--packages/backend/src/server/web/ClientServerService.ts43
-rw-r--r--packages/backend/test-federation/compose.tpl.yml4
-rw-r--r--packages/backend/test-federation/compose.yml2
-rw-r--r--packages/backend/test-federation/test/utils.ts75
-rw-r--r--packages/backend/test-server/.swcrc2
-rw-r--r--packages/backend/test/compose.yml8
-rw-r--r--packages/backend/test/e2e/oauth.ts422
-rw-r--r--packages/backend/test/resources/dummy-for-file-server-service.pngbin0 -> 6285 bytes
-rw-r--r--packages/backend/test/tsconfig.json1
-rw-r--r--packages/backend/test/unit/SearchService.ts483
-rw-r--r--packages/backend/test/unit/entities/DriveFileEntityService.ts227
-rw-r--r--packages/backend/test/unit/entities/DriveFolderEntityService.ts171
-rw-r--r--packages/backend/test/unit/server/FileServerService.ts770
-rw-r--r--packages/backend/test/utils.ts43
-rw-r--r--packages/backend/tsconfig.json1
195 files changed, 4197 insertions, 1900 deletions
diff --git a/packages/backend/assets/misc/bios.js b/packages/backend/assets/misc/bios.js
index 9ff5dca72a..f9716d8f00 100644
--- a/packages/backend/assets/misc/bios.js
+++ b/packages/backend/assets/misc/bios.js
@@ -9,7 +9,7 @@ window.onload = async () => {
const account = JSON.parse(localStorage.getItem('account'));
const i = account.token;
- const api = (endpoint, data = {}) => {
+ const _api = (endpoint, data = {}) => {
const promise = new Promise((resolve, reject) => {
// Append a credential
if (i) data.i = i;
diff --git a/packages/backend/build.js b/packages/backend/build.js
new file mode 100644
index 0000000000..52ca09b7a8
--- /dev/null
+++ b/packages/backend/build.js
@@ -0,0 +1,121 @@
+import fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { build } from 'esbuild';
+import { swcPlugin } from 'esbuild-plugin-swc';
+
+const _filename = fileURLToPath(import.meta.url);
+const _dirname = dirname(_filename);
+const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
+
+const resolveTsPathsPlugin = {
+ name: 'resolve-ts-paths',
+ setup(build) {
+ build.onResolve({ filter: /^\.{1,2}\/.*\.js$/ }, (args) => {
+ if (args.importer) {
+ const absPath = join(args.resolveDir, args.path);
+ const tsPath = absPath.slice(0, -3) + '.ts';
+ if (fs.existsSync(tsPath)) return { path: tsPath };
+ const tsxPath = absPath.slice(0, -3) + '.tsx';
+ if (fs.existsSync(tsxPath)) return { path: tsxPath };
+ }
+ });
+ },
+};
+
+const externalIpaddrPlugin = {
+ name: 'external-ipaddr',
+ setup(build) {
+ build.onResolve({ filter: /^ipaddr\.js$/ }, (args) => {
+ return { path: args.path, external: true };
+ });
+ },
+};
+
+/** @type {import('esbuild').BuildOptions} */
+const options = {
+ entryPoints: ['./src/boot/entry.ts'],
+ minify: true,
+ keepNames: true,
+ bundle: true,
+ outdir: './built/boot',
+ target: 'node22',
+ platform: 'node',
+ format: 'esm',
+ sourcemap: 'linked',
+ packages: 'external',
+ banner: {
+ js: 'import { createRequire as topLevelCreateRequire } from "module";' +
+ 'import ___url___ from "url";' +
+ 'const require = topLevelCreateRequire(import.meta.url);' +
+ 'const __filename = ___url___.fileURLToPath(import.meta.url);' +
+ 'const __dirname = ___url___.fileURLToPath(new URL(".", import.meta.url));',
+ },
+ plugins: [
+ externalIpaddrPlugin,
+ resolveTsPathsPlugin,
+ swcPlugin({
+ jsc: {
+ parser: {
+ syntax: 'typescript',
+ decorators: true,
+ dynamicImport: true,
+ },
+ transform: {
+ legacyDecorator: true,
+ decoratorMetadata: true,
+ },
+ experimental: {
+ keepImportAssertions: true,
+ },
+ baseUrl: join(_dirname, 'src'),
+ paths: {
+ '@/*': ['*'],
+ },
+ target: 'esnext',
+ keepClassNames: true,
+ },
+ }),
+ externalIpaddrPlugin,
+ ],
+ // external: [
+ // 'slacc-*',
+ // 'class-transformer',
+ // 'class-validator',
+ // '@sentry/*',
+ // '@nestjs/websockets/socket-module',
+ // '@nestjs/microservices/microservices-module',
+ // '@nestjs/microservices',
+ // '@napi-rs/canvas-win32-x64-msvc',
+ // 'mock-aws-s3',
+ // 'aws-sdk',
+ // 'nock',
+ // 'sharp',
+ // 'jsdom',
+ // 're2',
+ // '@napi-rs/canvas',
+ // ],
+};
+
+const args = process.argv.slice(2).map(arg => arg.toLowerCase());
+
+if (!args.includes('--no-clean')) {
+ fs.rmSync('./built', { recursive: true, force: true });
+}
+
+await buildSrc();
+
+async function buildSrc() {
+ console.log(`[${_package.name}] start building...`);
+
+ await build(options)
+ .then(() => {
+ console.log(`[${_package.name}] build succeeded.`);
+ })
+ .catch((err) => {
+ process.stderr.write(err.stderr || err.message || err);
+ process.exit(1);
+ });
+
+ console.log(`[${_package.name}] finish building.`);
+}
diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js
index ba7c705def..d15a703ba2 100644
--- a/packages/backend/eslint.config.js
+++ b/packages/backend/eslint.config.js
@@ -25,7 +25,6 @@ export default [
},
},
rules: {
- '@typescript-eslint/no-unused-vars': 'off',
'import/order': ['warn', {
groups: [
'builtin',
diff --git a/packages/backend/migration/1767169026317-birthday-index.js b/packages/backend/migration/1767169026317-birthday-index.js
new file mode 100644
index 0000000000..972fc08c9b
--- /dev/null
+++ b/packages/backend/migration/1767169026317-birthday-index.js
@@ -0,0 +1,20 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class BirthdayIndex1767169026317 {
+ name = 'BirthdayIndex1767169026317'
+
+ async up(queryRunner) {
+ await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
+ await queryRunner.query(`CREATE OR REPLACE FUNCTION get_birthday_date(birthday TEXT) RETURNS SMALLINT AS $$ BEGIN RETURN CAST((SUBSTR(birthday, 6, 2) || SUBSTR(birthday, 9, 2)) AS SMALLINT); END; $$ LANGUAGE plpgsql IMMUTABLE;`);
+ await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`);
+ await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`);
+ }
+}
diff --git a/packages/backend/ormconfig.js b/packages/backend/ormconfig.js
index dabc0893f4..1a8c146451 100644
--- a/packages/backend/ormconfig.js
+++ b/packages/backend/ormconfig.js
@@ -1,6 +1,6 @@
import { DataSource } from 'typeorm';
-import { loadConfig } from './built/config.js';
-import { entities } from './built/postgres.js';
+import { loadConfig } from './src-js/config.js';
+import { entities } from './src-js/postgres.js';
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
diff --git a/packages/backend/package.json b/packages/backend/package.json
index c7a8a6c223..f5270ea554 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -12,17 +12,17 @@
"start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js",
- "cli": "pnpm compile-config && node ./built/boot/cli.js",
+ "cli": "pnpm compile-config && node ./src-js/boot/cli.js",
"check:connect": "pnpm compile-config && node ./scripts/check_connect.js",
"compile-config": "node ./scripts/compile_config.js",
- "build": "swc src -d built -D --strip-leading-paths",
+ "build": "swc src -d src-js -D --strip-leading-paths && node ./build.js",
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
"watch:swc": "swc src -d built -D -w --strip-leading-paths",
- "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
+ "build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "pnpm compile-config && node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start",
"dev": "pnpm compile-config && node ./scripts/dev.mjs",
- "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
+ "typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
@@ -41,20 +41,20 @@
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
- "@swc/core-darwin-arm64": "1.15.3",
- "@swc/core-darwin-x64": "1.15.3",
+ "@swc/core-darwin-arm64": "1.15.11",
+ "@swc/core-darwin-x64": "1.15.11",
"@swc/core-freebsd-x64": "1.3.11",
- "@swc/core-linux-arm-gnueabihf": "1.15.3",
- "@swc/core-linux-arm64-gnu": "1.15.3",
- "@swc/core-linux-arm64-musl": "1.15.3",
- "@swc/core-linux-x64-gnu": "1.15.3",
- "@swc/core-linux-x64-musl": "1.15.3",
- "@swc/core-win32-arm64-msvc": "1.15.3",
- "@swc/core-win32-ia32-msvc": "1.15.3",
- "@swc/core-win32-x64-msvc": "1.15.3",
+ "@swc/core-linux-arm-gnueabihf": "1.15.11",
+ "@swc/core-linux-arm64-gnu": "1.15.11",
+ "@swc/core-linux-arm64-musl": "1.15.11",
+ "@swc/core-linux-x64-gnu": "1.15.11",
+ "@swc/core-linux-x64-musl": "1.15.11",
+ "@swc/core-win32-arm64-msvc": "1.15.11",
+ "@swc/core-win32-ia32-msvc": "1.15.11",
+ "@swc/core-win32-x64-msvc": "1.15.11",
"@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0",
- "bufferutil": "4.0.9",
+ "bufferutil": "4.1.0",
"slacc-android-arm-eabi": "0.0.10",
"slacc-android-arm64": "0.0.10",
"slacc-darwin-arm64": "0.0.10",
@@ -68,43 +68,42 @@
"slacc-linux-x64-musl": "0.0.10",
"slacc-win32-arm64-msvc": "0.0.10",
"slacc-win32-x64-msvc": "0.0.10",
- "utf-8-validate": "6.0.5"
+ "utf-8-validate": "6.0.6"
},
"dependencies": {
- "@aws-sdk/client-s3": "3.948.0",
- "@aws-sdk/lib-storage": "3.948.0",
+ "@aws-sdk/client-s3": "3.995.0",
+ "@aws-sdk/lib-storage": "3.995.0",
"@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
- "@fastify/express": "4.0.2",
+ "@fastify/express": "4.0.4",
"@fastify/http-proxy": "11.4.1",
- "@fastify/multipart": "9.3.0",
- "@fastify/static": "8.3.0",
- "@kitajs/html": "4.2.11",
+ "@fastify/multipart": "9.4.0",
+ "@fastify/static": "9.0.0",
+ "@kitajs/html": "4.2.13",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.2.5",
- "@napi-rs/canvas": "0.1.84",
- "@nestjs/common": "11.1.9",
- "@nestjs/core": "11.1.9",
- "@nestjs/testing": "11.1.9",
+ "@napi-rs/canvas": "0.1.94",
+ "@nestjs/common": "11.1.14",
+ "@nestjs/core": "11.1.14",
+ "@nestjs/testing": "11.1.14",
"@peertube/http-signature": "1.7.0",
- "@sentry/node": "10.29.0",
- "@sentry/profiling-node": "10.29.0",
+ "@sentry/node": "10.39.0",
+ "@sentry/profiling-node": "10.39.0",
"@simplewebauthn/server": "13.2.2",
- "@sinonjs/fake-timers": "15.0.0",
- "@smithy/node-http-handler": "4.4.5",
- "@swc/cli": "0.7.9",
- "@swc/core": "1.15.3",
+ "@sinonjs/fake-timers": "15.1.0",
+ "@smithy/node-http-handler": "4.4.10",
+ "@swc/cli": "0.8.0",
+ "@swc/core": "1.15.11",
"@twemoji/parser": "16.0.0",
- "@types/redis-info": "3.0.3",
"accepts": "1.3.8",
- "ajv": "8.17.1",
+ "ajv": "8.18.0",
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
- "body-parser": "2.2.1",
- "bullmq": "5.65.1",
+ "body-parser": "2.2.2",
+ "bullmq": "5.69.4",
"cacheable-lookup": "7.0.0",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
@@ -113,24 +112,24 @@
"content-disposition": "1.0.1",
"date-fns": "4.1.0",
"deep-email-validator": "0.1.21",
- "fastify": "5.6.2",
+ "fastify": "5.7.4",
"fastify-raw-body": "5.0.0",
- "feed": "5.1.0",
- "file-type": "21.1.1",
+ "feed": "5.2.0",
+ "file-type": "21.3.0",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.5",
- "got": "14.6.5",
+ "got": "14.6.6",
"hpagent": "1.2.0",
"http-link-header": "1.1.3",
"i18n": "workspace:*",
- "ioredis": "5.8.2",
+ "ioredis": "5.9.3",
"ip-cidr": "4.0.2",
"ipaddr.js": "2.3.0",
"is-svg": "6.1.0",
"json5": "2.2.3",
"jsonld": "9.0.0",
- "juice": "11.0.3",
- "meilisearch": "0.54.0",
+ "juice": "11.1.1",
+ "meilisearch": "0.55.0",
"mfm-js": "0.25.0",
"mime-types": "3.0.2",
"misskey-js": "workspace:*",
@@ -139,50 +138,48 @@
"nanoid": "5.1.6",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
- "node-html-parser": "7.0.1",
- "nodemailer": "7.0.11",
+ "node-html-parser": "7.0.2",
+ "nodemailer": "8.0.1",
"nsfwjs": "4.2.0",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
- "otpauth": "9.4.1",
- "pg": "8.16.3",
- "pkce-challenge": "5.0.1",
+ "otpauth": "9.5.0",
+ "pg": "8.18.0",
+ "pkce-challenge": "6.0.0",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"qrcode": "1.5.4",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
- "re2": "1.22.3",
- "redis-info": "3.1.0",
+ "re2": "1.23.3",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.2",
- "sanitize-html": "2.17.0",
+ "sanitize-html": "2.17.1",
"secure-json-parse": "4.1.0",
- "semver": "7.7.3",
+ "semver": "7.7.4",
"sharp": "0.33.5",
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
- "systeminformation": "5.27.14",
+ "systeminformation": "5.31.1",
"tinycolor2": "1.6.0",
"tmp": "0.2.5",
"tsc-alias": "1.8.16",
"typeorm": "0.3.28",
- "typescript": "5.9.3",
"ulid": "3.0.2",
"vary": "1.1.2",
"web-push": "3.6.7",
- "ws": "8.18.3",
+ "ws": "8.19.0",
"xev": "3.0.2"
},
"devDependencies": {
"@jest/globals": "29.7.0",
- "@kitajs/ts-html-plugin": "4.1.3",
- "@nestjs/platform-express": "11.1.9",
- "@sentry/vue": "10.29.0",
+ "@kitajs/ts-html-plugin": "4.1.4",
+ "@nestjs/platform-express": "11.1.14",
+ "@sentry/vue": "10.39.0",
"@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.39",
"@types/accepts": "1.3.7",
@@ -196,11 +193,11 @@
"@types/jsonld": "1.5.15",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
- "@types/node": "24.10.2",
- "@types/nodemailer": "7.0.4",
+ "@types/node": "24.10.13",
+ "@types/nodemailer": "7.0.11",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
- "@types/pg": "8.15.6",
+ "@types/pg": "8.16.0",
"@types/qrcode": "1.5.6",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
@@ -215,21 +212,22 @@
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
- "@typescript-eslint/eslint-plugin": "8.49.0",
- "@typescript-eslint/parser": "8.49.0",
+ "@typescript-eslint/eslint-plugin": "8.56.0",
+ "@typescript-eslint/parser": "8.56.0",
"aws-sdk-client-mock": "4.1.0",
"cbor": "10.0.11",
"cross-env": "10.1.0",
+ "esbuild-plugin-swc": "1.0.1",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
- "fkill": "10.0.1",
+ "fkill": "10.0.3",
"jest": "29.7.0",
"jest-mock": "29.7.0",
"js-yaml": "4.1.1",
- "nodemon": "3.1.11",
- "pid-port": "2.0.0",
+ "nodemon": "3.1.14",
+ "pid-port": "2.0.1",
"simple-oauth2": "5.1.0",
- "supertest": "7.1.4",
- "vite": "7.2.7"
+ "supertest": "7.2.2",
+ "vite": "7.3.1"
}
}
diff --git a/packages/backend/scripts/check_connect.js b/packages/backend/scripts/check_connect.js
index 96c4549ccb..a1cb839303 100644
--- a/packages/backend/scripts/check_connect.js
+++ b/packages/backend/scripts/check_connect.js
@@ -4,8 +4,8 @@
*/
import Redis from 'ioredis';
-import { loadConfig } from '../built/config.js';
-import { createPostgresDataSource } from '../built/postgres.js';
+import { loadConfig } from '../src-js/config.js';
+import { createPostgresDataSource } from '../src-js/postgres.js';
const config = loadConfig();
@@ -16,26 +16,22 @@ async function connectToPostgres() {
}
async function connectToRedis(redisOptions) {
- return await new Promise(async (resolve, reject) => {
- const redis = new Redis({
+ let redis;
+ try {
+ redis = new Redis({
...redisOptions,
lazyConnect: true,
reconnectOnError: false,
showFriendlyErrorStack: true,
});
- redis.on('error', e => reject(e));
- try {
- await redis.connect();
- resolve();
-
- } catch (e) {
- reject(e);
-
- } finally {
- redis.disconnect(false);
- }
- });
+ await Promise.race([
+ new Promise((_, reject) => redis.on('error', e => reject(e))),
+ redis.connect(),
+ ]);
+ } finally {
+ redis.disconnect(false);
+ }
}
// If not all of these are defined, the default one gets reused.
@@ -50,7 +46,7 @@ const promises = Array
]))
.map(connectToRedis)
.concat([
- connectToPostgres()
+ connectToPostgres(),
]);
await Promise.all(promises);
diff --git a/packages/backend/scripts/generate_api_json.js b/packages/backend/scripts/generate_api_json.js
index 798e243004..237f63a4d3 100644
--- a/packages/backend/scripts/generate_api_json.js
+++ b/packages/backend/scripts/generate_api_json.js
@@ -3,8 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import { writeFileSync, existsSync } from 'node:fs';
import { execa } from 'execa';
-import { writeFileSync, existsSync } from "node:fs";
async function main() {
if (!process.argv.includes('--no-build')) {
@@ -19,10 +19,10 @@ async function main() {
}
/** @type {import('../src/config.js')} */
- const { loadConfig } = await import('../built/config.js');
+ const { loadConfig } = await import('../src-js/config.js');
/** @type {import('../src/server/api/openapi/gen-spec.js')} */
- const { genOpenapiSpec } = await import('../built/server/api/openapi/gen-spec.js');
+ const { genOpenapiSpec } = await import('../src-js/server/api/openapi/gen-spec.js');
const config = loadConfig();
const spec = genOpenapiSpec(config, true);
diff --git a/packages/backend/scripts/measure-memory.mjs b/packages/backend/scripts/measure-memory.mjs
index 017252d7ec..3f30e24fb4 100644
--- a/packages/backend/scripts/measure-memory.mjs
+++ b/packages/backend/scripts/measure-memory.mjs
@@ -14,24 +14,56 @@ import { fork } from 'node:child_process';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
+import * as http from 'node:http';
+import * as fs from 'node:fs/promises';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
+const SAMPLE_COUNT = 3; // Number of samples to measure
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
-async function measureMemory() {
- const startTime = Date.now();
+const keys = {
+ VmPeak: 0,
+ VmSize: 0,
+ VmHWM: 0,
+ VmRSS: 0,
+ VmData: 0,
+ VmStk: 0,
+ VmExe: 0,
+ VmLib: 0,
+ VmPTE: 0,
+ VmSwap: 0,
+};
+
+async function getMemoryUsage(pid) {
+ const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
+
+ const result = {};
+ for (const key of Object.keys(keys)) {
+ const match = status.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
+ if (match) {
+ result[key] = parseInt(match[1], 10);
+ } else {
+ throw new Error(`Failed to parse ${key} from /proc/${pid}/status`);
+ }
+ }
+ return result;
+}
+
+async function measureMemory() {
// Start the Misskey backend server using fork to enable IPC
- const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), [], {
+ const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), ['expose-gc'], {
cwd: join(__dirname, '..'),
env: {
...process.env,
- NODE_ENV: 'test',
+ NODE_ENV: 'production',
+ MK_DISABLE_CLUSTERING: '1',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
+ execArgv: [...process.execArgv, '--expose-gc'],
});
let serverReady = false;
@@ -57,6 +89,40 @@ async function measureMemory() {
process.stderr.write(`[server error] ${err}\n`);
});
+ async function triggerGc() {
+ const ok = new Promise((resolve) => {
+ serverProcess.once('message', (message) => {
+ if (message === 'gc ok') resolve();
+ });
+ });
+
+ serverProcess.send('gc');
+
+ await ok;
+
+ await setTimeout(1000);
+ }
+
+ function createRequest() {
+ return new Promise((resolve, reject) => {
+ const req = http.request({
+ host: 'localhost',
+ port: 61812,
+ path: '/api/meta',
+ method: 'POST',
+ }, (res) => {
+ res.on('data', () => { });
+ res.on('end', () => {
+ resolve();
+ });
+ });
+ req.on('error', (err) => {
+ reject(err);
+ });
+ req.end();
+ });
+ }
+
// Wait for server to be ready or timeout
const startupStartTime = Date.now();
while (!serverReady) {
@@ -73,46 +139,23 @@ async function measureMemory() {
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
- // Get memory usage from the server process via /proc
const pid = serverProcess.pid;
- let memoryInfo;
- try {
- const fs = await import('node:fs/promises');
+ const beforeGc = await getMemoryUsage(pid);
- // Read /proc/[pid]/status for detailed memory info
- const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
- const vmRssMatch = status.match(/VmRSS:\s+(\d+)\s+kB/);
- const vmDataMatch = status.match(/VmData:\s+(\d+)\s+kB/);
- const vmSizeMatch = status.match(/VmSize:\s+(\d+)\s+kB/);
+ await triggerGc();
- memoryInfo = {
- rss: vmRssMatch ? parseInt(vmRssMatch[1], 10) * 1024 : null,
- heapUsed: vmDataMatch ? parseInt(vmDataMatch[1], 10) * 1024 : null,
- vmSize: vmSizeMatch ? parseInt(vmSizeMatch[1], 10) * 1024 : null,
- };
- } catch (err) {
- // Fallback: use ps command
- process.stderr.write(`Warning: Could not read /proc/${pid}/status: ${err}\n`);
+ const afterGc = await getMemoryUsage(pid);
- const { execSync } = await import('node:child_process');
- try {
- const ps = execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' });
- const rssKb = parseInt(ps.trim(), 10);
- memoryInfo = {
- rss: rssKb * 1024,
- heapUsed: null,
- vmSize: null,
- };
- } catch {
- memoryInfo = {
- rss: null,
- heapUsed: null,
- vmSize: null,
- error: 'Could not measure memory',
- };
- }
- }
+ // create some http requests to simulate load
+ const REQUEST_COUNT = 10;
+ await Promise.all(
+ Array.from({ length: REQUEST_COUNT }).map(() => createRequest()),
+ );
+
+ await triggerGc();
+
+ const afterRequest = await getMemoryUsage(pid);
// Stop the server
serverProcess.kill('SIGTERM');
@@ -135,15 +178,51 @@ async function measureMemory() {
const result = {
timestamp: new Date().toISOString(),
- startupTimeMs: startupTime,
- memory: memoryInfo,
+ beforeGc,
+ afterGc,
+ afterRequest,
+ };
+
+ return result;
+}
+
+async function main() {
+ // 直列の方が時間的に分散されて正確そうだから直列でやる
+ const results = [];
+ for (let i = 0; i < SAMPLE_COUNT; i++) {
+ const res = await measureMemory();
+ results.push(res);
+ }
+
+ // Calculate averages
+ const beforeGc = structuredClone(keys);
+ const afterGc = structuredClone(keys);
+ const afterRequest = structuredClone(keys);
+ for (const res of results) {
+ for (const key of Object.keys(keys)) {
+ beforeGc[key] += res.beforeGc[key];
+ afterGc[key] += res.afterGc[key];
+ afterRequest[key] += res.afterRequest[key];
+ }
+ }
+ for (const key of Object.keys(keys)) {
+ beforeGc[key] = Math.round(beforeGc[key] / SAMPLE_COUNT);
+ afterGc[key] = Math.round(afterGc[key] / SAMPLE_COUNT);
+ afterRequest[key] = Math.round(afterRequest[key] / SAMPLE_COUNT);
+ }
+
+ const result = {
+ timestamp: new Date().toISOString(),
+ beforeGc,
+ afterGc,
+ afterRequest,
};
// Output as JSON to stdout
console.log(JSON.stringify(result, null, 2));
}
-measureMemory().catch((err) => {
+main().catch((err) => {
console.error(JSON.stringify({
error: err.message,
timestamp: new Date().toISOString(),
diff --git a/packages/backend/scripts/watch.mjs b/packages/backend/scripts/watch.mjs
index a0ccea3b16..9d608b233c 100644
--- a/packages/backend/scripts/watch.mjs
+++ b/packages/backend/scripts/watch.mjs
@@ -21,7 +21,7 @@ import { execa } from 'execa';
});
}, 3000);
- execa('tsc', ['-w', '-p', 'tsconfig.json'], {
+ execa('tsgo', ['-w', '-p', 'tsconfig.json'], {
stdout: process.stdout,
stderr: process.stderr,
});
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;
diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml
index 1404345e2a..ac93b24b87 100644
--- a/packages/backend/test-federation/compose.tpl.yml
+++ b/packages/backend/test-federation/compose.tpl.yml
@@ -35,6 +35,10 @@ services:
target: /misskey/packages/backend/built
read_only: true
- type: bind
+ source: ../src-js
+ target: /misskey/packages/backend/src-js
+ read_only: true
+ - type: bind
source: ../migration
target: /misskey/packages/backend/migration
read_only: true
diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml
index 25475a89ab..4d1b4b0d60 100644
--- a/packages/backend/test-federation/compose.yml
+++ b/packages/backend/test-federation/compose.yml
@@ -143,7 +143,7 @@ services:
bash -c "
npm install -g pnpm
pnpm -F backend i --frozen-lockfile
- pnpm exec tsc -p ./packages/backend/test-federation
+ pnpm exec tsgo -p ./packages/backend/test-federation
node ./packages/backend/test-federation/built/daemon.js
"
diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts
index 056a16ba15..6f09f13f17 100644
--- a/packages/backend/test-federation/test/utils.ts
+++ b/packages/backend/test-federation/test/utils.ts
@@ -234,30 +234,26 @@ export async function isFired<C extends keyof Misskey.Channels, T extends keyof
cond: (msg: Parameters<Misskey.Channels[C]['events'][T]>[0]) => boolean,
params?: Misskey.Channels[C]['params'],
): Promise<boolean> {
- return new Promise<boolean>(async (resolve, reject) => {
- const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
+ const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
+ try {
const connection = stream.useChannel(channel, params);
- connection.on(type as any, ((msg: any) => {
- if (cond(msg)) {
- stream.close();
- clearTimeout(timer);
- resolve(true);
- }
- }) as any);
-
- let timer: NodeJS.Timeout | undefined;
- await trigger().then(() => {
- timer = setTimeout(() => {
- stream.close();
- resolve(false);
- }, 500);
- }).catch(err => {
- stream.close();
- clearTimeout(timer);
- reject(err);
+ const receivePromise = new Promise<boolean>((resolve) => {
+ connection.on(type as never, ((msg: any) => {
+ if (cond(msg)) {
+ resolve(true);
+ }
+ }) as any);
});
- });
+
+ await trigger();
+ return await Promise.race([
+ receivePromise,
+ sleep(500).then(() => false),
+ ]);
+ } finally {
+ stream.close();
+ }
};
export async function isNoteUpdatedEventFired(
@@ -267,30 +263,27 @@ export async function isNoteUpdatedEventFired(
trigger: () => Promise<unknown>,
cond: (msg: Parameters<Misskey.StreamEvents['noteUpdated']>[0]) => boolean,
): Promise<boolean> {
- return new Promise<boolean>(async (resolve, reject) => {
- const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
+ const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket });
+ try {
stream.send('s', { id: noteId });
- stream.on('noteUpdated', msg => {
- if (cond(msg)) {
- stream.close();
- clearTimeout(timer);
- resolve(true);
- }
+
+ const receivePromise = new Promise<boolean>((resolve) => {
+ stream.on('noteUpdated', msg => {
+ if (cond(msg)) {
+ resolve(true);
+ }
+ });
});
- let timer: NodeJS.Timeout | undefined;
+ await trigger();
- await trigger().then(() => {
- timer = setTimeout(() => {
- stream.close();
- resolve(false);
- }, 500);
- }).catch(err => {
- stream.close();
- clearTimeout(timer);
- reject(err);
- });
- });
+ return await Promise.race([
+ receivePromise,
+ sleep(500).then(() => false),
+ ]);
+ } finally {
+ stream.close();
+ }
};
export async function assertNotificationReceived(
diff --git a/packages/backend/test-server/.swcrc b/packages/backend/test-server/.swcrc
index eeac7eabc6..3859603da3 100644
--- a/packages/backend/test-server/.swcrc
+++ b/packages/backend/test-server/.swcrc
@@ -13,7 +13,7 @@
"experimental": {
"keepImportAssertions": true
},
- "baseUrl": "../built",
+ "baseUrl": "../src-js",
"paths": {
"@/*": ["*"]
},
diff --git a/packages/backend/test/compose.yml b/packages/backend/test/compose.yml
index fe96616fc0..4f1dba6428 100644
--- a/packages/backend/test/compose.yml
+++ b/packages/backend/test/compose.yml
@@ -11,3 +11,11 @@ services:
environment:
POSTGRES_DB: "test-misskey"
POSTGRES_HOST_AUTH_METHOD: trust
+
+ meilisearchtest:
+ image: getmeili/meilisearch:v1.3.4
+ ports:
+ - "127.0.0.1:57712:7700"
+ environment:
+ - MEILI_NO_ANALYTICS=true
+ - MEILI_ENV=development
diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts
index 96a6311a5a..67a9026eb5 100644
--- a/packages/backend/test/e2e/oauth.ts
+++ b/packages/backend/test/e2e/oauth.ts
@@ -28,6 +28,7 @@ const host = `http://127.0.0.1:${port}`;
const clientPort = port + 1;
const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`;
+const redirect_uri2 = `http://127.0.0.1:${clientPort}/redirect2`;
const basicAuthParams: AuthorizationParamsExtended = {
redirect_uri,
@@ -807,45 +808,193 @@ describe('OAuth', () => {
});
});
- // https://indieauth.spec.indieweb.org/#client-information-discovery
describe('Client Information Discovery', () => {
- describe('Redirection', () => {
- const tests: Record<string, (reply: FastifyReply) => void> = {
- 'Read HTTP header': reply => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- },
- 'Mixed links': reply => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <link rel="redirect_uri" href="/redirect2" />
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- },
- 'Multiple items in Link header': reply => {
- reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- },
- 'Multiple items in HTML': reply => {
- reply.send(`
- <!DOCTYPE html>
- <link rel="redirect_uri" href="/redirect2" />
- <link rel="redirect_uri" href="/redirect" />
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- },
- };
+ // https://indieauth.spec.indieweb.org/#client-information-discovery
+ describe('JSON client metadata (11 July 2024)', () => {
+ test('Read JSON document', async () => {
+ sender = (reply): void => {
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/`,
+ client_uri: `http://127.0.0.1:${clientPort}/`,
+ client_name: 'Misklient JSON',
+ logo_uri: '/logo.png',
+ redirect_uris: ['/redirect'],
+ });
+ };
- for (const [title, replyFunc] of Object.entries(tests)) {
- test(title, async () => {
- sender = replyFunc;
+ const client = new AuthorizationCode(clientConfig);
+
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ const meta = getMeta(await response.text());
+ assert.strictEqual(meta.clientName, 'Misklient JSON');
+ assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
+ });
+
+ test('Merge Link header redirect_uri with JSON redirect_uris', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect2>; rel="redirect_uri"');
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/`,
+ client_uri: `http://127.0.0.1:${clientPort}/`,
+ client_name: 'Misklient JSON',
+ redirect_uris: ['/redirect'],
+ });
+ };
+
+ const client = new AuthorizationCode(clientConfig);
+
+ const ok1 = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(ok1.status, 200);
+
+ const ok2 = await fetch(client.authorizeURL({
+ redirect_uri: redirect_uri2,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(ok2.status, 200);
+ });
+
+ test('Reject when client_id does not match retrieved URL', async () => {
+ sender = (reply): void => {
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/mismatch`,
+ client_uri: `http://127.0.0.1:${clientPort}/`,
+ redirect_uris: ['/redirect'],
+ });
+ };
+
+ const client = new AuthorizationCode(clientConfig);
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ await assertDirectError(response, 400, 'invalid_request');
+ });
+
+ test('Reject when client_uri is not a prefix of client_id', async () => {
+ sender = (reply): void => {
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/`,
+ client_uri: `http://127.0.0.1:${clientPort}/no-prefix/`,
+ redirect_uris: ['/redirect'],
+ });
+ };
+
+ const client = new AuthorizationCode(clientConfig);
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ await assertDirectError(response, 400, 'invalid_request');
+ });
+
+ test('Reject when JSON metadata has no redirect_uris and no Link header', async () => {
+ sender = (reply): void => {
+ reply.header('content-type', 'application/json');
+ reply.send({
+ client_id: `http://127.0.0.1:${clientPort}/`,
+ client_uri: `http://127.0.0.1:${clientPort}/`,
+ client_name: 'Misklient JSON',
+ });
+ };
+
+ const client = new AuthorizationCode(clientConfig);
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ await assertDirectError(response, 400, 'invalid_request');
+ });
+ });
+
+ // https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
+ describe('HTML link client metadata (12 Feb 2022)', () => {
+ describe('Redirection', () => {
+ const tests: Record<string, (reply: FastifyReply) => void> = {
+ 'Read HTTP header': reply => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ },
+ 'Mixed links': reply => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <link rel="redirect_uri" href="/redirect2" />
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ },
+ 'Multiple items in Link header': reply => {
+ reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ },
+ 'Multiple items in HTML': reply => {
+ reply.send(`
+ <!DOCTYPE html>
+ <link rel="redirect_uri" href="/redirect2" />
+ <link rel="redirect_uri" href="/redirect" />
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ },
+ };
+
+ for (const [title, replyFunc] of Object.entries(tests)) {
+ test(title, async () => {
+ sender = replyFunc;
+
+ const client = new AuthorizationCode(clientConfig);
+
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ });
+ }
+
+ test('No item', async () => {
+ sender = (reply): void => {
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ };
const client = new AuthorizationCode(clientConfig);
@@ -856,20 +1005,17 @@ describe('OAuth', () => {
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
+
+ // direct error because there's no redirect URI to ping
+ await assertDirectError(response, 400, 'invalid_request');
});
- }
+ });
- test('No item', async () => {
- sender = (reply): void => {
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- };
- const client = new AuthorizationCode(clientConfig);
+ test('Disallow loopback', async () => {
+ await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
+ const client = new AuthorizationCode(clientConfig);
const response = await fetch(client.authorizeURL({
redirect_uri,
scope: 'write:notes',
@@ -877,119 +1023,103 @@ describe('OAuth', () => {
code_challenge: 'code',
code_challenge_method: 'S256',
} as AuthorizationParamsExtended));
-
- // direct error because there's no redirect URI to ping
await assertDirectError(response, 400, 'invalid_request');
});
- });
- test('Disallow loopback', async () => {
- await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' });
-
- const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- await assertDirectError(response, 400, 'invalid_request');
- });
-
- test('Missing name', async () => {
- sender = (reply): void => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send();
- };
+ test('Missing name', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send();
+ };
- const client = new AuthorizationCode(clientConfig);
+ const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
- assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
- });
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
+ });
- test('With Logo', async () => {
- sender = (reply): void => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app">
- <a href="/" class="u-url p-name">Misklient</a>
- <img src="/logo.png" class="u-logo" />
- </div>
- `);
- reply.send();
- };
+ test('With Logo', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app">
+ <a href="/" class="u-url p-name">Misklient</a>
+ <img src="/logo.png" class="u-logo" />
+ </div>
+ `);
+ reply.send();
+ };
- const client = new AuthorizationCode(clientConfig);
+ const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
- const meta = getMeta(await response.text());
- assert.strictEqual(meta.clientName, 'Misklient');
- assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
- });
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ const meta = getMeta(await response.text());
+ assert.strictEqual(meta.clientName, 'Misklient');
+ assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`);
+ });
- test('Missing Logo', async () => {
- sender = (reply): void => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/" class="u-url p-name">Misklient
- `);
- reply.send();
- };
+ test('Missing Logo', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/" class="u-url p-name">Misklient
+ `);
+ reply.send();
+ };
- const client = new AuthorizationCode(clientConfig);
+ const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
- const meta = getMeta(await response.text());
- assert.strictEqual(meta.clientName, 'Misklient');
- assert.strictEqual(meta.clientLogo, undefined);
- });
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ const meta = getMeta(await response.text());
+ assert.strictEqual(meta.clientName, 'Misklient');
+ assert.strictEqual(meta.clientLogo, undefined);
+ });
- test('Mismatching URL in h-app', async () => {
- sender = (reply): void => {
- reply.header('Link', '</redirect>; rel="redirect_uri"');
- reply.send(`
- <!DOCTYPE html>
- <div class="h-app"><a href="/foo" class="u-url p-name">Misklient
- `);
- reply.send();
- };
+ test('Mismatching URL in h-app', async () => {
+ sender = (reply): void => {
+ reply.header('Link', '</redirect>; rel="redirect_uri"');
+ reply.send(`
+ <!DOCTYPE html>
+ <div class="h-app"><a href="/foo" class="u-url p-name">Misklient
+ `);
+ reply.send();
+ };
- const client = new AuthorizationCode(clientConfig);
+ const client = new AuthorizationCode(clientConfig);
- const response = await fetch(client.authorizeURL({
- redirect_uri,
- scope: 'write:notes',
- state: 'state',
- code_challenge: 'code',
- code_challenge_method: 'S256',
- } as AuthorizationParamsExtended));
- assert.strictEqual(response.status, 200);
- assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
+ const response = await fetch(client.authorizeURL({
+ redirect_uri,
+ scope: 'write:notes',
+ state: 'state',
+ code_challenge: 'code',
+ code_challenge_method: 'S256',
+ } as AuthorizationParamsExtended));
+ assert.strictEqual(response.status, 200);
+ assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
+ });
});
});
diff --git a/packages/backend/test/resources/dummy-for-file-server-service.png b/packages/backend/test/resources/dummy-for-file-server-service.png
new file mode 100644
index 0000000000..39332b0c1b
--- /dev/null
+++ b/packages/backend/test/resources/dummy-for-file-server-service.png
Binary files differ
diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json
index c6754c4802..a2a86c696e 100644
--- a/packages/backend/test/tsconfig.json
+++ b/packages/backend/test/tsconfig.json
@@ -25,7 +25,6 @@
"isolatedModules": true,
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
- "baseUrl": "./",
"paths": {
"@/*": ["../src/*"]
},
diff --git a/packages/backend/test/unit/SearchService.ts b/packages/backend/test/unit/SearchService.ts
new file mode 100644
index 0000000000..6e17bef1c3
--- /dev/null
+++ b/packages/backend/test/unit/SearchService.ts
@@ -0,0 +1,483 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/globals';
+import { Test, TestingModule } from '@nestjs/testing';
+import type { Index, MeiliSearch } from 'meilisearch';
+import { type Config, loadConfig } from '@/config.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { CoreModule } from '@/core/CoreModule.js';
+import { SearchService } from '@/core/SearchService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { IdService } from '@/core/IdService.js';
+import { DI } from '@/di-symbols.js';
+import {
+ type BlockingsRepository,
+ type ChannelsRepository,
+ type FollowingsRepository,
+ type MutingsRepository,
+ type NotesRepository,
+ type UserProfilesRepository,
+ type UsersRepository,
+ type MiChannel,
+ type MiNote,
+ type MiUser,
+} from '@/models/_.js';
+
+describe('SearchService', () => {
+ type TestContext = {
+ app: TestingModule;
+ service: SearchService;
+ cacheService: CacheService;
+ idService: IdService;
+ mutingsRepository: MutingsRepository;
+ blockingsRepository: BlockingsRepository;
+ usersRepository: UsersRepository;
+ userProfilesRepository: UserProfilesRepository;
+ notesRepository: NotesRepository;
+ channelsRepository: ChannelsRepository;
+ followingsRepository: FollowingsRepository;
+ indexer?: (note: MiNote) => Promise<void>;
+ };
+
+ const meilisearchSettings = {
+ searchableAttributes: [
+ 'text',
+ 'cw',
+ ],
+ sortableAttributes: [
+ 'createdAt',
+ ],
+ filterableAttributes: [
+ 'createdAt',
+ 'userId',
+ 'userHost',
+ 'channelId',
+ 'tags',
+ ],
+ typoTolerance: {
+ enabled: false,
+ },
+ pagination: {
+ maxTotalHits: 10000,
+ },
+ };
+
+ async function buildContext(configOverride?: Config): Promise<TestContext> {
+ const builder = Test.createTestingModule({
+ imports: [
+ GlobalModule,
+ CoreModule,
+ ],
+ });
+
+ if (configOverride) {
+ builder.overrideProvider(DI.config).useValue(configOverride);
+ }
+
+ const app = await builder.compile();
+
+ app.enableShutdownHooks();
+
+ return {
+ app,
+ service: app.get(SearchService),
+ cacheService: app.get(CacheService),
+ idService: app.get(IdService),
+ mutingsRepository: app.get(DI.mutingsRepository),
+ blockingsRepository: app.get(DI.blockingsRepository),
+ usersRepository: app.get(DI.usersRepository),
+ userProfilesRepository: app.get(DI.userProfilesRepository),
+ notesRepository: app.get(DI.notesRepository),
+ channelsRepository: app.get(DI.channelsRepository),
+ followingsRepository: app.get(DI.followingsRepository),
+ };
+ }
+
+ async function cleanupContext(ctx: TestContext) {
+ await ctx.notesRepository.createQueryBuilder().delete().execute();
+ await ctx.mutingsRepository.createQueryBuilder().delete().execute();
+ await ctx.blockingsRepository.createQueryBuilder().delete().execute();
+ await ctx.followingsRepository.createQueryBuilder().delete().execute();
+ await ctx.channelsRepository.createQueryBuilder().delete().execute();
+ await ctx.userProfilesRepository.createQueryBuilder().delete().execute();
+ await ctx.usersRepository.createQueryBuilder().delete().execute();
+ }
+
+ async function createUser(ctx: TestContext, data: Partial<MiUser> = {}) {
+ const id = ctx.idService.gen();
+ const username = data.username ?? `user_${id}`;
+ const usernameLower = data.usernameLower ?? username.toLowerCase();
+
+ const user = await ctx.usersRepository
+ .insert({
+ id,
+ username,
+ usernameLower,
+ ...data,
+ })
+ .then(x => ctx.usersRepository.findOneByOrFail(x.identifiers[0]));
+
+ await ctx.userProfilesRepository.insert({
+ userId: id,
+ });
+
+ return user;
+ }
+
+ async function createChannel(ctx: TestContext, user: MiUser, data: Partial<MiChannel> = {}) {
+ const id = ctx.idService.gen();
+ const channel = await ctx.channelsRepository
+ .insert({
+ id,
+ userId: user.id,
+ name: data.name ?? `channel_${id}`,
+ ...data,
+ })
+ .then(x => ctx.channelsRepository.findOneByOrFail(x.identifiers[0]));
+
+ return channel;
+ }
+
+ async function createNote(ctx: TestContext, user: MiUser, data: Partial<MiNote> = {}, time?: number) {
+ const id = time == null ? ctx.idService.gen() : ctx.idService.gen(time);
+ const note = await ctx.notesRepository
+ .insert({
+ id,
+ text: 'hello',
+ userId: user.id,
+ userHost: user.host,
+ visibility: 'public',
+ tags: [],
+ ...data,
+ })
+ .then(x => ctx.notesRepository.findOneByOrFail(x.identifiers[0]));
+
+ if (ctx.indexer) {
+ await ctx.indexer(note);
+ }
+
+ return note;
+ }
+
+ async function createFollowing(ctx: TestContext, follower: MiUser, followee: MiUser) {
+ await ctx.followingsRepository.insert({
+ id: ctx.idService.gen(),
+ followerId: follower.id,
+ followeeId: followee.id,
+ followerHost: follower.host,
+ followeeHost: followee.host,
+ });
+ }
+
+ function clearUserCaches(ctx: TestContext, userId: MiUser['id']) {
+ ctx.cacheService.userMutingsCache.delete(userId);
+ ctx.cacheService.userBlockedCache.delete(userId);
+ ctx.cacheService.userBlockingCache.delete(userId);
+ }
+
+ async function createMuting(ctx: TestContext, muter: MiUser, mutee: MiUser) {
+ await ctx.mutingsRepository.insert({
+ id: ctx.idService.gen(),
+ muterId: muter.id,
+ muteeId: mutee.id,
+ });
+ clearUserCaches(ctx, muter.id);
+ }
+
+ async function createBlocking(ctx: TestContext, blocker: MiUser, blockee: MiUser) {
+ await ctx.blockingsRepository.insert({
+ id: ctx.idService.gen(),
+ blockerId: blocker.id,
+ blockeeId: blockee.id,
+ });
+ clearUserCaches(ctx, blocker.id);
+ clearUserCaches(ctx, blockee.id);
+ }
+
+ function defineSearchNoteTests(
+ getCtx: () => TestContext,
+ {
+ supportsFollowersVisibility,
+ sinceIdOrder,
+ }: {
+ supportsFollowersVisibility: boolean;
+ sinceIdOrder: 'asc' | 'desc';
+ },
+ ) {
+ describe('searchNote', () => {
+ test('filters notes by visibility (followers only visible to followers)', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+
+ const publicNote = await createNote(ctx, author, { text: 'hello public', visibility: 'public' });
+ const followersNote = await createNote(ctx, author, { text: 'hello followers', visibility: 'followers' });
+
+ const beforeFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+ expect(beforeFollow.map(note => note.id)).toEqual([publicNote.id]);
+
+ await createFollowing(ctx, me, author);
+
+ const afterFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+ const expectedIds = supportsFollowersVisibility
+ ? [followersNote.id, publicNote.id]
+ : [publicNote.id];
+ expect(afterFollow.map(note => note.id).sort()).toEqual(expectedIds.sort());
+ });
+
+ test('filters out suspended users via base note filtering', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const active = await createUser(ctx, { username: 'active', usernameLower: 'active', host: null });
+ const suspended = await createUser(ctx, { username: 'suspended', usernameLower: 'suspended', host: null, isSuspended: true });
+
+ const activeNote = await createNote(ctx, active, { text: 'hello active', visibility: 'public' });
+ await createNote(ctx, suspended, { text: 'hello suspended', visibility: 'public' });
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+ expect(result.map(note => note.id)).toEqual([activeNote.id]);
+ });
+
+ test('filters by userId', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const alice = await createUser(ctx, { username: 'alice', usernameLower: 'alice', host: null });
+ const bob = await createUser(ctx, { username: 'bob', usernameLower: 'bob', host: null });
+
+ const aliceNote = await createNote(ctx, alice, { text: 'hello alice', visibility: 'public' });
+ await createNote(ctx, bob, { text: 'hello bob', visibility: 'public' });
+
+ const result = await ctx.service.searchNote('hello', me, { userId: alice.id }, { limit: 10 });
+ expect(result.map(note => note.id)).toEqual([aliceNote.id]);
+ });
+
+ test('filters by channelId', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+ const channelA = await createChannel(ctx, author, { name: 'channel-a' });
+ const channelB = await createChannel(ctx, author, { name: 'channel-b' });
+
+ const channelNote = await createNote(ctx, author, { text: 'hello channel', channelId: channelA.id, visibility: 'public' });
+ await createNote(ctx, author, { text: 'hello other', channelId: channelB.id, visibility: 'public' });
+
+ const result = await ctx.service.searchNote('hello', me, { channelId: channelA.id }, { limit: 10 });
+ expect(result.map(note => note.id)).toEqual([channelNote.id]);
+ });
+
+ test('filters by host', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const local = await createUser(ctx, { username: 'local', usernameLower: 'local', host: null });
+ const remote = await createUser(ctx, { username: 'remote', usernameLower: 'remote', host: 'example.com' });
+
+ const localNote = await createNote(ctx, local, { text: 'hello local', visibility: 'public' });
+ const remoteNote = await createNote(ctx, remote, { text: 'hello remote', visibility: 'public', userHost: 'example.com' });
+
+ const localResult = await ctx.service.searchNote('hello', me, { host: '.' }, { limit: 10 });
+ expect(localResult.map(note => note.id)).toEqual([localNote.id]);
+
+ const remoteResult = await ctx.service.searchNote('hello', me, { host: 'example.com' }, { limit: 10 });
+ expect(remoteResult.map(note => note.id)).toEqual([remoteNote.id]);
+ });
+
+ describe('muting and blocking', () => {
+ test('filters out muted users', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const muted = await createUser(ctx, { username: 'muted', usernameLower: 'muted', host: null });
+ const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
+
+ await createNote(ctx, muted, { text: 'hello muted', visibility: 'public' });
+ const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
+
+ await createMuting(ctx, me, muted);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+
+ expect(result.map(note => note.id)).toEqual([otherNote.id]);
+ });
+
+ test('filters out users who block me', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const blocker = await createUser(ctx, { username: 'blocker', usernameLower: 'blocker', host: null });
+ const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
+
+ await createNote(ctx, blocker, { text: 'hello blocker', visibility: 'public' });
+ const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
+
+ await createBlocking(ctx, blocker, me);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+
+ expect(result.map(note => note.id)).toEqual([otherNote.id]);
+ });
+
+ test('filters no out users I block', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const blocked = await createUser(ctx, { username: 'blocked', usernameLower: 'blocked', host: null });
+ const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
+
+ const blockedNote = await createNote(ctx, blocked, { text: 'hello blocked', visibility: 'public' });
+ const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
+
+ await createBlocking(ctx, me, blocked);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
+ expect(result.map(note => note.id).sort()).toEqual([otherNote.id, blockedNote.id].sort());
+ });
+ });
+
+ describe('pagination', () => {
+ test('paginates with sinceId', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+
+ const t1 = Date.now() - 3000;
+ const t2 = Date.now() - 2000;
+ const t3 = Date.now() - 1000;
+
+ const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
+ const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
+ const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id });
+
+ const expected = sinceIdOrder === 'asc'
+ ? [note2.id, note3.id]
+ : [note3.id, note2.id];
+ expect(result.map(note => note.id)).toEqual(expected);
+ });
+
+ test('paginates with untilId', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+
+ const t1 = Date.now() - 3000;
+ const t2 = Date.now() - 2000;
+ const t3 = Date.now() - 1000;
+
+ const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
+ const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
+ const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, untilId: note3.id });
+
+ expect(result.map(note => note.id)).toEqual([note2.id, note1.id]);
+ });
+
+ test('paginates with sinceId and untilId together', async () => {
+ const ctx = getCtx();
+ const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
+ const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
+
+ const t1 = Date.now() - 4000;
+ const t2 = Date.now() - 3000;
+ const t3 = Date.now() - 2000;
+ const t4 = Date.now() - 1000;
+
+ const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
+ const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
+ const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
+ const note4 = await createNote(ctx, author, { text: 'hello' }, t4);
+
+ const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id, untilId: note4.id });
+
+ expect(result.map(note => note.id)).toEqual([note3.id, note2.id]);
+ });
+ });
+ });
+ }
+
+ describe('sqlLike', () => {
+ let ctx: TestContext;
+
+ beforeAll(async () => {
+ ctx = await buildContext();
+ });
+
+ afterAll(async () => {
+ await ctx.app.close();
+ });
+
+ afterEach(async () => {
+ await cleanupContext(ctx);
+ });
+
+ defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: true, sinceIdOrder: 'asc' });
+ });
+
+ describe('meilisearch', () => {
+ let ctx: TestContext;
+ let meilisearch: MeiliSearch;
+ let meilisearchIndex: Index;
+ let meiliConfig: Config;
+
+ beforeAll(async () => {
+ const baseConfig = loadConfig();
+ meiliConfig = {
+ ...baseConfig,
+ fulltextSearch: {
+ provider: 'meilisearch',
+ },
+ meilisearch: {
+ host: '127.0.0.1',
+ port: '57712',
+ apiKey: '',
+ index: 'test-search-service',
+ scope: 'global',
+ ssl: false,
+ },
+ };
+
+ ctx = await buildContext(meiliConfig);
+ meilisearch = ctx.app.get(DI.meilisearch) as MeiliSearch;
+ meilisearchIndex = meilisearch.index(`${meiliConfig.meilisearch!.index}---notes`);
+
+ const settingsTask = await meilisearchIndex.updateSettings(meilisearchSettings);
+ await meilisearch.tasks.waitForTask(settingsTask.taskUid);
+
+ const clearTask = await meilisearchIndex.deleteAllDocuments();
+ await meilisearch.tasks.waitForTask(clearTask.taskUid);
+
+ ctx.indexer = async (note: MiNote) => {
+ if (note.text == null && note.cw == null) return;
+ if (!['home', 'public'].includes(note.visibility)) return;
+ if (meiliConfig.meilisearch?.scope === 'local' && note.userHost != null) return;
+
+ const task = await meilisearchIndex.addDocuments([{
+ id: note.id,
+ createdAt: ctx.idService.parse(note.id).date.getTime(),
+ userId: note.userId,
+ userHost: note.userHost,
+ channelId: note.channelId,
+ cw: note.cw,
+ text: note.text,
+ tags: note.tags,
+ }], {
+ primaryKey: 'id',
+ });
+ await meilisearch.tasks.waitForTask(task.taskUid);
+ };
+ });
+
+ afterAll(async () => {
+ await ctx.app.close();
+ });
+
+ afterEach(async () => {
+ await cleanupContext(ctx);
+ const clearTask = await meilisearchIndex.deleteAllDocuments();
+ await meilisearch.tasks.waitForTask(clearTask.taskUid);
+ });
+
+ defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: false, sinceIdOrder: 'desc' });
+ });
+});
diff --git a/packages/backend/test/unit/entities/DriveFileEntityService.ts b/packages/backend/test/unit/entities/DriveFileEntityService.ts
new file mode 100644
index 0000000000..2e416326ee
--- /dev/null
+++ b/packages/backend/test/unit/entities/DriveFileEntityService.ts
@@ -0,0 +1,227 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+process.env.NODE_ENV = 'test';
+
+import { afterAll, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals';
+import { Test } from '@nestjs/testing';
+import type { TestingModule } from '@nestjs/testing';
+import type { DriveFilesRepository, DriveFoldersRepository, UsersRepository } from '@/models/_.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { CoreModule } from '@/core/CoreModule.js';
+import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
+import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { genAidx } from '@/misc/id/aidx.js';
+import { secureRndstr } from '@/misc/secure-rndstr.js';
+
+const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip;
+
+describe('DriveFileEntityService', () => {
+ let app: TestingModule;
+ let service: DriveFileEntityService;
+ let driveFolderEntityService: DriveFolderEntityService;
+ let driveFilesRepository: DriveFilesRepository;
+ let driveFoldersRepository: DriveFoldersRepository;
+ let usersRepository: UsersRepository;
+ let idCounter = 0;
+
+ const userEntityServiceMock = {
+ packMany: jest.fn(async (users: Array<string | { id: string }>) => {
+ return users.map(u => ({
+ id: typeof u === 'string' ? u : u.id,
+ username: 'user',
+ }));
+ }),
+ pack: jest.fn(async (user: string | { id: string }) => {
+ return {
+ id: typeof user === 'string' ? user : user.id,
+ username: 'user',
+ };
+ }),
+ };
+
+ const nextId = () => genAidx(Date.now() + (idCounter++));
+
+ const createUser = async () => {
+ const un = secureRndstr(16);
+ const id = nextId();
+ await usersRepository.insert({
+ id,
+ username: un,
+ usernameLower: un.toLowerCase(),
+ });
+ return usersRepository.findOneByOrFail({ id });
+ };
+
+ const createFolder = async (name: string, parentId: string | null) => {
+ const id = nextId();
+ await driveFoldersRepository.insert({
+ id,
+ name,
+ userId: null,
+ parentId,
+ });
+ return driveFoldersRepository.findOneByOrFail({ id });
+ };
+
+ const createFile = async (folderId: string | null, userId: string | null) => {
+ const id = nextId();
+ await driveFilesRepository.insert({
+ id,
+ userId,
+ userHost: null,
+ md5: secureRndstr(32),
+ name: `file-${id}`,
+ type: 'text/plain',
+ size: 1,
+ comment: null,
+ blurhash: null,
+ properties: {},
+ storedInternal: true,
+ url: `https://example.com/${id}`,
+ thumbnailUrl: null,
+ webpublicUrl: null,
+ webpublicType: null,
+ accessKey: null,
+ thumbnailAccessKey: null,
+ webpublicAccessKey: null,
+ uri: null,
+ src: null,
+ folderId,
+ isSensitive: false,
+ maybeSensitive: false,
+ maybePorn: false,
+ isLink: false,
+ requestHeaders: null,
+ requestIp: null,
+ });
+ return driveFilesRepository.findOneByOrFail({ id });
+ };
+
+ beforeAll(async () => {
+ const moduleBuilder = Test.createTestingModule({
+ imports: [GlobalModule, CoreModule],
+ });
+ moduleBuilder.overrideProvider(UserEntityService).useValue(userEntityServiceMock as any);
+
+ app = await moduleBuilder.compile();
+ await app.init();
+ app.enableShutdownHooks();
+
+ service = app.get<DriveFileEntityService>(DriveFileEntityService);
+ driveFolderEntityService = app.get<DriveFolderEntityService>(DriveFolderEntityService);
+ driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
+ driveFoldersRepository = app.get<DriveFoldersRepository>(DI.driveFoldersRepository);
+ usersRepository = app.get<UsersRepository>(DI.usersRepository);
+ });
+
+ beforeEach(() => {
+ userEntityServiceMock.packMany.mockClear();
+ userEntityServiceMock.pack.mockClear();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ describe('pack', () => {
+ test('detail: false', async () => {
+ const user = await createUser();
+ const folder = await createFolder('pack-root', null);
+ const file = await createFile(folder.id, user.id);
+
+ const packed = await service.pack(file, { detail: false, self: true }) as any;
+ expect(packed.id).toBe(file.id);
+ expect(packed.folder).toBeNull();
+ expect(packed.user).toBeNull();
+ expect(packed.userId).toBeNull();
+ });
+
+ test('detail: true', async () => {
+ const folder = await createFolder('pack-parent', null);
+ const child = await createFolder('pack-child', folder.id);
+ const file = await createFile(child.id, null);
+
+ const packed = await service.pack(file, { detail: true, self: true }) as any;
+ expect(packed.folder?.id).toBe(child.id);
+ expect(packed.folder?.parent?.id).toBe(folder.id);
+ });
+ });
+
+ describe('packNullable', () => {
+ test('returns null for missing', async () => {
+ const packed = await service.packNullable('non-existent' as any, { detail: false });
+ expect(packed).toBeNull();
+ });
+
+ test('uses packedUser hint when withUser', async () => {
+ const user = await createUser();
+ const file = await createFile(null, user.id);
+
+ const packed = await service.packNullable(file, { withUser: true, self: true }, {
+ packedUser: { id: user.id, username: 'hint' } as any,
+ });
+ expect(packed?.user?.id).toBe(user.id);
+ expect(packed?.user?.username).toBe('hint');
+ });
+ });
+
+ describe('packMany', () => {
+ test('withUser: true uses deduped packMany', async () => {
+ const user = await createUser();
+ const fileA = await createFile(null, user.id);
+ const fileB = await createFile(null, user.id);
+
+ const packed = await service.packMany([fileA, fileB], { withUser: true, self: true });
+ expect(packed.length).toBe(2);
+ expect(userEntityServiceMock.packMany).toHaveBeenCalledTimes(1);
+ expect(userEntityServiceMock.packMany.mock.calls[0]?.[0]?.length).toBe(1);
+ expect(packed[0]?.user?.id).toBe(user.id);
+ });
+
+ test('detail: true packs folder', async () => {
+ const folder = await createFolder('packmany-root', null);
+ const file = await createFile(folder.id, null);
+
+ const packed = await service.packMany([file], { detail: true, self: true });
+ expect(packed[0]?.folder?.id).toBe(folder.id);
+ expect(packed[0]?.folder?.parent).toBeUndefined();
+ });
+
+ test('detail: true uses DriveFolderEntityService pack', async () => {
+ const folder = await createFolder('packmany-folder', null);
+ const file = await createFile(folder.id, null);
+ const packSpy = jest.spyOn(driveFolderEntityService, 'pack');
+
+ await service.packMany([file], { detail: true, self: true });
+ expect(packSpy).toHaveBeenCalled();
+ packSpy.mockRestore();
+ });
+ });
+
+ describeBenchmark('benchmark', () => {
+ test('packMany', async () => {
+ const user = await createUser();
+ const folders = [];
+ for (let i = 0; i < 100; i++) {
+ folders.push(await createFolder(`bench-${i}`, null));
+ }
+ const files = [];
+ for (const folder of folders) {
+ for (let j = 0; j < 20; j++) {
+ files.push(await createFile(folder.id, user.id));
+ }
+ }
+
+ const start = Date.now();
+ await service.packMany(files, { detail: true, withUser: true, self: true });
+ const elapsed = Date.now() - start;
+
+ console.log(`DriveFileEntityService.packMany benchmark: ${elapsed}ms`);
+ });
+ });
+});
diff --git a/packages/backend/test/unit/entities/DriveFolderEntityService.ts b/packages/backend/test/unit/entities/DriveFolderEntityService.ts
new file mode 100644
index 0000000000..299ee5f42b
--- /dev/null
+++ b/packages/backend/test/unit/entities/DriveFolderEntityService.ts
@@ -0,0 +1,171 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+process.env.NODE_ENV = 'test';
+
+import { afterAll, beforeAll, describe, expect, test } from '@jest/globals';
+import { Test } from '@nestjs/testing';
+import type { TestingModule } from '@nestjs/testing';
+import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/_.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { CoreModule } from '@/core/CoreModule.js';
+import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js';
+import { DI } from '@/di-symbols.js';
+import { genAidx } from '@/misc/id/aidx.js';
+import { secureRndstr } from '@/misc/secure-rndstr.js';
+
+const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip;
+
+describe('DriveFolderEntityService', () => {
+ let app: TestingModule;
+ let service: DriveFolderEntityService;
+ let driveFoldersRepository: DriveFoldersRepository;
+ let driveFilesRepository: DriveFilesRepository;
+ let idCounter = 0;
+
+ const nextId = () => genAidx(Date.now() + (idCounter++));
+
+ const createFolder = async (name: string, parentId: string | null) => {
+ const id = nextId();
+ await driveFoldersRepository.insert({
+ id,
+ name,
+ userId: null,
+ parentId,
+ });
+ return driveFoldersRepository.findOneByOrFail({ id });
+ };
+
+ const createFile = async (folderId: string | null) => {
+ const id = nextId();
+ await driveFilesRepository.insert({
+ id,
+ userId: null,
+ userHost: null,
+ md5: secureRndstr(32),
+ name: `file-${id}`,
+ type: 'text/plain',
+ size: 1,
+ comment: null,
+ blurhash: null,
+ properties: {},
+ storedInternal: true,
+ url: `https://example.com/${id}`,
+ thumbnailUrl: null,
+ webpublicUrl: null,
+ webpublicType: null,
+ accessKey: null,
+ thumbnailAccessKey: null,
+ webpublicAccessKey: null,
+ uri: null,
+ src: null,
+ folderId,
+ isSensitive: false,
+ maybeSensitive: false,
+ maybePorn: false,
+ isLink: false,
+ requestHeaders: null,
+ requestIp: null,
+ });
+ };
+
+ beforeAll(async () => {
+ app = await Test.createTestingModule({
+ imports: [GlobalModule, CoreModule],
+ }).compile();
+ await app.init();
+ app.enableShutdownHooks();
+
+ service = app.get<DriveFolderEntityService>(DriveFolderEntityService);
+ driveFoldersRepository = app.get<DriveFoldersRepository>(DI.driveFoldersRepository);
+ driveFilesRepository = app.get<DriveFilesRepository>(DI.driveFilesRepository);
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ describe('pack', () => {
+ test('detail: false', async () => {
+ const root = await createFolder('root', null);
+ const child = await createFolder('child', root.id);
+
+ const packed = await service.pack(child, { detail: false }) as any;
+ expect(packed.id).toBe(child.id);
+ expect(packed.parentId).toBe(root.id);
+ expect(packed.parent).toBeUndefined();
+ expect(packed.foldersCount).toBeUndefined();
+ expect(packed.filesCount).toBeUndefined();
+ });
+
+ test('detail: true', async () => {
+ const root = await createFolder('root-detail', null);
+ const child = await createFolder('child-detail', root.id);
+ await createFolder('grandchild-detail', child.id);
+ await createFile(child.id);
+ await createFile(child.id);
+
+ const packed = await service.pack(child, { detail: true }) as any;
+ expect(packed.id).toBe(child.id);
+ expect(packed.foldersCount).toBe(1);
+ expect(packed.filesCount).toBe(2);
+ expect(packed.parent?.id).toBe(root.id);
+ expect(packed.parent?.parent).toBeUndefined();
+ });
+
+ test('detail: true reaches root for deep hierarchy', async () => {
+ const root = await createFolder('root-deep', null);
+ const level1 = await createFolder('level-1', root.id);
+ const level2 = await createFolder('level-2', level1.id);
+ const level3 = await createFolder('level-3', level2.id);
+ const level4 = await createFolder('level-4', level3.id);
+ const level5 = await createFolder('level-5', level4.id);
+
+ const packed = await service.pack(level5, { detail: true }) as any;
+ expect(packed.id).toBe(level5.id);
+ expect(packed.parent?.id).toBe(level4.id);
+ expect(packed.parent?.parent?.id).toBe(level3.id);
+ expect(packed.parent?.parent?.parent?.id).toBe(level2.id);
+ expect(packed.parent?.parent?.parent?.parent?.id).toBe(level1.id);
+ expect(packed.parent?.parent?.parent?.parent?.parent?.id).toBe(root.id);
+ expect(packed.parent?.parent?.parent?.parent?.parent?.parent).toBeUndefined();
+ });
+ });
+
+ describe('packMany', () => {
+ test('preserves order and packs parents', async () => {
+ const root = await createFolder('root-many', null);
+ const childA = await createFolder('child-a', root.id);
+ const childB = await createFolder('child-b', root.id);
+ await createFolder('child-a-sub', childA.id);
+ await createFile(childA.id);
+
+ const packed = await service.packMany([childB, childA], { detail: true }) as any;
+ expect(packed[0].id).toBe(childB.id);
+ expect(packed[1].id).toBe(childA.id);
+ expect(packed[0].parent?.id).toBe(root.id);
+ expect(packed[1].parent?.id).toBe(root.id);
+ expect(packed[0].filesCount).toBe(0);
+ expect(packed[1].filesCount).toBe(1);
+ expect(packed[0].foldersCount).toBe(0);
+ expect(packed[1].foldersCount).toBe(1);
+ });
+ });
+
+ describeBenchmark('benchmark', () => {
+ test('packMany', async () => {
+ const root = await createFolder('bench-root', null);
+ const folders = [];
+ for (let i = 0; i < 200; i++) {
+ folders.push(await createFolder(`bench-${i}`, root.id));
+ }
+
+ const start = Date.now();
+ await service.packMany(folders, { detail: true });
+ const elapsed = Date.now() - start;
+ console.log(`DriveFolderEntityService.packMany benchmark: ${elapsed}ms`);
+ });
+ });
+});
diff --git a/packages/backend/test/unit/server/FileServerService.ts b/packages/backend/test/unit/server/FileServerService.ts
new file mode 100644
index 0000000000..c88175c5c7
--- /dev/null
+++ b/packages/backend/test/unit/server/FileServerService.ts
@@ -0,0 +1,770 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import fastifyStatic from '@fastify/static';
+import Fastify, { type FastifyInstance } from 'fastify';
+import { describe, expect, test } from '@jest/globals';
+import sharp from 'sharp';
+import { DataSource, type Repository } from 'typeorm';
+import { initTestDb, randomString } from '../../utils.js';
+import type { AiService } from '@/core/AiService.js';
+import { DownloadService } from '@/core/DownloadService.js';
+import { FileInfoService } from '@/core/FileInfoService.js';
+import { HttpRequestService } from '@/core/HttpRequestService.js';
+import { ImageProcessingService } from '@/core/ImageProcessingService.js';
+import { InternalStorageService } from '@/core/InternalStorageService.js';
+import { IdService } from '@/core/IdService.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import { VideoProcessingService } from '@/core/VideoProcessingService.js';
+import { loadConfig, type Config } from '@/config.js';
+import { MiDriveFile } from '@/models/DriveFile.js';
+import { FileServerService } from '@/server/FileServerService.js';
+
+const dummyPath = path.resolve('test/resources/dummy-for-file-server-service.png');
+const dummySize = fs.statSync(dummyPath).size;
+const dummyBuffer = fs.readFileSync(dummyPath);
+const svgBuffer = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"></svg>', 'utf8');
+const textBuffer = Buffer.from('dummy text', 'utf8');
+
+async function createRemoteFileServer() {
+ const flatPngBuffer = await sharp({
+ create: { width: 8, height: 8, channels: 3, background: { r: 0, g: 0, b: 0 } },
+ }).png().toBuffer();
+ const server = Fastify();
+
+ server.get('/dummy.png', async (_request, reply) => {
+ reply.header('Content-Type', 'image/png');
+ reply.header('Content-Length', String(dummyBuffer.length));
+ return reply.send(dummyBuffer);
+ });
+
+ server.get('/dummy.svg', async (_request, reply) => {
+ reply.header('Content-Type', 'image/svg+xml');
+ reply.header('Content-Length', String(svgBuffer.length));
+ return reply.send(svgBuffer);
+ });
+
+ server.get('/dummy.txt', async (_request, reply) => {
+ reply.header('Content-Type', 'text/plain');
+ reply.header('Content-Length', String(textBuffer.length));
+ return reply.send(textBuffer);
+ });
+
+ server.get('/flat.png', async (_request, reply) => {
+ reply.header('Content-Type', 'image/png');
+ reply.header('Content-Length', String(flatPngBuffer.length));
+ return reply.send(flatPngBuffer);
+ });
+
+ const baseUrl = await server.listen({ port: 0, host: '127.0.0.1' });
+
+ return {
+ server,
+ pngUrl: `${baseUrl}/dummy.png`,
+ svgUrl: `${baseUrl}/dummy.svg`,
+ textUrl: `${baseUrl}/dummy.txt`,
+ flatPngUrl: `${baseUrl}/flat.png`,
+ };
+}
+
+describe('FileServerService', () => {
+ let db: DataSource;
+ let fastify: FastifyInstance;
+ let externalFastify: FastifyInstance;
+ let driveFilesRepository: Repository<MiDriveFile>;
+ let internalStorageService: InternalStorageService;
+ let idService: IdService;
+ let config: Config;
+ let fileServerService: FileServerService;
+ let externalFileServerService: FileServerService;
+ let remoteServer: FastifyInstance;
+ let remotePngUrl: string;
+ let remoteSvgUrl: string;
+ let remoteTextUrl: string;
+ let remoteFlatPngUrl: string;
+ const storedPaths: string[] = [];
+ let createdFallbackAssets = false;
+ let fallbackAssetsDir = '';
+
+ function writeInternalFile(key: string) {
+ const dest = internalStorageService.resolvePath(key);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(dummyPath, dest);
+ storedPaths.push(dest);
+ }
+
+ async function insertDriveFile(params: {
+ accessKey: string;
+ thumbnailAccessKey?: string | null;
+ webpublicAccessKey?: string | null;
+ storedInternal: boolean;
+ isLink: boolean;
+ uri?: string | null;
+ name?: string;
+ type?: string;
+ size?: number;
+ }) {
+ const accessKey = params.accessKey;
+ const url = params.uri ?? `${config.url}/files/${accessKey}`;
+ await driveFilesRepository.insert({
+ id: idService.gen(),
+ userId: null,
+ userHost: null,
+ md5: '00000000000000000000000000000000',
+ name: params.name ?? 'dummy.png',
+ type: params.type ?? 'image/png',
+ size: params.size ?? dummySize,
+ comment: null,
+ blurhash: null,
+ properties: {},
+ storedInternal: params.storedInternal,
+ url,
+ thumbnailUrl: null,
+ webpublicUrl: null,
+ webpublicType: null,
+ accessKey,
+ thumbnailAccessKey: params.thumbnailAccessKey ?? null,
+ webpublicAccessKey: params.webpublicAccessKey ?? null,
+ uri: params.uri ?? null,
+ src: null,
+ folderId: null,
+ isSensitive: false,
+ maybeSensitive: false,
+ maybePorn: false,
+ isLink: params.isLink,
+ requestHeaders: {},
+ requestIp: null,
+ });
+ }
+
+ beforeAll(async () => {
+ config = loadConfig();
+ db = await initTestDb(false);
+ driveFilesRepository = db.getRepository(MiDriveFile);
+
+ const loggerService = new LoggerService();
+ const aiService = {
+ detectSensitive: async () => null,
+ } as unknown as AiService;
+ const fileInfoService = new FileInfoService(aiService, loggerService);
+ const httpRequestService = new HttpRequestService(config);
+ const downloadService = new DownloadService(config, httpRequestService, loggerService);
+ const imageProcessingService = new ImageProcessingService();
+ const videoProcessingService = new VideoProcessingService(config, imageProcessingService);
+ internalStorageService = new InternalStorageService(config);
+ idService = new IdService(config);
+ fileServerService = new FileServerService(
+ config,
+ driveFilesRepository as any,
+ fileInfoService,
+ downloadService,
+ imageProcessingService,
+ videoProcessingService,
+ internalStorageService,
+ loggerService,
+ );
+
+ fastify = Fastify();
+ await fastify.register(fastifyStatic, {
+ root: path.resolve('src/server/assets'),
+ serve: false,
+ });
+ fileServerService.createServer(fastify, {}, () => {});
+ await fastify.ready();
+
+ const externalConfig = {
+ ...config,
+ mediaProxy: 'https://media-proxy.test',
+ externalMediaProxyEnabled: true,
+ } as Config;
+ externalFileServerService = new FileServerService(
+ externalConfig,
+ driveFilesRepository as any,
+ fileInfoService,
+ downloadService,
+ imageProcessingService,
+ videoProcessingService,
+ internalStorageService,
+ loggerService,
+ );
+ externalFastify = Fastify();
+ await externalFastify.register(fastifyStatic, {
+ root: path.resolve('src/server/assets'),
+ serve: false,
+ });
+ externalFileServerService.createServer(externalFastify, {}, () => {});
+ await externalFastify.ready();
+
+ const remoteServerInfo = await createRemoteFileServer();
+ remoteServer = remoteServerInfo.server;
+ remotePngUrl = remoteServerInfo.pngUrl;
+ remoteSvgUrl = remoteServerInfo.svgUrl;
+ remoteTextUrl = remoteServerInfo.textUrl;
+ remoteFlatPngUrl = remoteServerInfo.flatPngUrl;
+
+ fallbackAssetsDir = path.resolve('src/server/file/assets');
+ if (!fs.existsSync(fallbackAssetsDir)) {
+ fs.mkdirSync(fallbackAssetsDir, { recursive: true });
+ fs.copyFileSync(dummyPath, path.join(fallbackAssetsDir, 'dummy.png'));
+ createdFallbackAssets = true;
+ }
+ });
+
+ afterEach(async () => {
+ await driveFilesRepository.createQueryBuilder().delete().execute();
+ for (const filePath of storedPaths) {
+ try {
+ fs.unlinkSync(filePath);
+ } catch {
+ // NOP
+ }
+ }
+ storedPaths.length = 0;
+ });
+
+ afterAll(async () => {
+ await fastify.close();
+ await externalFastify.close();
+ await remoteServer.close();
+ await db.destroy();
+ if (createdFallbackAssets) {
+ fs.rmSync(fallbackAssetsDir, { recursive: true, force: true });
+ }
+ });
+
+ describe('GET /files/app-default.jpg', () => {
+ test('GET /files/app-default.jpg ヘッダを検証する', async () => {
+ const prevNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'test';
+
+ try {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/app-default.jpg',
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-type']).toBe('image/jpeg');
+ expect(res.headers['access-control-allow-origin']).toBeUndefined();
+ } finally {
+ process.env.NODE_ENV = prevNodeEnv;
+ }
+ });
+
+ test('GET /files/app-default.jpg development で CORS を許可する', async () => {
+ const prevNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'development';
+
+ try {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/app-default.jpg',
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['access-control-allow-origin']).toBe('*');
+ } finally {
+ process.env.NODE_ENV = prevNodeEnv;
+ }
+ });
+
+ test('GET /files/app-default.jpg?x=1 クエリを除去してリダイレクトする', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/app-default.jpg?x=1',
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers.location).toBe('/files/app-default.jpg');
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+ });
+
+ describe('GET /files/:key', () => {
+ test('GET /files/:key 404 のときダミー画像を返す', async () => {
+ const accessKey = randomString();
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['cache-control']).toBe('max-age=86400');
+ });
+
+ test('GET /files/:key 画像配信ヘッダを検証する', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['content-length']).toBe(String(dummySize));
+ expect(res.headers['content-disposition'] ?? '').toMatch(/^inline;/);
+ });
+
+ test('GET /files/:key Range で部分配信する', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ headers: {
+ range: 'bytes=0-3',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe('4');
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ });
+
+ test('GET /files/:key Range の終端を補正する', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ headers: {
+ range: 'bytes=0-999999',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-${dummySize - 1}/${dummySize}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe(String(dummySize));
+ });
+
+ test('GET /files/:key thumbnail の Range で部分配信する', async () => {
+ const accessKey = randomString();
+ const thumbnailKey = randomString();
+ writeInternalFile(thumbnailKey);
+ await insertDriveFile({
+ accessKey,
+ thumbnailAccessKey: thumbnailKey,
+ storedInternal: true,
+ isLink: false,
+ name: 'sample.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${thumbnailKey}`,
+ headers: {
+ range: 'bytes=0-3',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe('4');
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ });
+
+ test('GET /files/:key thumbnail のファイル名を整形する', async () => {
+ const accessKey = randomString();
+ const thumbnailKey = randomString();
+ writeInternalFile(thumbnailKey);
+ await insertDriveFile({
+ accessKey,
+ thumbnailAccessKey: thumbnailKey,
+ storedInternal: true,
+ isLink: false,
+ name: 'sample.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${thumbnailKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('sample-thumb.png');
+ });
+
+ test('GET /files/:key webpublic のファイル名を整形する', async () => {
+ const accessKey = randomString();
+ const webpublicKey = randomString();
+ writeInternalFile(webpublicKey);
+ await insertDriveFile({
+ accessKey,
+ webpublicAccessKey: webpublicKey,
+ storedInternal: true,
+ isLink: false,
+ name: 'sample.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${webpublicKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('sample-web.png');
+ });
+
+ test('GET /files/:key browsersafe でない MIME は octet-stream になる', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ type: 'application/x-msdownload',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('application/octet-stream');
+ });
+
+ test('GET /files/:key 204 のときキャッシュ制御を返す', async () => {
+ const accessKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ storedInternal: false,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(204);
+ expect(res.headers['cache-control']).toBe('max-age=86400');
+ });
+
+ test('GET /files/:key 外部リンクを取得して配信する', async () => {
+ const accessKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remotePngUrl,
+ name: 'remote.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-length']).toBe(String(dummyBuffer.length));
+ expect(res.headers['content-disposition'] ?? '').toContain('remote.png');
+ });
+
+ test('GET /files/:key 外部リンクを Range で部分配信する', async () => {
+ const accessKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remotePngUrl,
+ name: 'remote.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${accessKey}`,
+ headers: {
+ range: 'bytes=0-3',
+ },
+ });
+
+ expect(res.statusCode).toBe(206);
+ expect(res.headers['content-range']).toBe(`bytes 0-3/${dummyBuffer.length}`);
+ expect(res.headers['accept-ranges']).toBe('bytes');
+ expect(res.headers['content-length']).toBe(String(dummyBuffer.length));
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ });
+
+ test('GET /files/:key thumbnail は mediaProxy/static.webp にリダイレクトする', async () => {
+ const accessKey = randomString();
+ const thumbnailKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ thumbnailAccessKey: thumbnailKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remotePngUrl,
+ name: 'remote.png',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${thumbnailKey}`,
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers.location).toContain(`${config.mediaProxy}/static.webp`);
+ expect(res.headers.location).toContain('static=1');
+ });
+
+ test('GET /files/:key webpublic svg は mediaProxy/svg.webp にリダイレクトする', async () => {
+ const accessKey = randomString();
+ const webpublicKey = randomString();
+ await insertDriveFile({
+ accessKey,
+ webpublicAccessKey: webpublicKey,
+ storedInternal: false,
+ isLink: true,
+ uri: remoteSvgUrl,
+ name: 'vector.svg',
+ type: 'image/svg+xml',
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/files/${webpublicKey}`,
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers.location).toContain(`${config.mediaProxy}/svg.webp`);
+ });
+ });
+
+ describe('GET /files/:key/*', () => {
+ test('GET /files/:key/* 正規の /files/:key にリダイレクトする', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/files/testkey/extra/path',
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers.location).toBe(`${config.url}/files/testkey`);
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+ });
+
+ describe('GET /proxy/:url*', () => {
+ test('GET /proxy/:url* 外部メディアプロキシへリダイレクトする', async () => {
+ const res = await externalFastify.inject({
+ method: 'GET',
+ url: '/proxy/path-part?url=https%3A%2F%2Fexample.com%2Fimg.png&static=1',
+ });
+
+ expect(res.statusCode).toBe(301);
+ expect(res.headers['cache-control']).toBe('public, max-age=259200');
+ expect(res.headers.location).toContain('https://media-proxy.test/');
+ expect(res.headers.location).toContain('url=https%3A%2F%2Fexample.com%2Fimg.png');
+ expect(res.headers.location).toContain('static=1');
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+
+ test('GET /proxy/:url* misskey User-Agent を拒否する', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png',
+ headers: {
+ 'user-agent': 'misskey/1.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* origin 指定時は User-Agent 必須を検証する', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png&origin=1',
+ headers: {
+ 'user-agent': '',
+ },
+ });
+
+ expect(res.statusCode).toBe(400);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ expect(res.headers.location).toBeUndefined();
+ expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
+ });
+
+ test('GET /proxy/:url* emoji 指定で非画像は 404 を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}&emoji=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* 非画像は 403 を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* emoji static で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&emoji=1&static=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* avatar static で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&avatar=1&static=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* static で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&static=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* preview で webp を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&preview=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp');
+ });
+
+ test('GET /proxy/:url* svg を webp に変換する', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteSvgUrl)}`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/webp');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.svg.webp');
+ });
+
+ test('GET /proxy/:url* badge で低エントロピー画像は 404 を返す', async () => {
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(remoteFlatPngUrl)}&badge=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['cache-control']).toBe('max-age=300');
+ });
+
+ test('GET /proxy/:url* 画像をそのまま返す', async () => {
+ const accessKey = randomString();
+ writeInternalFile(accessKey);
+ await insertDriveFile({
+ accessKey,
+ storedInternal: true,
+ isLink: false,
+ });
+
+ const res = await fastify.inject({
+ method: 'GET',
+ url: `/proxy/any?url=${encodeURIComponent(`${config.url}/files/${accessKey}`)}&origin=1`,
+ headers: {
+ 'user-agent': 'Mozilla/5.0',
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['content-type']).toBe('image/png');
+ expect(res.headers['cache-control']).toBe('max-age=31536000, immutable');
+ expect(res.headers['content-disposition'] ?? '').toContain('dummy.png');
+ });
+ });
+});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index ecca28b5af..f91fb7f9b1 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -404,37 +404,28 @@ export function connectStream<C extends keyof misskey.Channels>(user: UserToken,
}
export const waitFire = async <C extends keyof misskey.Channels>(user: UserToken, channel: C, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: misskey.Channels[C]['params']) => {
- return new Promise<boolean>(async (res, rej) => {
- let timer: NodeJS.Timeout | null = null;
+ let ws: WebSocket | undefined;
- let ws: WebSocket;
- try {
- ws = await connectStream(user, channel, msg => {
+ try {
+ let callback: (msg: Record<string, unknown>) => void;
+ const receivedPromise = new Promise<boolean>((resolve) => {
+ callback = (msg: Record<string, unknown>) => {
if (cond(msg)) {
- ws.close();
- if (timer) clearTimeout(timer);
- res(true);
+ resolve(true);
}
- }, params);
- } catch (e) {
- rej(e);
- }
-
- if (!ws!) return;
+ };
+ });
- timer = setTimeout(() => {
- ws.close();
- res(false);
- }, 3000);
+ ws = await connectStream(user, channel, callback!, params);
+ await trgr();
- try {
- await trgr();
- } catch (e) {
- ws.close();
- if (timer) clearTimeout(timer);
- rej(e);
- }
- });
+ return await Promise.race([
+ receivedPromise,
+ new Promise<void>((r) => setTimeout(() => r(), 3000)).then(() => false),
+ ]);
+ } finally {
+ if (ws) ws.close();
+ }
};
/**
diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json
index 25584e475d..dac56f25de 100644
--- a/packages/backend/tsconfig.json
+++ b/packages/backend/tsconfig.json
@@ -26,7 +26,6 @@
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
"rootDir": "./src",
- "baseUrl": "./",
"paths": {
"@/*": ["./src/*"]
},