summaryrefslogtreecommitdiff
path: root/packages/backend/src/misc
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
commit0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch)
tree40874799472fa07416f17b50a398ac33b7771905 /packages/backend/src/misc
parentupdate deps (diff)
downloadsharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip
refactoring
Resolve #7779
Diffstat (limited to 'packages/backend/src/misc')
-rw-r--r--packages/backend/src/misc/antenna-cache.ts36
-rw-r--r--packages/backend/src/misc/api-permissions.ts35
-rw-r--r--packages/backend/src/misc/app-lock.ts31
-rw-r--r--packages/backend/src/misc/before-shutdown.ts92
-rw-r--r--packages/backend/src/misc/cache.ts43
-rw-r--r--packages/backend/src/misc/cafy-id.ts32
-rw-r--r--packages/backend/src/misc/captcha.ts56
-rw-r--r--packages/backend/src/misc/check-hit-antenna.ts90
-rw-r--r--packages/backend/src/misc/check-word-mute.ts39
-rw-r--r--packages/backend/src/misc/content-disposition.ts6
-rw-r--r--packages/backend/src/misc/convert-host.ts26
-rw-r--r--packages/backend/src/misc/count-same-renotes.ts15
-rw-r--r--packages/backend/src/misc/create-temp.ts10
-rw-r--r--packages/backend/src/misc/detect-url-mime.ts15
-rw-r--r--packages/backend/src/misc/download-text-file.ts25
-rw-r--r--packages/backend/src/misc/download-url.ts87
-rw-r--r--packages/backend/src/misc/emoji-regex.ts3
-rw-r--r--packages/backend/src/misc/extract-custom-emojis-from-mfm.ts10
-rw-r--r--packages/backend/src/misc/extract-hashtags.ts9
-rw-r--r--packages/backend/src/misc/extract-mentions.ts11
-rw-r--r--packages/backend/src/misc/fetch-meta.ts35
-rw-r--r--packages/backend/src/misc/fetch-proxy-account.ts9
-rw-r--r--packages/backend/src/misc/fetch.ts141
-rw-r--r--packages/backend/src/misc/gen-avatar.ts90
-rw-r--r--packages/backend/src/misc/gen-id.ts21
-rw-r--r--packages/backend/src/misc/gen-key-pair.ts36
-rw-r--r--packages/backend/src/misc/get-file-info.ts196
-rw-r--r--packages/backend/src/misc/get-note-summary.ts54
-rw-r--r--packages/backend/src/misc/get-reaction-emoji.ts16
-rw-r--r--packages/backend/src/misc/hard-limits.ts14
-rw-r--r--packages/backend/src/misc/i18n.ts29
-rw-r--r--packages/backend/src/misc/id/aid.ts25
-rw-r--r--packages/backend/src/misc/id/meid.ts26
-rw-r--r--packages/backend/src/misc/id/meidg.ts28
-rw-r--r--packages/backend/src/misc/id/object-id.ts26
-rw-r--r--packages/backend/src/misc/identifiable-error.ts13
-rw-r--r--packages/backend/src/misc/is-blocker-user-related.ts15
-rw-r--r--packages/backend/src/misc/is-duplicate-key-value-error.ts3
-rw-r--r--packages/backend/src/misc/is-muted-user-related.ts15
-rw-r--r--packages/backend/src/misc/is-quote.ts5
-rw-r--r--packages/backend/src/misc/keypair-store.ts10
-rw-r--r--packages/backend/src/misc/normalize-for-search.ts6
-rw-r--r--packages/backend/src/misc/nyaize.ts15
-rw-r--r--packages/backend/src/misc/populate-emojis.ts124
-rw-r--r--packages/backend/src/misc/reaction-lib.ts129
-rw-r--r--packages/backend/src/misc/safe-for-sql.ts3
-rw-r--r--packages/backend/src/misc/schema.ts107
-rw-r--r--packages/backend/src/misc/secure-rndstr.ts21
-rw-r--r--packages/backend/src/misc/show-machine-info.ts13
-rw-r--r--packages/backend/src/misc/simple-schema.ts15
-rw-r--r--packages/backend/src/misc/truncate.ts11
51 files changed, 1922 insertions, 0 deletions
diff --git a/packages/backend/src/misc/antenna-cache.ts b/packages/backend/src/misc/antenna-cache.ts
new file mode 100644
index 0000000000..a23eeb45ec
--- /dev/null
+++ b/packages/backend/src/misc/antenna-cache.ts
@@ -0,0 +1,36 @@
+import { Antennas } from '@/models/index';
+import { Antenna } from '@/models/entities/antenna';
+import { subsdcriber } from '../db/redis';
+
+let antennasFetched = false;
+let antennas: Antenna[] = [];
+
+export async function getAntennas() {
+ if (!antennasFetched) {
+ antennas = await Antennas.find();
+ antennasFetched = true;
+ }
+
+ return antennas;
+}
+
+subsdcriber.on('message', async (_, data) => {
+ const obj = JSON.parse(data);
+
+ if (obj.channel === 'internal') {
+ const { type, body } = obj.message;
+ switch (type) {
+ case 'antennaCreated':
+ antennas.push(body);
+ break;
+ case 'antennaUpdated':
+ antennas[antennas.findIndex(a => a.id === body.id)] = body;
+ break;
+ case 'antennaDeleted':
+ antennas = antennas.filter(a => a.id !== body.id);
+ break;
+ default:
+ break;
+ }
+ }
+});
diff --git a/packages/backend/src/misc/api-permissions.ts b/packages/backend/src/misc/api-permissions.ts
new file mode 100644
index 0000000000..160cdf9fd6
--- /dev/null
+++ b/packages/backend/src/misc/api-permissions.ts
@@ -0,0 +1,35 @@
+export const kinds = [
+ 'read:account',
+ 'write:account',
+ 'read:blocks',
+ 'write:blocks',
+ 'read:drive',
+ 'write:drive',
+ 'read:favorites',
+ 'write:favorites',
+ 'read:following',
+ 'write:following',
+ 'read:messaging',
+ 'write:messaging',
+ 'read:mutes',
+ 'write:mutes',
+ 'write:notes',
+ 'read:notifications',
+ 'write:notifications',
+ 'read:reactions',
+ 'write:reactions',
+ 'write:votes',
+ 'read:pages',
+ 'write:pages',
+ 'write:page-likes',
+ 'read:page-likes',
+ 'read:user-groups',
+ 'write:user-groups',
+ 'read:channels',
+ 'write:channels',
+ 'read:gallery',
+ 'write:gallery',
+ 'read:gallery-likes',
+ 'write:gallery-likes',
+];
+// IF YOU ADD KINDS(PERMISSIONS), YOU MUST ADD TRANSLATIONS (under _permissions).
diff --git a/packages/backend/src/misc/app-lock.ts b/packages/backend/src/misc/app-lock.ts
new file mode 100644
index 0000000000..a32b600612
--- /dev/null
+++ b/packages/backend/src/misc/app-lock.ts
@@ -0,0 +1,31 @@
+import { redisClient } from '../db/redis';
+import { promisify } from 'util';
+import * as redisLock from 'redis-lock';
+
+/**
+ * Retry delay (ms) for lock acquisition
+ */
+const retryDelay = 100;
+
+const lock: (key: string, timeout?: number) => Promise<() => void>
+ = redisClient
+ ? promisify(redisLock(redisClient, retryDelay))
+ : async () => () => { };
+
+/**
+ * Get AP Object lock
+ * @param uri AP object ID
+ * @param timeout Lock timeout (ms), The timeout releases previous lock.
+ * @returns Unlock function
+ */
+export function getApLock(uri: string, timeout = 30 * 1000) {
+ return lock(`ap-object:${uri}`, timeout);
+}
+
+export function getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000) {
+ return lock(`instance:${host}`, timeout);
+}
+
+export function getChartInsertLock(lockKey: string, timeout = 30 * 1000) {
+ return lock(`chart-insert:${lockKey}`, timeout);
+}
diff --git a/packages/backend/src/misc/before-shutdown.ts b/packages/backend/src/misc/before-shutdown.ts
new file mode 100644
index 0000000000..33abf5fb4d
--- /dev/null
+++ b/packages/backend/src/misc/before-shutdown.ts
@@ -0,0 +1,92 @@
+// https://gist.github.com/nfantone/1eaa803772025df69d07f4dbf5df7e58
+
+'use strict';
+
+/**
+ * @callback BeforeShutdownListener
+ * @param {string} [signalOrEvent] The exit signal or event name received on the process.
+ */
+
+/**
+ * System signals the app will listen to initiate shutdown.
+ * @const {string[]}
+ */
+const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM'];
+
+/**
+ * Time in milliseconds to wait before forcing shutdown.
+ * @const {number}
+ */
+const SHUTDOWN_TIMEOUT = 15000;
+
+/**
+ * A queue of listener callbacks to execute before shutting
+ * down the process.
+ * @type {BeforeShutdownListener[]}
+ */
+const shutdownListeners = [];
+
+/**
+ * Listen for signals and execute given `fn` function once.
+ * @param {string[]} signals System signals to listen to.
+ * @param {function(string)} fn Function to execute on shutdown.
+ */
+const processOnce = (signals, fn) => {
+ for (const sig of signals) {
+ process.once(sig, fn);
+ }
+};
+
+/**
+ * Sets a forced shutdown mechanism that will exit the process after `timeout` milliseconds.
+ * @param {number} timeout Time to wait before forcing shutdown (milliseconds)
+ */
+const forceExitAfter = timeout => () => {
+ setTimeout(() => {
+ // Force shutdown after timeout
+ console.warn(`Could not close resources gracefully after ${timeout}ms: forcing shutdown`);
+ return process.exit(1);
+ }, timeout).unref();
+};
+
+/**
+ * Main process shutdown handler. Will invoke every previously registered async shutdown listener
+ * in the queue and exit with a code of `0`. Any `Promise` rejections from any listener will
+ * be logged out as a warning, but won't prevent other callbacks from executing.
+ * @param {string} signalOrEvent The exit signal or event name received on the process.
+ */
+async function shutdownHandler(signalOrEvent) {
+ if (process.env.NODE_ENV === 'test') return process.exit(0);
+
+ console.warn(`Shutting down: received [${signalOrEvent}] signal`);
+
+ for (const listener of shutdownListeners) {
+ try {
+ await listener(signalOrEvent);
+ } catch (err) {
+ console.warn(`A shutdown handler failed before completing with: ${err.message || err}`);
+ }
+ }
+
+ return process.exit(0);
+}
+
+/**
+ * Registers a new shutdown listener to be invoked before exiting
+ * the main process. Listener handlers are guaranteed to be called in the order
+ * they were registered.
+ * @param {BeforeShutdownListener} listener The shutdown listener to register.
+ * @returns {BeforeShutdownListener} Echoes back the supplied `listener`.
+ */
+export function beforeShutdown(listener) {
+ shutdownListeners.push(listener);
+ return listener;
+}
+
+// Register shutdown callback that kills the process after `SHUTDOWN_TIMEOUT` milliseconds
+// This prevents custom shutdown handlers from hanging the process indefinitely
+processOnce(SHUTDOWN_SIGNALS, forceExitAfter(SHUTDOWN_TIMEOUT));
+
+// Register process shutdown callback
+// Will listen to incoming signal events and execute all registered handlers in the stack
+processOnce(SHUTDOWN_SIGNALS, shutdownHandler);
diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts
new file mode 100644
index 0000000000..71fbbd8a4c
--- /dev/null
+++ b/packages/backend/src/misc/cache.ts
@@ -0,0 +1,43 @@
+export class Cache<T> {
+ private cache: Map<string | null, { date: number; value: T; }>;
+ private lifetime: number;
+
+ constructor(lifetime: Cache<never>['lifetime']) {
+ this.cache = new Map();
+ this.lifetime = lifetime;
+ }
+
+ public set(key: string | null, value: T): void {
+ this.cache.set(key, {
+ date: Date.now(),
+ value
+ });
+ }
+
+ public get(key: string | null): T | undefined {
+ const cached = this.cache.get(key);
+ if (cached == null) return undefined;
+ if ((Date.now() - cached.date) > this.lifetime) {
+ this.cache.delete(key);
+ return undefined;
+ }
+ return cached.value;
+ }
+
+ public delete(key: string | null) {
+ this.cache.delete(key);
+ }
+
+ public async fetch(key: string | null, fetcher: () => Promise<T>): Promise<T> {
+ const cachedValue = this.get(key);
+ if (cachedValue !== undefined) {
+ // Cache HIT
+ return cachedValue;
+ }
+
+ // Cache MISS
+ const value = await fetcher();
+ this.set(key, value);
+ return value;
+ }
+}
diff --git a/packages/backend/src/misc/cafy-id.ts b/packages/backend/src/misc/cafy-id.ts
new file mode 100644
index 0000000000..39886611e1
--- /dev/null
+++ b/packages/backend/src/misc/cafy-id.ts
@@ -0,0 +1,32 @@
+import { Context } from 'cafy';
+
+export class ID<Maybe = string> extends Context<string | (Maybe extends {} ? string : Maybe)> {
+ public readonly name = 'ID';
+
+ constructor(optional = false, nullable = false) {
+ super(optional, nullable);
+
+ this.push((v: any) => {
+ if (typeof v !== 'string') {
+ return new Error('must-be-an-id');
+ }
+ return true;
+ });
+ }
+
+ public getType() {
+ return super.getType('String');
+ }
+
+ public makeOptional(): ID<undefined> {
+ return new ID(true, false);
+ }
+
+ public makeNullable(): ID<null> {
+ return new ID(false, true);
+ }
+
+ public makeOptionalNullable(): ID<undefined | null> {
+ return new ID(true, true);
+ }
+}
diff --git a/packages/backend/src/misc/captcha.ts b/packages/backend/src/misc/captcha.ts
new file mode 100644
index 0000000000..f36943b589
--- /dev/null
+++ b/packages/backend/src/misc/captcha.ts
@@ -0,0 +1,56 @@
+import fetch from 'node-fetch';
+import { URLSearchParams } from 'url';
+import { getAgentByUrl } from './fetch';
+import config from '@/config/index';
+
+export async function verifyRecaptcha(secret: string, response: string) {
+ const result = await getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => {
+ throw `recaptcha-request-failed: ${e}`;
+ });
+
+ if (result.success !== true) {
+ const errorCodes = result['error-codes'] ? result['error-codes']?.join(', ') : '';
+ throw `recaptcha-failed: ${errorCodes}`;
+ }
+}
+
+export async function verifyHcaptcha(secret: string, response: string) {
+ const result = await getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => {
+ throw `hcaptcha-request-failed: ${e}`;
+ });
+
+ if (result.success !== true) {
+ const errorCodes = result['error-codes'] ? result['error-codes']?.join(', ') : '';
+ throw `hcaptcha-failed: ${errorCodes}`;
+ }
+}
+
+type CaptchaResponse = {
+ success: boolean;
+ 'error-codes'?: string[];
+};
+
+async function getCaptchaResponse(url: string, secret: string, response: string): Promise<CaptchaResponse> {
+ const params = new URLSearchParams({
+ secret,
+ response
+ });
+
+ const res = await fetch(url, {
+ method: 'POST',
+ body: params,
+ headers: {
+ 'User-Agent': config.userAgent
+ },
+ timeout: 10 * 1000,
+ agent: getAgentByUrl
+ }).catch(e => {
+ throw `${e.message || e}`;
+ });
+
+ if (!res.ok) {
+ throw `${res.status}`;
+ }
+
+ return await res.json() as CaptchaResponse;
+}
diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts
new file mode 100644
index 0000000000..e70b7429c7
--- /dev/null
+++ b/packages/backend/src/misc/check-hit-antenna.ts
@@ -0,0 +1,90 @@
+import { Antenna } from '@/models/entities/antenna';
+import { Note } from '@/models/entities/note';
+import { User } from '@/models/entities/user';
+import { UserListJoinings, UserGroupJoinings } from '@/models/index';
+import { getFullApAccount } from './convert-host';
+import * as Acct from 'misskey-js/built/acct';
+import { Packed } from './schema';
+
+/**
+ * noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい
+ */
+export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
+ if (note.visibility === 'specified') return false;
+
+ if (note.visibility === 'followers') {
+ if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
+ if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
+ }
+
+ if (!antenna.withReplies && note.replyId != null) return false;
+
+ if (antenna.src === 'home') {
+ if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
+ if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
+ } else if (antenna.src === 'list') {
+ const listUsers = (await UserListJoinings.find({
+ userListId: antenna.userListId!
+ })).map(x => x.userId);
+
+ if (!listUsers.includes(note.userId)) return false;
+ } else if (antenna.src === 'group') {
+ const joining = await UserGroupJoinings.findOneOrFail(antenna.userGroupJoiningId!);
+
+ const groupUsers = (await UserGroupJoinings.find({
+ userGroupId: joining.userGroupId
+ })).map(x => x.userId);
+
+ if (!groupUsers.includes(note.userId)) return false;
+ } else if (antenna.src === 'users') {
+ const accts = antenna.users.map(x => {
+ const { username, host } = Acct.parse(x);
+ return getFullApAccount(username, host).toLowerCase();
+ });
+ if (!accts.includes(getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
+ }
+
+ const keywords = antenna.keywords
+ // Clean up
+ .map(xs => xs.filter(x => x !== ''))
+ .filter(xs => xs.length > 0);
+
+ if (keywords.length > 0) {
+ if (note.text == null) return false;
+
+ const matched = keywords.some(and =>
+ and.every(keyword =>
+ antenna.caseSensitive
+ ? note.text!.includes(keyword)
+ : note.text!.toLowerCase().includes(keyword.toLowerCase())
+ ));
+
+ if (!matched) return false;
+ }
+
+ const excludeKeywords = antenna.excludeKeywords
+ // Clean up
+ .map(xs => xs.filter(x => x !== ''))
+ .filter(xs => xs.length > 0);
+
+ if (excludeKeywords.length > 0) {
+ if (note.text == null) return false;
+
+ const matched = excludeKeywords.some(and =>
+ and.every(keyword =>
+ antenna.caseSensitive
+ ? note.text!.includes(keyword)
+ : note.text!.toLowerCase().includes(keyword.toLowerCase())
+ ));
+
+ if (matched) return false;
+ }
+
+ if (antenna.withFile) {
+ if (note.fileIds && note.fileIds.length === 0) return false;
+ }
+
+ // TODO: eval expression
+
+ return true;
+}
diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts
new file mode 100644
index 0000000000..e2e871dd2b
--- /dev/null
+++ b/packages/backend/src/misc/check-word-mute.ts
@@ -0,0 +1,39 @@
+const RE2 = require('re2');
+import { Note } from '@/models/entities/note';
+import { User } from '@/models/entities/user';
+
+type NoteLike = {
+ userId: Note['userId'];
+ text: Note['text'];
+};
+
+type UserLike = {
+ id: User['id'];
+};
+
+export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: string[][]): Promise<boolean> {
+ // 自分自身
+ if (me && (note.userId === me.id)) return false;
+
+ const words = mutedWords
+ // Clean up
+ .map(xs => xs.filter(x => x !== ''))
+ .filter(xs => xs.length > 0);
+
+ if (words.length > 0) {
+ if (note.text == null) return false;
+
+ const matched = words.some(and =>
+ and.every(keyword => {
+ const regexp = keyword.match(/^\/(.+)\/(.*)$/);
+ if (regexp) {
+ return new RE2(regexp[1], regexp[2]).test(note.text!);
+ }
+ return note.text!.includes(keyword);
+ }));
+
+ if (matched) return true;
+ }
+
+ return false;
+}
diff --git a/packages/backend/src/misc/content-disposition.ts b/packages/backend/src/misc/content-disposition.ts
new file mode 100644
index 0000000000..9df7ed4688
--- /dev/null
+++ b/packages/backend/src/misc/content-disposition.ts
@@ -0,0 +1,6 @@
+const cd = require('content-disposition');
+
+export function contentDisposition(type: 'inline' | 'attachment', filename: string): string {
+ const fallback = filename.replace(/[^\w.-]/g, '_');
+ return cd(filename, { type, fallback });
+}
diff --git a/packages/backend/src/misc/convert-host.ts b/packages/backend/src/misc/convert-host.ts
new file mode 100644
index 0000000000..6e9f6ed3e9
--- /dev/null
+++ b/packages/backend/src/misc/convert-host.ts
@@ -0,0 +1,26 @@
+import { URL } from 'url';
+import config from '@/config/index';
+import { toASCII } from 'punycode/';
+
+export function getFullApAccount(username: string, host: string | null) {
+ return host ? `${username}@${toPuny(host)}` : `${username}@${toPuny(config.host)}`;
+}
+
+export function isSelfHost(host: string) {
+ if (host == null) return true;
+ return toPuny(config.host) === toPuny(host);
+}
+
+export function extractDbHost(uri: string) {
+ const url = new URL(uri);
+ return toPuny(url.hostname);
+}
+
+export function toPuny(host: string) {
+ return toASCII(host.toLowerCase());
+}
+
+export function toPunyNullable(host: string | null | undefined): string | null {
+ if (host == null) return null;
+ return toASCII(host.toLowerCase());
+}
diff --git a/packages/backend/src/misc/count-same-renotes.ts b/packages/backend/src/misc/count-same-renotes.ts
new file mode 100644
index 0000000000..6628761182
--- /dev/null
+++ b/packages/backend/src/misc/count-same-renotes.ts
@@ -0,0 +1,15 @@
+import { Notes } from '@/models/index';
+
+export async function countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise<number> {
+ // 指定したユーザーの指定したノートのリノートがいくつあるか数える
+ const query = Notes.createQueryBuilder('note')
+ .where('note.userId = :userId', { userId })
+ .andWhere('note.renoteId = :renoteId', { renoteId });
+
+ // 指定した投稿を除く
+ if (excludeNoteId) {
+ query.andWhere('note.id != :excludeNoteId', { excludeNoteId });
+ }
+
+ return await query.getCount();
+}
diff --git a/packages/backend/src/misc/create-temp.ts b/packages/backend/src/misc/create-temp.ts
new file mode 100644
index 0000000000..04604cf7d0
--- /dev/null
+++ b/packages/backend/src/misc/create-temp.ts
@@ -0,0 +1,10 @@
+import * as tmp from 'tmp';
+
+export function createTemp(): Promise<[string, any]> {
+ return new Promise<[string, any]>((res, rej) => {
+ tmp.file((e, path, fd, cleanup) => {
+ if (e) return rej(e);
+ res([path, cleanup]);
+ });
+ });
+}
diff --git a/packages/backend/src/misc/detect-url-mime.ts b/packages/backend/src/misc/detect-url-mime.ts
new file mode 100644
index 0000000000..274c291737
--- /dev/null
+++ b/packages/backend/src/misc/detect-url-mime.ts
@@ -0,0 +1,15 @@
+import { createTemp } from './create-temp';
+import { downloadUrl } from './download-url';
+import { detectType } from './get-file-info';
+
+export async function detectUrlMime(url: string) {
+ const [path, cleanup] = await createTemp();
+
+ try {
+ await downloadUrl(url, path);
+ const { mime } = await detectType(path);
+ return mime;
+ } finally {
+ cleanup();
+ }
+}
diff --git a/packages/backend/src/misc/download-text-file.ts b/packages/backend/src/misc/download-text-file.ts
new file mode 100644
index 0000000000..e8e23cc120
--- /dev/null
+++ b/packages/backend/src/misc/download-text-file.ts
@@ -0,0 +1,25 @@
+import * as fs from 'fs';
+import * as util from 'util';
+import Logger from '@/services/logger';
+import { createTemp } from './create-temp';
+import { downloadUrl } from './download-url';
+
+const logger = new Logger('download-text-file');
+
+export async function downloadTextFile(url: string): Promise<string> {
+ // Create temp file
+ const [path, cleanup] = await createTemp();
+
+ logger.info(`Temp file is ${path}`);
+
+ try {
+ // write content at URL to temp file
+ await downloadUrl(url, path);
+
+ const text = await util.promisify(fs.readFile)(path, 'utf8');
+
+ return text;
+ } finally {
+ cleanup();
+ }
+}
diff --git a/packages/backend/src/misc/download-url.ts b/packages/backend/src/misc/download-url.ts
new file mode 100644
index 0000000000..c96b4fd1d6
--- /dev/null
+++ b/packages/backend/src/misc/download-url.ts
@@ -0,0 +1,87 @@
+import * as fs from 'fs';
+import * as stream from 'stream';
+import * as util from 'util';
+import got, * as Got from 'got';
+import { httpAgent, httpsAgent, StatusError } from './fetch';
+import config from '@/config/index';
+import * as chalk from 'chalk';
+import Logger from '@/services/logger';
+import * as IPCIDR from 'ip-cidr';
+const PrivateIp = require('private-ip');
+
+const pipeline = util.promisify(stream.pipeline);
+
+export async function downloadUrl(url: string, path: string) {
+ const logger = new Logger('download');
+
+ logger.info(`Downloading ${chalk.cyan(url)} ...`);
+
+ const timeout = 30 * 1000;
+ const operationTimeout = 60 * 1000;
+ const maxSize = config.maxFileSize || 262144000;
+
+ const req = got.stream(url, {
+ headers: {
+ 'User-Agent': config.userAgent
+ },
+ timeout: {
+ lookup: timeout,
+ connect: timeout,
+ secureConnect: timeout,
+ socket: timeout, // read timeout
+ response: timeout,
+ send: timeout,
+ request: operationTimeout, // whole operation timeout
+ },
+ agent: {
+ http: httpAgent,
+ https: httpsAgent,
+ },
+ http2: false, // default
+ retry: 0,
+ }).on('response', (res: Got.Response) => {
+ if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !config.proxy && res.ip) {
+ if (isPrivateIp(res.ip)) {
+ logger.warn(`Blocked address: ${res.ip}`);
+ req.destroy();
+ }
+ }
+
+ const contentLength = res.headers['content-length'];
+ if (contentLength != null) {
+ const size = Number(contentLength);
+ if (size > maxSize) {
+ logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`);
+ req.destroy();
+ }
+ }
+ }).on('downloadProgress', (progress: Got.Progress) => {
+ if (progress.transferred > maxSize) {
+ logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
+ req.destroy();
+ }
+ });
+
+ try {
+ await pipeline(req, fs.createWriteStream(path));
+ } catch (e) {
+ if (e instanceof Got.HTTPError) {
+ throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage);
+ } else {
+ throw e;
+ }
+ }
+
+ logger.succ(`Download finished: ${chalk.cyan(url)}`);
+}
+
+function isPrivateIp(ip: string) {
+ for (const net of config.allowedPrivateNetworks || []) {
+ const cidr = new IPCIDR(net);
+ if (cidr.contains(ip)) {
+ return false;
+ }
+ }
+
+ return PrivateIp(ip);
+}
diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts
new file mode 100644
index 0000000000..8b07fbd8f2
--- /dev/null
+++ b/packages/backend/src/misc/emoji-regex.ts
@@ -0,0 +1,3 @@
+const twemojiRegex = require('twemoji-parser/dist/lib/regex').default;
+
+export const emojiRegex = new RegExp(`(${twemojiRegex.source})`);
diff --git a/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts
new file mode 100644
index 0000000000..b29ce281b3
--- /dev/null
+++ b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts
@@ -0,0 +1,10 @@
+import * as mfm from 'mfm-js';
+import { unique } from '@/prelude/array';
+
+export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] {
+ const emojiNodes = mfm.extract(nodes, (node) => {
+ return (node.type === 'emojiCode' && node.props.name.length <= 100);
+ });
+
+ return unique(emojiNodes.map(x => x.props.name));
+}
diff --git a/packages/backend/src/misc/extract-hashtags.ts b/packages/backend/src/misc/extract-hashtags.ts
new file mode 100644
index 0000000000..b0a74df219
--- /dev/null
+++ b/packages/backend/src/misc/extract-hashtags.ts
@@ -0,0 +1,9 @@
+import * as mfm from 'mfm-js';
+import { unique } from '@/prelude/array';
+
+export function extractHashtags(nodes: mfm.MfmNode[]): string[] {
+ const hashtagNodes = mfm.extract(nodes, (node) => node.type === 'hashtag');
+ const hashtags = unique(hashtagNodes.map(x => x.props.hashtag));
+
+ return hashtags;
+}
diff --git a/packages/backend/src/misc/extract-mentions.ts b/packages/backend/src/misc/extract-mentions.ts
new file mode 100644
index 0000000000..cc19b161a8
--- /dev/null
+++ b/packages/backend/src/misc/extract-mentions.ts
@@ -0,0 +1,11 @@
+// test is located in test/extract-mentions
+
+import * as mfm from 'mfm-js';
+
+export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
+ // TODO: 重複を削除
+ const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention');
+ const mentions = mentionNodes.map(x => x.props);
+
+ return mentions;
+}
diff --git a/packages/backend/src/misc/fetch-meta.ts b/packages/backend/src/misc/fetch-meta.ts
new file mode 100644
index 0000000000..a0bcdd4d48
--- /dev/null
+++ b/packages/backend/src/misc/fetch-meta.ts
@@ -0,0 +1,35 @@
+import { Meta } from '@/models/entities/meta';
+import { getConnection } from 'typeorm';
+
+let cache: Meta;
+
+export async function fetchMeta(noCache = false): Promise<Meta> {
+ if (!noCache && cache) return cache;
+
+ return await getConnection().transaction(async transactionalEntityManager => {
+ // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
+ const meta = await transactionalEntityManager.findOne(Meta, {
+ order: {
+ id: 'DESC'
+ }
+ });
+
+ if (meta) {
+ cache = meta;
+ return meta;
+ } else {
+ const saved = await transactionalEntityManager.save(Meta, {
+ id: 'x'
+ }) as Meta;
+
+ cache = saved;
+ return saved;
+ }
+ });
+}
+
+setInterval(() => {
+ fetchMeta(true).then(meta => {
+ cache = meta;
+ });
+}, 1000 * 10);
diff --git a/packages/backend/src/misc/fetch-proxy-account.ts b/packages/backend/src/misc/fetch-proxy-account.ts
new file mode 100644
index 0000000000..e0eedea4c8
--- /dev/null
+++ b/packages/backend/src/misc/fetch-proxy-account.ts
@@ -0,0 +1,9 @@
+import { fetchMeta } from './fetch-meta';
+import { ILocalUser } from '@/models/entities/user';
+import { Users } from '@/models/index';
+
+export async function fetchProxyAccount(): Promise<ILocalUser | null> {
+ const meta = await fetchMeta();
+ if (meta.proxyAccountId == null) return null;
+ return await Users.findOneOrFail(meta.proxyAccountId) as ILocalUser;
+}
diff --git a/packages/backend/src/misc/fetch.ts b/packages/backend/src/misc/fetch.ts
new file mode 100644
index 0000000000..f4f16a27e2
--- /dev/null
+++ b/packages/backend/src/misc/fetch.ts
@@ -0,0 +1,141 @@
+import * as http from 'http';
+import * as https from 'https';
+import CacheableLookup from 'cacheable-lookup';
+import fetch from 'node-fetch';
+import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
+import config from '@/config/index';
+import { URL } from 'url';
+
+export async function getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: Record<string, string>) {
+ const res = await getResponse({
+ url,
+ method: 'GET',
+ headers: Object.assign({
+ 'User-Agent': config.userAgent,
+ Accept: accept
+ }, headers || {}),
+ timeout
+ });
+
+ return await res.json();
+}
+
+export async function getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: Record<string, string>) {
+ const res = await getResponse({
+ url,
+ method: 'GET',
+ headers: Object.assign({
+ 'User-Agent': config.userAgent,
+ Accept: accept
+ }, headers || {}),
+ timeout
+ });
+
+ return await res.text();
+}
+
+export async function getResponse(args: { url: string, method: string, body?: string, headers: Record<string, string>, timeout?: number, size?: number }) {
+ const timeout = args?.timeout || 10 * 1000;
+
+ const controller = new AbortController();
+ setTimeout(() => {
+ controller.abort();
+ }, timeout * 6);
+
+ const res = await fetch(args.url, {
+ method: args.method,
+ headers: args.headers,
+ body: args.body,
+ timeout,
+ size: args?.size || 10 * 1024 * 1024,
+ agent: getAgentByUrl,
+ signal: controller.signal,
+ });
+
+ if (!res.ok) {
+ throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
+ }
+
+ return res;
+}
+
+const cache = new CacheableLookup({
+ maxTtl: 3600, // 1hours
+ errorTtl: 30, // 30secs
+ lookup: false, // nativeのdns.lookupにfallbackしない
+});
+
+/**
+ * Get http non-proxy agent
+ */
+const _http = new http.Agent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ lookup: cache.lookup,
+} as http.AgentOptions);
+
+/**
+ * Get https non-proxy agent
+ */
+const _https = new https.Agent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ lookup: cache.lookup,
+} as https.AgentOptions);
+
+const maxSockets = Math.max(256, config.deliverJobConcurrency || 128);
+
+/**
+ * Get http proxy or non-proxy agent
+ */
+export const httpAgent = config.proxy
+ ? new HttpProxyAgent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ maxSockets,
+ maxFreeSockets: 256,
+ scheduling: 'lifo',
+ proxy: config.proxy
+ })
+ : _http;
+
+/**
+ * Get https proxy or non-proxy agent
+ */
+export const httpsAgent = config.proxy
+ ? new HttpsProxyAgent({
+ keepAlive: true,
+ keepAliveMsecs: 30 * 1000,
+ maxSockets,
+ maxFreeSockets: 256,
+ scheduling: 'lifo',
+ proxy: config.proxy
+ })
+ : _https;
+
+/**
+ * Get agent by URL
+ * @param url URL
+ * @param bypassProxy Allways bypass proxy
+ */
+export function getAgentByUrl(url: URL, bypassProxy = false) {
+ if (bypassProxy || (config.proxyBypassHosts || []).includes(url.hostname)) {
+ return url.protocol == 'http:' ? _http : _https;
+ } else {
+ return url.protocol == 'http:' ? httpAgent : httpsAgent;
+ }
+}
+
+export class StatusError extends Error {
+ public statusCode: number;
+ public statusMessage?: string;
+ public isClientError: boolean;
+
+ constructor(message: string, statusCode: number, statusMessage?: string) {
+ super(message);
+ this.name = 'StatusError';
+ this.statusCode = statusCode;
+ this.statusMessage = statusMessage;
+ this.isClientError = typeof this.statusCode === 'number' && this.statusCode >= 400 && this.statusCode < 500;
+ }
+}
diff --git a/packages/backend/src/misc/gen-avatar.ts b/packages/backend/src/misc/gen-avatar.ts
new file mode 100644
index 0000000000..f03ca9f96d
--- /dev/null
+++ b/packages/backend/src/misc/gen-avatar.ts
@@ -0,0 +1,90 @@
+/**
+ * Random avatar generator
+ */
+
+import * as p from 'pureimage';
+import * as gen from 'random-seed';
+import { WriteStream } from 'fs';
+
+const size = 256; // px
+const n = 5; // resolution
+const margin = (size / n);
+const colors = [
+ '#e57373',
+ '#F06292',
+ '#BA68C8',
+ '#9575CD',
+ '#7986CB',
+ '#64B5F6',
+ '#4FC3F7',
+ '#4DD0E1',
+ '#4DB6AC',
+ '#81C784',
+ '#8BC34A',
+ '#AFB42B',
+ '#F57F17',
+ '#FF5722',
+ '#795548',
+ '#455A64',
+];
+const bg = '#e9e9e9';
+
+const actualSize = size - (margin * 2);
+const cellSize = actualSize / n;
+const sideN = Math.floor(n / 2);
+
+/**
+ * Generate buffer of random avatar by seed
+ */
+export function genAvatar(seed: string, stream: WriteStream): Promise<void> {
+ const rand = gen.create(seed);
+ const canvas = p.make(size, size);
+ const ctx = canvas.getContext('2d');
+
+ ctx.fillStyle = bg;
+ ctx.beginPath();
+ ctx.fillRect(0, 0, size, size);
+
+ ctx.fillStyle = colors[rand(colors.length)];
+
+ // side bitmap (filled by false)
+ const side: boolean[][] = new Array(sideN);
+ for (let i = 0; i < side.length; i++) {
+ side[i] = new Array(n).fill(false);
+ }
+
+ // 1*n (filled by false)
+ const center: boolean[] = new Array(n).fill(false);
+
+ // tslint:disable-next-line:prefer-for-of
+ for (let x = 0; x < side.length; x++) {
+ for (let y = 0; y < side[x].length; y++) {
+ side[x][y] = rand(3) === 0;
+ }
+ }
+
+ for (let i = 0; i < center.length; i++) {
+ center[i] = rand(3) === 0;
+ }
+
+ // Draw
+ for (let x = 0; x < n; x++) {
+ for (let y = 0; y < n; y++) {
+ const isXCenter = x === ((n - 1) / 2);
+ if (isXCenter && !center[y]) continue;
+
+ const isLeftSide = x < ((n - 1) / 2);
+ if (isLeftSide && !side[x][y]) continue;
+
+ const isRightSide = x > ((n - 1) / 2);
+ if (isRightSide && !side[sideN - (x - sideN)][y]) continue;
+
+ const actualX = margin + (cellSize * x);
+ const actualY = margin + (cellSize * y);
+ ctx.beginPath();
+ ctx.fillRect(actualX, actualY, cellSize, cellSize);
+ }
+ }
+
+ return p.encodePNGToStream(canvas, stream);
+}
diff --git a/packages/backend/src/misc/gen-id.ts b/packages/backend/src/misc/gen-id.ts
new file mode 100644
index 0000000000..b1b542dc4b
--- /dev/null
+++ b/packages/backend/src/misc/gen-id.ts
@@ -0,0 +1,21 @@
+import { ulid } from 'ulid';
+import { genAid } from './id/aid';
+import { genMeid } from './id/meid';
+import { genMeidg } from './id/meidg';
+import { genObjectId } from './id/object-id';
+import config from '@/config/index';
+
+const metohd = config.id.toLowerCase();
+
+export function genId(date?: Date): string {
+ if (!date || (date > new Date())) date = new Date();
+
+ switch (metohd) {
+ case 'aid': return genAid(date);
+ case 'meid': return genMeid(date);
+ case 'meidg': return genMeidg(date);
+ case 'ulid': return ulid(date.getTime());
+ case 'objectid': return genObjectId(date);
+ default: throw new Error('unrecognized id generation method');
+ }
+}
diff --git a/packages/backend/src/misc/gen-key-pair.ts b/packages/backend/src/misc/gen-key-pair.ts
new file mode 100644
index 0000000000..d4a8fa7534
--- /dev/null
+++ b/packages/backend/src/misc/gen-key-pair.ts
@@ -0,0 +1,36 @@
+import * as crypto from 'crypto';
+import * as util from 'util';
+
+const generateKeyPair = util.promisify(crypto.generateKeyPair);
+
+export async function genRsaKeyPair(modulusLength = 2048) {
+ return await generateKeyPair('rsa', {
+ modulusLength,
+ publicKeyEncoding: {
+ type: 'spki',
+ format: 'pem'
+ },
+ privateKeyEncoding: {
+ type: 'pkcs8',
+ format: 'pem',
+ cipher: undefined,
+ passphrase: undefined
+ }
+ });
+}
+
+export async function genEcKeyPair(namedCurve: 'prime256v1' | 'secp384r1' | 'secp521r1' | 'curve25519' = 'prime256v1') {
+ return await generateKeyPair('ec', {
+ namedCurve,
+ publicKeyEncoding: {
+ type: 'spki',
+ format: 'pem'
+ },
+ privateKeyEncoding: {
+ type: 'pkcs8',
+ format: 'pem',
+ cipher: undefined,
+ passphrase: undefined
+ }
+ });
+}
diff --git a/packages/backend/src/misc/get-file-info.ts b/packages/backend/src/misc/get-file-info.ts
new file mode 100644
index 0000000000..39ba541395
--- /dev/null
+++ b/packages/backend/src/misc/get-file-info.ts
@@ -0,0 +1,196 @@
+import * as fs from 'fs';
+import * as crypto from 'crypto';
+import * as stream from 'stream';
+import * as util from 'util';
+import * as fileType from 'file-type';
+import isSvg from 'is-svg';
+import * as probeImageSize from 'probe-image-size';
+import * as sharp from 'sharp';
+import { encode } from 'blurhash';
+
+const pipeline = util.promisify(stream.pipeline);
+
+export type FileInfo = {
+ size: number;
+ md5: string;
+ type: {
+ mime: string;
+ ext: string | null;
+ };
+ width?: number;
+ height?: number;
+ blurhash?: string;
+ warnings: string[];
+};
+
+const TYPE_OCTET_STREAM = {
+ mime: 'application/octet-stream',
+ ext: null
+};
+
+const TYPE_SVG = {
+ mime: 'image/svg+xml',
+ ext: 'svg'
+};
+
+/**
+ * Get file information
+ */
+export async function getFileInfo(path: string): Promise<FileInfo> {
+ const warnings = [] as string[];
+
+ const size = await getFileSize(path);
+ const md5 = await calcHash(path);
+
+ let type = await detectType(path);
+
+ // image dimensions
+ let width: number | undefined;
+ let height: number | undefined;
+
+ if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) {
+ const imageSize = await detectImageSize(path).catch(e => {
+ warnings.push(`detectImageSize failed: ${e}`);
+ return undefined;
+ });
+
+ // うまく判定できない画像は octet-stream にする
+ if (!imageSize) {
+ warnings.push(`cannot detect image dimensions`);
+ type = TYPE_OCTET_STREAM;
+ } else if (imageSize.wUnits === 'px') {
+ width = imageSize.width;
+ height = imageSize.height;
+
+ // 制限を超えている画像は octet-stream にする
+ if (imageSize.width > 16383 || imageSize.height > 16383) {
+ warnings.push(`image dimensions exceeds limits`);
+ type = TYPE_OCTET_STREAM;
+ }
+ } else {
+ warnings.push(`unsupported unit type: ${imageSize.wUnits}`);
+ }
+ }
+
+ let blurhash: string | undefined;
+
+ if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) {
+ blurhash = await getBlurhash(path).catch(e => {
+ warnings.push(`getBlurhash failed: ${e}`);
+ return undefined;
+ });
+ }
+
+ return {
+ size,
+ md5,
+ type,
+ width,
+ height,
+ blurhash,
+ warnings,
+ };
+}
+
+/**
+ * Detect MIME Type and extension
+ */
+export async function detectType(path: string) {
+ // Check 0 byte
+ const fileSize = await getFileSize(path);
+ if (fileSize === 0) {
+ return TYPE_OCTET_STREAM;
+ }
+
+ const type = await fileType.fromFile(path);
+
+ if (type) {
+ // XMLはSVGかもしれない
+ if (type.mime === 'application/xml' && await checkSvg(path)) {
+ return TYPE_SVG;
+ }
+
+ return {
+ mime: type.mime,
+ ext: type.ext
+ };
+ }
+
+ // 種類が不明でもSVGかもしれない
+ if (await checkSvg(path)) {
+ return TYPE_SVG;
+ }
+
+ // それでも種類が不明なら application/octet-stream にする
+ return TYPE_OCTET_STREAM;
+}
+
+/**
+ * Check the file is SVG or not
+ */
+export async function checkSvg(path: string) {
+ try {
+ const size = await getFileSize(path);
+ if (size > 1 * 1024 * 1024) return false;
+ return isSvg(fs.readFileSync(path));
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Get file size
+ */
+export async function getFileSize(path: string): Promise<number> {
+ const getStat = util.promisify(fs.stat);
+ return (await getStat(path)).size;
+}
+
+/**
+ * Calculate MD5 hash
+ */
+async function calcHash(path: string): Promise<string> {
+ const hash = crypto.createHash('md5').setEncoding('hex');
+ await pipeline(fs.createReadStream(path), hash);
+ return hash.read();
+}
+
+/**
+ * Detect dimensions of image
+ */
+async function detectImageSize(path: string): Promise<{
+ width: number;
+ height: number;
+ wUnits: string;
+ hUnits: string;
+}> {
+ const readable = fs.createReadStream(path);
+ const imageSize = await probeImageSize(readable);
+ readable.destroy();
+ return imageSize;
+}
+
+/**
+ * Calculate average color of image
+ */
+function getBlurhash(path: string): Promise<string> {
+ return new Promise((resolve, reject) => {
+ sharp(path)
+ .raw()
+ .ensureAlpha()
+ .resize(64, 64, { fit: 'inside' })
+ .toBuffer((err, buffer, { width, height }) => {
+ if (err) return reject(err);
+
+ let hash;
+
+ try {
+ hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7);
+ } catch (e) {
+ return reject(e);
+ }
+
+ resolve(hash);
+ });
+ });
+}
diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts
new file mode 100644
index 0000000000..d7273d1c5b
--- /dev/null
+++ b/packages/backend/src/misc/get-note-summary.ts
@@ -0,0 +1,54 @@
+import { Packed } from './schema';
+
+/**
+ * 投稿を表す文字列を取得します。
+ * @param {*} note (packされた)投稿
+ */
+export const getNoteSummary = (note: Packed<'Note'>): string => {
+ if (note.deletedAt) {
+ return `(❌⛔)`;
+ }
+
+ if (note.isHidden) {
+ return `(⛔)`;
+ }
+
+ let summary = '';
+
+ // 本文
+ if (note.cw != null) {
+ summary += note.cw;
+ } else {
+ summary += note.text ? note.text : '';
+ }
+
+ // ファイルが添付されているとき
+ if ((note.files || []).length != 0) {
+ summary += ` (📎${note.files!.length})`;
+ }
+
+ // 投票が添付されているとき
+ if (note.poll) {
+ summary += ` (📊)`;
+ }
+
+ // 返信のとき
+ if (note.replyId) {
+ if (note.reply) {
+ summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
+ } else {
+ summary += '\n\nRE: ...';
+ }
+ }
+
+ // Renoteのとき
+ if (note.renoteId) {
+ if (note.renote) {
+ summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
+ } else {
+ summary += '\n\nRN: ...';
+ }
+ }
+
+ return summary.trim();
+};
diff --git a/packages/backend/src/misc/get-reaction-emoji.ts b/packages/backend/src/misc/get-reaction-emoji.ts
new file mode 100644
index 0000000000..c2e0b98582
--- /dev/null
+++ b/packages/backend/src/misc/get-reaction-emoji.ts
@@ -0,0 +1,16 @@
+export default function(reaction: string): string {
+ switch (reaction) {
+ case 'like': return '👍';
+ case 'love': return '❤️';
+ case 'laugh': return '😆';
+ case 'hmm': return '🤔';
+ case 'surprise': return '😮';
+ case 'congrats': return '🎉';
+ case 'angry': return '💢';
+ case 'confused': return '😥';
+ case 'rip': return '😇';
+ case 'pudding': return '🍮';
+ case 'star': return '⭐';
+ default: return reaction;
+ }
+}
diff --git a/packages/backend/src/misc/hard-limits.ts b/packages/backend/src/misc/hard-limits.ts
new file mode 100644
index 0000000000..1039f7335a
--- /dev/null
+++ b/packages/backend/src/misc/hard-limits.ts
@@ -0,0 +1,14 @@
+
+// If you change DB_* values, you must also change the DB schema.
+
+/**
+ * Maximum note text length that can be stored in DB.
+ * Surrogate pairs count as one
+ */
+export const DB_MAX_NOTE_TEXT_LENGTH = 8192;
+
+/**
+ * Maximum image description length that can be stored in DB.
+ * Surrogate pairs count as one
+ */
+export const DB_MAX_IMAGE_COMMENT_LENGTH = 512;
diff --git a/packages/backend/src/misc/i18n.ts b/packages/backend/src/misc/i18n.ts
new file mode 100644
index 0000000000..4fa398763a
--- /dev/null
+++ b/packages/backend/src/misc/i18n.ts
@@ -0,0 +1,29 @@
+export class I18n<T extends Record<string, any>> {
+ public locale: T;
+
+ constructor(locale: T) {
+ this.locale = locale;
+
+ //#region BIND
+ this.t = this.t.bind(this);
+ //#endregion
+ }
+
+ // string にしているのは、ドット区切りでのパス指定を許可するため
+ // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
+ public t(key: string, args?: Record<string, any>): string {
+ try {
+ let str = key.split('.').reduce((o, i) => o[i], this.locale) as string;
+
+ if (args) {
+ for (const [k, v] of Object.entries(args)) {
+ str = str.replace(`{${k}}`, v);
+ }
+ }
+ return str;
+ } catch (e) {
+ console.warn(`missing localization '${key}'`);
+ return key;
+ }
+ }
+}
diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts
new file mode 100644
index 0000000000..2bcde90bff
--- /dev/null
+++ b/packages/backend/src/misc/id/aid.ts
@@ -0,0 +1,25 @@
+// AID
+// 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ2の[ノイズ文字列]
+
+import * as crypto from 'crypto';
+
+const TIME2000 = 946684800000;
+let counter = crypto.randomBytes(2).readUInt16LE(0);
+
+function getTime(time: number) {
+ time = time - TIME2000;
+ if (time < 0) time = 0;
+
+ return time.toString(36).padStart(8, '0');
+}
+
+function getNoise() {
+ return counter.toString(36).padStart(2, '0').slice(-2);
+}
+
+export function genAid(date: Date): string {
+ const t = date.getTime();
+ if (isNaN(t)) throw 'Failed to create AID: Invalid Date';
+ counter++;
+ return getTime(t) + getNoise();
+}
diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts
new file mode 100644
index 0000000000..30bbdf1698
--- /dev/null
+++ b/packages/backend/src/misc/id/meid.ts
@@ -0,0 +1,26 @@
+const CHARS = '0123456789abcdef';
+
+function getTime(time: number) {
+ if (time < 0) time = 0;
+ if (time === 0) {
+ return CHARS[0];
+ }
+
+ time += 0x800000000000;
+
+ return time.toString(16).padStart(12, CHARS[0]);
+}
+
+function getRandom() {
+ let str = '';
+
+ for (let i = 0; i < 12; i++) {
+ str += CHARS[Math.floor(Math.random() * CHARS.length)];
+ }
+
+ return str;
+}
+
+export function genMeid(date: Date): string {
+ return getTime(date.getTime()) + getRandom();
+}
diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts
new file mode 100644
index 0000000000..d4aaaea1ba
--- /dev/null
+++ b/packages/backend/src/misc/id/meidg.ts
@@ -0,0 +1,28 @@
+const CHARS = '0123456789abcdef';
+
+// 4bit Fixed hex value 'g'
+// 44bit UNIX Time ms in Hex
+// 48bit Random value in Hex
+
+function getTime(time: number) {
+ if (time < 0) time = 0;
+ if (time === 0) {
+ return CHARS[0];
+ }
+
+ return time.toString(16).padStart(11, CHARS[0]);
+}
+
+function getRandom() {
+ let str = '';
+
+ for (let i = 0; i < 12; i++) {
+ str += CHARS[Math.floor(Math.random() * CHARS.length)];
+ }
+
+ return str;
+}
+
+export function genMeidg(date: Date): string {
+ return 'g' + getTime(date.getTime()) + getRandom();
+}
diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts
new file mode 100644
index 0000000000..392ea43301
--- /dev/null
+++ b/packages/backend/src/misc/id/object-id.ts
@@ -0,0 +1,26 @@
+const CHARS = '0123456789abcdef';
+
+function getTime(time: number) {
+ if (time < 0) time = 0;
+ if (time === 0) {
+ return CHARS[0];
+ }
+
+ time = Math.floor(time / 1000);
+
+ return time.toString(16).padStart(8, CHARS[0]);
+}
+
+function getRandom() {
+ let str = '';
+
+ for (let i = 0; i < 16; i++) {
+ str += CHARS[Math.floor(Math.random() * CHARS.length)];
+ }
+
+ return str;
+}
+
+export function genObjectId(date: Date): string {
+ return getTime(date.getTime()) + getRandom();
+}
diff --git a/packages/backend/src/misc/identifiable-error.ts b/packages/backend/src/misc/identifiable-error.ts
new file mode 100644
index 0000000000..2d7c6bd0c6
--- /dev/null
+++ b/packages/backend/src/misc/identifiable-error.ts
@@ -0,0 +1,13 @@
+/**
+ * ID付きエラー
+ */
+export class IdentifiableError extends Error {
+ public message: string;
+ public id: string;
+
+ constructor(id: string, message?: string) {
+ super(message);
+ this.message = message || '';
+ this.id = id;
+ }
+}
diff --git a/packages/backend/src/misc/is-blocker-user-related.ts b/packages/backend/src/misc/is-blocker-user-related.ts
new file mode 100644
index 0000000000..8c0ebfad9b
--- /dev/null
+++ b/packages/backend/src/misc/is-blocker-user-related.ts
@@ -0,0 +1,15 @@
+export function isBlockerUserRelated(note: any, blockerUserIds: Set<string>): boolean {
+ if (blockerUserIds.has(note.userId)) {
+ return true;
+ }
+
+ if (note.reply != null && blockerUserIds.has(note.reply.userId)) {
+ return true;
+ }
+
+ if (note.renote != null && blockerUserIds.has(note.renote.userId)) {
+ return true;
+ }
+
+ return false;
+}
diff --git a/packages/backend/src/misc/is-duplicate-key-value-error.ts b/packages/backend/src/misc/is-duplicate-key-value-error.ts
new file mode 100644
index 0000000000..23d8ceb1b7
--- /dev/null
+++ b/packages/backend/src/misc/is-duplicate-key-value-error.ts
@@ -0,0 +1,3 @@
+export function isDuplicateKeyValueError(e: Error): boolean {
+ return e.message.startsWith('duplicate key value');
+}
diff --git a/packages/backend/src/misc/is-muted-user-related.ts b/packages/backend/src/misc/is-muted-user-related.ts
new file mode 100644
index 0000000000..2caa743f95
--- /dev/null
+++ b/packages/backend/src/misc/is-muted-user-related.ts
@@ -0,0 +1,15 @@
+export function isMutedUserRelated(note: any, mutedUserIds: Set<string>): boolean {
+ if (mutedUserIds.has(note.userId)) {
+ return true;
+ }
+
+ if (note.reply != null && mutedUserIds.has(note.reply.userId)) {
+ return true;
+ }
+
+ if (note.renote != null && mutedUserIds.has(note.renote.userId)) {
+ return true;
+ }
+
+ return false;
+}
diff --git a/packages/backend/src/misc/is-quote.ts b/packages/backend/src/misc/is-quote.ts
new file mode 100644
index 0000000000..2b57f036a2
--- /dev/null
+++ b/packages/backend/src/misc/is-quote.ts
@@ -0,0 +1,5 @@
+import { Note } from '@/models/entities/note';
+
+export default function(note: Note): boolean {
+ return note.renoteId != null && (note.text != null || note.hasPoll || (note.fileIds != null && note.fileIds.length > 0));
+}
diff --git a/packages/backend/src/misc/keypair-store.ts b/packages/backend/src/misc/keypair-store.ts
new file mode 100644
index 0000000000..c018013b7b
--- /dev/null
+++ b/packages/backend/src/misc/keypair-store.ts
@@ -0,0 +1,10 @@
+import { UserKeypairs } from '@/models/index';
+import { User } from '@/models/entities/user';
+import { UserKeypair } from '@/models/entities/user-keypair';
+import { Cache } from './cache';
+
+const cache = new Cache<UserKeypair>(Infinity);
+
+export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> {
+ return await cache.fetch(userId, () => UserKeypairs.findOneOrFail(userId));
+}
diff --git a/packages/backend/src/misc/normalize-for-search.ts b/packages/backend/src/misc/normalize-for-search.ts
new file mode 100644
index 0000000000..200540566e
--- /dev/null
+++ b/packages/backend/src/misc/normalize-for-search.ts
@@ -0,0 +1,6 @@
+export function normalizeForSearch(tag: string): string {
+ // ref.
+ // - https://analytics-note.xyz/programming/unicode-normalization-forms/
+ // - https://maku77.github.io/js/string/normalize.html
+ return tag.normalize('NFKC').toLowerCase();
+}
diff --git a/packages/backend/src/misc/nyaize.ts b/packages/backend/src/misc/nyaize.ts
new file mode 100644
index 0000000000..500d1db2cb
--- /dev/null
+++ b/packages/backend/src/misc/nyaize.ts
@@ -0,0 +1,15 @@
+export function nyaize(text: string): string {
+ return text
+ // ja-JP
+ .replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ')
+ // en-US
+ .replace(/(?<=n)a/gi, x => x === 'A' ? 'YA' : 'ya')
+ .replace(/(?<=morn)ing/gi, x => x === 'ING' ? 'YAN' : 'yan')
+ .replace(/(?<=every)one/gi, x => x === 'ONE' ? 'NYAN' : 'nyan')
+ // ko-KR
+ .replace(/[나-낳]/g, match => String.fromCharCode(
+ match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0)
+ ))
+ .replace(/(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm, '다냥')
+ .replace(/(야(?=\?))|(야$)|(야(?= ))/gm, '냥');
+}
diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts
new file mode 100644
index 0000000000..f0a8bde31e
--- /dev/null
+++ b/packages/backend/src/misc/populate-emojis.ts
@@ -0,0 +1,124 @@
+import { In } from 'typeorm';
+import { Emojis } from '@/models/index';
+import { Emoji } from '@/models/entities/emoji';
+import { Note } from '@/models/entities/note';
+import { Cache } from './cache';
+import { isSelfHost, toPunyNullable } from './convert-host';
+import { decodeReaction } from './reaction-lib';
+import config from '@/config/index';
+import { query } from '@/prelude/url';
+
+const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
+
+/**
+ * 添付用絵文字情報
+ */
+type PopulatedEmoji = {
+ name: string;
+ url: string;
+};
+
+function normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
+ // クエリに使うホスト
+ let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
+ : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
+ : isSelfHost(src) ? null // 自ホスト指定
+ : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない)
+
+ host = toPunyNullable(host);
+
+ return host;
+}
+
+function parseEmojiStr(emojiName: string, noteUserHost: string | null) {
+ const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/);
+ if (!match) return { name: null, host: null };
+
+ const name = match[1];
+
+ // ホスト正規化
+ const host = toPunyNullable(normalizeHost(match[2], noteUserHost));
+
+ return { name, host };
+}
+
+/**
+ * 添付用絵文字情報を解決する
+ * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能))
+ * @param noteUserHost ノートやユーザープロフィールの所有者のホスト
+ * @returns 絵文字情報, nullは未マッチを意味する
+ */
+export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
+ const { name, host } = parseEmojiStr(emojiName, noteUserHost);
+ if (name == null) return null;
+
+ const queryOrNull = async () => (await Emojis.findOne({
+ name,
+ host
+ })) || null;
+
+ const emoji = await cache.fetch(`${name} ${host}`, queryOrNull);
+
+ if (emoji == null) return null;
+
+ const isLocal = emoji.host == null;
+ const url = isLocal ? emoji.url : `${config.url}/proxy/image.png?${query({url: emoji.url})}`;
+
+ return {
+ name: emojiName,
+ url,
+ };
+}
+
+/**
+ * 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される)
+ */
+export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> {
+ const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost)));
+ return emojis.filter((x): x is PopulatedEmoji => x != null);
+}
+
+export function aggregateNoteEmojis(notes: Note[]) {
+ let emojis: { name: string | null; host: string | null; }[] = [];
+ for (const note of notes) {
+ emojis = emojis.concat(note.emojis
+ .map(e => parseEmojiStr(e, note.userHost)));
+ if (note.renote) {
+ emojis = emojis.concat(note.renote.emojis
+ .map(e => parseEmojiStr(e, note.renote!.userHost)));
+ if (note.renote.user) {
+ emojis = emojis.concat(note.renote.user.emojis
+ .map(e => parseEmojiStr(e, note.renote!.userHost)));
+ }
+ }
+ const customReactions = Object.keys(note.reactions).map(x => decodeReaction(x)).filter(x => x.name != null) as typeof emojis;
+ emojis = emojis.concat(customReactions);
+ if (note.user) {
+ emojis = emojis.concat(note.user.emojis
+ .map(e => parseEmojiStr(e, note.userHost)));
+ }
+ }
+ return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[];
+}
+
+/**
+ * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します
+ */
+export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
+ const notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null);
+ const emojisQuery: any[] = [];
+ const hosts = new Set(notCachedEmojis.map(e => e.host));
+ for (const host of hosts) {
+ emojisQuery.push({
+ name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)),
+ host: host
+ });
+ }
+ const _emojis = emojisQuery.length > 0 ? await Emojis.find({
+ where: emojisQuery,
+ select: ['name', 'host', 'url']
+ }) : [];
+ for (const emoji of _emojis) {
+ cache.set(`${emoji.name} ${emoji.host}`, emoji);
+ }
+}
diff --git a/packages/backend/src/misc/reaction-lib.ts b/packages/backend/src/misc/reaction-lib.ts
new file mode 100644
index 0000000000..46dedfa24b
--- /dev/null
+++ b/packages/backend/src/misc/reaction-lib.ts
@@ -0,0 +1,129 @@
+import { emojiRegex } from './emoji-regex';
+import { fetchMeta } from './fetch-meta';
+import { Emojis } from '@/models/index';
+import { toPunyNullable } from './convert-host';
+
+const legacies: Record<string, string> = {
+ 'like': '👍',
+ 'love': '❤', // ここに記述する場合は異体字セレクタを入れない
+ 'laugh': '😆',
+ 'hmm': '🤔',
+ 'surprise': '😮',
+ 'congrats': '🎉',
+ 'angry': '💢',
+ 'confused': '😥',
+ 'rip': '😇',
+ 'pudding': '🍮',
+ 'star': '⭐',
+};
+
+export async function getFallbackReaction(): Promise<string> {
+ const meta = await fetchMeta();
+ return meta.useStarForReactionFallback ? '⭐' : '👍';
+}
+
+export function convertLegacyReactions(reactions: Record<string, number>) {
+ const _reactions = {} as Record<string, number>;
+
+ for (const reaction of Object.keys(reactions)) {
+ if (reactions[reaction] <= 0) continue;
+
+ if (Object.keys(legacies).includes(reaction)) {
+ if (_reactions[legacies[reaction]]) {
+ _reactions[legacies[reaction]] += reactions[reaction];
+ } else {
+ _reactions[legacies[reaction]] = reactions[reaction];
+ }
+ } else {
+ if (_reactions[reaction]) {
+ _reactions[reaction] += reactions[reaction];
+ } else {
+ _reactions[reaction] = reactions[reaction];
+ }
+ }
+ }
+
+ const _reactions2 = {} as Record<string, number>;
+
+ for (const reaction of Object.keys(_reactions)) {
+ _reactions2[decodeReaction(reaction).reaction] = _reactions[reaction];
+ }
+
+ return _reactions2;
+}
+
+export async function toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> {
+ if (reaction == null) return await getFallbackReaction();
+
+ reacterHost = toPunyNullable(reacterHost);
+
+ // 文字列タイプのリアクションを絵文字に変換
+ if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
+
+ // Unicode絵文字
+ const match = emojiRegex.exec(reaction);
+ if (match) {
+ // 合字を含む1つの絵文字
+ const unicode = match[0];
+
+ // 異体字セレクタ除去
+ return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
+ }
+
+ const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
+ if (custom) {
+ const name = custom[1];
+ const emoji = await Emojis.findOne({
+ host: reacterHost || null,
+ name,
+ });
+
+ if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
+ }
+
+ return await getFallbackReaction();
+}
+
+type DecodedReaction = {
+ /**
+ * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.')
+ */
+ reaction: string;
+
+ /**
+ * name (カスタム絵文字の場合name, Emojiクエリに使う)
+ */
+ name?: string;
+
+ /**
+ * host (カスタム絵文字の場合host, Emojiクエリに使う)
+ */
+ host?: string | null;
+};
+
+export function decodeReaction(str: string): DecodedReaction {
+ const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/);
+
+ if (custom) {
+ const name = custom[1];
+ const host = custom[2] || null;
+
+ return {
+ reaction: `:${name}@${host || '.'}:`, // ローカル分は@以降を省略するのではなく.にする
+ name,
+ host
+ };
+ }
+
+ return {
+ reaction: str,
+ name: undefined,
+ host: undefined
+ };
+}
+
+export function convertLegacyReaction(reaction: string): string {
+ reaction = decodeReaction(reaction).reaction;
+ if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
+ return reaction;
+}
diff --git a/packages/backend/src/misc/safe-for-sql.ts b/packages/backend/src/misc/safe-for-sql.ts
new file mode 100644
index 0000000000..02eb7f0a26
--- /dev/null
+++ b/packages/backend/src/misc/safe-for-sql.ts
@@ -0,0 +1,3 @@
+export function safeForSql(text: string): boolean {
+ return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text);
+}
diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts
new file mode 100644
index 0000000000..4131875ef7
--- /dev/null
+++ b/packages/backend/src/misc/schema.ts
@@ -0,0 +1,107 @@
+import { SimpleObj, SimpleSchema } from './simple-schema';
+import { packedUserSchema } from '@/models/repositories/user';
+import { packedNoteSchema } from '@/models/repositories/note';
+import { packedUserListSchema } from '@/models/repositories/user-list';
+import { packedAppSchema } from '@/models/repositories/app';
+import { packedMessagingMessageSchema } from '@/models/repositories/messaging-message';
+import { packedNotificationSchema } from '@/models/repositories/notification';
+import { packedDriveFileSchema } from '@/models/repositories/drive-file';
+import { packedDriveFolderSchema } from '@/models/repositories/drive-folder';
+import { packedFollowingSchema } from '@/models/repositories/following';
+import { packedMutingSchema } from '@/models/repositories/muting';
+import { packedBlockingSchema } from '@/models/repositories/blocking';
+import { packedNoteReactionSchema } from '@/models/repositories/note-reaction';
+import { packedHashtagSchema } from '@/models/repositories/hashtag';
+import { packedPageSchema } from '@/models/repositories/page';
+import { packedUserGroupSchema } from '@/models/repositories/user-group';
+import { packedNoteFavoriteSchema } from '@/models/repositories/note-favorite';
+import { packedChannelSchema } from '@/models/repositories/channel';
+import { packedAntennaSchema } from '@/models/repositories/antenna';
+import { packedClipSchema } from '@/models/repositories/clip';
+import { packedFederationInstanceSchema } from '@/models/repositories/federation-instance';
+import { packedQueueCountSchema } from '@/models/repositories/queue';
+import { packedGalleryPostSchema } from '@/models/repositories/gallery-post';
+import { packedEmojiSchema } from '@/models/repositories/emoji';
+import { packedReversiGameSchema } from '@/models/repositories/games/reversi/game';
+import { packedReversiMatchingSchema } from '@/models/repositories/games/reversi/matching';
+
+export const refs = {
+ User: packedUserSchema,
+ UserList: packedUserListSchema,
+ UserGroup: packedUserGroupSchema,
+ App: packedAppSchema,
+ MessagingMessage: packedMessagingMessageSchema,
+ Note: packedNoteSchema,
+ NoteReaction: packedNoteReactionSchema,
+ NoteFavorite: packedNoteFavoriteSchema,
+ Notification: packedNotificationSchema,
+ DriveFile: packedDriveFileSchema,
+ DriveFolder: packedDriveFolderSchema,
+ Following: packedFollowingSchema,
+ Muting: packedMutingSchema,
+ Blocking: packedBlockingSchema,
+ Hashtag: packedHashtagSchema,
+ Page: packedPageSchema,
+ Channel: packedChannelSchema,
+ QueueCount: packedQueueCountSchema,
+ Antenna: packedAntennaSchema,
+ Clip: packedClipSchema,
+ FederationInstance: packedFederationInstanceSchema,
+ GalleryPost: packedGalleryPostSchema,
+ Emoji: packedEmojiSchema,
+ ReversiGame: packedReversiGameSchema,
+ ReversiMatching: packedReversiMatchingSchema,
+};
+
+export type Packed<x extends keyof typeof refs> = ObjType<(typeof refs[x])['properties']>;
+
+export interface Schema extends SimpleSchema {
+ items?: Schema;
+ properties?: Obj;
+ ref?: keyof typeof refs;
+}
+
+type NonUndefinedPropertyNames<T extends Obj> = {
+ [K in keyof T]: T[K]['optional'] extends true ? never : K
+}[keyof T];
+
+type UndefinedPropertyNames<T extends Obj> = {
+ [K in keyof T]: T[K]['optional'] extends true ? K : never
+}[keyof T];
+
+type OnlyRequired<T extends Obj> = Pick<T, NonUndefinedPropertyNames<T>>;
+type OnlyOptional<T extends Obj> = Pick<T, UndefinedPropertyNames<T>>;
+
+export interface Obj extends SimpleObj { [key: string]: Schema; }
+
+export type ObjType<s extends Obj> =
+ { [P in keyof OnlyOptional<s>]?: SchemaType<s[P]> } &
+ { [P in keyof OnlyRequired<s>]: SchemaType<s[P]> };
+
+// https://qiita.com/hrsh7th@github/items/84e8968c3601009cdcf2
+type MyType<T extends Schema> = {
+ 0: any;
+ 1: SchemaType<T>;
+}[T extends Schema ? 1 : 0];
+
+type NullOrUndefined<p extends Schema, T> =
+ p['nullable'] extends true
+ ? p['optional'] extends true
+ ? (T | null | undefined)
+ : (T | null)
+ : p['optional'] extends true
+ ? (T | undefined)
+ : T;
+
+export type SchemaType<p extends Schema> =
+ p['type'] extends 'number' ? NullOrUndefined<p, number> :
+ p['type'] extends 'string' ? NullOrUndefined<p, string> :
+ p['type'] extends 'boolean' ? NullOrUndefined<p, boolean> :
+ p['type'] extends 'array' ? NullOrUndefined<p, MyType<NonNullable<p['items']>>[]> :
+ p['type'] extends 'object' ? (
+ p['ref'] extends keyof typeof refs
+ ? NullOrUndefined<p, Packed<p['ref']>>
+ : NullOrUndefined<p, ObjType<NonNullable<p['properties']>>>
+ ) :
+ p['type'] extends 'any' ? NullOrUndefined<p, any> :
+ any;
diff --git a/packages/backend/src/misc/secure-rndstr.ts b/packages/backend/src/misc/secure-rndstr.ts
new file mode 100644
index 0000000000..76ee1225eb
--- /dev/null
+++ b/packages/backend/src/misc/secure-rndstr.ts
@@ -0,0 +1,21 @@
+import * as crypto from 'crypto';
+
+const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';
+const LU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+export function secureRndstr(length = 32, useLU = true): string {
+ const chars = useLU ? LU_CHARS : L_CHARS;
+ const chars_len = chars.length;
+
+ let str = '';
+
+ for (let i = 0; i < length; i++) {
+ let rand = Math.floor((crypto.randomBytes(1).readUInt8(0) / 0xFF) * chars_len);
+ if (rand === chars_len) {
+ rand = chars_len - 1;
+ }
+ str += chars.charAt(rand);
+ }
+
+ return str;
+}
diff --git a/packages/backend/src/misc/show-machine-info.ts b/packages/backend/src/misc/show-machine-info.ts
new file mode 100644
index 0000000000..58747c1152
--- /dev/null
+++ b/packages/backend/src/misc/show-machine-info.ts
@@ -0,0 +1,13 @@
+import * as os from 'os';
+import * as sysUtils from 'systeminformation';
+import Logger from '@/services/logger';
+
+export async function showMachineInfo(parentLogger: Logger) {
+ const logger = parentLogger.createSubLogger('machine');
+ logger.debug(`Hostname: ${os.hostname()}`);
+ logger.debug(`Platform: ${process.platform} Arch: ${process.arch}`);
+ const mem = await sysUtils.mem();
+ const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1);
+ const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1);
+ logger.debug(`CPU: ${os.cpus().length} core MEM: ${totalmem}GB (available: ${availmem}GB)`);
+}
diff --git a/packages/backend/src/misc/simple-schema.ts b/packages/backend/src/misc/simple-schema.ts
new file mode 100644
index 0000000000..abbb348e24
--- /dev/null
+++ b/packages/backend/src/misc/simple-schema.ts
@@ -0,0 +1,15 @@
+export interface SimpleSchema {
+ type: 'boolean' | 'number' | 'string' | 'array' | 'object' | 'any';
+ nullable: boolean;
+ optional: boolean;
+ items?: SimpleSchema;
+ properties?: SimpleObj;
+ description?: string;
+ example?: any;
+ format?: string;
+ ref?: string;
+ enum?: string[];
+ default?: boolean | null;
+}
+
+export interface SimpleObj { [key: string]: SimpleSchema; }
diff --git a/packages/backend/src/misc/truncate.ts b/packages/backend/src/misc/truncate.ts
new file mode 100644
index 0000000000..cb120331a1
--- /dev/null
+++ b/packages/backend/src/misc/truncate.ts
@@ -0,0 +1,11 @@
+import { substring } from 'stringz';
+
+export function truncate(input: string, size: number): string;
+export function truncate(input: string | undefined, size: number): string | undefined;
+export function truncate(input: string | undefined, size: number): string | undefined {
+ if (!input) {
+ return input;
+ } else {
+ return substring(input, 0, size);
+ }
+}