79
packages/backend/src/boot/index.ts
Normal file
79
packages/backend/src/boot/index.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import * as cluster from 'cluster';
|
||||
import * as chalk from 'chalk';
|
||||
import Xev from 'xev';
|
||||
|
||||
import Logger from '@/services/logger';
|
||||
import { envOption } from '../env';
|
||||
|
||||
// for typeorm
|
||||
import 'reflect-metadata';
|
||||
import { masterMain } from './master';
|
||||
import { workerMain } from './worker';
|
||||
|
||||
const logger = new Logger('core', 'cyan');
|
||||
const clusterLogger = logger.createSubLogger('cluster', 'orange', false);
|
||||
const ev = new Xev();
|
||||
|
||||
/**
|
||||
* Init process
|
||||
*/
|
||||
export default async function() {
|
||||
process.title = `Misskey (${cluster.isPrimary ? 'master' : 'worker'})`;
|
||||
|
||||
if (cluster.isPrimary || envOption.disableClustering) {
|
||||
await masterMain();
|
||||
|
||||
if (cluster.isPrimary) {
|
||||
ev.mount();
|
||||
}
|
||||
}
|
||||
|
||||
if (cluster.isWorker || envOption.disableClustering) {
|
||||
await workerMain();
|
||||
}
|
||||
|
||||
// ユニットテスト時にMisskeyが子プロセスで起動された時のため
|
||||
// それ以外のときは process.send は使えないので弾く
|
||||
if (process.send) {
|
||||
process.send('ok');
|
||||
}
|
||||
}
|
||||
|
||||
//#region Events
|
||||
|
||||
// Listen new workers
|
||||
cluster.on('fork', worker => {
|
||||
clusterLogger.debug(`Process forked: [${worker.id}]`);
|
||||
});
|
||||
|
||||
// Listen online workers
|
||||
cluster.on('online', worker => {
|
||||
clusterLogger.debug(`Process is now online: [${worker.id}]`);
|
||||
});
|
||||
|
||||
// Listen for dying workers
|
||||
cluster.on('exit', worker => {
|
||||
// Replace the dead worker,
|
||||
// we're not sentimental
|
||||
clusterLogger.error(chalk.red(`[${worker.id}] died :(`));
|
||||
cluster.fork();
|
||||
});
|
||||
|
||||
// Display detail of unhandled promise rejection
|
||||
if (!envOption.quiet) {
|
||||
process.on('unhandledRejection', console.dir);
|
||||
}
|
||||
|
||||
// Display detail of uncaught exception
|
||||
process.on('uncaughtException', err => {
|
||||
try {
|
||||
logger.error(err);
|
||||
} catch { }
|
||||
});
|
||||
|
||||
// Dying away...
|
||||
process.on('exit', code => {
|
||||
logger.info(`The process is going to exit with code ${code}`);
|
||||
});
|
||||
|
||||
//#endregion
|
194
packages/backend/src/boot/master.ts
Normal file
194
packages/backend/src/boot/master.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import * as fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import * as os from 'os';
|
||||
import * as cluster from 'cluster';
|
||||
import * as chalk from 'chalk';
|
||||
import * as portscanner from 'portscanner';
|
||||
import { getConnection } from 'typeorm';
|
||||
|
||||
import Logger from '@/services/logger';
|
||||
import loadConfig from '@/config/load';
|
||||
import { Config } from '@/config/types';
|
||||
import { lessThan } from '@/prelude/array';
|
||||
import { envOption } from '../env';
|
||||
import { showMachineInfo } from '@/misc/show-machine-info';
|
||||
import { initDb } from '../db/postgre';
|
||||
|
||||
//const _filename = fileURLToPath(import.meta.url);
|
||||
const _filename = __filename;
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
|
||||
|
||||
const logger = new Logger('core', 'cyan');
|
||||
const bootLogger = logger.createSubLogger('boot', 'magenta', false);
|
||||
|
||||
function greet() {
|
||||
if (!envOption.quiet) {
|
||||
//#region Misskey logo
|
||||
const v = `v${meta.version}`;
|
||||
console.log(' _____ _ _ ');
|
||||
console.log(' | |_|___ ___| |_ ___ _ _ ');
|
||||
console.log(' | | | | |_ -|_ -| \'_| -_| | |');
|
||||
console.log(' |_|_|_|_|___|___|_,_|___|_ |');
|
||||
console.log(' ' + chalk.gray(v) + (' |___|\n'.substr(v.length)));
|
||||
//#endregion
|
||||
|
||||
console.log(' Misskey is an open-source decentralized microblogging platform.');
|
||||
console.log(chalk.keyword('orange')(' If you like Misskey, please donate to support development. https://www.patreon.com/syuilo'));
|
||||
|
||||
console.log('');
|
||||
console.log(chalk`--- ${os.hostname()} {gray (PID: ${process.pid.toString()})} ---`);
|
||||
}
|
||||
|
||||
bootLogger.info('Welcome to Misskey!');
|
||||
bootLogger.info(`Misskey v${meta.version}`, null, true);
|
||||
}
|
||||
|
||||
function isRoot() {
|
||||
// maybe process.getuid will be undefined under not POSIX environment (e.g. Windows)
|
||||
return process.getuid != null && process.getuid() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Init master process
|
||||
*/
|
||||
export async function masterMain() {
|
||||
let config!: Config;
|
||||
|
||||
// initialize app
|
||||
try {
|
||||
greet();
|
||||
showEnvironment();
|
||||
await showMachineInfo(bootLogger);
|
||||
showNodejsVersion();
|
||||
config = loadConfigBoot();
|
||||
await connectDb();
|
||||
await validatePort(config);
|
||||
} catch (e) {
|
||||
bootLogger.error('Fatal error occurred during initialization', null, true);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
bootLogger.succ('Misskey initialized');
|
||||
|
||||
if (!envOption.disableClustering) {
|
||||
await spawnWorkers(config.clusterLimit);
|
||||
}
|
||||
|
||||
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
|
||||
|
||||
if (!envOption.noDaemons) {
|
||||
require('../daemons/server-stats').default();
|
||||
require('../daemons/queue-stats').default();
|
||||
require('../daemons/janitor').default();
|
||||
}
|
||||
}
|
||||
|
||||
const runningNodejsVersion = process.version.slice(1).split('.').map(x => parseInt(x, 10));
|
||||
const requiredNodejsVersion = [11, 7, 0];
|
||||
const satisfyNodejsVersion = !lessThan(runningNodejsVersion, requiredNodejsVersion);
|
||||
|
||||
function showEnvironment(): void {
|
||||
const env = process.env.NODE_ENV;
|
||||
const logger = bootLogger.createSubLogger('env');
|
||||
logger.info(typeof env === 'undefined' ? 'NODE_ENV is not set' : `NODE_ENV: ${env}`);
|
||||
|
||||
if (env !== 'production') {
|
||||
logger.warn('The environment is not in production mode.');
|
||||
logger.warn('DO NOT USE FOR PRODUCTION PURPOSE!', null, true);
|
||||
}
|
||||
|
||||
logger.info(`You ${isRoot() ? '' : 'do not '}have root privileges`);
|
||||
}
|
||||
|
||||
function showNodejsVersion(): void {
|
||||
const nodejsLogger = bootLogger.createSubLogger('nodejs');
|
||||
|
||||
nodejsLogger.info(`Version ${runningNodejsVersion.join('.')}`);
|
||||
|
||||
if (!satisfyNodejsVersion) {
|
||||
nodejsLogger.error(`Node.js version is less than ${requiredNodejsVersion.join('.')}. Please upgrade it.`, null, true);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function loadConfigBoot(): Config {
|
||||
const configLogger = bootLogger.createSubLogger('config');
|
||||
let config;
|
||||
|
||||
try {
|
||||
config = loadConfig();
|
||||
} catch (exception) {
|
||||
if (typeof exception === 'string') {
|
||||
configLogger.error(exception);
|
||||
process.exit(1);
|
||||
}
|
||||
if (exception.code === 'ENOENT') {
|
||||
configLogger.error('Configuration file not found', null, true);
|
||||
process.exit(1);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
|
||||
configLogger.succ('Loaded');
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function connectDb(): Promise<void> {
|
||||
const dbLogger = bootLogger.createSubLogger('db');
|
||||
|
||||
// Try to connect to DB
|
||||
try {
|
||||
dbLogger.info('Connecting...');
|
||||
await initDb();
|
||||
const v = await getConnection().query('SHOW server_version').then(x => x[0].server_version);
|
||||
dbLogger.succ(`Connected: v${v}`);
|
||||
} catch (e) {
|
||||
dbLogger.error('Cannot connect', null, true);
|
||||
dbLogger.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function validatePort(config: Config): Promise<void> {
|
||||
const isWellKnownPort = (port: number) => port < 1024;
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return await portscanner.checkPortStatus(port, '127.0.0.1') === 'closed';
|
||||
}
|
||||
|
||||
if (config.port == null || Number.isNaN(config.port)) {
|
||||
bootLogger.error('The port is not configured. Please configure port.', null, true);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.platform === 'linux' && isWellKnownPort(config.port) && !isRoot()) {
|
||||
bootLogger.error('You need root privileges to listen on well-known port on Linux', null, true);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!await isPortAvailable(config.port)) {
|
||||
bootLogger.error(`Port ${config.port} is already in use`, null, true);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnWorkers(limit: number = 1) {
|
||||
const workers = Math.min(limit, os.cpus().length);
|
||||
bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`);
|
||||
await Promise.all([...Array(workers)].map(spawnWorker));
|
||||
bootLogger.succ('All workers started');
|
||||
}
|
||||
|
||||
function spawnWorker(): Promise<void> {
|
||||
return new Promise(res => {
|
||||
const worker = cluster.fork();
|
||||
worker.on('message', message => {
|
||||
if (message !== 'ready') return;
|
||||
res();
|
||||
});
|
||||
});
|
||||
}
|
20
packages/backend/src/boot/worker.ts
Normal file
20
packages/backend/src/boot/worker.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import * as cluster from 'cluster';
|
||||
import { initDb } from '../db/postgre';
|
||||
|
||||
/**
|
||||
* Init worker process
|
||||
*/
|
||||
export async function workerMain() {
|
||||
await initDb();
|
||||
|
||||
// start server
|
||||
await require('../server').default();
|
||||
|
||||
// start job queue
|
||||
require('../queue').default();
|
||||
|
||||
if (cluster.isWorker) {
|
||||
// Send a 'ready' message to parent process
|
||||
process.send!('ready');
|
||||
}
|
||||
}
|
3
packages/backend/src/config/index.ts
Normal file
3
packages/backend/src/config/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import load from './load';
|
||||
|
||||
export default load();
|
61
packages/backend/src/config/load.ts
Normal file
61
packages/backend/src/config/load.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Config loader
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { Source, Mixin } from './types';
|
||||
|
||||
//const _filename = fileURLToPath(import.meta.url);
|
||||
const _filename = __filename;
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
/**
|
||||
* Path of configuration directory
|
||||
*/
|
||||
const dir = `${_dirname}/../../../../.config`;
|
||||
|
||||
/**
|
||||
* Path of configuration file
|
||||
*/
|
||||
const path = process.env.NODE_ENV === 'test'
|
||||
? `${dir}/test.yml`
|
||||
: `${dir}/default.yml`;
|
||||
|
||||
export default function load() {
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
const mixin = {} as Mixin;
|
||||
|
||||
const url = tryCreateUrl(config.url);
|
||||
|
||||
config.url = url.origin;
|
||||
|
||||
config.port = config.port || parseInt(process.env.PORT || '', 10);
|
||||
|
||||
mixin.version = meta.version;
|
||||
mixin.host = url.host;
|
||||
mixin.hostname = url.hostname;
|
||||
mixin.scheme = url.protocol.replace(/:$/, '');
|
||||
mixin.wsScheme = mixin.scheme.replace('http', 'ws');
|
||||
mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`;
|
||||
mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`;
|
||||
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
|
||||
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
|
||||
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
|
||||
|
||||
if (!config.redis.prefix) config.redis.prefix = mixin.host;
|
||||
|
||||
return Object.assign(config, mixin);
|
||||
}
|
||||
|
||||
function tryCreateUrl(url: string) {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
throw `url="${url}" is not a valid URL.`;
|
||||
}
|
||||
}
|
85
packages/backend/src/config/types.ts
Normal file
85
packages/backend/src/config/types.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* ユーザーが設定する必要のある情報
|
||||
*/
|
||||
export type Source = {
|
||||
repository_url?: string;
|
||||
feedback_url?: string;
|
||||
url: string;
|
||||
port: number;
|
||||
https?: { [x: string]: string };
|
||||
disableHsts?: boolean;
|
||||
db: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
redis: {
|
||||
host: string;
|
||||
port: number;
|
||||
pass: string;
|
||||
db?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
elasticsearch: {
|
||||
host: string;
|
||||
port: number;
|
||||
ssl?: boolean;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
index?: string;
|
||||
};
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
|
||||
maxFileSize?: number;
|
||||
|
||||
accesslog?: string;
|
||||
|
||||
clusterLimit?: number;
|
||||
|
||||
id: string;
|
||||
|
||||
outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual';
|
||||
|
||||
deliverJobConcurrency?: number;
|
||||
inboxJobConcurrency?: number;
|
||||
deliverJobPerSec?: number;
|
||||
inboxJobPerSec?: number;
|
||||
deliverJobMaxAttempts?: number;
|
||||
inboxJobMaxAttempts?: number;
|
||||
|
||||
syslog: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
mediaProxy?: string;
|
||||
|
||||
signToActivityPubGet?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報
|
||||
*/
|
||||
export type Mixin = {
|
||||
version: string;
|
||||
host: string;
|
||||
hostname: string;
|
||||
scheme: string;
|
||||
wsScheme: string;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
authUrl: string;
|
||||
driveUrl: string;
|
||||
userAgent: string;
|
||||
};
|
||||
|
||||
export type Config = Source & Mixin;
|
2
packages/backend/src/const.ts
Normal file
2
packages/backend/src/const.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
|
||||
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days
|
20
packages/backend/src/daemons/janitor.ts
Normal file
20
packages/backend/src/daemons/janitor.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// TODO: 消したい
|
||||
|
||||
const interval = 30 * 60 * 1000;
|
||||
import { AttestationChallenges } from '@/models/index';
|
||||
import { LessThan } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Clean up database occasionally
|
||||
*/
|
||||
export default function() {
|
||||
async function tick() {
|
||||
await AttestationChallenges.delete({
|
||||
createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000))
|
||||
});
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
setInterval(tick, interval);
|
||||
}
|
60
packages/backend/src/daemons/queue-stats.ts
Normal file
60
packages/backend/src/daemons/queue-stats.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import Xev from 'xev';
|
||||
import { deliverQueue, inboxQueue } from '../queue/queues';
|
||||
|
||||
const ev = new Xev();
|
||||
|
||||
const interval = 10000;
|
||||
|
||||
/**
|
||||
* Report queue stats regularly
|
||||
*/
|
||||
export default function() {
|
||||
const log = [] as any[];
|
||||
|
||||
ev.on('requestQueueStatsLog', x => {
|
||||
ev.emit(`queueStatsLog:${x.id}`, log.slice(0, x.length || 50));
|
||||
});
|
||||
|
||||
let activeDeliverJobs = 0;
|
||||
let activeInboxJobs = 0;
|
||||
|
||||
deliverQueue.on('global:active', () => {
|
||||
activeDeliverJobs++;
|
||||
});
|
||||
|
||||
inboxQueue.on('global:active', () => {
|
||||
activeInboxJobs++;
|
||||
});
|
||||
|
||||
async function tick() {
|
||||
const deliverJobCounts = await deliverQueue.getJobCounts();
|
||||
const inboxJobCounts = await inboxQueue.getJobCounts();
|
||||
|
||||
const stats = {
|
||||
deliver: {
|
||||
activeSincePrevTick: activeDeliverJobs,
|
||||
active: deliverJobCounts.active,
|
||||
waiting: deliverJobCounts.waiting,
|
||||
delayed: deliverJobCounts.delayed
|
||||
},
|
||||
inbox: {
|
||||
activeSincePrevTick: activeInboxJobs,
|
||||
active: inboxJobCounts.active,
|
||||
waiting: inboxJobCounts.waiting,
|
||||
delayed: inboxJobCounts.delayed
|
||||
},
|
||||
};
|
||||
|
||||
ev.emit('queueStats', stats);
|
||||
|
||||
log.unshift(stats);
|
||||
if (log.length > 200) log.pop();
|
||||
|
||||
activeDeliverJobs = 0;
|
||||
activeInboxJobs = 0;
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
setInterval(tick, interval);
|
||||
}
|
79
packages/backend/src/daemons/server-stats.ts
Normal file
79
packages/backend/src/daemons/server-stats.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import * as si from 'systeminformation';
|
||||
import Xev from 'xev';
|
||||
import * as osUtils from 'os-utils';
|
||||
|
||||
const ev = new Xev();
|
||||
|
||||
const interval = 2000;
|
||||
|
||||
const roundCpu = (num: number) => Math.round(num * 1000) / 1000;
|
||||
const round = (num: number) => Math.round(num * 10) / 10;
|
||||
|
||||
/**
|
||||
* Report server stats regularly
|
||||
*/
|
||||
export default function() {
|
||||
const log = [] as any[];
|
||||
|
||||
ev.on('requestServerStatsLog', x => {
|
||||
ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length || 50));
|
||||
});
|
||||
|
||||
async function tick() {
|
||||
const cpu = await cpuUsage();
|
||||
const memStats = await mem();
|
||||
const netStats = await net();
|
||||
const fsStats = await fs();
|
||||
|
||||
const stats = {
|
||||
cpu: roundCpu(cpu),
|
||||
mem: {
|
||||
used: round(memStats.used - memStats.buffers - memStats.cached),
|
||||
active: round(memStats.active),
|
||||
},
|
||||
net: {
|
||||
rx: round(Math.max(0, netStats.rx_sec)),
|
||||
tx: round(Math.max(0, netStats.tx_sec)),
|
||||
},
|
||||
fs: {
|
||||
r: round(Math.max(0, fsStats.rIO_sec)),
|
||||
w: round(Math.max(0, fsStats.wIO_sec)),
|
||||
}
|
||||
};
|
||||
ev.emit('serverStats', stats);
|
||||
log.unshift(stats);
|
||||
if (log.length > 200) log.pop();
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
setInterval(tick, interval);
|
||||
}
|
||||
|
||||
// CPU STAT
|
||||
function cpuUsage() {
|
||||
return new Promise((res, rej) => {
|
||||
osUtils.cpuUsage((cpuUsage: number) => {
|
||||
res(cpuUsage);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// MEMORY STAT
|
||||
async function mem() {
|
||||
const data = await si.mem();
|
||||
return data;
|
||||
}
|
||||
|
||||
// NETWORK STAT
|
||||
async function net() {
|
||||
const iface = await si.networkInterfaceDefault();
|
||||
const data = await si.networkStats(iface);
|
||||
return data[0];
|
||||
}
|
||||
|
||||
// FS STAT
|
||||
async function fs() {
|
||||
const data = await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 }));
|
||||
return data || { rIO_sec: 0, wIO_sec: 0 };
|
||||
}
|
56
packages/backend/src/db/elasticsearch.ts
Normal file
56
packages/backend/src/db/elasticsearch.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import * as elasticsearch from '@elastic/elasticsearch';
|
||||
import config from '@/config/index';
|
||||
|
||||
const index = {
|
||||
settings: {
|
||||
analysis: {
|
||||
analyzer: {
|
||||
ngram: {
|
||||
tokenizer: 'ngram'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mappings: {
|
||||
properties: {
|
||||
text: {
|
||||
type: 'text',
|
||||
index: true,
|
||||
analyzer: 'ngram',
|
||||
},
|
||||
userId: {
|
||||
type: 'keyword',
|
||||
index: true,
|
||||
},
|
||||
userHost: {
|
||||
type: 'keyword',
|
||||
index: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Init ElasticSearch connection
|
||||
const client = config.elasticsearch ? new elasticsearch.Client({
|
||||
node: `${config.elasticsearch.ssl ? 'https://' : 'http://'}${config.elasticsearch.host}:${config.elasticsearch.port}`,
|
||||
auth: (config.elasticsearch.user && config.elasticsearch.pass) ? {
|
||||
username: config.elasticsearch.user,
|
||||
password: config.elasticsearch.pass
|
||||
} : undefined,
|
||||
pingTimeout: 30000
|
||||
}) : null;
|
||||
|
||||
if (client) {
|
||||
client.indices.exists({
|
||||
index: config.elasticsearch.index || 'misskey_note',
|
||||
}).then(exist => {
|
||||
if (!exist.body) {
|
||||
client.indices.create({
|
||||
index: config.elasticsearch.index || 'misskey_note',
|
||||
body: index
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default client;
|
3
packages/backend/src/db/logger.ts
Normal file
3
packages/backend/src/db/logger.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import Logger from '@/services/logger';
|
||||
|
||||
export const dbLogger = new Logger('db');
|
225
packages/backend/src/db/postgre.ts
Normal file
225
packages/backend/src/db/postgre.ts
Normal file
@ -0,0 +1,225 @@
|
||||
// https://github.com/typeorm/typeorm/issues/2400
|
||||
const types = require('pg').types;
|
||||
types.setTypeParser(20, Number);
|
||||
|
||||
import { createConnection, Logger, getConnection } from 'typeorm';
|
||||
import config from '@/config/index';
|
||||
import { entities as charts } from '@/services/chart/entities';
|
||||
import { dbLogger } from './logger';
|
||||
import * as highlight from 'cli-highlight';
|
||||
|
||||
import { User } from '@/models/entities/user';
|
||||
import { DriveFile } from '@/models/entities/drive-file';
|
||||
import { DriveFolder } from '@/models/entities/drive-folder';
|
||||
import { AccessToken } from '@/models/entities/access-token';
|
||||
import { App } from '@/models/entities/app';
|
||||
import { PollVote } from '@/models/entities/poll-vote';
|
||||
import { Note } from '@/models/entities/note';
|
||||
import { NoteReaction } from '@/models/entities/note-reaction';
|
||||
import { NoteWatching } from '@/models/entities/note-watching';
|
||||
import { NoteThreadMuting } from '@/models/entities/note-thread-muting';
|
||||
import { NoteUnread } from '@/models/entities/note-unread';
|
||||
import { Notification } from '@/models/entities/notification';
|
||||
import { Meta } from '@/models/entities/meta';
|
||||
import { Following } from '@/models/entities/following';
|
||||
import { Instance } from '@/models/entities/instance';
|
||||
import { Muting } from '@/models/entities/muting';
|
||||
import { SwSubscription } from '@/models/entities/sw-subscription';
|
||||
import { Blocking } from '@/models/entities/blocking';
|
||||
import { UserList } from '@/models/entities/user-list';
|
||||
import { UserListJoining } from '@/models/entities/user-list-joining';
|
||||
import { UserGroup } from '@/models/entities/user-group';
|
||||
import { UserGroupJoining } from '@/models/entities/user-group-joining';
|
||||
import { UserGroupInvitation } from '@/models/entities/user-group-invitation';
|
||||
import { Hashtag } from '@/models/entities/hashtag';
|
||||
import { NoteFavorite } from '@/models/entities/note-favorite';
|
||||
import { AbuseUserReport } from '@/models/entities/abuse-user-report';
|
||||
import { RegistrationTicket } from '@/models/entities/registration-tickets';
|
||||
import { MessagingMessage } from '@/models/entities/messaging-message';
|
||||
import { Signin } from '@/models/entities/signin';
|
||||
import { AuthSession } from '@/models/entities/auth-session';
|
||||
import { FollowRequest } from '@/models/entities/follow-request';
|
||||
import { Emoji } from '@/models/entities/emoji';
|
||||
import { ReversiGame } from '@/models/entities/games/reversi/game';
|
||||
import { ReversiMatching } from '@/models/entities/games/reversi/matching';
|
||||
import { UserNotePining } from '@/models/entities/user-note-pining';
|
||||
import { Poll } from '@/models/entities/poll';
|
||||
import { UserKeypair } from '@/models/entities/user-keypair';
|
||||
import { UserPublickey } from '@/models/entities/user-publickey';
|
||||
import { UserProfile } from '@/models/entities/user-profile';
|
||||
import { UserSecurityKey } from '@/models/entities/user-security-key';
|
||||
import { AttestationChallenge } from '@/models/entities/attestation-challenge';
|
||||
import { Page } from '@/models/entities/page';
|
||||
import { PageLike } from '@/models/entities/page-like';
|
||||
import { GalleryPost } from '@/models/entities/gallery-post';
|
||||
import { GalleryLike } from '@/models/entities/gallery-like';
|
||||
import { ModerationLog } from '@/models/entities/moderation-log';
|
||||
import { UsedUsername } from '@/models/entities/used-username';
|
||||
import { Announcement } from '@/models/entities/announcement';
|
||||
import { AnnouncementRead } from '@/models/entities/announcement-read';
|
||||
import { Clip } from '@/models/entities/clip';
|
||||
import { ClipNote } from '@/models/entities/clip-note';
|
||||
import { Antenna } from '@/models/entities/antenna';
|
||||
import { AntennaNote } from '@/models/entities/antenna-note';
|
||||
import { PromoNote } from '@/models/entities/promo-note';
|
||||
import { PromoRead } from '@/models/entities/promo-read';
|
||||
import { envOption } from '../env';
|
||||
import { Relay } from '@/models/entities/relay';
|
||||
import { MutedNote } from '@/models/entities/muted-note';
|
||||
import { Channel } from '@/models/entities/channel';
|
||||
import { ChannelFollowing } from '@/models/entities/channel-following';
|
||||
import { ChannelNotePining } from '@/models/entities/channel-note-pining';
|
||||
import { RegistryItem } from '@/models/entities/registry-item';
|
||||
import { Ad } from '@/models/entities/ad';
|
||||
import { PasswordResetRequest } from '@/models/entities/password-reset-request';
|
||||
import { UserPending } from '@/models/entities/user-pending';
|
||||
|
||||
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
|
||||
|
||||
class MyCustomLogger implements Logger {
|
||||
private highlight(sql: string) {
|
||||
return highlight.highlight(sql, {
|
||||
language: 'sql', ignoreIllegals: true,
|
||||
});
|
||||
}
|
||||
|
||||
public logQuery(query: string, parameters?: any[]) {
|
||||
if (envOption.verbose) {
|
||||
sqlLogger.info(this.highlight(query));
|
||||
}
|
||||
}
|
||||
|
||||
public logQueryError(error: string, query: string, parameters?: any[]) {
|
||||
sqlLogger.error(this.highlight(query));
|
||||
}
|
||||
|
||||
public logQuerySlow(time: number, query: string, parameters?: any[]) {
|
||||
sqlLogger.warn(this.highlight(query));
|
||||
}
|
||||
|
||||
public logSchemaBuild(message: string) {
|
||||
sqlLogger.info(message);
|
||||
}
|
||||
|
||||
public log(message: string) {
|
||||
sqlLogger.info(message);
|
||||
}
|
||||
|
||||
public logMigration(message: string) {
|
||||
sqlLogger.info(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const entities = [
|
||||
Announcement,
|
||||
AnnouncementRead,
|
||||
Meta,
|
||||
Instance,
|
||||
App,
|
||||
AuthSession,
|
||||
AccessToken,
|
||||
User,
|
||||
UserProfile,
|
||||
UserKeypair,
|
||||
UserPublickey,
|
||||
UserList,
|
||||
UserListJoining,
|
||||
UserGroup,
|
||||
UserGroupJoining,
|
||||
UserGroupInvitation,
|
||||
UserNotePining,
|
||||
UserSecurityKey,
|
||||
UsedUsername,
|
||||
AttestationChallenge,
|
||||
Following,
|
||||
FollowRequest,
|
||||
Muting,
|
||||
Blocking,
|
||||
Note,
|
||||
NoteFavorite,
|
||||
NoteReaction,
|
||||
NoteWatching,
|
||||
NoteThreadMuting,
|
||||
NoteUnread,
|
||||
Page,
|
||||
PageLike,
|
||||
GalleryPost,
|
||||
GalleryLike,
|
||||
DriveFile,
|
||||
DriveFolder,
|
||||
Poll,
|
||||
PollVote,
|
||||
Notification,
|
||||
Emoji,
|
||||
Hashtag,
|
||||
SwSubscription,
|
||||
AbuseUserReport,
|
||||
RegistrationTicket,
|
||||
MessagingMessage,
|
||||
Signin,
|
||||
ModerationLog,
|
||||
Clip,
|
||||
ClipNote,
|
||||
Antenna,
|
||||
AntennaNote,
|
||||
PromoNote,
|
||||
PromoRead,
|
||||
ReversiGame,
|
||||
ReversiMatching,
|
||||
Relay,
|
||||
MutedNote,
|
||||
Channel,
|
||||
ChannelFollowing,
|
||||
ChannelNotePining,
|
||||
RegistryItem,
|
||||
Ad,
|
||||
PasswordResetRequest,
|
||||
UserPending,
|
||||
...charts as any
|
||||
];
|
||||
|
||||
export function initDb(justBorrow = false, sync = false, forceRecreate = false) {
|
||||
if (!forceRecreate) {
|
||||
try {
|
||||
const conn = getConnection();
|
||||
return Promise.resolve(conn);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const log = process.env.NODE_ENV != 'production';
|
||||
|
||||
return createConnection({
|
||||
type: 'postgres',
|
||||
host: config.db.host,
|
||||
port: config.db.port,
|
||||
username: config.db.user,
|
||||
password: config.db.pass,
|
||||
database: config.db.db,
|
||||
extra: config.db.extra,
|
||||
synchronize: process.env.NODE_ENV === 'test' || sync,
|
||||
dropSchema: process.env.NODE_ENV === 'test' && !justBorrow,
|
||||
cache: !config.db.disableCache ? {
|
||||
type: 'redis',
|
||||
options: {
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
password: config.redis.pass,
|
||||
prefix: `${config.redis.prefix}:query:`,
|
||||
db: config.redis.db || 0
|
||||
}
|
||||
} : false,
|
||||
logging: log,
|
||||
logger: log ? new MyCustomLogger() : undefined,
|
||||
entities: entities
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetDb() {
|
||||
const conn = await getConnection();
|
||||
const tables = await conn.query(`SELECT relname AS "table"
|
||||
FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
|
||||
WHERE nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
AND C.relkind = 'r'
|
||||
AND nspname !~ '^pg_toast';`);
|
||||
await Promise.all(tables.map(t => t.table).map(x => conn.query(`DELETE FROM "${x}" CASCADE`)));
|
||||
}
|
19
packages/backend/src/db/redis.ts
Normal file
19
packages/backend/src/db/redis.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import * as redis from 'redis';
|
||||
import config from '@/config/index';
|
||||
|
||||
export function createConnection() {
|
||||
return redis.createClient(
|
||||
config.redis.port,
|
||||
config.redis.host,
|
||||
{
|
||||
password: config.redis.pass,
|
||||
prefix: config.redis.prefix,
|
||||
db: config.redis.db || 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export const subsdcriber = createConnection();
|
||||
subsdcriber.subscribe(config.host);
|
||||
|
||||
export const redisClient = createConnection();
|
20
packages/backend/src/env.ts
Normal file
20
packages/backend/src/env.ts
Normal file
@ -0,0 +1,20 @@
|
||||
const envOption = {
|
||||
onlyQueue: false,
|
||||
onlyServer: false,
|
||||
noDaemons: false,
|
||||
disableClustering: false,
|
||||
verbose: false,
|
||||
withLogTime: false,
|
||||
quiet: false,
|
||||
slow: false,
|
||||
};
|
||||
|
||||
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {
|
||||
if (process.env['MK_' + key.replace(/[A-Z]/g, letter => `_${letter}`).toUpperCase()]) envOption[key] = true;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') envOption.disableClustering = true;
|
||||
if (process.env.NODE_ENV === 'test') envOption.quiet = true;
|
||||
if (process.env.NODE_ENV === 'test') envOption.noDaemons = true;
|
||||
|
||||
export { envOption };
|
263
packages/backend/src/games/reversi/core.ts
Normal file
263
packages/backend/src/games/reversi/core.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import { count, concat } from '@/prelude/array';
|
||||
|
||||
// MISSKEY REVERSI ENGINE
|
||||
|
||||
/**
|
||||
* true ... 黒
|
||||
* false ... 白
|
||||
*/
|
||||
export type Color = boolean;
|
||||
const BLACK = true;
|
||||
const WHITE = false;
|
||||
|
||||
export type MapPixel = 'null' | 'empty';
|
||||
|
||||
export type Options = {
|
||||
isLlotheo: boolean;
|
||||
canPutEverywhere: boolean;
|
||||
loopedBoard: boolean;
|
||||
};
|
||||
|
||||
export type Undo = {
|
||||
/**
|
||||
* 色
|
||||
*/
|
||||
color: Color;
|
||||
|
||||
/**
|
||||
* どこに打ったか
|
||||
*/
|
||||
pos: number;
|
||||
|
||||
/**
|
||||
* 反転した石の位置の配列
|
||||
*/
|
||||
effects: number[];
|
||||
|
||||
/**
|
||||
* ターン
|
||||
*/
|
||||
turn: Color | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* リバーシエンジン
|
||||
*/
|
||||
export default class Reversi {
|
||||
public map: MapPixel[];
|
||||
public mapWidth: number;
|
||||
public mapHeight: number;
|
||||
public board: (Color | null | undefined)[];
|
||||
public turn: Color | null = BLACK;
|
||||
public opts: Options;
|
||||
|
||||
public prevPos = -1;
|
||||
public prevColor: Color | null = null;
|
||||
|
||||
private logs: Undo[] = [];
|
||||
|
||||
/**
|
||||
* ゲームを初期化します
|
||||
*/
|
||||
constructor(map: string[], opts: Options) {
|
||||
//#region binds
|
||||
this.put = this.put.bind(this);
|
||||
//#endregion
|
||||
|
||||
//#region Options
|
||||
this.opts = opts;
|
||||
if (this.opts.isLlotheo == null) this.opts.isLlotheo = false;
|
||||
if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false;
|
||||
if (this.opts.loopedBoard == null) this.opts.loopedBoard = false;
|
||||
//#endregion
|
||||
|
||||
//#region Parse map data
|
||||
this.mapWidth = map[0].length;
|
||||
this.mapHeight = map.length;
|
||||
const mapData = map.join('');
|
||||
|
||||
this.board = mapData.split('').map(d => d === '-' ? null : d === 'b' ? BLACK : d === 'w' ? WHITE : undefined);
|
||||
|
||||
this.map = mapData.split('').map(d => d === '-' || d === 'b' || d === 'w' ? 'empty' : 'null');
|
||||
//#endregion
|
||||
|
||||
// ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある
|
||||
if (!this.canPutSomewhere(BLACK))
|
||||
this.turn = this.canPutSomewhere(WHITE) ? WHITE : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 黒石の数
|
||||
*/
|
||||
public get blackCount() {
|
||||
return count(BLACK, this.board);
|
||||
}
|
||||
|
||||
/**
|
||||
* 白石の数
|
||||
*/
|
||||
public get whiteCount() {
|
||||
return count(WHITE, this.board);
|
||||
}
|
||||
|
||||
public transformPosToXy(pos: number): number[] {
|
||||
const x = pos % this.mapWidth;
|
||||
const y = Math.floor(pos / this.mapWidth);
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
public transformXyToPos(x: number, y: number): number {
|
||||
return x + (y * this.mapWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定のマスに石を打ちます
|
||||
* @param color 石の色
|
||||
* @param pos 位置
|
||||
*/
|
||||
public put(color: Color, pos: number) {
|
||||
this.prevPos = pos;
|
||||
this.prevColor = color;
|
||||
|
||||
this.board[pos] = color;
|
||||
|
||||
// 反転させられる石を取得
|
||||
const effects = this.effects(color, pos);
|
||||
|
||||
// 反転させる
|
||||
for (const pos of effects) {
|
||||
this.board[pos] = color;
|
||||
}
|
||||
|
||||
const turn = this.turn;
|
||||
|
||||
this.logs.push({
|
||||
color,
|
||||
pos,
|
||||
effects,
|
||||
turn
|
||||
});
|
||||
|
||||
this.calcTurn();
|
||||
}
|
||||
|
||||
private calcTurn() {
|
||||
// ターン計算
|
||||
this.turn =
|
||||
this.canPutSomewhere(!this.prevColor) ? !this.prevColor :
|
||||
this.canPutSomewhere(this.prevColor!) ? this.prevColor :
|
||||
null;
|
||||
}
|
||||
|
||||
public undo() {
|
||||
const undo = this.logs.pop()!;
|
||||
this.prevColor = undo.color;
|
||||
this.prevPos = undo.pos;
|
||||
this.board[undo.pos] = null;
|
||||
for (const pos of undo.effects) {
|
||||
const color = this.board[pos];
|
||||
this.board[pos] = !color;
|
||||
}
|
||||
this.turn = undo.turn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定した位置のマップデータのマスを取得します
|
||||
* @param pos 位置
|
||||
*/
|
||||
public mapDataGet(pos: number): MapPixel {
|
||||
const [x, y] = this.transformPosToXy(pos);
|
||||
return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? 'null' : this.map[pos];
|
||||
}
|
||||
|
||||
/**
|
||||
* 打つことができる場所を取得します
|
||||
*/
|
||||
public puttablePlaces(color: Color): number[] {
|
||||
return Array.from(this.board.keys()).filter(i => this.canPut(color, i));
|
||||
}
|
||||
|
||||
/**
|
||||
* 打つことができる場所があるかどうかを取得します
|
||||
*/
|
||||
public canPutSomewhere(color: Color): boolean {
|
||||
return this.puttablePlaces(color).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定のマスに石を打つことができるかどうかを取得します
|
||||
* @param color 自分の色
|
||||
* @param pos 位置
|
||||
*/
|
||||
public canPut(color: Color, pos: number): boolean {
|
||||
return (
|
||||
this.board[pos] !== null ? false : // 既に石が置いてある場所には打てない
|
||||
this.opts.canPutEverywhere ? this.mapDataGet(pos) == 'empty' : // 挟んでなくても置けるモード
|
||||
this.effects(color, pos).length !== 0); // 相手の石を1つでも反転させられるか
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定のマスに石を置いた時の、反転させられる石を取得します
|
||||
* @param color 自分の色
|
||||
* @param initPos 位置
|
||||
*/
|
||||
public effects(color: Color, initPos: number): number[] {
|
||||
const enemyColor = !color;
|
||||
|
||||
const diffVectors: [number, number][] = [
|
||||
[ 0, -1], // 上
|
||||
[ +1, -1], // 右上
|
||||
[ +1, 0], // 右
|
||||
[ +1, +1], // 右下
|
||||
[ 0, +1], // 下
|
||||
[ -1, +1], // 左下
|
||||
[ -1, 0], // 左
|
||||
[ -1, -1] // 左上
|
||||
];
|
||||
|
||||
const effectsInLine = ([dx, dy]: [number, number]): number[] => {
|
||||
const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy];
|
||||
|
||||
const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列
|
||||
let [x, y] = this.transformPosToXy(initPos);
|
||||
while (true) {
|
||||
[x, y] = nextPos(x, y);
|
||||
|
||||
// 座標が指し示す位置がボード外に出たとき
|
||||
if (this.opts.loopedBoard && this.transformXyToPos(
|
||||
(x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth),
|
||||
(y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight)) === initPos)
|
||||
// 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ)
|
||||
return found;
|
||||
else if (x === -1 || y === -1 || x === this.mapWidth || y === this.mapHeight)
|
||||
return []; // 挟めないことが確定 (盤面外に到達)
|
||||
|
||||
const pos = this.transformXyToPos(x, y);
|
||||
if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達)
|
||||
const stone = this.board[pos];
|
||||
if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達)
|
||||
if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見)
|
||||
if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見)
|
||||
}
|
||||
};
|
||||
|
||||
return concat(diffVectors.map(effectsInLine));
|
||||
}
|
||||
|
||||
/**
|
||||
* ゲームが終了したか否か
|
||||
*/
|
||||
public get isEnded(): boolean {
|
||||
return this.turn === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ゲームの勝者 (null = 引き分け)
|
||||
*/
|
||||
public get winner(): Color | null {
|
||||
return this.isEnded ?
|
||||
this.blackCount == this.whiteCount ? null :
|
||||
this.opts.isLlotheo === this.blackCount > this.whiteCount ? WHITE : BLACK :
|
||||
undefined as never;
|
||||
}
|
||||
}
|
896
packages/backend/src/games/reversi/maps.ts
Normal file
896
packages/backend/src/games/reversi/maps.ts
Normal file
@ -0,0 +1,896 @@
|
||||
/**
|
||||
* 組み込みマップ定義
|
||||
*
|
||||
* データ値:
|
||||
* (スペース) ... マス無し
|
||||
* - ... マス
|
||||
* b ... 初期配置される黒石
|
||||
* w ... 初期配置される白石
|
||||
*/
|
||||
|
||||
export type Map = {
|
||||
name?: string;
|
||||
category?: string;
|
||||
author?: string;
|
||||
data: string[];
|
||||
};
|
||||
|
||||
export const fourfour: Map = {
|
||||
name: '4x4',
|
||||
category: '4x4',
|
||||
data: [
|
||||
'----',
|
||||
'-wb-',
|
||||
'-bw-',
|
||||
'----'
|
||||
]
|
||||
};
|
||||
|
||||
export const sixsix: Map = {
|
||||
name: '6x6',
|
||||
category: '6x6',
|
||||
data: [
|
||||
'------',
|
||||
'------',
|
||||
'--wb--',
|
||||
'--bw--',
|
||||
'------',
|
||||
'------'
|
||||
]
|
||||
};
|
||||
|
||||
export const roundedSixsix: Map = {
|
||||
name: '6x6 rounded',
|
||||
category: '6x6',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' ---- ',
|
||||
'------',
|
||||
'--wb--',
|
||||
'--bw--',
|
||||
'------',
|
||||
' ---- '
|
||||
]
|
||||
};
|
||||
|
||||
export const roundedSixsix2: Map = {
|
||||
name: '6x6 rounded 2',
|
||||
category: '6x6',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' -- ',
|
||||
' ---- ',
|
||||
'--wb--',
|
||||
'--bw--',
|
||||
' ---- ',
|
||||
' -- '
|
||||
]
|
||||
};
|
||||
|
||||
export const eighteight: Map = {
|
||||
name: '8x8',
|
||||
category: '8x8',
|
||||
data: [
|
||||
'--------',
|
||||
'--------',
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------',
|
||||
'--------',
|
||||
'--------'
|
||||
]
|
||||
};
|
||||
|
||||
export const eighteightH1: Map = {
|
||||
name: '8x8 handicap 1',
|
||||
category: '8x8',
|
||||
data: [
|
||||
'b-------',
|
||||
'--------',
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------',
|
||||
'--------',
|
||||
'--------'
|
||||
]
|
||||
};
|
||||
|
||||
export const eighteightH2: Map = {
|
||||
name: '8x8 handicap 2',
|
||||
category: '8x8',
|
||||
data: [
|
||||
'b-------',
|
||||
'--------',
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------',
|
||||
'--------',
|
||||
'-------b'
|
||||
]
|
||||
};
|
||||
|
||||
export const eighteightH3: Map = {
|
||||
name: '8x8 handicap 3',
|
||||
category: '8x8',
|
||||
data: [
|
||||
'b------b',
|
||||
'--------',
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------',
|
||||
'--------',
|
||||
'-------b'
|
||||
]
|
||||
};
|
||||
|
||||
export const eighteightH4: Map = {
|
||||
name: '8x8 handicap 4',
|
||||
category: '8x8',
|
||||
data: [
|
||||
'b------b',
|
||||
'--------',
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------',
|
||||
'--------',
|
||||
'b------b'
|
||||
]
|
||||
};
|
||||
|
||||
export const eighteightH28: Map = {
|
||||
name: '8x8 handicap 28',
|
||||
category: '8x8',
|
||||
data: [
|
||||
'bbbbbbbb',
|
||||
'b------b',
|
||||
'b------b',
|
||||
'b--wb--b',
|
||||
'b--bw--b',
|
||||
'b------b',
|
||||
'b------b',
|
||||
'bbbbbbbb'
|
||||
]
|
||||
};
|
||||
|
||||
export const roundedEighteight: Map = {
|
||||
name: '8x8 rounded',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' ------ ',
|
||||
'--------',
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------',
|
||||
'--------',
|
||||
' ------ '
|
||||
]
|
||||
};
|
||||
|
||||
export const roundedEighteight2: Map = {
|
||||
name: '8x8 rounded 2',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' ---- ',
|
||||
' ------ ',
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------',
|
||||
' ------ ',
|
||||
' ---- '
|
||||
]
|
||||
};
|
||||
|
||||
export const roundedEighteight3: Map = {
|
||||
name: '8x8 rounded 3',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' -- ',
|
||||
' ---- ',
|
||||
' ------ ',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
' ------ ',
|
||||
' ---- ',
|
||||
' -- '
|
||||
]
|
||||
};
|
||||
|
||||
export const eighteightWithNotch: Map = {
|
||||
name: '8x8 with notch',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'--- ---',
|
||||
'--------',
|
||||
'--------',
|
||||
' --wb-- ',
|
||||
' --bw-- ',
|
||||
'--------',
|
||||
'--------',
|
||||
'--- ---'
|
||||
]
|
||||
};
|
||||
|
||||
export const eighteightWithSomeHoles: Map = {
|
||||
name: '8x8 with some holes',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'--- ----',
|
||||
'----- --',
|
||||
'-- -----',
|
||||
'---wb---',
|
||||
'---bw- -',
|
||||
' -------',
|
||||
'--- ----',
|
||||
'--------'
|
||||
]
|
||||
};
|
||||
|
||||
export const circle: Map = {
|
||||
name: 'Circle',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' -- ',
|
||||
' ------ ',
|
||||
' ------ ',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
' ------ ',
|
||||
' ------ ',
|
||||
' -- '
|
||||
]
|
||||
};
|
||||
|
||||
export const smile: Map = {
|
||||
name: 'Smile',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' ------ ',
|
||||
'--------',
|
||||
'-- -- --',
|
||||
'---wb---',
|
||||
'-- bw --',
|
||||
'--- ---',
|
||||
'--------',
|
||||
' ------ '
|
||||
]
|
||||
};
|
||||
|
||||
export const window: Map = {
|
||||
name: 'Window',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'--------',
|
||||
'- -- -',
|
||||
'- -- -',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'- -- -',
|
||||
'- -- -',
|
||||
'--------'
|
||||
]
|
||||
};
|
||||
|
||||
export const reserved: Map = {
|
||||
name: 'Reserved',
|
||||
category: '8x8',
|
||||
author: 'Aya',
|
||||
data: [
|
||||
'w------b',
|
||||
'--------',
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------',
|
||||
'--------',
|
||||
'b------w'
|
||||
]
|
||||
};
|
||||
|
||||
export const x: Map = {
|
||||
name: 'X',
|
||||
category: '8x8',
|
||||
author: 'Aya',
|
||||
data: [
|
||||
'w------b',
|
||||
'-w----b-',
|
||||
'--w--b--',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--b--w--',
|
||||
'-b----w-',
|
||||
'b------w'
|
||||
]
|
||||
};
|
||||
|
||||
export const parallel: Map = {
|
||||
name: 'Parallel',
|
||||
category: '8x8',
|
||||
author: 'Aya',
|
||||
data: [
|
||||
'--------',
|
||||
'--------',
|
||||
'--------',
|
||||
'---bb---',
|
||||
'---ww---',
|
||||
'--------',
|
||||
'--------',
|
||||
'--------'
|
||||
]
|
||||
};
|
||||
|
||||
export const lackOfBlack: Map = {
|
||||
name: 'Lack of Black',
|
||||
category: '8x8',
|
||||
data: [
|
||||
'--------',
|
||||
'--------',
|
||||
'--------',
|
||||
'---w----',
|
||||
'---bw---',
|
||||
'--------',
|
||||
'--------',
|
||||
'--------'
|
||||
]
|
||||
};
|
||||
|
||||
export const squareParty: Map = {
|
||||
name: 'Square Party',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'--------',
|
||||
'-wwwbbb-',
|
||||
'-w-wb-b-',
|
||||
'-wwwbbb-',
|
||||
'-bbbwww-',
|
||||
'-b-bw-w-',
|
||||
'-bbbwww-',
|
||||
'--------'
|
||||
]
|
||||
};
|
||||
|
||||
export const minesweeper: Map = {
|
||||
name: 'Minesweeper',
|
||||
category: '8x8',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'b-b--w-w',
|
||||
'-w-wb-b-',
|
||||
'w-b--w-b',
|
||||
'-b-wb-w-',
|
||||
'-w-bw-b-',
|
||||
'b-w--b-w',
|
||||
'-b-bw-w-',
|
||||
'w-w--b-b'
|
||||
]
|
||||
};
|
||||
|
||||
export const tenthtenth: Map = {
|
||||
name: '10x10',
|
||||
category: '10x10',
|
||||
data: [
|
||||
'----------',
|
||||
'----------',
|
||||
'----------',
|
||||
'----------',
|
||||
'----wb----',
|
||||
'----bw----',
|
||||
'----------',
|
||||
'----------',
|
||||
'----------',
|
||||
'----------'
|
||||
]
|
||||
};
|
||||
|
||||
export const hole: Map = {
|
||||
name: 'The Hole',
|
||||
category: '10x10',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'----------',
|
||||
'----------',
|
||||
'--wb--wb--',
|
||||
'--bw--bw--',
|
||||
'---- ----',
|
||||
'---- ----',
|
||||
'--wb--wb--',
|
||||
'--bw--bw--',
|
||||
'----------',
|
||||
'----------'
|
||||
]
|
||||
};
|
||||
|
||||
export const grid: Map = {
|
||||
name: 'Grid',
|
||||
category: '10x10',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'----------',
|
||||
'- - -- - -',
|
||||
'----------',
|
||||
'- - -- - -',
|
||||
'----wb----',
|
||||
'----bw----',
|
||||
'- - -- - -',
|
||||
'----------',
|
||||
'- - -- - -',
|
||||
'----------'
|
||||
]
|
||||
};
|
||||
|
||||
export const cross: Map = {
|
||||
name: 'Cross',
|
||||
category: '10x10',
|
||||
author: 'Aya',
|
||||
data: [
|
||||
' ---- ',
|
||||
' ---- ',
|
||||
' ---- ',
|
||||
'----------',
|
||||
'----wb----',
|
||||
'----bw----',
|
||||
'----------',
|
||||
' ---- ',
|
||||
' ---- ',
|
||||
' ---- '
|
||||
]
|
||||
};
|
||||
|
||||
export const charX: Map = {
|
||||
name: 'Char X',
|
||||
category: '10x10',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'--- ---',
|
||||
'---- ----',
|
||||
'----------',
|
||||
' -------- ',
|
||||
' --wb-- ',
|
||||
' --bw-- ',
|
||||
' -------- ',
|
||||
'----------',
|
||||
'---- ----',
|
||||
'--- ---'
|
||||
]
|
||||
};
|
||||
|
||||
export const charY: Map = {
|
||||
name: 'Char Y',
|
||||
category: '10x10',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'--- ---',
|
||||
'---- ----',
|
||||
'----------',
|
||||
' -------- ',
|
||||
' --wb-- ',
|
||||
' --bw-- ',
|
||||
' ------ ',
|
||||
' ------ ',
|
||||
' ------ ',
|
||||
' ------ '
|
||||
]
|
||||
};
|
||||
|
||||
export const walls: Map = {
|
||||
name: 'Walls',
|
||||
category: '10x10',
|
||||
author: 'Aya',
|
||||
data: [
|
||||
' bbbbbbbb ',
|
||||
'w--------w',
|
||||
'w--------w',
|
||||
'w--------w',
|
||||
'w---wb---w',
|
||||
'w---bw---w',
|
||||
'w--------w',
|
||||
'w--------w',
|
||||
'w--------w',
|
||||
' bbbbbbbb '
|
||||
]
|
||||
};
|
||||
|
||||
export const cpu: Map = {
|
||||
name: 'CPU',
|
||||
category: '10x10',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' b b b b ',
|
||||
'w--------w',
|
||||
' -------- ',
|
||||
'w--------w',
|
||||
' ---wb--- ',
|
||||
' ---bw--- ',
|
||||
'w--------w',
|
||||
' -------- ',
|
||||
'w--------w',
|
||||
' b b b b '
|
||||
]
|
||||
};
|
||||
|
||||
export const checker: Map = {
|
||||
name: 'Checker',
|
||||
category: '10x10',
|
||||
author: 'Aya',
|
||||
data: [
|
||||
'----------',
|
||||
'----------',
|
||||
'----------',
|
||||
'---wbwb---',
|
||||
'---bwbw---',
|
||||
'---wbwb---',
|
||||
'---bwbw---',
|
||||
'----------',
|
||||
'----------',
|
||||
'----------'
|
||||
]
|
||||
};
|
||||
|
||||
export const japaneseCurry: Map = {
|
||||
name: 'Japanese curry',
|
||||
category: '10x10',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'w-b-b-b-b-',
|
||||
'-w-b-b-b-b',
|
||||
'w-w-b-b-b-',
|
||||
'-w-w-b-b-b',
|
||||
'w-w-wwb-b-',
|
||||
'-w-wbb-b-b',
|
||||
'w-w-w-b-b-',
|
||||
'-w-w-w-b-b',
|
||||
'w-w-w-w-b-',
|
||||
'-w-w-w-w-b'
|
||||
]
|
||||
};
|
||||
|
||||
export const mosaic: Map = {
|
||||
name: 'Mosaic',
|
||||
category: '10x10',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'- - - - - ',
|
||||
' - - - - -',
|
||||
'- - - - - ',
|
||||
' - w w - -',
|
||||
'- - b b - ',
|
||||
' - w w - -',
|
||||
'- - b b - ',
|
||||
' - - - - -',
|
||||
'- - - - - ',
|
||||
' - - - - -',
|
||||
]
|
||||
};
|
||||
|
||||
export const arena: Map = {
|
||||
name: 'Arena',
|
||||
category: '10x10',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'- - -- - -',
|
||||
' - - - - ',
|
||||
'- ------ -',
|
||||
' -------- ',
|
||||
'- --wb-- -',
|
||||
'- --bw-- -',
|
||||
' -------- ',
|
||||
'- ------ -',
|
||||
' - - - - ',
|
||||
'- - -- - -'
|
||||
]
|
||||
};
|
||||
|
||||
export const reactor: Map = {
|
||||
name: 'Reactor',
|
||||
category: '10x10',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'-w------b-',
|
||||
'b- - - -w',
|
||||
'- --wb-- -',
|
||||
'---b w---',
|
||||
'- b wb w -',
|
||||
'- w bw b -',
|
||||
'---w b---',
|
||||
'- --bw-- -',
|
||||
'w- - - -b',
|
||||
'-b------w-'
|
||||
]
|
||||
};
|
||||
|
||||
export const sixeight: Map = {
|
||||
name: '6x8',
|
||||
category: 'Special',
|
||||
data: [
|
||||
'------',
|
||||
'------',
|
||||
'------',
|
||||
'--wb--',
|
||||
'--bw--',
|
||||
'------',
|
||||
'------',
|
||||
'------'
|
||||
]
|
||||
};
|
||||
|
||||
export const spark: Map = {
|
||||
name: 'Spark',
|
||||
category: 'Special',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' - - ',
|
||||
'----------',
|
||||
' -------- ',
|
||||
' -------- ',
|
||||
' ---wb--- ',
|
||||
' ---bw--- ',
|
||||
' -------- ',
|
||||
' -------- ',
|
||||
'----------',
|
||||
' - - '
|
||||
]
|
||||
};
|
||||
|
||||
export const islands: Map = {
|
||||
name: 'Islands',
|
||||
category: 'Special',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'-------- ',
|
||||
'---wb--- ',
|
||||
'---bw--- ',
|
||||
'-------- ',
|
||||
' - - ',
|
||||
' - - ',
|
||||
' --------',
|
||||
' --------',
|
||||
' --------',
|
||||
' --------'
|
||||
]
|
||||
};
|
||||
|
||||
export const galaxy: Map = {
|
||||
name: 'Galaxy',
|
||||
category: 'Special',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' ------ ',
|
||||
' --www--- ',
|
||||
' ------w--- ',
|
||||
'---bbb--w---',
|
||||
'--b---b-w-b-',
|
||||
'-b--wwb-w-b-',
|
||||
'-b-w-bww--b-',
|
||||
'-b-w-b---b--',
|
||||
'---w--bbb---',
|
||||
' ---w------ ',
|
||||
' ---www-- ',
|
||||
' ------ '
|
||||
]
|
||||
};
|
||||
|
||||
export const triangle: Map = {
|
||||
name: 'Triangle',
|
||||
category: 'Special',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' -- ',
|
||||
' -- ',
|
||||
' ---- ',
|
||||
' ---- ',
|
||||
' --wb-- ',
|
||||
' --bw-- ',
|
||||
' -------- ',
|
||||
' -------- ',
|
||||
'----------',
|
||||
'----------'
|
||||
]
|
||||
};
|
||||
|
||||
export const iphonex: Map = {
|
||||
name: 'iPhone X',
|
||||
category: 'Special',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' -- -- ',
|
||||
'--------',
|
||||
'--------',
|
||||
'--------',
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------',
|
||||
'--------',
|
||||
'--------',
|
||||
'--------',
|
||||
' ------ '
|
||||
]
|
||||
};
|
||||
|
||||
export const dealWithIt: Map = {
|
||||
name: 'Deal with it!',
|
||||
category: 'Special',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
'------------',
|
||||
'--w-b-------',
|
||||
' --b-w------',
|
||||
' --w-b---- ',
|
||||
' ------- '
|
||||
]
|
||||
};
|
||||
|
||||
export const experiment: Map = {
|
||||
name: 'Let\'s experiment',
|
||||
category: 'Special',
|
||||
author: 'syuilo',
|
||||
data: [
|
||||
' ------------ ',
|
||||
'------wb------',
|
||||
'------bw------',
|
||||
'--------------',
|
||||
' - - ',
|
||||
'------ ------',
|
||||
'bbbbbb wwwwww',
|
||||
'bbbbbb wwwwww',
|
||||
'bbbbbb wwwwww',
|
||||
'bbbbbb wwwwww',
|
||||
'wwwwww bbbbbb'
|
||||
]
|
||||
};
|
||||
|
||||
export const bigBoard: Map = {
|
||||
name: 'Big board',
|
||||
category: 'Special',
|
||||
data: [
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'-------wb-------',
|
||||
'-------bw-------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------',
|
||||
'----------------'
|
||||
]
|
||||
};
|
||||
|
||||
export const twoBoard: Map = {
|
||||
name: 'Two board',
|
||||
category: 'Special',
|
||||
author: 'Aya',
|
||||
data: [
|
||||
'-------- --------',
|
||||
'-------- --------',
|
||||
'-------- --------',
|
||||
'---wb--- ---wb---',
|
||||
'---bw--- ---bw---',
|
||||
'-------- --------',
|
||||
'-------- --------',
|
||||
'-------- --------'
|
||||
]
|
||||
};
|
||||
|
||||
export const test1: Map = {
|
||||
name: 'Test1',
|
||||
category: 'Test',
|
||||
data: [
|
||||
'--------',
|
||||
'---wb---',
|
||||
'---bw---',
|
||||
'--------'
|
||||
]
|
||||
};
|
||||
|
||||
export const test2: Map = {
|
||||
name: 'Test2',
|
||||
category: 'Test',
|
||||
data: [
|
||||
'------',
|
||||
'------',
|
||||
'-b--w-',
|
||||
'-w--b-',
|
||||
'-w--b-'
|
||||
]
|
||||
};
|
||||
|
||||
export const test3: Map = {
|
||||
name: 'Test3',
|
||||
category: 'Test',
|
||||
data: [
|
||||
'-w-',
|
||||
'--w',
|
||||
'w--',
|
||||
'-w-',
|
||||
'--w',
|
||||
'w--',
|
||||
'-w-',
|
||||
'--w',
|
||||
'w--',
|
||||
'-w-',
|
||||
'---',
|
||||
'b--',
|
||||
]
|
||||
};
|
||||
|
||||
export const test4: Map = {
|
||||
name: 'Test4',
|
||||
category: 'Test',
|
||||
data: [
|
||||
'-w--b-',
|
||||
'-w--b-',
|
||||
'------',
|
||||
'-w--b-',
|
||||
'-w--b-'
|
||||
]
|
||||
};
|
||||
|
||||
// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)A1に打ってしまう
|
||||
export const test6: Map = {
|
||||
name: 'Test6',
|
||||
category: 'Test',
|
||||
data: [
|
||||
'--wwwww-',
|
||||
'wwwwwwww',
|
||||
'wbbbwbwb',
|
||||
'wbbbbwbb',
|
||||
'wbwbbwbb',
|
||||
'wwbwbbbb',
|
||||
'--wbbbbb',
|
||||
'-wwwww--',
|
||||
]
|
||||
};
|
||||
|
||||
// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)G7に打ってしまう
|
||||
export const test7: Map = {
|
||||
name: 'Test7',
|
||||
category: 'Test',
|
||||
data: [
|
||||
'b--w----',
|
||||
'b-wwww--',
|
||||
'bwbwwwbb',
|
||||
'wbwwwwb-',
|
||||
'wwwwwww-',
|
||||
'-wwbbwwb',
|
||||
'--wwww--',
|
||||
'--wwww--',
|
||||
]
|
||||
};
|
||||
|
||||
// 検証用: この盤面で藍(lv5)が黒で始めると何故か(?)A1に打ってしまう
|
||||
export const test8: Map = {
|
||||
name: 'Test8',
|
||||
category: 'Test',
|
||||
data: [
|
||||
'--------',
|
||||
'-----w--',
|
||||
'w--www--',
|
||||
'wwwwww--',
|
||||
'bbbbwww-',
|
||||
'wwwwww--',
|
||||
'--www---',
|
||||
'--ww----',
|
||||
]
|
||||
};
|
18
packages/backend/src/games/reversi/package.json
Normal file
18
packages/backend/src/games/reversi/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "misskey-reversi",
|
||||
"version": "0.0.5",
|
||||
"description": "Misskey reversi engine",
|
||||
"keywords": [
|
||||
"misskey"
|
||||
],
|
||||
"author": "syuilo <i@syuilo.com>",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/misskey-dev/misskey.git",
|
||||
"bugs": "https://github.com/misskey-dev/misskey/issues",
|
||||
"main": "./built/core.js",
|
||||
"types": "./built/core.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
21
packages/backend/src/games/reversi/tsconfig.json
Normal file
21
packages/backend/src/games/reversi/tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"noEmitOnError": false,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"experimentalDecorators": true,
|
||||
"declaration": true,
|
||||
"sourceMap": false,
|
||||
"target": "es2017",
|
||||
"module": "commonjs",
|
||||
"removeComments": false,
|
||||
"noLib": false,
|
||||
"outDir": "./built",
|
||||
"rootDir": "./"
|
||||
},
|
||||
"compileOnSave": false,
|
||||
"include": [
|
||||
"./core.ts"
|
||||
]
|
||||
}
|
1
packages/backend/src/global.d.ts
vendored
Normal file
1
packages/backend/src/global.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
type FIXME = any;
|
11
packages/backend/src/index.ts
Normal file
11
packages/backend/src/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Misskey Entry Point!
|
||||
*/
|
||||
|
||||
Error.stackTraceLimit = Infinity;
|
||||
|
||||
require('events').EventEmitter.defaultMaxListeners = 128;
|
||||
|
||||
import boot from './boot/index';
|
||||
|
||||
boot();
|
209
packages/backend/src/mfm/from-html.ts
Normal file
209
packages/backend/src/mfm/from-html.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import * as parse5 from 'parse5';
|
||||
import treeAdapter = require('parse5/lib/tree-adapters/default');
|
||||
import { URL } from 'url';
|
||||
|
||||
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
||||
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
||||
|
||||
export function fromHtml(html: string, hashtagNames?: string[]): string | null {
|
||||
if (html == null) return null;
|
||||
|
||||
const dom = parse5.parseFragment(html);
|
||||
|
||||
let text = '';
|
||||
|
||||
for (const n of dom.childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
|
||||
function getText(node: parse5.Node): string {
|
||||
if (treeAdapter.isTextNode(node)) return node.value;
|
||||
if (!treeAdapter.isElementNode(node)) return '';
|
||||
if (node.nodeName === 'br') return '\n';
|
||||
|
||||
if (node.childNodes) {
|
||||
return node.childNodes.map(n => getText(n)).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function appendChildren(childNodes: parse5.ChildNode[]): void {
|
||||
if (childNodes) {
|
||||
for (const n of childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function analyze(node: parse5.Node) {
|
||||
if (treeAdapter.isTextNode(node)) {
|
||||
text += node.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip comment or document type node
|
||||
if (!treeAdapter.isElementNode(node)) return;
|
||||
|
||||
switch (node.nodeName) {
|
||||
case 'br':
|
||||
text += '\n';
|
||||
break;
|
||||
|
||||
case 'a':
|
||||
{
|
||||
const txt = getText(node);
|
||||
const rel = node.attrs.find(x => x.name === 'rel');
|
||||
const href = node.attrs.find(x => x.name === 'href');
|
||||
|
||||
// ハッシュタグ
|
||||
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
|
||||
text += txt;
|
||||
// メンション
|
||||
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
|
||||
const part = txt.split('@');
|
||||
|
||||
if (part.length === 2 && href) {
|
||||
//#region ホスト名部分が省略されているので復元する
|
||||
const acct = `${txt}@${(new URL(href.value)).hostname}`;
|
||||
text += acct;
|
||||
//#endregion
|
||||
} else if (part.length === 3) {
|
||||
text += txt;
|
||||
}
|
||||
// その他
|
||||
} else {
|
||||
const generateLink = () => {
|
||||
if (!href && !txt) {
|
||||
return '';
|
||||
}
|
||||
if (!href) {
|
||||
return txt;
|
||||
}
|
||||
if (!txt || txt === href.value) { // #6383: Missing text node
|
||||
if (href.value.match(urlRegexFull)) {
|
||||
return href.value;
|
||||
} else {
|
||||
return `<${href.value}>`;
|
||||
}
|
||||
}
|
||||
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
|
||||
return `[${txt}](<${href.value}>)`; // #6846
|
||||
} else {
|
||||
return `[${txt}](${href.value})`;
|
||||
}
|
||||
};
|
||||
|
||||
text += generateLink();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'h1':
|
||||
{
|
||||
text += '【';
|
||||
appendChildren(node.childNodes);
|
||||
text += '】\n';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'b':
|
||||
case 'strong':
|
||||
{
|
||||
text += '**';
|
||||
appendChildren(node.childNodes);
|
||||
text += '**';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'small':
|
||||
{
|
||||
text += '<small>';
|
||||
appendChildren(node.childNodes);
|
||||
text += '</small>';
|
||||
break;
|
||||
}
|
||||
|
||||
case 's':
|
||||
case 'del':
|
||||
{
|
||||
text += '~~';
|
||||
appendChildren(node.childNodes);
|
||||
text += '~~';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'i':
|
||||
case 'em':
|
||||
{
|
||||
text += '<i>';
|
||||
appendChildren(node.childNodes);
|
||||
text += '</i>';
|
||||
break;
|
||||
}
|
||||
|
||||
// block code (<pre><code>)
|
||||
case 'pre': {
|
||||
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
|
||||
text += '\n```\n';
|
||||
text += getText(node.childNodes[0]);
|
||||
text += '\n```\n';
|
||||
} else {
|
||||
appendChildren(node.childNodes);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// inline code (<code>)
|
||||
case 'code': {
|
||||
text += '`';
|
||||
appendChildren(node.childNodes);
|
||||
text += '`';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blockquote': {
|
||||
const t = getText(node);
|
||||
if (t) {
|
||||
text += '> ';
|
||||
text += t.split('\n').join(`\n> `);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'p':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6':
|
||||
{
|
||||
text += '\n\n';
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
|
||||
// other block elements
|
||||
case 'div':
|
||||
case 'header':
|
||||
case 'footer':
|
||||
case 'article':
|
||||
case 'li':
|
||||
case 'dt':
|
||||
case 'dd':
|
||||
{
|
||||
text += '\n';
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
|
||||
default: // includes inline elements
|
||||
{
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
153
packages/backend/src/mfm/to-html.ts
Normal file
153
packages/backend/src/mfm/to-html.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import { JSDOM } from 'jsdom';
|
||||
import * as mfm from 'mfm-js';
|
||||
import config from '@/config/index';
|
||||
import { intersperse } from '@/prelude/array';
|
||||
import { IMentionedRemoteUsers } from '@/models/entities/note';
|
||||
|
||||
export function toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
|
||||
if (nodes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { window } = new JSDOM('');
|
||||
|
||||
const doc = window.document;
|
||||
|
||||
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
|
||||
if (children) {
|
||||
for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
|
||||
bold(node) {
|
||||
const el = doc.createElement('b');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
small(node) {
|
||||
const el = doc.createElement('small');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
strike(node) {
|
||||
const el = doc.createElement('del');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
italic(node) {
|
||||
const el = doc.createElement('i');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
fn(node) {
|
||||
const el = doc.createElement('i');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
blockCode(node) {
|
||||
const pre = doc.createElement('pre');
|
||||
const inner = doc.createElement('code');
|
||||
inner.textContent = node.props.code;
|
||||
pre.appendChild(inner);
|
||||
return pre;
|
||||
},
|
||||
|
||||
center(node) {
|
||||
const el = doc.createElement('div');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
emojiCode(node) {
|
||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||
},
|
||||
|
||||
unicodeEmoji(node) {
|
||||
return doc.createTextNode(node.props.emoji);
|
||||
},
|
||||
|
||||
hashtag(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.href = `${config.url}/tags/${node.props.hashtag}`;
|
||||
a.textContent = `#${node.props.hashtag}`;
|
||||
a.setAttribute('rel', 'tag');
|
||||
return a;
|
||||
},
|
||||
|
||||
inlineCode(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.code;
|
||||
return el;
|
||||
},
|
||||
|
||||
mathInline(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
|
||||
mathBlock(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
|
||||
link(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.href = node.props.url;
|
||||
appendChildren(node.children, a);
|
||||
return a;
|
||||
},
|
||||
|
||||
mention(node) {
|
||||
const a = doc.createElement('a');
|
||||
const { username, host, acct } = node.props;
|
||||
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||
a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${config.url}/${acct}`;
|
||||
a.className = 'u-url mention';
|
||||
a.textContent = acct;
|
||||
return a;
|
||||
},
|
||||
|
||||
quote(node) {
|
||||
const el = doc.createElement('blockquote');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
text(node) {
|
||||
const el = doc.createElement('span');
|
||||
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
url(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.href = node.props.url;
|
||||
a.textContent = node.props.url;
|
||||
return a;
|
||||
},
|
||||
|
||||
search(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.href = `https://www.google.com/search?q=${node.props.query}`;
|
||||
a.textContent = node.props.content;
|
||||
return a;
|
||||
}
|
||||
};
|
||||
|
||||
appendChildren(nodes, doc.body);
|
||||
|
||||
return `<p>${doc.body.innerHTML}</p>`;
|
||||
}
|
36
packages/backend/src/misc/antenna-cache.ts
Normal file
36
packages/backend/src/misc/antenna-cache.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
35
packages/backend/src/misc/api-permissions.ts
Normal file
35
packages/backend/src/misc/api-permissions.ts
Normal file
@ -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).
|
31
packages/backend/src/misc/app-lock.ts
Normal file
31
packages/backend/src/misc/app-lock.ts
Normal file
@ -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);
|
||||
}
|
92
packages/backend/src/misc/before-shutdown.ts
Normal file
92
packages/backend/src/misc/before-shutdown.ts
Normal file
@ -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);
|
43
packages/backend/src/misc/cache.ts
Normal file
43
packages/backend/src/misc/cache.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
32
packages/backend/src/misc/cafy-id.ts
Normal file
32
packages/backend/src/misc/cafy-id.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
56
packages/backend/src/misc/captcha.ts
Normal file
56
packages/backend/src/misc/captcha.ts
Normal file
@ -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;
|
||||
}
|
90
packages/backend/src/misc/check-hit-antenna.ts
Normal file
90
packages/backend/src/misc/check-hit-antenna.ts
Normal file
@ -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;
|
||||
}
|
39
packages/backend/src/misc/check-word-mute.ts
Normal file
39
packages/backend/src/misc/check-word-mute.ts
Normal file
@ -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;
|
||||
}
|
6
packages/backend/src/misc/content-disposition.ts
Normal file
6
packages/backend/src/misc/content-disposition.ts
Normal file
@ -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 });
|
||||
}
|
26
packages/backend/src/misc/convert-host.ts
Normal file
26
packages/backend/src/misc/convert-host.ts
Normal file
@ -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());
|
||||
}
|
15
packages/backend/src/misc/count-same-renotes.ts
Normal file
15
packages/backend/src/misc/count-same-renotes.ts
Normal file
@ -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();
|
||||
}
|
10
packages/backend/src/misc/create-temp.ts
Normal file
10
packages/backend/src/misc/create-temp.ts
Normal file
@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
15
packages/backend/src/misc/detect-url-mime.ts
Normal file
15
packages/backend/src/misc/detect-url-mime.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
25
packages/backend/src/misc/download-text-file.ts
Normal file
25
packages/backend/src/misc/download-text-file.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
87
packages/backend/src/misc/download-url.ts
Normal file
87
packages/backend/src/misc/download-url.ts
Normal file
@ -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);
|
||||
}
|
3
packages/backend/src/misc/emoji-regex.ts
Normal file
3
packages/backend/src/misc/emoji-regex.ts
Normal file
@ -0,0 +1,3 @@
|
||||
const twemojiRegex = require('twemoji-parser/dist/lib/regex').default;
|
||||
|
||||
export const emojiRegex = new RegExp(`(${twemojiRegex.source})`);
|
10
packages/backend/src/misc/extract-custom-emojis-from-mfm.ts
Normal file
10
packages/backend/src/misc/extract-custom-emojis-from-mfm.ts
Normal file
@ -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));
|
||||
}
|
9
packages/backend/src/misc/extract-hashtags.ts
Normal file
9
packages/backend/src/misc/extract-hashtags.ts
Normal file
@ -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;
|
||||
}
|
11
packages/backend/src/misc/extract-mentions.ts
Normal file
11
packages/backend/src/misc/extract-mentions.ts
Normal file
@ -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;
|
||||
}
|
35
packages/backend/src/misc/fetch-meta.ts
Normal file
35
packages/backend/src/misc/fetch-meta.ts
Normal file
@ -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);
|
9
packages/backend/src/misc/fetch-proxy-account.ts
Normal file
9
packages/backend/src/misc/fetch-proxy-account.ts
Normal file
@ -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;
|
||||
}
|
141
packages/backend/src/misc/fetch.ts
Normal file
141
packages/backend/src/misc/fetch.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
90
packages/backend/src/misc/gen-avatar.ts
Normal file
90
packages/backend/src/misc/gen-avatar.ts
Normal file
@ -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);
|
||||
}
|
21
packages/backend/src/misc/gen-id.ts
Normal file
21
packages/backend/src/misc/gen-id.ts
Normal file
@ -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');
|
||||
}
|
||||
}
|
36
packages/backend/src/misc/gen-key-pair.ts
Normal file
36
packages/backend/src/misc/gen-key-pair.ts
Normal file
@ -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
|
||||
}
|
||||
});
|
||||
}
|
196
packages/backend/src/misc/get-file-info.ts
Normal file
196
packages/backend/src/misc/get-file-info.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
54
packages/backend/src/misc/get-note-summary.ts
Normal file
54
packages/backend/src/misc/get-note-summary.ts
Normal file
@ -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();
|
||||
};
|
16
packages/backend/src/misc/get-reaction-emoji.ts
Normal file
16
packages/backend/src/misc/get-reaction-emoji.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
14
packages/backend/src/misc/hard-limits.ts
Normal file
14
packages/backend/src/misc/hard-limits.ts
Normal file
@ -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;
|
29
packages/backend/src/misc/i18n.ts
Normal file
29
packages/backend/src/misc/i18n.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
25
packages/backend/src/misc/id/aid.ts
Normal file
25
packages/backend/src/misc/id/aid.ts
Normal file
@ -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();
|
||||
}
|
26
packages/backend/src/misc/id/meid.ts
Normal file
26
packages/backend/src/misc/id/meid.ts
Normal file
@ -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();
|
||||
}
|
28
packages/backend/src/misc/id/meidg.ts
Normal file
28
packages/backend/src/misc/id/meidg.ts
Normal file
@ -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();
|
||||
}
|
26
packages/backend/src/misc/id/object-id.ts
Normal file
26
packages/backend/src/misc/id/object-id.ts
Normal file
@ -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();
|
||||
}
|
13
packages/backend/src/misc/identifiable-error.ts
Normal file
13
packages/backend/src/misc/identifiable-error.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
15
packages/backend/src/misc/is-blocker-user-related.ts
Normal file
15
packages/backend/src/misc/is-blocker-user-related.ts
Normal file
@ -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;
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
export function isDuplicateKeyValueError(e: Error): boolean {
|
||||
return e.message.startsWith('duplicate key value');
|
||||
}
|
15
packages/backend/src/misc/is-muted-user-related.ts
Normal file
15
packages/backend/src/misc/is-muted-user-related.ts
Normal file
@ -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;
|
||||
}
|
5
packages/backend/src/misc/is-quote.ts
Normal file
5
packages/backend/src/misc/is-quote.ts
Normal file
@ -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));
|
||||
}
|
10
packages/backend/src/misc/keypair-store.ts
Normal file
10
packages/backend/src/misc/keypair-store.ts
Normal file
@ -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));
|
||||
}
|
6
packages/backend/src/misc/normalize-for-search.ts
Normal file
6
packages/backend/src/misc/normalize-for-search.ts
Normal file
@ -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();
|
||||
}
|
15
packages/backend/src/misc/nyaize.ts
Normal file
15
packages/backend/src/misc/nyaize.ts
Normal file
@ -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, '냥');
|
||||
}
|
124
packages/backend/src/misc/populate-emojis.ts
Normal file
124
packages/backend/src/misc/populate-emojis.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
129
packages/backend/src/misc/reaction-lib.ts
Normal file
129
packages/backend/src/misc/reaction-lib.ts
Normal file
@ -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;
|
||||
}
|
3
packages/backend/src/misc/safe-for-sql.ts
Normal file
3
packages/backend/src/misc/safe-for-sql.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function safeForSql(text: string): boolean {
|
||||
return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text);
|
||||
}
|
107
packages/backend/src/misc/schema.ts
Normal file
107
packages/backend/src/misc/schema.ts
Normal file
@ -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;
|
21
packages/backend/src/misc/secure-rndstr.ts
Normal file
21
packages/backend/src/misc/secure-rndstr.ts
Normal file
@ -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;
|
||||
}
|
13
packages/backend/src/misc/show-machine-info.ts
Normal file
13
packages/backend/src/misc/show-machine-info.ts
Normal file
@ -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)`);
|
||||
}
|
15
packages/backend/src/misc/simple-schema.ts
Normal file
15
packages/backend/src/misc/simple-schema.ts
Normal file
@ -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; }
|
11
packages/backend/src/misc/truncate.ts
Normal file
11
packages/backend/src/misc/truncate.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
74
packages/backend/src/models/entities/abuse-user-report.ts
Normal file
74
packages/backend/src/models/entities/abuse-user-report.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class AbuseUserReport {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the AbuseUserReport.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public targetUserId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public targetUser: User | null;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public reporterId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public reporter: User | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true
|
||||
})
|
||||
public assigneeId: User['id'] | null;
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
@JoinColumn()
|
||||
public assignee: User | null;
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false
|
||||
})
|
||||
public resolved: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048,
|
||||
})
|
||||
public comment: string;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public targetUserHost: string | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public reporterHost: string | null;
|
||||
//#endregion
|
||||
}
|
96
packages/backend/src/models/entities/access-token.ts
Normal file
96
packages/backend/src/models/entities/access-token.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { App } from './app';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class AccessToken {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the AccessToken.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
public lastUsedAt: Date | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128
|
||||
})
|
||||
public token: string;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
nullable: true,
|
||||
default: null
|
||||
})
|
||||
public session: string | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128
|
||||
})
|
||||
public hash: string;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
default: null
|
||||
})
|
||||
public appId: App['id'] | null;
|
||||
|
||||
@ManyToOne(type => App, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public app: App | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
nullable: true,
|
||||
default: null
|
||||
})
|
||||
public name: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
nullable: true,
|
||||
default: null
|
||||
})
|
||||
public description: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
nullable: true,
|
||||
default: null
|
||||
})
|
||||
public iconUrl: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 64, array: true,
|
||||
default: '{}'
|
||||
})
|
||||
public permission: string[];
|
||||
|
||||
@Column('boolean', {
|
||||
default: false
|
||||
})
|
||||
public fetched: boolean;
|
||||
}
|
59
packages/backend/src/models/entities/ad.ts
Normal file
59
packages/backend/src/models/entities/ad.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Entity, Index, Column, PrimaryColumn } from 'typeorm';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class Ad {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the Ad.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The expired date of the Ad.'
|
||||
})
|
||||
public expiresAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 32, nullable: false
|
||||
})
|
||||
public place: string;
|
||||
|
||||
// 今は使われていないが将来的に活用される可能性はある
|
||||
@Column('varchar', {
|
||||
length: 32, nullable: false
|
||||
})
|
||||
public priority: string;
|
||||
|
||||
@Column('integer', {
|
||||
default: 1, nullable: false
|
||||
})
|
||||
public ratio: number;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, nullable: false
|
||||
})
|
||||
public url: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, nullable: false
|
||||
})
|
||||
public imageUrl: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 8192, nullable: false
|
||||
})
|
||||
public memo: string;
|
||||
|
||||
constructor(data: Partial<Ad>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
36
packages/backend/src/models/entities/announcement-read.ts
Normal file
36
packages/backend/src/models/entities/announcement-read.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { Announcement } from './announcement';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['userId', 'announcementId'], { unique: true })
|
||||
export class AnnouncementRead {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the AnnouncementRead.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public announcementId: Announcement['id'];
|
||||
|
||||
@ManyToOne(type => Announcement, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public announcement: Announcement | null;
|
||||
}
|
43
packages/backend/src/models/entities/announcement.ts
Normal file
43
packages/backend/src/models/entities/announcement.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Entity, Index, Column, PrimaryColumn } from 'typeorm';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class Announcement {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the Announcement.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The updated date of the Announcement.',
|
||||
nullable: true
|
||||
})
|
||||
public updatedAt: Date | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 8192, nullable: false
|
||||
})
|
||||
public text: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256, nullable: false
|
||||
})
|
||||
public title: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, nullable: true
|
||||
})
|
||||
public imageUrl: string | null;
|
||||
|
||||
constructor(data: Partial<Announcement>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
43
packages/backend/src/models/entities/antenna-note.ts
Normal file
43
packages/backend/src/models/entities/antenna-note.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||
import { Note } from './note';
|
||||
import { Antenna } from './antenna';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['noteId', 'antennaId'], { unique: true })
|
||||
export class AntennaNote {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The note ID.'
|
||||
})
|
||||
public noteId: Note['id'];
|
||||
|
||||
@ManyToOne(type => Note, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public note: Note | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The antenna ID.'
|
||||
})
|
||||
public antennaId: Antenna['id'];
|
||||
|
||||
@ManyToOne(type => Antenna, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public antenna: Antenna | null;
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false
|
||||
})
|
||||
public read: boolean;
|
||||
}
|
99
packages/backend/src/models/entities/antenna.ts
Normal file
99
packages/backend/src/models/entities/antenna.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
import { UserList } from './user-list';
|
||||
import { UserGroupJoining } from './user-group-joining';
|
||||
|
||||
@Entity()
|
||||
export class Antenna {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the Antenna.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The owner ID.'
|
||||
})
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
comment: 'The name of the Antenna.'
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Column('enum', { enum: ['home', 'all', 'users', 'list', 'group'] })
|
||||
public src: 'home' | 'all' | 'users' | 'list' | 'group';
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true
|
||||
})
|
||||
public userListId: UserList['id'] | null;
|
||||
|
||||
@ManyToOne(type => UserList, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public userList: UserList | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true
|
||||
})
|
||||
public userGroupJoiningId: UserGroupJoining['id'] | null;
|
||||
|
||||
@ManyToOne(type => UserGroupJoining, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public userGroupJoining: UserGroupJoining | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true,
|
||||
default: '{}'
|
||||
})
|
||||
public users: string[];
|
||||
|
||||
@Column('jsonb', {
|
||||
default: []
|
||||
})
|
||||
public keywords: string[][];
|
||||
|
||||
@Column('jsonb', {
|
||||
default: []
|
||||
})
|
||||
public excludeKeywords: string[][];
|
||||
|
||||
@Column('boolean', {
|
||||
default: false
|
||||
})
|
||||
public caseSensitive: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false
|
||||
})
|
||||
public withReplies: boolean;
|
||||
|
||||
@Column('boolean')
|
||||
public withFile: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true,
|
||||
})
|
||||
public expression: string | null;
|
||||
|
||||
@Column('boolean')
|
||||
public notify: boolean;
|
||||
}
|
60
packages/backend/src/models/entities/app.ts
Normal file
60
packages/backend/src/models/entities/app.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { Entity, PrimaryColumn, Column, Index, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class App {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the App.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: 'The owner ID.'
|
||||
})
|
||||
public userId: User['id'] | null;
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'SET NULL',
|
||||
nullable: true,
|
||||
})
|
||||
public user: User | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 64,
|
||||
comment: 'The secret key of the App.'
|
||||
})
|
||||
public secret: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
comment: 'The name of the App.'
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
comment: 'The description of the App.'
|
||||
})
|
||||
public description: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 64, array: true,
|
||||
comment: 'The permission of the App.'
|
||||
})
|
||||
public permission: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: 'The callbackUrl of the App.'
|
||||
})
|
||||
public callbackUrl: string | null;
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class AttestationChallenge {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@PrimaryColumn(id())
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 64,
|
||||
comment: 'Hex-encoded sha256 hash of the challenge.'
|
||||
})
|
||||
public challenge: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The date challenge was created for expiry purposes.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('boolean', {
|
||||
comment:
|
||||
'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.',
|
||||
default: false
|
||||
})
|
||||
public registrationChallenge: boolean;
|
||||
|
||||
constructor(data: Partial<AttestationChallenge>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
43
packages/backend/src/models/entities/auth-session.ts
Normal file
43
packages/backend/src/models/entities/auth-session.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { App } from './app';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class AuthSession {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the AuthSession.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128
|
||||
})
|
||||
public token: string;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true
|
||||
})
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column(id())
|
||||
public appId: App['id'];
|
||||
|
||||
@ManyToOne(type => App, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public app: App | null;
|
||||
}
|
42
packages/backend/src/models/entities/blocking.ts
Normal file
42
packages/backend/src/models/entities/blocking.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['blockerId', 'blockeeId'], { unique: true })
|
||||
export class Blocking {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the Blocking.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The blockee user ID.'
|
||||
})
|
||||
public blockeeId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public blockee: User | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The blocker user ID.'
|
||||
})
|
||||
public blockerId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public blocker: User | null;
|
||||
}
|
43
packages/backend/src/models/entities/channel-following.ts
Normal file
43
packages/backend/src/models/entities/channel-following.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
import { Channel } from './channel';
|
||||
|
||||
@Entity()
|
||||
@Index(['followerId', 'followeeId'], { unique: true })
|
||||
export class ChannelFollowing {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the ChannelFollowing.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The followee channel ID.'
|
||||
})
|
||||
public followeeId: Channel['id'];
|
||||
|
||||
@ManyToOne(type => Channel, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public followee: Channel | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The follower user ID.'
|
||||
})
|
||||
public followerId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public follower: User | null;
|
||||
}
|
35
packages/backend/src/models/entities/channel-note-pining.ts
Normal file
35
packages/backend/src/models/entities/channel-note-pining.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { Note } from './note';
|
||||
import { Channel } from './channel';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['channelId', 'noteId'], { unique: true })
|
||||
export class ChannelNotePining {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the ChannelNotePining.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public channelId: Channel['id'];
|
||||
|
||||
@ManyToOne(type => Channel, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public channel: Channel | null;
|
||||
|
||||
@Column(id())
|
||||
public noteId: Note['id'];
|
||||
|
||||
@ManyToOne(type => Note, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public note: Note | null;
|
||||
}
|
75
packages/backend/src/models/entities/channel.ts
Normal file
75
packages/backend/src/models/entities/channel.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
import { DriveFile } from './drive-file';
|
||||
|
||||
@Entity()
|
||||
export class Channel {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the Channel.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true
|
||||
})
|
||||
public lastNotedAt: Date | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: 'The owner ID.'
|
||||
})
|
||||
public userId: User['id'] | null;
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
comment: 'The name of the Channel.'
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true,
|
||||
comment: 'The description of the Channel.'
|
||||
})
|
||||
public description: string | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: 'The ID of banner Channel.'
|
||||
})
|
||||
public bannerId: DriveFile['id'] | null;
|
||||
|
||||
@ManyToOne(type => DriveFile, {
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
@JoinColumn()
|
||||
public banner: DriveFile | null;
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
comment: 'The count of notes.'
|
||||
})
|
||||
public notesCount: number;
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
comment: 'The count of users.'
|
||||
})
|
||||
public usersCount: number;
|
||||
}
|
37
packages/backend/src/models/entities/clip-note.ts
Normal file
37
packages/backend/src/models/entities/clip-note.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||
import { Note } from './note';
|
||||
import { Clip } from './clip';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['noteId', 'clipId'], { unique: true })
|
||||
export class ClipNote {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The note ID.'
|
||||
})
|
||||
public noteId: Note['id'];
|
||||
|
||||
@ManyToOne(type => Note, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public note: Note | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The clip ID.'
|
||||
})
|
||||
public clipId: Clip['id'];
|
||||
|
||||
@ManyToOne(type => Clip, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public clip: Clip | null;
|
||||
}
|
44
packages/backend/src/models/entities/clip.ts
Normal file
44
packages/backend/src/models/entities/clip.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class Clip {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the Clip.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The owner ID.'
|
||||
})
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
comment: 'The name of the Clip.'
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false
|
||||
})
|
||||
public isPublic: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true, default: null,
|
||||
comment: 'The description of the Clip.'
|
||||
})
|
||||
public description: string | null;
|
||||
}
|
164
packages/backend/src/models/entities/drive-file.ts
Normal file
164
packages/backend/src/models/entities/drive-file.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { DriveFolder } from './drive-folder';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['userId', 'folderId', 'id'])
|
||||
export class DriveFile {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the DriveFile.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: 'The owner ID.'
|
||||
})
|
||||
public userId: User['id'] | null;
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: 'The host of owner. It will be null if the user in local.'
|
||||
})
|
||||
public userHost: string | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 32,
|
||||
comment: 'The MD5 hash of the DriveFile.'
|
||||
})
|
||||
public md5: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256,
|
||||
comment: 'The file name of the DriveFile.'
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
comment: 'The content type (MIME) of the DriveFile.'
|
||||
})
|
||||
public type: string;
|
||||
|
||||
@Column('integer', {
|
||||
comment: 'The file size (bytes) of the DriveFile.'
|
||||
})
|
||||
public size: number;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: 'The comment of the DriveFile.'
|
||||
})
|
||||
public comment: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: 'The BlurHash string.'
|
||||
})
|
||||
public blurhash: string | null;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: {},
|
||||
comment: 'The any properties of the DriveFile. For example, it includes image width/height.'
|
||||
})
|
||||
public properties: { width?: number; height?: number; avgColor?: string };
|
||||
|
||||
@Index()
|
||||
@Column('boolean')
|
||||
public storedInternal: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
comment: 'The URL of the DriveFile.'
|
||||
})
|
||||
public url: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: 'The URL of the thumbnail of the DriveFile.'
|
||||
})
|
||||
public thumbnailUrl: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: 'The URL of the webpublic of the DriveFile.'
|
||||
})
|
||||
public webpublicUrl: string | null;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column('varchar', {
|
||||
length: 256, nullable: true,
|
||||
})
|
||||
public accessKey: string | null;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column('varchar', {
|
||||
length: 256, nullable: true,
|
||||
})
|
||||
public thumbnailAccessKey: string | null;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column('varchar', {
|
||||
length: 256, nullable: true,
|
||||
})
|
||||
public webpublicAccessKey: string | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: 'The URI of the DriveFile. it will be null when the DriveFile is local.'
|
||||
})
|
||||
public uri: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
})
|
||||
public src: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: 'The parent folder ID. If null, it means the DriveFile is located in root.'
|
||||
})
|
||||
public folderId: DriveFolder['id'] | null;
|
||||
|
||||
@ManyToOne(type => DriveFolder, {
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
@JoinColumn()
|
||||
public folder: DriveFolder | null;
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether the DriveFile is NSFW.'
|
||||
})
|
||||
public isSensitive: boolean;
|
||||
|
||||
/**
|
||||
* 外部の(信頼されていない)URLへの直リンクか否か
|
||||
*/
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether the DriveFile is direct link to remote server.'
|
||||
})
|
||||
public isLink: boolean;
|
||||
}
|
49
packages/backend/src/models/entities/drive-folder.ts
Normal file
49
packages/backend/src/models/entities/drive-folder.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { JoinColumn, ManyToOne, Entity, PrimaryColumn, Index, Column } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class DriveFolder {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the DriveFolder.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
comment: 'The name of the DriveFolder.'
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: 'The owner ID.'
|
||||
})
|
||||
public userId: User['id'] | null;
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: 'The parent folder ID. If null, it means the DriveFolder is located in root.'
|
||||
})
|
||||
public parentId: DriveFolder['id'] | null;
|
||||
|
||||
@ManyToOne(type => DriveFolder, {
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
@JoinColumn()
|
||||
public parent: DriveFolder | null;
|
||||
}
|
51
packages/backend/src/models/entities/emoji.ts
Normal file
51
packages/backend/src/models/entities/emoji.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { PrimaryColumn, Entity, Index, Column } from 'typeorm';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['name', 'host'], { unique: true })
|
||||
export class Emoji {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true
|
||||
})
|
||||
public updatedAt: Date | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true
|
||||
})
|
||||
public host: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true
|
||||
})
|
||||
public category: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
})
|
||||
public url: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true
|
||||
})
|
||||
public uri: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 64, nullable: true
|
||||
})
|
||||
public type: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
array: true, length: 128, default: '{}'
|
||||
})
|
||||
public aliases: string[];
|
||||
}
|
85
packages/backend/src/models/entities/follow-request.ts
Normal file
85
packages/backend/src/models/entities/follow-request.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['followerId', 'followeeId'], { unique: true })
|
||||
export class FollowRequest {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the FollowRequest.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The followee user ID.'
|
||||
})
|
||||
public followeeId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public followee: User | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The follower user ID.'
|
||||
})
|
||||
public followerId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public follower: User | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: 'id of Follow Activity.'
|
||||
})
|
||||
public requestId: string | null;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followerHost: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followerInbox: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followerSharedInbox: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followeeHost: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followeeInbox: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followeeSharedInbox: string | null;
|
||||
//#endregion
|
||||
}
|
80
packages/backend/src/models/entities/following.ts
Normal file
80
packages/backend/src/models/entities/following.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
@Index(['followerId', 'followeeId'], { unique: true })
|
||||
export class Following {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the Following.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The followee user ID.'
|
||||
})
|
||||
public followeeId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public followee: User | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The follower user ID.'
|
||||
})
|
||||
public followerId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public follower: User | null;
|
||||
|
||||
//#region Denormalized fields
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followerHost: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followerInbox: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followerSharedInbox: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followeeHost: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followeeInbox: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
comment: '[Denormalized]'
|
||||
})
|
||||
public followeeSharedInbox: string | null;
|
||||
//#endregion
|
||||
}
|
33
packages/backend/src/models/entities/gallery-like.ts
Normal file
33
packages/backend/src/models/entities/gallery-like.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
import { GalleryPost } from './gallery-post';
|
||||
|
||||
@Entity()
|
||||
@Index(['userId', 'postId'], { unique: true })
|
||||
export class GalleryLike {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone')
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column(id())
|
||||
public postId: GalleryPost['id'];
|
||||
|
||||
@ManyToOne(type => GalleryPost, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public post: GalleryPost | null;
|
||||
}
|
79
packages/backend/src/models/entities/gallery-post.ts
Normal file
79
packages/backend/src/models/entities/gallery-post.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
import { DriveFile } from './drive-file';
|
||||
|
||||
@Entity()
|
||||
export class GalleryPost {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the GalleryPost.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The updated date of the GalleryPost.'
|
||||
})
|
||||
public updatedAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256,
|
||||
})
|
||||
public title: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true
|
||||
})
|
||||
public description: string | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The ID of author.'
|
||||
})
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
array: true, default: '{}'
|
||||
})
|
||||
public fileIds: DriveFile['id'][];
|
||||
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether the post is sensitive.'
|
||||
})
|
||||
public isSensitive: boolean;
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0
|
||||
})
|
||||
public likedCount: number;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, array: true, default: '{}'
|
||||
})
|
||||
public tags: string[];
|
||||
|
||||
constructor(data: Partial<GalleryPost>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
133
packages/backend/src/models/entities/games/reversi/game.ts
Normal file
133
packages/backend/src/models/entities/games/reversi/game.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from '../../user';
|
||||
import { id } from '../../../id';
|
||||
|
||||
@Entity()
|
||||
export class ReversiGame {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the ReversiGame.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
comment: 'The started date of the ReversiGame.'
|
||||
})
|
||||
public startedAt: Date | null;
|
||||
|
||||
@Column(id())
|
||||
public user1Id: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user1: User | null;
|
||||
|
||||
@Column(id())
|
||||
public user2Id: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public user2: User | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public user1Accepted: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public user2Accepted: boolean;
|
||||
|
||||
/**
|
||||
* どちらのプレイヤーが先行(黒)か
|
||||
* 1 ... user1
|
||||
* 2 ... user2
|
||||
*/
|
||||
@Column('integer', {
|
||||
nullable: true,
|
||||
})
|
||||
public black: number | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isStarted: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isEnded: boolean;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true
|
||||
})
|
||||
public winnerId: User['id'] | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true
|
||||
})
|
||||
public surrendered: User['id'] | null;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: [],
|
||||
})
|
||||
public logs: {
|
||||
at: Date;
|
||||
color: boolean;
|
||||
pos: number;
|
||||
}[];
|
||||
|
||||
@Column('varchar', {
|
||||
array: true, length: 64,
|
||||
})
|
||||
public map: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 32
|
||||
})
|
||||
public bw: string;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isLlotheo: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public canPutEverywhere: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public loopedBoard: boolean;
|
||||
|
||||
@Column('jsonb', {
|
||||
nullable: true, default: null,
|
||||
})
|
||||
public form1: any | null;
|
||||
|
||||
@Column('jsonb', {
|
||||
nullable: true, default: null,
|
||||
})
|
||||
public form2: any | null;
|
||||
|
||||
/**
|
||||
* ログのposを文字列としてすべて連結したもののCRC32値
|
||||
*/
|
||||
@Column('varchar', {
|
||||
length: 32, nullable: true
|
||||
})
|
||||
public crc32: string | null;
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { User } from '../../user';
|
||||
import { id } from '../../../id';
|
||||
|
||||
@Entity()
|
||||
export class ReversiMatching {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the ReversiMatching.'
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public parentId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public parent: User | null;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public childId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
@JoinColumn()
|
||||
public child: User | null;
|
||||
}
|
87
packages/backend/src/models/entities/hashtag.ts
Normal file
87
packages/backend/src/models/entities/hashtag.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { Entity, PrimaryColumn, Index, Column } from 'typeorm';
|
||||
import { User } from './user';
|
||||
import { id } from '../id';
|
||||
|
||||
@Entity()
|
||||
export class Hashtag {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column('varchar', {
|
||||
length: 128
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
array: true,
|
||||
})
|
||||
public mentionedUserIds: User['id'][];
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0
|
||||
})
|
||||
public mentionedUsersCount: number;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
array: true,
|
||||
})
|
||||
public mentionedLocalUserIds: User['id'][];
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0
|
||||
})
|
||||
public mentionedLocalUsersCount: number;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
array: true,
|
||||
})
|
||||
public mentionedRemoteUserIds: User['id'][];
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0
|
||||
})
|
||||
public mentionedRemoteUsersCount: number;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
array: true,
|
||||
})
|
||||
public attachedUserIds: User['id'][];
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0
|
||||
})
|
||||
public attachedUsersCount: number;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
array: true,
|
||||
})
|
||||
public attachedLocalUserIds: User['id'][];
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0
|
||||
})
|
||||
public attachedLocalUsersCount: number;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
array: true,
|
||||
})
|
||||
public attachedRemoteUserIds: User['id'][];
|
||||
|
||||
@Index()
|
||||
@Column('integer', {
|
||||
default: 0
|
||||
})
|
||||
public attachedRemoteUsersCount: number;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user