2023-07-27 05:31:52 +00:00
|
|
|
|
/*
|
2024-02-13 15:59:27 +00:00
|
|
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
2023-07-27 05:31:52 +00:00
|
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
*/
|
|
|
|
|
|
2023-03-21 23:59:50 +00:00
|
|
|
|
import * as assert from 'node:assert';
|
2023-03-03 02:13:12 +00:00
|
|
|
|
import { readFile } from 'node:fs/promises';
|
2024-01-08 08:43:52 +00:00
|
|
|
|
import { basename, isAbsolute } from 'node:path';
|
2023-12-27 06:08:59 +00:00
|
|
|
|
import { randomUUID } from 'node:crypto';
|
2023-03-19 11:26:38 +00:00
|
|
|
|
import { inspect } from 'node:util';
|
2023-06-28 04:37:13 +00:00
|
|
|
|
import WebSocket, { ClientOptions } from 'ws';
|
2024-03-03 11:15:35 +00:00
|
|
|
|
import fetch, { File, RequestInit, type Headers } from 'node-fetch';
|
2023-03-03 02:13:12 +00:00
|
|
|
|
import { DataSource } from 'typeorm';
|
2023-03-18 00:01:10 +00:00
|
|
|
|
import { JSDOM } from 'jsdom';
|
2024-07-29 12:31:32 +00:00
|
|
|
|
import { type Response } from 'node-fetch';
|
|
|
|
|
import Fastify from 'fastify';
|
2023-03-03 02:13:12 +00:00
|
|
|
|
import { entities } from '../src/postgres.js';
|
|
|
|
|
import { loadConfig } from '../src/config.js';
|
|
|
|
|
import type * as misskey from 'misskey-js';
|
2024-07-29 12:31:32 +00:00
|
|
|
|
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
|
|
|
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
|
|
|
|
import { ApiError } from '@/server/api/error.js';
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
2024-01-07 01:35:58 +00:00
|
|
|
|
export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js';
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export interface UserToken {
|
2023-06-28 04:37:13 +00:00
|
|
|
|
token: string;
|
|
|
|
|
bearer?: boolean;
|
|
|
|
|
}
|
2023-06-26 23:07:20 +00:00
|
|
|
|
|
2024-07-29 12:31:32 +00:00
|
|
|
|
export type SystemWebhookPayload = {
|
|
|
|
|
server: string;
|
|
|
|
|
hookId: string;
|
|
|
|
|
eventId: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
type: string;
|
|
|
|
|
body: any;
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-03 02:13:12 +00:00
|
|
|
|
const config = loadConfig();
|
|
|
|
|
export const port = config.port;
|
2023-12-27 06:10:24 +00:00
|
|
|
|
export const origin = config.url;
|
|
|
|
|
export const host = new URL(config.url).host;
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
2024-07-29 12:31:32 +00:00
|
|
|
|
export const WEBHOOK_HOST = 'http://localhost:15080';
|
|
|
|
|
export const WEBHOOK_PORT = 15080;
|
|
|
|
|
|
2023-06-26 23:07:20 +00:00
|
|
|
|
export const cookie = (me: UserToken): string => {
|
2023-03-18 00:01:10 +00:00
|
|
|
|
return `token=${me.token};`;
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export type ApiRequest<E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req'] = misskey.Endpoints[E]['req']> = {
|
|
|
|
|
endpoint: E,
|
|
|
|
|
parameters: P,
|
2023-06-26 23:07:20 +00:00
|
|
|
|
user: UserToken | undefined,
|
2023-03-19 11:26:38 +00:00
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const successfulApiCall = async <E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req']>(request: ApiRequest<E, P>, assertion: {
|
2023-04-12 04:20:16 +00:00
|
|
|
|
status?: number,
|
2024-03-03 11:15:35 +00:00
|
|
|
|
} = {}): Promise<misskey.api.SwitchCaseResponseType<E, P>> => {
|
2023-03-19 11:26:38 +00:00
|
|
|
|
const { endpoint, parameters, user } = request;
|
|
|
|
|
const res = await api(endpoint, parameters, user);
|
2023-04-12 04:20:16 +00:00
|
|
|
|
const status = assertion.status ?? (res.body == null ? 204 : 200);
|
|
|
|
|
assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true }));
|
2024-07-14 00:33:16 +00:00
|
|
|
|
|
|
|
|
|
return res.body as misskey.api.SwitchCaseResponseType<E, P>;
|
2023-03-19 11:26:38 +00:00
|
|
|
|
};
|
|
|
|
|
|
2024-07-14 00:33:16 +00:00
|
|
|
|
export const failedApiCall = async <E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req']>(request: ApiRequest<E, P>, assertion: {
|
2023-03-19 11:26:38 +00:00
|
|
|
|
status: number,
|
|
|
|
|
code: string,
|
|
|
|
|
id: string
|
2024-07-14 00:33:16 +00:00
|
|
|
|
}): Promise<void> => {
|
2023-03-19 11:26:38 +00:00
|
|
|
|
const { endpoint, parameters, user } = request;
|
|
|
|
|
const { status, code, id } = assertion;
|
|
|
|
|
const res = await api(endpoint, parameters, user);
|
|
|
|
|
assert.strictEqual(res.status, status, inspect(res.body));
|
2024-07-14 00:33:16 +00:00
|
|
|
|
assert.ok(res.body);
|
|
|
|
|
assert.strictEqual(castAsError(res.body as any).error.code, code, inspect(res.body));
|
|
|
|
|
assert.strictEqual(castAsError(res.body as any).error.id, id, inspect(res.body));
|
2023-03-19 11:26:38 +00:00
|
|
|
|
};
|
|
|
|
|
|
2024-07-14 00:33:16 +00:00
|
|
|
|
export const api = async <E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req']>(path: E, params: P, me?: UserToken): Promise<{
|
2024-01-08 08:43:52 +00:00
|
|
|
|
status: number,
|
|
|
|
|
headers: Headers,
|
2024-07-14 00:33:16 +00:00
|
|
|
|
body: misskey.api.SwitchCaseResponseType<E, P>
|
2024-01-08 08:43:52 +00:00
|
|
|
|
}> => {
|
2023-06-28 04:37:13 +00:00
|
|
|
|
const bodyAuth: Record<string, string> = {};
|
|
|
|
|
const headers: Record<string, string> = {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (me?.bearer) {
|
|
|
|
|
headers.Authorization = `Bearer ${me.token}`;
|
|
|
|
|
} else if (me) {
|
|
|
|
|
bodyAuth.i = me.token;
|
|
|
|
|
}
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
const res = await relativeFetch(`api/${path}`, {
|
2023-03-03 02:13:12 +00:00
|
|
|
|
method: 'POST',
|
2023-06-28 04:37:13 +00:00
|
|
|
|
headers,
|
|
|
|
|
body: JSON.stringify(Object.assign(bodyAuth, params)),
|
2023-03-03 02:13:12 +00:00
|
|
|
|
redirect: 'manual',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
|
2024-07-14 00:33:16 +00:00
|
|
|
|
? await res.json() as misskey.api.SwitchCaseResponseType<E, P>
|
2023-03-03 02:13:12 +00:00
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
return {
|
2023-06-28 04:37:13 +00:00
|
|
|
|
status: res.status,
|
|
|
|
|
headers: res.headers,
|
2024-07-14 00:33:16 +00:00
|
|
|
|
// FIXME: removing this non-null assertion: requires better typing around empty response.
|
|
|
|
|
body: body!,
|
2023-03-03 02:13:12 +00:00
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2023-07-27 09:51:58 +00:00
|
|
|
|
export const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
|
2023-03-03 02:13:12 +00:00
|
|
|
|
return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
|
|
|
|
|
};
|
|
|
|
|
|
2023-10-04 02:32:33 +00:00
|
|
|
|
export function randomString(chars = 'abcdefghijklmnopqrstuvwxyz0123456789', length = 16) {
|
2023-10-03 11:26:11 +00:00
|
|
|
|
let randomString = '';
|
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
|
|
|
randomString += chars[Math.floor(Math.random() * chars.length)];
|
|
|
|
|
}
|
|
|
|
|
return randomString;
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-14 23:19:27 +00:00
|
|
|
|
/**
|
|
|
|
|
* @brief プロミスにタイムアウト追加
|
|
|
|
|
* @param p 待ち対象プロミス
|
|
|
|
|
* @param timeout 待機ミリ秒
|
|
|
|
|
*/
|
|
|
|
|
function timeoutPromise<T>(p: Promise<T>, timeout: number): Promise<T> {
|
|
|
|
|
return Promise.race([
|
|
|
|
|
p,
|
2024-02-17 03:41:19 +00:00
|
|
|
|
new Promise((reject) => {
|
|
|
|
|
setTimeout(() => { reject(new Error('timed out')); }, timeout);
|
|
|
|
|
}) as never,
|
2024-01-14 23:19:27 +00:00
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-24 23:34:18 +00:00
|
|
|
|
export const signup = async (params?: Partial<misskey.Endpoints['signup']['req']>): Promise<NonNullable<misskey.Endpoints['signup']['res']>> => {
|
2023-03-03 02:13:12 +00:00
|
|
|
|
const q = Object.assign({
|
2023-10-03 11:26:11 +00:00
|
|
|
|
username: randomString(),
|
2023-03-03 02:13:12 +00:00
|
|
|
|
password: 'test',
|
|
|
|
|
}, params);
|
|
|
|
|
|
|
|
|
|
const res = await api('signup', q);
|
|
|
|
|
|
|
|
|
|
return res.body;
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const post = async (user: UserToken, params: misskey.Endpoints['notes/create']['req']): Promise<misskey.entities.Note> => {
|
2023-03-07 23:56:09 +00:00
|
|
|
|
const q = params;
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
|
|
|
|
const res = await api('notes/create', q, user);
|
|
|
|
|
|
2024-07-14 00:33:16 +00:00
|
|
|
|
// FIXME: the return type should reflect this fact.
|
|
|
|
|
return (res.body ? res.body.createdNote : null)!;
|
2023-03-03 02:13:12 +00:00
|
|
|
|
};
|
|
|
|
|
|
2023-12-27 06:08:59 +00:00
|
|
|
|
export const createAppToken = async (user: UserToken, permissions: (typeof misskey.permissions)[number][]) => {
|
|
|
|
|
const res = await api('miauth/gen-token', {
|
|
|
|
|
session: randomUUID(),
|
|
|
|
|
permission: permissions,
|
|
|
|
|
}, user);
|
|
|
|
|
|
|
|
|
|
return (res.body as misskey.entities.MiauthGenTokenResponse).token;
|
|
|
|
|
};
|
|
|
|
|
|
2023-03-19 11:26:38 +00:00
|
|
|
|
// 非公開ノートをAPI越しに見たときのノート NoteEntityService.ts
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const hiddenNote = (note: misskey.entities.Note): misskey.entities.Note => {
|
|
|
|
|
const temp: misskey.entities.Note = {
|
2023-03-19 11:26:38 +00:00
|
|
|
|
...note,
|
|
|
|
|
fileIds: [],
|
|
|
|
|
files: [],
|
|
|
|
|
text: null,
|
|
|
|
|
cw: null,
|
|
|
|
|
isHidden: true,
|
|
|
|
|
};
|
|
|
|
|
delete temp.visibleUserIds;
|
|
|
|
|
delete temp.poll;
|
|
|
|
|
return temp;
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const react = async (user: UserToken, note: misskey.entities.Note, reaction: string): Promise<void> => {
|
2023-03-03 02:13:12 +00:00
|
|
|
|
await api('notes/reactions/create', {
|
|
|
|
|
noteId: note.id,
|
|
|
|
|
reaction: reaction,
|
|
|
|
|
}, user);
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const userList = async (user: UserToken, userList: Partial<misskey.entities.UserList> = {}): Promise<misskey.entities.UserList> => {
|
2023-05-19 11:53:20 +00:00
|
|
|
|
const res = await api('users/lists/create', {
|
|
|
|
|
name: 'test',
|
2024-03-03 11:15:35 +00:00
|
|
|
|
...userList,
|
2023-05-19 11:53:20 +00:00
|
|
|
|
}, user);
|
|
|
|
|
return res.body;
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const page = async (user: UserToken, page: Partial<misskey.entities.Page> = {}): Promise<misskey.entities.Page> => {
|
2023-03-18 00:01:10 +00:00
|
|
|
|
const res = await api('pages/create', {
|
|
|
|
|
alignCenter: false,
|
|
|
|
|
content: [
|
|
|
|
|
{
|
|
|
|
|
id: '2be9a64b-5ada-43a3-85f3-ec3429551ded',
|
|
|
|
|
text: 'Hello World!',
|
|
|
|
|
type: 'text',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
eyeCatchingImageId: null,
|
2024-03-03 11:15:35 +00:00
|
|
|
|
font: 'sans-serif' as any,
|
2023-03-18 00:01:10 +00:00
|
|
|
|
hideTitleWhenPinned: false,
|
|
|
|
|
name: '1678594845072',
|
|
|
|
|
script: '',
|
|
|
|
|
summary: null,
|
|
|
|
|
title: '',
|
|
|
|
|
variables: [],
|
|
|
|
|
...page,
|
|
|
|
|
}, user);
|
|
|
|
|
return res.body;
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const play = async (user: UserToken, play: Partial<misskey.entities.Flash> = {}): Promise<misskey.entities.Flash> => {
|
2023-03-18 00:01:10 +00:00
|
|
|
|
const res = await api('flash/create', {
|
|
|
|
|
permissions: [],
|
|
|
|
|
script: 'test',
|
|
|
|
|
summary: '',
|
|
|
|
|
title: 'test',
|
|
|
|
|
...play,
|
|
|
|
|
}, user);
|
|
|
|
|
return res.body;
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const clip = async (user: UserToken, clip: Partial<misskey.entities.Clip> = {}): Promise<misskey.entities.Clip> => {
|
2023-03-18 00:01:10 +00:00
|
|
|
|
const res = await api('clips/create', {
|
|
|
|
|
description: null,
|
|
|
|
|
isPublic: true,
|
|
|
|
|
name: 'test',
|
|
|
|
|
...clip,
|
|
|
|
|
}, user);
|
|
|
|
|
return res.body;
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const galleryPost = async (user: UserToken, galleryPost: Partial<misskey.entities.GalleryPost> = {}): Promise<misskey.entities.GalleryPost> => {
|
2023-03-18 00:01:10 +00:00
|
|
|
|
const res = await api('gallery/posts/create', {
|
|
|
|
|
description: null,
|
|
|
|
|
fileIds: [],
|
|
|
|
|
isSensitive: false,
|
|
|
|
|
title: 'test',
|
2024-03-03 11:15:35 +00:00
|
|
|
|
...galleryPost,
|
2023-03-18 00:01:10 +00:00
|
|
|
|
}, user);
|
|
|
|
|
return res.body;
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const channel = async (user: UserToken, channel: Partial<misskey.entities.Channel> = {}): Promise<misskey.entities.Channel> => {
|
2023-03-18 00:01:10 +00:00
|
|
|
|
const res = await api('channels/create', {
|
|
|
|
|
bannerId: null,
|
|
|
|
|
description: null,
|
|
|
|
|
name: 'test',
|
|
|
|
|
...channel,
|
|
|
|
|
}, user);
|
|
|
|
|
return res.body;
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const role = async (user: UserToken, role: Partial<misskey.entities.Role> = {}, policies: any = {}): Promise<misskey.entities.Role> => {
|
2023-04-12 04:20:16 +00:00
|
|
|
|
const res = await api('admin/roles/create', {
|
|
|
|
|
asBadge: false,
|
|
|
|
|
canEditMembersByModerator: false,
|
|
|
|
|
color: null,
|
|
|
|
|
condFormula: {
|
|
|
|
|
id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85',
|
|
|
|
|
type: 'isRemote',
|
2024-03-03 11:15:35 +00:00
|
|
|
|
} as any,
|
2023-04-12 04:20:16 +00:00
|
|
|
|
description: '',
|
|
|
|
|
displayOrder: 0,
|
|
|
|
|
iconUrl: null,
|
|
|
|
|
isAdministrator: false,
|
|
|
|
|
isModerator: false,
|
|
|
|
|
isPublic: false,
|
|
|
|
|
name: 'New Role',
|
|
|
|
|
target: 'manual',
|
2023-06-24 23:34:18 +00:00
|
|
|
|
policies: {
|
|
|
|
|
...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, {
|
2023-04-12 04:20:16 +00:00
|
|
|
|
priority: 0,
|
|
|
|
|
useDefault: true,
|
|
|
|
|
value: v,
|
|
|
|
|
}]),
|
|
|
|
|
...policies,
|
|
|
|
|
},
|
|
|
|
|
...role,
|
|
|
|
|
}, user);
|
|
|
|
|
return res.body;
|
|
|
|
|
};
|
|
|
|
|
|
2023-03-03 02:13:12 +00:00
|
|
|
|
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
|
|
|
|
|
*/
|
2024-01-08 08:43:52 +00:00
|
|
|
|
export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadOptions = {}): Promise<{
|
|
|
|
|
status: number,
|
|
|
|
|
headers: Headers,
|
2024-03-03 11:15:35 +00:00
|
|
|
|
body: misskey.entities.DriveFile | null
|
2024-01-08 08:43:52 +00:00
|
|
|
|
}> => {
|
2023-03-03 02:13:12 +00:00
|
|
|
|
const absPath = path == null
|
2024-07-02 05:29:44 +00:00
|
|
|
|
? new URL('resources/192.jpg', import.meta.url)
|
2023-03-03 02:13:12 +00:00
|
|
|
|
: isAbsolute(path.toString())
|
|
|
|
|
? new URL(path)
|
|
|
|
|
: new URL(path, new URL('resources/', import.meta.url));
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', blob ??
|
|
|
|
|
new File([await readFile(absPath)], basename(absPath.toString())));
|
|
|
|
|
formData.append('force', 'true');
|
|
|
|
|
if (name) {
|
|
|
|
|
formData.append('name', name);
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-28 04:37:13 +00:00
|
|
|
|
const headers: Record<string, string> = {};
|
|
|
|
|
if (user?.bearer) {
|
|
|
|
|
headers.Authorization = `Bearer ${user.token}`;
|
|
|
|
|
} else if (user) {
|
|
|
|
|
formData.append('i', user.token);
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-03 02:13:12 +00:00
|
|
|
|
const res = await relativeFetch('api/drive/files/create', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: formData,
|
2023-06-28 04:37:13 +00:00
|
|
|
|
headers,
|
2023-03-03 02:13:12 +00:00
|
|
|
|
});
|
|
|
|
|
|
2023-06-28 04:37:13 +00:00
|
|
|
|
const body = res.status !== 204 ? await res.json() as misskey.Endpoints['drive/files/create']['res'] : null;
|
2023-03-03 02:13:12 +00:00
|
|
|
|
return {
|
|
|
|
|
status: res.status,
|
2023-06-28 04:37:13 +00:00
|
|
|
|
headers: res.headers,
|
2023-03-03 02:13:12 +00:00
|
|
|
|
body,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-03 11:15:35 +00:00
|
|
|
|
export const uploadUrl = async (user: UserToken, url: string): Promise<misskey.entities.DriveFile> => {
|
2023-03-03 02:13:12 +00:00
|
|
|
|
const marker = Math.random().toString();
|
|
|
|
|
|
2024-01-14 23:19:27 +00:00
|
|
|
|
const catcher = makeStreamCatcher(
|
|
|
|
|
user,
|
|
|
|
|
'main',
|
|
|
|
|
(msg) => msg.type === 'urlUploadFinished' && msg.body.marker === marker,
|
2024-03-03 11:15:35 +00:00
|
|
|
|
(msg) => msg.body.file,
|
2024-02-17 03:41:19 +00:00
|
|
|
|
60 * 1000,
|
2024-01-14 23:19:27 +00:00
|
|
|
|
);
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
|
|
|
|
await api('drive/files/upload-from-url', {
|
|
|
|
|
url,
|
|
|
|
|
marker,
|
|
|
|
|
force: true,
|
|
|
|
|
}, user);
|
|
|
|
|
|
2024-01-14 23:19:27 +00:00
|
|
|
|
return catcher;
|
2023-03-03 02:13:12 +00:00
|
|
|
|
};
|
|
|
|
|
|
2024-02-28 08:43:17 +00:00
|
|
|
|
export function connectStream<C extends keyof misskey.Channels>(user: UserToken, channel: C, listener: (message: Record<string, any>) => any, params?: misskey.Channels[C]['params']): Promise<WebSocket> {
|
2023-03-03 02:13:12 +00:00
|
|
|
|
return new Promise((res, rej) => {
|
2023-06-28 04:37:13 +00:00
|
|
|
|
const url = new URL(`ws://127.0.0.1:${port}/streaming`);
|
|
|
|
|
const options: ClientOptions = {};
|
|
|
|
|
if (user.bearer) {
|
|
|
|
|
options.headers = { Authorization: `Bearer ${user.token}` };
|
|
|
|
|
} else {
|
|
|
|
|
url.searchParams.set('i', user.token);
|
|
|
|
|
}
|
|
|
|
|
const ws = new WebSocket(url, options);
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
2023-06-28 04:37:13 +00:00
|
|
|
|
ws.on('unexpected-response', (req, res) => rej(res));
|
2023-03-03 02:13:12 +00:00
|
|
|
|
ws.on('open', () => {
|
|
|
|
|
ws.on('message', data => {
|
|
|
|
|
const msg = JSON.parse(data.toString());
|
|
|
|
|
if (msg.type === 'channel' && msg.body.id === 'a') {
|
|
|
|
|
listener(msg.body);
|
|
|
|
|
} else if (msg.type === 'connected' && msg.body.id === 'a') {
|
|
|
|
|
res(ws);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
ws.send(JSON.stringify({
|
|
|
|
|
type: 'connect',
|
|
|
|
|
body: {
|
|
|
|
|
channel: channel,
|
|
|
|
|
id: 'a',
|
|
|
|
|
pong: true,
|
|
|
|
|
params: params,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2024-02-28 08:43:17 +00:00
|
|
|
|
export const waitFire = async <C extends keyof misskey.Channels>(user: UserToken, channel: C, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: misskey.Channels[C]['params']) => {
|
2023-03-03 02:13:12 +00:00
|
|
|
|
return new Promise<boolean>(async (res, rej) => {
|
|
|
|
|
let timer: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
|
|
|
let ws: WebSocket;
|
|
|
|
|
try {
|
|
|
|
|
ws = await connectStream(user, channel, msg => {
|
|
|
|
|
if (cond(msg)) {
|
|
|
|
|
ws.close();
|
|
|
|
|
if (timer) clearTimeout(timer);
|
|
|
|
|
res(true);
|
|
|
|
|
}
|
|
|
|
|
}, params);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
rej(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!ws!) return;
|
|
|
|
|
|
|
|
|
|
timer = setTimeout(() => {
|
|
|
|
|
ws.close();
|
|
|
|
|
res(false);
|
2023-04-07 11:05:15 +00:00
|
|
|
|
}, 3000);
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await trgr();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ws.close();
|
|
|
|
|
if (timer) clearTimeout(timer);
|
|
|
|
|
rej(e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2024-01-14 23:19:27 +00:00
|
|
|
|
/**
|
|
|
|
|
* @brief WebSocketストリームから特定条件の通知を拾うプロミスを生成
|
|
|
|
|
* @param user ユーザー認証情報
|
|
|
|
|
* @param channel チャンネル
|
|
|
|
|
* @param cond 条件
|
|
|
|
|
* @param extractor 取り出し処理
|
|
|
|
|
* @param timeout ミリ秒タイムアウト
|
|
|
|
|
* @returns 時間内に正常に処理できた場合に通知からextractorを通した値を得る
|
|
|
|
|
*/
|
|
|
|
|
export function makeStreamCatcher<T>(
|
2024-02-17 03:41:19 +00:00
|
|
|
|
user: UserToken,
|
2024-02-28 08:43:17 +00:00
|
|
|
|
channel: keyof misskey.Channels,
|
2024-02-17 03:41:19 +00:00
|
|
|
|
cond: (message: Record<string, any>) => boolean,
|
|
|
|
|
extractor: (message: Record<string, any>) => T,
|
|
|
|
|
timeout = 60 * 1000): Promise<T> {
|
|
|
|
|
let ws: WebSocket;
|
2024-01-14 23:19:27 +00:00
|
|
|
|
const p = new Promise<T>(async (resolve) => {
|
|
|
|
|
ws = await connectStream(user, channel, (msg) => {
|
|
|
|
|
if (cond(msg)) {
|
2024-02-17 03:41:19 +00:00
|
|
|
|
resolve(extractor(msg));
|
2024-01-14 23:19:27 +00:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}).finally(() => {
|
2024-02-17 03:41:19 +00:00
|
|
|
|
ws.close();
|
2024-01-14 23:19:27 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return timeoutPromise(p, timeout);
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-24 23:34:18 +00:00
|
|
|
|
export type SimpleGetResponse = {
|
|
|
|
|
status: number,
|
|
|
|
|
body: any | JSDOM | null,
|
|
|
|
|
type: string | null,
|
|
|
|
|
location: string | null
|
2023-03-18 00:01:10 +00:00
|
|
|
|
};
|
2024-06-22 03:51:02 +00:00
|
|
|
|
export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined, bodyExtractor: (res: Response) => Promise<string | null> = _ => Promise.resolve(null)): Promise<SimpleGetResponse> => {
|
2023-03-03 02:13:12 +00:00
|
|
|
|
const res = await relativeFetch(path, {
|
|
|
|
|
headers: {
|
|
|
|
|
Accept: accept,
|
2023-03-18 00:01:10 +00:00
|
|
|
|
Cookie: cookie,
|
2023-03-03 02:13:12 +00:00
|
|
|
|
},
|
|
|
|
|
redirect: 'manual',
|
|
|
|
|
});
|
|
|
|
|
|
2023-03-10 00:37:22 +00:00
|
|
|
|
const jsonTypes = [
|
|
|
|
|
'application/json; charset=utf-8',
|
|
|
|
|
'application/activity+json; charset=utf-8',
|
|
|
|
|
];
|
2023-03-18 00:01:10 +00:00
|
|
|
|
const htmlTypes = [
|
|
|
|
|
'text/html; charset=utf-8',
|
|
|
|
|
];
|
2023-03-10 00:37:22 +00:00
|
|
|
|
|
2024-02-17 03:41:19 +00:00
|
|
|
|
if (res.ok && (
|
|
|
|
|
accept.startsWith('application/activity+json') ||
|
|
|
|
|
(accept.startsWith('application/ld+json') && accept.includes('https://www.w3.org/ns/activitystreams'))
|
|
|
|
|
)) {
|
|
|
|
|
// validateContentTypeSetAsActivityPubのテストを兼ねる
|
|
|
|
|
validateContentTypeSetAsActivityPub(res);
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-24 23:34:18 +00:00
|
|
|
|
const body =
|
2024-01-08 08:43:52 +00:00
|
|
|
|
jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() :
|
|
|
|
|
htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) :
|
2024-06-22 03:51:02 +00:00
|
|
|
|
await bodyExtractor(res);
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: res.status,
|
|
|
|
|
body,
|
|
|
|
|
type: res.headers.get('content-type'),
|
|
|
|
|
location: res.headers.get('location'),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2023-05-19 11:53:20 +00:00
|
|
|
|
/**
|
|
|
|
|
* あるAPIエンドポイントのPaginationが複数の条件で一貫した挙動であることをテストします。
|
|
|
|
|
* (sinceId, untilId, sinceDate, untilDate, offset, limit)
|
|
|
|
|
* @param expected 期待値となるEntityの並び(例:Note[])昇順降順が一致している必要がある
|
|
|
|
|
* @param fetchEntities Entity[]を返却するテスト対象のAPIを呼び出す関数
|
|
|
|
|
* @param offsetBy 何をキーとしてPaginationするか。
|
|
|
|
|
* @param ordering 昇順・降順
|
|
|
|
|
*/
|
|
|
|
|
export async function testPaginationConsistency<Entity extends { id: string, createdAt?: string }>(
|
|
|
|
|
expected: Entity[],
|
|
|
|
|
fetchEntities: (paginationParam: {
|
|
|
|
|
limit?: number,
|
|
|
|
|
offset?: number,
|
|
|
|
|
sinceId?: string,
|
|
|
|
|
untilId?: string,
|
|
|
|
|
sinceDate?: number,
|
|
|
|
|
untilDate?: number,
|
|
|
|
|
}) => Promise<Entity[]>,
|
|
|
|
|
offsetBy: 'offset' | 'id' | 'createdAt' = 'id',
|
|
|
|
|
ordering: 'desc' | 'asc' = 'desc'): Promise<void> {
|
|
|
|
|
const rangeToParam = (p: { limit?: number, until?: Entity, since?: Entity }): object => {
|
|
|
|
|
if (offsetBy === 'id') {
|
|
|
|
|
return { limit: p.limit, sinceId: p.since?.id, untilId: p.until?.id };
|
|
|
|
|
} else {
|
|
|
|
|
const sinceDate = p.since?.createdAt !== undefined ? new Date(p.since.createdAt).getTime() : undefined;
|
|
|
|
|
const untilDate = p.until?.createdAt !== undefined ? new Date(p.until.createdAt).getTime() : undefined;
|
|
|
|
|
return { limit: p.limit, sinceDate, untilDate };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const limit of [1, 5, 10, 100, undefined]) {
|
2023-10-10 00:45:40 +00:00
|
|
|
|
/*
|
2023-05-19 11:53:20 +00:00
|
|
|
|
// 1. sinceId/DateとuntilId/Dateで両端を指定して取得した結果が期待通りになっていること
|
|
|
|
|
if (ordering === 'desc') {
|
2023-07-14 01:45:01 +00:00
|
|
|
|
const end = expected.at(-1)!;
|
2023-05-19 11:53:20 +00:00
|
|
|
|
let last = await fetchEntities(rangeToParam({ limit, since: end }));
|
|
|
|
|
const actual: Entity[] = [];
|
|
|
|
|
while (last.length !== 0) {
|
|
|
|
|
actual.push(...last);
|
2023-07-14 01:45:01 +00:00
|
|
|
|
last = await fetchEntities(rangeToParam({ limit, until: last.at(-1), since: end }));
|
2023-05-19 11:53:20 +00:00
|
|
|
|
}
|
|
|
|
|
actual.push(end);
|
|
|
|
|
assert.deepStrictEqual(
|
|
|
|
|
actual.map(({ id, createdAt }) => id + ':' + createdAt),
|
|
|
|
|
expected.map(({ id, createdAt }) => id + ':' + createdAt));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. sinceId/Date指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
|
|
|
|
|
if (ordering === 'asc') {
|
|
|
|
|
// 昇順にしたときの先頭(一番古いもの)をもってくる(expected[1]を基準に降順にして0番目)
|
|
|
|
|
let last = await fetchEntities({ limit: 1, untilId: expected[1].id });
|
|
|
|
|
const actual: Entity[] = [];
|
|
|
|
|
while (last.length !== 0) {
|
|
|
|
|
actual.push(...last);
|
2023-07-14 01:45:01 +00:00
|
|
|
|
last = await fetchEntities(rangeToParam({ limit, since: last.at(-1) }));
|
2023-05-19 11:53:20 +00:00
|
|
|
|
}
|
|
|
|
|
assert.deepStrictEqual(
|
|
|
|
|
actual.map(({ id, createdAt }) => id + ':' + createdAt),
|
|
|
|
|
expected.map(({ id, createdAt }) => id + ':' + createdAt));
|
|
|
|
|
}
|
2023-10-10 00:45:40 +00:00
|
|
|
|
*/
|
2023-05-19 11:53:20 +00:00
|
|
|
|
|
|
|
|
|
// 3. untilId指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
|
|
|
|
|
if (ordering === 'desc') {
|
|
|
|
|
let last = await fetchEntities({ limit });
|
|
|
|
|
const actual: Entity[] = [];
|
|
|
|
|
while (last.length !== 0) {
|
|
|
|
|
actual.push(...last);
|
2023-07-14 01:45:01 +00:00
|
|
|
|
last = await fetchEntities(rangeToParam({ limit, until: last.at(-1) }));
|
2023-05-19 11:53:20 +00:00
|
|
|
|
}
|
|
|
|
|
assert.deepStrictEqual(
|
|
|
|
|
actual.map(({ id, createdAt }) => id + ':' + createdAt),
|
|
|
|
|
expected.map(({ id, createdAt }) => id + ':' + createdAt));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. offset指定+limitで取得してつなぎ合わせた結果が期待通りになっていること
|
|
|
|
|
if (offsetBy === 'offset') {
|
|
|
|
|
let last = await fetchEntities({ limit, offset: 0 });
|
|
|
|
|
let offset = limit ?? 10;
|
|
|
|
|
const actual: Entity[] = [];
|
|
|
|
|
while (last.length !== 0) {
|
|
|
|
|
actual.push(...last);
|
|
|
|
|
last = await fetchEntities({ limit, offset });
|
|
|
|
|
offset += limit ?? 10;
|
|
|
|
|
}
|
|
|
|
|
assert.deepStrictEqual(
|
|
|
|
|
actual.map(({ id, createdAt }) => id + ':' + createdAt),
|
|
|
|
|
expected.map(({ id, createdAt }) => id + ':' + createdAt));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-03-03 02:13:12 +00:00
|
|
|
|
export async function initTestDb(justBorrow = false, initEntities?: any[]) {
|
2023-05-29 02:54:49 +00:00
|
|
|
|
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test');
|
2023-03-03 02:13:12 +00:00
|
|
|
|
|
|
|
|
|
const db = new DataSource({
|
|
|
|
|
type: 'postgres',
|
|
|
|
|
host: config.db.host,
|
|
|
|
|
port: config.db.port,
|
|
|
|
|
username: config.db.user,
|
|
|
|
|
password: config.db.pass,
|
|
|
|
|
database: config.db.db,
|
2024-11-23 17:51:33 +00:00
|
|
|
|
synchronize: !justBorrow,
|
|
|
|
|
dropSchema: !justBorrow,
|
2023-03-03 02:13:12 +00:00
|
|
|
|
entities: initEntities ?? entities,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await db.initialize();
|
|
|
|
|
|
|
|
|
|
return db;
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-08 08:43:52 +00:00
|
|
|
|
export async function sendEnvUpdateRequest(params: { key: string, value?: string }) {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`http://localhost:${port + 1000}/env`,
|
|
|
|
|
{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify(params),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (res.status !== 200) {
|
|
|
|
|
throw new Error('server env update failed.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function sendEnvResetRequest() {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`http://localhost:${port + 1000}/env-reset`,
|
|
|
|
|
{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({}),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (res.status !== 200) {
|
|
|
|
|
throw new Error('server env update failed.');
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-07-14 00:33:16 +00:00
|
|
|
|
|
|
|
|
|
// 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。
|
|
|
|
|
// FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する
|
|
|
|
|
export function castAsError(obj: Record<string, unknown>): { error: ApiError } {
|
|
|
|
|
return obj as { error: ApiError };
|
|
|
|
|
}
|
2024-07-29 12:31:32 +00:00
|
|
|
|
|
|
|
|
|
export async function captureWebhook<T = SystemWebhookPayload>(postAction: () => Promise<void>, port = WEBHOOK_PORT): Promise<T> {
|
|
|
|
|
const fastify = Fastify();
|
|
|
|
|
|
|
|
|
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
|
|
|
const result = await new Promise<string>(async (resolve, reject) => {
|
|
|
|
|
fastify.all('/', async (req, res) => {
|
|
|
|
|
timeoutHandle && clearTimeout(timeoutHandle);
|
|
|
|
|
|
|
|
|
|
const body = JSON.stringify(req.body);
|
|
|
|
|
res.status(200).send('ok');
|
|
|
|
|
await fastify.close();
|
|
|
|
|
resolve(body);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await fastify.listen({ port });
|
|
|
|
|
|
|
|
|
|
timeoutHandle = setTimeout(async () => {
|
|
|
|
|
await fastify.close();
|
|
|
|
|
reject(new Error('timeout'));
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await postAction();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
await fastify.close();
|
|
|
|
|
reject(e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await fastify.close();
|
|
|
|
|
|
|
|
|
|
return JSON.parse(result) as T;
|
|
|
|
|
}
|