refactoring

Resolve #7779
This commit is contained in:
syuilo
2021-11-12 02:02:25 +09:00
parent 037837b551
commit 0e4a111f81
1714 changed files with 20803 additions and 11751 deletions

View 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

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

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

View File

@ -0,0 +1,3 @@
import load from './load';
export default load();

View 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.`;
}
}

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

View 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

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

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

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

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

View File

@ -0,0 +1,3 @@
import Logger from '@/services/logger';
export const dbLogger = new Logger('db');

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

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

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

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

View 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----',
]
};

View 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": {}
}

View 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
View File

@ -0,0 +1 @@
type FIXME = any;

View File

@ -0,0 +1,11 @@
/**
* Misskey Entry Point!
*/
Error.stackTraceLimit = Infinity;
require('events').EventEmitter.defaultMaxListeners = 128;
import boot from './boot/index';
boot();

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

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

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

View 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).

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

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

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

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

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

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

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

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

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

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

View 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]);
});
});
}

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

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

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

View File

@ -0,0 +1,3 @@
const twemojiRegex = require('twemoji-parser/dist/lib/regex').default;
export const emojiRegex = new RegExp(`(${twemojiRegex.source})`);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,3 @@
export function isDuplicateKeyValueError(e: Error): boolean {
return e.message.startsWith('duplicate key value');
}

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

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

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

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

View 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, '냥');
}

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

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

View File

@ -0,0 +1,3 @@
export function safeForSql(text: string): boolean {
return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text);
}

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

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

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

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

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

View 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
}

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

View 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[];
}

View 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
}

View 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
}

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

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

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

View File

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

View 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