summaryrefslogtreecommitdiff
path: root/packages/backend
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend')
-rw-r--r--packages/backend/.eslintrc.cjs2
-rw-r--r--packages/backend/check_connect.js6
-rw-r--r--packages/backend/jest.config.cjs16
-rw-r--r--packages/backend/migration/1677570181236-role-assignment-expires-at.js13
-rw-r--r--packages/backend/package.json24
-rw-r--r--packages/backend/src/GlobalModule.ts9
-rw-r--r--packages/backend/src/boot/common.ts4
-rw-r--r--packages/backend/src/core/AntennaService.ts18
-rw-r--r--packages/backend/src/core/CreateNotificationService.ts37
-rw-r--r--packages/backend/src/core/DownloadService.ts20
-rw-r--r--packages/backend/src/core/DriveService.ts44
-rw-r--r--packages/backend/src/core/NoteCreateService.ts16
-rw-r--r--packages/backend/src/core/NoteReadService.ts47
-rw-r--r--packages/backend/src/core/RoleService.ts77
-rw-r--r--packages/backend/src/core/WebhookService.ts3
-rw-r--r--packages/backend/src/core/chart/ChartManagementService.ts8
-rw-r--r--packages/backend/src/core/chart/charts/per-user-notes.ts4
-rw-r--r--packages/backend/src/core/entities/DriveFileEntityService.ts28
-rw-r--r--packages/backend/src/core/entities/GalleryPostEntityService.ts3
-rw-r--r--packages/backend/src/core/entities/NoteEntityService.ts24
-rw-r--r--packages/backend/src/core/entities/NotificationEntityService.ts84
-rw-r--r--packages/backend/src/core/entities/RoleEntityService.ts13
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts14
-rw-r--r--packages/backend/src/misc/correct-filename.ts15
-rw-r--r--packages/backend/src/misc/is-mime-image.ts2
-rw-r--r--packages/backend/src/misc/is-not-null.ts5
-rw-r--r--packages/backend/src/misc/schema.ts17
-rw-r--r--packages/backend/src/models/entities/RoleAssignment.ts6
-rw-r--r--packages/backend/src/queue/processors/CleanProcessorService.ts18
-rw-r--r--packages/backend/src/server/FileServerService.ts68
-rw-r--r--packages/backend/src/server/ServerService.ts36
-rw-r--r--packages/backend/src/server/api/ApiCallService.ts12
-rw-r--r--packages/backend/src/server/api/ApiServerService.ts32
-rw-r--r--packages/backend/src/server/api/endpoints.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/drive/show-file.ts18
-rw-r--r--packages/backend/src/server/api/endpoints/admin/emoji/add.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/assign.ts29
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/unassign.ts22
-rw-r--r--packages/backend/src/server/api/endpoints/admin/roles/users.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/channels/timeline.ts6
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/show.ts18
-rw-r--r--packages/backend/src/server/api/endpoints/i/update-email.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.test.ts263
-rw-r--r--packages/backend/src/server/api/endpoints/notes/create.ts100
-rw-r--r--packages/backend/src/server/api/endpoints/notes/search-by-tag.ts39
-rw-r--r--packages/backend/src/server/api/endpoints/notes/timeline.ts29
-rw-r--r--packages/backend/src/server/api/endpoints/pages/show.ts20
-rw-r--r--packages/backend/src/server/api/endpoints/roles/users.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/users/followers.ts27
-rw-r--r--packages/backend/src/server/api/endpoints/users/following.ts27
-rw-r--r--packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts17
-rw-r--r--packages/backend/src/server/api/endpoints/users/show.ts40
-rw-r--r--packages/backend/src/server/api/stream/types.ts9
-rw-r--r--packages/backend/src/server/web/boot.js7
-rw-r--r--packages/backend/test/e2e/api-visibility.ts (renamed from packages/backend/test/_e2e/api-visibility.ts)32
-rw-r--r--packages/backend/test/e2e/api.ts (renamed from packages/backend/test/_e2e/api.ts)52
-rw-r--r--packages/backend/test/e2e/block.ts (renamed from packages/backend/test/_e2e/block.ts)22
-rw-r--r--packages/backend/test/e2e/endpoints.ts (renamed from packages/backend/test/_e2e/endpoints.ts)309
-rw-r--r--packages/backend/test/e2e/fetch-resource.ts (renamed from packages/backend/test/_e2e/fetch-resource.ts)42
-rw-r--r--packages/backend/test/e2e/ff-visibility.ts (renamed from packages/backend/test/_e2e/ff-visibility.ts)64
-rw-r--r--packages/backend/test/e2e/mute.ts (renamed from packages/backend/test/_e2e/mute.ts)30
-rw-r--r--packages/backend/test/e2e/note.ts (renamed from packages/backend/test/_e2e/note.ts)214
-rw-r--r--packages/backend/test/e2e/streaming.ts (renamed from packages/backend/test/_e2e/streaming.ts)88
-rw-r--r--packages/backend/test/e2e/thread-mute.ts (renamed from packages/backend/test/_e2e/thread-mute.ts)30
-rw-r--r--packages/backend/test/e2e/user-notes.ts (renamed from packages/backend/test/_e2e/user-notes.ts)14
-rw-r--r--packages/backend/test/prelude/get-api-validator.ts11
-rw-r--r--packages/backend/test/resources/misskey.svg7
-rw-r--r--packages/backend/test/tsconfig.json3
-rw-r--r--packages/backend/test/unit/RoleService.ts75
-rw-r--r--packages/backend/test/unit/misc/others.ts42
-rw-r--r--packages/backend/test/utils.ts203
-rw-r--r--packages/backend/tsconfig.json7
72 files changed, 1625 insertions, 1038 deletions
diff --git a/packages/backend/.eslintrc.cjs b/packages/backend/.eslintrc.cjs
index 5a06889dcd..f9fe4814e6 100644
--- a/packages/backend/.eslintrc.cjs
+++ b/packages/backend/.eslintrc.cjs
@@ -1,7 +1,7 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
- project: ['./tsconfig.json'],
+ project: ['./tsconfig.json', './test/tsconfig.json'],
},
extends: [
'../shared/.eslintrc.js',
diff --git a/packages/backend/check_connect.js b/packages/backend/check_connect.js
index 8bf134a105..ed429c0254 100644
--- a/packages/backend/check_connect.js
+++ b/packages/backend/check_connect.js
@@ -1,10 +1,10 @@
-import {loadConfig} from './built/config.js';
-import {createRedisConnection} from "./built/redis.js";
+import { loadConfig } from './built/config.js';
+import { createRedisConnection } from './built/redis.js';
const config = loadConfig();
const redis = createRedisConnection(config);
redis.on('connect', () => redis.disconnect());
redis.on('error', (e) => {
- throw e;
+ throw e;
});
diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs
index 2f11f6a3e9..6b1afec734 100644
--- a/packages/backend/jest.config.cjs
+++ b/packages/backend/jest.config.cjs
@@ -20,7 +20,7 @@ module.exports = {
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
- collectCoverageFrom: ['src/**/*.ts'],
+ collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
@@ -91,7 +91,7 @@ module.exports = {
// See https://github.com/swc-project/jest/issues/64#issuecomment-1029753225
// TODO: Use `--allowImportingTsExtensions` on TypeScript 5.0 so that we can
// directly import `.ts` files without this hack.
- '^(\\.{1,2}/.*)\\.js$': '$1',
+ '^((?:\\.{1,2}|[A-Z:])*/.*)\\.js$': '$1',
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
@@ -159,7 +159,8 @@ module.exports = {
// The glob patterns Jest uses to detect test files
testMatch: [
"<rootDir>/test/unit/**/*.ts",
- //"<rootDir>/test/e2e/**/*.ts"
+ "<rootDir>/src/**/*.test.ts",
+ "<rootDir>/test/e2e/**/*.ts",
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
@@ -206,4 +207,13 @@ module.exports = {
// watchman: true,
extensionsToTreatAsEsm: ['.ts'],
+
+ testTimeout: 60000,
+
+ // Let Jest kill the test worker whenever it grows too much
+ // (It seems there's a known memory leak issue in Node.js' vm.Script used by Jest)
+ // https://github.com/facebook/jest/issues/11956
+ maxWorkers: 1, // Make it use worker (that can be killed and restarted)
+ logHeapUsage: true, // To debug when out-of-memory happens on CI
+ workerIdleMemoryLimit: '1GiB', // Limit the worker to 1GB (GitHub Workflows dies at 2GB)
};
diff --git a/packages/backend/migration/1677570181236-role-assignment-expires-at.js b/packages/backend/migration/1677570181236-role-assignment-expires-at.js
new file mode 100644
index 0000000000..3ac2edab0a
--- /dev/null
+++ b/packages/backend/migration/1677570181236-role-assignment-expires-at.js
@@ -0,0 +1,13 @@
+export class roleAssignmentExpiresAt1677570181236 {
+ name = 'roleAssignmentExpiresAt1677570181236'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "role_assignment" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`);
+ await queryRunner.query(`CREATE INDEX "IDX_539b6c08c05067599743bb6389" ON "role_assignment" ("expiresAt") `);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`DROP INDEX "public"."IDX_539b6c08c05067599743bb6389"`);
+ await queryRunner.query(`ALTER TABLE "role_assignment" DROP COLUMN "expiresAt"`);
+ }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 4d1d37efff..35e8dc5c60 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -15,8 +15,8 @@
"typecheck": "tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
- "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --runInBand --detectOpenHandles",
- "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --runInBand --detectOpenHandles",
+ "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit",
+ "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"test": "pnpm jest",
"test-and-coverage": "pnpm jest-and-coverage"
@@ -80,7 +80,7 @@
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "12.5.3",
- "happy-dom": "^8.7.0",
+ "happy-dom": "8.9.0",
"hpagent": "1.2.0",
"ioredis": "4.28.5",
"ip-cidr": "3.1.0",
@@ -88,7 +88,7 @@
"js-yaml": "4.1.0",
"jsdom": "21.1.0",
"json5": "2.2.3",
- "jsonld": "8.1.0",
+ "jsonld": "8.1.1",
"jsrsasign": "10.6.1",
"mfm-js": "0.23.3",
"mime-types": "2.1.35",
@@ -124,10 +124,11 @@
"seedrandom": "3.0.5",
"semver": "7.3.8",
"sharp": "0.31.3",
+ "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
- "systeminformation": "5.17.9",
+ "systeminformation": "5.17.10",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tsc-alias": "1.8.2",
@@ -146,7 +147,6 @@
},
"devDependencies": {
"@jest/globals": "29.4.3",
- "@redocly/openapi-core": "1.0.0-beta.123",
"@swc/jest": "0.2.24",
"@types/accepts": "1.3.5",
"@types/archiver": "5.3.1",
@@ -156,7 +156,7 @@
"@types/color-convert": "2.0.0",
"@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1",
- "@types/fluent-ffmpeg": "2.1.20",
+ "@types/fluent-ffmpeg": "2.1.21",
"@types/ioredis": "4.28.10",
"@types/jest": "29.4.0",
"@types/js-yaml": "4.0.5",
@@ -164,7 +164,7 @@
"@types/jsonld": "1.5.8",
"@types/jsrsasign": "10.5.5",
"@types/mime-types": "2.1.1",
- "@types/node": "18.14.0",
+ "@types/node": "18.14.1",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.7",
"@types/oauth": "0.9.1",
@@ -183,18 +183,18 @@
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/unzipper": "0.10.5",
- "@types/uuid": "9.0.0",
+ "@types/uuid": "9.0.1",
"@types/vary": "1.1.0",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.52.0",
- "@typescript-eslint/parser": "5.52.0",
+ "@typescript-eslint/parser": "5.53.0",
"cross-env": "7.0.3",
- "eslint": "8.34.0",
+ "eslint": "8.35.0",
"eslint-plugin-import": "2.27.5",
"execa": "6.1.0",
"jest": "29.4.3",
"jest-mock": "29.4.3"
}
-} \ No newline at end of file
+}
diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts
index 35416209a0..801f1db741 100644
--- a/packages/backend/src/GlobalModule.ts
+++ b/packages/backend/src/GlobalModule.ts
@@ -1,3 +1,4 @@
+import { setTimeout } from 'node:timers/promises';
import { Global, Inject, Module } from '@nestjs/common';
import Redis from 'ioredis';
import { DataSource } from 'typeorm';
@@ -57,6 +58,14 @@ export class GlobalModule implements OnApplicationShutdown {
) {}
async onApplicationShutdown(signal: string): Promise<void> {
+ if (process.env.NODE_ENV === 'test') {
+ // XXX:
+ // Shutting down the existing connections causes errors on Jest as
+ // Misskey has asynchronous postgres/redis connections that are not
+ // awaited.
+ // Let's wait for some random time for them to finish.
+ await setTimeout(5000);
+ }
await Promise.all([
this.db.destroy(),
this.redisClient.disconnect(),
diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts
index 04aa26e652..279a1fe59d 100644
--- a/packages/backend/src/boot/common.ts
+++ b/packages/backend/src/boot/common.ts
@@ -16,12 +16,14 @@ export async function server() {
app.enableShutdownHooks();
const serverService = app.get(ServerService);
- serverService.launch();
+ await serverService.launch();
app.get(ChartManagementService).start();
app.get(JanitorService).start();
app.get(QueueStatsService).start();
app.get(ServerStatsService).start();
+
+ return app;
}
export async function jobQueue() {
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 0e72545934..05930350fa 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -171,13 +171,15 @@ export class AntennaService implements OnApplicationShutdown {
.filter(xs => xs.length > 0);
if (keywords.length > 0) {
- if (note.text == null) return false;
+ if (note.text == null && note.cw == null) return false;
+
+ const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
const matched = keywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
- ? note.text!.includes(keyword)
- : note.text!.toLowerCase().includes(keyword.toLowerCase()),
+ ? _text.includes(keyword)
+ : _text.toLowerCase().includes(keyword.toLowerCase()),
));
if (!matched) return false;
@@ -189,13 +191,15 @@ export class AntennaService implements OnApplicationShutdown {
.filter(xs => xs.length > 0);
if (excludeKeywords.length > 0) {
- if (note.text == null) return false;
-
+ if (note.text == null && note.cw == null) return false;
+
+ const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
+
const matched = excludeKeywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
- ? note.text!.includes(keyword)
- : note.text!.toLowerCase().includes(keyword.toLowerCase()),
+ ? _text.includes(keyword)
+ : _text.toLowerCase().includes(keyword.toLowerCase()),
));
if (matched) return false;
diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts
index cd47844a75..eba7171fb6 100644
--- a/packages/backend/src/core/CreateNotificationService.ts
+++ b/packages/backend/src/core/CreateNotificationService.ts
@@ -1,4 +1,5 @@
-import { Inject, Injectable } from '@nestjs/common';
+import { setTimeout } from 'node:timers/promises';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import type { Notification } from '@/models/entities/Notification.js';
@@ -10,7 +11,9 @@ import { PushNotificationService } from '@/core/PushNotificationService.js';
import { bindThis } from '@/decorators.js';
@Injectable()
-export class CreateNotificationService {
+export class CreateNotificationService implements OnApplicationShutdown {
+ #shutdownController = new AbortController();
+
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -40,11 +43,11 @@ export class CreateNotificationService {
if (data.notifierId && (notifieeId === data.notifierId)) {
return null;
}
-
+
const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
-
+
const isMuted = profile?.mutingNotificationTypes.includes(type);
-
+
// Create notification
const notification = await this.notificationsRepository.insert({
id: this.idService.genId(),
@@ -56,18 +59,18 @@ export class CreateNotificationService {
...data,
} as Partial<Notification>)
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
-
+
const packed = await this.notificationEntityService.pack(notification, {});
-
+
// Publish notification event
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
-
+
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
- setTimeout(async () => {
+ setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id });
if (fresh == null) return; // 既に削除されているかもしれない
if (fresh.isRead) return;
-
+
//#region ただしミュートしているユーザーからの通知なら無視
const mutings = await this.mutingsRepository.findBy({
muterId: notifieeId,
@@ -76,14 +79,14 @@ export class CreateNotificationService {
return;
}
//#endregion
-
+
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
-
+
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
- }, 2000);
-
+ }, () => { /* aborted, ignore it */ });
+
return notification;
}
@@ -103,7 +106,7 @@ export class CreateNotificationService {
sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
*/
}
-
+
@bindThis
private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) {
/*
@@ -115,4 +118,8 @@ export class CreateNotificationService {
sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
*/
}
+
+ onApplicationShutdown(signal?: string | undefined): void {
+ this.#shutdownController.abort();
+ }
}
diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts
index 852c1f32e3..bd999c67da 100644
--- a/packages/backend/src/core/DownloadService.ts
+++ b/packages/backend/src/core/DownloadService.ts
@@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip';
import chalk from 'chalk';
import got, * as Got from 'got';
+import { parse } from 'content-disposition';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -32,13 +33,18 @@ export class DownloadService {
}
@bindThis
- public async downloadUrl(url: string, path: string): Promise<void> {
+ public async downloadUrl(url: string, path: string): Promise<{
+ filename: string;
+ }> {
this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
const timeout = 30 * 1000;
const operationTimeout = 60 * 1000;
const maxSize = this.config.maxFileSize ?? 262144000;
+ const urlObj = new URL(url);
+ let filename = urlObj.pathname.split('/').pop() ?? 'untitled';
+
const req = got.stream(url, {
headers: {
'User-Agent': this.config.userAgent,
@@ -77,6 +83,14 @@ export class DownloadService {
req.destroy();
}
}
+
+ const contentDisposition = res.headers['content-disposition'];
+ if (contentDisposition != null) {
+ const parsed = parse(contentDisposition);
+ if (parsed.parameters.filename) {
+ filename = parsed.parameters.filename;
+ }
+ }
}).on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > maxSize) {
this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
@@ -95,6 +109,10 @@ export class DownloadService {
}
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
+
+ return {
+ filename,
+ };
}
@bindThis
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index b15c967c85..f4a06faebb 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -34,6 +34,7 @@ import { FileInfoService } from '@/core/FileInfoService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type S3 from 'aws-sdk/clients/s3.js';
+import { correctFilename } from '@/misc/correct-filename.js';
type AddFileArgs = {
/** User who wish to add file */
@@ -168,7 +169,7 @@ export class DriveService {
//#region Uploads
this.registerLogger.info(`uploading original: ${key}`);
const uploads = [
- this.upload(key, fs.createReadStream(path), type, name),
+ this.upload(key, fs.createReadStream(path), type, ext, name),
];
if (alts.webpublic) {
@@ -176,7 +177,7 @@ export class DriveService {
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
- uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name));
+ uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name));
}
if (alts.thumbnail) {
@@ -184,7 +185,7 @@ export class DriveService {
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
- uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type));
+ uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext));
}
await Promise.all(uploads);
@@ -360,7 +361,7 @@ export class DriveService {
* Upload to ObjectStorage
*/
@bindThis
- private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) {
+ private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) {
if (type === 'image/apng') type = 'image/png';
if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream';
@@ -374,7 +375,12 @@ export class DriveService {
CacheControl: 'max-age=31536000, immutable',
} as S3.PutObjectRequest;
- if (filename) params.ContentDisposition = contentDisposition('inline', filename);
+ if (filename) params.ContentDisposition = contentDisposition(
+ 'inline',
+ // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
+ // 許可されているファイル形式でしか拡張子をつけない
+ ext ? correctFilename(filename, ext) : filename,
+ );
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
const s3 = this.s3Service.getS3(meta);
@@ -466,7 +472,12 @@ export class DriveService {
//}
// detect name
- const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled');
+ const detectedName = correctFilename(
+ // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、
+ // extを付加してデータベースの文字数制限に当たることはまずない
+ (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled',
+ info.type.ext
+ );
if (user && !force) {
// Check if there is a file with the same hash
@@ -736,24 +747,19 @@ export class DriveService {
requestIp = null,
requestHeaders = null,
}: UploadFromUrlArgs): Promise<DriveFile> {
- let name = new URL(url).pathname.split('/').pop() ?? null;
- if (name == null || !this.driveFileEntityService.validateFileName(name)) {
- name = null;
- }
-
- // If the comment is same as the name, skip comment
- // (image.name is passed in when receiving attachment)
- if (comment !== null && name === comment) {
- comment = null;
- }
-
// Create temp file
const [path, cleanup] = await createTemp();
try {
// write content at URL to temp file
- await this.downloadService.downloadUrl(url, path);
-
+ const { filename: name } = await this.downloadService.downloadUrl(url, path);
+
+ // If the comment is same as the name, skip comment
+ // (image.name is passed in when receiving attachment)
+ if (comment !== null && name === comment) {
+ comment = null;
+ }
+
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
this.downloaderLogger.succ(`Got: ${driveFile.id}`);
return driveFile!;
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 54c135a7c5..4c4261ba79 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -1,6 +1,7 @@
+import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
import { In, DataSource } from 'typeorm';
-import { Inject, Injectable } from '@nestjs/common';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
@@ -137,7 +138,9 @@ type Option = {
};
@Injectable()
-export class NoteCreateService {
+export class NoteCreateService implements OnApplicationShutdown {
+ #shutdownController = new AbortController();
+
constructor(
@Inject(DI.config)
private config: Config,
@@ -313,7 +316,10 @@ export class NoteCreateService {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
- setImmediate(() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!));
+ setImmediate('post created', { signal: this.#shutdownController.signal }).then(
+ () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
+ () => { /* aborted, ignore this */ },
+ );
return note;
}
@@ -756,4 +762,8 @@ export class NoteCreateService {
return mentionedUsers;
}
+
+ onApplicationShutdown(signal?: string | undefined) {
+ this.#shutdownController.abort();
+ }
}
diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts
index 84983d600e..d23fb8238b 100644
--- a/packages/backend/src/core/NoteReadService.ts
+++ b/packages/backend/src/core/NoteReadService.ts
@@ -1,4 +1,5 @@
-import { Inject, Injectable } from '@nestjs/common';
+import { setTimeout } from 'node:timers/promises';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { In, IsNull, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { User } from '@/models/entities/User.js';
@@ -15,7 +16,9 @@ import { AntennaService } from './AntennaService.js';
import { PushNotificationService } from './PushNotificationService.js';
@Injectable()
-export class NoteReadService {
+export class NoteReadService implements OnApplicationShutdown {
+ #shutdownController = new AbortController();
+
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -60,14 +63,14 @@ export class NoteReadService {
});
if (mute.map(m => m.muteeId).includes(note.userId)) return;
//#endregion
-
+
// スレッドミュート
const threadMute = await this.noteThreadMutingsRepository.findOneBy({
userId: userId,
threadId: note.threadId ?? note.id,
});
if (threadMute) return;
-
+
const unread = {
id: this.idService.genId(),
noteId: note.id,
@@ -77,15 +80,15 @@ export class NoteReadService {
noteChannelId: note.channelId,
noteUserId: note.userId,
};
-
+
await this.noteUnreadsRepository.insert(unread);
-
+
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
- setTimeout(async () => {
+ setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id });
-
+
if (exist == null) return;
-
+
if (params.isMentioned) {
this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);
}
@@ -95,8 +98,8 @@ export class NoteReadService {
if (note.channelId) {
this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id);
}
- }, 2000);
- }
+ }, () => { /* aborted, ignore it */ });
+ }
@bindThis
public async read(
@@ -113,24 +116,24 @@ export class NoteReadService {
},
select: ['followeeId'],
})).map(x => x.followeeId));
-
+
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
const readMentions: (Note | Packed<'Note'>)[] = [];
const readSpecifiedNotes: (Note | Packed<'Note'>)[] = [];
const readChannelNotes: (Note | Packed<'Note'>)[] = [];
const readAntennaNotes: (Note | Packed<'Note'>)[] = [];
-
+
for (const note of notes) {
if (note.mentions && note.mentions.includes(userId)) {
readMentions.push(note);
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
readSpecifiedNotes.push(note);
}
-
+
if (note.channelId && followingChannels.has(note.channelId)) {
readChannelNotes.push(note);
}
-
+
if (note.user != null) { // たぶんnullになることは無いはずだけど一応
for (const antenna of myAntennas) {
if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) {
@@ -139,14 +142,14 @@ export class NoteReadService {
}
}
}
-
+
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) {
// Remove the record
await this.noteUnreadsRepository.delete({
userId: userId,
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]),
});
-
+
// TODO: ↓まとめてクエリしたい
this.noteUnreadsRepository.countBy({
@@ -183,7 +186,7 @@ export class NoteReadService {
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
});
}
-
+
if (readAntennaNotes.length > 0) {
await this.antennaNotesRepository.update({
antennaId: In(myAntennas.map(a => a.id)),
@@ -191,14 +194,14 @@ export class NoteReadService {
}, {
read: true,
});
-
+
// TODO: まとめてクエリしたい
for (const antenna of myAntennas) {
const count = await this.antennaNotesRepository.countBy({
antennaId: antenna.id,
read: false,
});
-
+
if (count === 0) {
this.globalEventService.publishMainStream(userId, 'readAntenna', antenna);
this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id });
@@ -213,4 +216,8 @@ export class NoteReadService {
});
}
}
+
+ onApplicationShutdown(signal?: string | undefined): void {
+ this.#shutdownController.abort();
+ }
}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index b84d5e7585..7149591198 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -11,6 +11,8 @@ import { UserCacheService } from '@/core/UserCacheService.js';
import type { RoleCondFormulaValue } from '@/models/entities/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = {
@@ -56,6 +58,9 @@ export class RoleService implements OnApplicationShutdown {
private rolesCache: Cache<Role[]>;
private roleAssignmentByUserIdCache: Cache<RoleAssignment[]>;
+ public static AlreadyAssignedError = class extends Error {};
+ public static NotAssignedError = class extends Error {};
+
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@@ -72,6 +77,8 @@ export class RoleService implements OnApplicationShutdown {
private metaService: MetaService,
private userCacheService: UserCacheService,
private userEntityService: UserEntityService,
+ private globalEventService: GlobalEventService,
+ private idService: IdService,
) {
//this.onMessage = this.onMessage.bind(this);
@@ -128,6 +135,7 @@ export class RoleService implements OnApplicationShutdown {
cached.push({
...body,
createdAt: new Date(body.createdAt),
+ expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
});
}
break;
@@ -193,7 +201,10 @@ export class RoleService implements OnApplicationShutdown {
@bindThis
public async getUserRoles(userId: User['id']) {
- const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
+ const now = Date.now();
+ let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
+ // 期限切れのロールを除外
+ assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId);
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id));
@@ -207,7 +218,10 @@ export class RoleService implements OnApplicationShutdown {
*/
@bindThis
public async getUserBadgeRoles(userId: User['id']) {
- const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
+ const now = Date.now();
+ let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
+ // 期限切れのロールを除外
+ assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId);
const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({}));
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
@@ -317,6 +331,65 @@ export class RoleService implements OnApplicationShutdown {
}
@bindThis
+ public async assign(userId: User['id'], roleId: Role['id'], expiresAt: Date | null = null): Promise<void> {
+ const now = new Date();
+
+ const existing = await this.roleAssignmentsRepository.findOneBy({
+ roleId: roleId,
+ userId: userId,
+ });
+
+ if (existing) {
+ if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) {
+ await this.roleAssignmentsRepository.delete({
+ roleId: roleId,
+ userId: userId,
+ });
+ } else {
+ throw new RoleService.AlreadyAssignedError();
+ }
+ }
+
+ const created = await this.roleAssignmentsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: now,
+ expiresAt: expiresAt,
+ roleId: roleId,
+ userId: userId,
+ }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0]));
+
+ this.rolesRepository.update(roleId, {
+ lastUsedAt: new Date(),
+ });
+
+ this.globalEventService.publishInternalEvent('userRoleAssigned', created);
+ }
+
+ @bindThis
+ public async unassign(userId: User['id'], roleId: Role['id']): Promise<void> {
+ const now = new Date();
+
+ const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId });
+ if (existing == null) {
+ throw new RoleService.NotAssignedError();
+ } else if (existing.expiresAt && (existing.expiresAt.getTime() < now.getTime())) {
+ await this.roleAssignmentsRepository.delete({
+ roleId: roleId,
+ userId: userId,
+ });
+ throw new RoleService.NotAssignedError();
+ }
+
+ await this.roleAssignmentsRepository.delete(existing.id);
+
+ this.rolesRepository.update(roleId, {
+ lastUsedAt: now,
+ });
+
+ this.globalEventService.publishInternalEvent('userRoleUnassigned', existing);
+ }
+
+ @bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
}
diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts
index 30caa9682c..ac1e413de6 100644
--- a/packages/backend/src/core/WebhookService.ts
+++ b/packages/backend/src/core/WebhookService.ts
@@ -47,6 +47,7 @@ export class WebhookService implements OnApplicationShutdown {
this.webhooks.push({
...body,
createdAt: new Date(body.createdAt),
+ latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
});
}
break;
@@ -57,11 +58,13 @@ export class WebhookService implements OnApplicationShutdown {
this.webhooks[i] = {
...body,
createdAt: new Date(body.createdAt),
+ latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
};
} else {
this.webhooks.push({
...body,
createdAt: new Date(body.createdAt),
+ latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
});
}
} else {
diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts
index dbde757676..03e3612658 100644
--- a/packages/backend/src/core/chart/ChartManagementService.ts
+++ b/packages/backend/src/core/chart/ChartManagementService.ts
@@ -62,8 +62,10 @@ export class ChartManagementService implements OnApplicationShutdown {
async onApplicationShutdown(signal: string): Promise<void> {
clearInterval(this.saveIntervalId);
- await Promise.all(
- this.charts.map(chart => chart.save()),
- );
+ if (process.env.NODE_ENV !== 'test') {
+ await Promise.all(
+ this.charts.map(chart => chart.save()),
+ );
+ }
}
}
diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts
index 1e2a579dfa..d8966f34c1 100644
--- a/packages/backend/src/core/chart/charts/per-user-notes.ts
+++ b/packages/backend/src/core/chart/charts/per-user-notes.ts
@@ -45,8 +45,8 @@ export default class PerUserNotesChart extends Chart<typeof schema> {
}
@bindThis
- public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean): Promise<void> {
- await this.commit({
+ public update(user: { id: User['id'] }, note: Note, isAdditional: boolean): void {
+ this.commit({
'total': isAdditional ? 1 : -1,
'inc': isAdditional ? 1 : 0,
'dec': isAdditional ? 0 : 1,
diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts
index 158fafa9d5..f769ddd5e9 100644
--- a/packages/backend/src/core/entities/DriveFileEntityService.ts
+++ b/packages/backend/src/core/entities/DriveFileEntityService.ts
@@ -1,5 +1,5 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
-import { DataSource } from 'typeorm';
+import { DataSource, In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -21,6 +21,7 @@ type PackOptions = {
};
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
+import { isNotNull } from '@/misc/is-not-null.js';
@Injectable()
export class DriveFileEntityService {
@@ -255,10 +256,33 @@ export class DriveFileEntityService {
@bindThis
public async packMany(
- files: (DriveFile['id'] | DriveFile)[],
+ files: DriveFile[],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
const items = await Promise.all(files.map(f => this.packNullable(f, options)));
return items.filter((x): x is Packed<'DriveFile'> => x != null);
}
+
+ @bindThis
+ public async packManyByIdsMap(
+ fileIds: DriveFile['id'][],
+ options?: PackOptions,
+ ): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> {
+ const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
+ const packedFiles = await this.packMany(files, options);
+ const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f]));
+ for (const id of fileIds) {
+ if (!map.has(id)) map.set(id, null);
+ }
+ return map;
+ }
+
+ @bindThis
+ public async packManyByIds(
+ fileIds: DriveFile['id'][],
+ options?: PackOptions,
+ ): Promise<Packed<'DriveFile'>[]> {
+ const filesMap = await this.packManyByIdsMap(fileIds, options);
+ return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
+ }
}
diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts
index ab29e7dba1..fb147ae181 100644
--- a/packages/backend/src/core/entities/GalleryPostEntityService.ts
+++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts
@@ -41,7 +41,8 @@ export class GalleryPostEntityService {
title: post.title,
description: post.description,
fileIds: post.fileIds,
- files: this.driveFileEntityService.packMany(post.fileIds),
+ // TODO: packMany causes N+1 queries
+ files: this.driveFileEntityService.packManyByIds(post.fileIds),
tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive,
likedCount: post.likedCount,
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index 2ffe5f1c21..4ec10df9a6 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
+import { isNotNull } from '@/misc/is-not-null.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js';
@@ -249,6 +250,21 @@ export class NoteEntityService implements OnModuleInit {
}
@bindThis
+ public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
+ const missingIds = [];
+ for (const id of fileIds) {
+ if (!packedFiles.has(id)) missingIds.push(id);
+ }
+ if (missingIds.length) {
+ const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds);
+ for (const [k, v] of additionalMap) {
+ packedFiles.set(k, v);
+ }
+ }
+ return fileIds.map(id => packedFiles.get(id)).filter(isNotNull);
+ }
+
+ @bindThis
public async pack(
src: Note['id'] | Note,
me?: { id: User['id'] } | null | undefined,
@@ -257,6 +273,7 @@ export class NoteEntityService implements OnModuleInit {
skipHide?: boolean;
_hint_?: {
myReactions: Map<Note['id'], NoteReaction | null>;
+ packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>;
};
},
): Promise<Packed<'Note'>> {
@@ -284,6 +301,7 @@ export class NoteEntityService implements OnModuleInit {
const reactionEmojiNames = Object.keys(note.reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
+ const packedFiles = options?._hint_?.packedFiles;
const packed: Packed<'Note'> = await awaitAll({
id: note.id,
@@ -304,7 +322,7 @@ export class NoteEntityService implements OnModuleInit {
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
tags: note.tags.length > 0 ? note.tags : undefined,
fileIds: note.fileIds,
- files: this.driveFileEntityService.packMany(note.fileIds),
+ files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds),
replyId: note.replyId,
renoteId: note.renoteId,
channelId: note.channelId ?? undefined,
@@ -388,11 +406,15 @@ export class NoteEntityService implements OnModuleInit {
}
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
+ // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
+ const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull);
+ const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds);
return await Promise.all(notes.map(n => this.pack(n, me, {
...options,
_hint_: {
myReactions: myReactionsMap,
+ packedFiles,
},
})));
}
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index 33c76c6937..be88a213f4 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -1,19 +1,21 @@
import { Inject, Injectable } from '@nestjs/common';
-import { In } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Notification } from '@/models/entities/Notification.js';
-import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { Note } from '@/models/entities/Note.js';
import type { Packed } from '@/misc/schema.js';
import { bindThis } from '@/decorators.js';
+import { isNotNull } from '@/misc/is-not-null.js';
+import { notificationTypes } from '@/types.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';
+const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]);
+
@Injectable()
export class NotificationEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
@@ -48,13 +50,20 @@ export class NotificationEntityService implements OnModuleInit {
public async pack(
src: Notification['id'] | Notification,
options: {
- _hintForEachNotes_?: {
- myReactions: Map<Note['id'], NoteReaction | null>;
+ _hint_?: {
+ packedNotes: Map<Note['id'], Packed<'Note'>>;
};
},
): Promise<Packed<'Notification'>> {
const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src });
const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null;
+ const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
+ options._hint_?.packedNotes != null
+ ? options._hint_.packedNotes.get(notification.noteId)
+ : this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
+ detail: true,
+ })
+ ) : undefined;
return await awaitAll({
id: notification.id,
@@ -63,43 +72,10 @@ export class NotificationEntityService implements OnModuleInit {
isRead: notification.isRead,
userId: notification.notifierId,
user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null,
- ...(notification.type === 'mention' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
- ...(notification.type === 'reply' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
- ...(notification.type === 'renote' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
- ...(notification.type === 'quote' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
+ ...(noteIfNeed != null ? { note: noteIfNeed } : {}),
...(notification.type === 'reaction' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
reaction: notification.reaction,
} : {}),
- ...(notification.type === 'pollEnded' ? {
- note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
- detail: true,
- _hint_: options._hintForEachNotes_,
- }),
- } : {}),
...(notification.type === 'achievementEarned' ? {
achievement: notification.achievement,
} : {}),
@@ -111,32 +87,32 @@ export class NotificationEntityService implements OnModuleInit {
});
}
+ /**
+ * @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId
+ */
@bindThis
public async packMany(
notifications: Notification[],
meId: User['id'],
) {
if (notifications.length === 0) return [];
-
- const notes = notifications.filter(x => x.note != null).map(x => x.note!);
- const noteIds = notes.map(n => n.id);
- const myReactionsMap = new Map<Note['id'], NoteReaction | null>();
- const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!);
- const targets = [...noteIds, ...renoteIds];
- const myReactions = await this.noteReactionsRepository.findBy({
- userId: meId,
- noteId: In(targets),
- });
-
- for (const target of targets) {
- myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null);
+
+ for (const notification of notifications) {
+ if (meId !== notification.notifieeId) {
+ // because we call note packMany with meId, all notifieeId should be same as meId
+ throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION');
+ }
}
- await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
+ const notes = notifications.map(x => x.note).filter(isNotNull);
+ const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
+ detail: true,
+ });
+ const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
return await Promise.all(notifications.map(x => this.pack(x, {
- _hintForEachNotes_: {
- myReactions: myReactionsMap,
+ _hint_: {
+ packedNotes,
},
})));
}
diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts
index 80ef5ac1fa..2f1d51fa1a 100644
--- a/packages/backend/src/core/entities/RoleEntityService.ts
+++ b/packages/backend/src/core/entities/RoleEntityService.ts
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
@@ -28,9 +29,13 @@ export class RoleEntityService {
) {
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
- const assigns = await this.roleAssignmentsRepository.findBy({
- roleId: role.id,
- });
+ const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign')
+ .where('assign.roleId = :roleId', { roleId: role.id })
+ .andWhere(new Brackets(qb => { qb
+ .where('assign.expiresAt IS NULL')
+ .orWhere('assign.expiresAt > :now', { now: new Date() });
+ }))
+ .getCount();
const policies = { ...role.policies };
for (const [k, v] of Object.entries(DEFAULT_POLICIES)) {
@@ -57,7 +62,7 @@ export class RoleEntityService {
asBadge: role.asBadge,
canEditMembersByModerator: role.canEditMembersByModerator,
policies: policies,
- usersCount: assigns.length,
+ usersCount: assignedCount,
});
}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 8c36e47f1b..3635643218 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -278,27 +278,27 @@ export class UserEntityService implements OnModuleInit {
@bindThis
public async getAvatarUrl(user: User): Promise<string> {
if (user.avatar) {
- return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
+ return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else if (user.avatarId) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
- return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
+ return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
- return this.getIdenticonUrl(user.id);
+ return this.getIdenticonUrl(user);
}
}
@bindThis
public getAvatarUrlSync(user: User): string {
if (user.avatar) {
- return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
+ return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
- return this.getIdenticonUrl(user.id);
+ return this.getIdenticonUrl(user);
}
}
@bindThis
- public getIdenticonUrl(userId: User['id']): string {
- return `${this.config.url}/identicon/${userId}`;
+ public getIdenticonUrl(user: User): string {
+ return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
}
public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>(
diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts
new file mode 100644
index 0000000000..3357d8c1bd
--- /dev/null
+++ b/packages/backend/src/misc/correct-filename.ts
@@ -0,0 +1,15 @@
+// 与えられた拡張子とファイル名が一致しているかどうかを確認し、
+// 一致していない場合は拡張子を付与して返す
+export function correctFilename(filename: string, ext: string | null) {
+ const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown';
+ if (filename.endsWith(dotExt)) {
+ return filename;
+ }
+ if (ext === 'jpg' && filename.endsWith('.jpeg')) {
+ return filename;
+ }
+ if (ext === 'tif' && filename.endsWith('.tiff')) {
+ return filename;
+ }
+ return `${filename}${dotExt}`;
+}
diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts
index acf5c1ede3..0b6d147dc1 100644
--- a/packages/backend/src/misc/is-mime-image.ts
+++ b/packages/backend/src/misc/is-mime-image.ts
@@ -4,6 +4,8 @@ const dictionary = {
'safe-file': FILE_TYPE_BROWSERSAFE,
'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'],
'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'],
+ 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
+ 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
};
export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime);
diff --git a/packages/backend/src/misc/is-not-null.ts b/packages/backend/src/misc/is-not-null.ts
new file mode 100644
index 0000000000..d89a1957be
--- /dev/null
+++ b/packages/backend/src/misc/is-not-null.ts
@@ -0,0 +1,5 @@
+// we are using {} as "any non-nullish value" as expected
+// eslint-disable-next-line @typescript-eslint/ban-types
+export function isNotNull<T extends {}>(input: T | undefined | null): input is T {
+ return input != null;
+}
diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts
index 7fc4a3e654..6a0802f8a4 100644
--- a/packages/backend/src/misc/schema.ts
+++ b/packages/backend/src/misc/schema.ts
@@ -116,10 +116,10 @@ export type Obj = Record<string, Schema>;
// https://github.com/misskey-dev/misskey/issues/8535
// To avoid excessive stack depth error,
// deceive TypeScript with UnionToIntersection (or more precisely, `infer` expression within it).
-export type ObjType<s extends Obj, RequiredProps extends keyof s> =
+export type ObjType<s extends Obj, RequiredProps extends ReadonlyArray<keyof s>> =
UnionToIntersection<
{ -readonly [R in RequiredPropertyNames<s>]-?: SchemaType<s[R]> } &
- { -readonly [R in RequiredProps]-?: SchemaType<s[R]> } &
+ { -readonly [R in RequiredProps[number]]-?: SchemaType<s[R]> } &
{ -readonly [P in keyof s]?: SchemaType<s[P]> }
>;
@@ -136,18 +136,19 @@ type PartialIntersection<T> = Partial<UnionToIntersection<T>>;
// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552
// To get union, we use `Foo extends any ? Hoge<Foo> : never`
type UnionSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? SchemaType<X> : never;
-type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never;
+//type UnionObjectSchemaType<a extends readonly any[], X extends Schema = a[number]> = X extends any ? ObjectSchemaType<X> : never;
+type UnionObjType<s extends Obj, a extends readonly any[], X extends ReadonlyArray<keyof s> = a[number]> = X extends any ? ObjType<s, X> : never;
type ArrayUnion<T> = T extends any ? Array<T> : never;
type ObjectSchemaTypeDef<p extends Schema> =
p['ref'] extends keyof typeof refs ? Packed<p['ref']> :
p['properties'] extends NonNullable<Obj> ?
- p['anyOf'] extends ReadonlyArray<Schema> ?
- ObjType<p['properties'], NonNullable<p['required']>[number]> & UnionObjectSchemaType<p['anyOf']> & PartialIntersection<UnionObjectSchemaType<p['anyOf']>>
- :
- ObjType<p['properties'], NonNullable<p['required']>[number]>
+ p['anyOf'] extends ReadonlyArray<Schema> ? p['anyOf'][number]['required'] extends ReadonlyArray<keyof p['properties']> ?
+ UnionObjType<p['properties'], NonNullable<p['anyOf'][number]['required']>> & ObjType<p['properties'], NonNullable<p['required']>>
+ : never
+ : ObjType<p['properties'], NonNullable<p['required']>>
:
- p['anyOf'] extends ReadonlyArray<Schema> ? UnionObjectSchemaType<p['anyOf']> & PartialIntersection<UnionObjectSchemaType<p['anyOf']>> :
+ p['anyOf'] extends ReadonlyArray<Schema> ? never : // see CONTRIBUTING.md
p['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<p['allOf']>> :
any
diff --git a/packages/backend/src/models/entities/RoleAssignment.ts b/packages/backend/src/models/entities/RoleAssignment.ts
index e86f2a8999..972810940f 100644
--- a/packages/backend/src/models/entities/RoleAssignment.ts
+++ b/packages/backend/src/models/entities/RoleAssignment.ts
@@ -39,4 +39,10 @@ export class RoleAssignment {
})
@JoinColumn()
public role: Role | null;
+
+ @Index()
+ @Column('timestamp with time zone', {
+ nullable: true,
+ })
+ public expiresAt: Date | null;
}
diff --git a/packages/backend/src/queue/processors/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts
index 406184cbde..7fd2cde9c0 100644
--- a/packages/backend/src/queue/processors/CleanProcessorService.ts
+++ b/packages/backend/src/queue/processors/CleanProcessorService.ts
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
-import { LessThan } from 'typeorm';
+import { In, LessThan } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, UserIpsRepository } from '@/models/index.js';
+import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
@@ -29,6 +29,9 @@ export class CleanProcessorService {
@Inject(DI.antennaNotesRepository)
private antennaNotesRepository: AntennaNotesRepository,
+ @Inject(DI.roleAssignmentsRepository)
+ private roleAssignmentsRepository: RoleAssignmentsRepository,
+
private queueLoggerService: QueueLoggerService,
private idService: IdService,
) {
@@ -56,6 +59,17 @@ export class CleanProcessorService {
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
});
+ const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign')
+ .where('assign.expiresAt IS NOT NULL')
+ .andWhere('assign.expiresAt < :now', { now: new Date() })
+ .getMany();
+
+ if (expiredRoleAssignments.length > 0) {
+ await this.roleAssignmentsRepository.delete({
+ id: In(expiredRoleAssignments.map(x => x.id)),
+ });
+ }
+
this.logger.succ('Cleaned.');
done();
}
diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts
index c12ae9b824..835657b625 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -22,6 +22,8 @@ import { bindThis } from '@/decorators.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { isMimeImage } from '@/misc/is-mime-image.js';
import sharp from 'sharp';
+import { sharpBmp } from 'sharp-read-bmp';
+import { correctFilename } from '@/misc/correct-filename.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -52,15 +54,6 @@ export class FileServerService {
}
@bindThis
- public commonReadableHandlerGenerator(reply: FastifyReply) {
- return (err: Error): void => {
- this.logger.error(err);
- reply.code(500);
- reply.header('Cache-Control', 'max-age=300');
- };
- }
-
- @bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
@@ -140,7 +133,7 @@ export class FileServerService {
let image: IImageStreamable | null = null;
if (file.fileRole === 'thumbnail') {
- if (isMimeImage(file.mime, 'sharp-convertible-image')) {
+ 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`);
@@ -190,13 +183,19 @@ export class FileServerService {
}
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
+ reply.header('Content-Disposition',
+ contentDisposition(
+ 'inline',
+ correctFilename(file.filename, image.ext)
+ )
+ );
return image.data;
}
if (file.fileRole !== 'original') {
- const filename = rename(file.file.name, {
+ const filename = rename(file.filename, {
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
- extname: file.ext ? `.${file.ext}` : undefined,
+ extname: file.ext ? `.${file.ext}` : '.unknown',
}).toString();
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
@@ -204,12 +203,10 @@ export class FileServerService {
reply.header('Content-Disposition', contentDisposition('inline', filename));
return fs.createReadStream(file.path);
} else {
- const stream = fs.createReadStream(file.path);
- stream.on('error', this.commonReadableHandlerGenerator(reply));
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
reply.header('Cache-Control', 'max-age=31536000, immutable');
- reply.header('Content-Disposition', contentDisposition('inline', file.file.name));
- return stream;
+ reply.header('Content-Disposition', contentDisposition('inline', file.filename));
+ return fs.createReadStream(file.path);
}
} catch (e) {
if ('cleanup' in file) file.cleanup();
@@ -226,7 +223,10 @@ export class FileServerService {
return;
}
- if (this.config.externalMediaProxyEnabled) {
+ // アバタークロップなど、どうしてもオリジンである必要がある場合
+ const mustOrigin = 'origin' in request.query;
+
+ if (this.config.externalMediaProxyEnabled && !mustOrigin) {
// 外部のメディアプロキシが有効なら、そちらにリダイレクト
reply.header('Cache-Control', 'public, max-age=259200'); // 3 days
@@ -258,8 +258,8 @@ export class FileServerService {
}
try {
- const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image');
- const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image');
+ 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 ||
@@ -283,7 +283,7 @@ export class FileServerService {
type: file.mime,
};
} else {
- const data = sharp(file.path, { animated: !('static' in request.query) })
+ const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({
height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true,
@@ -297,11 +297,11 @@ export class FileServerService {
};
}
} else if ('static' in request.query) {
- image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280);
+ image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 280);
} else if ('preview' in request.query) {
- image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200);
+ image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
} else if ('badge' in request.query) {
- const mask = sharp(file.path)
+ const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, {
fit: 'inside',
withoutEnlargement: false,
@@ -357,6 +357,12 @@ export class FileServerService {
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();
@@ -366,8 +372,8 @@ export class FileServerService {
@bindThis
private async getStreamAndTypeFromUrl(url: string): Promise<
- { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; }
- | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
+ { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
+ | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
> {
@@ -383,11 +389,11 @@ export class FileServerService {
@bindThis
private async downloadAndDetectTypeFromUrl(url: string): Promise<
- { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; }
+ { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
> {
const [path, cleanup] = await createTemp();
try {
- await this.downloadService.downloadUrl(url, path);
+ const { filename } = await this.downloadService.downloadUrl(url, path);
const { mime, ext } = await this.fileInfoService.detectType(path);
@@ -395,6 +401,7 @@ export class FileServerService {
state: 'remote',
mime, ext,
path, cleanup,
+ filename,
};
} catch (e) {
cleanup();
@@ -404,8 +411,8 @@ export class FileServerService {
@bindThis
private async getFileFromKey(key: string): Promise<
- { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
- | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
+ { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
+ | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
> {
@@ -429,6 +436,7 @@ export class FileServerService {
url: file.uri,
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
file,
+ filename: file.name,
};
}
@@ -440,6 +448,7 @@ export class FileServerService {
state: 'stored_internal',
fileRole: isThumbnail ? 'thumbnail' : 'webpublic',
file,
+ filename: file.name,
mime, ext,
path,
};
@@ -449,6 +458,7 @@ export class FileServerService {
state: 'stored_internal',
fileRole: 'original',
file,
+ filename: file.name,
mime: file.type,
ext: null,
path,
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index 8200b24fd4..e61383468c 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -1,7 +1,7 @@
import cluster from 'node:cluster';
import * as fs from 'node:fs';
-import { Inject, Injectable } from '@nestjs/common';
-import Fastify from 'fastify';
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
+import Fastify, { FastifyInstance } from 'fastify';
import { IsNull } from 'typeorm';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Config } from '@/config.js';
@@ -23,8 +23,9 @@ import { FileServerService } from './FileServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
@Injectable()
-export class ServerService {
+export class ServerService implements OnApplicationShutdown {
private logger: Logger;
+ #fastify: FastifyInstance;
constructor(
@Inject(DI.config)
@@ -54,11 +55,12 @@ export class ServerService {
}
@bindThis
- public launch() {
+ public async launch() {
const fastify = Fastify({
trustProxy: true,
logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''),
});
+ this.#fastify = fastify;
// HSTS
// 6months (15552000sec)
@@ -75,7 +77,7 @@ export class ServerService {
fastify.register(this.nodeinfoServerService.createServer);
fastify.register(this.wellKnownServerService.createServer);
- fastify.get<{ Params: { path: string }; Querystring: { static?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
+ fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
const path = request.params.path;
reply.header('Cache-Control', 'public, max-age=86400');
@@ -105,11 +107,19 @@ export class ServerService {
}
}
- const url = new URL(`${this.config.mediaProxy}/emoji.webp`);
- // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
- url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
- url.searchParams.set('emoji', '1');
- if ('static' in request.query) url.searchParams.set('static', '1');
+ let url: URL;
+ if ('badge' in request.query) {
+ url = new URL(`${this.config.mediaProxy}/emoji.png`);
+ // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
+ url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
+ url.searchParams.set('badge', '1');
+ } else {
+ url = new URL(`${this.config.mediaProxy}/emoji.webp`);
+ // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
+ url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
+ url.searchParams.set('emoji', '1');
+ if ('static' in request.query) url.searchParams.set('static', '1');
+ }
return await reply.redirect(
301,
@@ -195,5 +205,11 @@ export class ServerService {
});
fastify.listen({ port: this.config.port, host: '0.0.0.0' });
+
+ await fastify.ready();
+ }
+
+ async onApplicationShutdown(signal: string): Promise<void> {
+ await this.#fastify.close();
}
}
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 347fa59d36..f84a3aa59b 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -2,6 +2,7 @@ import { pipeline } from 'node:stream';
import * as fs from 'node:fs';
import { promisify } from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
+import { v4 as uuid } from 'uuid';
import { DI } from '@/di-symbols.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { LocalUser, User } from '@/models/entities/User.js';
@@ -99,9 +100,12 @@ export class ApiCallService implements OnApplicationShutdown {
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
) {
- const multipartData = await request.file();
+ const multipartData = await request.file().catch(() => {
+ /* Fastify throws if the remote didn't send multipart data. Return 400 below. */
+ });
if (multipartData == null) {
reply.code(400);
+ reply.send();
return;
}
@@ -320,6 +324,7 @@ export class ApiCallService implements OnApplicationShutdown {
if (err instanceof ApiError) {
throw err;
} else {
+ const errId = uuid();
this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, {
ep: ep.name,
ps: data,
@@ -327,14 +332,15 @@ export class ApiCallService implements OnApplicationShutdown {
message: err.message,
code: err.name,
stack: err.stack,
+ id: errId,
},
});
- console.error(err);
+ console.error(err, errId);
throw new ApiError(null, {
e: {
message: err.message,
code: err.name,
- stack: err.stack,
+ id: errId,
},
});
}
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index 2b99da01b6..115d60986c 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -73,28 +73,32 @@ export class ApiServerService {
Params: { endpoint: string; },
Body: Record<string, unknown>,
Querystring: Record<string, unknown>,
- }>('/' + endpoint.name, (request, reply) => {
+ }>('/' + endpoint.name, async (request, reply) => {
if (request.method === 'GET' && !endpoint.meta.allowGet) {
reply.code(405);
reply.send();
return;
}
-
- this.apiCallService.handleMultipartRequest(ep, request, reply);
+
+ // Await so that any error can automatically be translated to HTTP 500
+ await this.apiCallService.handleMultipartRequest(ep, request, reply);
+ return reply;
});
} else {
fastify.all<{
Params: { endpoint: string; },
Body: Record<string, unknown>,
Querystring: Record<string, unknown>,
- }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, (request, reply) => {
+ }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, async (request, reply) => {
if (request.method === 'GET' && !endpoint.meta.allowGet) {
reply.code(405);
reply.send();
return;
}
-
- this.apiCallService.handleRequest(ep, request, reply);
+
+ // Await so that any error can automatically be translated to HTTP 500
+ await this.apiCallService.handleRequest(ep, request, reply);
+ return reply;
});
}
}
@@ -160,6 +164,22 @@ export class ApiServerService {
}
});
+ // Make sure any unknown path under /api returns HTTP 404 Not Found,
+ // because otherwise ClientServerService will return the base client HTML
+ // page with HTTP 200.
+ fastify.get('*', (request, reply) => {
+ reply.code(404);
+ // Mock ApiCallService.send's error handling
+ reply.send({
+ error: {
+ message: 'Unknown API endpoint.',
+ code: 'UNKNOWN_API_ENDPOINT',
+ id: '2ca3b769-540a-4f08-9dd5-b5a825b6d0f1',
+ kind: 'client',
+ },
+ });
+ });
+
done();
}
}
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 4d5ed9fb62..4f521148e0 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -741,8 +741,8 @@ export interface IEndpoint {
const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
return {
name: name,
- meta: ep.meta ?? {},
- params: ep.paramDef,
+ get meta() { return ep.meta ?? {}; },
+ get params() { return ep.paramDef; },
};
});
diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts
index 85b566aabe..1d27ac2137 100644
--- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts
+++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts
@@ -138,19 +138,13 @@ export const meta = {
export const paramDef = {
type: 'object',
+ properties: {
+ fileId: { type: 'string', format: 'misskey:id' },
+ url: { type: 'string' },
+ },
anyOf: [
- {
- properties: {
- fileId: { type: 'string', format: 'misskey:id' },
- },
- required: ['fileId'],
- },
- {
- properties: {
- url: { type: 'string' },
- },
- required: ['url'],
- },
+ { required: ['fileId'] },
+ { required: ['url'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
index 8889f30269..04c58050ff 100644
--- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
+++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts
@@ -16,7 +16,7 @@ export const meta = {
errors: {
noSuchFile: {
message: 'No such file.',
- code: 'MO_SUCH_FILE',
+ code: 'NO_SUCH_FILE',
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
},
},
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts
index 7bfb2f6625..b80aaba122 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts
@@ -1,10 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
+import type { RolesRepository, UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
-import { IdService } from '@/core/IdService.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = {
@@ -39,6 +37,10 @@ export const paramDef = {
properties: {
roleId: { type: 'string', format: 'misskey:id' },
userId: { type: 'string', format: 'misskey:id' },
+ expiresAt: {
+ type: 'integer',
+ nullable: true,
+ },
},
required: [
'roleId',
@@ -56,12 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
- @Inject(DI.roleAssignmentsRepository)
- private roleAssignmentsRepository: RoleAssignmentsRepository,
-
- private globalEventService: GlobalEventService,
private roleService: RoleService,
- private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
@@ -78,19 +75,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchUser);
}
- const date = new Date();
- const created = await this.roleAssignmentsRepository.insert({
- id: this.idService.genId(),
- createdAt: date,
- roleId: role.id,
- userId: user.id,
- }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0]));
+ if (ps.expiresAt && ps.expiresAt <= Date.now()) {
+ return;
+ }
- this.rolesRepository.update(ps.roleId, {
- lastUsedAt: new Date(),
- });
-
- this.globalEventService.publishInternalEvent('userRoleAssigned', created);
+ await this.roleService.assign(user.id, role.id, ps.expiresAt ? new Date(ps.expiresAt) : null);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts
index 141cc5ee89..45c4f76943 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts
@@ -1,10 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js';
+import type { RolesRepository, UsersRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
-import { IdService } from '@/core/IdService.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
import { RoleService } from '@/core/RoleService.js';
export const meta = {
@@ -62,12 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
- @Inject(DI.roleAssignmentsRepository)
- private roleAssignmentsRepository: RoleAssignmentsRepository,
-
- private globalEventService: GlobalEventService,
private roleService: RoleService,
- private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
@@ -84,18 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchUser);
}
- const roleAssignment = await this.roleAssignmentsRepository.findOneBy({ userId: user.id, roleId: role.id });
- if (roleAssignment == null) {
- throw new ApiError(meta.errors.notAssigned);
- }
-
- await this.roleAssignmentsRepository.delete(roleAssignment.id);
-
- this.rolesRepository.update(ps.roleId, {
- lastUsedAt: new Date(),
- });
-
- this.globalEventService.publishInternalEvent('userRoleUnassigned', roleAssignment);
+ await this.roleService.unassign(user.id, role.id);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
index bb016a8425..35edca5460 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
@@ -56,6 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
+ .andWhere(new Brackets(qb => { qb
+ .where('assign.expiresAt IS NULL')
+ .orWhere('assign.expiresAt > :now', { now: new Date() });
+ }))
.innerJoinAndSelect('assign.user', 'user');
const assigns = await query
@@ -64,7 +69,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
+ createdAt: assign.createdAt,
user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
+ expiresAt: assign.expiresAt,
})));
});
}
diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts
index 58f8835279..cdaa400137 100644
--- a/packages/backend/src/server/api/endpoints/channels/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts
@@ -82,6 +82,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.leftJoinAndSelect('note.channel', 'channel');
+
+ if (me) {
+ this.queryService.generateMutedUserQuery(query, me);
+ this.queryService.generateMutedNoteQuery(query, me);
+ this.queryService.generateBlockedUserQuery(query, me);
+ }
//#endregion
const timeline = await query.take(ps.limit).getMany();
diff --git a/packages/backend/src/server/api/endpoints/drive/files/show.ts b/packages/backend/src/server/api/endpoints/drive/files/show.ts
index e0a07a3640..271b33ef4b 100644
--- a/packages/backend/src/server/api/endpoints/drive/files/show.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files/show.ts
@@ -39,19 +39,13 @@ export const meta = {
export const paramDef = {
type: 'object',
+ properties: {
+ fileId: { type: 'string', format: 'misskey:id' },
+ url: { type: 'string' },
+ },
anyOf: [
- {
- properties: {
- fileId: { type: 'string', format: 'misskey:id' },
- },
- required: ['fileId'],
- },
- {
- properties: {
- url: { type: 'string' },
- },
- required: ['url'],
- },
+ { required: ['fileId'] },
+ { required: ['url'] },
],
} as const;
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 b656c5c51d..4f543a6472 100644
--- a/packages/backend/src/server/api/endpoints/i/update-email.ts
+++ b/packages/backend/src/server/api/endpoints/i/update-email.ts
@@ -73,8 +73,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
if (ps.email != null) {
- const available = await this.emailService.validateEmailForAccount(ps.email);
- if (!available) {
+ const res = await this.emailService.validateEmailForAccount(ps.email);
+ if (!res.available) {
throw new ApiError(meta.errors.unavailable);
}
}
diff --git a/packages/backend/src/server/api/endpoints/notes/create.test.ts b/packages/backend/src/server/api/endpoints/notes/create.test.ts
new file mode 100644
index 0000000000..6bff7fc0c9
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/notes/create.test.ts
@@ -0,0 +1,263 @@
+process.env.NODE_ENV = 'test';
+
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { dirname } from 'node:path';
+import { describe, test, expect } from '@jest/globals';
+import { getValidator } from '../../../../../test/prelude/get-api-validator.js';
+import { paramDef } from './create.js';
+
+const _filename = fileURLToPath(import.meta.url);
+const _dirname = dirname(_filename);
+
+const VALID = true;
+const INVALID = false;
+
+describe('api:notes/create', () => {
+ describe('validation', () => {
+ const v = getValidator(paramDef);
+ const tooLong = readFile(_dirname + '/../../../../../test/resources/misskey.svg', 'utf-8');
+
+ test('reject empty', () => {
+ const valid = v({ });
+ expect(valid).toBe(INVALID);
+ });
+
+ describe('text', () => {
+ test('simple post', () => {
+ expect(v({ text: 'Hello, world!' }))
+ .toBe(VALID);
+ });
+
+ test('null post', () => {
+ expect(v({ text: null }))
+ .toBe(INVALID);
+ });
+
+ test('0 characters post', () => {
+ expect(v({ text: '' }))
+ .toBe(INVALID);
+ });
+
+ test('over 3000 characters post', async () => {
+ expect(v({ text: await tooLong }))
+ .toBe(INVALID);
+ });
+ });
+
+ describe('cw', () => {
+ test('simple cw', () => {
+ expect(v({ text: 'Hello, world!', cw: 'Hello, world!' }))
+ .toBe(VALID);
+ });
+
+ test('null cw', () => {
+ expect(v({ text: 'Body', cw: null }))
+ .toBe(VALID);
+ });
+
+ test('0 characters cw', () => {
+ expect(v({ text: 'Body', cw: '' }))
+ .toBe(VALID);
+ });
+
+ test('reject only cw', () => {
+ expect(v({ cw: 'Hello, world!' }))
+ .toBe(INVALID);
+ });
+
+ test('over 100 characters cw', async () => {
+ expect(v({ text: 'Body', cw: await tooLong }))
+ .toBe(INVALID);
+ });
+ });
+
+ describe('visibility', () => {
+ test('public', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'public' }))
+ .toBe(VALID);
+ });
+
+ test('home', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'home' }))
+ .toBe(VALID);
+ });
+
+ test('followers', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'followers' }))
+ .toBe(VALID);
+ });
+
+ test('reject only visibility', () => {
+ expect(v({ visibility: 'public' }))
+ .toBe(INVALID);
+ });
+
+ test('reject invalid visibility', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'invalid' }))
+ .toBe(INVALID);
+ });
+
+ test('reject null visibility', () => {
+ expect(v({ text: 'Hello, world!', visibility: null }))
+ .toBe(INVALID);
+ });
+
+ describe('visibility:specified', () => {
+ test('specified without visibleUserIds', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'specified' }))
+ .toBe(VALID);
+ });
+
+ test('specified with empty visibleUserIds', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: [] }))
+ .toBe(VALID);
+ });
+
+ test('reject specified with non unique visibleUserIds', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: ['1', '1', '2'] }))
+ .toBe(INVALID);
+ });
+
+ test('reject specified with null visibleUserIds', () => {
+ expect(v({ text: 'Hello, world!', visibility: 'specified', visibleUserIds: null }))
+ .toBe(INVALID);
+ });
+ });
+ });
+
+ describe('fileIds', () => {
+ test('only fileIds', () => {
+ expect(v({ fileIds: ['1', '2', '3'] }))
+ .toBe(VALID);
+ });
+
+ test('text and fileIds', () => {
+ expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'] }))
+ .toBe(VALID);
+ });
+
+ test('reject null fileIds', () => {
+ expect(v({ fileIds: null }))
+ .toBe(INVALID);
+ });
+
+ test('reject text and null fileIds (複合的なanyOfのバリデーションが正しく動作する)', () => {
+ expect(v({ text: 'Hello, world!', fileIds: null }))
+ .toBe(INVALID);
+ });
+
+ test('reject 0 files', () => {
+ expect(v({ fileIds: [] }))
+ .toBe(INVALID);
+ });
+
+ test('reject non unique', () => {
+ expect(v({ fileIds: ['1', '1', '2'] }))
+ .toBe(INVALID);
+ });
+
+ test('reject invalid id', () => {
+ expect(v({ fileIds: ['あ'] }))
+ .toBe(INVALID);
+ });
+
+ test('reject over 17 files', () => {
+ const valid = v({ text: 'Hello, world!', fileIds: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18'] });
+ expect(valid).toBe(INVALID);
+ });
+ });
+
+ describe('poll', () => {
+ test('note with poll', () => {
+ expect(v({ text: 'Hello, world!', poll: { choices: ['a', 'b', 'c'] } }))
+ .toBe(VALID);
+ });
+
+ test('null poll', () => {
+ expect(v({ text: 'Hello, world!', poll: null }))
+ .toBe(VALID);
+ });
+
+ test('allow only poll', () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c'] } }))
+ .toBe(VALID);
+ });
+
+ test('poll with expiresAt', async () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c'], expiresAt: 1 } }))
+ .toBe(VALID);
+ });
+
+ test('poll with expiredAfter', async () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 1 } }))
+ .toBe(VALID);
+ });
+
+ test('reject poll without choices', () => {
+ expect(v({ poll: { } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with empty choices', () => {
+ expect(v({ poll: { choices: [] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with null choices', () => {
+ expect(v({ poll: { choices: null } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with 1 choice', () => {
+ expect(v({ poll: { choices: ['a'] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with too long choice', async () => {
+ expect(v({ poll: { choices: [await tooLong, '2'] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with too many choices', () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with non unique choices', () => {
+ expect(v({ poll: { choices: ['a', 'a', 'b', 'c'] } }))
+ .toBe(INVALID);
+ });
+
+ test('reject poll with expiredAfter 0', async () => {
+ expect(v({ poll: { choices: ['a', 'b', 'c'], expiredAfter: 0 } }))
+ .toBe(INVALID);
+ });
+ });
+
+ describe('renote', () => {
+ test('just a renote', () => {
+ expect(v({ renoteId: '1' }))
+ .toBe(VALID);
+ });
+ test('just a quote', () => {
+ expect(v({ text: 'Hello, world!', renoteId: '1' }))
+ .toBe(VALID);
+ });
+ test('reject invalid renoteId', () => {
+ expect(v({ renoteId: 'あ' }))
+ .toBe(INVALID);
+ });
+ });
+
+ test('text, fileIds and poll', () => {
+ expect(v({ text: 'Hello, world!', fileIds: ['1', '2', '3'], poll: { choices: ['a', 'b', 'c'] } }))
+ .toBe(VALID);
+ });
+
+ test('text, invalid fileIds and invalid poll', () => {
+ expect(v({ text: 'Hello, world!', fileIds: ['あ'], poll: { choices: ['a'] } }))
+ .toBe(INVALID);
+ });
+ });
+});
diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts
index f4c5a84a4f..786ad103b0 100644
--- a/packages/backend/src/server/api/endpoints/notes/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/create.ts
@@ -101,74 +101,56 @@ export const paramDef = {
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },
replyId: { type: 'string', format: 'misskey:id', nullable: true },
+ renoteId: { type: 'string', format: 'misskey:id', nullable: true },
channelId: { type: 'string', format: 'misskey:id', nullable: true },
- },
- anyOf: [
- {
- // (re)note with text, files and poll are optional
- properties: {
- text: { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, nullable: false },
- },
- required: ['text'],
+
+ // anyOf内にバリデーションを書いても最初の一つしかチェックされない
+ // See https://github.com/misskey-dev/misskey/pull/10082
+ text: {
+ type: 'string',
+ minLength: 1,
+ maxLength: MAX_NOTE_TEXT_LENGTH,
+ nullable: false
},
- {
- // (re)note with files, text and poll are optional
- properties: {
- fileIds: {
- type: 'array',
- uniqueItems: true,
- minItems: 1,
- maxItems: 16,
- items: { type: 'string', format: 'misskey:id' },
- },
- },
- required: ['fileIds'],
+ fileIds: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 1,
+ maxItems: 16,
+ items: { type: 'string', format: 'misskey:id' },
},
- {
- // (re)note with files, text and poll are optional
+ mediaIds: {
+ type: 'array',
+ uniqueItems: true,
+ minItems: 1,
+ maxItems: 16,
+ items: { type: 'string', format: 'misskey:id' },
+ },
+ poll: {
+ type: 'object',
+ nullable: true,
properties: {
- mediaIds: {
- deprecated: true,
- description: 'Use `fileIds` instead. If both are specified, this property is discarded.',
+ choices: {
type: 'array',
uniqueItems: true,
- minItems: 1,
- maxItems: 16,
- items: { type: 'string', format: 'misskey:id' },
- },
- },
- required: ['mediaIds'],
- },
- {
- // (re)note with poll, text and files are optional
- properties: {
- poll: {
- type: 'object',
- nullable: true,
- properties: {
- choices: {
- type: 'array',
- uniqueItems: true,
- minItems: 2,
- maxItems: 10,
- items: { type: 'string', minLength: 1, maxLength: 50 },
- },
- multiple: { type: 'boolean' },
- expiresAt: { type: 'integer', nullable: true },
- expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
- },
- required: ['choices'],
+ minItems: 2,
+ maxItems: 10,
+ items: { type: 'string', minLength: 1, maxLength: 50 },
},
+ multiple: { type: 'boolean' },
+ expiresAt: { type: 'integer', nullable: true },
+ expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
},
- required: ['poll'],
- },
- {
- // pure renote
- properties: {
- renoteId: { type: 'string', format: 'misskey:id', nullable: true },
- },
- required: ['renoteId'],
+ required: ['choices'],
},
+ },
+ // (re)note with text, files and poll are optional
+ anyOf: [
+ { required: ['text'] },
+ { required: ['renoteId'] },
+ { required: ['fileIds'] },
+ { required: ['mediaIds'] },
+ { required: ['poll'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
index bcd793ac43..da1a4bcc46 100644
--- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
+++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts
@@ -36,32 +36,25 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
- },
- anyOf: [
- {
- properties: {
- tag: { type: 'string', minLength: 1 },
- },
- required: ['tag'],
- },
- {
- properties: {
- query: {
- type: 'array',
- description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
- items: {
- type: 'array',
- items: {
- type: 'string',
- minLength: 1,
- },
- minItems: 1,
- },
- minItems: 1,
+
+ tag: { type: 'string', minLength: 1 },
+ query: {
+ type: 'array',
+ description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
+ items: {
+ type: 'array',
+ items: {
+ type: 'string',
+ minLength: 1,
},
+ minItems: 1,
},
- required: ['query'],
+ minItems: 1,
},
+ },
+ anyOf: [
+ { required: ['tag'] },
+ { required: ['query'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts
index d1c35e36e2..e6de087c4a 100644
--- a/packages/backend/src/server/api/endpoints/notes/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts
@@ -58,25 +58,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private activeUsersChart: ActiveUsersChart,
) {
super(meta, paramDef, async (ps, me) => {
- const hasFollowing = (await this.followingsRepository.count({
- where: {
- followerId: me.id,
- },
- take: 1,
- })) !== 0;
-
- //#region Construct query
- const followingQuery = this.followingsRepository.createQueryBuilder('following')
+ const followees = await this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
- .where('following.followerId = :followerId', { followerId: me.id });
+ .where('following.followerId = :followerId', { followerId: me.id })
+ .getMany();
+ //#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで
- .andWhere(new Brackets(qb => { qb
- .where('note.userId = :meId', { meId: me.id });
- if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`);
- }))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
@@ -87,8 +77,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
- .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
- .setParameters(followingQuery.getParameters());
+ .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
+
+ if (followees.length > 0) {
+ const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
+
+ query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
+ } else {
+ query.andWhere('note.userId = :meId', { meId: me.id });
+ }
this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, me);
diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts
index 651252afbb..bf2b2a431e 100644
--- a/packages/backend/src/server/api/endpoints/pages/show.ts
+++ b/packages/backend/src/server/api/endpoints/pages/show.ts
@@ -29,20 +29,14 @@ export const meta = {
export const paramDef = {
type: 'object',
+ properties: {
+ pageId: { type: 'string', format: 'misskey:id' },
+ name: { type: 'string' },
+ username: { type: 'string' },
+ },
anyOf: [
- {
- properties: {
- pageId: { type: 'string', format: 'misskey:id' },
- },
- required: ['pageId'],
- },
- {
- properties: {
- name: { type: 'string' },
- username: { type: 'string' },
- },
- required: ['name', 'username'],
- },
+ { required: ['pageId'] },
+ { required: ['name', 'username'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts
index 6e221b6c67..607dc24206 100644
--- a/packages/backend/src/server/api/endpoints/roles/users.ts
+++ b/packages/backend/src/server/api/endpoints/roles/users.ts
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
+import { Brackets } from 'typeorm';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
@@ -56,6 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
+ .andWhere(new Brackets(qb => { qb
+ .where('assign.expiresAt IS NULL')
+ .orWhere('assign.expiresAt > :now', { now: new Date() });
+ }))
.innerJoinAndSelect('assign.user', 'user');
const assigns = await query
diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts
index 17ce920011..97f1310c36 100644
--- a/packages/backend/src/server/api/endpoints/users/followers.ts
+++ b/packages/backend/src/server/api/endpoints/users/followers.ts
@@ -46,25 +46,18 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+
+ userId: { type: 'string', format: 'misskey:id' },
+ username: { type: 'string' },
+ host: {
+ type: 'string',
+ nullable: true,
+ description: 'The local host is represented with `null`.',
+ },
},
anyOf: [
- {
- properties: {
- userId: { type: 'string', format: 'misskey:id' },
- },
- required: ['userId'],
- },
- {
- properties: {
- username: { type: 'string' },
- host: {
- type: 'string',
- nullable: true,
- description: 'The local host is represented with `null`.',
- },
- },
- required: ['username', 'host'],
- },
+ { required: ['userId'] },
+ { required: ['username', 'host'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts
index 6dbda0d72f..d406594a2e 100644
--- a/packages/backend/src/server/api/endpoints/users/following.ts
+++ b/packages/backend/src/server/api/endpoints/users/following.ts
@@ -46,25 +46,18 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+
+ userId: { type: 'string', format: 'misskey:id' },
+ username: { type: 'string' },
+ host: {
+ type: 'string',
+ nullable: true,
+ description: 'The local host is represented with `null`.',
+ },
},
anyOf: [
- {
- properties: {
- userId: { type: 'string', format: 'misskey:id' },
- },
- required: ['userId'],
- },
- {
- properties: {
- username: { type: 'string' },
- host: {
- type: 'string',
- nullable: true,
- description: 'The local host is represented with `null`.',
- },
- },
- required: ['username', 'host'],
- },
+ { required: ['userId'] },
+ { required: ['username', 'host'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
index 1cefcf2707..6c340d8fb2 100644
--- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
+++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts
@@ -31,20 +31,13 @@ export const paramDef = {
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: { type: 'boolean', default: true },
+
+ username: { type: 'string', nullable: true },
+ host: { type: 'string', nullable: true },
},
anyOf: [
- {
- properties: {
- username: { type: 'string', nullable: true },
- },
- required: ['username'],
- },
- {
- properties: {
- host: { type: 'string', nullable: true },
- },
- required: ['host'],
- },
+ { required: ['username'] },
+ { required: ['host'] },
],
} as const;
diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts
index 70258ef009..29f24b045a 100644
--- a/packages/backend/src/server/api/endpoints/users/show.ts
+++ b/packages/backend/src/server/api/endpoints/users/show.ts
@@ -54,32 +54,22 @@ export const meta = {
export const paramDef = {
type: 'object',
- anyOf: [
- {
- properties: {
- userId: { type: 'string', format: 'misskey:id' },
- },
- required: ['userId'],
- },
- {
- properties: {
- userIds: { type: 'array', uniqueItems: true, items: {
- type: 'string', format: 'misskey:id',
- } },
- },
- required: ['userIds'],
- },
- {
- properties: {
- username: { type: 'string' },
- host: {
- type: 'string',
- nullable: true,
- description: 'The local host is represented with `null`.',
- },
- },
- required: ['username'],
+ properties: {
+ userId: { type: 'string', format: 'misskey:id' },
+ userIds: { type: 'array', uniqueItems: true, items: {
+ type: 'string', format: 'misskey:id',
+ } },
+ username: { type: 'string' },
+ host: {
+ type: 'string',
+ nullable: true,
+ description: 'The local host is represented with `null`.',
},
+ },
+ anyOf: [
+ { required: ['userId'] },
+ { required: ['userIds'] },
+ { required: ['username'] },
],
} as const;
diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts
index 9287952cb6..c450773055 100644
--- a/packages/backend/src/server/api/stream/types.ts
+++ b/packages/backend/src/server/api/stream/types.ts
@@ -178,7 +178,14 @@ type EventUnionFromDictionary<
// redis通すとDateのインスタンスはstringに変換されるので
type Serialized<T> = {
- [K in keyof T]: T[K] extends Date ? string : T[K] extends Record<string, any> ? Serialized<T[K]> : T[K];
+ [K in keyof T]:
+ T[K] extends Date
+ ? string
+ : T[K] extends (Date | null)
+ ? (string | null)
+ : T[K] extends Record<string, any>
+ ? Serialized<T[K]>
+ : T[K];
};
type SerializedAll<T> = {
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index c6cb25e43a..fd7f54da54 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -61,6 +61,13 @@
renderError('META_FETCH_V');
return;
}
+
+ // for https://github.com/misskey-dev/misskey/issues/10202
+ if (lang == null || lang.toString == null || lang.toString() === 'null') {
+ console.error('invalid lang value detected!!!', typeof lang, lang);
+ lang = 'en-US';
+ }
+
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
diff --git a/packages/backend/test/_e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts
index d29b9acb3d..4e162f42d0 100644
--- a/packages/backend/test/_e2e/api-visibility.ts
+++ b/packages/backend/test/e2e/api-visibility.ts
@@ -1,18 +1,18 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, startServer, shutdownServer } from '../utils.js';
+import { signup, api, post, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('API visibility', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
beforeAll(async () => {
p = await startServer();
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('Note visibility', () => {
@@ -60,7 +60,7 @@ describe('API visibility', () => {
//#endregion
const show = async (noteId: any, by: any) => {
- return await request('/notes/show', {
+ return await api('/notes/show', {
noteId,
}, by);
};
@@ -75,7 +75,7 @@ describe('API visibility', () => {
target2 = await signup({ username: 'target2' });
// follow alice <= follower
- await request('/following/create', { userId: alice.id }, follower);
+ await api('/following/create', { userId: alice.id }, follower);
// normal posts
pub = await post(alice, { text: 'x', visibility: 'public' });
@@ -413,21 +413,21 @@ describe('API visibility', () => {
//#region HTL
test('[HTL] public-post が 自分が見れる', async () => {
- const res = await request('/notes/timeline', { limit: 100 }, alice);
+ const res = await api('/notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === pub.id);
assert.strictEqual(notes[0].text, 'x');
});
test('[HTL] public-post が 非フォロワーから見れない', async () => {
- const res = await request('/notes/timeline', { limit: 100 }, other);
+ const res = await api('/notes/timeline', { limit: 100 }, other);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === pub.id);
assert.strictEqual(notes.length, 0);
});
test('[HTL] followers-post が フォロワーから見れる', async () => {
- const res = await request('/notes/timeline', { limit: 100 }, follower);
+ const res = await api('/notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === fol.id);
assert.strictEqual(notes[0].text, 'x');
@@ -436,21 +436,21 @@ describe('API visibility', () => {
//#region RTL
test('[replies] followers-reply が フォロワーから見れる', async () => {
- const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, follower);
+ const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, follower);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x');
});
test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => {
- const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, other);
+ const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, other);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes.length, 0);
});
test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
- const res = await request('/notes/replies', { noteId: tgt.id, limit: 100 }, target);
+ const res = await api('/notes/replies', { noteId: tgt.id, limit: 100 }, target);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x');
@@ -459,14 +459,14 @@ describe('API visibility', () => {
//#region MTL
test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
- const res = await request('/notes/mentions', { limit: 100 }, target);
+ const res = await api('/notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folR.id);
assert.strictEqual(notes[0].text, 'x');
});
test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => {
- const res = await request('/notes/mentions', { limit: 100 }, target);
+ const res = await api('/notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200);
const notes = res.body.filter((n: any) => n.id === folM.id);
assert.strictEqual(notes[0].text, '@target x');
@@ -474,4 +474,4 @@ describe('API visibility', () => {
//#endregion
});
});
-*/
+
diff --git a/packages/backend/test/_e2e/api.ts b/packages/backend/test/e2e/api.ts
index 7542c34db0..6ceccf66eb 100644
--- a/packages/backend/test/_e2e/api.ts
+++ b/packages/backend/test/e2e/api.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from '../utils.js';
+import { signup, api, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('API', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let bob: any;
let carol: any;
@@ -15,69 +15,69 @@ describe('API', () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('General validation', () => {
- test('wrong type', async(async () => {
- const res = await request('/test', {
+ test('wrong type', async () => {
+ const res = await api('/test', {
required: true,
string: 42,
});
assert.strictEqual(res.status, 400);
- }));
+ });
- test('missing require param', async(async () => {
- const res = await request('/test', {
+ test('missing require param', async () => {
+ const res = await api('/test', {
string: 'a',
});
assert.strictEqual(res.status, 400);
- }));
+ });
- test('invalid misskey:id (empty string)', async(async () => {
- const res = await request('/test', {
+ test('invalid misskey:id (empty string)', async () => {
+ const res = await api('/test', {
required: true,
id: '',
});
assert.strictEqual(res.status, 400);
- }));
+ });
- test('valid misskey:id', async(async () => {
- const res = await request('/test', {
+ test('valid misskey:id', async () => {
+ const res = await api('/test', {
required: true,
id: '8wvhjghbxu',
});
assert.strictEqual(res.status, 200);
- }));
+ });
- test('default value', async(async () => {
- const res = await request('/test', {
+ test('default value', async () => {
+ const res = await api('/test', {
required: true,
string: 'a',
});
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.default, 'hello');
- }));
+ });
- test('can set null even if it has default value', async(async () => {
- const res = await request('/test', {
+ test('can set null even if it has default value', async () => {
+ const res = await api('/test', {
required: true,
nullableDefault: null,
});
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.nullableDefault, null);
- }));
+ });
- test('cannot set undefined if it has default value', async(async () => {
- const res = await request('/test', {
+ test('cannot set undefined if it has default value', async () => {
+ const res = await api('/test', {
required: true,
nullableDefault: undefined,
});
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.nullableDefault, 'hello');
- }));
+ });
});
});
diff --git a/packages/backend/test/_e2e/block.ts b/packages/backend/test/e2e/block.ts
index c5f43e153c..4e9030f85d 100644
--- a/packages/backend/test/_e2e/block.ts
+++ b/packages/backend/test/e2e/block.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, startServer, shutdownServer } from '../utils.js';
+import { signup, api, post, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Block', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
// alice blocks bob
let alice: any;
@@ -17,14 +17,14 @@ describe('Block', () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('Block作成', async () => {
- const res = await request('/blocking/create', {
+ const res = await api('/blocking/create', {
userId: bob.id,
}, alice);
@@ -32,7 +32,7 @@ describe('Block', () => {
});
test('ブロックされているユーザーをフォローできない', async () => {
- const res = await request('/following/create', { userId: alice.id }, bob);
+ const res = await api('/following/create', { userId: alice.id }, bob);
assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0');
@@ -41,7 +41,7 @@ describe('Block', () => {
test('ブロックされているユーザーにリアクションできない', async () => {
const note = await post(alice, { text: 'hello' });
- const res = await request('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob);
+ const res = await api('/notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob);
assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec');
@@ -50,7 +50,7 @@ describe('Block', () => {
test('ブロックされているユーザーに返信できない', async () => {
const note = await post(alice, { text: 'hello' });
- const res = await request('/notes/create', { replyId: note.id, text: 'yo' }, bob);
+ const res = await api('/notes/create', { replyId: note.id, text: 'yo' }, bob);
assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3');
@@ -59,7 +59,7 @@ describe('Block', () => {
test('ブロックされているユーザーのノートをRenoteできない', async () => {
const note = await post(alice, { text: 'hello' });
- const res = await request('/notes/create', { renoteId: note.id, text: 'yo' }, bob);
+ const res = await api('/notes/create', { renoteId: note.id, text: 'yo' }, bob);
assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3');
@@ -74,7 +74,7 @@ describe('Block', () => {
const bobNote = await post(bob);
const carolNote = await post(carol);
- const res = await request('/notes/local-timeline', {}, bob);
+ const res = await api('/notes/local-timeline', {}, bob);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
diff --git a/packages/backend/test/_e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts
index aed980d6c8..42bdc5f24d 100644
--- a/packages/backend/test/_e2e/endpoints.ts
+++ b/packages/backend/test/e2e/endpoints.ts
@@ -1,29 +1,35 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import * as openapi from '@redocly/openapi-core';
-import { startServer, signup, post, request, simpleGet, port, shutdownServer, api } from '../utils.js';
+// node-fetch only supports it's own Blob yet
+// https://github.com/node-fetch/node-fetch/pull/1664
+import { Blob } from 'node-fetch';
+import { startServer, signup, post, api, uploadFile } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Endpoints', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let bob: any;
+ let carol: any;
+ let dave: any;
beforeAll(async () => {
p = await startServer();
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
- }, 1000 * 30);
+ carol = await signup({ username: 'carol' });
+ dave = await signup({ username: 'dave' });
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('signup', () => {
test('不正なユーザー名でアカウントが作成できない', async () => {
- const res = await request('api/signup', {
+ const res = await api('signup', {
username: 'test.',
password: 'test',
});
@@ -31,7 +37,7 @@ describe('Endpoints', () => {
});
test('空のパスワードでアカウントが作成できない', async () => {
- const res = await request('api/signup', {
+ const res = await api('signup', {
username: 'test',
password: '',
});
@@ -44,7 +50,7 @@ describe('Endpoints', () => {
password: 'test1',
};
- const res = await request('api/signup', me);
+ const res = await api('signup', me);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -52,7 +58,7 @@ describe('Endpoints', () => {
});
test('同じユーザー名のアカウントは作成できない', async () => {
- const res = await request('api/signup', {
+ const res = await api('signup', {
username: 'test1',
password: 'test1',
});
@@ -63,7 +69,7 @@ describe('Endpoints', () => {
describe('signin', () => {
test('間違ったパスワードでサインインできない', async () => {
- const res = await request('api/signin', {
+ const res = await api('signin', {
username: 'test1',
password: 'bar',
});
@@ -72,7 +78,7 @@ describe('Endpoints', () => {
});
test('クエリをインジェクションできない', async () => {
- const res = await request('api/signin', {
+ const res = await api('signin', {
username: 'test1',
password: {
$gt: '',
@@ -83,7 +89,7 @@ describe('Endpoints', () => {
});
test('正しい情報でサインインできる', async () => {
- const res = await request('api/signin', {
+ const res = await api('signin', {
username: 'test1',
password: 'test1',
});
@@ -111,11 +117,12 @@ describe('Endpoints', () => {
assert.strictEqual(res.body.birthday, myBirthday);
});
- test('名前を空白にできない', async () => {
+ test('名前を空白にできる', async () => {
const res = await api('/i/update', {
name: ' ',
}, alice);
- assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.name, ' ');
});
test('誕生日の設定を削除できる', async () => {
@@ -201,7 +208,6 @@ describe('Endpoints', () => {
test('リアクションできる', async () => {
const bobPost = await post(bob);
- const alice = await signup({ username: 'alice' });
const res = await api('/notes/reactions/create', {
noteId: bobPost.id,
reaction: '🚀',
@@ -214,7 +220,7 @@ describe('Endpoints', () => {
}, alice);
assert.strictEqual(resNote.status, 200);
- assert.strictEqual(resNote.body.reactions['🚀'], [alice.id]);
+ assert.strictEqual(resNote.body.reactions['🚀'], 1);
});
test('自分の投稿にもリアクションできる', async () => {
@@ -228,7 +234,7 @@ describe('Endpoints', () => {
assert.strictEqual(res.status, 204);
});
- test('二重にリアクションできない', async () => {
+ test('二重にリアクションすると上書きされる', async () => {
const bobPost = await post(bob);
await api('/notes/reactions/create', {
@@ -241,7 +247,14 @@ describe('Endpoints', () => {
reaction: '🚀',
}, alice);
- assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.status, 204);
+
+ const resNote = await api('/notes/show', {
+ noteId: bobPost.id,
+ }, alice);
+
+ assert.strictEqual(resNote.status, 200);
+ assert.deepStrictEqual(resNote.body.reactions, { '🚀': 1 });
});
test('存在しない投稿にはリアクションできない', async () => {
@@ -369,57 +382,22 @@ describe('Endpoints', () => {
});
});
- /*
- describe('/i', () => {
- test('', async () => {
- });
- });
- */
-});
-
-/*
-process.env.NODE_ENV = 'test';
-
-import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { async, signup, request, post, react, uploadFile, startServer, shutdownServer } from './utils.js';
-
-describe('API: Endpoints', () => {
- let p: childProcess.ChildProcess;
- let alice: any;
- let bob: any;
- let carol: any;
-
- before(async () => {
- p = await startServer();
- alice = await signup({ username: 'alice' });
- bob = await signup({ username: 'bob' });
- carol = await signup({ username: 'carol' });
- });
-
- after(async () => {
- await shutdownServer(p);
- });
-
describe('drive', () => {
test('ドライブ情報を取得できる', async () => {
- await uploadFile({
- userId: alice.id,
- size: 256
+ await uploadFile(alice, {
+ blob: new Blob([new Uint8Array(256)]),
});
- await uploadFile({
- userId: alice.id,
- size: 512
+ await uploadFile(alice, {
+ blob: new Blob([new Uint8Array(512)]),
});
- await uploadFile({
- userId: alice.id,
- size: 1024
+ await uploadFile(alice, {
+ blob: new Blob([new Uint8Array(1024)]),
});
const res = await api('/drive', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- expect(res.body).have.property('usage').eql(1792);
- }));
+ expect(res.body).toHaveProperty('usage', 1792);
+ });
});
describe('drive/files/create', () => {
@@ -428,397 +406,400 @@ describe('API: Endpoints', () => {
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.strictEqual(res.body.name, 'Lenna.png');
- }));
+ assert.strictEqual(res.body.name, 'Lenna.jpg');
+ });
test('ファイルに名前を付けられる', async () => {
- const res = await assert.request(server)
- .post('/drive/files/create')
- .field('i', alice.token)
- .field('name', 'Belmond.png')
- .attach('file', fs.readFileSync(__dirname + '/resources/Lenna.png'), 'Lenna.png');
+ const res = await uploadFile(alice, { name: 'Belmond.jpg' });
- expect(res).have.status(200);
- expect(res.body).be.a('object');
- expect(res.body).have.property('name').eql('Belmond.png');
- }));
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.name, 'Belmond.jpg');
+ });
+
+ test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => {
+ const res = await uploadFile(alice, { name: 'Belmond.png' });
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.name, 'Belmond.png.jpg');
+ });
test('ファイル無しで怒られる', async () => {
const res = await api('/drive/files/create', {}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('SVGファイルを作成できる', async () => {
- const res = await uploadFile(alice, __dirname + '/resources/image.svg');
+ const res = await uploadFile(alice, { path: 'image.svg' });
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'image.svg');
assert.strictEqual(res.body.type, 'image/svg+xml');
- }));
+ });
});
describe('drive/files/update', () => {
test('名前を更新できる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const newName = 'いちごパスタ.png';
const res = await api('/drive/files/update', {
fileId: file.id,
- name: newName
+ name: newName,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, newName);
- }));
+ });
test('他人のファイルは更新できない', async () => {
- const file = await uploadFile(bob);
+ const file = (await uploadFile(alice)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- name: 'いちごパスタ.png'
- }, alice);
+ name: 'いちごパスタ.png',
+ }, bob);
assert.strictEqual(res.status, 400);
- }));
+ });
test('親フォルダを更新できる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: folder.id
+ folderId: folder.id,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.folderId, folder.id);
- }));
+ });
test('親フォルダを無しにできる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
await api('/drive/files/update', {
fileId: file.id,
- folderId: folder.id
+ folderId: folder.id,
}, alice);
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: null
+ folderId: null,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.folderId, null);
- }));
+ });
test('他人のフォルダには入れられない', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, bob)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: folder.id
+ folderId: folder.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('存在しないフォルダで怒られる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: '000000000000000000000000'
+ folderId: '000000000000000000000000',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('不正なフォルダIDで怒られる', async () => {
- const file = await uploadFile(alice);
+ const file = (await uploadFile(alice)).body;
const res = await api('/drive/files/update', {
fileId: file.id,
- folderId: 'foo'
+ folderId: 'foo',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('ファイルが存在しなかったら怒る', async () => {
const res = await api('/drive/files/update', {
fileId: '000000000000000000000000',
- name: 'いちごパスタ.png'
+ name: 'いちごパスタ.png',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('間違ったIDで怒られる', async () => {
const res = await api('/drive/files/update', {
fileId: 'kyoppie',
- name: 'いちごパスタ.png'
+ name: 'いちごパスタ.png',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
});
describe('drive/folders/create', () => {
test('フォルダを作成できる', async () => {
const res = await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'test');
- }));
+ });
});
describe('drive/folders/update', () => {
test('名前を更新できる', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- name: 'new name'
+ name: 'new name',
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.name, 'new name');
- }));
+ });
test('他人のフォルダを更新できない', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, bob)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- name: 'new name'
+ name: 'new name',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('親フォルダを更新できる', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const parentFolder = (await api('/drive/folders/create', {
- name: 'parent'
+ name: 'parent',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: parentFolder.id
+ parentId: parentFolder.id,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.parentId, parentFolder.id);
- }));
+ });
test('親フォルダを無しに更新できる', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const parentFolder = (await api('/drive/folders/create', {
- name: 'parent'
+ name: 'parent',
}, alice)).body;
await api('/drive/folders/update', {
folderId: folder.id,
- parentId: parentFolder.id
+ parentId: parentFolder.id,
}, alice);
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: null
+ parentId: null,
}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.parentId, null);
- }));
+ });
test('他人のフォルダを親フォルダに設定できない', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const parentFolder = (await api('/drive/folders/create', {
- name: 'parent'
+ name: 'parent',
}, bob)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: parentFolder.id
+ parentId: parentFolder.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('フォルダが循環するような構造にできない', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const parentFolder = (await api('/drive/folders/create', {
- name: 'parent'
+ name: 'parent',
}, alice)).body;
await api('/drive/folders/update', {
folderId: parentFolder.id,
- parentId: folder.id
+ parentId: folder.id,
}, alice);
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: parentFolder.id
+ parentId: parentFolder.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('フォルダが循環するような構造にできない(再帰的)', async () => {
const folderA = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const folderB = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const folderC = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
await api('/drive/folders/update', {
folderId: folderB.id,
- parentId: folderA.id
+ parentId: folderA.id,
}, alice);
await api('/drive/folders/update', {
folderId: folderC.id,
- parentId: folderB.id
+ parentId: folderB.id,
}, alice);
const res = await api('/drive/folders/update', {
folderId: folderA.id,
- parentId: folderC.id
+ parentId: folderC.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('フォルダが循環するような構造にできない(自身)', async () => {
const folderA = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folderA.id,
- parentId: folderA.id
+ parentId: folderA.id,
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('存在しない親フォルダを設定できない', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: '000000000000000000000000'
+ parentId: '000000000000000000000000',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('不正な親フォルダIDで怒られる', async () => {
const folder = (await api('/drive/folders/create', {
- name: 'test'
+ name: 'test',
}, alice)).body;
const res = await api('/drive/folders/update', {
folderId: folder.id,
- parentId: 'foo'
+ parentId: 'foo',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('存在しないフォルダを更新できない', async () => {
const res = await api('/drive/folders/update', {
- folderId: '000000000000000000000000'
+ folderId: '000000000000000000000000',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
test('不正なフォルダIDで怒られる', async () => {
const res = await api('/drive/folders/update', {
- folderId: 'foo'
+ folderId: 'foo',
}, alice);
assert.strictEqual(res.status, 400);
- }));
+ });
});
describe('notes/replies', () => {
test('自分に閲覧権限のない投稿は含まれない', async () => {
const alicePost = await post(alice, {
- text: 'foo'
+ text: 'foo',
});
await post(bob, {
replyId: alicePost.id,
text: 'bar',
visibility: 'specified',
- visibleUserIds: [alice.id]
+ visibleUserIds: [alice.id],
});
const res = await api('/notes/replies', {
- noteId: alicePost.id
+ noteId: alicePost.id,
}, carol);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 0);
- }));
+ });
});
describe('notes/timeline', () => {
test('フォロワー限定投稿が含まれる', async () => {
await api('/following/create', {
- userId: alice.id
- }, bob);
+ userId: carol.id,
+ }, dave);
- const alicePost = await post(alice, {
+ const carolPost = await post(carol, {
text: 'foo',
- visibility: 'followers'
+ visibility: 'followers',
});
- const res = await api('/notes/timeline', {}, bob);
+ const res = await api('/notes/timeline', {}, dave);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
assert.strictEqual(res.body.length, 1);
- assert.strictEqual(res.body[0].id, alicePost.id);
- }));
+ assert.strictEqual(res.body[0].id, carolPost.id);
+ });
});
});
-*/
diff --git a/packages/backend/test/_e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts
index b8ba3f2477..6b3c795235 100644
--- a/packages/backend/test/_e2e/fetch-resource.ts
+++ b/packages/backend/test/e2e/fetch-resource.ts
@@ -1,9 +1,8 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import * as openapi from '@redocly/openapi-core';
-import { startServer, signup, post, request, simpleGet, port, shutdownServer } from '../utils.js';
+import { startServer, signup, post, api, simpleGet } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
// Request Accept
const ONLY_AP = 'application/activity+json';
@@ -13,11 +12,10 @@ const UNSPECIFIED = '*/*';
// Response Content-Type
const AP = 'application/activity+json; charset=utf-8';
-const JSON = 'application/json; charset=utf-8';
const HTML = 'text/html; charset=utf-8';
describe('Fetch resource', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let alicesPost: any;
@@ -28,15 +26,15 @@ describe('Fetch resource', () => {
alicesPost = await post(alice, {
text: 'test',
});
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('Common', () => {
test('meta', async () => {
- const res = await request('/meta', {
+ const res = await api('/meta', {
});
assert.strictEqual(res.status, 200);
@@ -54,36 +52,26 @@ describe('Fetch resource', () => {
assert.strictEqual(res.type, HTML);
});
- test('GET api-doc', async () => {
+ test('GET api-doc (廃止)', async () => {
const res = await simpleGet('/api-doc');
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.type, HTML);
+ assert.strictEqual(res.status, 404);
});
- test('GET api.json', async () => {
+ test('GET api.json (廃止)', async () => {
const res = await simpleGet('/api.json');
- assert.strictEqual(res.status, 200);
- assert.strictEqual(res.type, JSON);
+ assert.strictEqual(res.status, 404);
});
- test('Validate api.json', async () => {
- const config = await openapi.loadConfig();
- const result = await openapi.bundle({
- config,
- ref: `http://localhost:${port}/api.json`,
- });
-
- for (const problem of result.problems) {
- console.log(`${problem.message} - ${problem.location[0]?.pointer}`);
- }
-
- assert.strictEqual(result.problems.length, 0);
+ test('GET api/foo (存在しない)', async () => {
+ const res = await simpleGet('/api/foo');
+ assert.strictEqual(res.status, 404);
+ assert.strictEqual(res.body.error.code, 'UNKNOWN_API_ENDPOINT');
});
test('GET favicon.ico', async () => {
const res = await simpleGet('/favicon.ico');
assert.strictEqual(res.status, 200);
- assert.strictEqual(res.type, 'image/x-icon');
+ assert.strictEqual(res.type, 'image/vnd.microsoft.icon');
});
test('GET apple-touch-icon.png', async () => {
diff --git a/packages/backend/test/_e2e/ff-visibility.ts b/packages/backend/test/e2e/ff-visibility.ts
index 84a5b5ef28..d53919ca1e 100644
--- a/packages/backend/test/_e2e/ff-visibility.ts
+++ b/packages/backend/test/e2e/ff-visibility.ts
@@ -1,36 +1,34 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, react, connectStream, startServer, shutdownServer, simpleGet } from '../utils.js';
+import { signup, api, startServer, simpleGet } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('FF visibility', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let bob: any;
- let carol: any;
beforeAll(async () => {
p = await startServer();
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
- carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'public',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, bob);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, bob);
@@ -41,14 +39,14 @@ describe('FF visibility', () => {
});
test('ffVisibility が followers なユーザーのフォロー/フォロワーを自分で見れる', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'followers',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, alice);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, alice);
@@ -59,14 +57,14 @@ describe('FF visibility', () => {
});
test('ffVisibility が followers なユーザーのフォロー/フォロワーを非フォロワーが見れない', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'followers',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, bob);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, bob);
@@ -75,18 +73,18 @@ describe('FF visibility', () => {
});
test('ffVisibility が followers なユーザーのフォロー/フォロワーをフォロワーが見れる', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'followers',
}, alice);
- await request('/following/create', {
+ await api('/following/create', {
userId: alice.id,
}, bob);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, bob);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, bob);
@@ -97,14 +95,14 @@ describe('FF visibility', () => {
});
test('ffVisibility が private なユーザーのフォロー/フォロワーを自分で見れる', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'private',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, alice);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, alice);
@@ -115,14 +113,14 @@ describe('FF visibility', () => {
});
test('ffVisibility が private なユーザーのフォロー/フォロワーを他人が見れない', async () => {
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'private',
}, alice);
- const followingRes = await request('/users/following', {
+ const followingRes = await api('/users/following', {
userId: alice.id,
}, bob);
- const followersRes = await request('/users/followers', {
+ const followersRes = await api('/users/followers', {
userId: alice.id,
}, bob);
@@ -133,7 +131,7 @@ describe('FF visibility', () => {
describe('AP', () => {
test('ffVisibility が public 以外ならばAPからは取得できない', async () => {
{
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'public',
}, alice);
@@ -143,22 +141,22 @@ describe('FF visibility', () => {
assert.strictEqual(followersRes.status, 200);
}
{
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'followers',
}, alice);
- const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode }));
- const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode }));
+ const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json');
+ const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json');
assert.strictEqual(followingRes.status, 403);
assert.strictEqual(followersRes.status, 403);
}
{
- await request('/i/update', {
+ await api('/i/update', {
ffVisibility: 'private',
}, alice);
- const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json').catch(res => ({ status: res.statusCode }));
- const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json').catch(res => ({ status: res.statusCode }));
+ const followingRes = await simpleGet(`/users/${alice.id}/following`, 'application/activity+json');
+ const followersRes = await simpleGet(`/users/${alice.id}/followers`, 'application/activity+json');
assert.strictEqual(followingRes.status, 403);
assert.strictEqual(followersRes.status, 403);
}
diff --git a/packages/backend/test/_e2e/mute.ts b/packages/backend/test/e2e/mute.ts
index 8f7f72bb97..6654a290be 100644
--- a/packages/backend/test/_e2e/mute.ts
+++ b/packages/backend/test/e2e/mute.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, react, startServer, shutdownServer, waitFire } from '../utils.js';
+import { signup, api, post, react, startServer, waitFire } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Mute', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
// alice mutes carol
let alice: any;
@@ -17,14 +17,14 @@ describe('Mute', () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('ミュート作成', async () => {
- const res = await request('/mute/create', {
+ const res = await api('/mute/create', {
userId: carol.id,
}, alice);
@@ -35,7 +35,7 @@ describe('Mute', () => {
const bobNote = await post(bob, { text: '@alice hi' });
const carolNote = await post(carol, { text: '@alice hi' });
- const res = await request('/notes/mentions', {}, alice);
+ const res = await api('/notes/mentions', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
@@ -45,11 +45,11 @@ describe('Mute', () => {
test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
await post(carol, { text: '@alice hi' });
- const res = await request('/i', {}, alice);
+ const res = await api('/i', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.hasUnreadMentions, false);
@@ -57,7 +57,7 @@ describe('Mute', () => {
test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention');
@@ -66,8 +66,8 @@ describe('Mute', () => {
test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
- await request('/notifications/mark-all-as-read', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
+ await api('/notifications/mark-all-as-read', {}, alice);
const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification');
@@ -80,7 +80,7 @@ describe('Mute', () => {
const bobNote = await post(bob);
const carolNote = await post(carol);
- const res = await request('/notes/local-timeline', {}, alice);
+ const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
@@ -96,7 +96,7 @@ describe('Mute', () => {
renoteId: carolNote.id,
});
- const res = await request('/notes/local-timeline', {}, alice);
+ const res = await api('/notes/local-timeline', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
@@ -112,7 +112,7 @@ describe('Mute', () => {
await react(bob, aliceNote, 'like');
await react(carol, aliceNote, 'like');
- const res = await request('/i/notifications', {}, alice);
+ const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
diff --git a/packages/backend/test/_e2e/note.ts b/packages/backend/test/e2e/note.ts
index 47af6808f6..1b5f9580d5 100644
--- a/packages/backend/test/_e2e/note.ts
+++ b/packages/backend/test/e2e/note.ts
@@ -1,12 +1,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { Note } from '../../src/models/entities/note.js';
-import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from '../utils.js';
+import { Note } from '@/models/entities/Note.js';
+import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Note', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let Notes: any;
let alice: any;
@@ -18,10 +18,10 @@ describe('Note', () => {
Notes = connection.getRepository(Note);
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('投稿できる', async () => {
@@ -29,7 +29,7 @@ describe('Note', () => {
text: 'test',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -39,7 +39,7 @@ describe('Note', () => {
test('ファイルを添付できる', async () => {
const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg');
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
fileIds: [file.id],
}, alice);
@@ -48,37 +48,37 @@ describe('Note', () => {
assert.deepStrictEqual(res.body.createdNote.fileIds, [file.id]);
}, 1000 * 10);
- test('他人のファイルは無視', async () => {
+ test('他人のファイルで怒られる', async () => {
const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg');
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
text: 'test',
fileIds: [file.id],
}, alice);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.deepStrictEqual(res.body.createdNote.fileIds, []);
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE');
+ assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}, 1000 * 10);
- test('存在しないファイルは無視', async () => {
- const res = await request('/notes/create', {
+ test('存在しないファイルで怒られる', async () => {
+ const res = await api('/notes/create', {
text: 'test',
fileIds: ['000000000000000000000000'],
}, alice);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.deepStrictEqual(res.body.createdNote.fileIds, []);
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE');
+ assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
});
- test('不正なファイルIDは無視', async () => {
- const res = await request('/notes/create', {
+ test('不正なファイルIDで怒られる', async () => {
+ const res = await api('/notes/create', {
fileIds: ['kyoppie'],
}, alice);
- assert.strictEqual(res.status, 200);
- assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.deepStrictEqual(res.body.createdNote.fileIds, []);
+ assert.strictEqual(res.status, 400);
+ assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE');
+ assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
});
test('返信できる', async () => {
@@ -91,7 +91,7 @@ describe('Note', () => {
replyId: bobPost.id,
};
- const res = await request('/notes/create', alicePost, alice);
+ const res = await api('/notes/create', alicePost, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -109,7 +109,7 @@ describe('Note', () => {
renoteId: bobPost.id,
};
- const res = await request('/notes/create', alicePost, alice);
+ const res = await api('/notes/create', alicePost, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -127,7 +127,7 @@ describe('Note', () => {
renoteId: bobPost.id,
};
- const res = await request('/notes/create', alicePost, alice);
+ const res = await api('/notes/create', alicePost, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -140,7 +140,7 @@ describe('Note', () => {
const post = {
text: '!'.repeat(3000),
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
});
@@ -148,7 +148,7 @@ describe('Note', () => {
const post = {
text: '!'.repeat(3001),
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -157,7 +157,7 @@ describe('Note', () => {
text: 'test',
replyId: '000000000000000000000000',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -165,7 +165,7 @@ describe('Note', () => {
const post = {
renoteId: '000000000000000000000000',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -174,7 +174,7 @@ describe('Note', () => {
text: 'test',
replyId: 'foo',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -182,7 +182,7 @@ describe('Note', () => {
const post = {
renoteId: 'foo',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 400);
});
@@ -191,7 +191,7 @@ describe('Note', () => {
text: '@ghost yo',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -203,7 +203,7 @@ describe('Note', () => {
text: '@bob @bob @bob yo',
};
- const res = await request('/notes/create', post, alice);
+ const res = await api('/notes/create', post, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
@@ -213,9 +213,125 @@ describe('Note', () => {
assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
});
+ describe('添付ファイル情報', () => {
+ test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const res = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.createdNote.files.length, 1);
+ assert.strictEqual(res.body.createdNote.files[0].id, file.body.id);
+ });
+
+ test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const res = await api('/notes', {
+ withFiles: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.files.length, 1);
+ assert.strictEqual(myNote.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const renoted = await api('/notes/create', {
+ renoteId: createdNote.body.createdNote.id,
+ }, alice);
+ assert.strictEqual(renoted.status, 200);
+
+ const res = await api('/notes', {
+ renote: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.renote.files.length, 1);
+ assert.strictEqual(myNote.renote.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const reply = await api('/notes/create', {
+ replyId: createdNote.body.createdNote.id,
+ text: 'this is reply',
+ }, alice);
+ assert.strictEqual(reply.status, 200);
+
+ const res = await api('/notes', {
+ reply: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.reply.files.length, 1);
+ assert.strictEqual(myNote.reply.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const reply = await api('/notes/create', {
+ replyId: createdNote.body.createdNote.id,
+ text: 'this is reply',
+ }, alice);
+ assert.strictEqual(reply.status, 200);
+
+ const renoted = await api('/notes/create', {
+ renoteId: reply.body.createdNote.id,
+ }, alice);
+ assert.strictEqual(renoted.status, 200);
+
+ const res = await api('/notes', {
+ renote: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.renote.reply.files.length, 1);
+ assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id);
+ });
+ });
+
describe('notes/create', () => {
test('投票を添付できる', async () => {
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
text: 'test',
poll: {
choices: ['foo', 'bar'],
@@ -228,14 +344,14 @@ describe('Note', () => {
});
test('投票の選択肢が無くて怒られる', async () => {
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
poll: {},
}, alice);
assert.strictEqual(res.status, 400);
});
test('投票の選択肢が無くて怒られる (空の配列)', async () => {
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
poll: {
choices: [],
},
@@ -244,7 +360,7 @@ describe('Note', () => {
});
test('投票の選択肢が1つで怒られる', async () => {
- const res = await request('/notes/create', {
+ const res = await api('/notes/create', {
poll: {
choices: ['Strawberry Pasta'],
},
@@ -253,14 +369,14 @@ describe('Note', () => {
});
test('投票できる', async () => {
- const { body } = await request('/notes/create', {
+ const { body } = await api('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
},
}, alice);
- const res = await request('/notes/polls/vote', {
+ const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 1,
}, alice);
@@ -269,19 +385,19 @@ describe('Note', () => {
});
test('複数投票できない', async () => {
- const { body } = await request('/notes/create', {
+ const { body } = await api('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
},
}, alice);
- await request('/notes/polls/vote', {
+ await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 0,
}, alice);
- const res = await request('/notes/polls/vote', {
+ const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 2,
}, alice);
@@ -290,7 +406,7 @@ describe('Note', () => {
});
test('許可されている場合は複数投票できる', async () => {
- const { body } = await request('/notes/create', {
+ const { body } = await api('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
@@ -298,17 +414,17 @@ describe('Note', () => {
},
}, alice);
- await request('/notes/polls/vote', {
+ await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 0,
}, alice);
- await request('/notes/polls/vote', {
+ await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 1,
}, alice);
- const res = await request('/notes/polls/vote', {
+ const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 2,
}, alice);
@@ -317,7 +433,7 @@ describe('Note', () => {
});
test('締め切られている場合は投票できない', async () => {
- const { body } = await request('/notes/create', {
+ const { body } = await api('/notes/create', {
text: 'test',
poll: {
choices: ['sakura', 'izumi', 'ako'],
@@ -327,7 +443,7 @@ describe('Note', () => {
await new Promise(x => setTimeout(x, 2));
- const res = await request('/notes/polls/vote', {
+ const res = await api('/notes/polls/vote', {
noteId: body.createdNote.id,
choice: 1,
}, alice);
diff --git a/packages/backend/test/_e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts
index 790451d9b4..23c431f2e7 100644
--- a/packages/backend/test/_e2e/streaming.ts
+++ b/packages/backend/test/e2e/streaming.ts
@@ -1,12 +1,12 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { Following } from '../../src/models/entities/following.js';
-import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from '../utils.js';
+import { Following } from '@/models/entities/Following.js';
+import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Streaming', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let Followings: any;
const follow = async (follower: any, followee: any) => {
@@ -71,10 +71,10 @@ describe('Streaming', () => {
listId: list.id,
userId: kyoko.id,
}, chitose);
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
describe('Events', () => {
@@ -404,43 +404,45 @@ describe('Streaming', () => {
});
}));
- test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
- let fooCount = 0;
- let barCount = 0;
- let fooBarCount = 0;
-
- const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
- if (type === 'note') {
- if (body.text === '#foo') fooCount++;
- if (body.text === '#bar') barCount++;
- if (body.text === '#foo #bar') fooBarCount++;
- }
- }, {
- q: [
- ['foo', 'bar'],
- ],
- });
-
- post(chitose, {
- text: '#foo',
- });
-
- post(chitose, {
- text: '#bar',
- });
-
- post(chitose, {
- text: '#foo #bar',
- });
-
- setTimeout(() => {
- assert.strictEqual(fooCount, 0);
- assert.strictEqual(barCount, 0);
- assert.strictEqual(fooBarCount, 1);
- ws.close();
- done();
- }, 3000);
- }));
+ // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"
+
+ // test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
+ // let fooCount = 0;
+ // let barCount = 0;
+ // let fooBarCount = 0;
+
+ // const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
+ // if (type === 'note') {
+ // if (body.text === '#foo') fooCount++;
+ // if (body.text === '#bar') barCount++;
+ // if (body.text === '#foo #bar') fooBarCount++;
+ // }
+ // }, {
+ // q: [
+ // ['foo', 'bar'],
+ // ],
+ // });
+
+ // post(chitose, {
+ // text: '#foo',
+ // });
+
+ // post(chitose, {
+ // text: '#bar',
+ // });
+
+ // post(chitose, {
+ // text: '#foo #bar',
+ // });
+
+ // setTimeout(() => {
+ // assert.strictEqual(fooCount, 0);
+ // assert.strictEqual(barCount, 0);
+ // assert.strictEqual(fooBarCount, 1);
+ // ws.close();
+ // done();
+ // }, 3000);
+ // }));
test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise<void>(async done => {
let fooCount = 0;
diff --git a/packages/backend/test/_e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts
index 890b52a8c1..792436d88f 100644
--- a/packages/backend/test/_e2e/thread-mute.ts
+++ b/packages/backend/test/e2e/thread-mute.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, react, connectStream, startServer, shutdownServer } from '../utils.js';
+import { signup, api, post, connectStream, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('Note thread mute', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let bob: any;
@@ -16,22 +16,22 @@ describe('Note thread mute', () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async () => {
- await shutdownServer(p);
+ await p.close();
});
test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => {
const bobNote = await post(bob, { text: '@alice @carol root note' });
const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' });
- await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
+ await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' });
- const res = await request('/notes/mentions', {}, alice);
+ const res = await api('/notes/mentions', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
@@ -42,27 +42,27 @@ describe('Note thread mute', () => {
test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
const bobNote = await post(bob, { text: '@alice @carol root note' });
- await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
+ await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
- const res = await request('/i', {}, alice);
+ const res = await api('/i', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.hasUnreadMentions, false);
});
- test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => {
+ test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise<void>(async done => {
// 状態リセット
- await request('/i/read-all-unread-notes', {}, alice);
+ await api('/i/read-all-unread-notes', {}, alice);
const bobNote = await post(bob, { text: '@alice @carol root note' });
- await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
+ await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
let fired = false;
@@ -86,12 +86,12 @@ describe('Note thread mute', () => {
const bobNote = await post(bob, { text: '@alice @carol root note' });
const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' });
- await request('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
+ await api('/notes/thread-muting/create', { noteId: bobNote.id }, alice);
const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' });
const carolReplyWithoutMention = await post(carol, { replyId: aliceReply.id, text: 'child note' });
- const res = await request('/i/notifications', {}, alice);
+ const res = await api('/i/notifications', {}, alice);
assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true);
diff --git a/packages/backend/test/_e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts
index a6cc1057f9..690cba1746 100644
--- a/packages/backend/test/_e2e/user-notes.ts
+++ b/packages/backend/test/e2e/user-notes.ts
@@ -1,11 +1,11 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
-import * as childProcess from 'child_process';
-import { signup, request, post, uploadUrl, startServer, shutdownServer } from '../utils.js';
+import { signup, api, post, uploadUrl, startServer } from '../utils.js';
+import type { INestApplicationContext } from '@nestjs/common';
describe('users/notes', () => {
- let p: childProcess.ChildProcess;
+ let p: INestApplicationContext;
let alice: any;
let jpgNote: any;
@@ -26,14 +26,14 @@ describe('users/notes', () => {
jpgPngNote = await post(alice, {
fileIds: [jpg.id, png.id],
});
- }, 1000 * 30);
+ }, 1000 * 60 * 2);
afterAll(async() => {
- await shutdownServer(p);
+ await p.close();
});
test('ファイルタイプ指定 (jpg)', async () => {
- const res = await request('/users/notes', {
+ const res = await api('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg'],
}, alice);
@@ -46,7 +46,7 @@ describe('users/notes', () => {
});
test('ファイルタイプ指定 (jpg or png)', async () => {
- const res = await request('/users/notes', {
+ const res = await api('/users/notes', {
userId: alice.id,
fileType: ['image/jpeg', 'image/png'],
}, alice);
diff --git a/packages/backend/test/prelude/get-api-validator.ts b/packages/backend/test/prelude/get-api-validator.ts
new file mode 100644
index 0000000000..1f4a2dbc95
--- /dev/null
+++ b/packages/backend/test/prelude/get-api-validator.ts
@@ -0,0 +1,11 @@
+import { Schema } from '@/misc/schema';
+import Ajv from 'ajv';
+
+export const getValidator = (paramDef: Schema) => {
+ const ajv = new Ajv({
+ useDefaults: true,
+ });
+ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
+
+ return ajv.compile(paramDef);
+}
diff --git a/packages/backend/test/resources/misskey.svg b/packages/backend/test/resources/misskey.svg
new file mode 100644
index 0000000000..3fcb2d3ecb
--- /dev/null
+++ b/packages/backend/test/resources/misskey.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="515px" height="136px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
+ <g id="path2" transform="matrix(0.264585,0,0,0.264585,-50.0005,-50.0001)">
+ <path d="M256.418,188.976C248.558,188.944 240.758,190.308 233.379,193.013C220.308,197.613 209.533,205.888 201.091,217.802C193.02,229.329 188.977,242.195 188.977,256.409L188.977,508.89C188.977,527.332 195.52,543.29 208.576,556.732C222.032,569.803 237.99,576.331 256.418,576.331C275.259,576.331 291.204,569.803 304.274,556.747C317.73,543.291 324.441,527.332 324.441,508.89L324.441,462.983C324.584,453.04 334.824,455.655 340.01,462.983C349.695,479.767 370.251,494.19 394.193,494.105L394.193,494.119C418.119,494.019 438.005,482.196 448.375,462.983C452.304,458.354 463.377,450.455 464.52,462.983L464.52,508.89C464.52,527.332 471.047,543.29 484.104,556.732C497.574,569.803 513.511,576.331 531.953,576.331C550.78,576.331 566.739,569.803 579.809,556.747C593.265,543.291 599.977,527.332 599.977,508.89L599.977,256.409C599.977,242.195 595.752,229.329 587.309,217.802C579.224,205.874 568.653,197.613 555.597,193.013C547.912,190.314 540.228,188.976 532.543,188.976C511.788,188.976 494.301,197.046 480.073,213.188L411.636,293.281C410.107,294.438 405.006,303.247 394.178,303.247C383.379,303.247 378.868,294.439 377.325,293.296L308.297,213.188C294.47,197.046 277.173,188.976 256.418,188.976ZM682.904,188.983C666.763,188.983 652.926,194.748 641.404,206.271C630.261,217.413 624.691,231.054 624.691,247.196C624.691,263.338 630.261,277.174 641.404,288.697C652.926,299.839 666.763,305.41 682.904,305.41C699.046,305.41 712.88,299.839 724.412,288.697C735.935,277.174 741.693,263.338 741.693,247.196C741.693,231.054 735.935,217.413 724.412,206.271C712.88,194.748 699.046,188.983 682.904,188.983ZM1327.58,193.013C1311.45,193.013 1297.62,198.779 1286.1,210.302C1274.96,221.444 1269.38,235.08 1269.38,251.212L1269.38,519.72C1269.38,535.861 1274.96,549.697 1286.1,561.22C1297.62,572.362 1311.45,577.933 1327.58,577.933C1343.73,577.933 1357.55,572.362 1369.08,561.22C1380.6,549.697 1386.37,535.867 1386.37,519.734C1386.37,508.211 1387.9,502.453 1390.96,502.453C1392.51,502.453 1394.05,503.023 1395.58,504.166L1453.2,560.061C1464.72,571.203 1478.36,576.774 1494.11,576.774C1510.62,576.774 1524.45,571.009 1535.6,559.486C1546.74,547.572 1552.31,533.936 1552.31,518.575C1552.31,502.053 1546.36,488.029 1534.45,476.506C1508.32,450.765 1494.69,437.517 1493.54,436.755C1488.54,431.755 1488.73,427.53 1494.11,424.073L1495.27,423.497L1495.27,422.929L1531.57,399.875C1548.85,388.352 1557.5,372.026 1557.5,350.904C1557.5,339.381 1554.42,328.622 1548.28,318.623C1536.76,301.339 1520.43,292.691 1499.3,292.691C1487.78,292.691 1477.02,295.768 1467.04,301.911C1422.48,331.499 1399.42,346.678 1397.88,347.449C1395.2,349.363 1392.7,349.738 1390.4,348.586C1387.7,347.434 1386.35,344.939 1386.35,341.101L1386.35,251.212C1386.35,235.08 1380.59,221.444 1369.07,210.302C1357.55,198.779 1343.72,193.013 1327.58,193.013ZM1716.37,291.738C1676.42,291.738 1642.24,305.949 1613.81,334.376C1585.76,362.422 1571.74,396.227 1571.74,435.795C1571.74,475.745 1585.76,509.932 1613.81,538.359C1642.24,566.404 1676.42,580.428 1716.37,580.428C1755.94,580.428 1789.94,566.404 1818.37,538.359C1827.2,529.521 1831.62,518.773 1831.62,506.107C1831.62,493.423 1827.2,482.664 1818.37,473.827C1809.53,464.999 1798.77,460.584 1786.11,460.584C1773.42,460.584 1762.66,464.999 1753.83,473.827C1743.46,484.588 1730.97,489.963 1716.37,489.963C1701.4,489.963 1688.53,484.78 1677.77,474.41C1667.39,463.649 1662.2,450.775 1662.2,435.795C1662.2,421.206 1667.59,408.72 1678.35,398.34C1683.73,392.578 1690.26,388.74 1697.93,386.817C1699.87,386.436 1701.4,386.623 1702.55,387.385C1703.32,388.547 1702.93,389.702 1701.39,390.854L1689.87,402.953C1681.03,411.791 1676.61,422.359 1676.61,434.644C1676.61,447.319 1680.45,457.497 1688.13,465.182C1695.81,472.868 1706.57,476.705 1720.41,476.705C1730.01,476.705 1739.61,471.91 1749.21,462.311L1816.06,396.044C1824.9,387.197 1829.32,376.436 1829.32,363.77C1829.32,351.086 1824.9,340.332 1816.06,331.504C1789.17,304.992 1755.94,291.738 1716.37,291.738ZM877.977,292.668C841.947,292.194 813.839,301.679 793.662,321.133C775.996,338.036 767.168,359.358 767.168,385.089C767.549,417.363 780.035,441.565 804.624,457.697C811.918,462.687 820.941,466.72 831.693,469.796C837.083,471.72 846.111,474.02 858.777,476.705C869.919,479.391 882.023,481.886 895.088,484.191C897.774,484.962 898.924,486.312 898.543,488.236C898.543,490.541 897.58,491.691 895.657,491.691C890.667,492.072 886.059,492.266 881.831,492.266C850.328,488.81 829.001,485.927 817.859,483.622C814.793,482.851 811.535,482.463 808.078,482.463C796.165,482.463 785.787,486.884 776.949,495.721C768.502,504.178 764.282,514.551 764.282,526.836C764.282,536.825 767.352,545.854 773.494,553.92C780.027,561.986 788.486,567.169 798.866,569.473C831.13,576.778 860.317,580.428 886.429,580.428C922.16,580.428 950.013,570.825 969.992,551.617C987.277,535.094 995.925,513.775 995.925,487.653C995.925,455.388 983.626,431.187 959.037,415.045C945.972,406.598 927.915,400.45 904.869,396.612C891.042,393.927 879.518,391.427 870.3,389.112L870.3,389.127C867.605,388.356 866.067,386.818 865.686,384.513C865.686,382.59 867.224,381.44 870.3,381.059C873.757,380.678 877.415,380.678 881.262,381.059C913.146,384.135 934.652,386.823 945.794,389.127C948.861,389.889 951.931,390.271 955.007,390.271C967.301,390.271 977.674,386.051 986.121,377.604C994.959,368.767 999.379,358.393 999.379,346.49C999.379,336.109 996.109,326.894 989.576,318.837C983.043,310.761 974.79,305.566 964.81,303.261C938.298,297.5 911.788,294.042 885.285,292.89C882.813,292.77 880.379,292.7 877.977,292.668ZM1128.73,292.668C1092.7,292.194 1064.59,301.679 1044.42,321.133C1026.75,338.036 1017.92,359.358 1017.92,385.089C1018.3,417.363 1030.79,441.565 1055.38,457.697C1062.67,462.687 1071.7,466.72 1082.46,469.796C1087.84,471.72 1096.86,474.02 1109.54,476.705C1120.68,479.391 1132.79,481.886 1145.84,484.191C1148.53,484.962 1149.68,486.312 1149.3,488.236C1149.3,490.541 1148.34,491.691 1146.41,491.691C1141.42,492.072 1136.81,492.266 1132.59,492.266C1101.09,488.81 1079.77,485.927 1068.63,483.622C1065.55,482.851 1062.29,482.463 1058.83,482.463C1046.92,482.463 1036.55,486.884 1027.72,495.721C1019.26,504.178 1015.04,514.551 1015.04,526.836C1015.04,536.825 1018.11,545.854 1024.26,553.92C1030.79,561.986 1039.24,567.169 1049.62,569.473C1081.88,576.778 1111.08,580.428 1137.2,580.428C1172.92,580.428 1200.77,570.825 1220.75,551.617C1238.03,535.094 1246.68,513.775 1246.68,487.653C1246.68,455.388 1234.39,431.187 1209.81,415.045C1196.74,406.598 1178.68,400.45 1155.64,396.612C1141.81,393.927 1130.29,391.427 1121.07,389.112L1121.05,389.127C1118.37,388.356 1116.84,386.818 1116.45,384.513C1116.45,382.59 1117.99,381.44 1121.05,381.059C1124.52,380.678 1128.17,380.678 1132.01,381.059C1163.89,384.135 1185.41,386.823 1196.55,389.127C1199.62,389.889 1202.69,390.271 1205.76,390.271C1218.06,390.271 1228.43,386.051 1236.89,377.604C1245.72,368.767 1250.13,358.393 1250.13,346.49C1250.13,336.109 1246.87,326.894 1240.35,318.837C1233.81,310.761 1225.55,305.566 1215.56,303.261C1189.06,297.5 1162.55,294.042 1136.04,292.89C1133.57,292.77 1131.13,292.7 1128.73,292.668ZM1910.17,296.736C1894.04,296.736 1880.21,302.501 1868.69,314.024C1857.55,325.157 1851.98,338.793 1851.98,354.934L1851.98,435.028C1851.98,473.825 1865.8,507.05 1893.45,534.705C1921.12,562.36 1954.36,576.191 1993.15,576.191C2000.84,576.191 2007.95,575.614 2014.48,574.471C2018.32,573.699 2021,574.469 2022.53,576.774C2023.69,578.307 2023.3,580.42 2021.39,583.115C2016.39,590.029 2005.82,593.486 1989.69,593.486C1983.55,593.486 1975.68,591.949 1966.07,588.873C1956.47,585.797 1948.98,584.259 1943.6,584.259C1920.93,584.259 1904.99,594.638 1895.77,615.388C1892.32,622.302 1890.58,629.598 1890.58,637.283C1890.58,659.948 1900.77,675.892 1921.13,685.11C1941.49,694.709 1964.34,699.505 1989.69,699.505C2033.49,699.505 2068.25,686.639 2093.98,660.898C2120.11,635.166 2133.18,600.784 2133.18,557.758L2133.18,452.308C2133.94,446.157 2134.32,440.399 2134.32,435.028L2134.32,354.934C2134.32,338.802 2128.57,325.166 2117.04,314.024C2105.9,302.501 2092.27,296.736 2076.13,296.736C2059.99,296.736 2046.16,302.501 2034.63,314.024C2023.11,325.157 2017.35,338.793 2017.35,354.934L2017.35,435.028C2017.35,441.551 2015.04,447.309 2010.43,452.308C2005.83,456.918 2000.07,459.225 1993.15,459.225C1986.62,459.225 1980.86,456.918 1975.87,452.308C1971.26,447.309 1968.95,441.551 1968.95,435.028L1968.95,354.934C1968.95,338.802 1963.19,325.166 1951.67,314.024C1940.14,302.501 1926.3,296.736 1910.17,296.736ZM683.473,316.947C667.331,316.947 653.495,322.713 641.972,334.236C630.449,345.768 624.691,359.602 624.691,375.744L624.691,518.118C624.691,534.259 630.449,548.095 641.972,559.618C653.504,570.761 667.341,576.331 683.473,576.331C699.624,576.331 713.27,570.761 724.412,559.618C735.935,548.095 741.693,534.259 741.693,518.118L741.693,375.744C741.693,359.593 735.935,345.759 724.412,334.236C713.261,322.713 699.614,316.947 683.473,316.947Z" style="fill:white;fill-rule:nonzero;"/>
+ </g>
+</svg>
diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json
index da82ddc4a1..8a024a678b 100644
--- a/packages/backend/test/tsconfig.json
+++ b/packages/backend/test/tsconfig.json
@@ -33,11 +33,12 @@
"lib": [
"esnext"
],
- "types": ["jest"]
+ "types": ["jest", "node"]
},
"compileOnSave": false,
"include": [
"./**/*.ts",
+ "../src/**/*.test.ts",
"../src/@types/**/*.ts",
]
}
diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts
index 221f743d3a..6fe04274e6 100644
--- a/packages/backend/test/unit/RoleService.ts
+++ b/packages/backend/test/unit/RoleService.ts
@@ -3,16 +3,18 @@ process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
-import { DataSource } from 'typeorm';
+import * as lolex from '@sinonjs/fake-timers';
import rndstr from 'rndstr';
import { GlobalModule } from '@/GlobalModule.js';
import { RoleService } from '@/core/RoleService.js';
import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
-import { CoreModule } from '@/core/CoreModule.js';
import { MetaService } from '@/core/MetaService.js';
import { genAid } from '@/misc/id/aid.js';
import { UserCacheService } from '@/core/UserCacheService.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { sleep } from '../utils.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
@@ -25,6 +27,7 @@ describe('RoleService', () => {
let rolesRepository: RolesRepository;
let roleAssignmentsRepository: RoleAssignmentsRepository;
let metaService: jest.Mocked<MetaService>;
+ let clock: lolex.InstalledClock;
function createUser(data: Partial<User> = {}) {
const un = rndstr('a-z0-9', 16);
@@ -50,16 +53,12 @@ describe('RoleService', () => {
.then(x => rolesRepository.findOneByOrFail(x.identifiers[0]));
}
- async function assign(roleId: Role['id'], userId: User['id']) {
- await roleAssignmentsRepository.insert({
- id: genAid(new Date()),
- createdAt: new Date(),
- roleId,
- userId,
+ beforeEach(async () => {
+ clock = lolex.install({
+ now: new Date(),
+ shouldClearNativeTimers: true,
});
- }
- beforeEach(async () => {
app = await Test.createTestingModule({
imports: [
GlobalModule,
@@ -67,6 +66,8 @@ describe('RoleService', () => {
providers: [
RoleService,
UserCacheService,
+ IdService,
+ GlobalEventService,
],
})
.useMocker((token) => {
@@ -92,12 +93,15 @@ describe('RoleService', () => {
});
afterEach(async () => {
+ clock.uninstall();
+
await Promise.all([
app.get(DI.metasRepository).delete({}),
usersRepository.delete({}),
rolesRepository.delete({}),
roleAssignmentsRepository.delete({}),
]);
+
await app.close();
});
@@ -115,7 +119,7 @@ describe('RoleService', () => {
expect(result.canManageCustomEmojis).toBe(false);
});
- test('instance default policies 2', async () => {
+ test('instance default policies 2', async () => {
const user = await createUser();
metaService.fetch.mockResolvedValue({
policies: {
@@ -128,7 +132,7 @@ describe('RoleService', () => {
expect(result.canManageCustomEmojis).toBe(true);
});
- test('with role', async () => {
+ test('with role', async () => {
const user = await createUser();
const role = await createRole({
name: 'a',
@@ -140,7 +144,7 @@ describe('RoleService', () => {
},
},
});
- await assign(role.id, user.id);
+ await roleService.assign(user.id, role.id);
metaService.fetch.mockResolvedValue({
policies: {
canManageCustomEmojis: false,
@@ -152,7 +156,7 @@ describe('RoleService', () => {
expect(result.canManageCustomEmojis).toBe(true);
});
- test('priority', async () => {
+ test('priority', async () => {
const user = await createUser();
const role1 = await createRole({
name: 'role1',
@@ -174,8 +178,8 @@ describe('RoleService', () => {
},
},
});
- await assign(role1.id, user.id);
- await assign(role2.id, user.id);
+ await roleService.assign(user.id, role1.id);
+ await roleService.assign(user.id, role2.id);
metaService.fetch.mockResolvedValue({
policies: {
driveCapacityMb: 50,
@@ -187,7 +191,7 @@ describe('RoleService', () => {
expect(result.driveCapacityMb).toBe(100);
});
- test('conditional role', async () => {
+ test('conditional role', async () => {
const user1 = await createUser({
createdAt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 365)),
});
@@ -228,5 +232,42 @@ describe('RoleService', () => {
expect(user1Policies.canManageCustomEmojis).toBe(false);
expect(user2Policies.canManageCustomEmojis).toBe(true);
});
+
+ test('expired role', async () => {
+ const user = await createUser();
+ const role = await createRole({
+ name: 'a',
+ policies: {
+ canManageCustomEmojis: {
+ useDefault: false,
+ priority: 0,
+ value: true,
+ },
+ },
+ });
+ await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24)));
+ metaService.fetch.mockResolvedValue({
+ policies: {
+ canManageCustomEmojis: false,
+ },
+ } as any);
+
+ const result = await roleService.getUserPolicies(user.id);
+ expect(result.canManageCustomEmojis).toBe(true);
+
+ clock.tick('25:00:00');
+
+ const resultAfter25h = await roleService.getUserPolicies(user.id);
+ expect(resultAfter25h.canManageCustomEmojis).toBe(false);
+
+ await roleService.assign(user.id, role.id);
+
+ // ストリーミング経由で反映されるまでちょっと待つ
+ clock.uninstall();
+ await sleep(100);
+
+ const resultAfter25hAgain = await roleService.getUserPolicies(user.id);
+ expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true);
+ });
});
});
diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts
new file mode 100644
index 0000000000..c476aef33b
--- /dev/null
+++ b/packages/backend/test/unit/misc/others.ts
@@ -0,0 +1,42 @@
+import { describe, test, expect } from '@jest/globals';
+import { contentDisposition } from '@/misc/content-disposition.js';
+import { correctFilename } from '@/misc/correct-filename.js';
+
+describe('misc:content-disposition', () => {
+ test('inline', () => {
+ expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
+ });
+ test('attachment', () => {
+ expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
+ });
+ test('non ascii', () => {
+ expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D');
+ });
+});
+
+describe('misc:correct-filename', () => {
+ test('simple', () => {
+ expect(correctFilename('filename', 'jpg')).toBe('filename.jpg');
+ });
+ test('with same ext', () => {
+ expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg');
+ });
+ test('.ext', () => {
+ expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg');
+ });
+ test('with different ext', () => {
+ expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg');
+ });
+ test('non ascii with space', () => {
+ expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg');
+ });
+ test('jpeg', () => {
+ expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg');
+ });
+ test('tiff', () => {
+ expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff');
+ });
+ test('null ext', () => {
+ expect(correctFilename('filename', null)).toBe('filename.unknown');
+ });
+});
diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts
index 50988939aa..8203e49359 100644
--- a/packages/backend/test/utils.ts
+++ b/packages/backend/test/utils.ts
@@ -1,87 +1,50 @@
-import * as fs from 'node:fs';
-import * as path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { dirname } from 'node:path';
-import * as childProcess from 'child_process';
-import * as http from 'node:http';
-import { SIGKILL } from 'constants';
+import { readFile } from 'node:fs/promises';
+import { isAbsolute, basename } from 'node:path';
import WebSocket from 'ws';
-import fetch from 'node-fetch';
-import FormData from 'form-data';
+import fetch, { Blob, File, RequestInit } from 'node-fetch';
import { DataSource } from 'typeorm';
-import got, { RequestError } from 'got';
-import loadConfig from '../src/config/load.js';
-import { entities } from '@/postgres.js';
+import { entities } from '../src/postgres.js';
+import { loadConfig } from '../src/config.js';
import type * as misskey from 'misskey-js';
-const _filename = fileURLToPath(import.meta.url);
-const _dirname = dirname(_filename);
+export { server as startServer } from '@/boot/common.js';
const config = loadConfig();
export const port = config.port;
export const api = async (endpoint: string, params: any, me?: any) => {
- endpoint = endpoint.replace(/^\//, '');
-
- const auth = me ? {
- i: me.token,
- } : {};
-
- try {
- const res = await got<string>(`http://localhost:${port}/api/${endpoint}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(Object.assign(auth, params)),
- retry: {
- limit: 0,
- },
- });
-
- const status = res.statusCode;
- const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null;
-
- return {
- status,
- body,
- };
- } catch (err: unknown) {
- if (err instanceof RequestError && err.response) {
- const status = err.response.statusCode;
- const body = await JSON.parse(err.response.body as string);
-
- return {
- status,
- body,
- };
- } else {
- throw err;
- }
- }
+ const normalized = endpoint.replace(/^\//, '');
+ return await request(`api/${normalized}`, params, me);
};
-export const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
+const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => {
const auth = me ? {
i: me.token,
} : {};
- const res = await fetch(`http://localhost:${port}/${path}`, {
+ const res = await relativeFetch(path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(Object.assign(auth, params)),
+ redirect: 'manual',
});
const status = res.status;
- const body = res.status === 200 ? await res.json().catch() : null;
+ const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
+ ? await res.json()
+ : null;
return {
body, status,
};
};
+const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
+ return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
+};
+
export const signup = async (params?: any): Promise<any> => {
const q = Object.assign({
username: 'test',
@@ -110,30 +73,46 @@ export const react = async (user: any, note: any, reaction: string): Promise<any
}, user);
};
+interface UploadOptions {
+ /** Optional, absolute path or relative from ./resources/ */
+ path?: string | URL;
+ /** The name to be used for the file upload */
+ name?: string;
+ /** A Blob can be provided instead of path */
+ blob?: Blob;
+}
+
/**
* Upload file
* @param user User
- * @param _path Optional, absolute path or relative from ./resources/
*/
-export const uploadFile = async (user: any, _path?: string): Promise<any> => {
- const absPath = _path == null ? `${_dirname}/resources/Lenna.jpg` : path.isAbsolute(_path) ? _path : `${_dirname}/resources/${_path}`;
+export const uploadFile = async (user: any, { path, name, blob }: UploadOptions = {}): Promise<any> => {
+ const absPath = path == null
+ ? new URL('resources/Lenna.jpg', import.meta.url)
+ : isAbsolute(path.toString())
+ ? new URL(path)
+ : new URL(path, new URL('resources/', import.meta.url));
- const formData = new FormData() as any;
+ const formData = new FormData();
formData.append('i', user.token);
- formData.append('file', fs.createReadStream(absPath));
+ formData.append('file', blob ??
+ new File([await readFile(absPath)], basename(absPath.toString())));
formData.append('force', 'true');
+ if (name) {
+ formData.append('name', name);
+ }
- const res = await got<string>(`http://localhost:${port}/api/drive/files/create`, {
+ const res = await relativeFetch('api/drive/files/create', {
method: 'POST',
body: formData,
- retry: {
- limit: 0,
- },
});
- const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null;
+ const body = res.status !== 204 ? await res.json() : null;
- return body;
+ return {
+ status: res.status,
+ body,
+ };
};
export const uploadUrl = async (user: any, url: string) => {
@@ -160,7 +139,7 @@ export const uploadUrl = async (user: any, url: string) => {
export function connectStream(user: any, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
return new Promise((res, rej) => {
- const ws = new WebSocket(`ws://localhost:${port}/streaming?i=${user.token}`);
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/streaming?i=${user.token}`);
ws.on('open', () => {
ws.on('message', data => {
@@ -187,7 +166,7 @@ export function connectStream(user: any, channel: string, listener: (message: Re
export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => {
return new Promise<boolean>(async (res, rej) => {
- let timer: NodeJS.Timeout;
+ let timer: NodeJS.Timeout | null = null;
let ws: WebSocket;
try {
@@ -219,41 +198,25 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond
});
};
-export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => {
- // node-fetchだと3xxを取れない
- return await new Promise((resolve, reject) => {
- const req = http.request(`http://localhost:${port}${path}`, {
- headers: {
- Accept: accept,
- },
- }, res => {
- if (res.statusCode! >= 400) {
- reject(res);
- } else {
- resolve({
- status: res.statusCode,
- type: res.headers['content-type'],
- location: res.headers.location,
- });
- }
- });
-
- req.end();
+export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status: number, body: any, type: string | null, location: string | null }> => {
+ const res = await relativeFetch(path, {
+ headers: {
+ Accept: accept,
+ },
+ redirect: 'manual',
});
-};
-export function launchServer(callbackSpawnedProcess: (p: childProcess.ChildProcess) => void, moreProcess: () => Promise<void> = async () => {}) {
- return (done: (err?: Error) => any) => {
- const p = childProcess.spawn('node', [_dirname + '/../index.js'], {
- stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
- env: { NODE_ENV: 'test', PATH: process.env.PATH },
- });
- callbackSpawnedProcess(p);
- p.on('message', message => {
- if (message === 'ok') moreProcess().then(() => done()).catch(e => done(e));
- });
+ const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
+ ? await res.json()
+ : null;
+
+ return {
+ status: res.status,
+ body,
+ type: res.headers.get('content-type'),
+ location: res.headers.get('location'),
};
-}
+};
export async function initTestDb(justBorrow = false, initEntities?: any[]) {
if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';
@@ -275,46 +238,6 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) {
return db;
}
-export function startServer(timeout = 60 * 1000): Promise<childProcess.ChildProcess> {
- return new Promise((res, rej) => {
- const t = setTimeout(() => {
- p.kill(SIGKILL);
- rej('timeout to start');
- }, timeout);
-
- const p = childProcess.spawn('node', [_dirname + '/../built/index.js'], {
- stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
- env: { NODE_ENV: 'test', PATH: process.env.PATH },
- });
-
- p.on('error', e => rej(e));
-
- p.on('message', message => {
- if (message === 'ok') {
- clearTimeout(t);
- res(p);
- }
- });
- });
-}
-
-export function shutdownServer(p: childProcess.ChildProcess | undefined, timeout = 20 * 1000) {
- if (p == null) return Promise.resolve('nop');
- return new Promise((res, rej) => {
- const t = setTimeout(() => {
- p.kill(SIGKILL);
- res('force exit');
- }, timeout);
-
- p.once('exit', () => {
- clearTimeout(t);
- res('exited');
- });
-
- p.kill();
- });
-}
-
export function sleep(msec: number) {
return new Promise<void>(res => {
setTimeout(() => {
diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json
index 6f335a2442..faadbcdfc6 100644
--- a/packages/backend/tsconfig.json
+++ b/packages/backend/tsconfig.json
@@ -26,9 +26,7 @@
"rootDir": "./src",
"baseUrl": "./",
"paths": {
- "@/*": [
- "./src/*"
- ]
+ "@/*": ["./src/*"]
},
"outDir": "./built",
"types": [
@@ -46,4 +44,7 @@
"include": [
"./src/**/*.ts"
],
+ "exclude": [
+ "./src/**/*.test.ts"
+ ]
}