diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2026-03-05 10:56:50 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-05 10:56:50 +0000 |
| commit | fe3dd8edb5f30104cd0a7ed755eb254feda2922d (patch) | |
| tree | af6cf5fa4ca75302ac2de5db742cead00bc13d21 /packages/backend | |
| parent | Merge pull request #16998 from misskey-dev/develop (diff) | |
| parent | Release: 2026.3.0 (diff) | |
| download | misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.gz misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.bz2 misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.zip | |
Merge pull request #17217 from misskey-dev/develop
Release: 2026.3.0
Diffstat (limited to 'packages/backend')
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 Binary files differnew file mode 100644 index 0000000000..39332b0c1b --- /dev/null +++ b/packages/backend/test/resources/dummy-for-file-server-service.png 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/*"] }, |