Merge branch 'develop' into mkusername-empty

This commit is contained in:
Kagami Sascha Rosylight 2023-03-06 07:51:17 +01:00 committed by GitHub
commit 5651353c27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
201 changed files with 4859 additions and 2521 deletions

View file

@ -1,7 +1,7 @@
module.exports = {
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
project: ['./tsconfig.json', './test/tsconfig.json'],
},
extends: [
'../shared/.eslintrc.js',

View file

@ -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;
});

View file

@ -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)
};

View file

@ -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"`);
}
}

View file

@ -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"
}
}
}

View file

@ -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(),

View file

@ -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() {

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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

View file

@ -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!;

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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));
@ -316,6 +330,65 @@ export class RoleService implements OnApplicationShutdown {
return users;
}
@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);

View file

@ -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 {

View file

@ -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()),
);
}
}
}

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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,

View file

@ -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';
@ -248,6 +249,21 @@ export class NoteEntityService implements OnModuleInit {
return true;
}
@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,
@ -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,
},
})));
}

View file

@ -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,
},
})));
}

View file

@ -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,
});
}

View file

@ -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>(

View file

@ -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}`;
}

View file

@ -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);

View file

@ -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;
}

View file

@ -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

View file

@ -39,4 +39,10 @@ export class RoleAssignment {
})
@JoinColumn()
public role: Role | null;
@Index()
@Column('timestamp with time zone', {
nullable: true,
})
public expiresAt: Date | null;
}

View file

@ -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();
}

View file

@ -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);
@ -51,15 +53,6 @@ export class FileServerService {
//this.createServer = this.createServer.bind(this);
}
@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) => {
@ -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,

View file

@ -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();
}
}

View file

@ -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,
},
});
}

View file

@ -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();
}
}

View file

@ -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; },
};
});

View file

@ -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;

View file

@ -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',
},
},

View file

@ -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);
});
}
}

View file

@ -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);
});
}
}

View file

@ -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,
})));
});
}

View file

@ -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();

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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);
});
});
});

View file

@ -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内にバリデーションを書いても最初の一つしかチェックされない
// See https://github.com/misskey-dev/misskey/pull/10082
text: {
type: 'string',
minLength: 1,
maxLength: MAX_NOTE_TEXT_LENGTH,
nullable: false
},
fileIds: {
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
mediaIds: {
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: 'string', format: 'misskey:id' },
},
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'],
},
},
// (re)note with text, files and poll are optional
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'],
},
{
// (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'],
},
{
// (re)note with files, text and poll are optional
properties: {
mediaIds: {
deprecated: true,
description: 'Use `fileIds` instead. If both are specified, this property is discarded.',
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'],
},
},
required: ['poll'],
},
{
// pure renote
properties: {
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: ['renoteId'],
},
{ required: ['text'] },
{ required: ['renoteId'] },
{ required: ['fileIds'] },
{ required: ['mediaIds'] },
{ required: ['poll'] },
],
} as const;

View file

@ -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 },
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,
},
minItems: 1,
},
},
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,
},
},
required: ['query'],
},
{ required: ['tag'] },
{ required: ['query'] },
],
} as const;

View file

@ -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;
const followees = await this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id })
.getMany();
//#region Construct query
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
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);

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -54,32 +54,22 @@ export const meta = {
export const paramDef = {
type: 'object',
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: [
{
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'],
},
{ required: ['userId'] },
{ required: ['userIds'] },
{ required: ['username'] },
],
} as const;

View file

@ -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> = {

View file

@ -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);

View file

@ -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
});
});
*/

View file

@ -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');
}));
});
});
});

View file

@ -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);

View file

@ -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);
});
});
});
*/

View file

@ -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 () => {

View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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);

View file

@ -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);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -33,11 +33,12 @@
"lib": [
"esnext"
],
"types": ["jest"]
"types": ["jest", "node"]
},
"compileOnSave": false,
"include": [
"./**/*.ts",
"../src/**/*.test.ts",
"../src/@types/**/*.ts",
]
}

View file

@ -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,
});
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);
});
});
});

View file

@ -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');
});
});

View file

@ -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(() => {

View file

@ -26,9 +26,7 @@
"rootDir": "./src",
"baseUrl": "./",
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
},
"outDir": "./built",
"types": [
@ -46,4 +44,7 @@
"include": [
"./src/**/*.ts"
],
"exclude": [
"./src/**/*.test.ts"
]
}