diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /packages/backend/src | |
| parent | update deps (diff) | |
| download | misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'packages/backend/src')
776 files changed, 54429 insertions, 0 deletions
diff --git a/packages/backend/src/boot/index.ts b/packages/backend/src/boot/index.ts new file mode 100644 index 0000000000..1723c32dd0 --- /dev/null +++ b/packages/backend/src/boot/index.ts @@ -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 diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts new file mode 100644 index 0000000000..eeb7017cb0 --- /dev/null +++ b/packages/backend/src/boot/master.ts @@ -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(); + }); + }); +} diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts new file mode 100644 index 0000000000..362fa3f26b --- /dev/null +++ b/packages/backend/src/boot/worker.ts @@ -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'); + } +} diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts new file mode 100644 index 0000000000..7bfdca4612 --- /dev/null +++ b/packages/backend/src/config/index.ts @@ -0,0 +1,3 @@ +import load from './load'; + +export default load(); diff --git a/packages/backend/src/config/load.ts b/packages/backend/src/config/load.ts new file mode 100644 index 0000000000..1b5457cdb0 --- /dev/null +++ b/packages/backend/src/config/load.ts @@ -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.`; + } +} diff --git a/packages/backend/src/config/types.ts b/packages/backend/src/config/types.ts new file mode 100644 index 0000000000..e3ca6c1ab6 --- /dev/null +++ b/packages/backend/src/config/types.ts @@ -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; diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts new file mode 100644 index 0000000000..43f59f1e4f --- /dev/null +++ b/packages/backend/src/const.ts @@ -0,0 +1,2 @@ +export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min +export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days diff --git a/packages/backend/src/daemons/janitor.ts b/packages/backend/src/daemons/janitor.ts new file mode 100644 index 0000000000..72568cfe18 --- /dev/null +++ b/packages/backend/src/daemons/janitor.ts @@ -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); +} diff --git a/packages/backend/src/daemons/queue-stats.ts b/packages/backend/src/daemons/queue-stats.ts new file mode 100644 index 0000000000..77f09b18d6 --- /dev/null +++ b/packages/backend/src/daemons/queue-stats.ts @@ -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); +} diff --git a/packages/backend/src/daemons/server-stats.ts b/packages/backend/src/daemons/server-stats.ts new file mode 100644 index 0000000000..8dfa946250 --- /dev/null +++ b/packages/backend/src/daemons/server-stats.ts @@ -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 }; +} diff --git a/packages/backend/src/db/elasticsearch.ts b/packages/backend/src/db/elasticsearch.ts new file mode 100644 index 0000000000..c99183007a --- /dev/null +++ b/packages/backend/src/db/elasticsearch.ts @@ -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; diff --git a/packages/backend/src/db/logger.ts b/packages/backend/src/db/logger.ts new file mode 100644 index 0000000000..62f90555a0 --- /dev/null +++ b/packages/backend/src/db/logger.ts @@ -0,0 +1,3 @@ +import Logger from '@/services/logger'; + +export const dbLogger = new Logger('db'); diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts new file mode 100644 index 0000000000..f52c2ab722 --- /dev/null +++ b/packages/backend/src/db/postgre.ts @@ -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`))); +} diff --git a/packages/backend/src/db/redis.ts b/packages/backend/src/db/redis.ts new file mode 100644 index 0000000000..7e0ee1e3ce --- /dev/null +++ b/packages/backend/src/db/redis.ts @@ -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(); diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts new file mode 100644 index 0000000000..1b678edc44 --- /dev/null +++ b/packages/backend/src/env.ts @@ -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 }; diff --git a/packages/backend/src/games/reversi/core.ts b/packages/backend/src/games/reversi/core.ts new file mode 100644 index 0000000000..9bfce9834a --- /dev/null +++ b/packages/backend/src/games/reversi/core.ts @@ -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; + } +} diff --git a/packages/backend/src/games/reversi/maps.ts b/packages/backend/src/games/reversi/maps.ts new file mode 100644 index 0000000000..dc0d1bf9d0 --- /dev/null +++ b/packages/backend/src/games/reversi/maps.ts @@ -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----', + ] +}; diff --git a/packages/backend/src/games/reversi/package.json b/packages/backend/src/games/reversi/package.json new file mode 100644 index 0000000000..a4415ad141 --- /dev/null +++ b/packages/backend/src/games/reversi/package.json @@ -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": {} +} diff --git a/packages/backend/src/games/reversi/tsconfig.json b/packages/backend/src/games/reversi/tsconfig.json new file mode 100644 index 0000000000..851fb6b7e4 --- /dev/null +++ b/packages/backend/src/games/reversi/tsconfig.json @@ -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" + ] +} diff --git a/packages/backend/src/global.d.ts b/packages/backend/src/global.d.ts new file mode 100644 index 0000000000..7343aa1994 --- /dev/null +++ b/packages/backend/src/global.d.ts @@ -0,0 +1 @@ +type FIXME = any; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts new file mode 100644 index 0000000000..5e4e377ab0 --- /dev/null +++ b/packages/backend/src/index.ts @@ -0,0 +1,11 @@ +/** + * Misskey Entry Point! + */ + +Error.stackTraceLimit = Infinity; + +require('events').EventEmitter.defaultMaxListeners = 128; + +import boot from './boot/index'; + +boot(); diff --git a/packages/backend/src/mfm/from-html.ts b/packages/backend/src/mfm/from-html.ts new file mode 100644 index 0000000000..de6aa3d0cc --- /dev/null +++ b/packages/backend/src/mfm/from-html.ts @@ -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; + } + } + } +} diff --git a/packages/backend/src/mfm/to-html.ts b/packages/backend/src/mfm/to-html.ts new file mode 100644 index 0000000000..343c76fb3e --- /dev/null +++ b/packages/backend/src/mfm/to-html.ts @@ -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>`; +} diff --git a/packages/backend/src/misc/antenna-cache.ts b/packages/backend/src/misc/antenna-cache.ts new file mode 100644 index 0000000000..a23eeb45ec --- /dev/null +++ b/packages/backend/src/misc/antenna-cache.ts @@ -0,0 +1,36 @@ +import { Antennas } from '@/models/index'; +import { Antenna } from '@/models/entities/antenna'; +import { subsdcriber } from '../db/redis'; + +let antennasFetched = false; +let antennas: Antenna[] = []; + +export async function getAntennas() { + if (!antennasFetched) { + antennas = await Antennas.find(); + antennasFetched = true; + } + + return antennas; +} + +subsdcriber.on('message', async (_, data) => { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'antennaCreated': + antennas.push(body); + break; + case 'antennaUpdated': + antennas[antennas.findIndex(a => a.id === body.id)] = body; + break; + case 'antennaDeleted': + antennas = antennas.filter(a => a.id !== body.id); + break; + default: + break; + } + } +}); diff --git a/packages/backend/src/misc/api-permissions.ts b/packages/backend/src/misc/api-permissions.ts new file mode 100644 index 0000000000..160cdf9fd6 --- /dev/null +++ b/packages/backend/src/misc/api-permissions.ts @@ -0,0 +1,35 @@ +export const kinds = [ + 'read:account', + 'write:account', + 'read:blocks', + 'write:blocks', + 'read:drive', + 'write:drive', + 'read:favorites', + 'write:favorites', + 'read:following', + 'write:following', + 'read:messaging', + 'write:messaging', + 'read:mutes', + 'write:mutes', + 'write:notes', + 'read:notifications', + 'write:notifications', + 'read:reactions', + 'write:reactions', + 'write:votes', + 'read:pages', + 'write:pages', + 'write:page-likes', + 'read:page-likes', + 'read:user-groups', + 'write:user-groups', + 'read:channels', + 'write:channels', + 'read:gallery', + 'write:gallery', + 'read:gallery-likes', + 'write:gallery-likes', +]; +// IF YOU ADD KINDS(PERMISSIONS), YOU MUST ADD TRANSLATIONS (under _permissions). diff --git a/packages/backend/src/misc/app-lock.ts b/packages/backend/src/misc/app-lock.ts new file mode 100644 index 0000000000..a32b600612 --- /dev/null +++ b/packages/backend/src/misc/app-lock.ts @@ -0,0 +1,31 @@ +import { redisClient } from '../db/redis'; +import { promisify } from 'util'; +import * as redisLock from 'redis-lock'; + +/** + * Retry delay (ms) for lock acquisition + */ +const retryDelay = 100; + +const lock: (key: string, timeout?: number) => Promise<() => void> + = redisClient + ? promisify(redisLock(redisClient, retryDelay)) + : async () => () => { }; + +/** + * Get AP Object lock + * @param uri AP object ID + * @param timeout Lock timeout (ms), The timeout releases previous lock. + * @returns Unlock function + */ +export function getApLock(uri: string, timeout = 30 * 1000) { + return lock(`ap-object:${uri}`, timeout); +} + +export function getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000) { + return lock(`instance:${host}`, timeout); +} + +export function getChartInsertLock(lockKey: string, timeout = 30 * 1000) { + return lock(`chart-insert:${lockKey}`, timeout); +} diff --git a/packages/backend/src/misc/before-shutdown.ts b/packages/backend/src/misc/before-shutdown.ts new file mode 100644 index 0000000000..33abf5fb4d --- /dev/null +++ b/packages/backend/src/misc/before-shutdown.ts @@ -0,0 +1,92 @@ +// https://gist.github.com/nfantone/1eaa803772025df69d07f4dbf5df7e58 + +'use strict'; + +/** + * @callback BeforeShutdownListener + * @param {string} [signalOrEvent] The exit signal or event name received on the process. + */ + +/** + * System signals the app will listen to initiate shutdown. + * @const {string[]} + */ +const SHUTDOWN_SIGNALS = ['SIGINT', 'SIGTERM']; + +/** + * Time in milliseconds to wait before forcing shutdown. + * @const {number} + */ +const SHUTDOWN_TIMEOUT = 15000; + +/** + * A queue of listener callbacks to execute before shutting + * down the process. + * @type {BeforeShutdownListener[]} + */ +const shutdownListeners = []; + +/** + * Listen for signals and execute given `fn` function once. + * @param {string[]} signals System signals to listen to. + * @param {function(string)} fn Function to execute on shutdown. + */ +const processOnce = (signals, fn) => { + for (const sig of signals) { + process.once(sig, fn); + } +}; + +/** + * Sets a forced shutdown mechanism that will exit the process after `timeout` milliseconds. + * @param {number} timeout Time to wait before forcing shutdown (milliseconds) + */ +const forceExitAfter = timeout => () => { + setTimeout(() => { + // Force shutdown after timeout + console.warn(`Could not close resources gracefully after ${timeout}ms: forcing shutdown`); + return process.exit(1); + }, timeout).unref(); +}; + +/** + * Main process shutdown handler. Will invoke every previously registered async shutdown listener + * in the queue and exit with a code of `0`. Any `Promise` rejections from any listener will + * be logged out as a warning, but won't prevent other callbacks from executing. + * @param {string} signalOrEvent The exit signal or event name received on the process. + */ +async function shutdownHandler(signalOrEvent) { + if (process.env.NODE_ENV === 'test') return process.exit(0); + + console.warn(`Shutting down: received [${signalOrEvent}] signal`); + + for (const listener of shutdownListeners) { + try { + await listener(signalOrEvent); + } catch (err) { + console.warn(`A shutdown handler failed before completing with: ${err.message || err}`); + } + } + + return process.exit(0); +} + +/** + * Registers a new shutdown listener to be invoked before exiting + * the main process. Listener handlers are guaranteed to be called in the order + * they were registered. + * @param {BeforeShutdownListener} listener The shutdown listener to register. + * @returns {BeforeShutdownListener} Echoes back the supplied `listener`. + */ +export function beforeShutdown(listener) { + shutdownListeners.push(listener); + return listener; +} + +// Register shutdown callback that kills the process after `SHUTDOWN_TIMEOUT` milliseconds +// This prevents custom shutdown handlers from hanging the process indefinitely +processOnce(SHUTDOWN_SIGNALS, forceExitAfter(SHUTDOWN_TIMEOUT)); + +// Register process shutdown callback +// Will listen to incoming signal events and execute all registered handlers in the stack +processOnce(SHUTDOWN_SIGNALS, shutdownHandler); diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts new file mode 100644 index 0000000000..71fbbd8a4c --- /dev/null +++ b/packages/backend/src/misc/cache.ts @@ -0,0 +1,43 @@ +export class Cache<T> { + private cache: Map<string | null, { date: number; value: T; }>; + private lifetime: number; + + constructor(lifetime: Cache<never>['lifetime']) { + this.cache = new Map(); + this.lifetime = lifetime; + } + + public set(key: string | null, value: T): void { + this.cache.set(key, { + date: Date.now(), + value + }); + } + + public get(key: string | null): T | undefined { + const cached = this.cache.get(key); + if (cached == null) return undefined; + if ((Date.now() - cached.date) > this.lifetime) { + this.cache.delete(key); + return undefined; + } + return cached.value; + } + + public delete(key: string | null) { + this.cache.delete(key); + } + + public async fetch(key: string | null, fetcher: () => Promise<T>): Promise<T> { + const cachedValue = this.get(key); + if (cachedValue !== undefined) { + // Cache HIT + return cachedValue; + } + + // Cache MISS + const value = await fetcher(); + this.set(key, value); + return value; + } +} diff --git a/packages/backend/src/misc/cafy-id.ts b/packages/backend/src/misc/cafy-id.ts new file mode 100644 index 0000000000..39886611e1 --- /dev/null +++ b/packages/backend/src/misc/cafy-id.ts @@ -0,0 +1,32 @@ +import { Context } from 'cafy'; + +export class ID<Maybe = string> extends Context<string | (Maybe extends {} ? string : Maybe)> { + public readonly name = 'ID'; + + constructor(optional = false, nullable = false) { + super(optional, nullable); + + this.push((v: any) => { + if (typeof v !== 'string') { + return new Error('must-be-an-id'); + } + return true; + }); + } + + public getType() { + return super.getType('String'); + } + + public makeOptional(): ID<undefined> { + return new ID(true, false); + } + + public makeNullable(): ID<null> { + return new ID(false, true); + } + + public makeOptionalNullable(): ID<undefined | null> { + return new ID(true, true); + } +} diff --git a/packages/backend/src/misc/captcha.ts b/packages/backend/src/misc/captcha.ts new file mode 100644 index 0000000000..f36943b589 --- /dev/null +++ b/packages/backend/src/misc/captcha.ts @@ -0,0 +1,56 @@ +import fetch from 'node-fetch'; +import { URLSearchParams } from 'url'; +import { getAgentByUrl } from './fetch'; +import config from '@/config/index'; + +export async function verifyRecaptcha(secret: string, response: string) { + const result = await getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => { + throw `recaptcha-request-failed: ${e}`; + }); + + if (result.success !== true) { + const errorCodes = result['error-codes'] ? result['error-codes']?.join(', ') : ''; + throw `recaptcha-failed: ${errorCodes}`; + } +} + +export async function verifyHcaptcha(secret: string, response: string) { + const result = await getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => { + throw `hcaptcha-request-failed: ${e}`; + }); + + if (result.success !== true) { + const errorCodes = result['error-codes'] ? result['error-codes']?.join(', ') : ''; + throw `hcaptcha-failed: ${errorCodes}`; + } +} + +type CaptchaResponse = { + success: boolean; + 'error-codes'?: string[]; +}; + +async function getCaptchaResponse(url: string, secret: string, response: string): Promise<CaptchaResponse> { + const params = new URLSearchParams({ + secret, + response + }); + + const res = await fetch(url, { + method: 'POST', + body: params, + headers: { + 'User-Agent': config.userAgent + }, + timeout: 10 * 1000, + agent: getAgentByUrl + }).catch(e => { + throw `${e.message || e}`; + }); + + if (!res.ok) { + throw `${res.status}`; + } + + return await res.json() as CaptchaResponse; +} diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts new file mode 100644 index 0000000000..e70b7429c7 --- /dev/null +++ b/packages/backend/src/misc/check-hit-antenna.ts @@ -0,0 +1,90 @@ +import { Antenna } from '@/models/entities/antenna'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; +import { UserListJoinings, UserGroupJoinings } from '@/models/index'; +import { getFullApAccount } from './convert-host'; +import * as Acct from 'misskey-js/built/acct'; +import { Packed } from './schema'; + +/** + * noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい + */ +export async function checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> { + if (note.visibility === 'specified') return false; + + if (note.visibility === 'followers') { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; + } + + if (!antenna.withReplies && note.replyId != null) return false; + + if (antenna.src === 'home') { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false; + } else if (antenna.src === 'list') { + const listUsers = (await UserListJoinings.find({ + userListId: antenna.userListId! + })).map(x => x.userId); + + if (!listUsers.includes(note.userId)) return false; + } else if (antenna.src === 'group') { + const joining = await UserGroupJoinings.findOneOrFail(antenna.userGroupJoiningId!); + + const groupUsers = (await UserGroupJoinings.find({ + userGroupId: joining.userGroupId + })).map(x => x.userId); + + if (!groupUsers.includes(note.userId)) return false; + } else if (antenna.src === 'users') { + const accts = antenna.users.map(x => { + const { username, host } = Acct.parse(x); + return getFullApAccount(username, host).toLowerCase(); + }); + if (!accts.includes(getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false; + } + + const keywords = antenna.keywords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (keywords.length > 0) { + if (note.text == null) return false; + + const matched = keywords.some(and => + and.every(keyword => + antenna.caseSensitive + ? note.text!.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()) + )); + + if (!matched) return false; + } + + const excludeKeywords = antenna.excludeKeywords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (excludeKeywords.length > 0) { + if (note.text == null) return false; + + const matched = excludeKeywords.some(and => + and.every(keyword => + antenna.caseSensitive + ? note.text!.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()) + )); + + if (matched) return false; + } + + if (antenna.withFile) { + if (note.fileIds && note.fileIds.length === 0) return false; + } + + // TODO: eval expression + + return true; +} diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts new file mode 100644 index 0000000000..e2e871dd2b --- /dev/null +++ b/packages/backend/src/misc/check-word-mute.ts @@ -0,0 +1,39 @@ +const RE2 = require('re2'); +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; + +type NoteLike = { + userId: Note['userId']; + text: Note['text']; +}; + +type UserLike = { + id: User['id']; +}; + +export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: string[][]): Promise<boolean> { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + const words = mutedWords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (words.length > 0) { + if (note.text == null) return false; + + const matched = words.some(and => + and.every(keyword => { + const regexp = keyword.match(/^\/(.+)\/(.*)$/); + if (regexp) { + return new RE2(regexp[1], regexp[2]).test(note.text!); + } + return note.text!.includes(keyword); + })); + + if (matched) return true; + } + + return false; +} diff --git a/packages/backend/src/misc/content-disposition.ts b/packages/backend/src/misc/content-disposition.ts new file mode 100644 index 0000000000..9df7ed4688 --- /dev/null +++ b/packages/backend/src/misc/content-disposition.ts @@ -0,0 +1,6 @@ +const cd = require('content-disposition'); + +export function contentDisposition(type: 'inline' | 'attachment', filename: string): string { + const fallback = filename.replace(/[^\w.-]/g, '_'); + return cd(filename, { type, fallback }); +} diff --git a/packages/backend/src/misc/convert-host.ts b/packages/backend/src/misc/convert-host.ts new file mode 100644 index 0000000000..6e9f6ed3e9 --- /dev/null +++ b/packages/backend/src/misc/convert-host.ts @@ -0,0 +1,26 @@ +import { URL } from 'url'; +import config from '@/config/index'; +import { toASCII } from 'punycode/'; + +export function getFullApAccount(username: string, host: string | null) { + return host ? `${username}@${toPuny(host)}` : `${username}@${toPuny(config.host)}`; +} + +export function isSelfHost(host: string) { + if (host == null) return true; + return toPuny(config.host) === toPuny(host); +} + +export function extractDbHost(uri: string) { + const url = new URL(uri); + return toPuny(url.hostname); +} + +export function toPuny(host: string) { + return toASCII(host.toLowerCase()); +} + +export function toPunyNullable(host: string | null | undefined): string | null { + if (host == null) return null; + return toASCII(host.toLowerCase()); +} diff --git a/packages/backend/src/misc/count-same-renotes.ts b/packages/backend/src/misc/count-same-renotes.ts new file mode 100644 index 0000000000..6628761182 --- /dev/null +++ b/packages/backend/src/misc/count-same-renotes.ts @@ -0,0 +1,15 @@ +import { Notes } from '@/models/index'; + +export async function countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise<number> { + // 指定したユーザーの指定したノートのリノートがいくつあるか数える + const query = Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId }) + .andWhere('note.renoteId = :renoteId', { renoteId }); + + // 指定した投稿を除く + if (excludeNoteId) { + query.andWhere('note.id != :excludeNoteId', { excludeNoteId }); + } + + return await query.getCount(); +} diff --git a/packages/backend/src/misc/create-temp.ts b/packages/backend/src/misc/create-temp.ts new file mode 100644 index 0000000000..04604cf7d0 --- /dev/null +++ b/packages/backend/src/misc/create-temp.ts @@ -0,0 +1,10 @@ +import * as tmp from 'tmp'; + +export function createTemp(): Promise<[string, any]> { + return new Promise<[string, any]>((res, rej) => { + tmp.file((e, path, fd, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); +} diff --git a/packages/backend/src/misc/detect-url-mime.ts b/packages/backend/src/misc/detect-url-mime.ts new file mode 100644 index 0000000000..274c291737 --- /dev/null +++ b/packages/backend/src/misc/detect-url-mime.ts @@ -0,0 +1,15 @@ +import { createTemp } from './create-temp'; +import { downloadUrl } from './download-url'; +import { detectType } from './get-file-info'; + +export async function detectUrlMime(url: string) { + const [path, cleanup] = await createTemp(); + + try { + await downloadUrl(url, path); + const { mime } = await detectType(path); + return mime; + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/misc/download-text-file.ts b/packages/backend/src/misc/download-text-file.ts new file mode 100644 index 0000000000..e8e23cc120 --- /dev/null +++ b/packages/backend/src/misc/download-text-file.ts @@ -0,0 +1,25 @@ +import * as fs from 'fs'; +import * as util from 'util'; +import Logger from '@/services/logger'; +import { createTemp } from './create-temp'; +import { downloadUrl } from './download-url'; + +const logger = new Logger('download-text-file'); + +export async function downloadTextFile(url: string): Promise<string> { + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + // write content at URL to temp file + await downloadUrl(url, path); + + const text = await util.promisify(fs.readFile)(path, 'utf8'); + + return text; + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/misc/download-url.ts b/packages/backend/src/misc/download-url.ts new file mode 100644 index 0000000000..c96b4fd1d6 --- /dev/null +++ b/packages/backend/src/misc/download-url.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import * as stream from 'stream'; +import * as util from 'util'; +import got, * as Got from 'got'; +import { httpAgent, httpsAgent, StatusError } from './fetch'; +import config from '@/config/index'; +import * as chalk from 'chalk'; +import Logger from '@/services/logger'; +import * as IPCIDR from 'ip-cidr'; +const PrivateIp = require('private-ip'); + +const pipeline = util.promisify(stream.pipeline); + +export async function downloadUrl(url: string, path: string) { + const logger = new Logger('download'); + + logger.info(`Downloading ${chalk.cyan(url)} ...`); + + const timeout = 30 * 1000; + const operationTimeout = 60 * 1000; + const maxSize = config.maxFileSize || 262144000; + + const req = got.stream(url, { + headers: { + 'User-Agent': config.userAgent + }, + timeout: { + lookup: timeout, + connect: timeout, + secureConnect: timeout, + socket: timeout, // read timeout + response: timeout, + send: timeout, + request: operationTimeout, // whole operation timeout + }, + agent: { + http: httpAgent, + https: httpsAgent, + }, + http2: false, // default + retry: 0, + }).on('response', (res: Got.Response) => { + if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !config.proxy && res.ip) { + if (isPrivateIp(res.ip)) { + logger.warn(`Blocked address: ${res.ip}`); + req.destroy(); + } + } + + const contentLength = res.headers['content-length']; + if (contentLength != null) { + const size = Number(contentLength); + if (size > maxSize) { + logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`); + req.destroy(); + } + } + }).on('downloadProgress', (progress: Got.Progress) => { + if (progress.transferred > maxSize) { + logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); + req.destroy(); + } + }); + + try { + await pipeline(req, fs.createWriteStream(path)); + } catch (e) { + if (e instanceof Got.HTTPError) { + throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage); + } else { + throw e; + } + } + + logger.succ(`Download finished: ${chalk.cyan(url)}`); +} + +function isPrivateIp(ip: string) { + for (const net of config.allowedPrivateNetworks || []) { + const cidr = new IPCIDR(net); + if (cidr.contains(ip)) { + return false; + } + } + + return PrivateIp(ip); +} diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts new file mode 100644 index 0000000000..8b07fbd8f2 --- /dev/null +++ b/packages/backend/src/misc/emoji-regex.ts @@ -0,0 +1,3 @@ +const twemojiRegex = require('twemoji-parser/dist/lib/regex').default; + +export const emojiRegex = new RegExp(`(${twemojiRegex.source})`); diff --git a/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts new file mode 100644 index 0000000000..b29ce281b3 --- /dev/null +++ b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts @@ -0,0 +1,10 @@ +import * as mfm from 'mfm-js'; +import { unique } from '@/prelude/array'; + +export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] { + const emojiNodes = mfm.extract(nodes, (node) => { + return (node.type === 'emojiCode' && node.props.name.length <= 100); + }); + + return unique(emojiNodes.map(x => x.props.name)); +} diff --git a/packages/backend/src/misc/extract-hashtags.ts b/packages/backend/src/misc/extract-hashtags.ts new file mode 100644 index 0000000000..b0a74df219 --- /dev/null +++ b/packages/backend/src/misc/extract-hashtags.ts @@ -0,0 +1,9 @@ +import * as mfm from 'mfm-js'; +import { unique } from '@/prelude/array'; + +export function extractHashtags(nodes: mfm.MfmNode[]): string[] { + const hashtagNodes = mfm.extract(nodes, (node) => node.type === 'hashtag'); + const hashtags = unique(hashtagNodes.map(x => x.props.hashtag)); + + return hashtags; +} diff --git a/packages/backend/src/misc/extract-mentions.ts b/packages/backend/src/misc/extract-mentions.ts new file mode 100644 index 0000000000..cc19b161a8 --- /dev/null +++ b/packages/backend/src/misc/extract-mentions.ts @@ -0,0 +1,11 @@ +// test is located in test/extract-mentions + +import * as mfm from 'mfm-js'; + +export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { + // TODO: 重複を削除 + const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention'); + const mentions = mentionNodes.map(x => x.props); + + return mentions; +} diff --git a/packages/backend/src/misc/fetch-meta.ts b/packages/backend/src/misc/fetch-meta.ts new file mode 100644 index 0000000000..a0bcdd4d48 --- /dev/null +++ b/packages/backend/src/misc/fetch-meta.ts @@ -0,0 +1,35 @@ +import { Meta } from '@/models/entities/meta'; +import { getConnection } from 'typeorm'; + +let cache: Meta; + +export async function fetchMeta(noCache = false): Promise<Meta> { + if (!noCache && cache) return cache; + + return await getConnection().transaction(async transactionalEntityManager => { + // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する + const meta = await transactionalEntityManager.findOne(Meta, { + order: { + id: 'DESC' + } + }); + + if (meta) { + cache = meta; + return meta; + } else { + const saved = await transactionalEntityManager.save(Meta, { + id: 'x' + }) as Meta; + + cache = saved; + return saved; + } + }); +} + +setInterval(() => { + fetchMeta(true).then(meta => { + cache = meta; + }); +}, 1000 * 10); diff --git a/packages/backend/src/misc/fetch-proxy-account.ts b/packages/backend/src/misc/fetch-proxy-account.ts new file mode 100644 index 0000000000..e0eedea4c8 --- /dev/null +++ b/packages/backend/src/misc/fetch-proxy-account.ts @@ -0,0 +1,9 @@ +import { fetchMeta } from './fetch-meta'; +import { ILocalUser } from '@/models/entities/user'; +import { Users } from '@/models/index'; + +export async function fetchProxyAccount(): Promise<ILocalUser | null> { + const meta = await fetchMeta(); + if (meta.proxyAccountId == null) return null; + return await Users.findOneOrFail(meta.proxyAccountId) as ILocalUser; +} diff --git a/packages/backend/src/misc/fetch.ts b/packages/backend/src/misc/fetch.ts new file mode 100644 index 0000000000..f4f16a27e2 --- /dev/null +++ b/packages/backend/src/misc/fetch.ts @@ -0,0 +1,141 @@ +import * as http from 'http'; +import * as https from 'https'; +import CacheableLookup from 'cacheable-lookup'; +import fetch from 'node-fetch'; +import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; +import config from '@/config/index'; +import { URL } from 'url'; + +export async function getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: Record<string, string>) { + const res = await getResponse({ + url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': config.userAgent, + Accept: accept + }, headers || {}), + timeout + }); + + return await res.json(); +} + +export async function getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: Record<string, string>) { + const res = await getResponse({ + url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': config.userAgent, + Accept: accept + }, headers || {}), + timeout + }); + + return await res.text(); +} + +export async function getResponse(args: { url: string, method: string, body?: string, headers: Record<string, string>, timeout?: number, size?: number }) { + const timeout = args?.timeout || 10 * 1000; + + const controller = new AbortController(); + setTimeout(() => { + controller.abort(); + }, timeout * 6); + + const res = await fetch(args.url, { + method: args.method, + headers: args.headers, + body: args.body, + timeout, + size: args?.size || 10 * 1024 * 1024, + agent: getAgentByUrl, + signal: controller.signal, + }); + + if (!res.ok) { + throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText); + } + + return res; +} + +const cache = new CacheableLookup({ + maxTtl: 3600, // 1hours + errorTtl: 30, // 30secs + lookup: false, // nativeのdns.lookupにfallbackしない +}); + +/** + * Get http non-proxy agent + */ +const _http = new http.Agent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, +} as http.AgentOptions); + +/** + * Get https non-proxy agent + */ +const _https = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, +} as https.AgentOptions); + +const maxSockets = Math.max(256, config.deliverJobConcurrency || 128); + +/** + * Get http proxy or non-proxy agent + */ +export const httpAgent = config.proxy + ? new HttpProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: config.proxy + }) + : _http; + +/** + * Get https proxy or non-proxy agent + */ +export const httpsAgent = config.proxy + ? new HttpsProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: config.proxy + }) + : _https; + +/** + * Get agent by URL + * @param url URL + * @param bypassProxy Allways bypass proxy + */ +export function getAgentByUrl(url: URL, bypassProxy = false) { + if (bypassProxy || (config.proxyBypassHosts || []).includes(url.hostname)) { + return url.protocol == 'http:' ? _http : _https; + } else { + return url.protocol == 'http:' ? httpAgent : httpsAgent; + } +} + +export class StatusError extends Error { + public statusCode: number; + public statusMessage?: string; + public isClientError: boolean; + + constructor(message: string, statusCode: number, statusMessage?: string) { + super(message); + this.name = 'StatusError'; + this.statusCode = statusCode; + this.statusMessage = statusMessage; + this.isClientError = typeof this.statusCode === 'number' && this.statusCode >= 400 && this.statusCode < 500; + } +} diff --git a/packages/backend/src/misc/gen-avatar.ts b/packages/backend/src/misc/gen-avatar.ts new file mode 100644 index 0000000000..f03ca9f96d --- /dev/null +++ b/packages/backend/src/misc/gen-avatar.ts @@ -0,0 +1,90 @@ +/** + * Random avatar generator + */ + +import * as p from 'pureimage'; +import * as gen from 'random-seed'; +import { WriteStream } from 'fs'; + +const size = 256; // px +const n = 5; // resolution +const margin = (size / n); +const colors = [ + '#e57373', + '#F06292', + '#BA68C8', + '#9575CD', + '#7986CB', + '#64B5F6', + '#4FC3F7', + '#4DD0E1', + '#4DB6AC', + '#81C784', + '#8BC34A', + '#AFB42B', + '#F57F17', + '#FF5722', + '#795548', + '#455A64', +]; +const bg = '#e9e9e9'; + +const actualSize = size - (margin * 2); +const cellSize = actualSize / n; +const sideN = Math.floor(n / 2); + +/** + * Generate buffer of random avatar by seed + */ +export function genAvatar(seed: string, stream: WriteStream): Promise<void> { + const rand = gen.create(seed); + const canvas = p.make(size, size); + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = bg; + ctx.beginPath(); + ctx.fillRect(0, 0, size, size); + + ctx.fillStyle = colors[rand(colors.length)]; + + // side bitmap (filled by false) + const side: boolean[][] = new Array(sideN); + for (let i = 0; i < side.length; i++) { + side[i] = new Array(n).fill(false); + } + + // 1*n (filled by false) + const center: boolean[] = new Array(n).fill(false); + + // tslint:disable-next-line:prefer-for-of + for (let x = 0; x < side.length; x++) { + for (let y = 0; y < side[x].length; y++) { + side[x][y] = rand(3) === 0; + } + } + + for (let i = 0; i < center.length; i++) { + center[i] = rand(3) === 0; + } + + // Draw + for (let x = 0; x < n; x++) { + for (let y = 0; y < n; y++) { + const isXCenter = x === ((n - 1) / 2); + if (isXCenter && !center[y]) continue; + + const isLeftSide = x < ((n - 1) / 2); + if (isLeftSide && !side[x][y]) continue; + + const isRightSide = x > ((n - 1) / 2); + if (isRightSide && !side[sideN - (x - sideN)][y]) continue; + + const actualX = margin + (cellSize * x); + const actualY = margin + (cellSize * y); + ctx.beginPath(); + ctx.fillRect(actualX, actualY, cellSize, cellSize); + } + } + + return p.encodePNGToStream(canvas, stream); +} diff --git a/packages/backend/src/misc/gen-id.ts b/packages/backend/src/misc/gen-id.ts new file mode 100644 index 0000000000..b1b542dc4b --- /dev/null +++ b/packages/backend/src/misc/gen-id.ts @@ -0,0 +1,21 @@ +import { ulid } from 'ulid'; +import { genAid } from './id/aid'; +import { genMeid } from './id/meid'; +import { genMeidg } from './id/meidg'; +import { genObjectId } from './id/object-id'; +import config from '@/config/index'; + +const metohd = config.id.toLowerCase(); + +export function genId(date?: Date): string { + if (!date || (date > new Date())) date = new Date(); + + switch (metohd) { + case 'aid': return genAid(date); + case 'meid': return genMeid(date); + case 'meidg': return genMeidg(date); + case 'ulid': return ulid(date.getTime()); + case 'objectid': return genObjectId(date); + default: throw new Error('unrecognized id generation method'); + } +} diff --git a/packages/backend/src/misc/gen-key-pair.ts b/packages/backend/src/misc/gen-key-pair.ts new file mode 100644 index 0000000000..d4a8fa7534 --- /dev/null +++ b/packages/backend/src/misc/gen-key-pair.ts @@ -0,0 +1,36 @@ +import * as crypto from 'crypto'; +import * as util from 'util'; + +const generateKeyPair = util.promisify(crypto.generateKeyPair); + +export async function genRsaKeyPair(modulusLength = 2048) { + return await generateKeyPair('rsa', { + modulusLength, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined + } + }); +} + +export async function genEcKeyPair(namedCurve: 'prime256v1' | 'secp384r1' | 'secp521r1' | 'curve25519' = 'prime256v1') { + return await generateKeyPair('ec', { + namedCurve, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined + } + }); +} diff --git a/packages/backend/src/misc/get-file-info.ts b/packages/backend/src/misc/get-file-info.ts new file mode 100644 index 0000000000..39ba541395 --- /dev/null +++ b/packages/backend/src/misc/get-file-info.ts @@ -0,0 +1,196 @@ +import * as fs from 'fs'; +import * as crypto from 'crypto'; +import * as stream from 'stream'; +import * as util from 'util'; +import * as fileType from 'file-type'; +import isSvg from 'is-svg'; +import * as probeImageSize from 'probe-image-size'; +import * as sharp from 'sharp'; +import { encode } from 'blurhash'; + +const pipeline = util.promisify(stream.pipeline); + +export type FileInfo = { + size: number; + md5: string; + type: { + mime: string; + ext: string | null; + }; + width?: number; + height?: number; + blurhash?: string; + warnings: string[]; +}; + +const TYPE_OCTET_STREAM = { + mime: 'application/octet-stream', + ext: null +}; + +const TYPE_SVG = { + mime: 'image/svg+xml', + ext: 'svg' +}; + +/** + * Get file information + */ +export async function getFileInfo(path: string): Promise<FileInfo> { + const warnings = [] as string[]; + + const size = await getFileSize(path); + const md5 = await calcHash(path); + + let type = await detectType(path); + + // image dimensions + let width: number | undefined; + let height: number | undefined; + + if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) { + const imageSize = await detectImageSize(path).catch(e => { + warnings.push(`detectImageSize failed: ${e}`); + return undefined; + }); + + // うまく判定できない画像は octet-stream にする + if (!imageSize) { + warnings.push(`cannot detect image dimensions`); + type = TYPE_OCTET_STREAM; + } else if (imageSize.wUnits === 'px') { + width = imageSize.width; + height = imageSize.height; + + // 制限を超えている画像は octet-stream にする + if (imageSize.width > 16383 || imageSize.height > 16383) { + warnings.push(`image dimensions exceeds limits`); + type = TYPE_OCTET_STREAM; + } + } else { + warnings.push(`unsupported unit type: ${imageSize.wUnits}`); + } + } + + let blurhash: string | undefined; + + if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) { + blurhash = await getBlurhash(path).catch(e => { + warnings.push(`getBlurhash failed: ${e}`); + return undefined; + }); + } + + return { + size, + md5, + type, + width, + height, + blurhash, + warnings, + }; +} + +/** + * Detect MIME Type and extension + */ +export async function detectType(path: string) { + // Check 0 byte + const fileSize = await getFileSize(path); + if (fileSize === 0) { + return TYPE_OCTET_STREAM; + } + + const type = await fileType.fromFile(path); + + if (type) { + // XMLはSVGかもしれない + if (type.mime === 'application/xml' && await checkSvg(path)) { + return TYPE_SVG; + } + + return { + mime: type.mime, + ext: type.ext + }; + } + + // 種類が不明でもSVGかもしれない + if (await checkSvg(path)) { + return TYPE_SVG; + } + + // それでも種類が不明なら application/octet-stream にする + return TYPE_OCTET_STREAM; +} + +/** + * Check the file is SVG or not + */ +export async function checkSvg(path: string) { + try { + const size = await getFileSize(path); + if (size > 1 * 1024 * 1024) return false; + return isSvg(fs.readFileSync(path)); + } catch { + return false; + } +} + +/** + * Get file size + */ +export async function getFileSize(path: string): Promise<number> { + const getStat = util.promisify(fs.stat); + return (await getStat(path)).size; +} + +/** + * Calculate MD5 hash + */ +async function calcHash(path: string): Promise<string> { + const hash = crypto.createHash('md5').setEncoding('hex'); + await pipeline(fs.createReadStream(path), hash); + return hash.read(); +} + +/** + * Detect dimensions of image + */ +async function detectImageSize(path: string): Promise<{ + width: number; + height: number; + wUnits: string; + hUnits: string; +}> { + const readable = fs.createReadStream(path); + const imageSize = await probeImageSize(readable); + readable.destroy(); + return imageSize; +} + +/** + * Calculate average color of image + */ +function getBlurhash(path: string): Promise<string> { + return new Promise((resolve, reject) => { + sharp(path) + .raw() + .ensureAlpha() + .resize(64, 64, { fit: 'inside' }) + .toBuffer((err, buffer, { width, height }) => { + if (err) return reject(err); + + let hash; + + try { + hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7); + } catch (e) { + return reject(e); + } + + resolve(hash); + }); + }); +} diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts new file mode 100644 index 0000000000..d7273d1c5b --- /dev/null +++ b/packages/backend/src/misc/get-note-summary.ts @@ -0,0 +1,54 @@ +import { Packed } from './schema'; + +/** + * 投稿を表す文字列を取得します。 + * @param {*} note (packされた)投稿 + */ +export const getNoteSummary = (note: Packed<'Note'>): string => { + if (note.deletedAt) { + return `(❌⛔)`; + } + + if (note.isHidden) { + return `(⛔)`; + } + + let summary = ''; + + // 本文 + if (note.cw != null) { + summary += note.cw; + } else { + summary += note.text ? note.text : ''; + } + + // ファイルが添付されているとき + if ((note.files || []).length != 0) { + summary += ` (📎${note.files!.length})`; + } + + // 投票が添付されているとき + if (note.poll) { + summary += ` (📊)`; + } + + // 返信のとき + if (note.replyId) { + if (note.reply) { + summary += `\n\nRE: ${getNoteSummary(note.reply)}`; + } else { + summary += '\n\nRE: ...'; + } + } + + // Renoteのとき + if (note.renoteId) { + if (note.renote) { + summary += `\n\nRN: ${getNoteSummary(note.renote)}`; + } else { + summary += '\n\nRN: ...'; + } + } + + return summary.trim(); +}; diff --git a/packages/backend/src/misc/get-reaction-emoji.ts b/packages/backend/src/misc/get-reaction-emoji.ts new file mode 100644 index 0000000000..c2e0b98582 --- /dev/null +++ b/packages/backend/src/misc/get-reaction-emoji.ts @@ -0,0 +1,16 @@ +export default function(reaction: string): string { + switch (reaction) { + case 'like': return '👍'; + case 'love': return '❤️'; + case 'laugh': return '😆'; + case 'hmm': return '🤔'; + case 'surprise': return '😮'; + case 'congrats': return '🎉'; + case 'angry': return '💢'; + case 'confused': return '😥'; + case 'rip': return '😇'; + case 'pudding': return '🍮'; + case 'star': return '⭐'; + default: return reaction; + } +} diff --git a/packages/backend/src/misc/hard-limits.ts b/packages/backend/src/misc/hard-limits.ts new file mode 100644 index 0000000000..1039f7335a --- /dev/null +++ b/packages/backend/src/misc/hard-limits.ts @@ -0,0 +1,14 @@ + +// If you change DB_* values, you must also change the DB schema. + +/** + * Maximum note text length that can be stored in DB. + * Surrogate pairs count as one + */ +export const DB_MAX_NOTE_TEXT_LENGTH = 8192; + +/** + * Maximum image description length that can be stored in DB. + * Surrogate pairs count as one + */ +export const DB_MAX_IMAGE_COMMENT_LENGTH = 512; diff --git a/packages/backend/src/misc/i18n.ts b/packages/backend/src/misc/i18n.ts new file mode 100644 index 0000000000..4fa398763a --- /dev/null +++ b/packages/backend/src/misc/i18n.ts @@ -0,0 +1,29 @@ +export class I18n<T extends Record<string, any>> { + public locale: T; + + constructor(locale: T) { + this.locale = locale; + + //#region BIND + this.t = this.t.bind(this); + //#endregion + } + + // string にしているのは、ドット区切りでのパス指定を許可するため + // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも + public t(key: string, args?: Record<string, any>): string { + try { + let str = key.split('.').reduce((o, i) => o[i], this.locale) as string; + + if (args) { + for (const [k, v] of Object.entries(args)) { + str = str.replace(`{${k}}`, v); + } + } + return str; + } catch (e) { + console.warn(`missing localization '${key}'`); + return key; + } + } +} diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts new file mode 100644 index 0000000000..2bcde90bff --- /dev/null +++ b/packages/backend/src/misc/id/aid.ts @@ -0,0 +1,25 @@ +// AID +// 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ2の[ノイズ文字列] + +import * as crypto from 'crypto'; + +const TIME2000 = 946684800000; +let counter = crypto.randomBytes(2).readUInt16LE(0); + +function getTime(time: number) { + time = time - TIME2000; + if (time < 0) time = 0; + + return time.toString(36).padStart(8, '0'); +} + +function getNoise() { + return counter.toString(36).padStart(2, '0').slice(-2); +} + +export function genAid(date: Date): string { + const t = date.getTime(); + if (isNaN(t)) throw 'Failed to create AID: Invalid Date'; + counter++; + return getTime(t) + getNoise(); +} diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts new file mode 100644 index 0000000000..30bbdf1698 --- /dev/null +++ b/packages/backend/src/misc/id/meid.ts @@ -0,0 +1,26 @@ +const CHARS = '0123456789abcdef'; + +function getTime(time: number) { + if (time < 0) time = 0; + if (time === 0) { + return CHARS[0]; + } + + time += 0x800000000000; + + return time.toString(16).padStart(12, CHARS[0]); +} + +function getRandom() { + let str = ''; + + for (let i = 0; i < 12; i++) { + str += CHARS[Math.floor(Math.random() * CHARS.length)]; + } + + return str; +} + +export function genMeid(date: Date): string { + return getTime(date.getTime()) + getRandom(); +} diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts new file mode 100644 index 0000000000..d4aaaea1ba --- /dev/null +++ b/packages/backend/src/misc/id/meidg.ts @@ -0,0 +1,28 @@ +const CHARS = '0123456789abcdef'; + +// 4bit Fixed hex value 'g' +// 44bit UNIX Time ms in Hex +// 48bit Random value in Hex + +function getTime(time: number) { + if (time < 0) time = 0; + if (time === 0) { + return CHARS[0]; + } + + return time.toString(16).padStart(11, CHARS[0]); +} + +function getRandom() { + let str = ''; + + for (let i = 0; i < 12; i++) { + str += CHARS[Math.floor(Math.random() * CHARS.length)]; + } + + return str; +} + +export function genMeidg(date: Date): string { + return 'g' + getTime(date.getTime()) + getRandom(); +} diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts new file mode 100644 index 0000000000..392ea43301 --- /dev/null +++ b/packages/backend/src/misc/id/object-id.ts @@ -0,0 +1,26 @@ +const CHARS = '0123456789abcdef'; + +function getTime(time: number) { + if (time < 0) time = 0; + if (time === 0) { + return CHARS[0]; + } + + time = Math.floor(time / 1000); + + return time.toString(16).padStart(8, CHARS[0]); +} + +function getRandom() { + let str = ''; + + for (let i = 0; i < 16; i++) { + str += CHARS[Math.floor(Math.random() * CHARS.length)]; + } + + return str; +} + +export function genObjectId(date: Date): string { + return getTime(date.getTime()) + getRandom(); +} diff --git a/packages/backend/src/misc/identifiable-error.ts b/packages/backend/src/misc/identifiable-error.ts new file mode 100644 index 0000000000..2d7c6bd0c6 --- /dev/null +++ b/packages/backend/src/misc/identifiable-error.ts @@ -0,0 +1,13 @@ +/** + * ID付きエラー + */ +export class IdentifiableError extends Error { + public message: string; + public id: string; + + constructor(id: string, message?: string) { + super(message); + this.message = message || ''; + this.id = id; + } +} diff --git a/packages/backend/src/misc/is-blocker-user-related.ts b/packages/backend/src/misc/is-blocker-user-related.ts new file mode 100644 index 0000000000..8c0ebfad9b --- /dev/null +++ b/packages/backend/src/misc/is-blocker-user-related.ts @@ -0,0 +1,15 @@ +export function isBlockerUserRelated(note: any, blockerUserIds: Set<string>): boolean { + if (blockerUserIds.has(note.userId)) { + return true; + } + + if (note.reply != null && blockerUserIds.has(note.reply.userId)) { + return true; + } + + if (note.renote != null && blockerUserIds.has(note.renote.userId)) { + return true; + } + + return false; +} diff --git a/packages/backend/src/misc/is-duplicate-key-value-error.ts b/packages/backend/src/misc/is-duplicate-key-value-error.ts new file mode 100644 index 0000000000..23d8ceb1b7 --- /dev/null +++ b/packages/backend/src/misc/is-duplicate-key-value-error.ts @@ -0,0 +1,3 @@ +export function isDuplicateKeyValueError(e: Error): boolean { + return e.message.startsWith('duplicate key value'); +} diff --git a/packages/backend/src/misc/is-muted-user-related.ts b/packages/backend/src/misc/is-muted-user-related.ts new file mode 100644 index 0000000000..2caa743f95 --- /dev/null +++ b/packages/backend/src/misc/is-muted-user-related.ts @@ -0,0 +1,15 @@ +export function isMutedUserRelated(note: any, mutedUserIds: Set<string>): boolean { + if (mutedUserIds.has(note.userId)) { + return true; + } + + if (note.reply != null && mutedUserIds.has(note.reply.userId)) { + return true; + } + + if (note.renote != null && mutedUserIds.has(note.renote.userId)) { + return true; + } + + return false; +} diff --git a/packages/backend/src/misc/is-quote.ts b/packages/backend/src/misc/is-quote.ts new file mode 100644 index 0000000000..2b57f036a2 --- /dev/null +++ b/packages/backend/src/misc/is-quote.ts @@ -0,0 +1,5 @@ +import { Note } from '@/models/entities/note'; + +export default function(note: Note): boolean { + return note.renoteId != null && (note.text != null || note.hasPoll || (note.fileIds != null && note.fileIds.length > 0)); +} diff --git a/packages/backend/src/misc/keypair-store.ts b/packages/backend/src/misc/keypair-store.ts new file mode 100644 index 0000000000..c018013b7b --- /dev/null +++ b/packages/backend/src/misc/keypair-store.ts @@ -0,0 +1,10 @@ +import { UserKeypairs } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { UserKeypair } from '@/models/entities/user-keypair'; +import { Cache } from './cache'; + +const cache = new Cache<UserKeypair>(Infinity); + +export async function getUserKeypair(userId: User['id']): Promise<UserKeypair> { + return await cache.fetch(userId, () => UserKeypairs.findOneOrFail(userId)); +} diff --git a/packages/backend/src/misc/normalize-for-search.ts b/packages/backend/src/misc/normalize-for-search.ts new file mode 100644 index 0000000000..200540566e --- /dev/null +++ b/packages/backend/src/misc/normalize-for-search.ts @@ -0,0 +1,6 @@ +export function normalizeForSearch(tag: string): string { + // ref. + // - https://analytics-note.xyz/programming/unicode-normalization-forms/ + // - https://maku77.github.io/js/string/normalize.html + return tag.normalize('NFKC').toLowerCase(); +} diff --git a/packages/backend/src/misc/nyaize.ts b/packages/backend/src/misc/nyaize.ts new file mode 100644 index 0000000000..500d1db2cb --- /dev/null +++ b/packages/backend/src/misc/nyaize.ts @@ -0,0 +1,15 @@ +export function nyaize(text: string): string { + return text + // ja-JP + .replace(/な/g, 'にゃ').replace(/ナ/g, 'ニャ').replace(/ナ/g, 'ニャ') + // en-US + .replace(/(?<=n)a/gi, x => x === 'A' ? 'YA' : 'ya') + .replace(/(?<=morn)ing/gi, x => x === 'ING' ? 'YAN' : 'yan') + .replace(/(?<=every)one/gi, x => x === 'ONE' ? 'NYAN' : 'nyan') + // ko-KR + .replace(/[나-낳]/g, match => String.fromCharCode( + match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0) + )) + .replace(/(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm, '다냥') + .replace(/(야(?=\?))|(야$)|(야(?= ))/gm, '냥'); +} diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts new file mode 100644 index 0000000000..f0a8bde31e --- /dev/null +++ b/packages/backend/src/misc/populate-emojis.ts @@ -0,0 +1,124 @@ +import { In } from 'typeorm'; +import { Emojis } from '@/models/index'; +import { Emoji } from '@/models/entities/emoji'; +import { Note } from '@/models/entities/note'; +import { Cache } from './cache'; +import { isSelfHost, toPunyNullable } from './convert-host'; +import { decodeReaction } from './reaction-lib'; +import config from '@/config/index'; +import { query } from '@/prelude/url'; + +const cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12); + +/** + * 添付用絵文字情報 + */ +type PopulatedEmoji = { + name: string; + url: string; +}; + +function normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { + // クエリに使うホスト + let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) + : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) + : isSelfHost(src) ? null // 自ホスト指定 + : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) + + host = toPunyNullable(host); + + return host; +} + +function parseEmojiStr(emojiName: string, noteUserHost: string | null) { + const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); + if (!match) return { name: null, host: null }; + + const name = match[1]; + + // ホスト正規化 + const host = toPunyNullable(normalizeHost(match[2], noteUserHost)); + + return { name, host }; +} + +/** + * 添付用絵文字情報を解決する + * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能)) + * @param noteUserHost ノートやユーザープロフィールの所有者のホスト + * @returns 絵文字情報, nullは未マッチを意味する + */ +export async function populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> { + const { name, host } = parseEmojiStr(emojiName, noteUserHost); + if (name == null) return null; + + const queryOrNull = async () => (await Emojis.findOne({ + name, + host + })) || null; + + const emoji = await cache.fetch(`${name} ${host}`, queryOrNull); + + if (emoji == null) return null; + + const isLocal = emoji.host == null; + const url = isLocal ? emoji.url : `${config.url}/proxy/image.png?${query({url: emoji.url})}`; + + return { + name: emojiName, + url, + }; +} + +/** + * 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される) + */ +export async function populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> { + const emojis = await Promise.all(emojiNames.map(x => populateEmoji(x, noteUserHost))); + return emojis.filter((x): x is PopulatedEmoji => x != null); +} + +export function aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[]; +} + +/** + * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します + */ +export async function prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> { + const notCachedEmojis = emojis.filter(emoji => cache.get(`${emoji.name} ${emoji.host}`) == null); + const emojisQuery: any[] = []; + const hosts = new Set(notCachedEmojis.map(e => e.host)); + for (const host of hosts) { + emojisQuery.push({ + name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)), + host: host + }); + } + const _emojis = emojisQuery.length > 0 ? await Emojis.find({ + where: emojisQuery, + select: ['name', 'host', 'url'] + }) : []; + for (const emoji of _emojis) { + cache.set(`${emoji.name} ${emoji.host}`, emoji); + } +} diff --git a/packages/backend/src/misc/reaction-lib.ts b/packages/backend/src/misc/reaction-lib.ts new file mode 100644 index 0000000000..46dedfa24b --- /dev/null +++ b/packages/backend/src/misc/reaction-lib.ts @@ -0,0 +1,129 @@ +import { emojiRegex } from './emoji-regex'; +import { fetchMeta } from './fetch-meta'; +import { Emojis } from '@/models/index'; +import { toPunyNullable } from './convert-host'; + +const legacies: Record<string, string> = { + 'like': '👍', + 'love': '❤', // ここに記述する場合は異体字セレクタを入れない + 'laugh': '😆', + 'hmm': '🤔', + 'surprise': '😮', + 'congrats': '🎉', + 'angry': '💢', + 'confused': '😥', + 'rip': '😇', + 'pudding': '🍮', + 'star': '⭐', +}; + +export async function getFallbackReaction(): Promise<string> { + const meta = await fetchMeta(); + return meta.useStarForReactionFallback ? '⭐' : '👍'; +} + +export function convertLegacyReactions(reactions: Record<string, number>) { + const _reactions = {} as Record<string, number>; + + for (const reaction of Object.keys(reactions)) { + if (reactions[reaction] <= 0) continue; + + if (Object.keys(legacies).includes(reaction)) { + if (_reactions[legacies[reaction]]) { + _reactions[legacies[reaction]] += reactions[reaction]; + } else { + _reactions[legacies[reaction]] = reactions[reaction]; + } + } else { + if (_reactions[reaction]) { + _reactions[reaction] += reactions[reaction]; + } else { + _reactions[reaction] = reactions[reaction]; + } + } + } + + const _reactions2 = {} as Record<string, number>; + + for (const reaction of Object.keys(_reactions)) { + _reactions2[decodeReaction(reaction).reaction] = _reactions[reaction]; + } + + return _reactions2; +} + +export async function toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> { + if (reaction == null) return await getFallbackReaction(); + + reacterHost = toPunyNullable(reacterHost); + + // 文字列タイプのリアクションを絵文字に変換 + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + + // Unicode絵文字 + const match = emojiRegex.exec(reaction); + if (match) { + // 合字を含む1つの絵文字 + const unicode = match[0]; + + // 異体字セレクタ除去 + return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); + } + + const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); + if (custom) { + const name = custom[1]; + const emoji = await Emojis.findOne({ + host: reacterHost || null, + name, + }); + + if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; + } + + return await getFallbackReaction(); +} + +type DecodedReaction = { + /** + * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.') + */ + reaction: string; + + /** + * name (カスタム絵文字の場合name, Emojiクエリに使う) + */ + name?: string; + + /** + * host (カスタム絵文字の場合host, Emojiクエリに使う) + */ + host?: string | null; +}; + +export function decodeReaction(str: string): DecodedReaction { + const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/); + + if (custom) { + const name = custom[1]; + const host = custom[2] || null; + + return { + reaction: `:${name}@${host || '.'}:`, // ローカル分は@以降を省略するのではなく.にする + name, + host + }; + } + + return { + reaction: str, + name: undefined, + host: undefined + }; +} + +export function convertLegacyReaction(reaction: string): string { + reaction = decodeReaction(reaction).reaction; + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + return reaction; +} diff --git a/packages/backend/src/misc/safe-for-sql.ts b/packages/backend/src/misc/safe-for-sql.ts new file mode 100644 index 0000000000..02eb7f0a26 --- /dev/null +++ b/packages/backend/src/misc/safe-for-sql.ts @@ -0,0 +1,3 @@ +export function safeForSql(text: string): boolean { + return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text); +} diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts new file mode 100644 index 0000000000..4131875ef7 --- /dev/null +++ b/packages/backend/src/misc/schema.ts @@ -0,0 +1,107 @@ +import { SimpleObj, SimpleSchema } from './simple-schema'; +import { packedUserSchema } from '@/models/repositories/user'; +import { packedNoteSchema } from '@/models/repositories/note'; +import { packedUserListSchema } from '@/models/repositories/user-list'; +import { packedAppSchema } from '@/models/repositories/app'; +import { packedMessagingMessageSchema } from '@/models/repositories/messaging-message'; +import { packedNotificationSchema } from '@/models/repositories/notification'; +import { packedDriveFileSchema } from '@/models/repositories/drive-file'; +import { packedDriveFolderSchema } from '@/models/repositories/drive-folder'; +import { packedFollowingSchema } from '@/models/repositories/following'; +import { packedMutingSchema } from '@/models/repositories/muting'; +import { packedBlockingSchema } from '@/models/repositories/blocking'; +import { packedNoteReactionSchema } from '@/models/repositories/note-reaction'; +import { packedHashtagSchema } from '@/models/repositories/hashtag'; +import { packedPageSchema } from '@/models/repositories/page'; +import { packedUserGroupSchema } from '@/models/repositories/user-group'; +import { packedNoteFavoriteSchema } from '@/models/repositories/note-favorite'; +import { packedChannelSchema } from '@/models/repositories/channel'; +import { packedAntennaSchema } from '@/models/repositories/antenna'; +import { packedClipSchema } from '@/models/repositories/clip'; +import { packedFederationInstanceSchema } from '@/models/repositories/federation-instance'; +import { packedQueueCountSchema } from '@/models/repositories/queue'; +import { packedGalleryPostSchema } from '@/models/repositories/gallery-post'; +import { packedEmojiSchema } from '@/models/repositories/emoji'; +import { packedReversiGameSchema } from '@/models/repositories/games/reversi/game'; +import { packedReversiMatchingSchema } from '@/models/repositories/games/reversi/matching'; + +export const refs = { + User: packedUserSchema, + UserList: packedUserListSchema, + UserGroup: packedUserGroupSchema, + App: packedAppSchema, + MessagingMessage: packedMessagingMessageSchema, + Note: packedNoteSchema, + NoteReaction: packedNoteReactionSchema, + NoteFavorite: packedNoteFavoriteSchema, + Notification: packedNotificationSchema, + DriveFile: packedDriveFileSchema, + DriveFolder: packedDriveFolderSchema, + Following: packedFollowingSchema, + Muting: packedMutingSchema, + Blocking: packedBlockingSchema, + Hashtag: packedHashtagSchema, + Page: packedPageSchema, + Channel: packedChannelSchema, + QueueCount: packedQueueCountSchema, + Antenna: packedAntennaSchema, + Clip: packedClipSchema, + FederationInstance: packedFederationInstanceSchema, + GalleryPost: packedGalleryPostSchema, + Emoji: packedEmojiSchema, + ReversiGame: packedReversiGameSchema, + ReversiMatching: packedReversiMatchingSchema, +}; + +export type Packed<x extends keyof typeof refs> = ObjType<(typeof refs[x])['properties']>; + +export interface Schema extends SimpleSchema { + items?: Schema; + properties?: Obj; + ref?: keyof typeof refs; +} + +type NonUndefinedPropertyNames<T extends Obj> = { + [K in keyof T]: T[K]['optional'] extends true ? never : K +}[keyof T]; + +type UndefinedPropertyNames<T extends Obj> = { + [K in keyof T]: T[K]['optional'] extends true ? K : never +}[keyof T]; + +type OnlyRequired<T extends Obj> = Pick<T, NonUndefinedPropertyNames<T>>; +type OnlyOptional<T extends Obj> = Pick<T, UndefinedPropertyNames<T>>; + +export interface Obj extends SimpleObj { [key: string]: Schema; } + +export type ObjType<s extends Obj> = + { [P in keyof OnlyOptional<s>]?: SchemaType<s[P]> } & + { [P in keyof OnlyRequired<s>]: SchemaType<s[P]> }; + +// https://qiita.com/hrsh7th@github/items/84e8968c3601009cdcf2 +type MyType<T extends Schema> = { + 0: any; + 1: SchemaType<T>; +}[T extends Schema ? 1 : 0]; + +type NullOrUndefined<p extends Schema, T> = + p['nullable'] extends true + ? p['optional'] extends true + ? (T | null | undefined) + : (T | null) + : p['optional'] extends true + ? (T | undefined) + : T; + +export type SchemaType<p extends Schema> = + p['type'] extends 'number' ? NullOrUndefined<p, number> : + p['type'] extends 'string' ? NullOrUndefined<p, string> : + p['type'] extends 'boolean' ? NullOrUndefined<p, boolean> : + p['type'] extends 'array' ? NullOrUndefined<p, MyType<NonNullable<p['items']>>[]> : + p['type'] extends 'object' ? ( + p['ref'] extends keyof typeof refs + ? NullOrUndefined<p, Packed<p['ref']>> + : NullOrUndefined<p, ObjType<NonNullable<p['properties']>>> + ) : + p['type'] extends 'any' ? NullOrUndefined<p, any> : + any; diff --git a/packages/backend/src/misc/secure-rndstr.ts b/packages/backend/src/misc/secure-rndstr.ts new file mode 100644 index 0000000000..76ee1225eb --- /dev/null +++ b/packages/backend/src/misc/secure-rndstr.ts @@ -0,0 +1,21 @@ +import * as crypto from 'crypto'; + +const L_CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; +const LU_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +export function secureRndstr(length = 32, useLU = true): string { + const chars = useLU ? LU_CHARS : L_CHARS; + const chars_len = chars.length; + + let str = ''; + + for (let i = 0; i < length; i++) { + let rand = Math.floor((crypto.randomBytes(1).readUInt8(0) / 0xFF) * chars_len); + if (rand === chars_len) { + rand = chars_len - 1; + } + str += chars.charAt(rand); + } + + return str; +} diff --git a/packages/backend/src/misc/show-machine-info.ts b/packages/backend/src/misc/show-machine-info.ts new file mode 100644 index 0000000000..58747c1152 --- /dev/null +++ b/packages/backend/src/misc/show-machine-info.ts @@ -0,0 +1,13 @@ +import * as os from 'os'; +import * as sysUtils from 'systeminformation'; +import Logger from '@/services/logger'; + +export async function showMachineInfo(parentLogger: Logger) { + const logger = parentLogger.createSubLogger('machine'); + logger.debug(`Hostname: ${os.hostname()}`); + logger.debug(`Platform: ${process.platform} Arch: ${process.arch}`); + const mem = await sysUtils.mem(); + const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1); + const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1); + logger.debug(`CPU: ${os.cpus().length} core MEM: ${totalmem}GB (available: ${availmem}GB)`); +} diff --git a/packages/backend/src/misc/simple-schema.ts b/packages/backend/src/misc/simple-schema.ts new file mode 100644 index 0000000000..abbb348e24 --- /dev/null +++ b/packages/backend/src/misc/simple-schema.ts @@ -0,0 +1,15 @@ +export interface SimpleSchema { + type: 'boolean' | 'number' | 'string' | 'array' | 'object' | 'any'; + nullable: boolean; + optional: boolean; + items?: SimpleSchema; + properties?: SimpleObj; + description?: string; + example?: any; + format?: string; + ref?: string; + enum?: string[]; + default?: boolean | null; +} + +export interface SimpleObj { [key: string]: SimpleSchema; } diff --git a/packages/backend/src/misc/truncate.ts b/packages/backend/src/misc/truncate.ts new file mode 100644 index 0000000000..cb120331a1 --- /dev/null +++ b/packages/backend/src/misc/truncate.ts @@ -0,0 +1,11 @@ +import { substring } from 'stringz'; + +export function truncate(input: string, size: number): string; +export function truncate(input: string | undefined, size: number): string | undefined; +export function truncate(input: string | undefined, size: number): string | undefined { + if (!input) { + return input; + } else { + return substring(input, 0, size); + } +} diff --git a/packages/backend/src/models/entities/abuse-user-report.ts b/packages/backend/src/models/entities/abuse-user-report.ts new file mode 100644 index 0000000000..c0cff139f6 --- /dev/null +++ b/packages/backend/src/models/entities/abuse-user-report.ts @@ -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 +} diff --git a/packages/backend/src/models/entities/access-token.ts b/packages/backend/src/models/entities/access-token.ts new file mode 100644 index 0000000000..5f41b3c1fc --- /dev/null +++ b/packages/backend/src/models/entities/access-token.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/ad.ts b/packages/backend/src/models/entities/ad.ts new file mode 100644 index 0000000000..b2fc04c4f0 --- /dev/null +++ b/packages/backend/src/models/entities/ad.ts @@ -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; + } + } +} diff --git a/packages/backend/src/models/entities/announcement-read.ts b/packages/backend/src/models/entities/announcement-read.ts new file mode 100644 index 0000000000..892beb826f --- /dev/null +++ b/packages/backend/src/models/entities/announcement-read.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/announcement.ts b/packages/backend/src/models/entities/announcement.ts new file mode 100644 index 0000000000..06d379c229 --- /dev/null +++ b/packages/backend/src/models/entities/announcement.ts @@ -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; + } + } +} diff --git a/packages/backend/src/models/entities/antenna-note.ts b/packages/backend/src/models/entities/antenna-note.ts new file mode 100644 index 0000000000..9b911524ef --- /dev/null +++ b/packages/backend/src/models/entities/antenna-note.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/antenna.ts b/packages/backend/src/models/entities/antenna.ts new file mode 100644 index 0000000000..bcfe09a829 --- /dev/null +++ b/packages/backend/src/models/entities/antenna.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/app.ts b/packages/backend/src/models/entities/app.ts new file mode 100644 index 0000000000..ea87546311 --- /dev/null +++ b/packages/backend/src/models/entities/app.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/attestation-challenge.ts b/packages/backend/src/models/entities/attestation-challenge.ts new file mode 100644 index 0000000000..942747c02f --- /dev/null +++ b/packages/backend/src/models/entities/attestation-challenge.ts @@ -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; + } + } +} diff --git a/packages/backend/src/models/entities/auth-session.ts b/packages/backend/src/models/entities/auth-session.ts new file mode 100644 index 0000000000..4eec27e3f6 --- /dev/null +++ b/packages/backend/src/models/entities/auth-session.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/blocking.ts b/packages/backend/src/models/entities/blocking.ts new file mode 100644 index 0000000000..48487cb086 --- /dev/null +++ b/packages/backend/src/models/entities/blocking.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/channel-following.ts b/packages/backend/src/models/entities/channel-following.ts new file mode 100644 index 0000000000..fca801e5ab --- /dev/null +++ b/packages/backend/src/models/entities/channel-following.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/channel-note-pining.ts b/packages/backend/src/models/entities/channel-note-pining.ts new file mode 100644 index 0000000000..26a7eb501f --- /dev/null +++ b/packages/backend/src/models/entities/channel-note-pining.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/channel.ts b/packages/backend/src/models/entities/channel.ts new file mode 100644 index 0000000000..f2d713612d --- /dev/null +++ b/packages/backend/src/models/entities/channel.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/clip-note.ts b/packages/backend/src/models/entities/clip-note.ts new file mode 100644 index 0000000000..7d96b2ef7a --- /dev/null +++ b/packages/backend/src/models/entities/clip-note.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/clip.ts b/packages/backend/src/models/entities/clip.ts new file mode 100644 index 0000000000..66b5b8847e --- /dev/null +++ b/packages/backend/src/models/entities/clip.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/drive-file.ts b/packages/backend/src/models/entities/drive-file.ts new file mode 100644 index 0000000000..698dfac222 --- /dev/null +++ b/packages/backend/src/models/entities/drive-file.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/drive-folder.ts b/packages/backend/src/models/entities/drive-folder.ts new file mode 100644 index 0000000000..a80d075855 --- /dev/null +++ b/packages/backend/src/models/entities/drive-folder.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/emoji.ts b/packages/backend/src/models/entities/emoji.ts new file mode 100644 index 0000000000..d6080ae099 --- /dev/null +++ b/packages/backend/src/models/entities/emoji.ts @@ -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[]; +} diff --git a/packages/backend/src/models/entities/follow-request.ts b/packages/backend/src/models/entities/follow-request.ts new file mode 100644 index 0000000000..22ec263962 --- /dev/null +++ b/packages/backend/src/models/entities/follow-request.ts @@ -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 +} diff --git a/packages/backend/src/models/entities/following.ts b/packages/backend/src/models/entities/following.ts new file mode 100644 index 0000000000..ee3286a1a1 --- /dev/null +++ b/packages/backend/src/models/entities/following.ts @@ -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 +} diff --git a/packages/backend/src/models/entities/gallery-like.ts b/packages/backend/src/models/entities/gallery-like.ts new file mode 100644 index 0000000000..7d084a2275 --- /dev/null +++ b/packages/backend/src/models/entities/gallery-like.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/gallery-post.ts b/packages/backend/src/models/entities/gallery-post.ts new file mode 100644 index 0000000000..f59cd671f3 --- /dev/null +++ b/packages/backend/src/models/entities/gallery-post.ts @@ -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; + } + } +} diff --git a/packages/backend/src/models/entities/games/reversi/game.ts b/packages/backend/src/models/entities/games/reversi/game.ts new file mode 100644 index 0000000000..9deacaf5c6 --- /dev/null +++ b/packages/backend/src/models/entities/games/reversi/game.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/games/reversi/matching.ts b/packages/backend/src/models/entities/games/reversi/matching.ts new file mode 100644 index 0000000000..477a29316e --- /dev/null +++ b/packages/backend/src/models/entities/games/reversi/matching.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/hashtag.ts b/packages/backend/src/models/entities/hashtag.ts new file mode 100644 index 0000000000..842cdaa562 --- /dev/null +++ b/packages/backend/src/models/entities/hashtag.ts @@ -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; +} diff --git a/packages/backend/src/models/entities/instance.ts b/packages/backend/src/models/entities/instance.ts new file mode 100644 index 0000000000..7c8719e06a --- /dev/null +++ b/packages/backend/src/models/entities/instance.ts @@ -0,0 +1,180 @@ +import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; +import { id } from '../id'; + +@Entity() +export class Instance { + @PrimaryColumn(id()) + public id: string; + + /** + * このインスタンスを捕捉した日時 + */ + @Index() + @Column('timestamp with time zone', { + comment: 'The caught date of the Instance.' + }) + public caughtAt: Date; + + /** + * ホスト + */ + @Index({ unique: true }) + @Column('varchar', { + length: 128, + comment: 'The host of the Instance.' + }) + public host: string; + + /** + * インスタンスのユーザー数 + */ + @Column('integer', { + default: 0, + comment: 'The count of the users of the Instance.' + }) + public usersCount: number; + + /** + * インスタンスの投稿数 + */ + @Column('integer', { + default: 0, + comment: 'The count of the notes of the Instance.' + }) + public notesCount: number; + + /** + * このインスタンスのユーザーからフォローされている、自インスタンスのユーザーの数 + */ + @Column('integer', { + default: 0, + }) + public followingCount: number; + + /** + * このインスタンスのユーザーをフォローしている、自インスタンスのユーザーの数 + */ + @Column('integer', { + default: 0, + }) + public followersCount: number; + + /** + * ドライブ使用量 + */ + @Column('bigint', { + default: 0, + }) + public driveUsage: number; + + /** + * ドライブのファイル数 + */ + @Column('integer', { + default: 0, + }) + public driveFiles: number; + + /** + * 直近のリクエスト送信日時 + */ + @Column('timestamp with time zone', { + nullable: true, + }) + public latestRequestSentAt: Date | null; + + /** + * 直近のリクエスト送信時のHTTPステータスコード + */ + @Column('integer', { + nullable: true, + }) + public latestStatus: number | null; + + /** + * 直近のリクエスト受信日時 + */ + @Column('timestamp with time zone', { + nullable: true, + }) + public latestRequestReceivedAt: Date | null; + + /** + * このインスタンスと最後にやり取りした日時 + */ + @Column('timestamp with time zone') + public lastCommunicatedAt: Date; + + /** + * このインスタンスと不通かどうか + */ + @Column('boolean', { + default: false + }) + public isNotResponding: boolean; + + /** + * このインスタンスへの配信を停止するか + */ + @Index() + @Column('boolean', { + default: false + }) + public isSuspended: boolean; + + @Column('varchar', { + length: 64, nullable: true, default: null, + comment: 'The software of the Instance.' + }) + public softwareName: string | null; + + @Column('varchar', { + length: 64, nullable: true, default: null, + }) + public softwareVersion: string | null; + + @Column('boolean', { + nullable: true, default: null, + }) + public openRegistrations: boolean | null; + + @Column('varchar', { + length: 256, nullable: true, default: null, + }) + public name: string | null; + + @Column('varchar', { + length: 4096, nullable: true, default: null, + }) + public description: string | null; + + @Column('varchar', { + length: 128, nullable: true, default: null, + }) + public maintainerName: string | null; + + @Column('varchar', { + length: 256, nullable: true, default: null, + }) + public maintainerEmail: string | null; + + @Column('varchar', { + length: 256, nullable: true, default: null, + }) + public iconUrl: string | null; + + @Column('varchar', { + length: 256, nullable: true, default: null, + }) + public faviconUrl: string | null; + + @Column('varchar', { + length: 64, nullable: true, default: null, + }) + public themeColor: string | null; + + @Column('timestamp with time zone', { + nullable: true, + }) + public infoUpdatedAt: Date | null; +} diff --git a/packages/backend/src/models/entities/messaging-message.ts b/packages/backend/src/models/entities/messaging-message.ts new file mode 100644 index 0000000000..ac0764674c --- /dev/null +++ b/packages/backend/src/models/entities/messaging-message.ts @@ -0,0 +1,89 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { DriveFile } from './drive-file'; +import { id } from '../id'; +import { UserGroup } from './user-group'; + +@Entity() +export class MessagingMessage { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the MessagingMessage.' + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The sender user ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), nullable: true, + comment: 'The recipient user ID.' + }) + public recipientId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public recipient: User | null; + + @Index() + @Column({ + ...id(), nullable: true, + comment: 'The recipient group ID.' + }) + public groupId: UserGroup['id'] | null; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public group: UserGroup | null; + + @Column('varchar', { + length: 4096, nullable: true + }) + public text: string | null; + + @Column('boolean', { + default: false, + }) + public isRead: boolean; + + @Column('varchar', { + length: 512, nullable: true, + }) + public uri: string | null; + + @Column({ + ...id(), + array: true, default: '{}' + }) + public reads: User['id'][]; + + @Column({ + ...id(), + nullable: true, + }) + public fileId: DriveFile['id'] | null; + + @ManyToOne(type => DriveFile, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public file: DriveFile | null; +} diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts new file mode 100644 index 0000000000..9a1a87c155 --- /dev/null +++ b/packages/backend/src/models/entities/meta.ts @@ -0,0 +1,423 @@ +import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { Clip } from './clip'; + +@Entity() +export class Meta { + @PrimaryColumn({ + type: 'varchar', + length: 32 + }) + public id: string; + + @Column('varchar', { + length: 128, nullable: true + }) + public name: string | null; + + @Column('varchar', { + length: 1024, nullable: true + }) + public description: string | null; + + /** + * メンテナの名前 + */ + @Column('varchar', { + length: 128, nullable: true + }) + public maintainerName: string | null; + + /** + * メンテナの連絡先 + */ + @Column('varchar', { + length: 128, nullable: true + }) + public maintainerEmail: string | null; + + @Column('boolean', { + default: false, + }) + public disableRegistration: boolean; + + @Column('boolean', { + default: false, + }) + public disableLocalTimeline: boolean; + + @Column('boolean', { + default: false, + }) + public disableGlobalTimeline: boolean; + + @Column('boolean', { + default: false, + }) + public useStarForReactionFallback: boolean; + + @Column('varchar', { + length: 64, array: true, default: '{}' + }) + public langs: string[]; + + @Column('varchar', { + length: 256, array: true, default: '{}' + }) + public pinnedUsers: string[]; + + @Column('varchar', { + length: 256, array: true, default: '{}' + }) + public hiddenTags: string[]; + + @Column('varchar', { + length: 256, array: true, default: '{}' + }) + public blockedHosts: string[]; + + @Column('varchar', { + length: 512, array: true, default: '{"/featured", "/channels", "/explore", "/pages", "/about-misskey"}' + }) + public pinnedPages: string[]; + + @Column({ + ...id(), + nullable: true, + }) + public pinnedClipId: Clip['id'] | null; + + @Column('varchar', { + length: 512, + nullable: true, + default: '/assets/ai.png' + }) + public mascotImageUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public bannerUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public backgroundImageUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public logoImageUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true, + default: 'https://xn--931a.moe/aiart/yubitun.png' + }) + public errorImageUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public iconUrl: string | null; + + @Column('boolean', { + default: true, + }) + public cacheRemoteFiles: boolean; + + @Column('boolean', { + default: false, + }) + public proxyRemoteFiles: boolean; + + @Column({ + ...id(), + nullable: true, + }) + public proxyAccountId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'SET NULL' + }) + @JoinColumn() + public proxyAccount: User | null; + + @Column('boolean', { + default: false, + }) + public emailRequiredForSignup: boolean; + + @Column('boolean', { + default: false, + }) + public enableHcaptcha: boolean; + + @Column('varchar', { + length: 64, + nullable: true + }) + public hcaptchaSiteKey: string | null; + + @Column('varchar', { + length: 64, + nullable: true + }) + public hcaptchaSecretKey: string | null; + + @Column('boolean', { + default: false, + }) + public enableRecaptcha: boolean; + + @Column('varchar', { + length: 64, + nullable: true + }) + public recaptchaSiteKey: string | null; + + @Column('varchar', { + length: 64, + nullable: true + }) + public recaptchaSecretKey: string | null; + + @Column('integer', { + default: 1024, + comment: 'Drive capacity of a local user (MB)' + }) + public localDriveCapacityMb: number; + + @Column('integer', { + default: 32, + comment: 'Drive capacity of a remote user (MB)' + }) + public remoteDriveCapacityMb: number; + + @Column('integer', { + default: 500, + comment: 'Max allowed note text length in characters' + }) + public maxNoteTextLength: number; + + @Column('varchar', { + length: 128, + nullable: true + }) + public summalyProxy: string | null; + + @Column('boolean', { + default: false, + }) + public enableEmail: boolean; + + @Column('varchar', { + length: 128, + nullable: true + }) + public email: string | null; + + @Column('boolean', { + default: false, + }) + public smtpSecure: boolean; + + @Column('varchar', { + length: 128, + nullable: true + }) + public smtpHost: string | null; + + @Column('integer', { + nullable: true + }) + public smtpPort: number | null; + + @Column('varchar', { + length: 128, + nullable: true + }) + public smtpUser: string | null; + + @Column('varchar', { + length: 128, + nullable: true + }) + public smtpPass: string | null; + + @Column('boolean', { + default: false, + }) + public enableServiceWorker: boolean; + + @Column('varchar', { + length: 128, + nullable: true + }) + public swPublicKey: string | null; + + @Column('varchar', { + length: 128, + nullable: true + }) + public swPrivateKey: string | null; + + @Column('boolean', { + default: false, + }) + public enableTwitterIntegration: boolean; + + @Column('varchar', { + length: 128, + nullable: true + }) + public twitterConsumerKey: string | null; + + @Column('varchar', { + length: 128, + nullable: true + }) + public twitterConsumerSecret: string | null; + + @Column('boolean', { + default: false, + }) + public enableGithubIntegration: boolean; + + @Column('varchar', { + length: 128, + nullable: true + }) + public githubClientId: string | null; + + @Column('varchar', { + length: 128, + nullable: true + }) + public githubClientSecret: string | null; + + @Column('boolean', { + default: false, + }) + public enableDiscordIntegration: boolean; + + @Column('varchar', { + length: 128, + nullable: true + }) + public discordClientId: string | null; + + @Column('varchar', { + length: 128, + nullable: true + }) + public discordClientSecret: string | null; + + @Column('varchar', { + length: 128, + nullable: true + }) + public deeplAuthKey: string | null; + + @Column('boolean', { + default: false, + }) + public deeplIsPro: boolean; + + @Column('varchar', { + length: 512, + nullable: true + }) + public ToSUrl: string | null; + + @Column('varchar', { + length: 512, + default: 'https://github.com/misskey-dev/misskey', + nullable: false + }) + public repositoryUrl: string; + + @Column('varchar', { + length: 512, + default: 'https://github.com/misskey-dev/misskey/issues/new', + nullable: true + }) + public feedbackUrl: string | null; + + @Column('boolean', { + default: false, + }) + public useObjectStorage: boolean; + + @Column('varchar', { + length: 512, + nullable: true + }) + public objectStorageBucket: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public objectStoragePrefix: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public objectStorageBaseUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public objectStorageEndpoint: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public objectStorageRegion: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public objectStorageAccessKey: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public objectStorageSecretKey: string | null; + + @Column('integer', { + nullable: true + }) + public objectStoragePort: number | null; + + @Column('boolean', { + default: true, + }) + public objectStorageUseSSL: boolean; + + @Column('boolean', { + default: true, + }) + public objectStorageUseProxy: boolean; + + @Column('boolean', { + default: false, + }) + public objectStorageSetPublicRead: boolean; + + @Column('boolean', { + default: true, + }) + public objectStorageS3ForcePathStyle: boolean; +} diff --git a/packages/backend/src/models/entities/moderation-log.ts b/packages/backend/src/models/entities/moderation-log.ts new file mode 100644 index 0000000000..33d3d683ae --- /dev/null +++ b/packages/backend/src/models/entities/moderation-log.ts @@ -0,0 +1,32 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class ModerationLog { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the ModerationLog.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + }) + public type: string; + + @Column('jsonb') + public info: Record<string, any>; +} diff --git a/packages/backend/src/models/entities/muted-note.ts b/packages/backend/src/models/entities/muted-note.ts new file mode 100644 index 0000000000..521876688c --- /dev/null +++ b/packages/backend/src/models/entities/muted-note.ts @@ -0,0 +1,48 @@ +import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Note } from './note'; +import { User } from './user'; +import { id } from '../id'; +import { mutedNoteReasons } from '../../types'; + +@Entity() +@Index(['noteId', 'userId'], { unique: true }) +export class MutedNote { + @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 user ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + /** + * ミュートされた理由。 + */ + @Index() + @Column('enum', { + enum: mutedNoteReasons, + comment: 'The reason of the MutedNote.' + }) + public reason: typeof mutedNoteReasons[number]; +} diff --git a/packages/backend/src/models/entities/muting.ts b/packages/backend/src/models/entities/muting.ts new file mode 100644 index 0000000000..0084213bcc --- /dev/null +++ b/packages/backend/src/models/entities/muting.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +@Index(['muterId', 'muteeId'], { unique: true }) +export class Muting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Muting.' + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The mutee user ID.' + }) + public muteeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The muter user ID.' + }) + public muterId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/entities/note-favorite.ts b/packages/backend/src/models/entities/note-favorite.ts new file mode 100644 index 0000000000..0713c3ae56 --- /dev/null +++ b/packages/backend/src/models/entities/note-favorite.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { Note } from './note'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class NoteFavorite { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the NoteFavorite.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/note-reaction.ts b/packages/backend/src/models/entities/note-reaction.ts new file mode 100644 index 0000000000..674dc3639e --- /dev/null +++ b/packages/backend/src/models/entities/note-reaction.ts @@ -0,0 +1,44 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { Note } from './note'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class NoteReaction { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the NoteReaction.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user?: User | null; + + @Index() + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note?: Note | null; + + // TODO: 対象noteのuserIdを非正規化したい(「受け取ったリアクション一覧」のようなものを(JOIN無しで)実装したいため) + + @Column('varchar', { + length: 260 + }) + public reaction: string; +} diff --git a/packages/backend/src/models/entities/note-thread-muting.ts b/packages/backend/src/models/entities/note-thread-muting.ts new file mode 100644 index 0000000000..b438522a4c --- /dev/null +++ b/packages/backend/src/models/entities/note-thread-muting.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { Note } from './note'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'threadId'], { unique: true }) +export class NoteThreadMuting { + @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; + + @Index() + @Column('varchar', { + length: 256, + }) + public threadId: string; +} diff --git a/packages/backend/src/models/entities/note-unread.ts b/packages/backend/src/models/entities/note-unread.ts new file mode 100644 index 0000000000..57dda4fafd --- /dev/null +++ b/packages/backend/src/models/entities/note-unread.ts @@ -0,0 +1,63 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { Note } from './note'; +import { id } from '../id'; +import { Channel } from './channel'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class NoteUnread { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; + + /** + * メンションか否か + */ + @Index() + @Column('boolean') + public isMentioned: boolean; + + /** + * ダイレクト投稿か否か + */ + @Index() + @Column('boolean') + public isSpecified: boolean; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: '[Denormalized]' + }) + public noteUserId: User['id']; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]' + }) + public noteChannelId: Channel['id'] | null; + //#endregion +} diff --git a/packages/backend/src/models/entities/note-watching.ts b/packages/backend/src/models/entities/note-watching.ts new file mode 100644 index 0000000000..741a1c0c8b --- /dev/null +++ b/packages/backend/src/models/entities/note-watching.ts @@ -0,0 +1,52 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { Note } from './note'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class NoteWatching { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the NoteWatching.' + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The watcher ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The target Note ID.' + }) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: '[Denormalized]' + }) + public noteUserId: Note['userId']; + //#endregion +} diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts new file mode 100644 index 0000000000..4a5411f93d --- /dev/null +++ b/packages/backend/src/models/entities/note.ts @@ -0,0 +1,247 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { DriveFile } from './drive-file'; +import { id } from '../id'; +import { noteVisibilities } from '../../types'; +import { Channel } from './channel'; + +@Entity() +@Index('IDX_NOTE_TAGS', { synchronize: false }) +@Index('IDX_NOTE_MENTIONS', { synchronize: false }) +@Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false }) +export class Note { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Note.' + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of reply target.' + }) + public replyId: Note['id'] | null; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public reply: Note | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of renote target.' + }) + public renoteId: Note['id'] | null; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public renote: Note | null; + + @Index() + @Column('varchar', { + length: 256, nullable: true + }) + public threadId: string | null; + + @Column('varchar', { + length: 8192, nullable: true + }) + public text: string | null; + + @Column('varchar', { + length: 256, nullable: true + }) + public name: string | null; + + @Column('varchar', { + length: 512, nullable: true + }) + public cw: string | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('boolean', { + default: false + }) + public viaMobile: boolean; + + @Column('boolean', { + default: false + }) + public localOnly: boolean; + + @Column('smallint', { + default: 0 + }) + public renoteCount: number; + + @Column('smallint', { + default: 0 + }) + public repliesCount: number; + + @Column('jsonb', { + default: {} + }) + public reactions: Record<string, number>; + + /** + * public ... 公開 + * home ... ホームタイムライン(ユーザーページのタイムライン含む)のみに流す + * followers ... フォロワーのみ + * specified ... visibleUserIds で指定したユーザーのみ + */ + @Column('enum', { enum: noteVisibilities }) + public visibility: typeof noteVisibilities[number]; + + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URI of a note. it will be null when the note is local.' + }) + public uri: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The human readable url of a note. it will be null when the note is local.' + }) + public url: string | null; + + @Column('integer', { + default: 0, select: false + }) + public score: number; + + @Index() + @Column({ + ...id(), + array: true, default: '{}' + }) + public fileIds: DriveFile['id'][]; + + @Index() + @Column('varchar', { + length: 256, array: true, default: '{}' + }) + public attachedFileTypes: string[]; + + @Index() + @Column({ + ...id(), + array: true, default: '{}' + }) + public visibleUserIds: User['id'][]; + + @Index() + @Column({ + ...id(), + array: true, default: '{}' + }) + public mentions: User['id'][]; + + @Column('text', { + default: '[]' + }) + public mentionedRemoteUsers: string; + + @Column('varchar', { + length: 128, array: true, default: '{}' + }) + public emojis: string[]; + + @Index() + @Column('varchar', { + length: 128, array: true, default: '{}' + }) + public tags: string[]; + + @Column('boolean', { + default: false + }) + public hasPoll: boolean; + + @Index() + @Column({ + ...id(), + nullable: true, default: null, + comment: 'The ID of source channel.' + }) + public channelId: Channel['id'] | null; + + @ManyToOne(type => Channel, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public channel: Channel | null; + + //#region Denormalized fields + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]' + }) + public userHost: string | null; + + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]' + }) + public replyUserId: User['id'] | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]' + }) + public replyUserHost: string | null; + + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]' + }) + public renoteUserId: User['id'] | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]' + }) + public renoteUserHost: string | null; + //#endregion + + constructor(data: Partial<Note>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export type IMentionedRemoteUsers = { + uri: string; + url?: string; + username: string; + host: string; +}[]; diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts new file mode 100644 index 0000000000..47184caacc --- /dev/null +++ b/packages/backend/src/models/entities/notification.ts @@ -0,0 +1,172 @@ +import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { Note } from './note'; +import { FollowRequest } from './follow-request'; +import { UserGroupInvitation } from './user-group-invitation'; +import { AccessToken } from './access-token'; +import { notificationTypes } from '@/types'; + +@Entity() +export class Notification { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Notification.' + }) + public createdAt: Date; + + /** + * 通知の受信者 + */ + @Index() + @Column({ + ...id(), + comment: 'The ID of recipient user of the Notification.' + }) + public notifieeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public notifiee: User | null; + + /** + * 通知の送信者(initiator) + */ + @Index() + @Column({ + ...id(), + nullable: true, + comment: 'The ID of sender user of the Notification.' + }) + public notifierId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public notifier: User | null; + + /** + * 通知の種類。 + * follow - フォローされた + * mention - 投稿で自分が言及された + * reply - (自分または自分がWatchしている)投稿が返信された + * renote - (自分または自分がWatchしている)投稿がRenoteされた + * quote - (自分または自分がWatchしている)投稿が引用Renoteされた + * reaction - (自分または自分がWatchしている)投稿にリアクションされた + * pollVote - (自分または自分がWatchしている)投稿の投票に投票された + * receiveFollowRequest - フォローリクエストされた + * followRequestAccepted - 自分の送ったフォローリクエストが承認された + * groupInvited - グループに招待された + * app - アプリ通知 + */ + @Index() + @Column('enum', { + enum: notificationTypes, + comment: 'The type of the Notification.' + }) + public type: typeof notificationTypes[number]; + + /** + * 通知が読まれたかどうか + */ + @Index() + @Column('boolean', { + default: false, + comment: 'Whether the Notification is read.' + }) + public isRead: boolean; + + @Column({ + ...id(), + nullable: true + }) + public noteId: Note['id'] | null; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; + + @Column({ + ...id(), + nullable: true + }) + public followRequestId: FollowRequest['id'] | null; + + @ManyToOne(type => FollowRequest, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public followRequest: FollowRequest | null; + + @Column({ + ...id(), + nullable: true + }) + public userGroupInvitationId: UserGroupInvitation['id'] | null; + + @ManyToOne(type => UserGroupInvitation, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public userGroupInvitation: UserGroupInvitation | null; + + @Column('varchar', { + length: 128, nullable: true + }) + public reaction: string | null; + + @Column('integer', { + nullable: true + }) + public choice: number | null; + + /** + * アプリ通知のbody + */ + @Column('varchar', { + length: 2048, nullable: true + }) + public customBody: string | null; + + /** + * アプリ通知のheader + * (省略時はアプリ名で表示されることを期待) + */ + @Column('varchar', { + length: 256, nullable: true + }) + public customHeader: string | null; + + /** + * アプリ通知のicon(URL) + * (省略時はアプリアイコンで表示されることを期待) + */ + @Column('varchar', { + length: 1024, nullable: true + }) + public customIcon: string | null; + + /** + * アプリ通知のアプリ(のトークン) + */ + @Index() + @Column({ + ...id(), + nullable: true + }) + public appAccessTokenId: AccessToken['id'] | null; + + @ManyToOne(type => AccessToken, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public appAccessToken: AccessToken | null; +} diff --git a/packages/backend/src/models/entities/page-like.ts b/packages/backend/src/models/entities/page-like.ts new file mode 100644 index 0000000000..ca84ece8fd --- /dev/null +++ b/packages/backend/src/models/entities/page-like.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { Page } from './page'; + +@Entity() +@Index(['userId', 'pageId'], { unique: true }) +export class PageLike { + @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 pageId: Page['id']; + + @ManyToOne(type => Page, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public page: Page | null; +} diff --git a/packages/backend/src/models/entities/page.ts b/packages/backend/src/models/entities/page.ts new file mode 100644 index 0000000000..ed0411a3d0 --- /dev/null +++ b/packages/backend/src/models/entities/page.ts @@ -0,0 +1,121 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; +import { DriveFile } from './drive-file'; + +@Entity() +@Index(['userId', 'name'], { unique: true }) +export class Page { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Page.' + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The updated date of the Page.' + }) + public updatedAt: Date; + + @Column('varchar', { + length: 256, + }) + public title: string; + + @Index() + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('varchar', { + length: 256, nullable: true + }) + public summary: string | null; + + @Column('boolean') + public alignCenter: boolean; + + @Column('boolean', { + default: false + }) + public hideTitleWhenPinned: boolean; + + @Column('varchar', { + length: 32, + }) + public font: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public eyeCatchingImageId: DriveFile['id'] | null; + + @ManyToOne(type => DriveFile, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public eyeCatchingImage: DriveFile | null; + + @Column('jsonb', { + default: [] + }) + public content: Record<string, any>[]; + + @Column('jsonb', { + default: [] + }) + public variables: Record<string, any>[]; + + @Column('varchar', { + length: 16384, + default: '' + }) + public script: string; + + /** + * public ... 公開 + * followers ... フォロワーのみ + * specified ... visibleUserIds で指定したユーザーのみ + */ + @Column('enum', { enum: ['public', 'followers', 'specified'] }) + public visibility: 'public' | 'followers' | 'specified'; + + @Index() + @Column({ + ...id(), + array: true, default: '{}' + }) + public visibleUserIds: User['id'][]; + + @Column('integer', { + default: 0 + }) + public likedCount: number; + + constructor(data: Partial<Page>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/password-reset-request.ts b/packages/backend/src/models/entities/password-reset-request.ts new file mode 100644 index 0000000000..6d41d38a93 --- /dev/null +++ b/packages/backend/src/models/entities/password-reset-request.ts @@ -0,0 +1,30 @@ +import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { id } from '../id'; +import { User } from './user'; + +@Entity() +export class PasswordResetRequest { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index({ unique: true }) + @Column('varchar', { + length: 256, + }) + public token: string; + + @Index() + @Column({ + ...id(), + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; +} diff --git a/packages/backend/src/models/entities/poll-vote.ts b/packages/backend/src/models/entities/poll-vote.ts new file mode 100644 index 0000000000..709376f909 --- /dev/null +++ b/packages/backend/src/models/entities/poll-vote.ts @@ -0,0 +1,40 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { Note } from './note'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'noteId', 'choice'], { unique: true }) +export class PollVote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the PollVote.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; + + @Column('integer') + public choice: number; +} diff --git a/packages/backend/src/models/entities/poll.ts b/packages/backend/src/models/entities/poll.ts new file mode 100644 index 0000000000..e3bbb1c3f2 --- /dev/null +++ b/packages/backend/src/models/entities/poll.ts @@ -0,0 +1,72 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; +import { id } from '../id'; +import { Note } from './note'; +import { User } from './user'; +import { noteVisibilities } from '../../types'; + +@Entity() +export class Poll { + @PrimaryColumn(id()) + public noteId: Note['id']; + + @OneToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; + + @Column('timestamp with time zone', { + nullable: true + }) + public expiresAt: Date | null; + + @Column('boolean') + public multiple: boolean; + + @Column('varchar', { + length: 128, array: true, default: '{}' + }) + public choices: string[]; + + @Column('integer', { + array: true, + }) + public votes: number[]; + + //#region Denormalized fields + @Column('enum', { + enum: noteVisibilities, + comment: '[Denormalized]' + }) + public noteVisibility: typeof noteVisibilities[number]; + + @Index() + @Column({ + ...id(), + comment: '[Denormalized]' + }) + public userId: User['id']; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]' + }) + public userHost: string | null; + //#endregion + + constructor(data: Partial<Poll>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export type IPoll = { + choices: string[]; + votes?: number[]; + multiple: boolean; + expiresAt: Date | null; +}; diff --git a/packages/backend/src/models/entities/promo-note.ts b/packages/backend/src/models/entities/promo-note.ts new file mode 100644 index 0000000000..474f1cb235 --- /dev/null +++ b/packages/backend/src/models/entities/promo-note.ts @@ -0,0 +1,28 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; +import { Note } from './note'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class PromoNote { + @PrimaryColumn(id()) + public noteId: Note['id']; + + @OneToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; + + @Column('timestamp with time zone') + public expiresAt: Date; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: '[Denormalized]' + }) + public userId: User['id']; + //#endregion +} diff --git a/packages/backend/src/models/entities/promo-read.ts b/packages/backend/src/models/entities/promo-read.ts new file mode 100644 index 0000000000..2e0977b6b5 --- /dev/null +++ b/packages/backend/src/models/entities/promo-read.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { Note } from './note'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class PromoRead { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the PromoRead.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/registration-tickets.ts b/packages/backend/src/models/entities/registration-tickets.ts new file mode 100644 index 0000000000..d962f78a78 --- /dev/null +++ b/packages/backend/src/models/entities/registration-tickets.ts @@ -0,0 +1,17 @@ +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from '../id'; + +@Entity() +export class RegistrationTicket { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index({ unique: true }) + @Column('varchar', { + length: 64, + }) + public code: string; +} diff --git a/packages/backend/src/models/entities/registry-item.ts b/packages/backend/src/models/entities/registry-item.ts new file mode 100644 index 0000000000..54d2ef2082 --- /dev/null +++ b/packages/backend/src/models/entities/registry-item.ts @@ -0,0 +1,58 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい +@Entity() +export class RegistryItem { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the RegistryItem.' + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + comment: 'The updated date of the RegistryItem.' + }) + public updatedAt: 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: 1024, + comment: 'The key of the RegistryItem.' + }) + public key: string; + + @Column('jsonb', { + default: {}, nullable: true, + comment: 'The value of the RegistryItem.' + }) + public value: any | null; + + @Index() + @Column('varchar', { + length: 1024, array: true, default: '{}' + }) + public scope: string[]; + + // サードパーティアプリに開放するときのためのカラム + @Index() + @Column('varchar', { + length: 512, nullable: true + }) + public domain: string | null; +} diff --git a/packages/backend/src/models/entities/relay.ts b/packages/backend/src/models/entities/relay.ts new file mode 100644 index 0000000000..4c82ccb125 --- /dev/null +++ b/packages/backend/src/models/entities/relay.ts @@ -0,0 +1,19 @@ +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from '../id'; + +@Entity() +export class Relay { + @PrimaryColumn(id()) + public id: string; + + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: false, + }) + public inbox: string; + + @Column('enum', { + enum: ['requesting', 'accepted', 'rejected'], + }) + public status: 'requesting' | 'accepted' | 'rejected'; +} diff --git a/packages/backend/src/models/entities/signin.ts b/packages/backend/src/models/entities/signin.ts new file mode 100644 index 0000000000..7e047084b1 --- /dev/null +++ b/packages/backend/src/models/entities/signin.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class Signin { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the Signin.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + }) + public ip: string; + + @Column('jsonb') + public headers: Record<string, any>; + + @Column('boolean') + public success: boolean; +} diff --git a/packages/backend/src/models/entities/sw-subscription.ts b/packages/backend/src/models/entities/sw-subscription.ts new file mode 100644 index 0000000000..7c3f6f0a6c --- /dev/null +++ b/packages/backend/src/models/entities/sw-subscription.ts @@ -0,0 +1,37 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class SwSubscription { + @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('varchar', { + length: 512, + }) + public endpoint: string; + + @Column('varchar', { + length: 256, + }) + public auth: string; + + @Column('varchar', { + length: 128, + }) + public publickey: string; +} diff --git a/packages/backend/src/models/entities/used-username.ts b/packages/backend/src/models/entities/used-username.ts new file mode 100644 index 0000000000..eb90bef6ca --- /dev/null +++ b/packages/backend/src/models/entities/used-username.ts @@ -0,0 +1,20 @@ +import { PrimaryColumn, Entity, Column } from 'typeorm'; + +@Entity() +export class UsedUsername { + @PrimaryColumn('varchar', { + length: 128, + }) + public username: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + constructor(data: Partial<UsedUsername>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-group-invitation.ts b/packages/backend/src/models/entities/user-group-invitation.ts new file mode 100644 index 0000000000..6fe8f20134 --- /dev/null +++ b/packages/backend/src/models/entities/user-group-invitation.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { UserGroup } from './user-group'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'userGroupId'], { unique: true }) +export class UserGroupInvitation { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroupInvitation.' + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The group ID.' + }) + public userGroupId: UserGroup['id']; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public userGroup: UserGroup | null; +} diff --git a/packages/backend/src/models/entities/user-group-joining.ts b/packages/backend/src/models/entities/user-group-joining.ts new file mode 100644 index 0000000000..e09c3230f1 --- /dev/null +++ b/packages/backend/src/models/entities/user-group-joining.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { UserGroup } from './user-group'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'userGroupId'], { unique: true }) +export class UserGroupJoining { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroupJoining.' + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The group ID.' + }) + public userGroupId: UserGroup['id']; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public userGroup: UserGroup | null; +} diff --git a/packages/backend/src/models/entities/user-group.ts b/packages/backend/src/models/entities/user-group.ts new file mode 100644 index 0000000000..f4bac03223 --- /dev/null +++ b/packages/backend/src/models/entities/user-group.ts @@ -0,0 +1,46 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class UserGroup { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the UserGroup.' + }) + public createdAt: Date; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of owner.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('boolean', { + default: false, + }) + public isPrivate: boolean; + + constructor(data: Partial<UserGroup>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-keypair.ts b/packages/backend/src/models/entities/user-keypair.ts new file mode 100644 index 0000000000..603321d758 --- /dev/null +++ b/packages/backend/src/models/entities/user-keypair.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, JoinColumn, Column, OneToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class UserKeypair { + @PrimaryColumn(id()) + public userId: User['id']; + + @OneToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 4096, + }) + public publicKey: string; + + @Column('varchar', { + length: 4096, + }) + public privateKey: string; + + constructor(data: Partial<UserKeypair>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-list-joining.ts b/packages/backend/src/models/entities/user-list-joining.ts new file mode 100644 index 0000000000..bb7dc40b95 --- /dev/null +++ b/packages/backend/src/models/entities/user-list-joining.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { UserList } from './user-list'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'userListId'], { unique: true }) +export class UserListJoining { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserListJoining.' + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The list ID.' + }) + public userListId: UserList['id']; + + @ManyToOne(type => UserList, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public userList: UserList | null; +} diff --git a/packages/backend/src/models/entities/user-list.ts b/packages/backend/src/models/entities/user-list.ts new file mode 100644 index 0000000000..35a83ef8c3 --- /dev/null +++ b/packages/backend/src/models/entities/user-list.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class UserList { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserList.' + }) + 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 UserList.' + }) + public name: string; +} diff --git a/packages/backend/src/models/entities/user-note-pining.ts b/packages/backend/src/models/entities/user-note-pining.ts new file mode 100644 index 0000000000..04a6f8f645 --- /dev/null +++ b/packages/backend/src/models/entities/user-note-pining.ts @@ -0,0 +1,35 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { Note } from './note'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +@Index(['userId', 'noteId'], { unique: true }) +export class UserNotePining { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the UserNotePinings.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/user-pending.ts b/packages/backend/src/models/entities/user-pending.ts new file mode 100644 index 0000000000..40482af333 --- /dev/null +++ b/packages/backend/src/models/entities/user-pending.ts @@ -0,0 +1,32 @@ +import { PrimaryColumn, Entity, Index, Column } from 'typeorm'; +import { id } from '../id'; + +@Entity() +export class UserPending { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index({ unique: true }) + @Column('varchar', { + length: 128, + }) + public code: string; + + @Column('varchar', { + length: 128, + }) + public username: string; + + @Column('varchar', { + length: 128, + }) + public email: string; + + @Column('varchar', { + length: 128, + }) + public password: string; +} diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts new file mode 100644 index 0000000000..8a8cacfd52 --- /dev/null +++ b/packages/backend/src/models/entities/user-profile.ts @@ -0,0 +1,215 @@ +import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { id } from '../id'; +import { User } from './user'; +import { Page } from './page'; +import { ffVisibility, notificationTypes } from '@/types'; + +// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも +// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン +@Entity() +export class UserProfile { + @PrimaryColumn(id()) + public userId: User['id']; + + @OneToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'The location of the User.' + }) + public location: string | null; + + @Column('char', { + length: 10, nullable: true, + comment: 'The birthday (YYYY-MM-DD) of the User.' + }) + public birthday: string | null; + + @Column('varchar', { + length: 2048, nullable: true, + comment: 'The description (bio) of the User.' + }) + public description: string | null; + + @Column('jsonb', { + default: [], + }) + public fields: { + name: string; + value: string; + }[]; + + @Column('varchar', { + length: 32, nullable: true, + }) + public lang: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'Remote URL of the user.' + }) + public url: string | null; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'The email address of the User.' + }) + public email: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public emailVerifyCode: string | null; + + @Column('boolean', { + default: false, + }) + public emailVerified: boolean; + + @Column('jsonb', { + default: ['follow', 'receiveFollowRequest', 'groupInvited'] + }) + public emailNotificationTypes: string[]; + + @Column('boolean', { + default: false, + }) + public publicReactions: boolean; + + @Column('enum', { + enum: ffVisibility, + default: 'public', + }) + public ffVisibility: typeof ffVisibility[number]; + + @Column('varchar', { + length: 128, nullable: true, + }) + public twoFactorTempSecret: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public twoFactorSecret: string | null; + + @Column('boolean', { + default: false, + }) + public twoFactorEnabled: boolean; + + @Column('boolean', { + default: false, + }) + public securityKeysAvailable: boolean; + + @Column('boolean', { + default: false, + }) + public usePasswordLessLogin: boolean; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'The password hash of the User. It will be null if the origin of the user is local.' + }) + public password: string | null; + + // TODO: そのうち消す + @Column('jsonb', { + default: {}, + comment: 'The client-specific data of the User.' + }) + public clientData: Record<string, any>; + + @Column('jsonb', { + default: {}, + comment: 'The room data of the User.' + }) + public room: Record<string, any>; + + @Column('boolean', { + default: false, + }) + public autoAcceptFollowed: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether reject index by crawler.' + }) + public noCrawle: boolean; + + @Column('boolean', { + default: false, + }) + public alwaysMarkNsfw: boolean; + + @Column('boolean', { + default: false, + }) + public carefulBot: boolean; + + @Column('boolean', { + default: true, + }) + public injectFeaturedNote: boolean; + + @Column('boolean', { + default: true, + }) + public receiveAnnouncementEmail: boolean; + + @Column({ + ...id(), + nullable: true + }) + public pinnedPageId: Page['id'] | null; + + @OneToOne(type => Page, { + onDelete: 'SET NULL' + }) + @JoinColumn() + public pinnedPage: Page | null; + + @Column('jsonb', { + default: {} + }) + public integrations: Record<string, any>; + + @Index() + @Column('boolean', { + default: false, select: false, + }) + public enableWordMute: boolean; + + @Column('jsonb', { + default: [] + }) + public mutedWords: string[][]; + + @Column('enum', { + enum: notificationTypes, + array: true, + default: [], + }) + public mutingNotificationTypes: typeof notificationTypes[number][]; + + //#region Denormalized fields + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: '[Denormalized]' + }) + public userHost: string | null; + //#endregion + + constructor(data: Partial<UserProfile>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-publickey.ts b/packages/backend/src/models/entities/user-publickey.ts new file mode 100644 index 0000000000..21edc3e9e2 --- /dev/null +++ b/packages/backend/src/models/entities/user-publickey.ts @@ -0,0 +1,34 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class UserPublickey { + @PrimaryColumn(id()) + public userId: User['id']; + + @OneToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index({ unique: true }) + @Column('varchar', { + length: 256, + }) + public keyId: string; + + @Column('varchar', { + length: 4096, + }) + public keyPem: string; + + constructor(data: Partial<UserPublickey>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-security-key.ts b/packages/backend/src/models/entities/user-security-key.ts new file mode 100644 index 0000000000..d54c728e53 --- /dev/null +++ b/packages/backend/src/models/entities/user-security-key.ts @@ -0,0 +1,48 @@ +import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class UserSecurityKey { + @PrimaryColumn('varchar', { + comment: 'Variable-length id given to navigator.credentials.get()' + }) + public id: string; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column('varchar', { + comment: + 'Variable-length public key used to verify attestations (hex-encoded).' + }) + public publicKey: string; + + @Column('timestamp with time zone', { + comment: + 'The date of the last time the UserSecurityKey was successfully validated.' + }) + public lastUsed: Date; + + @Column('varchar', { + comment: 'User-defined name for this key', + length: 30 + }) + public name: string; + + constructor(data: Partial<UserSecurityKey>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts new file mode 100644 index 0000000000..65aebd2d1a --- /dev/null +++ b/packages/backend/src/models/entities/user.ts @@ -0,0 +1,250 @@ +import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { DriveFile } from './drive-file'; +import { id } from '../id'; + +@Entity() +@Index(['usernameLower', 'host'], { unique: true }) +export class User { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the User.' + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + nullable: true, + comment: 'The updated date of the User.' + }) + public updatedAt: Date | null; + + @Column('timestamp with time zone', { + nullable: true + }) + public lastFetchedAt: Date | null; + + @Index() + @Column('timestamp with time zone', { + nullable: true + }) + public lastActiveDate: Date | null; + + @Column('boolean', { + default: false, + }) + public hideOnlineStatus: boolean; + + @Column('varchar', { + length: 128, + comment: 'The username of the User.' + }) + public username: string; + + @Index() + @Column('varchar', { + length: 128, select: false, + comment: 'The username (lowercased) of the User.' + }) + public usernameLower: string; + + @Column('varchar', { + length: 128, nullable: true, + comment: 'The name of the User.' + }) + public name: string | null; + + @Column('integer', { + default: 0, + comment: 'The count of followers.' + }) + public followersCount: number; + + @Column('integer', { + default: 0, + comment: 'The count of following.' + }) + public followingCount: number; + + @Column('integer', { + default: 0, + comment: 'The count of notes.' + }) + public notesCount: number; + + @Column({ + ...id(), + nullable: true, + comment: 'The ID of avatar DriveFile.' + }) + public avatarId: DriveFile['id'] | null; + + @OneToOne(type => DriveFile, { + onDelete: 'SET NULL' + }) + @JoinColumn() + public avatar: DriveFile | null; + + @Column({ + ...id(), + nullable: true, + comment: 'The ID of banner DriveFile.' + }) + public bannerId: DriveFile['id'] | null; + + @OneToOne(type => DriveFile, { + onDelete: 'SET NULL' + }) + @JoinColumn() + public banner: DriveFile | null; + + @Index() + @Column('varchar', { + length: 128, array: true, default: '{}' + }) + public tags: string[]; + + @Column('varchar', { + length: 512, nullable: true, + }) + public avatarUrl: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public bannerUrl: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public avatarBlurhash: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public bannerBlurhash: string | null; + + @Column('boolean', { + default: false, + comment: 'Whether the User is suspended.' + }) + public isSuspended: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is silenced.' + }) + public isSilenced: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is locked.' + }) + public isLocked: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is a bot.' + }) + public isBot: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is a cat.' + }) + public isCat: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is the admin.' + }) + public isAdmin: boolean; + + @Column('boolean', { + default: false, + comment: 'Whether the User is a moderator.' + }) + public isModerator: boolean; + + @Index() + @Column('boolean', { + default: true, + comment: 'Whether the User is explorable.' + }) + public isExplorable: boolean; + + // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ + @Column('boolean', { + default: false, + comment: 'Whether the User is deleted.' + }) + public isDeleted: boolean; + + @Column('varchar', { + length: 128, array: true, default: '{}' + }) + public emojis: string[]; + + @Index() + @Column('varchar', { + length: 128, nullable: true, + comment: 'The host of the User. It will be null if the origin of the user is local.' + }) + public host: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The inbox URL of the User. It will be null if the origin of the user is local.' + }) + public inbox: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The sharedInbox URL of the User. It will be null if the origin of the user is local.' + }) + public sharedInbox: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The featured URL of the User. It will be null if the origin of the user is local.' + }) + public featured: string | null; + + @Index() + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URI of the User. It will be null if the origin of the user is local.' + }) + public uri: string | null; + + @Column('varchar', { + length: 512, nullable: true, + comment: 'The URI of the user Follower Collection. It will be null if the origin of the user is local.' + }) + public followersUri: string | null; + + @Index({ unique: true }) + @Column('char', { + length: 16, nullable: true, unique: true, + comment: 'The native access token of the User. It will be null if the origin of the user is local.' + }) + public token: string | null; + + constructor(data: Partial<User>) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export interface ILocalUser extends User { + host: null; +} + +export interface IRemoteUser extends User { + host: string; +} diff --git a/packages/backend/src/models/id.ts b/packages/backend/src/models/id.ts new file mode 100644 index 0000000000..cdb8259073 --- /dev/null +++ b/packages/backend/src/models/id.ts @@ -0,0 +1,4 @@ +export const id = () => ({ + type: 'varchar' as const, + length: 32 +}); diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts new file mode 100644 index 0000000000..7154cca550 --- /dev/null +++ b/packages/backend/src/models/index.ts @@ -0,0 +1,130 @@ +import { getRepository, getCustomRepository } from 'typeorm'; +import { Announcement } from './entities/announcement'; +import { AnnouncementRead } from './entities/announcement-read'; +import { Instance } from './entities/instance'; +import { Poll } from './entities/poll'; +import { PollVote } from './entities/poll-vote'; +import { Meta } from './entities/meta'; +import { SwSubscription } from './entities/sw-subscription'; +import { NoteWatching } from './entities/note-watching'; +import { NoteThreadMuting } from './entities/note-thread-muting'; +import { NoteUnread } from './entities/note-unread'; +import { RegistrationTicket } from './entities/registration-tickets'; +import { UserRepository } from './repositories/user'; +import { NoteRepository } from './repositories/note'; +import { DriveFileRepository } from './repositories/drive-file'; +import { DriveFolderRepository } from './repositories/drive-folder'; +import { AccessToken } from './entities/access-token'; +import { UserNotePining } from './entities/user-note-pining'; +import { SigninRepository } from './repositories/signin'; +import { MessagingMessageRepository } from './repositories/messaging-message'; +import { ReversiGameRepository } from './repositories/games/reversi/game'; +import { UserListRepository } from './repositories/user-list'; +import { UserListJoining } from './entities/user-list-joining'; +import { UserGroupRepository } from './repositories/user-group'; +import { UserGroupJoining } from './entities/user-group-joining'; +import { UserGroupInvitationRepository } from './repositories/user-group-invitation'; +import { FollowRequestRepository } from './repositories/follow-request'; +import { MutingRepository } from './repositories/muting'; +import { BlockingRepository } from './repositories/blocking'; +import { NoteReactionRepository } from './repositories/note-reaction'; +import { NotificationRepository } from './repositories/notification'; +import { NoteFavoriteRepository } from './repositories/note-favorite'; +import { ReversiMatchingRepository } from './repositories/games/reversi/matching'; +import { UserPublickey } from './entities/user-publickey'; +import { UserKeypair } from './entities/user-keypair'; +import { AppRepository } from './repositories/app'; +import { FollowingRepository } from './repositories/following'; +import { AbuseUserReportRepository } from './repositories/abuse-user-report'; +import { AuthSessionRepository } from './repositories/auth-session'; +import { UserProfile } from './entities/user-profile'; +import { AttestationChallenge } from './entities/attestation-challenge'; +import { UserSecurityKey } from './entities/user-security-key'; +import { HashtagRepository } from './repositories/hashtag'; +import { PageRepository } from './repositories/page'; +import { PageLikeRepository } from './repositories/page-like'; +import { GalleryPostRepository } from './repositories/gallery-post'; +import { GalleryLikeRepository } from './repositories/gallery-like'; +import { ModerationLogRepository } from './repositories/moderation-logs'; +import { UsedUsername } from './entities/used-username'; +import { ClipRepository } from './repositories/clip'; +import { ClipNote } from './entities/clip-note'; +import { AntennaRepository } from './repositories/antenna'; +import { AntennaNote } from './entities/antenna-note'; +import { PromoNote } from './entities/promo-note'; +import { PromoRead } from './entities/promo-read'; +import { EmojiRepository } from './repositories/emoji'; +import { RelayRepository } from './repositories/relay'; +import { ChannelRepository } from './repositories/channel'; +import { MutedNote } from './entities/muted-note'; +import { ChannelFollowing } from './entities/channel-following'; +import { ChannelNotePining } from './entities/channel-note-pining'; +import { RegistryItem } from './entities/registry-item'; +import { Ad } from './entities/ad'; +import { PasswordResetRequest } from './entities/password-reset-request'; +import { UserPending } from './entities/user-pending'; + +export const Announcements = getRepository(Announcement); +export const AnnouncementReads = getRepository(AnnouncementRead); +export const Apps = getCustomRepository(AppRepository); +export const Notes = getCustomRepository(NoteRepository); +export const NoteFavorites = getCustomRepository(NoteFavoriteRepository); +export const NoteWatchings = getRepository(NoteWatching); +export const NoteThreadMutings = getRepository(NoteThreadMuting); +export const NoteReactions = getCustomRepository(NoteReactionRepository); +export const NoteUnreads = getRepository(NoteUnread); +export const Polls = getRepository(Poll); +export const PollVotes = getRepository(PollVote); +export const Users = getCustomRepository(UserRepository); +export const UserProfiles = getRepository(UserProfile); +export const UserKeypairs = getRepository(UserKeypair); +export const UserPendings = getRepository(UserPending); +export const AttestationChallenges = getRepository(AttestationChallenge); +export const UserSecurityKeys = getRepository(UserSecurityKey); +export const UserPublickeys = getRepository(UserPublickey); +export const UserLists = getCustomRepository(UserListRepository); +export const UserListJoinings = getRepository(UserListJoining); +export const UserGroups = getCustomRepository(UserGroupRepository); +export const UserGroupJoinings = getRepository(UserGroupJoining); +export const UserGroupInvitations = getCustomRepository(UserGroupInvitationRepository); +export const UserNotePinings = getRepository(UserNotePining); +export const UsedUsernames = getRepository(UsedUsername); +export const Followings = getCustomRepository(FollowingRepository); +export const FollowRequests = getCustomRepository(FollowRequestRepository); +export const Instances = getRepository(Instance); +export const Emojis = getCustomRepository(EmojiRepository); +export const DriveFiles = getCustomRepository(DriveFileRepository); +export const DriveFolders = getCustomRepository(DriveFolderRepository); +export const Notifications = getCustomRepository(NotificationRepository); +export const Metas = getRepository(Meta); +export const Mutings = getCustomRepository(MutingRepository); +export const Blockings = getCustomRepository(BlockingRepository); +export const SwSubscriptions = getRepository(SwSubscription); +export const Hashtags = getCustomRepository(HashtagRepository); +export const AbuseUserReports = getCustomRepository(AbuseUserReportRepository); +export const RegistrationTickets = getRepository(RegistrationTicket); +export const AuthSessions = getCustomRepository(AuthSessionRepository); +export const AccessTokens = getRepository(AccessToken); +export const Signins = getCustomRepository(SigninRepository); +export const MessagingMessages = getCustomRepository(MessagingMessageRepository); +export const ReversiGames = getCustomRepository(ReversiGameRepository); +export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository); +export const Pages = getCustomRepository(PageRepository); +export const PageLikes = getCustomRepository(PageLikeRepository); +export const GalleryPosts = getCustomRepository(GalleryPostRepository); +export const GalleryLikes = getCustomRepository(GalleryLikeRepository); +export const ModerationLogs = getCustomRepository(ModerationLogRepository); +export const Clips = getCustomRepository(ClipRepository); +export const ClipNotes = getRepository(ClipNote); +export const Antennas = getCustomRepository(AntennaRepository); +export const AntennaNotes = getRepository(AntennaNote); +export const PromoNotes = getRepository(PromoNote); +export const PromoReads = getRepository(PromoRead); +export const Relays = getCustomRepository(RelayRepository); +export const MutedNotes = getRepository(MutedNote); +export const Channels = getCustomRepository(ChannelRepository); +export const ChannelFollowings = getRepository(ChannelFollowing); +export const ChannelNotePinings = getRepository(ChannelNotePining); +export const RegistryItems = getRepository(RegistryItem); +export const Ads = getRepository(Ad); +export const PasswordResetRequests = getRepository(PasswordResetRequest); diff --git a/packages/backend/src/models/repositories/abuse-user-report.ts b/packages/backend/src/models/repositories/abuse-user-report.ts new file mode 100644 index 0000000000..039a9924d2 --- /dev/null +++ b/packages/backend/src/models/repositories/abuse-user-report.ts @@ -0,0 +1,38 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Users } from '../index'; +import { AbuseUserReport } from '@/models/entities/abuse-user-report'; +import { awaitAll } from '@/prelude/await-all'; + +@EntityRepository(AbuseUserReport) +export class AbuseUserReportRepository extends Repository<AbuseUserReport> { + public async pack( + src: AbuseUserReport['id'] | AbuseUserReport, + ) { + const report = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: report.id, + createdAt: report.createdAt, + comment: report.comment, + resolved: report.resolved, + reporterId: report.reporterId, + targetUserId: report.targetUserId, + assigneeId: report.assigneeId, + reporter: Users.pack(report.reporter || report.reporterId, null, { + detail: true + }), + targetUser: Users.pack(report.targetUser || report.targetUserId, null, { + detail: true + }), + assignee: report.assigneeId ? Users.pack(report.assignee || report.assigneeId, null, { + detail: true + }) : null, + }); + } + + public packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + } +} diff --git a/packages/backend/src/models/repositories/antenna.ts b/packages/backend/src/models/repositories/antenna.ts new file mode 100644 index 0000000000..657de55581 --- /dev/null +++ b/packages/backend/src/models/repositories/antenna.ts @@ -0,0 +1,124 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Antenna } from '@/models/entities/antenna'; +import { Packed } from '@/misc/schema'; +import { AntennaNotes, UserGroupJoinings } from '../index'; + +@EntityRepository(Antenna) +export class AntennaRepository extends Repository<Antenna> { + public async pack( + src: Antenna['id'] | Antenna, + ): Promise<Packed<'Antenna'>> { + const antenna = typeof src === 'object' ? src : await this.findOneOrFail(src); + + const hasUnreadNote = (await AntennaNotes.findOne({ antennaId: antenna.id, read: false })) != null; + const userGroupJoining = antenna.userGroupJoiningId ? await UserGroupJoinings.findOne(antenna.userGroupJoiningId) : null; + + return { + id: antenna.id, + createdAt: antenna.createdAt.toISOString(), + name: antenna.name, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + src: antenna.src, + userListId: antenna.userListId, + userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null, + users: antenna.users, + caseSensitive: antenna.caseSensitive, + notify: antenna.notify, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + hasUnreadNote + }; + } +} + +export const packedAntennaSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + keywords: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + }, + excludeKeywords: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + }, + src: { + type: 'string' as const, + optional: false as const, nullable: false as const, + enum: ['home', 'all', 'users', 'list', 'group'] + }, + userListId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + userGroupId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + users: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + caseSensitive: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + notify: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + withReplies: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + withFile: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hasUnreadNote: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + } + }, +}; diff --git a/packages/backend/src/models/repositories/app.ts b/packages/backend/src/models/repositories/app.ts new file mode 100644 index 0000000000..0226edad11 --- /dev/null +++ b/packages/backend/src/models/repositories/app.ts @@ -0,0 +1,75 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { App } from '@/models/entities/app'; +import { AccessTokens } from '../index'; +import { Packed } from '@/misc/schema'; +import { User } from '../entities/user'; + +@EntityRepository(App) +export class AppRepository extends Repository<App> { + public async pack( + src: App['id'] | App, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean, + includeSecret?: boolean, + includeProfileImageIds?: boolean + } + ): Promise<Packed<'App'>> { + const opts = Object.assign({ + detail: false, + includeSecret: false, + includeProfileImageIds: false + }, options); + + const app = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: app.id, + name: app.name, + callbackUrl: app.callbackUrl, + permission: app.permission, + ...(opts.includeSecret ? { secret: app.secret } : {}), + ...(me ? { + isAuthorized: await AccessTokens.count({ + appId: app.id, + userId: me, + }).then(count => count > 0) + } : {}) + }; + } +} + +export const packedAppSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + callbackUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + permission: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + secret: { + type: 'string' as const, + optional: true as const, nullable: false as const + }, + isAuthorized: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + } + } +}; diff --git a/packages/backend/src/models/repositories/auth-session.ts b/packages/backend/src/models/repositories/auth-session.ts new file mode 100644 index 0000000000..c8f4c10f2a --- /dev/null +++ b/packages/backend/src/models/repositories/auth-session.ts @@ -0,0 +1,21 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Apps } from '../index'; +import { AuthSession } from '@/models/entities/auth-session'; +import { awaitAll } from '@/prelude/await-all'; +import { User } from '@/models/entities/user'; + +@EntityRepository(AuthSession) +export class AuthSessionRepository extends Repository<AuthSession> { + public async pack( + src: AuthSession['id'] | AuthSession, + me?: { id: User['id'] } | null | undefined + ) { + const session = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: session.id, + app: Apps.pack(session.appId, me), + token: session.token + }); + } +} diff --git a/packages/backend/src/models/repositories/blocking.ts b/packages/backend/src/models/repositories/blocking.ts new file mode 100644 index 0000000000..ac60c9a4ce --- /dev/null +++ b/packages/backend/src/models/repositories/blocking.ts @@ -0,0 +1,60 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Users } from '../index'; +import { Blocking } from '@/models/entities/blocking'; +import { awaitAll } from '@/prelude/await-all'; +import { Packed } from '@/misc/schema'; +import { User } from '@/models/entities/user'; + +@EntityRepository(Blocking) +export class BlockingRepository extends Repository<Blocking> { + public async pack( + src: Blocking['id'] | Blocking, + me?: { id: User['id'] } | null | undefined + ): Promise<Packed<'Blocking'>> { + const blocking = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: blocking.id, + createdAt: blocking.createdAt.toISOString(), + blockeeId: blocking.blockeeId, + blockee: Users.pack(blocking.blockeeId, me, { + detail: true + }) + }); + } + + public packMany( + blockings: any[], + me: { id: User['id'] } + ) { + return Promise.all(blockings.map(x => this.pack(x, me))); + } +} + +export const packedBlockingSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + blockeeId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + blockee: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' as const, + }, + } +}; diff --git a/packages/backend/src/models/repositories/channel.ts b/packages/backend/src/models/repositories/channel.ts new file mode 100644 index 0000000000..5c7d095473 --- /dev/null +++ b/packages/backend/src/models/repositories/channel.ts @@ -0,0 +1,95 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Channel } from '@/models/entities/channel'; +import { Packed } from '@/misc/schema'; +import { DriveFiles, ChannelFollowings, NoteUnreads } from '../index'; +import { User } from '@/models/entities/user'; + +@EntityRepository(Channel) +export class ChannelRepository extends Repository<Channel> { + public async pack( + src: Channel['id'] | Channel, + me?: { id: User['id'] } | null | undefined, + ): Promise<Packed<'Channel'>> { + const channel = typeof src === 'object' ? src : await this.findOneOrFail(src); + const meId = me ? me.id : null; + + const banner = channel.bannerId ? await DriveFiles.findOne(channel.bannerId) : null; + + const hasUnreadNote = meId ? (await NoteUnreads.findOne({ noteChannelId: channel.id, userId: meId })) != null : undefined; + + const following = meId ? await ChannelFollowings.findOne({ + followerId: meId, + followeeId: channel.id, + }) : null; + + return { + id: channel.id, + createdAt: channel.createdAt.toISOString(), + lastNotedAt: channel.lastNotedAt ? channel.lastNotedAt.toISOString() : null, + name: channel.name, + description: channel.description, + userId: channel.userId, + bannerUrl: banner ? DriveFiles.getPublicUrl(banner, false) : null, + usersCount: channel.usersCount, + notesCount: channel.notesCount, + + ...(me ? { + isFollowing: following != null, + hasUnreadNote, + } : {}) + }; + } +} + +export const packedChannelSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + lastNotedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + description: { + type: 'string' as const, + nullable: true as const, optional: false as const, + }, + bannerUrl: { + type: 'string' as const, + format: 'url', + nullable: true as const, optional: false as const, + }, + notesCount: { + type: 'number' as const, + nullable: false as const, optional: false as const, + }, + usersCount: { + type: 'number' as const, + nullable: false as const, optional: false as const, + }, + isFollowing: { + type: 'boolean' as const, + optional: true as const, nullable: false as const, + }, + userId: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'id', + }, + }, +}; diff --git a/packages/backend/src/models/repositories/clip.ts b/packages/backend/src/models/repositories/clip.ts new file mode 100644 index 0000000000..7892811d48 --- /dev/null +++ b/packages/backend/src/models/repositories/clip.ts @@ -0,0 +1,70 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Clip } from '@/models/entities/clip'; +import { Packed } from '@/misc/schema'; +import { Users } from '../index'; +import { awaitAll } from '@/prelude/await-all'; + +@EntityRepository(Clip) +export class ClipRepository extends Repository<Clip> { + public async pack( + src: Clip['id'] | Clip, + ): Promise<Packed<'Clip'>> { + const clip = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: clip.id, + createdAt: clip.createdAt.toISOString(), + userId: clip.userId, + user: Users.pack(clip.user || clip.userId), + name: clip.name, + description: clip.description, + isPublic: clip.isPublic, + }); + } + + public packMany( + clips: Clip[], + ) { + return Promise.all(clips.map(x => this.pack(x))); + } +} + +export const packedClipSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + user: { + type: 'object' as const, + ref: 'User' as const, + optional: false as const, nullable: false as const, + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + description: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + isPublic: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + }, +}; diff --git a/packages/backend/src/models/repositories/drive-file.ts b/packages/backend/src/models/repositories/drive-file.ts new file mode 100644 index 0000000000..ddf9a46afd --- /dev/null +++ b/packages/backend/src/models/repositories/drive-file.ts @@ -0,0 +1,249 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { DriveFile } from '@/models/entities/drive-file'; +import { Users, DriveFolders } from '../index'; +import { User } from '@/models/entities/user'; +import { toPuny } from '@/misc/convert-host'; +import { awaitAll } from '@/prelude/await-all'; +import { Packed } from '@/misc/schema'; +import config from '@/config/index'; +import { query, appendQuery } from '@/prelude/url'; +import { Meta } from '@/models/entities/meta'; +import { fetchMeta } from '@/misc/fetch-meta'; + +type PackOptions = { + detail?: boolean, + self?: boolean, + withUser?: boolean, +}; + +@EntityRepository(DriveFile) +export class DriveFileRepository extends Repository<DriveFile> { + public validateFileName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) && + (name.indexOf('\\') === -1) && + (name.indexOf('/') === -1) && + (name.indexOf('..') === -1) + ); + } + + public getPublicUrl(file: DriveFile, thumbnail = false, meta?: Meta): string | null { + // リモートかつメディアプロキシ + if (file.uri != null && file.userHost != null && config.mediaProxy != null) { + return appendQuery(config.mediaProxy, query({ + url: file.uri, + thumbnail: thumbnail ? '1' : undefined + })); + } + + // リモートかつ期限切れはローカルプロキシを試みる + if (file.uri != null && file.isLink && meta && meta.proxyRemoteFiles) { + const key = thumbnail ? file.thumbnailAccessKey : file.webpublicAccessKey; + + if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外 + return `${config.url}/files/${key}`; + } + } + + const isImage = file.type && ['image/png', 'image/apng', 'image/gif', 'image/jpeg', 'image/webp', 'image/svg+xml'].includes(file.type); + + return thumbnail ? (file.thumbnailUrl || (isImage ? (file.webpublicUrl || file.url) : null)) : (file.webpublicUrl || file.url); + } + + public async calcDriveUsageOf(user: User['id'] | { id: User['id'] }): Promise<number> { + const id = typeof user === 'object' ? user.id : user; + + const { sum } = await this + .createQueryBuilder('file') + .where('file.userId = :id', { id: id }) + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) || 0; + } + + public async calcDriveUsageOfHost(host: string): Promise<number> { + const { sum } = await this + .createQueryBuilder('file') + .where('file.userHost = :host', { host: toPuny(host) }) + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) || 0; + } + + public async calcDriveUsageOfLocal(): Promise<number> { + const { sum } = await this + .createQueryBuilder('file') + .where('file.userHost IS NULL') + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) || 0; + } + + public async calcDriveUsageOfRemote(): Promise<number> { + const { sum } = await this + .createQueryBuilder('file') + .where('file.userHost IS NOT NULL') + .andWhere('file.isLink = FALSE') + .select('SUM(file.size)', 'sum') + .getRawOne(); + + return parseInt(sum, 10) || 0; + } + + public async pack(src: DriveFile['id'], options?: PackOptions): Promise<Packed<'DriveFile'> | null>; + public async pack(src: DriveFile, options?: PackOptions): Promise<Packed<'DriveFile'>>; + public async pack( + src: DriveFile['id'] | DriveFile, + options?: PackOptions + ): Promise<Packed<'DriveFile'> | null> { + const opts = Object.assign({ + detail: false, + self: false + }, options); + + const file = typeof src === 'object' ? src : await this.findOne(src); + if (file == null) return null; + + const meta = await fetchMeta(); + + return await awaitAll({ + id: file.id, + createdAt: file.createdAt.toISOString(), + name: file.name, + type: file.type, + md5: file.md5, + size: file.size, + isSensitive: file.isSensitive, + blurhash: file.blurhash, + properties: file.properties, + url: opts.self ? file.url : this.getPublicUrl(file, false, meta), + thumbnailUrl: this.getPublicUrl(file, true, meta), + comment: file.comment, + folderId: file.folderId, + folder: opts.detail && file.folderId ? DriveFolders.pack(file.folderId, { + detail: true + }) : null, + userId: opts.withUser ? file.userId : null, + user: (opts.withUser && file.userId) ? Users.pack(file.userId) : null + }); + } + + public async packMany( + files: (DriveFile['id'] | DriveFile)[], + options?: PackOptions + ) { + const items = await Promise.all(files.map(f => this.pack(f, options))); + return items.filter(x => x != null); + } +} + +export const packedDriveFileSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'lenna.jpg' + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'image/jpeg' + }, + md5: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'md5', + example: '15eca7fba0480996e2245f5185bf39f2' + }, + size: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 51469 + }, + isSensitive: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + blurhash: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + properties: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + width: { + type: 'number' as const, + optional: true as const, nullable: false as const, + example: 1280 + }, + height: { + type: 'number' as const, + optional: true as const, nullable: false as const, + example: 720 + }, + avgColor: { + type: 'string' as const, + optional: true as const, nullable: false as const, + example: 'rgb(40,65,87)' + } + } + }, + url: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + thumbnailUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + comment: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + folderId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + folder: { + type: 'object' as const, + optional: true as const, nullable: true as const, + ref: 'DriveFolder' as const, + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + user: { + type: 'object' as const, + optional: true as const, nullable: true as const, + ref: 'User' as const, + } + }, +}; diff --git a/packages/backend/src/models/repositories/drive-folder.ts b/packages/backend/src/models/repositories/drive-folder.ts new file mode 100644 index 0000000000..8ef6f01b5d --- /dev/null +++ b/packages/backend/src/models/repositories/drive-folder.ts @@ -0,0 +1,91 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { DriveFolders, DriveFiles } from '../index'; +import { DriveFolder } from '@/models/entities/drive-folder'; +import { awaitAll } from '@/prelude/await-all'; +import { Packed } from '@/misc/schema'; + +@EntityRepository(DriveFolder) +export class DriveFolderRepository extends Repository<DriveFolder> { + public validateFolderName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) + ); + } + + public async pack( + src: DriveFolder['id'] | DriveFolder, + options?: { + detail: boolean + } + ): Promise<Packed<'DriveFolder'>> { + const opts = Object.assign({ + detail: false + }, options); + + const folder = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: folder.id, + createdAt: folder.createdAt.toISOString(), + name: folder.name, + parentId: folder.parentId, + + ...(opts.detail ? { + foldersCount: DriveFolders.count({ + parentId: folder.id + }), + filesCount: DriveFiles.count({ + folderId: folder.id + }), + + ...(folder.parentId ? { + parent: this.pack(folder.parentId, { + detail: true + }) + } : {}) + } : {}) + }); + } +} + +export const packedDriveFolderSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + foldersCount: { + type: 'number' as const, + optional: true as const, nullable: false as const, + }, + filesCount: { + type: 'number' as const, + optional: true as const, nullable: false as const, + }, + parentId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + parent: { + type: 'object' as const, + optional: true as const, nullable: true as const, + ref: 'DriveFolder' as const, + }, + }, +}; diff --git a/packages/backend/src/models/repositories/emoji.ts b/packages/backend/src/models/repositories/emoji.ts new file mode 100644 index 0000000000..7985c27aba --- /dev/null +++ b/packages/backend/src/models/repositories/emoji.ts @@ -0,0 +1,65 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Emoji } from '@/models/entities/emoji'; +import { Packed } from '@/misc/schema'; + +@EntityRepository(Emoji) +export class EmojiRepository extends Repository<Emoji> { + public async pack( + src: Emoji['id'] | Emoji, + ): Promise<Packed<'Emoji'>> { + const emoji = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: emoji.id, + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + host: emoji.host, + url: emoji.url, + }; + } + + public packMany( + emojis: any[], + ) { + return Promise.all(emojis.map(x => this.pack(x))); + } +} + +export const packedEmojiSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + aliases: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + category: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + host: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + } +}; diff --git a/packages/backend/src/models/repositories/federation-instance.ts b/packages/backend/src/models/repositories/federation-instance.ts new file mode 100644 index 0000000000..4b70971ecf --- /dev/null +++ b/packages/backend/src/models/repositories/federation-instance.ts @@ -0,0 +1,106 @@ +import config from '@/config/index'; + +export const packedFederationInstanceSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + caughtAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + host: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'misskey.example.com' + }, + usersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + notesCount: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + followingCount: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + followersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + driveUsage: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + driveFiles: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + latestRequestSentAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time' + }, + lastCommunicatedAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + isNotResponding: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isSuspended: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + softwareName: { + type: 'string' as const, + optional: false as const, nullable: true as const, + example: 'misskey' + }, + softwareVersion: { + type: 'string' as const, + optional: false as const, nullable: true as const, + example: config.version + }, + openRegistrations: { + type: 'boolean' as const, + optional: false as const, nullable: true as const, + example: true + }, + name: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + description: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + maintainerName: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + maintainerEmail: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + iconUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url' + }, + infoUpdatedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time' + } + } +}; diff --git a/packages/backend/src/models/repositories/follow-request.ts b/packages/backend/src/models/repositories/follow-request.ts new file mode 100644 index 0000000000..d6ee58e235 --- /dev/null +++ b/packages/backend/src/models/repositories/follow-request.ts @@ -0,0 +1,20 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { FollowRequest } from '@/models/entities/follow-request'; +import { Users } from '../index'; +import { User } from '@/models/entities/user'; + +@EntityRepository(FollowRequest) +export class FollowRequestRepository extends Repository<FollowRequest> { + public async pack( + src: FollowRequest['id'] | FollowRequest, + me?: { id: User['id'] } | null | undefined + ) { + const request = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: request.id, + follower: await Users.pack(request.followerId, me), + followee: await Users.pack(request.followeeId, me), + }; + } +} diff --git a/packages/backend/src/models/repositories/following.ts b/packages/backend/src/models/repositories/following.ts new file mode 100644 index 0000000000..b1f716069f --- /dev/null +++ b/packages/backend/src/models/repositories/following.ts @@ -0,0 +1,124 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Users } from '../index'; +import { Following } from '@/models/entities/following'; +import { awaitAll } from '@/prelude/await-all'; +import { Packed } from '@/misc/schema'; +import { User } from '@/models/entities/user'; + +type LocalFollowerFollowing = Following & { + followerHost: null; + followerInbox: null; + followerSharedInbox: null; +}; + +type RemoteFollowerFollowing = Following & { + followerHost: string; + followerInbox: string; + followerSharedInbox: string; +}; + +type LocalFolloweeFollowing = Following & { + followeeHost: null; + followeeInbox: null; + followeeSharedInbox: null; +}; + +type RemoteFolloweeFollowing = Following & { + followeeHost: string; + followeeInbox: string; + followeeSharedInbox: string; +}; + +@EntityRepository(Following) +export class FollowingRepository extends Repository<Following> { + public isLocalFollower(following: Following): following is LocalFollowerFollowing { + return following.followerHost == null; + } + + public isRemoteFollower(following: Following): following is RemoteFollowerFollowing { + return following.followerHost != null; + } + + public isLocalFollowee(following: Following): following is LocalFolloweeFollowing { + return following.followeeHost == null; + } + + public isRemoteFollowee(following: Following): following is RemoteFolloweeFollowing { + return following.followeeHost != null; + } + + public async pack( + src: Following['id'] | Following, + me?: { id: User['id'] } | null | undefined, + opts?: { + populateFollowee?: boolean; + populateFollower?: boolean; + } + ): Promise<Packed<'Following'>> { + const following = typeof src === 'object' ? src : await this.findOneOrFail(src); + + if (opts == null) opts = {}; + + return await awaitAll({ + id: following.id, + createdAt: following.createdAt.toISOString(), + followeeId: following.followeeId, + followerId: following.followerId, + followee: opts.populateFollowee ? Users.pack(following.followee || following.followeeId, me, { + detail: true + }) : undefined, + follower: opts.populateFollower ? Users.pack(following.follower || following.followerId, me, { + detail: true + }) : undefined, + }); + } + + public packMany( + followings: any[], + me?: { id: User['id'] } | null | undefined, + opts?: { + populateFollowee?: boolean; + populateFollower?: boolean; + } + ) { + return Promise.all(followings.map(x => this.pack(x, me, opts))); + } +} + +export const packedFollowingSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + followeeId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + followee: { + type: 'object' as const, + optional: true as const, nullable: false as const, + ref: 'User' as const, + }, + followerId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + follower: { + type: 'object' as const, + optional: true as const, nullable: false as const, + ref: 'User' as const, + }, + } +}; diff --git a/packages/backend/src/models/repositories/gallery-like.ts b/packages/backend/src/models/repositories/gallery-like.ts new file mode 100644 index 0000000000..79123e5eec --- /dev/null +++ b/packages/backend/src/models/repositories/gallery-like.ts @@ -0,0 +1,25 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { GalleryLike } from '@/models/entities/gallery-like'; +import { GalleryPosts } from '../index'; + +@EntityRepository(GalleryLike) +export class GalleryLikeRepository extends Repository<GalleryLike> { + public async pack( + src: GalleryLike['id'] | GalleryLike, + me?: any + ) { + const like = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: like.id, + post: await GalleryPosts.pack(like.post || like.postId, me), + }; + } + + public packMany( + likes: any[], + me: any + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} diff --git a/packages/backend/src/models/repositories/gallery-post.ts b/packages/backend/src/models/repositories/gallery-post.ts new file mode 100644 index 0000000000..4f666ff252 --- /dev/null +++ b/packages/backend/src/models/repositories/gallery-post.ts @@ -0,0 +1,111 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { GalleryPost } from '@/models/entities/gallery-post'; +import { Packed } from '@/misc/schema'; +import { Users, DriveFiles, GalleryLikes } from '../index'; +import { awaitAll } from '@/prelude/await-all'; +import { User } from '@/models/entities/user'; + +@EntityRepository(GalleryPost) +export class GalleryPostRepository extends Repository<GalleryPost> { + public async pack( + src: GalleryPost['id'] | GalleryPost, + me?: { id: User['id'] } | null | undefined, + ): Promise<Packed<'GalleryPost'>> { + const meId = me ? me.id : null; + const post = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: post.id, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + userId: post.userId, + user: Users.pack(post.user || post.userId, me), + title: post.title, + description: post.description, + fileIds: post.fileIds, + files: DriveFiles.packMany(post.fileIds), + tags: post.tags.length > 0 ? post.tags : undefined, + isSensitive: post.isSensitive, + likedCount: post.likedCount, + isLiked: meId ? await GalleryLikes.findOne({ postId: post.id, userId: meId }).then(x => x != null) : undefined, + }); + } + + public packMany( + posts: GalleryPost[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(posts.map(x => this.pack(x, me))); + } +} + +export const packedGalleryPostSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + description: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + user: { + type: 'object' as const, + ref: 'User' as const, + optional: false as const, nullable: false as const, + }, + fileIds: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + } + }, + files: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile' as const, + } + }, + tags: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + }, + isSensitive: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + } +}; diff --git a/packages/backend/src/models/repositories/games/reversi/game.ts b/packages/backend/src/models/repositories/games/reversi/game.ts new file mode 100644 index 0000000000..9adb386fa9 --- /dev/null +++ b/packages/backend/src/models/repositories/games/reversi/game.ts @@ -0,0 +1,191 @@ +import { User } from '@/models/entities/user'; +import { EntityRepository, Repository } from 'typeorm'; +import { Users } from '../../../index'; +import { ReversiGame } from '@/models/entities/games/reversi/game'; +import { Packed } from '@/misc/schema'; + +@EntityRepository(ReversiGame) +export class ReversiGameRepository extends Repository<ReversiGame> { + public async pack( + src: ReversiGame['id'] | ReversiGame, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean + } + ): Promise<Packed<'ReversiGame'>> { + const opts = Object.assign({ + detail: true + }, options); + + const game = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: game.id, + createdAt: game.createdAt.toISOString(), + startedAt: game.startedAt && game.startedAt.toISOString(), + isStarted: game.isStarted, + isEnded: game.isEnded, + form1: game.form1, + form2: game.form2, + user1Accepted: game.user1Accepted, + user2Accepted: game.user2Accepted, + user1Id: game.user1Id, + user2Id: game.user2Id, + user1: await Users.pack(game.user1Id, me), + user2: await Users.pack(game.user2Id, me), + winnerId: game.winnerId, + winner: game.winnerId ? await Users.pack(game.winnerId, me) : null, + surrendered: game.surrendered, + black: game.black, + bw: game.bw, + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard, + ...(opts.detail ? { + logs: game.logs.map(log => ({ + at: log.at.toISOString(), + color: log.color, + pos: log.pos + })), + map: game.map, + } : {}) + }; + } +} + +export const packedReversiGameSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + startedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + isStarted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + isEnded: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + form1: { + type: 'any' as const, + optional: false as const, nullable: true as const, + }, + form2: { + type: 'any' as const, + optional: false as const, nullable: true as const, + }, + user1Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + user2Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + user1Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + user2Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + user1: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' as const, + }, + user2: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' as const, + }, + winnerId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + winner: { + type: 'object' as const, + optional: false as const, nullable: true as const, + ref: 'User' as const, + }, + surrendered: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + black: { + type: 'number' as const, + optional: false as const, nullable: true as const, + }, + bw: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + isLlotheo: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + canPutEverywhere: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + loopedBoard: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + logs: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'object' as const, + optional: true as const, nullable: false as const, + properties: { + at: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + color: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + pos: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + } + }, + map: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } +}; diff --git a/packages/backend/src/models/repositories/games/reversi/matching.ts b/packages/backend/src/models/repositories/games/reversi/matching.ts new file mode 100644 index 0000000000..b4515800df --- /dev/null +++ b/packages/backend/src/models/repositories/games/reversi/matching.ts @@ -0,0 +1,69 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { ReversiMatching } from '@/models/entities/games/reversi/matching'; +import { Users } from '../../../index'; +import { awaitAll } from '@/prelude/await-all'; +import { User } from '@/models/entities/user'; +import { Packed } from '@/misc/schema'; + +@EntityRepository(ReversiMatching) +export class ReversiMatchingRepository extends Repository<ReversiMatching> { + public async pack( + src: ReversiMatching['id'] | ReversiMatching, + me: { id: User['id'] } + ): Promise<Packed<'ReversiMatching'>> { + const matching = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: matching.id, + createdAt: matching.createdAt.toISOString(), + parentId: matching.parentId, + parent: Users.pack(matching.parentId, me, { + detail: true + }), + childId: matching.childId, + child: Users.pack(matching.childId, me, { + detail: true + }) + }); + } +} + +export const packedReversiMatchingSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + parentId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + parent: { + type: 'object' as const, + optional: false as const, nullable: true as const, + ref: 'User' as const, + }, + childId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + child: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' as const, + }, + } +}; diff --git a/packages/backend/src/models/repositories/hashtag.ts b/packages/backend/src/models/repositories/hashtag.ts new file mode 100644 index 0000000000..d52f6ba7c6 --- /dev/null +++ b/packages/backend/src/models/repositories/hashtag.ts @@ -0,0 +1,62 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Hashtag } from '@/models/entities/hashtag'; +import { Packed } from '@/misc/schema'; + +@EntityRepository(Hashtag) +export class HashtagRepository extends Repository<Hashtag> { + public async pack( + src: Hashtag, + ): Promise<Packed<'Hashtag'>> { + return { + tag: src.name, + mentionedUsersCount: src.mentionedUsersCount, + mentionedLocalUsersCount: src.mentionedLocalUsersCount, + mentionedRemoteUsersCount: src.mentionedRemoteUsersCount, + attachedUsersCount: src.attachedUsersCount, + attachedLocalUsersCount: src.attachedLocalUsersCount, + attachedRemoteUsersCount: src.attachedRemoteUsersCount, + }; + } + + public packMany( + hashtags: Hashtag[], + ) { + return Promise.all(hashtags.map(x => this.pack(x))); + } +} + +export const packedHashtagSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + tag: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'misskey', + }, + mentionedUsersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + mentionedLocalUsersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + mentionedRemoteUsersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + attachedUsersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + attachedLocalUsersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + attachedRemoteUsersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } +}; diff --git a/packages/backend/src/models/repositories/messaging-message.ts b/packages/backend/src/models/repositories/messaging-message.ts new file mode 100644 index 0000000000..abdff63689 --- /dev/null +++ b/packages/backend/src/models/repositories/messaging-message.ts @@ -0,0 +1,119 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { Users, DriveFiles, UserGroups } from '../index'; +import { Packed } from '@/misc/schema'; +import { User } from '@/models/entities/user'; + +@EntityRepository(MessagingMessage) +export class MessagingMessageRepository extends Repository<MessagingMessage> { + public validateText(text: string): boolean { + return text.trim().length <= 1000 && text.trim() != ''; + } + + public async pack( + src: MessagingMessage['id'] | MessagingMessage, + me?: { id: User['id'] } | null | undefined, + options?: { + populateRecipient?: boolean, + populateGroup?: boolean, + } + ): Promise<Packed<'MessagingMessage'>> { + const opts = options || { + populateRecipient: true, + populateGroup: true, + }; + + const message = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: message.id, + createdAt: message.createdAt.toISOString(), + text: message.text, + userId: message.userId, + user: await Users.pack(message.user || message.userId, me), + recipientId: message.recipientId, + recipient: message.recipientId && opts.populateRecipient ? await Users.pack(message.recipient || message.recipientId, me) : undefined, + groupId: message.groupId, + group: message.groupId && opts.populateGroup ? await UserGroups.pack(message.group || message.groupId) : undefined, + fileId: message.fileId, + file: message.fileId ? await DriveFiles.pack(message.fileId) : null, + isRead: message.isRead, + reads: message.reads, + }; + } +} + +export const packedMessagingMessageSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + user: { + type: 'object' as const, + ref: 'User' as const, + optional: true as const, nullable: false as const, + }, + text: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + fileId: { + type: 'string' as const, + optional: true as const, nullable: true as const, + format: 'id', + }, + file: { + type: 'object' as const, + optional: true as const, nullable: true as const, + ref: 'DriveFile' as const, + }, + recipientId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + }, + recipient: { + type: 'object' as const, + optional: true as const, nullable: true as const, + ref: 'User' as const, + }, + groupId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + }, + group: { + type: 'object' as const, + optional: true as const, nullable: true as const, + ref: 'UserGroup' as const, + }, + isRead: { + type: 'boolean' as const, + optional: true as const, nullable: false as const, + }, + reads: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + } + }, + }, +}; diff --git a/packages/backend/src/models/repositories/moderation-logs.ts b/packages/backend/src/models/repositories/moderation-logs.ts new file mode 100644 index 0000000000..c7df3afdc9 --- /dev/null +++ b/packages/backend/src/models/repositories/moderation-logs.ts @@ -0,0 +1,30 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Users } from '../index'; +import { ModerationLog } from '@/models/entities/moderation-log'; +import { awaitAll } from '@/prelude/await-all'; + +@EntityRepository(ModerationLog) +export class ModerationLogRepository extends Repository<ModerationLog> { + public async pack( + src: ModerationLog['id'] | ModerationLog, + ) { + const log = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: log.id, + createdAt: log.createdAt, + type: log.type, + info: log.info, + userId: log.userId, + user: Users.pack(log.user || log.userId, null, { + detail: true + }), + }); + } + + public packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + } +} diff --git a/packages/backend/src/models/repositories/muting.ts b/packages/backend/src/models/repositories/muting.ts new file mode 100644 index 0000000000..869afd3c4e --- /dev/null +++ b/packages/backend/src/models/repositories/muting.ts @@ -0,0 +1,60 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Users } from '../index'; +import { Muting } from '@/models/entities/muting'; +import { awaitAll } from '@/prelude/await-all'; +import { Packed } from '@/misc/schema'; +import { User } from '@/models/entities/user'; + +@EntityRepository(Muting) +export class MutingRepository extends Repository<Muting> { + public async pack( + src: Muting['id'] | Muting, + me?: { id: User['id'] } | null | undefined + ): Promise<Packed<'Muting'>> { + const muting = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + muteeId: muting.muteeId, + mutee: Users.pack(muting.muteeId, me, { + detail: true + }) + }); + } + + public packMany( + mutings: any[], + me: { id: User['id'] } + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + } +} + +export const packedMutingSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + muteeId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + mutee: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' as const, + }, + } +}; diff --git a/packages/backend/src/models/repositories/note-favorite.ts b/packages/backend/src/models/repositories/note-favorite.ts new file mode 100644 index 0000000000..47586a9116 --- /dev/null +++ b/packages/backend/src/models/repositories/note-favorite.ts @@ -0,0 +1,56 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { NoteFavorite } from '@/models/entities/note-favorite'; +import { Notes } from '../index'; +import { User } from '@/models/entities/user'; + +@EntityRepository(NoteFavorite) +export class NoteFavoriteRepository extends Repository<NoteFavorite> { + public async pack( + src: NoteFavorite['id'] | NoteFavorite, + me?: { id: User['id'] } | null | undefined + ) { + const favorite = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: favorite.id, + createdAt: favorite.createdAt, + noteId: favorite.noteId, + note: await Notes.pack(favorite.note || favorite.noteId, me), + }; + } + + public packMany( + favorites: any[], + me: { id: User['id'] } + ) { + return Promise.all(favorites.map(x => this.pack(x, me))); + } +} + +export const packedNoteFavoriteSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + note: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note' as const, + }, + noteId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + }, +}; diff --git a/packages/backend/src/models/repositories/note-reaction.ts b/packages/backend/src/models/repositories/note-reaction.ts new file mode 100644 index 0000000000..5d86065526 --- /dev/null +++ b/packages/backend/src/models/repositories/note-reaction.ts @@ -0,0 +1,60 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { NoteReaction } from '@/models/entities/note-reaction'; +import { Notes, Users } from '../index'; +import { Packed } from '@/misc/schema'; +import { convertLegacyReaction } from '@/misc/reaction-lib'; +import { User } from '@/models/entities/user'; + +@EntityRepository(NoteReaction) +export class NoteReactionRepository extends Repository<NoteReaction> { + public async pack( + src: NoteReaction['id'] | NoteReaction, + me?: { id: User['id'] } | null | undefined, + options?: { + withNote: boolean; + }, + ): Promise<Packed<'NoteReaction'>> { + const opts = Object.assign({ + withNote: false, + }, options); + + const reaction = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: reaction.id, + createdAt: reaction.createdAt.toISOString(), + user: await Users.pack(reaction.userId, me), + type: convertLegacyReaction(reaction.reaction), + ...(opts.withNote ? { + note: await Notes.pack(reaction.noteId, me), + } : {}) + }; + } +} + +export const packedNoteReactionSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + user: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' as const, + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + }, +}; diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts new file mode 100644 index 0000000000..0f00c34c9c --- /dev/null +++ b/packages/backend/src/models/repositories/note.ts @@ -0,0 +1,512 @@ +import { EntityRepository, Repository, In } from 'typeorm'; +import * as mfm from 'mfm-js'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; +import { Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '../index'; +import { Packed } from '@/misc/schema'; +import { nyaize } from '@/misc/nyaize'; +import { awaitAll } from '@/prelude/await-all'; +import { convertLegacyReaction, convertLegacyReactions, decodeReaction } from '@/misc/reaction-lib'; +import { NoteReaction } from '@/models/entities/note-reaction'; +import { aggregateNoteEmojis, populateEmojis, prefetchEmojis } from '@/misc/populate-emojis'; + +@EntityRepository(Note) +export class NoteRepository extends Repository<Note> { + public validateCw(x: string) { + return x.trim().length <= 100; + } + + public async isVisibleForMe(note: Note, meId: User['id'] | null): Promise<boolean> { + // visibility が specified かつ自分が指定されていなかったら非表示 + if (note.visibility === 'specified') { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else { + // 指定されているかどうか + const specified = note.visibleUserIds.some((id: any) => meId === id); + + if (specified) { + return true; + } else { + return false; + } + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (note.visibility === 'followers') { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else if (note.reply && (meId === note.reply.userId)) { + // 自分の投稿に対するリプライ + return true; + } else if (note.mentions && note.mentions.some(id => meId === id)) { + // 自分へのメンション + return true; + } else { + // フォロワーかどうか + const following = await Followings.findOne({ + followeeId: note.userId, + followerId: meId + }); + + if (following == null) { + return false; + } else { + return true; + } + } + } + + return true; + } + + private async hideNote(packedNote: Packed<'Note'>, meId: User['id'] | null) { + // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) + let hide = false; + + // visibility が specified かつ自分が指定されていなかったら非表示 + if (packedNote.visibility === 'specified') { + if (meId == null) { + hide = true; + } else if (meId === packedNote.userId) { + hide = false; + } else { + // 指定されているかどうか + const specified = packedNote.visibleUserIds!.some((id: any) => meId === id); + + if (specified) { + hide = false; + } else { + hide = true; + } + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (packedNote.visibility === 'followers') { + if (meId == null) { + hide = true; + } else if (meId === packedNote.userId) { + hide = false; + } else if (packedNote.reply && (meId === packedNote.reply.userId)) { + // 自分の投稿に対するリプライ + hide = false; + } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { + // 自分へのメンション + hide = false; + } else { + // フォロワーかどうか + const following = await Followings.findOne({ + followeeId: packedNote.userId, + followerId: meId + }); + + if (following == null) { + hide = true; + } else { + hide = false; + } + } + } + + if (hide) { + packedNote.visibleUserIds = undefined; + packedNote.fileIds = []; + packedNote.files = []; + packedNote.text = null; + packedNote.poll = undefined; + packedNote.cw = null; + packedNote.isHidden = true; + } + } + + public async pack( + src: Note['id'] | Note, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + _hint_?: { + myReactions: Map<Note['id'], NoteReaction | null>; + }; + } + ): Promise<Packed<'Note'>> { + const opts = Object.assign({ + detail: true, + skipHide: false + }, options); + + const meId = me ? me.id : null; + const note = typeof src === 'object' ? src : await this.findOneOrFail(src); + const host = note.userHost; + + async function populatePoll() { + const poll = await Polls.findOneOrFail(note.id); + const choices = poll.choices.map(c => ({ + text: c, + votes: poll.votes[poll.choices.indexOf(c)], + isVoted: false + })); + + if (poll.multiple) { + const votes = await PollVotes.find({ + userId: meId!, + noteId: note.id + }); + + const myChoices = votes.map(v => v.choice); + for (const myChoice of myChoices) { + choices[myChoice].isVoted = true; + } + } else { + const vote = await PollVotes.findOne({ + userId: meId!, + noteId: note.id + }); + + if (vote) { + choices[vote.choice].isVoted = true; + } + } + + return { + multiple: poll.multiple, + expiresAt: poll.expiresAt, + choices + }; + } + + async function populateMyReaction() { + if (options?._hint_?.myReactions) { + const reaction = options._hint_.myReactions.get(note.id); + if (reaction) { + return convertLegacyReaction(reaction.reaction); + } else if (reaction === null) { + return undefined; + } + // 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない + } + + const reaction = await NoteReactions.findOne({ + userId: meId!, + noteId: note.id, + }); + + if (reaction) { + return convertLegacyReaction(reaction.reaction); + } + + return undefined; + } + + let text = note.text; + + if (note.name && (note.url || note.uri)) { + text = `【${note.name}】\n${(note.text || '').trim()}\n\n${note.url || note.uri}`; + } + + const channel = note.channelId + ? note.channel + ? note.channel + : await Channels.findOne(note.channelId) + : null; + + const reactionEmojiNames = Object.keys(note.reactions).filter(x => x?.startsWith(':')).map(x => decodeReaction(x).reaction).map(x => x.replace(/:/g, '')); + + const packed = await awaitAll({ + id: note.id, + createdAt: note.createdAt.toISOString(), + userId: note.userId, + user: Users.pack(note.user || note.userId, me, { + detail: false, + }), + text: text, + cw: note.cw, + visibility: note.visibility, + localOnly: note.localOnly || undefined, + visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, + viaMobile: note.viaMobile || undefined, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + reactions: convertLegacyReactions(note.reactions), + tags: note.tags.length > 0 ? note.tags : undefined, + emojis: populateEmojis(note.emojis.concat(reactionEmojiNames), host), + fileIds: note.fileIds, + files: DriveFiles.packMany(note.fileIds), + replyId: note.replyId, + renoteId: note.renoteId, + channelId: note.channelId || undefined, + channel: channel ? { + id: channel.id, + name: channel.name, + } : undefined, + mentions: note.mentions.length > 0 ? note.mentions : undefined, + uri: note.uri || undefined, + url: note.url || undefined, + + ...(opts.detail ? { + reply: note.replyId ? this.pack(note.reply || note.replyId, me, { + detail: false, + _hint_: options?._hint_ + }) : undefined, + + renote: note.renoteId ? this.pack(note.renote || note.renoteId, me, { + detail: true, + _hint_: options?._hint_ + }) : undefined, + + poll: note.hasPoll ? populatePoll() : undefined, + + ...(meId ? { + myReaction: populateMyReaction() + } : {}) + } : {}) + }); + + if (packed.user.isCat && packed.text) { + const tokens = packed.text ? mfm.parse(packed.text) : []; + mfm.inspect(tokens, node => { + if (node.type === 'text') { + // TODO: quoteなtextはskip + node.props.text = nyaize(node.props.text); + } + }); + packed.text = mfm.toString(tokens); + } + + if (!opts.skipHide) { + await this.hideNote(packed, meId); + } + + return packed; + } + + public async packMany( + notes: Note[], + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean; + skipHide?: boolean; + } + ) { + if (notes.length === 0) return []; + + const meId = me ? me.id : null; + const myReactionsMap = new Map<Note['id'], NoteReaction | null>(); + if (meId) { + const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); + const targets = [...notes.map(n => n.id), ...renoteIds]; + const myReactions = await NoteReactions.find({ + userId: meId, + noteId: In(targets), + }); + + for (const target of targets) { + myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) || null); + } + } + + await prefetchEmojis(aggregateNoteEmojis(notes)); + + return await Promise.all(notes.map(n => this.pack(n, me, { + ...options, + _hint_: { + myReactions: myReactionsMap + } + }))); + } +} + +export const packedNoteSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + text: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + cw: { + type: 'string' as const, + optional: true as const, nullable: true as const, + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + user: { + type: 'object' as const, + ref: 'User' as const, + optional: false as const, nullable: false as const, + }, + replyId: { + type: 'string' as const, + optional: true as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + renoteId: { + type: 'string' as const, + optional: true as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + reply: { + type: 'object' as const, + optional: true as const, nullable: true as const, + ref: 'Note' as const, + }, + renote: { + type: 'object' as const, + optional: true as const, nullable: true as const, + ref: 'Note' as const, + }, + viaMobile: { + type: 'boolean' as const, + optional: true as const, nullable: false as const, + }, + isHidden: { + type: 'boolean' as const, + optional: true as const, nullable: false as const, + }, + visibility: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + mentions: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + } + }, + visibleUserIds: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + } + }, + fileIds: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + } + }, + files: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile' as const, + } + }, + tags: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + }, + poll: { + type: 'object' as const, + optional: true as const, nullable: true as const, + }, + channelId: { + type: 'string' as const, + optional: true as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + channel: { + type: 'object' as const, + optional: true as const, nullable: true as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + name: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + }, + }, + }, + localOnly: { + type: 'boolean' as const, + optional: true as const, nullable: false as const, + }, + emojis: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + }, + }, + }, + reactions: { + type: 'object' as const, + optional: false as const, nullable: false as const, + }, + renoteCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + repliesCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + uri: { + type: 'string' as const, + optional: true as const, nullable: false as const, + }, + url: { + type: 'string' as const, + optional: true as const, nullable: false as const, + }, + + myReaction: { + type: 'object' as const, + optional: true as const, nullable: true as const, + }, + }, +}; diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts new file mode 100644 index 0000000000..d1cf9b087e --- /dev/null +++ b/packages/backend/src/models/repositories/notification.ts @@ -0,0 +1,175 @@ +import { EntityRepository, In, Repository } from 'typeorm'; +import { Users, Notes, UserGroupInvitations, AccessTokens, NoteReactions } from '../index'; +import { Notification } from '@/models/entities/notification'; +import { awaitAll } from '@/prelude/await-all'; +import { Packed } from '@/misc/schema'; +import { Note } from '@/models/entities/note'; +import { NoteReaction } from '@/models/entities/note-reaction'; +import { User } from '@/models/entities/user'; +import { aggregateNoteEmojis, prefetchEmojis } from '@/misc/populate-emojis'; +import { notificationTypes } from '@/types'; + +@EntityRepository(Notification) +export class NotificationRepository extends Repository<Notification> { + public async pack( + src: Notification['id'] | Notification, + options: { + _hintForEachNotes_?: { + myReactions: Map<Note['id'], NoteReaction | null>; + }; + } + ): Promise<Packed<'Notification'>> { + const notification = typeof src === 'object' ? src : await this.findOneOrFail(src); + const token = notification.appAccessTokenId ? await AccessTokens.findOneOrFail(notification.appAccessTokenId) : null; + + return await awaitAll({ + id: notification.id, + createdAt: notification.createdAt.toISOString(), + type: notification.type, + isRead: notification.isRead, + userId: notification.notifierId, + user: notification.notifierId ? Users.pack(notification.notifier || notification.notifierId) : null, + ...(notification.type === 'mention' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_ + }), + } : {}), + ...(notification.type === 'reply' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_ + }), + } : {}), + ...(notification.type === 'renote' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_ + }), + } : {}), + ...(notification.type === 'quote' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_ + }), + } : {}), + ...(notification.type === 'reaction' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_ + }), + reaction: notification.reaction + } : {}), + ...(notification.type === 'pollVote' ? { + note: Notes.pack(notification.note || notification.noteId!, { id: notification.notifieeId }, { + detail: true, + _hint_: options._hintForEachNotes_ + }), + choice: notification.choice + } : {}), + ...(notification.type === 'groupInvited' ? { + invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!), + } : {}), + ...(notification.type === 'app' ? { + body: notification.customBody, + header: notification.customHeader || token?.name, + icon: notification.customIcon || token?.iconUrl, + } : {}), + }); + } + + public async packMany( + notifications: Notification[], + meId: User['id'] + ) { + if (notifications.length === 0) return []; + + const notes = notifications.filter(x => x.note != null).map(x => x.note!); + const noteIds = notes.map(n => n.id); + const myReactionsMap = new Map<Note['id'], NoteReaction | null>(); + const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); + const targets = [...noteIds, ...renoteIds]; + const myReactions = await NoteReactions.find({ + userId: meId, + noteId: In(targets), + }); + + for (const target of targets) { + myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) || null); + } + + await prefetchEmojis(aggregateNoteEmojis(notes)); + + return await Promise.all(notifications.map(x => this.pack(x, { + _hintForEachNotes_: { + myReactions: myReactionsMap + } + }))); + } +} + +export const packedNotificationSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + isRead: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const, + enum: [...notificationTypes], + }, + user: { + type: 'object' as const, + ref: 'User' as const, + optional: true as const, nullable: true as const, + }, + userId: { + type: 'string' as const, + optional: true as const, nullable: true as const, + format: 'id', + }, + note: { + type: 'object' as const, + ref: 'Note' as const, + optional: true as const, nullable: true as const, + }, + reaction: { + type: 'string' as const, + optional: true as const, nullable: true as const, + }, + choice: { + type: 'number' as const, + optional: true as const, nullable: true as const, + }, + invitation: { + type: 'object' as const, + optional: true as const, nullable: true as const, + }, + body: { + type: 'string' as const, + optional: true as const, nullable: true as const, + }, + header: { + type: 'string' as const, + optional: true as const, nullable: true as const, + }, + icon: { + type: 'string' as const, + optional: true as const, nullable: true as const, + }, + } +}; diff --git a/packages/backend/src/models/repositories/page-like.ts b/packages/backend/src/models/repositories/page-like.ts new file mode 100644 index 0000000000..28f34254d9 --- /dev/null +++ b/packages/backend/src/models/repositories/page-like.ts @@ -0,0 +1,26 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { PageLike } from '@/models/entities/page-like'; +import { Pages } from '../index'; +import { User } from '@/models/entities/user'; + +@EntityRepository(PageLike) +export class PageLikeRepository extends Repository<PageLike> { + public async pack( + src: PageLike['id'] | PageLike, + me?: { id: User['id'] } | null | undefined + ) { + const like = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: like.id, + page: await Pages.pack(like.page || like.pageId, me), + }; + } + + public packMany( + likes: any[], + me: { id: User['id'] } + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} diff --git a/packages/backend/src/models/repositories/page.ts b/packages/backend/src/models/repositories/page.ts new file mode 100644 index 0000000000..3a3642d7ec --- /dev/null +++ b/packages/backend/src/models/repositories/page.ts @@ -0,0 +1,142 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Page } from '@/models/entities/page'; +import { Packed } from '@/misc/schema'; +import { Users, DriveFiles, PageLikes } from '../index'; +import { awaitAll } from '@/prelude/await-all'; +import { DriveFile } from '@/models/entities/drive-file'; +import { User } from '@/models/entities/user'; + +@EntityRepository(Page) +export class PageRepository extends Repository<Page> { + public async pack( + src: Page['id'] | Page, + me?: { id: User['id'] } | null | undefined, + ): Promise<Packed<'Page'>> { + const meId = me ? me.id : null; + const page = typeof src === 'object' ? src : await this.findOneOrFail(src); + + const attachedFiles: Promise<DriveFile | undefined>[] = []; + const collectFile = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'image') { + attachedFiles.push(DriveFiles.findOne({ + id: x.fileId, + userId: page.userId + })); + } + if (x.children) { + collectFile(x.children); + } + } + }; + collectFile(page.content); + + // 後方互換性のため + let migrated = false; + const migrate = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'input') { + if (x.inputType === 'text') { + x.type = 'textInput'; + } + if (x.inputType === 'number') { + x.type = 'numberInput'; + if (x.default) x.default = parseInt(x.default, 10); + } + migrated = true; + } + if (x.children) { + migrate(x.children); + } + } + }; + migrate(page.content); + if (migrated) { + this.update(page.id, { + content: page.content + }); + } + + return await awaitAll({ + id: page.id, + createdAt: page.createdAt.toISOString(), + updatedAt: page.updatedAt.toISOString(), + userId: page.userId, + user: Users.pack(page.user || page.userId, me), // { detail: true } すると無限ループするので注意 + content: page.content, + variables: page.variables, + title: page.title, + name: page.name, + summary: page.summary, + hideTitleWhenPinned: page.hideTitleWhenPinned, + alignCenter: page.alignCenter, + font: page.font, + script: page.script, + eyeCatchingImageId: page.eyeCatchingImageId, + eyeCatchingImage: page.eyeCatchingImageId ? await DriveFiles.pack(page.eyeCatchingImageId) : null, + attachedFiles: DriveFiles.packMany(await Promise.all(attachedFiles)), + likedCount: page.likedCount, + isLiked: meId ? await PageLikes.findOne({ pageId: page.id, userId: meId }).then(x => x != null) : undefined, + }); + } + + public packMany( + pages: Page[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(pages.map(x => this.pack(x, me))); + } +} + +export const packedPageSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + summary: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + content: { + type: 'array' as const, + optional: false as const, nullable: false as const, + }, + variables: { + type: 'array' as const, + optional: false as const, nullable: false as const, + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + user: { + type: 'object' as const, + ref: 'User' as const, + optional: false as const, nullable: false as const, + }, + } +}; diff --git a/packages/backend/src/models/repositories/queue.ts b/packages/backend/src/models/repositories/queue.ts new file mode 100644 index 0000000000..161751ddc8 --- /dev/null +++ b/packages/backend/src/models/repositories/queue.ts @@ -0,0 +1,30 @@ +export const packedQueueCountSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + waiting: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + active: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + completed: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + failed: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + delayed: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + paused: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } +}; diff --git a/packages/backend/src/models/repositories/relay.ts b/packages/backend/src/models/repositories/relay.ts new file mode 100644 index 0000000000..72ead899f1 --- /dev/null +++ b/packages/backend/src/models/repositories/relay.ts @@ -0,0 +1,6 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Relay } from '@/models/entities/relay'; + +@EntityRepository(Relay) +export class RelayRepository extends Repository<Relay> { +} diff --git a/packages/backend/src/models/repositories/signin.ts b/packages/backend/src/models/repositories/signin.ts new file mode 100644 index 0000000000..f375f9b5c0 --- /dev/null +++ b/packages/backend/src/models/repositories/signin.ts @@ -0,0 +1,11 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Signin } from '@/models/entities/signin'; + +@EntityRepository(Signin) +export class SigninRepository extends Repository<Signin> { + public async pack( + src: Signin, + ) { + return src; + } +} diff --git a/packages/backend/src/models/repositories/user-group-invitation.ts b/packages/backend/src/models/repositories/user-group-invitation.ts new file mode 100644 index 0000000000..638603d6ea --- /dev/null +++ b/packages/backend/src/models/repositories/user-group-invitation.ts @@ -0,0 +1,23 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { UserGroupInvitation } from '@/models/entities/user-group-invitation'; +import { UserGroups } from '../index'; + +@EntityRepository(UserGroupInvitation) +export class UserGroupInvitationRepository extends Repository<UserGroupInvitation> { + public async pack( + src: UserGroupInvitation['id'] | UserGroupInvitation, + ) { + const invitation = typeof src === 'object' ? src : await this.findOneOrFail(src); + + return { + id: invitation.id, + group: await UserGroups.pack(invitation.userGroup || invitation.userGroupId), + }; + } + + public packMany( + invitations: any[], + ) { + return Promise.all(invitations.map(x => this.pack(x))); + } +} diff --git a/packages/backend/src/models/repositories/user-group.ts b/packages/backend/src/models/repositories/user-group.ts new file mode 100644 index 0000000000..b38a2fb50d --- /dev/null +++ b/packages/backend/src/models/repositories/user-group.ts @@ -0,0 +1,61 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { UserGroup } from '@/models/entities/user-group'; +import { UserGroupJoinings } from '../index'; +import { Packed } from '@/misc/schema'; + +@EntityRepository(UserGroup) +export class UserGroupRepository extends Repository<UserGroup> { + public async pack( + src: UserGroup['id'] | UserGroup, + ): Promise<Packed<'UserGroup'>> { + const userGroup = typeof src === 'object' ? src : await this.findOneOrFail(src); + + const users = await UserGroupJoinings.find({ + userGroupId: userGroup.id + }); + + return { + id: userGroup.id, + createdAt: userGroup.createdAt.toISOString(), + name: userGroup.name, + ownerId: userGroup.userId, + userIds: users.map(x => x.userId) + }; + } +} + +export const packedUserGroupSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + ownerId: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + }, + userIds: { + type: 'array' as const, + nullable: false as const, optional: true as const, + items: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + } + }, + }, +}; diff --git a/packages/backend/src/models/repositories/user-list.ts b/packages/backend/src/models/repositories/user-list.ts new file mode 100644 index 0000000000..331c278e6f --- /dev/null +++ b/packages/backend/src/models/repositories/user-list.ts @@ -0,0 +1,55 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { UserList } from '@/models/entities/user-list'; +import { UserListJoinings } from '../index'; +import { Packed } from '@/misc/schema'; + +@EntityRepository(UserList) +export class UserListRepository extends Repository<UserList> { + public async pack( + src: UserList['id'] | UserList, + ): Promise<Packed<'UserList'>> { + const userList = typeof src === 'object' ? src : await this.findOneOrFail(src); + + const users = await UserListJoinings.find({ + userListId: userList.id + }); + + return { + id: userList.id, + createdAt: userList.createdAt.toISOString(), + name: userList.name, + userIds: users.map(x => x.userId) + }; + } +} + +export const packedUserListSchema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + userIds: { + type: 'array' as const, + nullable: false as const, optional: true as const, + items: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + } + }, + }, +}; diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts new file mode 100644 index 0000000000..fc0860970c --- /dev/null +++ b/packages/backend/src/models/repositories/user.ts @@ -0,0 +1,659 @@ +import $ from 'cafy'; +import { EntityRepository, Repository, In, Not } from 'typeorm'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user'; +import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings, Instances } from '../index'; +import config from '@/config/index'; +import { Packed } from '@/misc/schema'; +import { awaitAll } from '@/prelude/await-all'; +import { populateEmojis } from '@/misc/populate-emojis'; +import { getAntennas } from '@/misc/antenna-cache'; +import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const'; + +@EntityRepository(User) +export class UserRepository extends Repository<User> { + public async getRelation(me: User['id'], target: User['id']) { + const [following1, following2, followReq1, followReq2, toBlocking, fromBlocked, mute] = await Promise.all([ + Followings.findOne({ + followerId: me, + followeeId: target + }), + Followings.findOne({ + followerId: target, + followeeId: me + }), + FollowRequests.findOne({ + followerId: me, + followeeId: target + }), + FollowRequests.findOne({ + followerId: target, + followeeId: me + }), + Blockings.findOne({ + blockerId: me, + blockeeId: target + }), + Blockings.findOne({ + blockerId: target, + blockeeId: me + }), + Mutings.findOne({ + muterId: me, + muteeId: target + }) + ]); + + return { + id: target, + isFollowing: following1 != null, + hasPendingFollowRequestFromYou: followReq1 != null, + hasPendingFollowRequestToYou: followReq2 != null, + isFollowed: following2 != null, + isBlocking: toBlocking != null, + isBlocked: fromBlocked != null, + isMuted: mute != null + }; + } + + public async getHasUnreadMessagingMessage(userId: User['id']): Promise<boolean> { + const mute = await Mutings.find({ + muterId: userId + }); + + const joinings = await UserGroupJoinings.find({ userId: userId }); + + const groupQs = Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder('message') + .where(`message.groupId = :groupId`, { groupId: j.userGroupId }) + .andWhere('message.userId != :userId', { userId: userId }) + .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) + .andWhere('message.createdAt > :joinedAt', { joinedAt: j.createdAt }) // 自分が加入する前の会話については、未読扱いしない + .getOne().then(x => x != null))); + + const [withUser, withGroups] = await Promise.all([ + MessagingMessages.count({ + where: { + recipientId: userId, + isRead: false, + ...(mute.length > 0 ? { userId: Not(In(mute.map(x => x.muteeId))) } : {}), + }, + take: 1 + }).then(count => count > 0), + groupQs + ]); + + return withUser || withGroups.some(x => x); + } + + public async getHasUnreadAnnouncement(userId: User['id']): Promise<boolean> { + const reads = await AnnouncementReads.find({ + userId: userId + }); + + const count = await Announcements.count(reads.length > 0 ? { + id: Not(In(reads.map(read => read.announcementId))) + } : {}); + + return count > 0; + } + + public async getHasUnreadAntenna(userId: User['id']): Promise<boolean> { + const myAntennas = (await getAntennas()).filter(a => a.userId === userId); + + const unread = myAntennas.length > 0 ? await AntennaNotes.findOne({ + antennaId: In(myAntennas.map(x => x.id)), + read: false + }) : null; + + return unread != null; + } + + public async getHasUnreadChannel(userId: User['id']): Promise<boolean> { + const channels = await ChannelFollowings.find({ followerId: userId }); + + const unread = channels.length > 0 ? await NoteUnreads.findOne({ + userId: userId, + noteChannelId: In(channels.map(x => x.followeeId)), + }) : null; + + return unread != null; + } + + public async getHasUnreadNotification(userId: User['id']): Promise<boolean> { + const mute = await Mutings.find({ + muterId: userId + }); + const mutedUserIds = mute.map(m => m.muteeId); + + const count = await Notifications.count({ + where: { + notifieeId: userId, + ...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), + isRead: false + }, + take: 1 + }); + + return count > 0; + } + + public async getHasPendingReceivedFollowRequest(userId: User['id']): Promise<boolean> { + const count = await FollowRequests.count({ + followeeId: userId + }); + + return count > 0; + } + + public getOnlineStatus(user: User): string { + if (user.hideOnlineStatus) return 'unknown'; + if (user.lastActiveDate == null) return 'unknown'; + const elapsed = Date.now() - user.lastActiveDate.getTime(); + return ( + elapsed < USER_ONLINE_THRESHOLD ? 'online' : + elapsed < USER_ACTIVE_THRESHOLD ? 'active' : + 'offline' + ); + } + + public getAvatarUrl(user: User): string { + if (user.avatarUrl) { + return user.avatarUrl; + } else { + return `${config.url}/random-avatar/${user.id}`; + } + } + + public async pack( + src: User['id'] | User, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean, + includeSecrets?: boolean, + } + ): Promise<Packed<'User'>> { + const opts = Object.assign({ + detail: false, + includeSecrets: false + }, options); + + const user = typeof src === 'object' ? src : await this.findOneOrFail(src); + const meId = me ? me.id : null; + + const relation = meId && (meId !== user.id) && opts.detail ? await this.getRelation(meId, user.id) : null; + const pins = opts.detail ? await UserNotePinings.createQueryBuilder('pin') + .where('pin.userId = :userId', { userId: user.id }) + .innerJoinAndSelect('pin.note', 'note') + .orderBy('pin.id', 'DESC') + .getMany() : []; + const profile = opts.detail ? await UserProfiles.findOneOrFail(user.id) : null; + + const followingCount = profile == null ? null : + (profile.ffVisibility === 'public') || (meId === user.id) ? user.followingCount : + (profile.ffVisibility === 'followers') && (relation!.isFollowing) ? user.followingCount : + null; + + const followersCount = profile == null ? null : + (profile.ffVisibility === 'public') || (meId === user.id) ? user.followersCount : + (profile.ffVisibility === 'followers') && (relation!.isFollowing) ? user.followersCount : + null; + + const falsy = opts.detail ? false : undefined; + + const packed = { + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: this.getAvatarUrl(user), + avatarBlurhash: user.avatarBlurhash, + avatarColor: null, // 後方互換性のため + isAdmin: user.isAdmin || falsy, + isModerator: user.isModerator || falsy, + isBot: user.isBot || falsy, + isCat: user.isCat || falsy, + instance: user.host ? Instances.findOne({ host: user.host }).then(instance => instance ? { + name: instance.name, + softwareName: instance.softwareName, + softwareVersion: instance.softwareVersion, + iconUrl: instance.iconUrl, + faviconUrl: instance.faviconUrl, + themeColor: instance.themeColor, + } : undefined) : undefined, + emojis: populateEmojis(user.emojis, user.host), + onlineStatus: this.getOnlineStatus(user), + + ...(opts.detail ? { + url: profile!.url, + uri: user.uri, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, + lastFetchedAt: user.lastFetchedAt?.toISOString(), + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, + bannerColor: null, // 後方互換性のため + isLocked: user.isLocked, + isModerator: user.isModerator || falsy, + isSilenced: user.isSilenced || falsy, + isSuspended: user.isSuspended || falsy, + description: profile!.description, + location: profile!.location, + birthday: profile!.birthday, + lang: profile!.lang, + fields: profile!.fields, + followersCount: followersCount || 0, + followingCount: followingCount || 0, + notesCount: user.notesCount, + pinnedNoteIds: pins.map(pin => pin.noteId), + pinnedNotes: Notes.packMany(pins.map(pin => pin.note!), me, { + detail: true + }), + pinnedPageId: profile!.pinnedPageId, + pinnedPage: profile!.pinnedPageId ? Pages.pack(profile!.pinnedPageId, me) : null, + publicReactions: profile!.publicReactions, + ffVisibility: profile!.ffVisibility, + twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, + securityKeys: profile!.twoFactorEnabled + ? UserSecurityKeys.count({ + userId: user.id + }).then(result => result >= 1) + : false, + } : {}), + + ...(opts.detail && meId === user.id ? { + avatarId: user.avatarId, + bannerId: user.bannerId, + injectFeaturedNote: profile!.injectFeaturedNote, + receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, + alwaysMarkNsfw: profile!.alwaysMarkNsfw, + carefulBot: profile!.carefulBot, + autoAcceptFollowed: profile!.autoAcceptFollowed, + noCrawle: profile!.noCrawle, + isExplorable: user.isExplorable, + isDeleted: user.isDeleted, + hideOnlineStatus: user.hideOnlineStatus, + hasUnreadSpecifiedNotes: NoteUnreads.count({ + where: { userId: user.id, isSpecified: true }, + take: 1 + }).then(count => count > 0), + hasUnreadMentions: NoteUnreads.count({ + where: { userId: user.id, isMentioned: true }, + take: 1 + }).then(count => count > 0), + hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), + hasUnreadAntenna: this.getHasUnreadAntenna(user.id), + hasUnreadChannel: this.getHasUnreadChannel(user.id), + hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), + hasUnreadNotification: this.getHasUnreadNotification(user.id), + hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), + integrations: profile!.integrations, + mutedWords: profile!.mutedWords, + mutingNotificationTypes: profile!.mutingNotificationTypes, + emailNotificationTypes: profile!.emailNotificationTypes, + } : {}), + + ...(opts.includeSecrets ? { + email: profile!.email, + emailVerified: profile!.emailVerified, + securityKeysList: profile!.twoFactorEnabled + ? UserSecurityKeys.find({ + where: { + userId: user.id + }, + select: ['id', 'name', 'lastUsed'] + }) + : [] + } : {}), + + ...(relation ? { + isFollowing: relation.isFollowing, + isFollowed: relation.isFollowed, + hasPendingFollowRequestFromYou: relation.hasPendingFollowRequestFromYou, + hasPendingFollowRequestToYou: relation.hasPendingFollowRequestToYou, + isBlocking: relation.isBlocking, + isBlocked: relation.isBlocked, + isMuted: relation.isMuted, + } : {}) + }; + + return await awaitAll(packed); + } + + public packMany( + users: (User['id'] | User)[], + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean, + includeSecrets?: boolean, + } + ) { + return Promise.all(users.map(u => this.pack(u, me, options))); + } + + public isLocalUser(user: User): user is ILocalUser; + public isLocalUser<T extends { host: User['host'] }>(user: T): user is T & { host: null; }; + public isLocalUser(user: User | { host: User['host'] }): boolean { + return user.host == null; + } + + public isRemoteUser(user: User): user is IRemoteUser; + public isRemoteUser<T extends { host: User['host'] }>(user: T): user is T & { host: string; }; + public isRemoteUser(user: User | { host: User['host'] }): boolean { + return !this.isLocalUser(user); + } + + //#region Validators + public validateLocalUsername = $.str.match(/^\w{1,20}$/); + public validatePassword = $.str.min(1); + public validateName = $.str.min(1).max(50); + public validateDescription = $.str.min(1).max(500); + public validateLocation = $.str.min(1).max(50); + public validateBirthday = $.str.match(/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/); + //#endregion +} + +export const packedUserSchema = { + type: 'object' as const, + nullable: false as const, optional: false as const, + properties: { + id: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + name: { + type: 'string' as const, + nullable: true as const, optional: false as const, + example: '藍' + }, + username: { + type: 'string' as const, + nullable: false as const, optional: false as const, + example: 'ai' + }, + host: { + type: 'string' as const, + nullable: true as const, optional: false as const, + example: 'misskey.example.com' + }, + avatarUrl: { + type: 'string' as const, + format: 'url', + nullable: true as const, optional: false as const, + }, + avatarBlurhash: { + type: 'any' as const, + nullable: true as const, optional: false as const, + }, + avatarColor: { + type: 'any' as const, + nullable: true as const, optional: false as const, + default: null + }, + isAdmin: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + default: false + }, + isModerator: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + default: false + }, + isBot: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + isCat: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + emojis: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'object' as const, + nullable: false as const, optional: false as const, + properties: { + name: { + type: 'string' as const, + nullable: false as const, optional: false as const + }, + url: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'url' + }, + } + } + }, + url: { + type: 'string' as const, + format: 'url', + nullable: true as const, optional: true as const, + }, + createdAt: { + type: 'string' as const, + nullable: false as const, optional: true as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + nullable: true as const, optional: true as const, + format: 'date-time', + }, + bannerUrl: { + type: 'string' as const, + format: 'url', + nullable: true as const, optional: true as const, + }, + bannerBlurhash: { + type: 'any' as const, + nullable: true as const, optional: true as const, + }, + bannerColor: { + type: 'any' as const, + nullable: true as const, optional: true as const, + default: null + }, + isLocked: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + isSuspended: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + example: false + }, + description: { + type: 'string' as const, + nullable: true as const, optional: true as const, + example: 'Hi masters, I am Ai!' + }, + location: { + type: 'string' as const, + nullable: true as const, optional: true as const, + }, + birthday: { + type: 'string' as const, + nullable: true as const, optional: true as const, + example: '2018-03-12' + }, + fields: { + type: 'array' as const, + nullable: false as const, optional: true as const, + items: { + type: 'object' as const, + nullable: false as const, optional: false as const, + properties: { + name: { + type: 'string' as const, + nullable: false as const, optional: false as const + }, + value: { + type: 'string' as const, + nullable: false as const, optional: false as const + } + }, + maxLength: 4 + } + }, + followersCount: { + type: 'number' as const, + nullable: false as const, optional: true as const, + }, + followingCount: { + type: 'number' as const, + nullable: false as const, optional: true as const, + }, + notesCount: { + type: 'number' as const, + nullable: false as const, optional: true as const, + }, + pinnedNoteIds: { + type: 'array' as const, + nullable: false as const, optional: true as const, + items: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + } + }, + pinnedNotes: { + type: 'array' as const, + nullable: false as const, optional: true as const, + items: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'Note' as const, + } + }, + pinnedPageId: { + type: 'string' as const, + nullable: true as const, optional: true as const + }, + pinnedPage: { + type: 'object' as const, + nullable: true as const, optional: true as const, + ref: 'Page' as const, + }, + twoFactorEnabled: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + default: false + }, + usePasswordLessLogin: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + default: false + }, + securityKeys: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + default: false + }, + avatarId: { + type: 'string' as const, + nullable: true as const, optional: true as const, + format: 'id' + }, + bannerId: { + type: 'string' as const, + nullable: true as const, optional: true as const, + format: 'id' + }, + autoWatch: { + type: 'boolean' as const, + nullable: false as const, optional: true as const + }, + injectFeaturedNote: { + type: 'boolean' as const, + nullable: false as const, optional: true as const + }, + alwaysMarkNsfw: { + type: 'boolean' as const, + nullable: false as const, optional: true as const + }, + carefulBot: { + type: 'boolean' as const, + nullable: false as const, optional: true as const + }, + autoAcceptFollowed: { + type: 'boolean' as const, + nullable: false as const, optional: true as const + }, + hasUnreadSpecifiedNotes: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + hasUnreadMentions: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + hasUnreadAnnouncement: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + hasUnreadAntenna: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + hasUnreadChannel: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + hasUnreadMessagingMessage: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + hasUnreadNotification: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + hasPendingReceivedFollowRequest: { + type: 'boolean' as const, + nullable: false as const, optional: true as const, + }, + integrations: { + type: 'object' as const, + nullable: false as const, optional: true as const + }, + mutedWords: { + type: 'array' as const, + nullable: false as const, optional: true as const + }, + mutingNotificationTypes: { + type: 'array' as const, + nullable: false as const, optional: true as const + }, + isFollowing: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + hasPendingFollowRequestFromYou: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + hasPendingFollowRequestToYou: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + isFollowed: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + isBlocking: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + isBlocked: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + isMuted: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + } + }, +}; diff --git a/packages/backend/src/prelude/README.md b/packages/backend/src/prelude/README.md new file mode 100644 index 0000000000..bb728cfb1b --- /dev/null +++ b/packages/backend/src/prelude/README.md @@ -0,0 +1,3 @@ +# Prelude +このディレクトリのコードはJavaScriptの表現能力を補うためのコードです。 +Misskey固有の処理とは独立したコードの集まりですが、Misskeyのコードを読みやすくすることを目的としています。 diff --git a/packages/backend/src/prelude/array.ts b/packages/backend/src/prelude/array.ts new file mode 100644 index 0000000000..d63f0475d0 --- /dev/null +++ b/packages/backend/src/prelude/array.ts @@ -0,0 +1,138 @@ +import { EndoRelation, Predicate } from './relation'; + +/** + * Count the number of elements that satisfy the predicate + */ + +export function countIf<T>(f: Predicate<T>, xs: T[]): number { + return xs.filter(f).length; +} + +/** + * Count the number of elements that is equal to the element + */ +export function count<T>(a: T, xs: T[]): number { + return countIf(x => x === a, xs); +} + +/** + * Concatenate an array of arrays + */ +export function concat<T>(xss: T[][]): T[] { + return ([] as T[]).concat(...xss); +} + +/** + * Intersperse the element between the elements of the array + * @param sep The element to be interspersed + */ +export function intersperse<T>(sep: T, xs: T[]): T[] { + return concat(xs.map(x => [sep, x])).slice(1); +} + +/** + * Returns the array of elements that is not equal to the element + */ +export function erase<T>(a: T, xs: T[]): T[] { + return xs.filter(x => x !== a); +} + +/** + * Finds the array of all elements in the first array not contained in the second array. + * The order of result values are determined by the first array. + */ +export function difference<T>(xs: T[], ys: T[]): T[] { + return xs.filter(x => !ys.includes(x)); +} + +/** + * Remove all but the first element from every group of equivalent elements + */ +export function unique<T>(xs: T[]): T[] { + return [...new Set(xs)]; +} + +export function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} + +export function maximum(xs: number[]): number { + return Math.max(...xs); +} + +/** + * Splits an array based on the equivalence relation. + * The concatenation of the result is equal to the argument. + */ +export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] { + const groups = [] as T[][]; + for (const x of xs) { + if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { + groups[groups.length - 1].push(x); + } else { + groups.push([x]); + } + } + return groups; +} + +/** + * Splits an array based on the equivalence relation induced by the function. + * The concatenation of the result is equal to the argument. + */ +export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] { + return groupBy((a, b) => f(a) === f(b), xs); +} + +export function groupByX<T>(collections: T[], keySelector: (x: T) => string) { + return collections.reduce((obj: Record<string, T[]>, item: T) => { + const key = keySelector(item); + if (!obj.hasOwnProperty(key)) { + obj[key] = []; + } + + obj[key].push(item); + + return obj; + }, {}); +} + +/** + * Compare two arrays by lexicographical order + */ +export function lessThan(xs: number[], ys: number[]): boolean { + for (let i = 0; i < Math.min(xs.length, ys.length); i++) { + if (xs[i] < ys[i]) return true; + if (xs[i] > ys[i]) return false; + } + return xs.length < ys.length; +} + +/** + * Returns the longest prefix of elements that satisfy the predicate + */ +export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] { + const ys = []; + for (const x of xs) { + if (f(x)) { + ys.push(x); + } else { + break; + } + } + return ys; +} + +export function cumulativeSum(xs: number[]): number[] { + const ys = Array.from(xs); // deep copy + for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; + return ys; +} + +export function toArray<T>(x: T | T[] | undefined): T[] { + return Array.isArray(x) ? x : x != null ? [x] : []; +} + +export function toSingle<T>(x: T | T[] | undefined): T | undefined { + return Array.isArray(x) ? x[0] : x; +} diff --git a/packages/backend/src/prelude/await-all.ts b/packages/backend/src/prelude/await-all.ts new file mode 100644 index 0000000000..24795f3ae5 --- /dev/null +++ b/packages/backend/src/prelude/await-all.ts @@ -0,0 +1,23 @@ +type Await<T> = T extends Promise<infer U> ? U : T; + +type AwaitAll<T> = { + [P in keyof T]: Await<T[P]>; +}; + +export async function awaitAll<T>(obj: T): Promise<AwaitAll<T>> { + const target = {} as any; + const keys = Object.keys(obj); + const values = Object.values(obj); + + const resolvedValues = await Promise.all(values.map(value => + (!value || !value.constructor || value.constructor.name !== 'Object') + ? value + : awaitAll(value) + )); + + for (let i = 0; i < keys.length; i++) { + target[keys[i]] = resolvedValues[i]; + } + + return target; +} diff --git a/packages/backend/src/prelude/math.ts b/packages/backend/src/prelude/math.ts new file mode 100644 index 0000000000..07b94bec30 --- /dev/null +++ b/packages/backend/src/prelude/math.ts @@ -0,0 +1,3 @@ +export function gcd(a: number, b: number): number { + return b === 0 ? a : gcd(b, a % b); +} diff --git a/packages/backend/src/prelude/maybe.ts b/packages/backend/src/prelude/maybe.ts new file mode 100644 index 0000000000..0b4b543ca5 --- /dev/null +++ b/packages/backend/src/prelude/maybe.ts @@ -0,0 +1,20 @@ +export interface IMaybe<T> { + isJust(): this is IJust<T>; +} + +export interface IJust<T> extends IMaybe<T> { + get(): T; +} + +export function just<T>(value: T): IJust<T> { + return { + isJust: () => true, + get: () => value + }; +} + +export function nothing<T>(): IMaybe<T> { + return { + isJust: () => false, + }; +} diff --git a/packages/backend/src/prelude/relation.ts b/packages/backend/src/prelude/relation.ts new file mode 100644 index 0000000000..1f4703f52f --- /dev/null +++ b/packages/backend/src/prelude/relation.ts @@ -0,0 +1,5 @@ +export type Predicate<T> = (a: T) => boolean; + +export type Relation<T, U> = (a: T, b: U) => boolean; + +export type EndoRelation<T> = Relation<T, T>; diff --git a/packages/backend/src/prelude/string.ts b/packages/backend/src/prelude/string.ts new file mode 100644 index 0000000000..b907e0a2e1 --- /dev/null +++ b/packages/backend/src/prelude/string.ts @@ -0,0 +1,15 @@ +export function concat(xs: string[]): string { + return xs.join(''); +} + +export function capitalize(s: string): string { + return toUpperCase(s.charAt(0)) + toLowerCase(s.slice(1)); +} + +export function toUpperCase(s: string): string { + return s.toUpperCase(); +} + +export function toLowerCase(s: string): string { + return s.toLowerCase(); +} diff --git a/packages/backend/src/prelude/symbol.ts b/packages/backend/src/prelude/symbol.ts new file mode 100644 index 0000000000..51e12f7450 --- /dev/null +++ b/packages/backend/src/prelude/symbol.ts @@ -0,0 +1 @@ +export const fallback = Symbol('fallback'); diff --git a/packages/backend/src/prelude/time.ts b/packages/backend/src/prelude/time.ts new file mode 100644 index 0000000000..34e8b6b17c --- /dev/null +++ b/packages/backend/src/prelude/time.ts @@ -0,0 +1,39 @@ +const dateTimeIntervals = { + 'day': 86400000, + 'hour': 3600000, + 'ms': 1, +}; + +export function dateUTC(time: number[]): Date { + const d = time.length === 2 ? Date.UTC(time[0], time[1]) + : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) + : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) + : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) + : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) + : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) + : null; + + if (!d) throw 'wrong number of arguments'; + + return new Date(d); +} + +export function isTimeSame(a: Date, b: Date): boolean { + return a.getTime() === b.getTime(); +} + +export function isTimeBefore(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) < 0; +} + +export function isTimeAfter(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) > 0; +} + +export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() + (value * dateTimeIntervals[span])); +} + +export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() - (value * dateTimeIntervals[span])); +} diff --git a/packages/backend/src/prelude/url.ts b/packages/backend/src/prelude/url.ts new file mode 100644 index 0000000000..c7f2b7c1e7 --- /dev/null +++ b/packages/backend/src/prelude/url.ts @@ -0,0 +1,13 @@ +export function query(obj: {}): string { + const params = Object.entries(obj) + .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) + .reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>); + + return Object.entries(params) + .map((e) => `${e[0]}=${encodeURIComponent(e[1])}`) + .join('&'); +} + +export function appendQuery(url: string, query: string): string { + return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; +} diff --git a/packages/backend/src/prelude/xml.ts b/packages/backend/src/prelude/xml.ts new file mode 100644 index 0000000000..0773f75d47 --- /dev/null +++ b/packages/backend/src/prelude/xml.ts @@ -0,0 +1,41 @@ +const map: Record<string, string> = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''' +}; + +const beginingOfCDATA = '<![CDATA['; +const endOfCDATA = ']]>'; + +export function escapeValue(x: string): string { + let insideOfCDATA = false; + let builder = ''; + for ( + let i = 0; + i < x.length; + ) { + if (insideOfCDATA) { + if (x.slice(i, i + beginingOfCDATA.length) === beginingOfCDATA) { + insideOfCDATA = true; + i += beginingOfCDATA.length; + } else { + builder += x[i++]; + } + } else { + if (x.slice(i, i + endOfCDATA.length) === endOfCDATA) { + insideOfCDATA = false; + i += endOfCDATA.length; + } else { + const b = x[i++]; + builder += map[b] || b; + } + } + } + return builder; +} + +export function escapeAttribute(x: string): string { + return Object.entries(map).reduce((a, [k, v]) => a.replace(k, v), x); +} diff --git a/packages/backend/src/queue/get-job-info.ts b/packages/backend/src/queue/get-job-info.ts new file mode 100644 index 0000000000..f601ae62d0 --- /dev/null +++ b/packages/backend/src/queue/get-job-info.ts @@ -0,0 +1,15 @@ +import * as Bull from 'bull'; + +export function getJobInfo(job: Bull.Job, increment = false) { + const age = Date.now() - job.timestamp; + + const formated = age > 60000 ? `${Math.floor(age / 1000 / 60)}m` + : age > 10000 ? `${Math.floor(age / 1000)}s` + : `${age}ms`; + + // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする + const currentAttempts = job.attemptsMade + (increment ? 1 : 0); + const maxAttempts = job.opts ? job.opts.attempts : 0; + + return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; +} diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts new file mode 100644 index 0000000000..37eb809604 --- /dev/null +++ b/packages/backend/src/queue/index.ts @@ -0,0 +1,255 @@ +import * as httpSignature from 'http-signature'; + +import config from '@/config/index'; +import { envOption } from '../env'; + +import processDeliver from './processors/deliver'; +import processInbox from './processors/inbox'; +import processDb from './processors/db/index'; +import procesObjectStorage from './processors/object-storage/index'; +import { queueLogger } from './logger'; +import { DriveFile } from '@/models/entities/drive-file'; +import { getJobInfo } from './get-job-info'; +import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue } from './queues'; +import { ThinUser } from './types'; +import { IActivity } from '@/remote/activitypub/type'; + +function renderError(e: Error): any { + return { + stack: e?.stack, + message: e?.message, + name: e?.name + }; +} + +const systemLogger = queueLogger.createSubLogger('system'); +const deliverLogger = queueLogger.createSubLogger('deliver'); +const inboxLogger = queueLogger.createSubLogger('inbox'); +const dbLogger = queueLogger.createSubLogger('db'); +const objectStorageLogger = queueLogger.createSubLogger('objectStorage'); + +systemQueue + .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`)); + +deliverQueue + .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) + .on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); + +inboxQueue + .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) + .on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) + .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); + +dbQueue + .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); + +objectStorageQueue + .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); + +export function deliver(user: ThinUser, content: unknown, to: string | null) { + if (content == null) return null; + if (to == null) return null; + + const data = { + user: { + id: user.id + }, + content, + to + }; + + return deliverQueue.add(data, { + attempts: config.deliverJobMaxAttempts || 12, + timeout: 1 * 60 * 1000, // 1min + backoff: { + type: 'apBackoff' + }, + removeOnComplete: true, + removeOnFail: true + }); +} + +export function inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { + const data = { + activity: activity, + signature + }; + + return inboxQueue.add(data, { + attempts: config.inboxJobMaxAttempts || 8, + timeout: 5 * 60 * 1000, // 5min + backoff: { + type: 'apBackoff' + }, + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createDeleteDriveFilesJob(user: ThinUser) { + return dbQueue.add('deleteDriveFiles', { + user: user + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createExportNotesJob(user: ThinUser) { + return dbQueue.add('exportNotes', { + user: user + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createExportFollowingJob(user: ThinUser) { + return dbQueue.add('exportFollowing', { + user: user + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createExportMuteJob(user: ThinUser) { + return dbQueue.add('exportMute', { + user: user + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createExportBlockingJob(user: ThinUser) { + return dbQueue.add('exportBlocking', { + user: user + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createExportUserListsJob(user: ThinUser) { + return dbQueue.add('exportUserLists', { + user: user + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importFollowing', { + user: user, + fileId: fileId + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importMuting', { + user: user, + fileId: fileId + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importBlocking', { + user: user, + fileId: fileId + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importUserLists', { + user: user, + fileId: fileId + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { + return dbQueue.add('deleteAccount', { + user: user, + soft: opts.soft + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createDeleteObjectStorageFileJob(key: string) { + return objectStorageQueue.add('deleteFile', { + key: key + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createCleanRemoteFilesJob() { + return objectStorageQueue.add('cleanRemoteFiles', {}, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export default function() { + if (envOption.onlyServer) return; + + deliverQueue.process(config.deliverJobConcurrency || 128, processDeliver); + inboxQueue.process(config.inboxJobConcurrency || 16, processInbox); + processDb(dbQueue); + procesObjectStorage(objectStorageQueue); + + systemQueue.add('resyncCharts', { + }, { + repeat: { cron: '0 0 * * *' } + }); +} + +export function destroy() { + deliverQueue.once('cleaned', (jobs, status) => { + deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + deliverQueue.clean(0, 'delayed'); + + inboxQueue.once('cleaned', (jobs, status) => { + inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + inboxQueue.clean(0, 'delayed'); +} diff --git a/packages/backend/src/queue/initialize.ts b/packages/backend/src/queue/initialize.ts new file mode 100644 index 0000000000..31102a3ed2 --- /dev/null +++ b/packages/backend/src/queue/initialize.ts @@ -0,0 +1,33 @@ +import * as Bull from 'bull'; +import config from '@/config/index'; + +export function initialize<T>(name: string, limitPerSec = -1) { + return new Bull<T>(name, { + redis: { + port: config.redis.port, + host: config.redis.host, + password: config.redis.pass, + db: config.redis.db || 0, + }, + prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', + limiter: limitPerSec > 0 ? { + max: limitPerSec, + duration: 1000 + } : undefined, + settings: { + backoffStrategies: { + apBackoff + } + } + }); +} + +// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 +function apBackoff(attemptsMade: number, err: Error) { + const baseDelay = 60 * 1000; // 1min + const maxBackoff = 8 * 60 * 60 * 1000; // 8hours + let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; + backoff = Math.min(backoff, maxBackoff); + backoff += Math.round(backoff * Math.random() * 0.2); + return backoff; +} diff --git a/packages/backend/src/queue/logger.ts b/packages/backend/src/queue/logger.ts new file mode 100644 index 0000000000..f789b9d079 --- /dev/null +++ b/packages/backend/src/queue/logger.ts @@ -0,0 +1,3 @@ +import Logger from '@/services/logger'; + +export const queueLogger = new Logger('queue', 'orange'); diff --git a/packages/backend/src/queue/processors/db/delete-account.ts b/packages/backend/src/queue/processors/db/delete-account.ts new file mode 100644 index 0000000000..e54f38e35e --- /dev/null +++ b/packages/backend/src/queue/processors/db/delete-account.ts @@ -0,0 +1,94 @@ +import * as Bull from 'bull'; +import { queueLogger } from '../../logger'; +import { DriveFiles, Notes, UserProfiles, Users } from '@/models/index'; +import { DbUserDeleteJobData } from '@/queue/types'; +import { Note } from '@/models/entities/note'; +import { DriveFile } from '@/models/entities/drive-file'; +import { MoreThan } from 'typeorm'; +import { deleteFileSync } from '@/services/drive/delete-file'; +import { sendEmail } from '@/services/send-email'; + +const logger = queueLogger.createSubLogger('delete-account'); + +export async function deleteAccount(job: Bull.Job<DbUserDeleteJobData>): Promise<string | void> { + logger.info(`Deleting account of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + return; + } + + { // Delete notes + let cursor: Note['id'] | null = null; + + while (true) { + const notes = await Notes.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 100, + order: { + id: 1 + } + }); + + if (notes.length === 0) { + break; + } + + cursor = notes[notes.length - 1].id; + + await Notes.delete(notes.map(note => note.id)); + } + + logger.succ(`All of notes deleted`); + } + + { // Delete files + let cursor: DriveFile['id'] | null = null; + + while (true) { + const files = await DriveFiles.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 10, + order: { + id: 1 + } + }); + + if (files.length === 0) { + break; + } + + cursor = files[files.length - 1].id; + + for (const file of files) { + await deleteFileSync(file); + } + } + + logger.succ(`All of files deleted`); + } + + { // Send email notification + const profile = await UserProfiles.findOneOrFail(user.id); + if (profile.email && profile.emailVerified) { + sendEmail(profile.email, 'Account deleted', + `Your account has been deleted.`, + `Your account has been deleted.`); + } + } + + // soft指定されている場合は物理削除しない + if (job.data.soft) { + // nop + } else { + await Users.delete(job.data.user.id); + } + + return 'Account deleted'; +} diff --git a/packages/backend/src/queue/processors/db/delete-drive-files.ts b/packages/backend/src/queue/processors/db/delete-drive-files.ts new file mode 100644 index 0000000000..8a28468b0d --- /dev/null +++ b/packages/backend/src/queue/processors/db/delete-drive-files.ts @@ -0,0 +1,56 @@ +import * as Bull from 'bull'; + +import { queueLogger } from '../../logger'; +import { deleteFileSync } from '@/services/drive/delete-file'; +import { Users, DriveFiles } from '@/models/index'; +import { MoreThan } from 'typeorm'; +import { DbUserJobData } from '@/queue/types'; + +const logger = queueLogger.createSubLogger('delete-drive-files'); + +export async function deleteDriveFiles(job: Bull.Job<DbUserJobData>, done: any): Promise<void> { + logger.info(`Deleting drive files of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + let deletedCount = 0; + let cursor: any = null; + + while (true) { + const files = await DriveFiles.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 100, + order: { + id: 1 + } + }); + + if (files.length === 0) { + job.progress(100); + break; + } + + cursor = files[files.length - 1].id; + + for (const file of files) { + await deleteFileSync(file); + deletedCount++; + } + + const total = await DriveFiles.count({ + userId: user.id, + }); + + job.progress(deletedCount / total); + } + + logger.succ(`All drive files (${deletedCount}) of ${user.id} has been deleted.`); + done(); +} diff --git a/packages/backend/src/queue/processors/db/export-blocking.ts b/packages/backend/src/queue/processors/db/export-blocking.ts new file mode 100644 index 0000000000..8b8aa259d4 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-blocking.ts @@ -0,0 +1,94 @@ +import * as Bull from 'bull'; +import * as tmp from 'tmp'; +import * as fs from 'fs'; + +import { queueLogger } from '../../logger'; +import addFile from '@/services/drive/add-file'; +import * as dateFormat from 'dateformat'; +import { getFullApAccount } from '@/misc/convert-host'; +import { Users, Blockings } from '@/models/index'; +import { MoreThan } from 'typeorm'; +import { DbUserJobData } from '@/queue/types'; + +const logger = queueLogger.createSubLogger('export-blocking'); + +export async function exportBlocking(job: Bull.Job<DbUserJobData>, done: any): Promise<void> { + logger.info(`Exporting blocking of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await new Promise<[string, any]>((res, rej) => { + tmp.file((e, path, fd, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); + + logger.info(`Temp file is ${path}`); + + const stream = fs.createWriteStream(path, { flags: 'a' }); + + let exportedCount = 0; + let cursor: any = null; + + while (true) { + const blockings = await Blockings.find({ + where: { + blockerId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 100, + order: { + id: 1 + } + }); + + if (blockings.length === 0) { + job.progress(100); + break; + } + + cursor = blockings[blockings.length - 1].id; + + for (const block of blockings) { + const u = await Users.findOne({ id: block.blockeeId }); + if (u == null) { + exportedCount++; continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise<void>((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedCount++; + } + + const total = await Blockings.count({ + blockerId: user.id, + }); + + job.progress(exportedCount / total); + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-mm-dd-HH-MM-ss') + '.csv'; + const driveFile = await addFile(user, path, fileName, null, null, true); + + logger.succ(`Exported to: ${driveFile.id}`); + cleanup(); + done(); +} diff --git a/packages/backend/src/queue/processors/db/export-following.ts b/packages/backend/src/queue/processors/db/export-following.ts new file mode 100644 index 0000000000..a0ecf5f560 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-following.ts @@ -0,0 +1,94 @@ +import * as Bull from 'bull'; +import * as tmp from 'tmp'; +import * as fs from 'fs'; + +import { queueLogger } from '../../logger'; +import addFile from '@/services/drive/add-file'; +import * as dateFormat from 'dateformat'; +import { getFullApAccount } from '@/misc/convert-host'; +import { Users, Followings } from '@/models/index'; +import { MoreThan } from 'typeorm'; +import { DbUserJobData } from '@/queue/types'; + +const logger = queueLogger.createSubLogger('export-following'); + +export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: any): Promise<void> { + logger.info(`Exporting following of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await new Promise<[string, any]>((res, rej) => { + tmp.file((e, path, fd, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); + + logger.info(`Temp file is ${path}`); + + const stream = fs.createWriteStream(path, { flags: 'a' }); + + let exportedCount = 0; + let cursor: any = null; + + while (true) { + const followings = await Followings.find({ + where: { + followerId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 100, + order: { + id: 1 + } + }); + + if (followings.length === 0) { + job.progress(100); + break; + } + + cursor = followings[followings.length - 1].id; + + for (const following of followings) { + const u = await Users.findOne({ id: following.followeeId }); + if (u == null) { + exportedCount++; continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise<void>((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedCount++; + } + + const total = await Followings.count({ + followerId: user.id, + }); + + job.progress(exportedCount / total); + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'following-' + dateFormat(new Date(), 'yyyy-mm-dd-HH-MM-ss') + '.csv'; + const driveFile = await addFile(user, path, fileName, null, null, true); + + logger.succ(`Exported to: ${driveFile.id}`); + cleanup(); + done(); +} diff --git a/packages/backend/src/queue/processors/db/export-mute.ts b/packages/backend/src/queue/processors/db/export-mute.ts new file mode 100644 index 0000000000..d5976f7d56 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-mute.ts @@ -0,0 +1,94 @@ +import * as Bull from 'bull'; +import * as tmp from 'tmp'; +import * as fs from 'fs'; + +import { queueLogger } from '../../logger'; +import addFile from '@/services/drive/add-file'; +import * as dateFormat from 'dateformat'; +import { getFullApAccount } from '@/misc/convert-host'; +import { Users, Mutings } from '@/models/index'; +import { MoreThan } from 'typeorm'; +import { DbUserJobData } from '@/queue/types'; + +const logger = queueLogger.createSubLogger('export-mute'); + +export async function exportMute(job: Bull.Job<DbUserJobData>, done: any): Promise<void> { + logger.info(`Exporting mute of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await new Promise<[string, any]>((res, rej) => { + tmp.file((e, path, fd, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); + + logger.info(`Temp file is ${path}`); + + const stream = fs.createWriteStream(path, { flags: 'a' }); + + let exportedCount = 0; + let cursor: any = null; + + while (true) { + const mutes = await Mutings.find({ + where: { + muterId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 100, + order: { + id: 1 + } + }); + + if (mutes.length === 0) { + job.progress(100); + break; + } + + cursor = mutes[mutes.length - 1].id; + + for (const mute of mutes) { + const u = await Users.findOne({ id: mute.muteeId }); + if (u == null) { + exportedCount++; continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise<void>((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedCount++; + } + + const total = await Mutings.count({ + muterId: user.id, + }); + + job.progress(exportedCount / total); + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-mm-dd-HH-MM-ss') + '.csv'; + const driveFile = await addFile(user, path, fileName, null, null, true); + + logger.succ(`Exported to: ${driveFile.id}`); + cleanup(); + done(); +} diff --git a/packages/backend/src/queue/processors/db/export-notes.ts b/packages/backend/src/queue/processors/db/export-notes.ts new file mode 100644 index 0000000000..49850aa706 --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-notes.ts @@ -0,0 +1,133 @@ +import * as Bull from 'bull'; +import * as tmp from 'tmp'; +import * as fs from 'fs'; + +import { queueLogger } from '../../logger'; +import addFile from '@/services/drive/add-file'; +import * as dateFormat from 'dateformat'; +import { Users, Notes, Polls } from '@/models/index'; +import { MoreThan } from 'typeorm'; +import { Note } from '@/models/entities/note'; +import { Poll } from '@/models/entities/poll'; +import { DbUserJobData } from '@/queue/types'; + +const logger = queueLogger.createSubLogger('export-notes'); + +export async function exportNotes(job: Bull.Job<DbUserJobData>, done: any): Promise<void> { + logger.info(`Exporting notes of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + // Create temp file + const [path, cleanup] = await new Promise<[string, any]>((res, rej) => { + tmp.file((e, path, fd, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); + + logger.info(`Temp file is ${path}`); + + const stream = fs.createWriteStream(path, { flags: 'a' }); + + await new Promise<void>((res, rej) => { + stream.write('[', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + + let exportedNotesCount = 0; + let cursor: any = null; + + while (true) { + const notes = await Notes.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 100, + order: { + id: 1 + } + }); + + if (notes.length === 0) { + job.progress(100); + break; + } + + cursor = notes[notes.length - 1].id; + + for (const note of notes) { + let poll: Poll | undefined; + if (note.hasPoll) { + poll = await Polls.findOneOrFail({ noteId: note.id }); + } + const content = JSON.stringify(serialize(note, poll)); + await new Promise<void>((res, rej) => { + stream.write(exportedNotesCount === 0 ? content : ',\n' + content, err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedNotesCount++; + } + + const total = await Notes.count({ + userId: user.id, + }); + + job.progress(exportedNotesCount / total); + } + + await new Promise<void>((res, rej) => { + stream.write(']', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-mm-dd-HH-MM-ss') + '.json'; + const driveFile = await addFile(user, path, fileName, null, null, true); + + logger.succ(`Exported to: ${driveFile.id}`); + cleanup(); + done(); +} + +function serialize(note: Note, poll: Poll | null = null): any { + return { + id: note.id, + text: note.text, + createdAt: note.createdAt, + fileIds: note.fileIds, + replyId: note.replyId, + renoteId: note.renoteId, + poll: poll, + cw: note.cw, + viaMobile: note.viaMobile, + visibility: note.visibility, + visibleUserIds: note.visibleUserIds, + localOnly: note.localOnly + }; +} diff --git a/packages/backend/src/queue/processors/db/export-user-lists.ts b/packages/backend/src/queue/processors/db/export-user-lists.ts new file mode 100644 index 0000000000..8a86c4df5d --- /dev/null +++ b/packages/backend/src/queue/processors/db/export-user-lists.ts @@ -0,0 +1,71 @@ +import * as Bull from 'bull'; +import * as tmp from 'tmp'; +import * as fs from 'fs'; + +import { queueLogger } from '../../logger'; +import addFile from '@/services/drive/add-file'; +import * as dateFormat from 'dateformat'; +import { getFullApAccount } from '@/misc/convert-host'; +import { Users, UserLists, UserListJoinings } from '@/models/index'; +import { In } from 'typeorm'; +import { DbUserJobData } from '@/queue/types'; + +const logger = queueLogger.createSubLogger('export-user-lists'); + +export async function exportUserLists(job: Bull.Job<DbUserJobData>, done: any): Promise<void> { + logger.info(`Exporting user lists of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + const lists = await UserLists.find({ + userId: user.id + }); + + // Create temp file + const [path, cleanup] = await new Promise<[string, any]>((res, rej) => { + tmp.file((e, path, fd, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); + + logger.info(`Temp file is ${path}`); + + const stream = fs.createWriteStream(path, { flags: 'a' }); + + for (const list of lists) { + const joinings = await UserListJoinings.find({ userListId: list.id }); + const users = await Users.find({ + id: In(joinings.map(j => j.userId)) + }); + + for (const u of users) { + const acct = getFullApAccount(u.username, u.host); + const content = `${list.name},${acct}`; + await new Promise<void>((res, rej) => { + stream.write(content + '\n', err => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + } + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-mm-dd-HH-MM-ss') + '.csv'; + const driveFile = await addFile(user, path, fileName, null, null, true); + + logger.succ(`Exported to: ${driveFile.id}`); + cleanup(); + done(); +} diff --git a/packages/backend/src/queue/processors/db/import-blocking.ts b/packages/backend/src/queue/processors/db/import-blocking.ts new file mode 100644 index 0000000000..2e77107034 --- /dev/null +++ b/packages/backend/src/queue/processors/db/import-blocking.ts @@ -0,0 +1,74 @@ +import * as Bull from 'bull'; + +import { queueLogger } from '../../logger'; +import * as Acct from 'misskey-js/built/acct'; +import { resolveUser } from '@/remote/resolve-user'; +import { downloadTextFile } from '@/misc/download-text-file'; +import { isSelfHost, toPuny } from '@/misc/convert-host'; +import { Users, DriveFiles, Blockings } from '@/models/index'; +import { DbUserImportJobData } from '@/queue/types'; +import block from '@/services/blocking/create'; + +const logger = queueLogger.createSubLogger('import-blocking'); + +export async function importBlocking(job: Bull.Job<DbUserImportJobData>, done: any): Promise<void> { + logger.info(`Importing blocking of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOne({ + id: job.data.fileId + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) ? await Users.findOne({ + host: null, + usernameLower: username.toLowerCase() + }) : await Users.findOne({ + host: toPuny(host!), + usernameLower: username.toLowerCase() + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Block[${linenum}] ${target.id} ...`); + + await block(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} + diff --git a/packages/backend/src/queue/processors/db/import-following.ts b/packages/backend/src/queue/processors/db/import-following.ts new file mode 100644 index 0000000000..2bd079e4bc --- /dev/null +++ b/packages/backend/src/queue/processors/db/import-following.ts @@ -0,0 +1,73 @@ +import * as Bull from 'bull'; + +import { queueLogger } from '../../logger'; +import follow from '@/services/following/create'; +import * as Acct from 'misskey-js/built/acct'; +import { resolveUser } from '@/remote/resolve-user'; +import { downloadTextFile } from '@/misc/download-text-file'; +import { isSelfHost, toPuny } from '@/misc/convert-host'; +import { Users, DriveFiles } from '@/models/index'; +import { DbUserImportJobData } from '@/queue/types'; + +const logger = queueLogger.createSubLogger('import-following'); + +export async function importFollowing(job: Bull.Job<DbUserImportJobData>, done: any): Promise<void> { + logger.info(`Importing following of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOne({ + id: job.data.fileId + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) ? await Users.findOne({ + host: null, + usernameLower: username.toLowerCase() + }) : await Users.findOne({ + host: toPuny(host!), + usernameLower: username.toLowerCase() + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Follow[${linenum}] ${target.id} ...`); + + follow(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} diff --git a/packages/backend/src/queue/processors/db/import-muting.ts b/packages/backend/src/queue/processors/db/import-muting.ts new file mode 100644 index 0000000000..8060980625 --- /dev/null +++ b/packages/backend/src/queue/processors/db/import-muting.ts @@ -0,0 +1,83 @@ +import * as Bull from 'bull'; + +import { queueLogger } from '../../logger'; +import * as Acct from 'misskey-js/built/acct'; +import { resolveUser } from '@/remote/resolve-user'; +import { downloadTextFile } from '@/misc/download-text-file'; +import { isSelfHost, toPuny } from '@/misc/convert-host'; +import { Users, DriveFiles, Mutings } from '@/models/index'; +import { DbUserImportJobData } from '@/queue/types'; +import { User } from '@/models/entities/user'; +import { genId } from '@/misc/gen-id'; + +const logger = queueLogger.createSubLogger('import-muting'); + +export async function importMuting(job: Bull.Job<DbUserImportJobData>, done: any): Promise<void> { + logger.info(`Importing muting of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOne({ + id: job.data.fileId + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) ? await Users.findOne({ + host: null, + usernameLower: username.toLowerCase() + }) : await Users.findOne({ + host: toPuny(host!), + usernameLower: username.toLowerCase() + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Mute[${linenum}] ${target.id} ...`); + + await mute(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} + +async function mute(user: User, target: User) { + await Mutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: user.id, + muteeId: target.id, + }); +} diff --git a/packages/backend/src/queue/processors/db/import-user-lists.ts b/packages/backend/src/queue/processors/db/import-user-lists.ts new file mode 100644 index 0000000000..46b728b387 --- /dev/null +++ b/packages/backend/src/queue/processors/db/import-user-lists.ts @@ -0,0 +1,80 @@ +import * as Bull from 'bull'; + +import { queueLogger } from '../../logger'; +import * as Acct from 'misskey-js/built/acct'; +import { resolveUser } from '@/remote/resolve-user'; +import { pushUserToUserList } from '@/services/user-list/push'; +import { downloadTextFile } from '@/misc/download-text-file'; +import { isSelfHost, toPuny } from '@/misc/convert-host'; +import { DriveFiles, Users, UserLists, UserListJoinings } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { DbUserImportJobData } from '@/queue/types'; + +const logger = queueLogger.createSubLogger('import-user-lists'); + +export async function importUserLists(job: Bull.Job<DbUserImportJobData>, done: any): Promise<void> { + logger.info(`Importing user lists of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOne({ + id: job.data.fileId + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const listName = line.split(',')[0].trim(); + const { username, host } = Acct.parse(line.split(',')[1].trim()); + + let list = await UserLists.findOne({ + userId: user.id, + name: listName + }); + + if (list == null) { + list = await UserLists.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: listName, + userIds: [] + }); + } + + let target = isSelfHost(host!) ? await Users.findOne({ + host: null, + usernameLower: username.toLowerCase() + }) : await Users.findOne({ + host: toPuny(host!), + usernameLower: username.toLowerCase() + }); + + if (target == null) { + target = await resolveUser(username, host); + } + + if (await UserListJoinings.findOne({ userListId: list.id, userId: target.id }) != null) continue; + + pushUserToUserList(target, list); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} diff --git a/packages/backend/src/queue/processors/db/index.ts b/packages/backend/src/queue/processors/db/index.ts new file mode 100644 index 0000000000..97087642b7 --- /dev/null +++ b/packages/backend/src/queue/processors/db/index.ts @@ -0,0 +1,33 @@ +import * as Bull from 'bull'; +import { DbJobData } from '@/queue/types'; +import { deleteDriveFiles } from './delete-drive-files'; +import { exportNotes } from './export-notes'; +import { exportFollowing } from './export-following'; +import { exportMute } from './export-mute'; +import { exportBlocking } from './export-blocking'; +import { exportUserLists } from './export-user-lists'; +import { importFollowing } from './import-following'; +import { importUserLists } from './import-user-lists'; +import { deleteAccount } from './delete-account'; +import { importMuting } from './import-muting'; +import { importBlocking } from './import-blocking'; + +const jobs = { + deleteDriveFiles, + exportNotes, + exportFollowing, + exportMute, + exportBlocking, + exportUserLists, + importFollowing, + importMuting, + importBlocking, + importUserLists, + deleteAccount, +} as Record<string, Bull.ProcessCallbackFunction<DbJobData> | Bull.ProcessPromiseFunction<DbJobData>>; + +export default function(dbQueue: Bull.Queue<DbJobData>) { + for (const [k, v] of Object.entries(jobs)) { + dbQueue.process(k, v); + } +} diff --git a/packages/backend/src/queue/processors/deliver.ts b/packages/backend/src/queue/processors/deliver.ts new file mode 100644 index 0000000000..3c61896a2f --- /dev/null +++ b/packages/backend/src/queue/processors/deliver.ts @@ -0,0 +1,94 @@ +import { URL } from 'url'; +import * as Bull from 'bull'; +import request from '@/remote/activitypub/request'; +import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc'; +import Logger from '@/services/logger'; +import { Instances } from '@/models/index'; +import { instanceChart } from '@/services/chart/index'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { toPuny } from '@/misc/convert-host'; +import { Cache } from '@/misc/cache'; +import { Instance } from '@/models/entities/instance'; +import { DeliverJobData } from '../types'; +import { StatusError } from '@/misc/fetch'; + +const logger = new Logger('deliver'); + +let latest: string | null = null; + +const suspendedHostsCache = new Cache<Instance[]>(1000 * 60 * 60); + +export default async (job: Bull.Job<DeliverJobData>) => { + const { host } = new URL(job.data.to); + + // ブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(toPuny(host))) { + return 'skip (blocked)'; + } + + // isSuspendedなら中断 + let suspendedHosts = suspendedHostsCache.get(null); + if (suspendedHosts == null) { + suspendedHosts = await Instances.find({ + where: { + isSuspended: true + }, + }); + suspendedHostsCache.set(null, suspendedHosts); + } + if (suspendedHosts.map(x => x.host).includes(toPuny(host))) { + return 'skip (suspended)'; + } + + try { + if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) { + logger.debug(`delivering ${latest}`); + } + + await request(job.data.user, job.data.to, job.data.content); + + // Update stats + registerOrFetchInstanceDoc(host).then(i => { + Instances.update(i.id, { + latestRequestSentAt: new Date(), + latestStatus: 200, + lastCommunicatedAt: new Date(), + isNotResponding: false + }); + + fetchInstanceMetadata(i); + + instanceChart.requestSent(i.host, true); + }); + + return 'Success'; + } catch (res) { + // Update stats + registerOrFetchInstanceDoc(host).then(i => { + Instances.update(i.id, { + latestRequestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : null, + isNotResponding: true + }); + + instanceChart.requestSent(i.host, false); + }); + + if (res instanceof StatusError) { + // 4xx + if (res.isClientError) { + // HTTPステータスコード4xxはクライアントエラーであり、それはつまり + // 何回再送しても成功することはないということなのでエラーにはしないでおく + return `${res.statusCode} ${res.statusMessage}`; + } + + // 5xx etc. + throw `${res.statusCode} ${res.statusMessage}`; + } else { + // DNS error, socket error, timeout ... + throw res; + } + } +}; diff --git a/packages/backend/src/queue/processors/inbox.ts b/packages/backend/src/queue/processors/inbox.ts new file mode 100644 index 0000000000..4032ce8653 --- /dev/null +++ b/packages/backend/src/queue/processors/inbox.ts @@ -0,0 +1,149 @@ +import { URL } from 'url'; +import * as Bull from 'bull'; +import * as httpSignature from 'http-signature'; +import perform from '@/remote/activitypub/perform'; +import Logger from '@/services/logger'; +import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc'; +import { Instances } from '@/models/index'; +import { instanceChart } from '@/services/chart/index'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { toPuny, extractDbHost } from '@/misc/convert-host'; +import { getApId } from '@/remote/activitypub/type'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata'; +import { InboxJobData } from '../types'; +import DbResolver from '@/remote/activitypub/db-resolver'; +import { resolvePerson } from '@/remote/activitypub/models/person'; +import { LdSignature } from '@/remote/activitypub/misc/ld-signature'; +import { StatusError } from '@/misc/fetch'; + +const logger = new Logger('inbox'); + +// ユーザーのinboxにアクティビティが届いた時の処理 +export default async (job: Bull.Job<InboxJobData>): Promise<string> => { + const signature = job.data.signature; // HTTP-signature + const activity = job.data.activity; + + //#region Log + const info = Object.assign({}, activity) as any; + delete info['@context']; + logger.debug(JSON.stringify(info, null, 2)); + //#endregion + + const host = toPuny(new URL(signature.keyId).hostname); + + // ブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(host)) { + return `Blocked request: ${host}`; + } + + const keyIdLower = signature.keyId.toLowerCase(); + if (keyIdLower.startsWith('acct:')) { + return `Old keyId is no longer supported. ${keyIdLower}`; + } + + // TDOO: キャッシュ + const dbResolver = new DbResolver(); + + // HTTP-Signature keyIdを元にDBから取得 + let authUser = await dbResolver.getAuthUserFromKeyId(signature.keyId); + + // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 + if (authUser == null) { + try { + authUser = await dbResolver.getAuthUserFromApId(getApId(activity.actor)); + } catch (e) { + // 対象が4xxならスキップ + if (e instanceof StatusError && e.isClientError) { + return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`; + } + throw `Error in actor ${activity.actor} - ${e.statusCode || e}`; + } + } + + // それでもわからなければ終了 + if (authUser == null) { + return `skip: failed to resolve user`; + } + + // publicKey がなくても終了 + if (authUser.key == null) { + return `skip: failed to resolve user publicKey`; + } + + // HTTP-Signatureの検証 + const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + + // また、signatureのsignerは、activity.actorと一致する必要がある + if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { + // 一致しなくても、でもLD-Signatureがありそうならそっちも見る + if (activity.signature) { + if (activity.signature.type !== 'RsaSignature2017') { + return `skip: unsupported LD-signature type ${activity.signature.type}`; + } + + // activity.signature.creator: https://example.oom/users/user#main-key + // みたいになっててUserを引っ張れば公開キーも入ることを期待する + if (activity.signature.creator) { + const candicate = activity.signature.creator.replace(/#.*/, ''); + await resolvePerson(candicate).catch(() => null); + } + + // keyIdからLD-Signatureのユーザーを取得 + authUser = await dbResolver.getAuthUserFromKeyId(activity.signature.creator); + if (authUser == null) { + return `skip: LD-Signatureのユーザーが取得できませんでした`; + } + + if (authUser.key == null) { + return `skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした`; + } + + // LD-Signature検証 + const ldSignature = new LdSignature(); + const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); + if (!verified) { + return `skip: LD-Signatureの検証に失敗しました`; + } + + // もう一度actorチェック + if (authUser.user.uri !== activity.actor) { + return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`; + } + + // ブロックしてたら中断 + const ldHost = extractDbHost(authUser.user.uri); + if (meta.blockedHosts.includes(ldHost)) { + return `Blocked request: ${ldHost}`; + } + } else { + return `skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`; + } + } + + // activity.idがあればホストが署名者のホストであることを確認する + if (typeof activity.id === 'string') { + const signerHost = extractDbHost(authUser.user.uri!); + const activityIdHost = extractDbHost(activity.id); + if (signerHost !== activityIdHost) { + return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`; + } + } + + // Update stats + registerOrFetchInstanceDoc(authUser.user.host).then(i => { + Instances.update(i.id, { + latestRequestReceivedAt: new Date(), + lastCommunicatedAt: new Date(), + isNotResponding: false + }); + + fetchInstanceMetadata(i); + + instanceChart.requestReceived(i.host); + }); + + // アクティビティを処理 + await perform(authUser.user, activity); + return `ok`; +}; diff --git a/packages/backend/src/queue/processors/object-storage/clean-remote-files.ts b/packages/backend/src/queue/processors/object-storage/clean-remote-files.ts new file mode 100644 index 0000000000..3b2e4ea939 --- /dev/null +++ b/packages/backend/src/queue/processors/object-storage/clean-remote-files.ts @@ -0,0 +1,50 @@ +import * as Bull from 'bull'; + +import { queueLogger } from '../../logger'; +import { deleteFileSync } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; +import { MoreThan, Not, IsNull } from 'typeorm'; + +const logger = queueLogger.createSubLogger('clean-remote-files'); + +export default async function cleanRemoteFiles(job: Bull.Job<{}>, done: any): Promise<void> { + logger.info(`Deleting cached remote files...`); + + let deletedCount = 0; + let cursor: any = null; + + while (true) { + const files = await DriveFiles.find({ + where: { + userHost: Not(IsNull()), + isLink: false, + ...(cursor ? { id: MoreThan(cursor) } : {}) + }, + take: 8, + order: { + id: 1 + } + }); + + if (files.length === 0) { + job.progress(100); + break; + } + + cursor = files[files.length - 1].id; + + await Promise.all(files.map(file => deleteFileSync(file, true))); + + deletedCount += 8; + + const total = await DriveFiles.count({ + userHost: Not(IsNull()), + isLink: false, + }); + + job.progress(deletedCount / total); + } + + logger.succ(`All cahced remote files has been deleted.`); + done(); +} diff --git a/packages/backend/src/queue/processors/object-storage/delete-file.ts b/packages/backend/src/queue/processors/object-storage/delete-file.ts new file mode 100644 index 0000000000..ed22968a27 --- /dev/null +++ b/packages/backend/src/queue/processors/object-storage/delete-file.ts @@ -0,0 +1,11 @@ +import { ObjectStorageFileJobData } from '@/queue/types'; +import * as Bull from 'bull'; +import { deleteObjectStorageFile } from '@/services/drive/delete-file'; + +export default async (job: Bull.Job<ObjectStorageFileJobData>) => { + const key: string = job.data.key; + + await deleteObjectStorageFile(key); + + return 'Success'; +}; diff --git a/packages/backend/src/queue/processors/object-storage/index.ts b/packages/backend/src/queue/processors/object-storage/index.ts new file mode 100644 index 0000000000..0d9570e179 --- /dev/null +++ b/packages/backend/src/queue/processors/object-storage/index.ts @@ -0,0 +1,15 @@ +import * as Bull from 'bull'; +import { ObjectStorageJobData } from '@/queue/types'; +import deleteFile from './delete-file'; +import cleanRemoteFiles from './clean-remote-files'; + +const jobs = { + deleteFile, + cleanRemoteFiles, +} as Record<string, Bull.ProcessCallbackFunction<ObjectStorageJobData> | Bull.ProcessPromiseFunction<ObjectStorageJobData>>; + +export default function(q: Bull.Queue) { + for (const [k, v] of Object.entries(jobs)) { + q.process(k, 16, v); + } +} diff --git a/packages/backend/src/queue/processors/system/index.ts b/packages/backend/src/queue/processors/system/index.ts new file mode 100644 index 0000000000..52b7868105 --- /dev/null +++ b/packages/backend/src/queue/processors/system/index.ts @@ -0,0 +1,12 @@ +import * as Bull from 'bull'; +import { resyncCharts } from './resync-charts'; + +const jobs = { + resyncCharts, +} as Record<string, Bull.ProcessCallbackFunction<{}> | Bull.ProcessPromiseFunction<{}>>; + +export default function(dbQueue: Bull.Queue<{}>) { + for (const [k, v] of Object.entries(jobs)) { + dbQueue.process(k, v); + } +} diff --git a/packages/backend/src/queue/processors/system/resync-charts.ts b/packages/backend/src/queue/processors/system/resync-charts.ts new file mode 100644 index 0000000000..b36b024cfb --- /dev/null +++ b/packages/backend/src/queue/processors/system/resync-charts.ts @@ -0,0 +1,21 @@ +import * as Bull from 'bull'; + +import { queueLogger } from '../../logger'; +import { driveChart, notesChart, usersChart } from '@/services/chart/index'; + +const logger = queueLogger.createSubLogger('resync-charts'); + +export default async function resyncCharts(job: Bull.Job<{}>, done: any): Promise<void> { + logger.info(`Resync charts...`); + + // TODO: ユーザーごとのチャートも更新する + // TODO: インスタンスごとのチャートも更新する + await Promise.all([ + driveChart.resync(), + notesChart.resync(), + usersChart.resync(), + ]); + + logger.succ(`All charts successfully resynced.`); + done(); +} diff --git a/packages/backend/src/queue/queues.ts b/packages/backend/src/queue/queues.ts new file mode 100644 index 0000000000..a66a7ca451 --- /dev/null +++ b/packages/backend/src/queue/queues.ts @@ -0,0 +1,9 @@ +import config from '@/config/index'; +import { initialize as initializeQueue } from './initialize'; +import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData } from './types'; + +export const systemQueue = initializeQueue<{}>('system'); +export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec || 128); +export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16); +export const dbQueue = initializeQueue<DbJobData>('db'); +export const objectStorageQueue = initializeQueue<ObjectStorageJobData>('objectStorage'); diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts new file mode 100644 index 0000000000..39cab29966 --- /dev/null +++ b/packages/backend/src/queue/types.ts @@ -0,0 +1,44 @@ +import { DriveFile } from '@/models/entities/drive-file'; +import { User } from '@/models/entities/user'; +import { IActivity } from '@/remote/activitypub/type'; +import * as httpSignature from 'http-signature'; + +export type DeliverJobData = { + /** Actor */ + user: ThinUser; + /** Activity */ + content: unknown; + /** inbox URL to deliver */ + to: string; +}; + +export type InboxJobData = { + activity: IActivity; + signature: httpSignature.IParsedSignature; +}; + +export type DbJobData = DbUserJobData | DbUserImportJobData | DbUserDeleteJobData; + +export type DbUserJobData = { + user: ThinUser; +}; + +export type DbUserDeleteJobData = { + user: ThinUser; + soft?: boolean; +}; + +export type DbUserImportJobData = { + user: ThinUser; + fileId: DriveFile['id']; +}; + +export type ObjectStorageJobData = ObjectStorageFileJobData | {}; + +export type ObjectStorageFileJobData = { + key: string; +}; + +export type ThinUser = { + id: User['id']; +}; diff --git a/packages/backend/src/remote/activitypub/ap-request.ts b/packages/backend/src/remote/activitypub/ap-request.ts new file mode 100644 index 0000000000..76a3857140 --- /dev/null +++ b/packages/backend/src/remote/activitypub/ap-request.ts @@ -0,0 +1,104 @@ +import * as crypto from 'crypto'; +import { URL } from 'url'; + +type Request = { + url: string; + method: string; + headers: Record<string, string>; +}; + +type PrivateKey = { + privateKeyPem: string; + keyId: string; +}; + +export function createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }) { + const u = new URL(args.url); + const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`; + + const request: Request = { + url: u.href, + method: 'POST', + headers: objectAssignWithLcKey({ + 'Date': new Date().toUTCString(), + 'Host': u.hostname, + 'Content-Type': 'application/activity+json', + 'Digest': digestHeader, + }, args.additionalHeaders), + }; + + const result = signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; +} + +export function createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }) { + const u = new URL(args.url); + + const request: Request = { + url: u.href, + method: 'GET', + headers: objectAssignWithLcKey({ + 'Accept': 'application/activity+json, application/ld+json', + 'Date': new Date().toUTCString(), + 'Host': new URL(args.url).hostname, + }, args.additionalHeaders), + }; + + const result = signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; +} + +function signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]) { + const signingString = genSigningString(request, includeHeaders); + const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); + const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; + + request.headers = objectAssignWithLcKey(request.headers, { + Signature: signatureHeader + }); + + return { + request, + signingString, + signature, + signatureHeader, + }; +} + +function genSigningString(request: Request, includeHeaders: string[]) { + request.headers = lcObjectKey(request.headers); + + const results: string[] = []; + + for (const key of includeHeaders.map(x => x.toLowerCase())) { + if (key === '(request-target)') { + results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`); + } else { + results.push(`${key}: ${request.headers[key]}`); + } + } + + return results.join('\n'); +} + +function lcObjectKey(src: Record<string, string>) { + const dst: Record<string, string> = {}; + for (const key of Object.keys(src).filter(x => x != '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; + return dst; +} + +function objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>) { + return Object.assign(lcObjectKey(a), lcObjectKey(b)); +} diff --git a/packages/backend/src/remote/activitypub/audience.ts b/packages/backend/src/remote/activitypub/audience.ts new file mode 100644 index 0000000000..3d2dab1459 --- /dev/null +++ b/packages/backend/src/remote/activitypub/audience.ts @@ -0,0 +1,92 @@ +import { ApObject, getApIds } from './type'; +import Resolver from './resolver'; +import { resolvePerson } from './models/person'; +import { unique, concat } from '@/prelude/array'; +import * as promiseLimit from 'promise-limit'; +import { User, IRemoteUser } from '@/models/entities/user'; + +type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +type AudienceInfo = { + visibility: Visibility, + mentionedUsers: User[], + visibleUsers: User[], +}; + +export async function parseAudience(actor: IRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise<AudienceInfo> { + const toGroups = groupingAudience(getApIds(to), actor); + const ccGroups = groupingAudience(getApIds(cc), actor); + + const others = unique(concat([toGroups.other, ccGroups.other])); + + const limit = promiseLimit<User | null>(2); + const mentionedUsers = (await Promise.all( + others.map(id => limit(() => resolvePerson(id, resolver).catch(() => null))) + )).filter((x): x is User => x != null); + + if (toGroups.public.length > 0) { + return { + visibility: 'public', + mentionedUsers, + visibleUsers: [] + }; + } + + if (ccGroups.public.length > 0) { + return { + visibility: 'home', + mentionedUsers, + visibleUsers: [] + }; + } + + if (toGroups.followers.length > 0) { + return { + visibility: 'followers', + mentionedUsers, + visibleUsers: [] + }; + } + + return { + visibility: 'specified', + mentionedUsers, + visibleUsers: mentionedUsers + }; +} + +function groupingAudience(ids: string[], actor: IRemoteUser) { + const groups = { + public: [] as string[], + followers: [] as string[], + other: [] as string[], + }; + + for (const id of ids) { + if (isPublic(id)) { + groups.public.push(id); + } else if (isFollowers(id, actor)) { + groups.followers.push(id); + } else { + groups.other.push(id); + } + } + + groups.other = unique(groups.other); + + return groups; +} + +function isPublic(id: string) { + return [ + 'https://www.w3.org/ns/activitystreams#Public', + 'as#Public', + 'Public', + ].includes(id); +} + +function isFollowers(id: string, actor: IRemoteUser) { + return ( + id === (actor.followersUri || `${actor.uri}/followers`) + ); +} diff --git a/packages/backend/src/remote/activitypub/db-resolver.ts b/packages/backend/src/remote/activitypub/db-resolver.ts new file mode 100644 index 0000000000..289b6f0ee8 --- /dev/null +++ b/packages/backend/src/remote/activitypub/db-resolver.ts @@ -0,0 +1,140 @@ +import config from '@/config/index'; +import { Note } from '@/models/entities/note'; +import { User, IRemoteUser } from '@/models/entities/user'; +import { UserPublickey } from '@/models/entities/user-publickey'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { Notes, Users, UserPublickeys, MessagingMessages } from '@/models/index'; +import { IObject, getApId } from './type'; +import { resolvePerson } from './models/person'; +import escapeRegexp = require('escape-regexp'); + +export default class DbResolver { + constructor() { + } + + /** + * AP Note => Misskey Note in DB + */ + public async getNoteFromApId(value: string | IObject): Promise<Note | null> { + const parsed = this.parseUri(value); + + if (parsed.id) { + return (await Notes.findOne({ + id: parsed.id + })) || null; + } + + if (parsed.uri) { + return (await Notes.findOne({ + uri: parsed.uri + })) || null; + } + + return null; + } + + public async getMessageFromApId(value: string | IObject): Promise<MessagingMessage | null> { + const parsed = this.parseUri(value); + + if (parsed.id) { + return (await MessagingMessages.findOne({ + id: parsed.id + })) || null; + } + + if (parsed.uri) { + return (await MessagingMessages.findOne({ + uri: parsed.uri + })) || null; + } + + return null; + } + + /** + * AP Person => Misskey User in DB + */ + public async getUserFromApId(value: string | IObject): Promise<User | null> { + const parsed = this.parseUri(value); + + if (parsed.id) { + return (await Users.findOne({ + id: parsed.id + })) || null; + } + + if (parsed.uri) { + return (await Users.findOne({ + uri: parsed.uri + })) || null; + } + + return null; + } + + /** + * AP KeyId => Misskey User and Key + */ + public async getAuthUserFromKeyId(keyId: string): Promise<AuthUser | null> { + const key = await UserPublickeys.findOne({ + keyId + }); + + if (key == null) return null; + + const user = await Users.findOne(key.userId) as IRemoteUser; + + return { + user, + key + }; + } + + /** + * AP Actor id => Misskey User and Key + */ + public async getAuthUserFromApId(uri: string): Promise<AuthUser | null> { + const user = await resolvePerson(uri) as IRemoteUser; + + if (user == null) return null; + + const key = await UserPublickeys.findOne(user.id); + + return { + user, + key + }; + } + + public parseUri(value: string | IObject): UriParseResult { + const uri = getApId(value); + + const localRegex = new RegExp('^' + escapeRegexp(config.url) + '/' + '(\\w+)' + '/' + '(\\w+)'); + const matchLocal = uri.match(localRegex); + + if (matchLocal) { + return { + type: matchLocal[1], + id: matchLocal[2] + }; + } else { + return { + uri + }; + } + } +} + +export type AuthUser = { + user: IRemoteUser; + key?: UserPublickey; +}; + +type UriParseResult = { + /** id in DB (local object only) */ + id?: string; + /** uri in DB (remote object only) */ + uri?: string; + /** hint of type (local object only, ex: notes, users) */ + type?: string +}; diff --git a/packages/backend/src/remote/activitypub/deliver-manager.ts b/packages/backend/src/remote/activitypub/deliver-manager.ts new file mode 100644 index 0000000000..d37f97a447 --- /dev/null +++ b/packages/backend/src/remote/activitypub/deliver-manager.ts @@ -0,0 +1,131 @@ +import { Users, Followings } from '@/models/index'; +import { ILocalUser, IRemoteUser, User } from '@/models/entities/user'; +import { deliver } from '@/queue/index'; + +//#region types +interface IRecipe { + type: string; +} + +interface IFollowersRecipe extends IRecipe { + type: 'Followers'; +} + +interface IDirectRecipe extends IRecipe { + type: 'Direct'; + to: IRemoteUser; +} + +const isFollowers = (recipe: any): recipe is IFollowersRecipe => + recipe.type === 'Followers'; + +const isDirect = (recipe: any): recipe is IDirectRecipe => + recipe.type === 'Direct'; +//#endregion + +export default class DeliverManager { + private actor: { id: User['id']; host: null; }; + private activity: any; + private recipes: IRecipe[] = []; + + /** + * Constructor + * @param actor Actor + * @param activity Activity to deliver + */ + constructor(actor: { id: User['id']; host: null; }, activity: any) { + this.actor = actor; + this.activity = activity; + } + + /** + * Add recipe for followers deliver + */ + public addFollowersRecipe() { + const deliver = { + type: 'Followers' + } as IFollowersRecipe; + + this.addRecipe(deliver); + } + + /** + * Add recipe for direct deliver + * @param to To + */ + public addDirectRecipe(to: IRemoteUser) { + const recipe = { + type: 'Direct', + to + } as IDirectRecipe; + + this.addRecipe(recipe); + } + + /** + * Add recipe + * @param recipe Recipe + */ + public addRecipe(recipe: IRecipe) { + this.recipes.push(recipe); + } + + /** + * Execute delivers + */ + public async execute() { + if (!Users.isLocalUser(this.actor)) return; + + const inboxes = new Set<string>(); + + // build inbox list + for (const recipe of this.recipes) { + if (isFollowers(recipe)) { + // followers deliver + const followers = await Followings.find({ + followeeId: this.actor.id + }); + + for (const following of followers) { + if (Followings.isRemoteFollower(following)) { + const inbox = following.followerSharedInbox || following.followerInbox; + inboxes.add(inbox); + } + } + } else if (isDirect(recipe)) { + // direct deliver + const inbox = recipe.to.inbox; + if (inbox) inboxes.add(inbox); + } + } + + // deliver + for (const inbox of inboxes) { + deliver(this.actor, this.activity, inbox); + } + } +} + +//#region Utilities +/** + * Deliver activity to followers + * @param activity Activity + * @param from Followee + */ +export async function deliverToFollowers(actor: ILocalUser, activity: any) { + const manager = new DeliverManager(actor, activity); + manager.addFollowersRecipe(); + await manager.execute(); +} + +/** + * Deliver activity to user + * @param activity Activity + * @param to Target user + */ +export async function deliverToUser(actor: ILocalUser, activity: any, to: IRemoteUser) { + const manager = new DeliverManager(actor, activity); + manager.addDirectRecipe(to); + await manager.execute(); +} +//#endregion diff --git a/packages/backend/src/remote/activitypub/kernel/accept/follow.ts b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts new file mode 100644 index 0000000000..1afb733ab5 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts @@ -0,0 +1,29 @@ +import { IRemoteUser } from '@/models/entities/user'; +import accept from '@/services/following/requests/accept'; +import { IFollow } from '../../type'; +import DbResolver from '../../db-resolver'; +import { relayAccepted } from '@/services/relay'; + +export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => { + // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある + + const dbResolver = new DbResolver(); + const follower = await dbResolver.getUserFromApId(activity.actor); + + if (follower == null) { + return `skip: follower not found`; + } + + if (follower.host != null) { + return `skip: follower is not a local user`; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await relayAccepted(match[1]); + } + + await accept(actor, follower); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/accept/index.ts b/packages/backend/src/remote/activitypub/kernel/accept/index.ts new file mode 100644 index 0000000000..5c6f81b2e3 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/accept/index.ts @@ -0,0 +1,24 @@ +import Resolver from '../../resolver'; +import { IRemoteUser } from '@/models/entities/user'; +import acceptFollow from './follow'; +import { IAccept, isFollow, getApType } from '../../type'; +import { apLogger } from '../../logger'; + +const logger = apLogger; + +export default async (actor: IRemoteUser, activity: IAccept): Promise<string> => { + const uri = activity.id || activity; + + logger.info(`Accept: ${uri}`); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await acceptFollow(actor, object); + + return `skip: Unknown Accept type: ${getApType(object)}`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/add/index.ts b/packages/backend/src/remote/activitypub/kernel/add/index.ts new file mode 100644 index 0000000000..b33be0cc85 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/add/index.ts @@ -0,0 +1,23 @@ +import { IRemoteUser } from '@/models/entities/user'; +import { IAdd } from '../../type'; +import { resolveNote } from '../../models/note'; +import { addPinned } from '@/services/i/pin'; + +export default async (actor: IRemoteUser, activity: IAdd): Promise<void> => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await resolveNote(activity.object); + if (note == null) throw new Error('note not found'); + await addPinned(actor, note.id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/announce/index.ts b/packages/backend/src/remote/activitypub/kernel/announce/index.ts new file mode 100644 index 0000000000..581357e577 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/announce/index.ts @@ -0,0 +1,19 @@ +import Resolver from '../../resolver'; +import { IRemoteUser } from '@/models/entities/user'; +import announceNote from './note'; +import { IAnnounce, getApId } from '../../type'; +import { apLogger } from '../../logger'; + +const logger = apLogger; + +export default async (actor: IRemoteUser, activity: IAnnounce): Promise<void> => { + const uri = getApId(activity); + + logger.info(`Announce: ${uri}`); + + const resolver = new Resolver(); + + const targetUri = getApId(activity.object); + + announceNote(resolver, actor, activity, targetUri); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/announce/note.ts b/packages/backend/src/remote/activitypub/kernel/announce/note.ts new file mode 100644 index 0000000000..5230867f24 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/announce/note.ts @@ -0,0 +1,67 @@ +import Resolver from '../../resolver'; +import post from '@/services/note/create'; +import { IRemoteUser } from '@/models/entities/user'; +import { IAnnounce, getApId } from '../../type'; +import { fetchNote, resolveNote } from '../../models/note'; +import { apLogger } from '../../logger'; +import { extractDbHost } from '@/misc/convert-host'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { getApLock } from '@/misc/app-lock'; +import { parseAudience } from '../../audience'; +import { StatusError } from '@/misc/fetch'; + +const logger = apLogger; + +/** + * アナウンスアクティビティを捌きます + */ +export default async function(resolver: Resolver, actor: IRemoteUser, activity: IAnnounce, targetUri: string): Promise<void> { + const uri = getApId(activity); + + // アナウンサーが凍結されていたらスキップ + if (actor.isSuspended) { + return; + } + + // アナウンス先をブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(extractDbHost(uri))) return; + + const unlock = await getApLock(uri); + + try { + // 既に同じURIを持つものが登録されていないかチェック + const exist = await fetchNote(uri); + if (exist) { + return; + } + + // Announce対象をresolve + let renote; + try { + renote = await resolveNote(targetUri); + } catch (e) { + // 対象が4xxならスキップ + if (e instanceof StatusError && e.isClientError) { + logger.warn(`Ignored announce target ${targetUri} - ${e.statusCode}`); + return; + } + logger.warn(`Error in announce target ${targetUri} - ${e.statusCode || e}`); + throw e; + } + + logger.info(`Creating the (Re)Note: ${uri}`); + + const activityAudience = await parseAudience(actor, activity.to, activity.cc); + + await post(actor, { + createdAt: activity.published ? new Date(activity.published) : null, + renote, + visibility: activityAudience.visibility, + visibleUsers: activityAudience.visibleUsers, + uri + }); + } finally { + unlock(); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/block/index.ts b/packages/backend/src/remote/activitypub/kernel/block/index.ts new file mode 100644 index 0000000000..4fd1e07b9b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/block/index.ts @@ -0,0 +1,22 @@ +import { IBlock } from '../../type'; +import block from '@/services/blocking/create'; +import { IRemoteUser } from '@/models/entities/user'; +import DbResolver from '../../db-resolver'; + +export default async (actor: IRemoteUser, activity: IBlock): Promise<string> => { + // ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず + + const dbResolver = new DbResolver(); + const blockee = await dbResolver.getUserFromApId(activity.object); + + if (blockee == null) { + return `skip: blockee not found`; + } + + if (blockee.host != null) { + return `skip: ブロックしようとしているユーザーはローカルユーザーではありません`; + } + + await block(actor, blockee); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/create/index.ts b/packages/backend/src/remote/activitypub/kernel/create/index.ts new file mode 100644 index 0000000000..ce039a363b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/create/index.ts @@ -0,0 +1,43 @@ +import Resolver from '../../resolver'; +import { IRemoteUser } from '@/models/entities/user'; +import createNote from './note'; +import { ICreate, getApId, isPost, getApType } from '../../type'; +import { apLogger } from '../../logger'; +import { toArray, concat, unique } from '@/prelude/array'; + +const logger = apLogger; + +export default async (actor: IRemoteUser, activity: ICreate): Promise<void> => { + const uri = getApId(activity); + + logger.info(`Create: ${uri}`); + + // copy audiences between activity <=> object. + if (typeof activity.object === 'object') { + const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); + const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)])); + + activity.to = to; + activity.cc = cc; + activity.object.to = to; + activity.object.cc = cc; + } + + // If there is no attributedTo, use Activity actor. + if (typeof activity.object === 'object' && !activity.object.attributedTo) { + activity.object.attributedTo = activity.actor; + } + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isPost(object)) { + createNote(resolver, actor, object, false, activity); + } else { + logger.warn(`Unknown type: ${getApType(object)}`); + } +}; diff --git a/packages/backend/src/remote/activitypub/kernel/create/note.ts b/packages/backend/src/remote/activitypub/kernel/create/note.ts new file mode 100644 index 0000000000..14e311e4cd --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/create/note.ts @@ -0,0 +1,44 @@ +import Resolver from '../../resolver'; +import { IRemoteUser } from '@/models/entities/user'; +import { createNote, fetchNote } from '../../models/note'; +import { getApId, IObject, ICreate } from '../../type'; +import { getApLock } from '@/misc/app-lock'; +import { extractDbHost } from '@/misc/convert-host'; +import { StatusError } from '@/misc/fetch'; + +/** + * 投稿作成アクティビティを捌きます + */ +export default async function(resolver: Resolver, actor: IRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise<string> { + const uri = getApId(note); + + if (typeof note === 'object') { + if (actor.uri !== note.attributedTo) { + return `skip: actor.uri !== note.attributedTo`; + } + + if (typeof note.id === 'string') { + if (extractDbHost(actor.uri) !== extractDbHost(note.id)) { + return `skip: host in actor.uri !== note.id`; + } + } + } + + const unlock = await getApLock(uri); + + try { + const exist = await fetchNote(note); + if (exist) return 'skip: note exists'; + + await createNote(note, resolver, silent); + return 'ok'; + } catch (e) { + if (e instanceof StatusError && e.isClientError) { + return `skip ${e.statusCode}`; + } else { + throw e; + } + } finally { + unlock(); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/delete/actor.ts b/packages/backend/src/remote/activitypub/kernel/delete/actor.ts new file mode 100644 index 0000000000..502f8d5ab5 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/delete/actor.ts @@ -0,0 +1,26 @@ +import { apLogger } from '../../logger'; +import { createDeleteAccountJob } from '@/queue'; +import { IRemoteUser } from '@/models/entities/user'; +import { Users } from '@/models/index'; + +const logger = apLogger; + +export async function deleteActor(actor: IRemoteUser, uri: string): Promise<string> { + logger.info(`Deleting the Actor: ${uri}`); + + if (actor.uri !== uri) { + return `skip: delete actor ${actor.uri} !== ${uri}`; + } + + if (actor.isDeleted) { + logger.info(`skip: already deleted`); + } + + const job = await createDeleteAccountJob(actor); + + await Users.update(actor.id, { + isDeleted: true, + }); + + return `ok: queued ${job.name} ${job.id}`; +} diff --git a/packages/backend/src/remote/activitypub/kernel/delete/index.ts b/packages/backend/src/remote/activitypub/kernel/delete/index.ts new file mode 100644 index 0000000000..86a452de76 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/delete/index.ts @@ -0,0 +1,49 @@ +import deleteNote from './note'; +import { IRemoteUser } from '@/models/entities/user'; +import { IDelete, getApId, isTombstone, IObject, validPost, validActor } from '../../type'; +import { toSingle } from '@/prelude/array'; +import { deleteActor } from './actor'; + +/** + * 削除アクティビティを捌きます + */ +export default async (actor: IRemoteUser, activity: IDelete): Promise<string> => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + // 削除対象objectのtype + let formarType: string | undefined; + + if (typeof activity.object === 'string') { + // typeが不明だけど、どうせ消えてるのでremote resolveしない + formarType = undefined; + } else { + const object = activity.object as IObject; + if (isTombstone(object)) { + formarType = toSingle(object.formerType); + } else { + formarType = toSingle(object.type); + } + } + + const uri = getApId(activity.object); + + // type不明でもactorとobjectが同じならばそれはPersonに違いない + if (!formarType && actor.uri === uri) { + formarType = 'Person'; + } + + // それでもなかったらおそらくNote + if (!formarType) { + formarType = 'Note'; + } + + if (validPost.includes(formarType)) { + return await deleteNote(actor, uri); + } else if (validActor.includes(formarType)) { + return await deleteActor(actor, uri); + } else { + return `Unknown type ${formarType}`; + } +}; diff --git a/packages/backend/src/remote/activitypub/kernel/delete/note.ts b/packages/backend/src/remote/activitypub/kernel/delete/note.ts new file mode 100644 index 0000000000..3875a33d13 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/delete/note.ts @@ -0,0 +1,41 @@ +import { IRemoteUser } from '@/models/entities/user'; +import deleteNode from '@/services/note/delete'; +import { apLogger } from '../../logger'; +import DbResolver from '../../db-resolver'; +import { getApLock } from '@/misc/app-lock'; +import { deleteMessage } from '@/services/messages/delete'; + +const logger = apLogger; + +export default async function(actor: IRemoteUser, uri: string): Promise<string> { + logger.info(`Deleting the Note: ${uri}`); + + const unlock = await getApLock(uri); + + try { + const dbResolver = new DbResolver(); + const note = await dbResolver.getNoteFromApId(uri); + + if (note == null) { + const message = await dbResolver.getMessageFromApId(uri); + if (message == null) return 'message not found'; + + if (message.userId !== actor.id) { + return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; + } + + await deleteMessage(message); + + return 'ok: message deleted'; + } + + if (note.userId !== actor.id) { + return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; + } + + await deleteNode(actor, note); + return 'ok: note deleted'; + } finally { + unlock(); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/flag/index.ts b/packages/backend/src/remote/activitypub/kernel/flag/index.ts new file mode 100644 index 0000000000..7abfd694cd --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/flag/index.ts @@ -0,0 +1,30 @@ +import { IRemoteUser } from '@/models/entities/user'; +import config from '@/config/index'; +import { IFlag, getApIds } from '../../type'; +import { AbuseUserReports, Users } from '@/models/index'; +import { In } from 'typeorm'; +import { genId } from '@/misc/gen-id'; + +export default async (actor: IRemoteUser, activity: IFlag): Promise<string> => { + // objectは `(User|Note) | (User|Note)[]` だけど、全パターンDBスキーマと対応させられないので + // 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する + const uris = getApIds(activity.object); + + const userIds = uris.filter(uri => uri.startsWith(config.url + '/users/')).map(uri => uri.split('/').pop()); + const users = await Users.find({ + id: In(userIds) + }); + if (users.length < 1) return `skip`; + + await AbuseUserReports.insert({ + id: genId(), + createdAt: new Date(), + targetUserId: users[0].id, + targetUserHost: users[0].host, + reporterId: actor.id, + reporterHost: actor.host, + comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}` + }); + + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/follow.ts b/packages/backend/src/remote/activitypub/kernel/follow.ts new file mode 100644 index 0000000000..3183207afa --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/follow.ts @@ -0,0 +1,20 @@ +import { IRemoteUser } from '@/models/entities/user'; +import follow from '@/services/following/create'; +import { IFollow } from '../type'; +import DbResolver from '../db-resolver'; + +export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => { + const dbResolver = new DbResolver(); + const followee = await dbResolver.getUserFromApId(activity.object); + + if (followee == null) { + return `skip: followee not found`; + } + + if (followee.host != null) { + return `skip: フォローしようとしているユーザーはローカルユーザーではありません`; + } + + await follow(actor, followee, activity.id); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/index.ts b/packages/backend/src/remote/activitypub/kernel/index.ts new file mode 100644 index 0000000000..20df28eec6 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/index.ts @@ -0,0 +1,71 @@ +import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag } from '../type'; +import { IRemoteUser } from '@/models/entities/user'; +import create from './create/index'; +import performDeleteActivity from './delete/index'; +import performUpdateActivity from './update/index'; +import { performReadActivity } from './read'; +import follow from './follow'; +import undo from './undo/index'; +import like from './like'; +import announce from './announce/index'; +import accept from './accept/index'; +import reject from './reject/index'; +import add from './add/index'; +import remove from './remove/index'; +import block from './block/index'; +import flag from './flag/index'; +import { apLogger } from '../logger'; +import Resolver from '../resolver'; +import { toArray } from '@/prelude/array'; + +export async function performActivity(actor: IRemoteUser, activity: IObject) { + if (isCollectionOrOrderedCollection(activity)) { + const resolver = new Resolver(); + for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { + const act = await resolver.resolve(item); + try { + await performOneActivity(actor, act); + } catch (e) { + apLogger.error(e); + } + } + } else { + await performOneActivity(actor, activity); + } +} + +async function performOneActivity(actor: IRemoteUser, activity: IObject): Promise<void> { + if (actor.isSuspended) return; + + if (isCreate(activity)) { + await create(actor, activity); + } else if (isDelete(activity)) { + await performDeleteActivity(actor, activity); + } else if (isUpdate(activity)) { + await performUpdateActivity(actor, activity); + } else if (isRead(activity)) { + await performReadActivity(actor, activity); + } else if (isFollow(activity)) { + await follow(actor, activity); + } else if (isAccept(activity)) { + await accept(actor, activity); + } else if (isReject(activity)) { + await reject(actor, activity); + } else if (isAdd(activity)) { + await add(actor, activity).catch(err => apLogger.error(err)); + } else if (isRemove(activity)) { + await remove(actor, activity).catch(err => apLogger.error(err)); + } else if (isAnnounce(activity)) { + await announce(actor, activity); + } else if (isLike(activity)) { + await like(actor, activity); + } else if (isUndo(activity)) { + await undo(actor, activity); + } else if (isBlock(activity)) { + await block(actor, activity); + } else if (isFlag(activity)) { + await flag(actor, activity); + } else { + apLogger.warn(`unrecognized activity type: ${(activity as any).type}`); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/like.ts b/packages/backend/src/remote/activitypub/kernel/like.ts new file mode 100644 index 0000000000..58d5aefefc --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/like.ts @@ -0,0 +1,21 @@ +import { IRemoteUser } from '@/models/entities/user'; +import { ILike, getApId } from '../type'; +import create from '@/services/note/reaction/create'; +import { fetchNote, extractEmojis } from '../models/note'; + +export default async (actor: IRemoteUser, activity: ILike) => { + const targetUri = getApId(activity.object); + + const note = await fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await extractEmojis(activity.tag || [], actor.host).catch(() => null); + + return await create(actor, note, activity._misskey_reaction || activity.content || activity.name).catch(e => { + if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { + return 'skip: already reacted'; + } else { + throw e; + } + }).then(() => 'ok'); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/move/index.ts b/packages/backend/src/remote/activitypub/kernel/move/index.ts new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/move/index.ts diff --git a/packages/backend/src/remote/activitypub/kernel/read.ts b/packages/backend/src/remote/activitypub/kernel/read.ts new file mode 100644 index 0000000000..11a1731869 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/read.ts @@ -0,0 +1,27 @@ +import { IRemoteUser } from '@/models/entities/user'; +import { IRead, getApId } from '../type'; +import { isSelfHost, extractDbHost } from '@/misc/convert-host'; +import { MessagingMessages } from '@/models/index'; +import { readUserMessagingMessage } from '../../../server/api/common/read-messaging-message'; + +export const performReadActivity = async (actor: IRemoteUser, activity: IRead): Promise<string> => { + const id = await getApId(activity.object); + + if (!isSelfHost(extractDbHost(id))) { + return `skip: Read to foreign host (${id})`; + } + + const messageId = id.split('/').pop(); + + const message = await MessagingMessages.findOne(messageId); + if (message == null) { + return `skip: message not found`; + } + + if (actor.id != message.recipientId) { + return `skip: actor is not a message recipient`; + } + + await readUserMessagingMessage(message.recipientId!, message.userId, [message.id]); + return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/reject/follow.ts b/packages/backend/src/remote/activitypub/kernel/reject/follow.ts new file mode 100644 index 0000000000..356547440f --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/reject/follow.ts @@ -0,0 +1,29 @@ +import { IRemoteUser } from '@/models/entities/user'; +import reject from '@/services/following/requests/reject'; +import { IFollow } from '../../type'; +import DbResolver from '../../db-resolver'; +import { relayRejected } from '@/services/relay'; + +export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => { + // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある + + const dbResolver = new DbResolver(); + const follower = await dbResolver.getUserFromApId(activity.actor); + + if (follower == null) { + return `skip: follower not found`; + } + + if (follower.host != null) { + return `skip: follower is not a local user`; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await relayRejected(match[1]); + } + + await reject(actor, follower); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/reject/index.ts b/packages/backend/src/remote/activitypub/kernel/reject/index.ts new file mode 100644 index 0000000000..d0de9c329b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/reject/index.ts @@ -0,0 +1,24 @@ +import Resolver from '../../resolver'; +import { IRemoteUser } from '@/models/entities/user'; +import rejectFollow from './follow'; +import { IReject, isFollow, getApType } from '../../type'; +import { apLogger } from '../../logger'; + +const logger = apLogger; + +export default async (actor: IRemoteUser, activity: IReject): Promise<string> => { + const uri = activity.id || activity; + + logger.info(`Reject: ${uri}`); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await rejectFollow(actor, object); + + return `skip: Unknown Reject type: ${getApType(object)}`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/remove/index.ts b/packages/backend/src/remote/activitypub/kernel/remove/index.ts new file mode 100644 index 0000000000..d59953e653 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/remove/index.ts @@ -0,0 +1,23 @@ +import { IRemoteUser } from '@/models/entities/user'; +import { IRemove } from '../../type'; +import { resolveNote } from '../../models/note'; +import { removePinned } from '@/services/i/pin'; + +export default async (actor: IRemoteUser, activity: IRemove): Promise<void> => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + if (activity.target == null) { + throw new Error('target is null'); + } + + if (activity.target === actor.featured) { + const note = await resolveNote(activity.object); + if (note == null) throw new Error('note not found'); + await removePinned(actor, note.id); + return; + } + + throw new Error(`unknown target: ${activity.target}`); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/announce.ts b/packages/backend/src/remote/activitypub/kernel/undo/announce.ts new file mode 100644 index 0000000000..7bb9d7fcad --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/announce.ts @@ -0,0 +1,17 @@ +import { Notes } from '@/models/index'; +import { IRemoteUser } from '@/models/entities/user'; +import { IAnnounce, getApId } from '../../type'; +import deleteNote from '@/services/note/delete'; + +export const undoAnnounce = async (actor: IRemoteUser, activity: IAnnounce): Promise<string> => { + const uri = getApId(activity); + + const note = await Notes.findOne({ + uri + }); + + if (!note) return 'skip: no such Announce'; + + await deleteNote(actor, note); + return 'ok: deleted'; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/block.ts b/packages/backend/src/remote/activitypub/kernel/undo/block.ts new file mode 100644 index 0000000000..61940486be --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/block.ts @@ -0,0 +1,20 @@ +import { IBlock } from '../../type'; +import unblock from '@/services/blocking/delete'; +import { IRemoteUser } from '@/models/entities/user'; +import DbResolver from '../../db-resolver'; + +export default async (actor: IRemoteUser, activity: IBlock): Promise<string> => { + const dbResolver = new DbResolver(); + const blockee = await dbResolver.getUserFromApId(activity.object); + + if (blockee == null) { + return `skip: blockee not found`; + } + + if (blockee.host != null) { + return `skip: ブロック解除しようとしているユーザーはローカルユーザーではありません`; + } + + await unblock(actor, blockee); + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/follow.ts b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts new file mode 100644 index 0000000000..d85c7e4a71 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts @@ -0,0 +1,41 @@ +import unfollow from '@/services/following/delete'; +import cancelRequest from '@/services/following/requests/cancel'; +import { IFollow } from '../../type'; +import { IRemoteUser } from '@/models/entities/user'; +import { FollowRequests, Followings } from '@/models/index'; +import DbResolver from '../../db-resolver'; + +export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => { + const dbResolver = new DbResolver(); + + const followee = await dbResolver.getUserFromApId(activity.object); + if (followee == null) { + return `skip: followee not found`; + } + + if (followee.host != null) { + return `skip: フォロー解除しようとしているユーザーはローカルユーザーではありません`; + } + + const req = await FollowRequests.findOne({ + followerId: actor.id, + followeeId: followee.id + }); + + const following = await Followings.findOne({ + followerId: actor.id, + followeeId: followee.id + }); + + if (req) { + await cancelRequest(followee, actor); + return `ok: follow request canceled`; + } + + if (following) { + await unfollow(actor, followee); + return `ok: unfollowed`; + } + + return `skip: リクエストもフォローもされていない`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/index.ts b/packages/backend/src/remote/activitypub/kernel/undo/index.ts new file mode 100644 index 0000000000..14b1add152 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/index.ts @@ -0,0 +1,34 @@ +import { IRemoteUser } from '@/models/entities/user'; +import { IUndo, isFollow, isBlock, isLike, isAnnounce, getApType } from '../../type'; +import unfollow from './follow'; +import unblock from './block'; +import undoLike from './like'; +import { undoAnnounce } from './announce'; +import Resolver from '../../resolver'; +import { apLogger } from '../../logger'; + +const logger = apLogger; + +export default async (actor: IRemoteUser, activity: IUndo): Promise<string> => { + if ('actor' in activity && actor.uri !== activity.actor) { + throw new Error('invalid actor'); + } + + const uri = activity.id || activity; + + logger.info(`Undo: ${uri}`); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await unfollow(actor, object); + if (isBlock(object)) return await unblock(actor, object); + if (isLike(object)) return await undoLike(actor, object); + if (isAnnounce(object)) return await undoAnnounce(actor, object); + + return `skip: unknown object type ${getApType(object)}`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/like.ts b/packages/backend/src/remote/activitypub/kernel/undo/like.ts new file mode 100644 index 0000000000..107d3053e3 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/like.ts @@ -0,0 +1,21 @@ +import { IRemoteUser } from '@/models/entities/user'; +import { ILike, getApId } from '../../type'; +import deleteReaction from '@/services/note/reaction/delete'; +import { fetchNote } from '../../models/note'; + +/** + * Process Undo.Like activity + */ +export default async (actor: IRemoteUser, activity: ILike) => { + const targetUri = getApId(activity.object); + + const note = await fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await deleteReaction(actor, note).catch(e => { + if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return; + throw e; + }); + + return `ok`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/update/index.ts b/packages/backend/src/remote/activitypub/kernel/update/index.ts new file mode 100644 index 0000000000..52bfc5002e --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/update/index.ts @@ -0,0 +1,34 @@ +import { IRemoteUser } from '@/models/entities/user'; +import { getApType, IUpdate, isActor } from '../../type'; +import { apLogger } from '../../logger'; +import { updateQuestion } from '../../models/question'; +import Resolver from '../../resolver'; +import { updatePerson } from '../../models/person'; + +/** + * Updateアクティビティを捌きます + */ +export default async (actor: IRemoteUser, activity: IUpdate): Promise<string> => { + if ('actor' in activity && actor.uri !== activity.actor) { + return `skip: invalid actor`; + } + + apLogger.debug('Update'); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch(e => { + apLogger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isActor(object)) { + await updatePerson(actor.uri!, resolver, object); + return `ok: Person updated`; + } else if (getApType(object) === 'Question') { + await updateQuestion(object).catch(e => console.log(e)); + return `ok: Question updated`; + } else { + return `skip: Unknown type: ${getApType(object)}`; + } +}; diff --git a/packages/backend/src/remote/activitypub/logger.ts b/packages/backend/src/remote/activitypub/logger.ts new file mode 100644 index 0000000000..e13add01db --- /dev/null +++ b/packages/backend/src/remote/activitypub/logger.ts @@ -0,0 +1,3 @@ +import { remoteLogger } from '../logger'; + +export const apLogger = remoteLogger.createSubLogger('ap', 'magenta'); diff --git a/packages/backend/src/remote/activitypub/misc/contexts.ts b/packages/backend/src/remote/activitypub/misc/contexts.ts new file mode 100644 index 0000000000..1426ba15f5 --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/contexts.ts @@ -0,0 +1,526 @@ +/* tslint:disable:quotemark indent */ +const id_v1 = { + "@context": { + "id": "@id", + "type": "@type", + + "cred": "https://w3id.org/credentials#", + "dc": "http://purl.org/dc/terms/", + "identity": "https://w3id.org/identity#", + "perm": "https://w3id.org/permissions#", + "ps": "https://w3id.org/payswarm#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "sec": "https://w3id.org/security#", + "schema": "http://schema.org/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + + "Group": "https://www.w3.org/ns/activitystreams#Group", + + "claim": {"@id": "cred:claim", "@type": "@id"}, + "credential": {"@id": "cred:credential", "@type": "@id"}, + "issued": {"@id": "cred:issued", "@type": "xsd:dateTime"}, + "issuer": {"@id": "cred:issuer", "@type": "@id"}, + "recipient": {"@id": "cred:recipient", "@type": "@id"}, + "Credential": "cred:Credential", + "CryptographicKeyCredential": "cred:CryptographicKeyCredential", + + "about": {"@id": "schema:about", "@type": "@id"}, + "address": {"@id": "schema:address", "@type": "@id"}, + "addressCountry": "schema:addressCountry", + "addressLocality": "schema:addressLocality", + "addressRegion": "schema:addressRegion", + "comment": "rdfs:comment", + "created": {"@id": "dc:created", "@type": "xsd:dateTime"}, + "creator": {"@id": "dc:creator", "@type": "@id"}, + "description": "schema:description", + "email": "schema:email", + "familyName": "schema:familyName", + "givenName": "schema:givenName", + "image": {"@id": "schema:image", "@type": "@id"}, + "label": "rdfs:label", + "name": "schema:name", + "postalCode": "schema:postalCode", + "streetAddress": "schema:streetAddress", + "title": "dc:title", + "url": {"@id": "schema:url", "@type": "@id"}, + "Person": "schema:Person", + "PostalAddress": "schema:PostalAddress", + "Organization": "schema:Organization", + + "identityService": {"@id": "identity:identityService", "@type": "@id"}, + "idp": {"@id": "identity:idp", "@type": "@id"}, + "Identity": "identity:Identity", + + "paymentProcessor": "ps:processor", + "preferences": {"@id": "ps:preferences", "@type": "@vocab"}, + + "cipherAlgorithm": "sec:cipherAlgorithm", + "cipherData": "sec:cipherData", + "cipherKey": "sec:cipherKey", + "digestAlgorithm": "sec:digestAlgorithm", + "digestValue": "sec:digestValue", + "domain": "sec:domain", + "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "initializationVector": "sec:initializationVector", + "member": {"@id": "schema:member", "@type": "@id"}, + "memberOf": {"@id": "schema:memberOf", "@type": "@id"}, + "nonce": "sec:nonce", + "normalizationAlgorithm": "sec:normalizationAlgorithm", + "owner": {"@id": "sec:owner", "@type": "@id"}, + "password": "sec:password", + "privateKey": {"@id": "sec:privateKey", "@type": "@id"}, + "privateKeyPem": "sec:privateKeyPem", + "publicKey": {"@id": "sec:publicKey", "@type": "@id"}, + "publicKeyPem": "sec:publicKeyPem", + "publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"}, + "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"}, + "signature": "sec:signature", + "signatureAlgorithm": "sec:signatureAlgorithm", + "signatureValue": "sec:signatureValue", + "CryptographicKey": "sec:Key", + "EncryptedMessage": "sec:EncryptedMessage", + "GraphSignature2012": "sec:GraphSignature2012", + "LinkedDataSignature2015": "sec:LinkedDataSignature2015", + + "accessControl": {"@id": "perm:accessControl", "@type": "@id"}, + "writePermission": {"@id": "perm:writePermission", "@type": "@id"} + } +}; + +const security_v1 = { + "@context": { + "id": "@id", + "type": "@type", + + "dc": "http://purl.org/dc/terms/", + "sec": "https://w3id.org/security#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + + "EcdsaKoblitzSignature2016": "sec:EcdsaKoblitzSignature2016", + "Ed25519Signature2018": "sec:Ed25519Signature2018", + "EncryptedMessage": "sec:EncryptedMessage", + "GraphSignature2012": "sec:GraphSignature2012", + "LinkedDataSignature2015": "sec:LinkedDataSignature2015", + "LinkedDataSignature2016": "sec:LinkedDataSignature2016", + "CryptographicKey": "sec:Key", + + "authenticationTag": "sec:authenticationTag", + "canonicalizationAlgorithm": "sec:canonicalizationAlgorithm", + "cipherAlgorithm": "sec:cipherAlgorithm", + "cipherData": "sec:cipherData", + "cipherKey": "sec:cipherKey", + "created": {"@id": "dc:created", "@type": "xsd:dateTime"}, + "creator": {"@id": "dc:creator", "@type": "@id"}, + "digestAlgorithm": "sec:digestAlgorithm", + "digestValue": "sec:digestValue", + "domain": "sec:domain", + "encryptionKey": "sec:encryptionKey", + "expiration": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, + "initializationVector": "sec:initializationVector", + "iterationCount": "sec:iterationCount", + "nonce": "sec:nonce", + "normalizationAlgorithm": "sec:normalizationAlgorithm", + "owner": {"@id": "sec:owner", "@type": "@id"}, + "password": "sec:password", + "privateKey": {"@id": "sec:privateKey", "@type": "@id"}, + "privateKeyPem": "sec:privateKeyPem", + "publicKey": {"@id": "sec:publicKey", "@type": "@id"}, + "publicKeyBase58": "sec:publicKeyBase58", + "publicKeyPem": "sec:publicKeyPem", + "publicKeyWif": "sec:publicKeyWif", + "publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"}, + "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"}, + "salt": "sec:salt", + "signature": "sec:signature", + "signatureAlgorithm": "sec:signingAlgorithm", + "signatureValue": "sec:signatureValue" + } +}; + +const activitystreams = { + "@context": { + "@vocab": "_:", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "as": "https://www.w3.org/ns/activitystreams#", + "ldp": "http://www.w3.org/ns/ldp#", + "vcard": "http://www.w3.org/2006/vcard/ns#", + "id": "@id", + "type": "@type", + "Accept": "as:Accept", + "Activity": "as:Activity", + "IntransitiveActivity": "as:IntransitiveActivity", + "Add": "as:Add", + "Announce": "as:Announce", + "Application": "as:Application", + "Arrive": "as:Arrive", + "Article": "as:Article", + "Audio": "as:Audio", + "Block": "as:Block", + "Collection": "as:Collection", + "CollectionPage": "as:CollectionPage", + "Relationship": "as:Relationship", + "Create": "as:Create", + "Delete": "as:Delete", + "Dislike": "as:Dislike", + "Document": "as:Document", + "Event": "as:Event", + "Follow": "as:Follow", + "Flag": "as:Flag", + "Group": "as:Group", + "Ignore": "as:Ignore", + "Image": "as:Image", + "Invite": "as:Invite", + "Join": "as:Join", + "Leave": "as:Leave", + "Like": "as:Like", + "Link": "as:Link", + "Mention": "as:Mention", + "Note": "as:Note", + "Object": "as:Object", + "Offer": "as:Offer", + "OrderedCollection": "as:OrderedCollection", + "OrderedCollectionPage": "as:OrderedCollectionPage", + "Organization": "as:Organization", + "Page": "as:Page", + "Person": "as:Person", + "Place": "as:Place", + "Profile": "as:Profile", + "Question": "as:Question", + "Reject": "as:Reject", + "Remove": "as:Remove", + "Service": "as:Service", + "TentativeAccept": "as:TentativeAccept", + "TentativeReject": "as:TentativeReject", + "Tombstone": "as:Tombstone", + "Undo": "as:Undo", + "Update": "as:Update", + "Video": "as:Video", + "View": "as:View", + "Listen": "as:Listen", + "Read": "as:Read", + "Move": "as:Move", + "Travel": "as:Travel", + "IsFollowing": "as:IsFollowing", + "IsFollowedBy": "as:IsFollowedBy", + "IsContact": "as:IsContact", + "IsMember": "as:IsMember", + "subject": { + "@id": "as:subject", + "@type": "@id" + }, + "relationship": { + "@id": "as:relationship", + "@type": "@id" + }, + "actor": { + "@id": "as:actor", + "@type": "@id" + }, + "attributedTo": { + "@id": "as:attributedTo", + "@type": "@id" + }, + "attachment": { + "@id": "as:attachment", + "@type": "@id" + }, + "bcc": { + "@id": "as:bcc", + "@type": "@id" + }, + "bto": { + "@id": "as:bto", + "@type": "@id" + }, + "cc": { + "@id": "as:cc", + "@type": "@id" + }, + "context": { + "@id": "as:context", + "@type": "@id" + }, + "current": { + "@id": "as:current", + "@type": "@id" + }, + "first": { + "@id": "as:first", + "@type": "@id" + }, + "generator": { + "@id": "as:generator", + "@type": "@id" + }, + "icon": { + "@id": "as:icon", + "@type": "@id" + }, + "image": { + "@id": "as:image", + "@type": "@id" + }, + "inReplyTo": { + "@id": "as:inReplyTo", + "@type": "@id" + }, + "items": { + "@id": "as:items", + "@type": "@id" + }, + "instrument": { + "@id": "as:instrument", + "@type": "@id" + }, + "orderedItems": { + "@id": "as:items", + "@type": "@id", + "@container": "@list" + }, + "last": { + "@id": "as:last", + "@type": "@id" + }, + "location": { + "@id": "as:location", + "@type": "@id" + }, + "next": { + "@id": "as:next", + "@type": "@id" + }, + "object": { + "@id": "as:object", + "@type": "@id" + }, + "oneOf": { + "@id": "as:oneOf", + "@type": "@id" + }, + "anyOf": { + "@id": "as:anyOf", + "@type": "@id" + }, + "closed": { + "@id": "as:closed", + "@type": "xsd:dateTime" + }, + "origin": { + "@id": "as:origin", + "@type": "@id" + }, + "accuracy": { + "@id": "as:accuracy", + "@type": "xsd:float" + }, + "prev": { + "@id": "as:prev", + "@type": "@id" + }, + "preview": { + "@id": "as:preview", + "@type": "@id" + }, + "replies": { + "@id": "as:replies", + "@type": "@id" + }, + "result": { + "@id": "as:result", + "@type": "@id" + }, + "audience": { + "@id": "as:audience", + "@type": "@id" + }, + "partOf": { + "@id": "as:partOf", + "@type": "@id" + }, + "tag": { + "@id": "as:tag", + "@type": "@id" + }, + "target": { + "@id": "as:target", + "@type": "@id" + }, + "to": { + "@id": "as:to", + "@type": "@id" + }, + "url": { + "@id": "as:url", + "@type": "@id" + }, + "altitude": { + "@id": "as:altitude", + "@type": "xsd:float" + }, + "content": "as:content", + "contentMap": { + "@id": "as:content", + "@container": "@language" + }, + "name": "as:name", + "nameMap": { + "@id": "as:name", + "@container": "@language" + }, + "duration": { + "@id": "as:duration", + "@type": "xsd:duration" + }, + "endTime": { + "@id": "as:endTime", + "@type": "xsd:dateTime" + }, + "height": { + "@id": "as:height", + "@type": "xsd:nonNegativeInteger" + }, + "href": { + "@id": "as:href", + "@type": "@id" + }, + "hreflang": "as:hreflang", + "latitude": { + "@id": "as:latitude", + "@type": "xsd:float" + }, + "longitude": { + "@id": "as:longitude", + "@type": "xsd:float" + }, + "mediaType": "as:mediaType", + "published": { + "@id": "as:published", + "@type": "xsd:dateTime" + }, + "radius": { + "@id": "as:radius", + "@type": "xsd:float" + }, + "rel": "as:rel", + "startIndex": { + "@id": "as:startIndex", + "@type": "xsd:nonNegativeInteger" + }, + "startTime": { + "@id": "as:startTime", + "@type": "xsd:dateTime" + }, + "summary": "as:summary", + "summaryMap": { + "@id": "as:summary", + "@container": "@language" + }, + "totalItems": { + "@id": "as:totalItems", + "@type": "xsd:nonNegativeInteger" + }, + "units": "as:units", + "updated": { + "@id": "as:updated", + "@type": "xsd:dateTime" + }, + "width": { + "@id": "as:width", + "@type": "xsd:nonNegativeInteger" + }, + "describes": { + "@id": "as:describes", + "@type": "@id" + }, + "formerType": { + "@id": "as:formerType", + "@type": "@id" + }, + "deleted": { + "@id": "as:deleted", + "@type": "xsd:dateTime" + }, + "inbox": { + "@id": "ldp:inbox", + "@type": "@id" + }, + "outbox": { + "@id": "as:outbox", + "@type": "@id" + }, + "following": { + "@id": "as:following", + "@type": "@id" + }, + "followers": { + "@id": "as:followers", + "@type": "@id" + }, + "streams": { + "@id": "as:streams", + "@type": "@id" + }, + "preferredUsername": "as:preferredUsername", + "endpoints": { + "@id": "as:endpoints", + "@type": "@id" + }, + "uploadMedia": { + "@id": "as:uploadMedia", + "@type": "@id" + }, + "proxyUrl": { + "@id": "as:proxyUrl", + "@type": "@id" + }, + "liked": { + "@id": "as:liked", + "@type": "@id" + }, + "oauthAuthorizationEndpoint": { + "@id": "as:oauthAuthorizationEndpoint", + "@type": "@id" + }, + "oauthTokenEndpoint": { + "@id": "as:oauthTokenEndpoint", + "@type": "@id" + }, + "provideClientKey": { + "@id": "as:provideClientKey", + "@type": "@id" + }, + "signClientKey": { + "@id": "as:signClientKey", + "@type": "@id" + }, + "sharedInbox": { + "@id": "as:sharedInbox", + "@type": "@id" + }, + "Public": { + "@id": "as:Public", + "@type": "@id" + }, + "source": "as:source", + "likes": { + "@id": "as:likes", + "@type": "@id" + }, + "shares": { + "@id": "as:shares", + "@type": "@id" + }, + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + } + } +}; + +export const CONTEXTS: Record<string, any> = { + "https://w3id.org/identity/v1": id_v1, + "https://w3id.org/security/v1": security_v1, + "https://www.w3.org/ns/activitystreams": activitystreams, +}; diff --git a/packages/backend/src/remote/activitypub/misc/get-note-html.ts b/packages/backend/src/remote/activitypub/misc/get-note-html.ts new file mode 100644 index 0000000000..043335a5be --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/get-note-html.ts @@ -0,0 +1,10 @@ +import * as mfm from 'mfm-js'; +import { Note } from '@/models/entities/note'; +import { toHtml } from '../../../mfm/to-html'; + +export default function(note: Note) { + let html = note.text ? toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers)) : null; + if (html == null) html = '<p>.</p>'; + + return html; +} diff --git a/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts b/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts new file mode 100644 index 0000000000..5cca04df21 --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts @@ -0,0 +1,9 @@ +import { IObject } from '../type'; +import { extractApHashtagObjects } from '../models/tag'; +import { fromHtml } from '../../../mfm/from-html'; + +export function htmlToMfm(html: string, tag?: IObject | IObject[]) { + const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null); + + return fromHtml(html, hashtagNames); +} diff --git a/packages/backend/src/remote/activitypub/misc/ld-signature.ts b/packages/backend/src/remote/activitypub/misc/ld-signature.ts new file mode 100644 index 0000000000..dec07ea81b --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/ld-signature.ts @@ -0,0 +1,134 @@ +import * as crypto from 'crypto'; +import * as jsonld from 'jsonld'; +import { CONTEXTS } from './contexts'; +import fetch from 'node-fetch'; +import { httpAgent, httpsAgent } from '@/misc/fetch'; + +// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 + +export class LdSignature { + public debug = false; + public preLoad = true; + public loderTimeout = 10 * 1000; + + constructor() { + } + + public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise<any> { + const options = { + type: 'RsaSignature2017', + creator, + domain, + nonce: crypto.randomBytes(16).toString('hex'), + created: (created || new Date()).toISOString() + } as { + type: string; + creator: string; + domain: string; + nonce: string; + created: string; + }; + + if (!domain) { + delete options.domain; + } + + const toBeSigned = await this.createVerifyData(data, options); + + const signer = crypto.createSign('sha256'); + signer.update(toBeSigned); + signer.end(); + + const signature = signer.sign(privateKey); + + return { + ...data, + signature: { + ...options, + signatureValue: signature.toString('base64') + } + }; + } + + public async verifyRsaSignature2017(data: any, publicKey: string): Promise<boolean> { + const toBeSigned = await this.createVerifyData(data, data.signature); + const verifier = crypto.createVerify('sha256'); + verifier.update(toBeSigned); + return verifier.verify(publicKey, data.signature.signatureValue, 'base64'); + } + + public async createVerifyData(data: any, options: any) { + const transformedOptions = { + ...options, + '@context': 'https://w3id.org/identity/v1' + }; + delete transformedOptions['type']; + delete transformedOptions['id']; + delete transformedOptions['signatureValue']; + const canonizedOptions = await this.normalize(transformedOptions); + const optionsHash = this.sha256(canonizedOptions); + const transformedData = { ...data }; + delete transformedData['signature']; + const cannonidedData = await this.normalize(transformedData); + if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`); + const documentHash = this.sha256(cannonidedData); + const verifyData = `${optionsHash}${documentHash}`; + return verifyData; + } + + public async normalize(data: any) { + const customLoader = this.getLoader(); + return await jsonld.normalize(data, { + documentLoader: customLoader + }); + } + + private getLoader() { + return async (url: string): Promise<any> => { + if (!url.match('^https?\:\/\/')) throw `Invalid URL ${url}`; + + if (this.preLoad) { + if (url in CONTEXTS) { + if (this.debug) console.debug(`HIT: ${url}`); + return { + contextUrl: null, + document: CONTEXTS[url], + documentUrl: url + }; + } + } + + if (this.debug) console.debug(`MISS: ${url}`); + const document = await this.fetchDocument(url); + return { + contextUrl: null, + document: document, + documentUrl: url + }; + }; + } + + private async fetchDocument(url: string) { + const json = await fetch(url, { + headers: { + Accept: 'application/ld+json, application/json', + }, + timeout: this.loderTimeout, + agent: u => u.protocol == 'http:' ? httpAgent : httpsAgent, + }).then(res => { + if (!res.ok) { + throw `${res.status} ${res.statusText}`; + } else { + return res.json(); + } + }); + + return json; + } + + public sha256(data: string): string { + const hash = crypto.createHash('sha256'); + hash.update(data); + return hash.digest('hex'); + } +} diff --git a/packages/backend/src/remote/activitypub/models/icon.ts b/packages/backend/src/remote/activitypub/models/icon.ts new file mode 100644 index 0000000000..50794a937d --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/icon.ts @@ -0,0 +1,5 @@ +export type IIcon = { + type: string; + mediaType?: string; + url?: string; +}; diff --git a/packages/backend/src/remote/activitypub/models/identifier.ts b/packages/backend/src/remote/activitypub/models/identifier.ts new file mode 100644 index 0000000000..f6c3bb8c88 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/identifier.ts @@ -0,0 +1,5 @@ +export type IIdentifier = { + type: string; + name: string; + value: string; +}; diff --git a/packages/backend/src/remote/activitypub/models/image.ts b/packages/backend/src/remote/activitypub/models/image.ts new file mode 100644 index 0000000000..d0a96e4313 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/image.ts @@ -0,0 +1,62 @@ +import uploadFromUrl from '@/services/drive/upload-from-url'; +import { IRemoteUser } from '@/models/entities/user'; +import Resolver from '../resolver'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { apLogger } from '../logger'; +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFiles } from '@/models/index'; +import { truncate } from '@/misc/truncate'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits'; + +const logger = apLogger; + +/** + * Imageを作成します。 + */ +export async function createImage(actor: IRemoteUser, value: any): Promise<DriveFile> { + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const image = await new Resolver().resolve(value) as any; + + if (image.url == null) { + throw new Error('invalid image: url not privided'); + } + + logger.info(`Creating the Image: ${image.url}`); + + const instance = await fetchMeta(); + const cache = instance.cacheRemoteFiles; + + let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache, truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH)); + + if (file.isLink) { + // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 + // URLを更新する + if (file.url !== image.url) { + await DriveFiles.update({ id: file.id }, { + url: image.url, + uri: image.url + }); + + file = await DriveFiles.findOneOrFail(file.id); + } + } + + return file; +} + +/** + * Imageを解決します。 + * + * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ +export async function resolveImage(actor: IRemoteUser, value: any): Promise<DriveFile> { + // TODO + + // リモートサーバーからフェッチしてきて登録 + return await createImage(actor, value); +} diff --git a/packages/backend/src/remote/activitypub/models/mention.ts b/packages/backend/src/remote/activitypub/models/mention.ts new file mode 100644 index 0000000000..ade9c90806 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/mention.ts @@ -0,0 +1,24 @@ +import { toArray, unique } from '@/prelude/array'; +import { IObject, isMention, IApMention } from '../type'; +import { resolvePerson } from './person'; +import * as promiseLimit from 'promise-limit'; +import Resolver from '../resolver'; +import { User } from '@/models/entities/user'; + +export async function extractApMentions(tags: IObject | IObject[] | null | undefined) { + const hrefs = unique(extractApMentionObjects(tags).map(x => x.href as string)); + + const resolver = new Resolver(); + + const limit = promiseLimit<User | null>(2); + const mentionedUsers = (await Promise.all( + hrefs.map(x => limit(() => resolvePerson(x, resolver).catch(() => null))) + )).filter((x): x is User => x != null); + + return mentionedUsers; +} + +export function extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] { + if (tags == null) return []; + return toArray(tags).filter(isMention); +} diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts new file mode 100644 index 0000000000..492dc05248 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/note.ts @@ -0,0 +1,356 @@ +import * as promiseLimit from 'promise-limit'; + +import config from '@/config/index'; +import Resolver from '../resolver'; +import post from '@/services/note/create'; +import { resolvePerson, updatePerson } from './person'; +import { resolveImage } from './image'; +import { IRemoteUser } from '@/models/entities/user'; +import { htmlToMfm } from '../misc/html-to-mfm'; +import { extractApHashtags } from './tag'; +import { unique, toArray, toSingle } from '@/prelude/array'; +import { extractPollFromQuestion } from './question'; +import vote from '@/services/note/polls/vote'; +import { apLogger } from '../logger'; +import { DriveFile } from '@/models/entities/drive-file'; +import { deliverQuestionUpdate } from '@/services/note/polls/update'; +import { extractDbHost, toPuny } from '@/misc/convert-host'; +import { Emojis, Polls, MessagingMessages } from '@/models/index'; +import { Note } from '@/models/entities/note'; +import { IObject, getOneApId, getApId, getOneApHrefNullable, validPost, IPost, isEmoji, getApType } from '../type'; +import { Emoji } from '@/models/entities/emoji'; +import { genId } from '@/misc/gen-id'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { getApLock } from '@/misc/app-lock'; +import { createMessage } from '@/services/messages/create'; +import { parseAudience } from '../audience'; +import { extractApMentions } from './mention'; +import DbResolver from '../db-resolver'; +import { StatusError } from '@/misc/fetch'; + +const logger = apLogger; + +export function validateNote(object: any, uri: string) { + const expectHost = extractDbHost(uri); + + if (object == null) { + return new Error('invalid Note: object is null'); + } + + if (!validPost.includes(getApType(object))) { + return new Error(`invalid Note: invalid object type ${getApType(object)}`); + } + + if (object.id && extractDbHost(object.id) !== expectHost) { + return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${extractDbHost(object.id)}`); + } + + if (object.attributedTo && extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { + return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${extractDbHost(object.attributedTo)}`); + } + + return null; +} + +/** + * Noteをフェッチします。 + * + * Misskeyに対象のNoteが登録されていればそれを返します。 + */ +export async function fetchNote(object: string | IObject): Promise<Note | null> { + const dbResolver = new DbResolver(); + return await dbResolver.getNoteFromApId(object); +} + +/** + * Noteを作成します。 + */ +export async function createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<Note | null> { + if (resolver == null) resolver = new Resolver(); + + const object: any = await resolver.resolve(value); + + const entryUri = getApId(value); + const err = validateNote(object, entryUri); + if (err) { + logger.error(`${err.message}`, { + resolver: { + history: resolver.getHistory() + }, + value: value, + object: object + }); + throw new Error('invalid note'); + } + + const note: IPost = object; + + logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); + + logger.info(`Creating the Note: ${note.id}`); + + // 投稿者をフェッチ + const actor = await resolvePerson(getOneApId(note.attributedTo), resolver) as IRemoteUser; + + // 投稿者が凍結されていたらスキップ + if (actor.isSuspended) { + throw new Error('actor has been suspended'); + } + + const noteAudience = await parseAudience(actor, note.to, note.cc); + let visibility = noteAudience.visibility; + const visibleUsers = noteAudience.visibleUsers; + + // Audience (to, cc) が指定されてなかった場合 + if (visibility === 'specified' && visibleUsers.length === 0) { + if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している + // こちらから匿名GET出来たものならばpublic + visibility = 'public'; + } + } + + let isTalk = note._misskey_talk && visibility === 'specified'; + + const apMentions = await extractApMentions(note.tag); + const apHashtags = await extractApHashtags(note.tag); + + // 添付ファイル + // TODO: attachmentは必ずしもImageではない + // TODO: attachmentは必ずしも配列ではない + // Noteがsensitiveなら添付もsensitiveにする + const limit = promiseLimit(2); + + note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; + const files = note.attachment + .map(attach => attach.sensitive = note.sensitive) + ? (await Promise.all(note.attachment.map(x => limit(() => resolveImage(actor, x)) as Promise<DriveFile>))) + .filter(image => image != null) + : []; + + // リプライ + const reply: Note | null = note.inReplyTo + ? await resolveNote(note.inReplyTo, resolver).then(x => { + if (x == null) { + logger.warn(`Specified inReplyTo, but nout found`); + throw new Error('inReplyTo not found'); + } else { + return x; + } + }).catch(async e => { + // トークだったらinReplyToのエラーは無視 + const uri = getApId(note.inReplyTo); + if (uri.startsWith(config.url + '/')) { + const id = uri.split('/').pop(); + const talk = await MessagingMessages.findOne(id); + if (talk) { + isTalk = true; + return null; + } + } + + logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${e.statusCode || e}`); + throw e; + }) + : null; + + // 引用 + let quote: Note | undefined | null; + + if (note._misskey_quote || note.quoteUrl) { + const tryResolveNote = async (uri: string): Promise<{ + status: 'ok'; + res: Note | null; + } | { + status: 'permerror' | 'temperror'; + }> => { + if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' }; + try { + const res = await resolveNote(uri); + if (res) { + return { + status: 'ok', + res + }; + } else { + return { + status: 'permerror' + }; + } + } catch (e) { + return { + status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror' + }; + } + }; + + const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string')); + const results = await Promise.all(uris.map(uri => tryResolveNote(uri))); + + quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x); + if (!quote) { + if (results.some(x => x.status === 'temperror')) { + throw 'quote resolve failed'; + } + } + } + + const cw = note.summary === '' ? null : note.summary; + + // テキストのパース + const text = note._misskey_content || (note.content ? htmlToMfm(note.content, note.tag) : null); + + // vote + if (reply && reply.hasPoll) { + const poll = await Polls.findOneOrFail(reply.id); + + const tryCreateVote = async (name: string, index: number): Promise<null> => { + if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { + logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); + } else if (index >= 0) { + logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); + await vote(actor, reply, index); + + // リモートフォロワーにUpdate配信 + deliverQuestionUpdate(reply.id); + } + return null; + }; + + if (note.name) { + return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name)); + } + } + + const emojis = await extractEmojis(note.tag || [], actor.host).catch(e => { + logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const apEmojis = emojis.map(emoji => emoji.name); + + const poll = await extractPollFromQuestion(note, resolver).catch(() => undefined); + + // ユーザーの情報が古かったらついでに更新しておく + if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + if (actor.uri) updatePerson(actor.uri); + } + + if (isTalk) { + for (const recipient of visibleUsers) { + await createMessage(actor, recipient, undefined, text || undefined, (files && files.length > 0) ? files[0] : null, object.id); + return null; + } + } + + return await post(actor, { + createdAt: note.published ? new Date(note.published) : null, + files, + reply, + renote: quote, + name: note.name, + cw, + text, + viaMobile: false, + localOnly: false, + visibility, + visibleUsers, + apMentions, + apHashtags, + apEmojis, + poll, + uri: note.id, + url: getOneApHrefNullable(note.url), + }, silent); +} + +/** + * Noteを解決します。 + * + * Misskeyに対象のNoteが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ +export async function resolveNote(value: string | IObject, resolver?: Resolver): Promise<Note | null> { + const uri = typeof value === 'string' ? value : value.id; + if (uri == null) throw new Error('missing uri'); + + // ブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(extractDbHost(uri))) throw { statusCode: 451 }; + + const unlock = await getApLock(uri); + + try { + //#region このサーバーに既に登録されていたらそれを返す + const exist = await fetchNote(uri); + + if (exist) { + return exist; + } + //#endregion + + if (uri.startsWith(config.url)) { + throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); + } + + // リモートサーバーからフェッチしてきて登録 + // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが + // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 + return await createNote(uri, resolver, true); + } finally { + unlock(); + } +} + +export async function extractEmojis(tags: IObject | IObject[], host: string): Promise<Emoji[]> { + host = toPuny(host); + + if (!tags) return []; + + const eomjiTags = toArray(tags).filter(isEmoji); + + return await Promise.all(eomjiTags.map(async tag => { + const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); + tag.icon = toSingle(tag.icon); + + const exists = await Emojis.findOne({ + host, + name + }); + + if (exists) { + if ((tag.updated != null && exists.updatedAt == null) + || (tag.id != null && exists.uri == null) + || (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt) + || (tag.icon!.url !== exists.url) + ) { + await Emojis.update({ + host, + name, + }, { + uri: tag.id, + url: tag.icon!.url, + updatedAt: new Date(), + }); + + return await Emojis.findOne({ + host, + name + }) as Emoji; + } + + return exists; + } + + logger.info(`register emoji host=${host}, name=${name}`); + + return await Emojis.save({ + id: genId(), + host, + name, + uri: tag.id, + url: tag.icon!.url, + updatedAt: new Date(), + aliases: [] + } as Partial<Emoji>); + })); +} diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts new file mode 100644 index 0000000000..eb8c00a10b --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/person.ts @@ -0,0 +1,494 @@ +import { URL } from 'url'; +import * as promiseLimit from 'promise-limit'; + +import $, { Context } from 'cafy'; +import config from '@/config/index'; +import Resolver from '../resolver'; +import { resolveImage } from './image'; +import { isCollectionOrOrderedCollection, isCollection, IActor, getApId, getOneApHrefNullable, IObject, isPropertyValue, IApPropertyValue, getApType, isActor } from '../type'; +import { fromHtml } from '../../../mfm/from-html'; +import { htmlToMfm } from '../misc/html-to-mfm'; +import { resolveNote, extractEmojis } from './note'; +import { registerOrFetchInstanceDoc } from '@/services/register-or-fetch-instance-doc'; +import { extractApHashtags } from './tag'; +import { apLogger } from '../logger'; +import { Note } from '@/models/entities/note'; +import { updateUsertags } from '@/services/update-hashtag'; +import { Users, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '@/models/index'; +import { User, IRemoteUser } from '@/models/entities/user'; +import { Emoji } from '@/models/entities/emoji'; +import { UserNotePining } from '@/models/entities/user-note-pining'; +import { genId } from '@/misc/gen-id'; +import { instanceChart, usersChart } from '@/services/chart/index'; +import { UserPublickey } from '@/models/entities/user-publickey'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error'; +import { toPuny } from '@/misc/convert-host'; +import { UserProfile } from '@/models/entities/user-profile'; +import { getConnection } from 'typeorm'; +import { toArray } from '@/prelude/array'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; +import { truncate } from '@/misc/truncate'; +import { StatusError } from '@/misc/fetch'; + +const logger = apLogger; + +const nameLength = 128; +const summaryLength = 2048; + +/** + * Validate and convert to actor object + * @param x Fetched object + * @param uri Fetch target URI + */ +function validateActor(x: IObject, uri: string): IActor { + const expectHost = toPuny(new URL(uri).hostname); + + if (x == null) { + throw new Error('invalid Actor: object is null'); + } + + if (!isActor(x)) { + throw new Error(`invalid Actor type '${x.type}'`); + } + + const validate = (name: string, value: any, validater: Context) => { + const e = validater.test(value); + if (e) throw new Error(`invalid Actor: ${name} ${e.message}`); + }; + + validate('id', x.id, $.str.min(1)); + validate('inbox', x.inbox, $.str.min(1)); + validate('preferredUsername', x.preferredUsername, $.str.min(1).max(128).match(/^\w([\w-.]*\w)?$/)); + + // These fields are only informational, and some AP software allows these + // fields to be very long. If they are too long, we cut them off. This way + // we can at least see these users and their activities. + validate('name', truncate(x.name, nameLength), $.optional.nullable.str); + validate('summary', truncate(x.summary, summaryLength), $.optional.nullable.str); + + const idHost = toPuny(new URL(x.id!).hostname); + if (idHost !== expectHost) { + throw new Error('invalid Actor: id has different host'); + } + + if (x.publicKey) { + if (typeof x.publicKey.id !== 'string') { + throw new Error('invalid Actor: publicKey.id is not a string'); + } + + const publicKeyIdHost = toPuny(new URL(x.publicKey.id).hostname); + if (publicKeyIdHost !== expectHost) { + throw new Error('invalid Actor: publicKey.id has different host'); + } + } + + return x; +} + +/** + * Personをフェッチします。 + * + * Misskeyに対象のPersonが登録されていればそれを返します。 + */ +export async function fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + // URIがこのサーバーを指しているならデータベースからフェッチ + if (uri.startsWith(config.url + '/')) { + const id = uri.split('/').pop(); + return await Users.findOne(id).then(x => x || null); + } + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await Users.findOne({ uri }); + + if (exist) { + return exist; + } + //#endregion + + return null; +} + +/** + * Personを作成します。 + */ +export async function createPerson(uri: string, resolver?: Resolver): Promise<User> { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + if (uri.startsWith(config.url)) { + throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); + } + + if (resolver == null) resolver = new Resolver(); + + const object = await resolver.resolve(uri) as any; + + const person = validateActor(object, uri); + + logger.info(`Creating the Person: ${person.id}`); + + const host = toPuny(new URL(object.id).hostname); + + const { fields } = analyzeAttachments(person.attachment || []); + + const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + + const isBot = getApType(object) === 'Service'; + + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); + + // Create user + let user: IRemoteUser; + try { + // Start transaction + await getConnection().transaction(async transactionalEntityManager => { + user = await transactionalEntityManager.save(new User({ + id: genId(), + avatarId: null, + bannerId: null, + createdAt: new Date(), + lastFetchedAt: new Date(), + name: truncate(person.name, nameLength), + isLocked: !!person.manuallyApprovesFollowers, + isExplorable: !!person.discoverable, + username: person.preferredUsername, + usernameLower: person.preferredUsername!.toLowerCase(), + host, + inbox: person.inbox, + sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers ? getApId(person.followers) : undefined, + featured: person.featured ? getApId(person.featured) : undefined, + uri: person.id, + tags, + isBot, + isCat: (person as any).isCat === true + })) as IRemoteUser; + + await transactionalEntityManager.save(new UserProfile({ + userId: user.id, + description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + url: getOneApHrefNullable(person.url), + fields, + birthday: bday ? bday[0] : null, + location: person['vcard:Address'] || null, + userHost: host + })); + + if (person.publicKey) { + await transactionalEntityManager.save(new UserPublickey({ + userId: user.id, + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem + })); + } + }); + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + // /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応 + const u = await Users.findOne({ + uri: person.id + }); + + if (u) { + user = u as IRemoteUser; + } else { + throw new Error('already registered'); + } + } else { + logger.error(e); + throw e; + } + } + + // Register host + registerOrFetchInstanceDoc(host).then(i => { + Instances.increment({ id: i.id }, 'usersCount', 1); + instanceChart.newUser(i.host); + fetchInstanceMetadata(i); + }); + + usersChart.update(user!, true); + + // ハッシュタグ更新 + updateUsertags(user!, tags); + + //#region アバターとヘッダー画像をフェッチ + const [avatar, banner] = await Promise.all([ + person.icon, + person.image + ].map(img => + img == null + ? Promise.resolve(null) + : resolveImage(user!, img).catch(() => null) + )); + + const avatarId = avatar ? avatar.id : null; + const bannerId = banner ? banner.id : null; + const avatarUrl = avatar ? DriveFiles.getPublicUrl(avatar, true) : null; + const bannerUrl = banner ? DriveFiles.getPublicUrl(banner) : null; + const avatarBlurhash = avatar ? avatar.blurhash : null; + const bannerBlurhash = banner ? banner.blurhash : null; + + await Users.update(user!.id, { + avatarId, + bannerId, + avatarUrl, + bannerUrl, + avatarBlurhash, + bannerBlurhash + }); + + user!.avatarId = avatarId; + user!.bannerId = bannerId; + user!.avatarUrl = avatarUrl; + user!.bannerUrl = bannerUrl; + user!.avatarBlurhash = avatarBlurhash; + user!.bannerBlurhash = bannerBlurhash; + //#endregion + + //#region カスタム絵文字取得 + const emojis = await extractEmojis(person.tag || [], host).catch(e => { + logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + await Users.update(user!.id, { + emojis: emojiNames + }); + //#endregion + + await updateFeatured(user!.id).catch(err => logger.error(err)); + + return user!; +} + +/** + * Personの情報を更新します。 + * Misskeyに対象のPersonが登録されていなければ無視します。 + * @param uri URI of Person + * @param resolver Resolver + * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) + */ +export async function updatePerson(uri: string, resolver?: Resolver | null, hint?: object): Promise<void> { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + // URIがこのサーバーを指しているならスキップ + if (uri.startsWith(config.url + '/')) { + return; + } + + //#region このサーバーに既に登録されているか + const exist = await Users.findOne({ uri }) as IRemoteUser; + + if (exist == null) { + return; + } + //#endregion + + if (resolver == null) resolver = new Resolver(); + + const object = hint || await resolver.resolve(uri) as any; + + const person = validateActor(object, uri); + + logger.info(`Updating the Person: ${person.id}`); + + // アバターとヘッダー画像をフェッチ + const [avatar, banner] = await Promise.all([ + person.icon, + person.image + ].map(img => + img == null + ? Promise.resolve(null) + : resolveImage(exist, img).catch(() => null) + )); + + // カスタム絵文字取得 + const emojis = await extractEmojis(person.tag || [], exist.host).catch(e => { + logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map(emoji => emoji.name); + + const { fields } = analyzeAttachments(person.attachment || []); + + const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32); + + const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); + + const updates = { + lastFetchedAt: new Date(), + inbox: person.inbox, + sharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers ? getApId(person.followers) : undefined, + featured: person.featured, + emojis: emojiNames, + name: truncate(person.name, nameLength), + tags, + isBot: getApType(object) === 'Service', + isCat: (person as any).isCat === true, + isLocked: !!person.manuallyApprovesFollowers, + isExplorable: !!person.discoverable, + } as Partial<User>; + + if (avatar) { + updates.avatarId = avatar.id; + updates.avatarUrl = DriveFiles.getPublicUrl(avatar, true); + updates.avatarBlurhash = avatar.blurhash; + } + + if (banner) { + updates.bannerId = banner.id; + updates.bannerUrl = DriveFiles.getPublicUrl(banner); + updates.bannerBlurhash = banner.blurhash; + } + + // Update user + await Users.update(exist.id, updates); + + if (person.publicKey) { + await UserPublickeys.update({ userId: exist.id }, { + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem + }); + } + + await UserProfiles.update({ userId: exist.id }, { + url: getOneApHrefNullable(person.url), + fields, + description: person.summary ? htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null, + birthday: bday ? bday[0] : null, + location: person['vcard:Address'] || null, + }); + + // ハッシュタグ更新 + updateUsertags(exist, tags); + + // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする + await Followings.update({ + followerId: exist.id + }, { + followerSharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined) + }); + + await updateFeatured(exist.id).catch(err => logger.error(err)); +} + +/** + * Personを解決します。 + * + * Misskeyに対象のPersonが登録されていればそれを返し、そうでなければ + * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + */ +export async function resolvePerson(uri: string, resolver?: Resolver): Promise<User> { + if (typeof uri !== 'string') throw new Error('uri is not string'); + + //#region このサーバーに既に登録されていたらそれを返す + const exist = await fetchPerson(uri); + + if (exist) { + return exist; + } + //#endregion + + // リモートサーバーからフェッチしてきて登録 + if (resolver == null) resolver = new Resolver(); + return await createPerson(uri, resolver); +} + +const services: { + [x: string]: (id: string, username: string) => any + } = { + 'misskey:authentication:twitter': (userId, screenName) => ({ userId, screenName }), + 'misskey:authentication:github': (id, login) => ({ id, login }), + 'misskey:authentication:discord': (id, name) => $discord(id, name) +}; + +const $discord = (id: string, name: string) => { + if (typeof name !== 'string') + name = 'unknown#0000'; + const [username, discriminator] = name.split('#'); + return { id, username, discriminator }; +}; + +function addService(target: { [x: string]: any }, source: IApPropertyValue) { + const service = services[source.name]; + + if (typeof source.value !== 'string') + source.value = 'unknown'; + + const [id, username] = source.value.split('@'); + + if (service) + target[source.name.split(':')[2]] = service(id, username); +} + +export function analyzeAttachments(attachments: IObject | IObject[] | undefined) { + const fields: { + name: string, + value: string + }[] = []; + const services: { [x: string]: any } = {}; + + if (Array.isArray(attachments)) { + for (const attachment of attachments.filter(isPropertyValue)) { + if (isPropertyValue(attachment.identifier)) { + addService(services, attachment.identifier); + } else { + fields.push({ + name: attachment.name, + value: fromHtml(attachment.value) + }); + } + } + } + + return { fields, services }; +} + +export async function updateFeatured(userId: User['id']) { + const user = await Users.findOneOrFail(userId); + if (!Users.isRemoteUser(user)) return; + if (!user.featured) return; + + logger.info(`Updating the featured: ${user.uri}`); + + const resolver = new Resolver(); + + // Resolve to (Ordered)Collection Object + const collection = await resolver.resolveCollection(user.featured); + if (!isCollectionOrOrderedCollection(collection)) throw new Error(`Object is not Collection or OrderedCollection`); + + // Resolve to Object(may be Note) arrays + const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems; + const items = await Promise.all(toArray(unresolvedItems).map(x => resolver.resolve(x))); + + // Resolve and regist Notes + const limit = promiseLimit<Note | null>(2); + const featuredNotes = await Promise.all(items + .filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも + .slice(0, 5) + .map(item => limit(() => resolveNote(item, resolver)))); + + await getConnection().transaction(async transactionalEntityManager => { + await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); + + // とりあえずidを別の時間で生成して順番を維持 + let td = 0; + for (const note of featuredNotes.filter(note => note != null)) { + td -= 1000; + transactionalEntityManager.insert(UserNotePining, { + id: genId(new Date(Date.now() + td)), + createdAt: new Date(), + userId: user.id, + noteId: note!.id + }); + } + }); +} diff --git a/packages/backend/src/remote/activitypub/models/question.ts b/packages/backend/src/remote/activitypub/models/question.ts new file mode 100644 index 0000000000..79f93c3a30 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/question.ts @@ -0,0 +1,83 @@ +import config from '@/config/index'; +import Resolver from '../resolver'; +import { IObject, IQuestion, isQuestion, } from '../type'; +import { apLogger } from '../logger'; +import { Notes, Polls } from '@/models/index'; +import { IPoll } from '@/models/entities/poll'; + +export async function extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> { + if (resolver == null) resolver = new Resolver(); + + const question = await resolver.resolve(source); + + if (!isQuestion(question)) { + throw new Error('invalid type'); + } + + const multiple = !question.oneOf; + const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null; + + if (multiple && !question.anyOf) { + throw new Error('invalid question'); + } + + const choices = question[multiple ? 'anyOf' : 'oneOf']! + .map((x, i) => x.name!); + + const votes = question[multiple ? 'anyOf' : 'oneOf']! + .map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0); + + return { + choices, + votes, + multiple, + expiresAt + }; +} + +/** + * Update votes of Question + * @param uri URI of AP Question object + * @returns true if updated + */ +export async function updateQuestion(value: any) { + const uri = typeof value === 'string' ? value : value.id; + + // URIがこのサーバーを指しているならスキップ + if (uri.startsWith(config.url + '/')) throw new Error('uri points local'); + + //#region このサーバーに既に登録されているか + const note = await Notes.findOne({ uri }); + if (note == null) throw new Error('Question is not registed'); + + const poll = await Polls.findOne({ noteId: note.id }); + if (poll == null) throw new Error('Question is not registed'); + //#endregion + + // resolve new Question object + const resolver = new Resolver(); + const question = await resolver.resolve(value) as IQuestion; + apLogger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); + + if (question.type !== 'Question') throw new Error('object is not a Question'); + + const apChoices = question.oneOf || question.anyOf; + + let changed = false; + + for (const choice of poll.choices) { + const oldCount = poll.votes[poll.choices.indexOf(choice)]; + const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems; + + if (oldCount != newCount) { + changed = true; + poll.votes[poll.choices.indexOf(choice)] = newCount; + } + } + + await Polls.update({ noteId: note.id }, { + votes: poll.votes + }); + + return changed; +} diff --git a/packages/backend/src/remote/activitypub/models/tag.ts b/packages/backend/src/remote/activitypub/models/tag.ts new file mode 100644 index 0000000000..fbc6b9b428 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/tag.ts @@ -0,0 +1,18 @@ +import { toArray } from '@/prelude/array'; +import { IObject, isHashtag, IApHashtag } from '../type'; + +export function extractApHashtags(tags: IObject | IObject[] | null | undefined) { + if (tags == null) return []; + + const hashtags = extractApHashtagObjects(tags); + + return hashtags.map(tag => { + const m = tag.name.match(/^#(.+)/); + return m ? m[1] : null; + }).filter((x): x is string => x != null); +} + +export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] { + if (tags == null) return []; + return toArray(tags).filter(isHashtag); +} diff --git a/packages/backend/src/remote/activitypub/perform.ts b/packages/backend/src/remote/activitypub/perform.ts new file mode 100644 index 0000000000..01f0e3676e --- /dev/null +++ b/packages/backend/src/remote/activitypub/perform.ts @@ -0,0 +1,7 @@ +import { IObject } from './type'; +import { IRemoteUser } from '@/models/entities/user'; +import { performActivity } from './kernel/index'; + +export default async (actor: IRemoteUser, activity: IObject): Promise<void> => { + await performActivity(actor, activity); +}; diff --git a/packages/backend/src/remote/activitypub/renderer/accept.ts b/packages/backend/src/remote/activitypub/renderer/accept.ts new file mode 100644 index 0000000000..f1e61f4c6a --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/accept.ts @@ -0,0 +1,8 @@ +import config from '@/config/index'; +import { User } from '@/models/entities/user'; + +export default (object: any, user: { id: User['id']; host: null }) => ({ + type: 'Accept', + actor: `${config.url}/users/${user.id}`, + object +}); diff --git a/packages/backend/src/remote/activitypub/renderer/add.ts b/packages/backend/src/remote/activitypub/renderer/add.ts new file mode 100644 index 0000000000..21414a9380 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/add.ts @@ -0,0 +1,9 @@ +import config from '@/config/index'; +import { ILocalUser } from '@/models/entities/user'; + +export default (user: ILocalUser, target: any, object: any) => ({ + type: 'Add', + actor: `${config.url}/users/${user.id}`, + target, + object +}); diff --git a/packages/backend/src/remote/activitypub/renderer/announce.ts b/packages/backend/src/remote/activitypub/renderer/announce.ts new file mode 100644 index 0000000000..7bf90922be --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/announce.ts @@ -0,0 +1,29 @@ +import config from '@/config/index'; +import { Note } from '@/models/entities/note'; + +export default (object: any, note: Note) => { + const attributedTo = `${config.url}/users/${note.userId}`; + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === 'public') { + to = ['https://www.w3.org/ns/activitystreams#Public']; + cc = [`${attributedTo}/followers`]; + } else if (note.visibility === 'home') { + to = [`${attributedTo}/followers`]; + cc = ['https://www.w3.org/ns/activitystreams#Public']; + } else { + return null; + } + + return { + id: `${config.url}/notes/${note.id}/activity`, + actor: `${config.url}/users/${note.userId}`, + type: 'Announce', + published: note.createdAt.toISOString(), + to, + cc, + object + }; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/block.ts b/packages/backend/src/remote/activitypub/renderer/block.ts new file mode 100644 index 0000000000..bb3d74295a --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/block.ts @@ -0,0 +1,8 @@ +import config from '@/config/index'; +import { ILocalUser, IRemoteUser } from '@/models/entities/user'; + +export default (blocker: ILocalUser, blockee: IRemoteUser) => ({ + type: 'Block', + actor: `${config.url}/users/${blocker.id}`, + object: blockee.uri +}); diff --git a/packages/backend/src/remote/activitypub/renderer/create.ts b/packages/backend/src/remote/activitypub/renderer/create.ts new file mode 100644 index 0000000000..04aa993a91 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/create.ts @@ -0,0 +1,17 @@ +import config from '@/config/index'; +import { Note } from '@/models/entities/note'; + +export default (object: any, note: Note) => { + const activity = { + id: `${config.url}/notes/${note.id}/activity`, + actor: `${config.url}/users/${note.userId}`, + type: 'Create', + published: note.createdAt.toISOString(), + object + } as any; + + if (object.to) activity.to = object.to; + if (object.cc) activity.cc = object.cc; + + return activity; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/delete.ts b/packages/backend/src/remote/activitypub/renderer/delete.ts new file mode 100644 index 0000000000..176a6f7e27 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/delete.ts @@ -0,0 +1,9 @@ +import config from '@/config/index'; +import { User } from '@/models/entities/user'; + +export default (object: any, user: { id: User['id']; host: null }) => ({ + type: 'Delete', + actor: `${config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), +}); diff --git a/packages/backend/src/remote/activitypub/renderer/document.ts b/packages/backend/src/remote/activitypub/renderer/document.ts new file mode 100644 index 0000000000..a9d86dea15 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/document.ts @@ -0,0 +1,9 @@ +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFiles } from '@/models/index'; + +export default (file: DriveFile) => ({ + type: 'Document', + mediaType: file.type, + url: DriveFiles.getPublicUrl(file), + name: file.comment, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/emoji.ts b/packages/backend/src/remote/activitypub/renderer/emoji.ts new file mode 100644 index 0000000000..ca514c56b5 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/emoji.ts @@ -0,0 +1,14 @@ +import config from '@/config/index'; +import { Emoji } from '@/models/entities/emoji'; + +export default (emoji: Emoji) => ({ + id: `${config.url}/emojis/${emoji.name}`, + type: 'Emoji', + name: `:${emoji.name}:`, + updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString, + icon: { + type: 'Image', + mediaType: emoji.type || 'image/png', + url: emoji.url + } +}); diff --git a/packages/backend/src/remote/activitypub/renderer/follow-relay.ts b/packages/backend/src/remote/activitypub/renderer/follow-relay.ts new file mode 100644 index 0000000000..984c3c7639 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/follow-relay.ts @@ -0,0 +1,14 @@ +import config from '@/config/index'; +import { Relay } from '@/models/entities/relay'; +import { ILocalUser } from '@/models/entities/user'; + +export function renderFollowRelay(relay: Relay, relayActor: ILocalUser) { + const follow = { + id: `${config.url}/activities/follow-relay/${relay.id}`, + type: 'Follow', + actor: `${config.url}/users/${relayActor.id}`, + object: 'https://www.w3.org/ns/activitystreams#Public' + }; + + return follow; +} diff --git a/packages/backend/src/remote/activitypub/renderer/follow-user.ts b/packages/backend/src/remote/activitypub/renderer/follow-user.ts new file mode 100644 index 0000000000..e3dde7f7fe --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/follow-user.ts @@ -0,0 +1,12 @@ +import config from '@/config/index'; +import { Users } from '@/models/index'; +import { User } from '@/models/entities/user'; + +/** + * Convert (local|remote)(Follower|Followee)ID to URL + * @param id Follower|Followee ID + */ +export default async function renderFollowUser(id: User['id']): Promise<any> { + const user = await Users.findOneOrFail(id); + return Users.isLocalUser(user) ? `${config.url}/users/${user.id}` : user.uri; +} diff --git a/packages/backend/src/remote/activitypub/renderer/follow.ts b/packages/backend/src/remote/activitypub/renderer/follow.ts new file mode 100644 index 0000000000..c8a7946799 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/follow.ts @@ -0,0 +1,15 @@ +import config from '@/config/index'; +import { User } from '@/models/entities/user'; +import { Users } from '@/models/index'; + +export default (follower: { id: User['id']; host: User['host']; uri: User['host'] }, followee: { id: User['id']; host: User['host']; uri: User['host'] }, requestId?: string) => { + const follow = { + type: 'Follow', + actor: Users.isLocalUser(follower) ? `${config.url}/users/${follower.id}` : follower.uri, + object: Users.isLocalUser(followee) ? `${config.url}/users/${followee.id}` : followee.uri + } as any; + + if (requestId) follow.id = requestId; + + return follow; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/hashtag.ts b/packages/backend/src/remote/activitypub/renderer/hashtag.ts new file mode 100644 index 0000000000..290c74c7fe --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/hashtag.ts @@ -0,0 +1,7 @@ +import config from '@/config/index'; + +export default (tag: string) => ({ + type: 'Hashtag', + href: `${config.url}/tags/${encodeURIComponent(tag)}`, + name: `#${tag}` +}); diff --git a/packages/backend/src/remote/activitypub/renderer/image.ts b/packages/backend/src/remote/activitypub/renderer/image.ts new file mode 100644 index 0000000000..0cb3d6ed65 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/image.ts @@ -0,0 +1,9 @@ +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFiles } from '@/models/index'; + +export default (file: DriveFile) => ({ + type: 'Image', + url: DriveFiles.getPublicUrl(file), + sensitive: file.isSensitive, + name: file.comment +}); diff --git a/packages/backend/src/remote/activitypub/renderer/index.ts b/packages/backend/src/remote/activitypub/renderer/index.ts new file mode 100644 index 0000000000..f6ec6583d0 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/index.ts @@ -0,0 +1,59 @@ +import config from '@/config/index'; +import { v4 as uuid } from 'uuid'; +import { IActivity } from '../type'; +import { LdSignature } from '../misc/ld-signature'; +import { getUserKeypair } from '@/misc/keypair-store'; +import { User } from '@/models/entities/user'; + +export const renderActivity = (x: any): IActivity | null => { + if (x == null) return null; + + if (x !== null && typeof x === 'object' && x.id == null) { + x.id = `${config.url}/${uuid()}`; + } + + return Object.assign({ + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + { + // as non-standards + manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', + sensitive: 'as:sensitive', + Hashtag: 'as:Hashtag', + quoteUrl: 'as:quoteUrl', + // Mastodon + toot: 'http://joinmastodon.org/ns#', + Emoji: 'toot:Emoji', + featured: 'toot:featured', + discoverable: 'toot:discoverable', + // schema + schema: 'http://schema.org#', + PropertyValue: 'schema:PropertyValue', + value: 'schema:value', + // Misskey + misskey: `${config.url}/ns#`, + '_misskey_content': 'misskey:_misskey_content', + '_misskey_quote': 'misskey:_misskey_quote', + '_misskey_reaction': 'misskey:_misskey_reaction', + '_misskey_votes': 'misskey:_misskey_votes', + '_misskey_talk': 'misskey:_misskey_talk', + 'isCat': 'misskey:isCat', + // vcard + vcard: 'http://www.w3.org/2006/vcard/ns#', + } + ] + }, x); +}; + +export const attachLdSignature = async (activity: any, user: { id: User['id']; host: null; }): Promise<IActivity | null> => { + if (activity == null) return null; + + const keypair = await getUserKeypair(user.id); + + const ldSignature = new LdSignature(); + ldSignature.debug = false; + activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${config.url}/users/${user.id}#main-key`); + + return activity; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/key.ts b/packages/backend/src/remote/activitypub/renderer/key.ts new file mode 100644 index 0000000000..992f98d79a --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/key.ts @@ -0,0 +1,14 @@ +import config from '@/config/index'; +import { ILocalUser } from '@/models/entities/user'; +import { UserKeypair } from '@/models/entities/user-keypair'; +import { createPublicKey } from 'crypto'; + +export default (user: ILocalUser, key: UserKeypair, postfix?: string) => ({ + id: `${config.url}/users/${user.id}${postfix || '/publickey'}`, + type: 'Key', + owner: `${config.url}/users/${user.id}`, + publicKeyPem: createPublicKey(key.publicKey).export({ + type: 'spki', + format: 'pem' + }) +}); diff --git a/packages/backend/src/remote/activitypub/renderer/like.ts b/packages/backend/src/remote/activitypub/renderer/like.ts new file mode 100644 index 0000000000..a7e79a176f --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/like.ts @@ -0,0 +1,30 @@ +import config from '@/config/index'; +import { NoteReaction } from '@/models/entities/note-reaction'; +import { Note } from '@/models/entities/note'; +import { Emojis } from '@/models/index'; +import renderEmoji from './emoji'; + +export const renderLike = async (noteReaction: NoteReaction, note: Note) => { + const reaction = noteReaction.reaction; + + const object = { + type: 'Like', + id: `${config.url}/likes/${noteReaction.id}`, + actor: `${config.url}/users/${noteReaction.userId}`, + object: note.uri ? note.uri : `${config.url}/notes/${noteReaction.noteId}`, + content: reaction, + _misskey_reaction: reaction + } as any; + + if (reaction.startsWith(':')) { + const name = reaction.replace(/:/g, ''); + const emoji = await Emojis.findOne({ + name, + host: null + }); + + if (emoji) object.tag = [ renderEmoji(emoji) ]; + } + + return object; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/mention.ts b/packages/backend/src/remote/activitypub/renderer/mention.ts new file mode 100644 index 0000000000..06d2d33e59 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/mention.ts @@ -0,0 +1,9 @@ +import config from '@/config/index'; +import { User, ILocalUser } from '@/models/entities/user'; +import { Users } from '@/models/index'; + +export default (mention: User) => ({ + type: 'Mention', + href: Users.isRemoteUser(mention) ? mention.uri : `${config.url}/users/${(mention as ILocalUser).id}`, + name: Users.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as ILocalUser).username}`, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/note.ts b/packages/backend/src/remote/activitypub/renderer/note.ts new file mode 100644 index 0000000000..84a1786784 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/note.ts @@ -0,0 +1,168 @@ +import renderDocument from './document'; +import renderHashtag from './hashtag'; +import renderMention from './mention'; +import renderEmoji from './emoji'; +import config from '@/config/index'; +import toHtml from '../misc/get-note-html'; +import { Note, IMentionedRemoteUsers } from '@/models/entities/note'; +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFiles, Notes, Users, Emojis, Polls } from '@/models/index'; +import { In } from 'typeorm'; +import { Emoji } from '@/models/entities/emoji'; +import { Poll } from '@/models/entities/poll'; + +export default async function renderNote(note: Note, dive = true, isTalk = false): Promise<any> { + const getPromisedFiles = async (ids: string[]) => { + if (!ids || ids.length === 0) return []; + const items = await DriveFiles.find({ id: In(ids) }); + return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; + }; + + let inReplyTo; + let inReplyToNote: Note | undefined; + + if (note.replyId) { + inReplyToNote = await Notes.findOne(note.replyId); + + if (inReplyToNote != null) { + const inReplyToUser = await Users.findOne(inReplyToNote.userId); + + if (inReplyToUser != null) { + if (inReplyToNote.uri) { + inReplyTo = inReplyToNote.uri; + } else { + if (dive) { + inReplyTo = await renderNote(inReplyToNote, false); + } else { + inReplyTo = `${config.url}/notes/${inReplyToNote.id}`; + } + } + } + } + } else { + inReplyTo = null; + } + + let quote; + + if (note.renoteId) { + const renote = await Notes.findOne(note.renoteId); + + if (renote) { + quote = renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`; + } + } + + const user = await Users.findOneOrFail(note.userId); + + const attributedTo = `${config.url}/users/${user.id}`; + + const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === 'public') { + to = ['https://www.w3.org/ns/activitystreams#Public']; + cc = [`${attributedTo}/followers`].concat(mentions); + } else if (note.visibility === 'home') { + to = [`${attributedTo}/followers`]; + cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions); + } else if (note.visibility === 'followers') { + to = [`${attributedTo}/followers`]; + cc = mentions; + } else { + to = mentions; + } + + const mentionedUsers = note.mentions.length > 0 ? await Users.find({ + id: In(note.mentions) + }) : []; + + const hashtagTags = (note.tags || []).map(tag => renderHashtag(tag)); + const mentionTags = mentionedUsers.map(u => renderMention(u)); + + const files = await getPromisedFiles(note.fileIds); + + const text = note.text; + let poll: Poll | undefined; + + if (note.hasPoll) { + poll = await Polls.findOne({ noteId: note.id }); + } + + let apText = text; + if (apText == null) apText = ''; + + if (quote) { + apText += `\n\nRE: ${quote}`; + } + + const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; + + const content = toHtml(Object.assign({}, note, { + text: apText + })); + + const emojis = await getEmojis(note.emojis); + const apemojis = emojis.map(emoji => renderEmoji(emoji)); + + const tag = [ + ...hashtagTags, + ...mentionTags, + ...apemojis, + ]; + + const asPoll = poll ? { + type: 'Question', + content: toHtml(Object.assign({}, note, { + text: text + })), + [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt, + [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ + type: 'Note', + name: text, + replies: { + type: 'Collection', + totalItems: poll!.votes[i] + } + })) + } : {}; + + const asTalk = isTalk ? { + _misskey_talk: true + } : {}; + + return { + id: `${config.url}/notes/${note.id}`, + type: 'Note', + attributedTo, + summary, + content, + _misskey_content: text, + _misskey_quote: quote, + quoteUrl: quote, + published: note.createdAt.toISOString(), + to, + cc, + inReplyTo, + attachment: files.map(renderDocument), + sensitive: note.cw != null || files.some(file => file.isSensitive), + tag, + ...asPoll, + ...asTalk + }; +} + +export async function getEmojis(names: string[]): Promise<Emoji[]> { + if (names == null || names.length === 0) return []; + + const emojis = await Promise.all( + names.map(name => Emojis.findOne({ + name, + host: null + })) + ); + + return emojis.filter(emoji => emoji != null) as Emoji[]; +} diff --git a/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts b/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts new file mode 100644 index 0000000000..2433358646 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts @@ -0,0 +1,23 @@ +/** + * Render OrderedCollectionPage + * @param id URL of self + * @param totalItems Number of total items + * @param orderedItems Items + * @param partOf URL of base + * @param prev URL of prev page (optional) + * @param next URL of next page (optional) + */ +export default function(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) { + const page = { + id, + partOf, + type: 'OrderedCollectionPage', + totalItems, + orderedItems + } as any; + + if (prev) page.prev = prev; + if (next) page.next = next; + + return page; +} diff --git a/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts b/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts new file mode 100644 index 0000000000..68870a0ecd --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts @@ -0,0 +1,21 @@ +/** + * Render OrderedCollection + * @param id URL of self + * @param totalItems Total number of items + * @param first URL of first page (optional) + * @param last URL of last page (optional) + * @param orderedItems attached objects (optional) + */ +export default function(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: object) { + const page: any = { + id, + type: 'OrderedCollection', + totalItems, + }; + + if (first) page.first = first; + if (last) page.last = last; + if (orderedItems) page.orderedItems = orderedItems; + + return page; +} diff --git a/packages/backend/src/remote/activitypub/renderer/person.ts b/packages/backend/src/remote/activitypub/renderer/person.ts new file mode 100644 index 0000000000..f2a283a870 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/person.ts @@ -0,0 +1,89 @@ +import { URL } from 'url'; +import * as mfm from 'mfm-js'; +import renderImage from './image'; +import renderKey from './key'; +import config from '@/config/index'; +import { ILocalUser } from '@/models/entities/user'; +import { toHtml } from '../../../mfm/to-html'; +import { getEmojis } from './note'; +import renderEmoji from './emoji'; +import { IIdentifier } from '../models/identifier'; +import renderHashtag from './hashtag'; +import { DriveFiles, UserProfiles } from '@/models/index'; +import { getUserKeypair } from '@/misc/keypair-store'; + +export async function renderPerson(user: ILocalUser) { + const id = `${config.url}/users/${user.id}`; + const isSystem = !!user.username.match(/\./); + + const [avatar, banner, profile] = await Promise.all([ + user.avatarId ? DriveFiles.findOne(user.avatarId) : Promise.resolve(undefined), + user.bannerId ? DriveFiles.findOne(user.bannerId) : Promise.resolve(undefined), + UserProfiles.findOneOrFail(user.id) + ]); + + const attachment: { + type: 'PropertyValue', + name: string, + value: string, + identifier?: IIdentifier + }[] = []; + + if (profile.fields) { + for (const field of profile.fields) { + attachment.push({ + type: 'PropertyValue', + name: field.name, + value: (field.value != null && field.value.match(/^https?:/)) + ? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>` + : field.value + }); + } + } + + const emojis = await getEmojis(user.emojis); + const apemojis = emojis.map(emoji => renderEmoji(emoji)); + + const hashtagTags = (user.tags || []).map(tag => renderHashtag(tag)); + + const tag = [ + ...apemojis, + ...hashtagTags, + ]; + + const keypair = await getUserKeypair(user.id); + + const person = { + type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', + id, + inbox: `${id}/inbox`, + outbox: `${id}/outbox`, + followers: `${id}/followers`, + following: `${id}/following`, + featured: `${id}/collections/featured`, + sharedInbox: `${config.url}/inbox`, + endpoints: { sharedInbox: `${config.url}/inbox` }, + url: `${config.url}/@${user.username}`, + preferredUsername: user.username, + name: user.name, + summary: profile.description ? toHtml(mfm.parse(profile.description)) : null, + icon: avatar ? renderImage(avatar) : null, + image: banner ? renderImage(banner) : null, + tag, + manuallyApprovesFollowers: user.isLocked, + discoverable: !!user.isExplorable, + publicKey: renderKey(user, keypair, `#main-key`), + isCat: user.isCat, + attachment: attachment.length ? attachment : undefined + } as any; + + if (profile?.birthday) { + person['vcard:bday'] = profile.birthday; + } + + if (profile?.location) { + person['vcard:Address'] = profile.location; + } + + return person; +} diff --git a/packages/backend/src/remote/activitypub/renderer/question.ts b/packages/backend/src/remote/activitypub/renderer/question.ts new file mode 100644 index 0000000000..246d599bab --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/question.ts @@ -0,0 +1,23 @@ +import config from '@/config/index'; +import { User } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { Poll } from '@/models/entities/poll'; + +export default async function renderQuestion(user: { id: User['id'] }, note: Note, poll: Poll) { + const question = { + type: 'Question', + id: `${config.url}/questions/${note.id}`, + actor: `${config.url}/users/${user.id}`, + content: note.text || '', + [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ + name: text, + _misskey_votes: poll.votes[i], + replies: { + type: 'Collection', + totalItems: poll.votes[i] + } + })) + }; + + return question; +} diff --git a/packages/backend/src/remote/activitypub/renderer/read.ts b/packages/backend/src/remote/activitypub/renderer/read.ts new file mode 100644 index 0000000000..95357f64d3 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/read.ts @@ -0,0 +1,9 @@ +import config from '@/config/index'; +import { User } from '@/models/entities/user'; +import { MessagingMessage } from '@/models/entities/messaging-message'; + +export const renderReadActivity = (user: { id: User['id'] }, message: MessagingMessage) => ({ + type: 'Read', + actor: `${config.url}/users/${user.id}`, + object: message.uri +}); diff --git a/packages/backend/src/remote/activitypub/renderer/reject.ts b/packages/backend/src/remote/activitypub/renderer/reject.ts new file mode 100644 index 0000000000..42beffecf2 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/reject.ts @@ -0,0 +1,8 @@ +import config from '@/config/index'; +import { User } from '@/models/entities/user'; + +export default (object: any, user: { id: User['id'] }) => ({ + type: 'Reject', + actor: `${config.url}/users/${user.id}`, + object +}); diff --git a/packages/backend/src/remote/activitypub/renderer/remove.ts b/packages/backend/src/remote/activitypub/renderer/remove.ts new file mode 100644 index 0000000000..79d60edbaa --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/remove.ts @@ -0,0 +1,9 @@ +import config from '@/config/index'; +import { User } from '@/models/entities/user'; + +export default (user: { id: User['id'] }, target: any, object: any) => ({ + type: 'Remove', + actor: `${config.url}/users/${user.id}`, + target, + object +}); diff --git a/packages/backend/src/remote/activitypub/renderer/tombstone.ts b/packages/backend/src/remote/activitypub/renderer/tombstone.ts new file mode 100644 index 0000000000..553406b93b --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/tombstone.ts @@ -0,0 +1,4 @@ +export default (id: string) => ({ + id, + type: 'Tombstone' +}); diff --git a/packages/backend/src/remote/activitypub/renderer/undo.ts b/packages/backend/src/remote/activitypub/renderer/undo.ts new file mode 100644 index 0000000000..14115b788d --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/undo.ts @@ -0,0 +1,13 @@ +import config from '@/config/index'; +import { ILocalUser, User } from '@/models/entities/user'; + +export default (object: any, user: { id: User['id'] }) => { + if (object == null) return null; + + return { + type: 'Undo', + actor: `${config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), + }; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/update.ts b/packages/backend/src/remote/activitypub/renderer/update.ts new file mode 100644 index 0000000000..8bb415d117 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/update.ts @@ -0,0 +1,15 @@ +import config from '@/config/index'; +import { User } from '@/models/entities/user'; + +export default (object: any, user: { id: User['id'] }) => { + const activity = { + id: `${config.url}/users/${user.id}#updates/${new Date().getTime()}`, + actor: `${config.url}/users/${user.id}`, + type: 'Update', + to: [ 'https://www.w3.org/ns/activitystreams#Public' ], + object, + published: new Date().toISOString(), + } as any; + + return activity; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/vote.ts b/packages/backend/src/remote/activitypub/renderer/vote.ts new file mode 100644 index 0000000000..ff038070f7 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/vote.ts @@ -0,0 +1,23 @@ +import config from '@/config/index'; +import { Note } from '@/models/entities/note'; +import { IRemoteUser, User } from '@/models/entities/user'; +import { PollVote } from '@/models/entities/poll-vote'; +import { Poll } from '@/models/entities/poll'; + +export default async function renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: IRemoteUser): Promise<any> { + return { + id: `${config.url}/users/${user.id}#votes/${vote.id}/activity`, + actor: `${config.url}/users/${user.id}`, + type: 'Create', + to: [pollOwner.uri], + published: new Date().toISOString(), + object: { + id: `${config.url}/users/${user.id}#votes/${vote.id}`, + type: 'Note', + attributedTo: `${config.url}/users/${user.id}`, + to: [pollOwner.uri], + inReplyTo: note.uri, + name: poll.choices[vote.choice] + } + }; +} diff --git a/packages/backend/src/remote/activitypub/request.ts b/packages/backend/src/remote/activitypub/request.ts new file mode 100644 index 0000000000..d6ced630c1 --- /dev/null +++ b/packages/backend/src/remote/activitypub/request.ts @@ -0,0 +1,58 @@ +import config from '@/config/index'; +import { getUserKeypair } from '@/misc/keypair-store'; +import { User } from '@/models/entities/user'; +import { getResponse } from '../../misc/fetch'; +import { createSignedPost, createSignedGet } from './ap-request'; + +export default async (user: { id: User['id'] }, url: string, object: any) => { + const body = JSON.stringify(object); + + const keypair = await getUserKeypair(user.id); + + const req = createSignedPost({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${config.url}/users/${user.id}#main-key` + }, + url, + body, + additionalHeaders: { + 'User-Agent': config.userAgent, + } + }); + + await getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + body, + }); +}; + +/** + * Get AP object with http-signature + * @param user http-signature user + * @param url URL to fetch + */ +export async function signedGet(url: string, user: { id: User['id'] }) { + const keypair = await getUserKeypair(user.id); + + const req = createSignedGet({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${config.url}/users/${user.id}#main-key` + }, + url, + additionalHeaders: { + 'User-Agent': config.userAgent, + } + }); + + const res = await getResponse({ + url, + method: req.request.method, + headers: req.request.headers + }); + + return await res.json(); +} diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts new file mode 100644 index 0000000000..f392a65e3a --- /dev/null +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -0,0 +1,73 @@ +import config from '@/config/index'; +import { getJson } from '@/misc/fetch'; +import { ILocalUser } from '@/models/entities/user'; +import { getInstanceActor } from '@/services/instance-actor'; +import { signedGet } from './request'; +import { IObject, isCollectionOrOrderedCollection, ICollection, IOrderedCollection } from './type'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { extractDbHost } from '@/misc/convert-host'; + +export default class Resolver { + private history: Set<string>; + private user?: ILocalUser; + + constructor() { + this.history = new Set(); + } + + public getHistory(): string[] { + return Array.from(this.history); + } + + public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> { + const collection = typeof value === 'string' + ? await this.resolve(value) + : value; + + if (isCollectionOrOrderedCollection(collection)) { + return collection; + } else { + throw new Error(`unrecognized collection type: ${collection.type}`); + } + } + + public async resolve(value: string | IObject): Promise<IObject> { + if (value == null) { + throw new Error('resolvee is null (or undefined)'); + } + + if (typeof value !== 'string') { + return value; + } + + if (this.history.has(value)) { + throw new Error('cannot resolve already resolved one'); + } + + this.history.add(value); + + const meta = await fetchMeta(); + const host = extractDbHost(value); + if (meta.blockedHosts.includes(host)) { + throw new Error('Instance is blocked'); + } + + if (config.signToActivityPubGet && !this.user) { + this.user = await getInstanceActor(); + } + + const object = this.user + ? await signedGet(value, this.user) + : await getJson(value, 'application/activity+json, application/ld+json'); + + if (object == null || ( + Array.isArray(object['@context']) ? + !object['@context'].includes('https://www.w3.org/ns/activitystreams') : + object['@context'] !== 'https://www.w3.org/ns/activitystreams' + )) { + throw new Error('invalid response'); + } + + return object; + } +} diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts new file mode 100644 index 0000000000..2051d2624d --- /dev/null +++ b/packages/backend/src/remote/activitypub/type.ts @@ -0,0 +1,289 @@ +export type obj = { [x: string]: any }; +export type ApObject = IObject | string | (IObject | string)[]; + +export interface IObject { + '@context': string | obj | obj[]; + type: string | string[]; + id?: string; + summary?: string; + published?: string; + cc?: ApObject; + to?: ApObject; + attributedTo: ApObject; + attachment?: any[]; + inReplyTo?: any; + replies?: ICollection; + content?: string; + name?: string; + startTime?: Date; + endTime?: Date; + icon?: any; + image?: any; + url?: ApObject; + href?: string; + tag?: IObject | IObject[]; + sensitive?: boolean; +} + +/** + * Get array of ActivityStreams Objects id + */ +export function getApIds(value: ApObject | undefined): string[] { + if (value == null) return []; + const array = Array.isArray(value) ? value : [value]; + return array.map(x => getApId(x)); +} + +/** + * Get first ActivityStreams Object id + */ +export function getOneApId(value: ApObject): string { + const firstOne = Array.isArray(value) ? value[0] : value; + return getApId(firstOne); +} + +/** + * Get ActivityStreams Object id + */ +export function getApId(value: string | IObject): string { + if (typeof value === 'string') return value; + if (typeof value.id === 'string') return value.id; + throw new Error(`cannot detemine id`); +} + +/** + * Get ActivityStreams Object type + */ +export function getApType(value: IObject): string { + if (typeof value.type === 'string') return value.type; + if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0]; + throw new Error(`cannot detect type`); +} + +export function getOneApHrefNullable(value: ApObject | undefined): string | undefined { + const firstOne = Array.isArray(value) ? value[0] : value; + return getApHrefNullable(firstOne); +} + +export function getApHrefNullable(value: string | IObject | undefined): string | undefined { + if (typeof value === 'string') return value; + if (typeof value?.href === 'string') return value.href; + return undefined; +} + +export interface IActivity extends IObject { + //type: 'Activity'; + actor: IObject | string; + object: IObject | string; + target?: IObject | string; + /** LD-Signature */ + signature?: { + type: string; + created: Date; + creator: string; + domain?: string; + nonce?: string; + signatureValue: string; + }; +} + +export interface ICollection extends IObject { + type: 'Collection'; + totalItems: number; + items: ApObject; +} + +export interface IOrderedCollection extends IObject { + type: 'OrderedCollection'; + totalItems: number; + orderedItems: ApObject; +} + +export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event']; + +export const isPost = (object: IObject): object is IPost => + validPost.includes(getApType(object)); + +export interface IPost extends IObject { + type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event'; + _misskey_content?: string; + _misskey_quote?: string; + quoteUrl?: string; + _misskey_talk: boolean; +} + +export interface IQuestion extends IObject { + type: 'Note' | 'Question'; + _misskey_content?: string; + _misskey_quote?: string; + quoteUrl?: string; + oneOf?: IQuestionChoice[]; + anyOf?: IQuestionChoice[]; + endTime?: Date; + closed?: Date; +} + +export const isQuestion = (object: IObject): object is IQuestion => + getApType(object) === 'Note' || getApType(object) === 'Question'; + +interface IQuestionChoice { + name?: string; + replies?: ICollection; + _misskey_votes?: number; +} +export interface ITombstone extends IObject { + type: 'Tombstone'; + formerType?: string; + deleted?: Date; +} + +export const isTombstone = (object: IObject): object is ITombstone => + getApType(object) === 'Tombstone'; + +export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application']; + +export const isActor = (object: IObject): object is IActor => + validActor.includes(getApType(object)); + +export interface IActor extends IObject { + type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application'; + name?: string; + preferredUsername?: string; + manuallyApprovesFollowers?: boolean; + discoverable?: boolean; + inbox: string; + sharedInbox?: string; // 後方互換性のため + publicKey?: { + id: string; + publicKeyPem: string; + }; + followers?: string | ICollection | IOrderedCollection; + following?: string | ICollection | IOrderedCollection; + featured?: string | IOrderedCollection; + outbox: string | IOrderedCollection; + endpoints?: { + sharedInbox?: string; + }; + 'vcard:bday'?: string; + 'vcard:Address'?: string; +} + +export const isCollection = (object: IObject): object is ICollection => + getApType(object) === 'Collection'; + +export const isOrderedCollection = (object: IObject): object is IOrderedCollection => + getApType(object) === 'OrderedCollection'; + +export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection => + isCollection(object) || isOrderedCollection(object); + +export interface IApPropertyValue extends IObject { + type: 'PropertyValue'; + identifier: IApPropertyValue; + name: string; + value: string; +} + +export const isPropertyValue = (object: IObject): object is IApPropertyValue => + object && + getApType(object) === 'PropertyValue' && + typeof object.name === 'string' && + typeof (object as any).value === 'string'; + +export interface IApMention extends IObject { + type: 'Mention'; + href: string; +} + +export const isMention = (object: IObject): object is IApMention=> + getApType(object) === 'Mention' && + typeof object.href === 'string'; + +export interface IApHashtag extends IObject { + type: 'Hashtag'; + name: string; +} + +export const isHashtag = (object: IObject): object is IApHashtag => + getApType(object) === 'Hashtag' && + typeof object.name === 'string'; + +export interface IApEmoji extends IObject { + type: 'Emoji'; + updated: Date; +} + +export const isEmoji = (object: IObject): object is IApEmoji => + getApType(object) === 'Emoji' && !Array.isArray(object.icon) && object.icon.url != null; + +export interface ICreate extends IActivity { + type: 'Create'; +} + +export interface IDelete extends IActivity { + type: 'Delete'; +} + +export interface IUpdate extends IActivity { + type: 'Update'; +} + +export interface IRead extends IActivity { + type: 'Read'; +} + +export interface IUndo extends IActivity { + type: 'Undo'; +} + +export interface IFollow extends IActivity { + type: 'Follow'; +} + +export interface IAccept extends IActivity { + type: 'Accept'; +} + +export interface IReject extends IActivity { + type: 'Reject'; +} + +export interface IAdd extends IActivity { + type: 'Add'; +} + +export interface IRemove extends IActivity { + type: 'Remove'; +} + +export interface ILike extends IActivity { + type: 'Like' | 'EmojiReaction' | 'EmojiReact'; + _misskey_reaction?: string; +} + +export interface IAnnounce extends IActivity { + type: 'Announce'; +} + +export interface IBlock extends IActivity { + type: 'Block'; +} + +export interface IFlag extends IActivity { + type: 'Flag'; +} + +export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; +export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; +export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; +export const isRead = (object: IObject): object is IRead => getApType(object) === 'Read'; +export const isUndo = (object: IObject): object is IUndo => getApType(object) === 'Undo'; +export const isFollow = (object: IObject): object is IFollow => getApType(object) === 'Follow'; +export const isAccept = (object: IObject): object is IAccept => getApType(object) === 'Accept'; +export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject'; +export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add'; +export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove'; +export const isLike = (object: IObject): object is ILike => getApType(object) === 'Like' || getApType(object) === 'EmojiReaction' || getApType(object) === 'EmojiReact'; +export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; +export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; +export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; diff --git a/packages/backend/src/remote/logger.ts b/packages/backend/src/remote/logger.ts new file mode 100644 index 0000000000..9ffad4d716 --- /dev/null +++ b/packages/backend/src/remote/logger.ts @@ -0,0 +1,3 @@ +import Logger from '@/services/logger'; + +export const remoteLogger = new Logger('remote', 'cyan'); diff --git a/packages/backend/src/remote/resolve-user.ts b/packages/backend/src/remote/resolve-user.ts new file mode 100644 index 0000000000..a12396abc8 --- /dev/null +++ b/packages/backend/src/remote/resolve-user.ts @@ -0,0 +1,110 @@ +import { URL } from 'url'; +import webFinger from './webfinger'; +import config from '@/config/index'; +import { createPerson, updatePerson } from './activitypub/models/person'; +import { remoteLogger } from './logger'; +import * as chalk from 'chalk'; +import { User, IRemoteUser } from '@/models/entities/user'; +import { Users } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; + +const logger = remoteLogger.createSubLogger('resolve-user'); + +export async function resolveUser(username: string, host: string | null, option?: any, resync = false): Promise<User> { + const usernameLower = username.toLowerCase(); + + if (host == null) { + logger.info(`return local user: ${usernameLower}`); + return await Users.findOne({ usernameLower, host: null }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + host = toPuny(host); + + if (config.host == host) { + logger.info(`return local user: ${usernameLower}`); + return await Users.findOne({ usernameLower, host: null }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + const user = await Users.findOne({ usernameLower, host }, option) as IRemoteUser; + + const acctLower = `${usernameLower}@${host}`; + + if (user == null) { + const self = await resolveSelf(acctLower); + + logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`); + return await createPerson(self.href); + } + + // resyncオプション OR ユーザー情報が古い場合は、WebFilgerからやりなおして返す + if (resync || user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { + // 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する + await Users.update(user.id, { + lastFetchedAt: new Date(), + }); + + logger.info(`try resync: ${acctLower}`); + const self = await resolveSelf(acctLower); + + if (user.uri !== self.href) { + // if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping. + logger.info(`uri missmatch: ${acctLower}`); + logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`); + + // validate uri + const uri = new URL(self.href); + if (uri.hostname !== host) { + throw new Error(`Invalid uri`); + } + + await Users.update({ + usernameLower, + host: host + }, { + uri: self.href + }); + } else { + logger.info(`uri is fine: ${acctLower}`); + } + + await updatePerson(self.href); + + logger.info(`return resynced remote user: ${acctLower}`); + return await Users.findOne({ uri: self.href }).then(u => { + if (u == null) { + throw new Error('user not found'); + } else { + return u; + } + }); + } + + logger.info(`return existing remote user: ${acctLower}`); + return user; +} + +async function resolveSelf(acctLower: string) { + logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); + const finger = await webFinger(acctLower).catch(e => { + logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ e.statusCode || e.message }`); + throw new Error(`Failed to WebFinger for ${acctLower}: ${ e.statusCode || e.message }`); + }); + const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self'); + if (!self) { + logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`); + throw new Error('self link not found'); + } + return self; +} diff --git a/packages/backend/src/remote/webfinger.ts b/packages/backend/src/remote/webfinger.ts new file mode 100644 index 0000000000..f63fd03628 --- /dev/null +++ b/packages/backend/src/remote/webfinger.ts @@ -0,0 +1,34 @@ +import { URL } from 'url'; +import { getJson } from '@/misc/fetch'; +import { query as urlQuery } from '@/prelude/url'; + +type ILink = { + href: string; + rel?: string; +}; + +type IWebFinger = { + links: ILink[]; + subject: string; +}; + +export default async function(query: string): Promise<IWebFinger> { + const url = genUrl(query); + + return await getJson(url, 'application/jrd+json, application/json'); +} + +function genUrl(query: string) { + if (query.match(/^https?:\/\//)) { + const u = new URL(query); + return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query }); + } + + const m = query.match(/^([^@]+)@(.*)/); + if (m) { + const hostname = m[2]; + return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); + } + + throw new Error(`Invalid query (${query})`); +} diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts new file mode 100644 index 0000000000..eabe681136 --- /dev/null +++ b/packages/backend/src/server/activitypub.ts @@ -0,0 +1,227 @@ +import * as Router from '@koa/router'; +import * as json from 'koa-json-body'; +import * as httpSignature from 'http-signature'; + +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderNote from '@/remote/activitypub/renderer/note'; +import renderKey from '@/remote/activitypub/renderer/key'; +import { renderPerson } from '@/remote/activitypub/renderer/person'; +import renderEmoji from '@/remote/activitypub/renderer/emoji'; +import Outbox, { packActivity } from './activitypub/outbox'; +import Followers from './activitypub/followers'; +import Following from './activitypub/following'; +import Featured from './activitypub/featured'; +import { inbox as processInbox } from '@/queue/index'; +import { isSelfHost } from '@/misc/convert-host'; +import { Notes, Users, Emojis, NoteReactions } from '@/models/index'; +import { ILocalUser, User } from '@/models/entities/user'; +import { In } from 'typeorm'; +import { renderLike } from '@/remote/activitypub/renderer/like'; +import { getUserKeypair } from '@/misc/keypair-store'; + +// Init router +const router = new Router(); + +//#region Routing + +function inbox(ctx: Router.RouterContext) { + let signature; + + try { + signature = httpSignature.parseRequest(ctx.req, { 'headers': [] }); + } catch (e) { + ctx.status = 401; + return; + } + + processInbox(ctx.request.body, signature); + + ctx.status = 202; +} + +const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; +const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; + +function isActivityPubReq(ctx: Router.RouterContext) { + ctx.response.vary('Accept'); + const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON); + return typeof accepted === 'string' && !accepted.match(/html/); +} + +export function setResponseType(ctx: Router.RouterContext) { + const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON); + if (accept === LD_JSON) { + ctx.response.type = LD_JSON; + } else { + ctx.response.type = ACTIVITY_JSON; + } +} + +// inbox +router.post('/inbox', json(), inbox); +router.post('/users/:user/inbox', json(), inbox); + +// note +router.get('/notes/:note', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const note = await Notes.findOne({ + id: ctx.params.note, + visibility: In(['public', 'home']), + localOnly: false + }); + + if (note == null) { + ctx.status = 404; + return; + } + + // リモートだったらリダイレクト + if (note.userHost != null) { + if (note.uri == null || isSelfHost(note.userHost)) { + ctx.status = 500; + return; + } + ctx.redirect(note.uri); + return; + } + + ctx.body = renderActivity(await renderNote(note, false)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +// note activity +router.get('/notes/:note/activity', async ctx => { + const note = await Notes.findOne({ + id: ctx.params.note, + userHost: null, + visibility: In(['public', 'home']), + localOnly: false + }); + + if (note == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await packActivity(note)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +// outbox +router.get('/users/:user/outbox', Outbox); + +// followers +router.get('/users/:user/followers', Followers); + +// following +router.get('/users/:user/following', Following); + +// featured +router.get('/users/:user/collections/featured', Featured); + +// publickey +router.get('/users/:user/publickey', async ctx => { + const userId = ctx.params.user; + + const user = await Users.findOne({ + id: userId, + host: null + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const keypair = await getUserKeypair(user.id); + + if (Users.isLocalUser(user)) { + ctx.body = renderActivity(renderKey(user, keypair)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); + } else { + ctx.status = 400; + } +}); + +// user +async function userInfo(ctx: Router.RouterContext, user: User | undefined) { + if (user == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await renderPerson(user as ILocalUser)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +} + +router.get('/users/:user', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const userId = ctx.params.user; + + const user = await Users.findOne({ + id: userId, + host: null, + isSuspended: false + }); + + await userInfo(ctx, user); +}); + +router.get('/@:user', async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const user = await Users.findOne({ + usernameLower: ctx.params.user.toLowerCase(), + host: null, + isSuspended: false + }); + + await userInfo(ctx, user); +}); +//#endregion + +// emoji +router.get('/emojis/:emoji', async ctx => { + const emoji = await Emojis.findOne({ + host: null, + name: ctx.params.emoji + }); + + if (emoji == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await renderEmoji(emoji)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +// like +router.get('/likes/:like', async ctx => { + const reaction = await NoteReactions.findOne(ctx.params.like); + + if (reaction == null) { + ctx.status = 404; + return; + } + + const note = await Notes.findOne(reaction.noteId); + + if (note == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await renderLike(reaction, note)); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}); + +export default router; diff --git a/packages/backend/src/server/activitypub/featured.ts b/packages/backend/src/server/activitypub/featured.ts new file mode 100644 index 0000000000..1598cc680f --- /dev/null +++ b/packages/backend/src/server/activitypub/featured.ts @@ -0,0 +1,41 @@ +import * as Router from '@koa/router'; +import config from '@/config/index'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection'; +import { setResponseType } from '../activitypub'; +import renderNote from '@/remote/activitypub/renderer/note'; +import { Users, Notes, UserNotePinings } from '@/models/index'; + +export default async (ctx: Router.RouterContext) => { + const userId = ctx.params.user; + + // Verify user + const user = await Users.findOne({ + id: userId, + host: null + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const pinings = await UserNotePinings.find({ + where: { userId: user.id }, + order: { id: 'DESC' } + }); + + const pinnedNotes = await Promise.all(pinings.map(pining => + Notes.findOneOrFail(pining.noteId))); + + const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note))); + + const rendered = renderOrderedCollection( + `${config.url}/users/${userId}/collections/featured`, + renderedNotes.length, undefined, undefined, renderedNotes + ); + + ctx.body = renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); +}; diff --git a/packages/backend/src/server/activitypub/followers.ts b/packages/backend/src/server/activitypub/followers.ts new file mode 100644 index 0000000000..baf2d23460 --- /dev/null +++ b/packages/backend/src/server/activitypub/followers.ts @@ -0,0 +1,102 @@ +import * as Router from '@koa/router'; +import config from '@/config/index'; +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as url from '@/prelude/url'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page'; +import renderFollowUser from '@/remote/activitypub/renderer/follow-user'; +import { setResponseType } from '../activitypub'; +import { Users, Followings, UserProfiles } from '@/models/index'; +import { LessThan } from 'typeorm'; + +export default async (ctx: Router.RouterContext) => { + const userId = ctx.params.user; + + // Get 'cursor' parameter + const [cursor, cursorErr] = $.optional.type(ID).get(ctx.request.query.cursor); + + // Get 'page' parameter + const pageErr = !$.optional.str.or(['true', 'false']).ok(ctx.request.query.page); + const page: boolean = ctx.request.query.page === 'true'; + + // Validate parameters + if (cursorErr || pageErr) { + ctx.status = 400; + return; + } + + // Verify user + const user = await Users.findOne({ + id: userId, + host: null + }); + + if (user == null) { + ctx.status = 404; + return; + } + + //#region Check ff visibility + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + + const limit = 10; + const partOf = `${config.url}/users/${userId}/followers`; + + if (page) { + const query = { + followeeId: user.id + } as any; + + // カーソルが指定されている場合 + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followers + const followings = await Followings.find({ + where: query, + take: limit + 1, + order: { id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowers = await Promise.all(followings.map(following => renderFollowUser(following.followerId))); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor + })}`, + user.followersCount, renderedFollowers, partOf, + undefined, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: followings[followings.length - 1].id + })}` : undefined + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); + ctx.body = renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); + } +}; diff --git a/packages/backend/src/server/activitypub/following.ts b/packages/backend/src/server/activitypub/following.ts new file mode 100644 index 0000000000..b9eb806c3c --- /dev/null +++ b/packages/backend/src/server/activitypub/following.ts @@ -0,0 +1,103 @@ +import * as Router from '@koa/router'; +import config from '@/config/index'; +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as url from '@/prelude/url'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page'; +import renderFollowUser from '@/remote/activitypub/renderer/follow-user'; +import { setResponseType } from '../activitypub'; +import { Users, Followings, UserProfiles } from '@/models/index'; +import { LessThan, FindConditions } from 'typeorm'; +import { Following } from '@/models/entities/following'; + +export default async (ctx: Router.RouterContext) => { + const userId = ctx.params.user; + + // Get 'cursor' parameter + const [cursor, cursorErr] = $.optional.type(ID).get(ctx.request.query.cursor); + + // Get 'page' parameter + const pageErr = !$.optional.str.or(['true', 'false']).ok(ctx.request.query.page); + const page: boolean = ctx.request.query.page === 'true'; + + // Validate parameters + if (cursorErr || pageErr) { + ctx.status = 400; + return; + } + + // Verify user + const user = await Users.findOne({ + id: userId, + host: null + }); + + if (user == null) { + ctx.status = 404; + return; + } + + //#region Check ff visibility + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + ctx.status = 403; + ctx.set('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + + const limit = 10; + const partOf = `${config.url}/users/${userId}/following`; + + if (page) { + const query = { + followerId: user.id + } as FindConditions<Following>; + + // カーソルが指定されている場合 + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followings + const followings = await Followings.find({ + where: query, + take: limit + 1, + order: { id: -1 } + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowees = await Promise.all(followings.map(following => renderFollowUser(following.followeeId))); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor + })}`, + user.followingCount, renderedFollowees, partOf, + undefined, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: followings[followings.length - 1].id + })}` : undefined + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); + ctx.body = renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); + } +}; diff --git a/packages/backend/src/server/activitypub/outbox.ts b/packages/backend/src/server/activitypub/outbox.ts new file mode 100644 index 0000000000..df528e8b5a --- /dev/null +++ b/packages/backend/src/server/activitypub/outbox.ts @@ -0,0 +1,108 @@ +import * as Router from '@koa/router'; +import config from '@/config/index'; +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderOrderedCollection from '@/remote/activitypub/renderer/ordered-collection'; +import renderOrderedCollectionPage from '@/remote/activitypub/renderer/ordered-collection-page'; +import { setResponseType } from '../activitypub'; +import renderNote from '@/remote/activitypub/renderer/note'; +import renderCreate from '@/remote/activitypub/renderer/create'; +import renderAnnounce from '@/remote/activitypub/renderer/announce'; +import { countIf } from '@/prelude/array'; +import * as url from '@/prelude/url'; +import { Users, Notes } from '@/models/index'; +import { makePaginationQuery } from '../api/common/make-pagination-query'; +import { Brackets } from 'typeorm'; +import { Note } from '@/models/entities/note'; + +export default async (ctx: Router.RouterContext) => { + const userId = ctx.params.user; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $.optional.type(ID).get(ctx.request.query.since_id); + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $.optional.type(ID).get(ctx.request.query.until_id); + + // Get 'page' parameter + const pageErr = !$.optional.str.or(['true', 'false']).ok(ctx.request.query.page); + const page: boolean = ctx.request.query.page === 'true'; + + // Validate parameters + if (sinceIdErr || untilIdErr || pageErr || countIf(x => x != null, [sinceId, untilId]) > 1) { + ctx.status = 400; + return; + } + + // Verify user + const user = await Users.findOne({ + id: userId, + host: null + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const limit = 20; + const partOf = `${config.url}/users/${userId}/outbox`; + + if (page) { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), sinceId, untilId) + .andWhere('note.userId = :userId', { userId: user.id }) + .andWhere(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })) + .andWhere('note.localOnly = FALSE'); + + const notes = await query.take(limit).getMany(); + + if (sinceId) notes.reverse(); + + const activities = await Promise.all(notes.map(note => packActivity(note))); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + since_id: sinceId, + until_id: untilId + })}`, + user.notesCount, activities, partOf, + notes.length ? `${partOf}?${url.query({ + page: 'true', + since_id: notes[0].id + })}` : undefined, + notes.length ? `${partOf}?${url.query({ + page: 'true', + until_id: notes[notes.length - 1].id + })}` : undefined + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection(partOf, user.notesCount, + `${partOf}?page=true`, + `${partOf}?page=true&since_id=000000000000000000000000` + ); + ctx.body = renderActivity(rendered); + ctx.set('Cache-Control', 'public, max-age=180'); + setResponseType(ctx); + } +}; + +/** + * Pack Create<Note> or Announce Activity + * @param note Note + */ +export async function packActivity(note: Note): Promise<any> { + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + const renote = await Notes.findOneOrFail(note.renoteId); + return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`, note); + } + + return renderCreate(await renderNote(note, false), note); +} diff --git a/packages/backend/src/server/api/2fa.ts b/packages/backend/src/server/api/2fa.ts new file mode 100644 index 0000000000..117446383d --- /dev/null +++ b/packages/backend/src/server/api/2fa.ts @@ -0,0 +1,422 @@ +import * as crypto from 'crypto'; +import config from '@/config/index'; +import * as jsrsasign from 'jsrsasign'; + +const ECC_PRELUDE = Buffer.from([0x04]); +const NULL_BYTE = Buffer.from([0]); +const PEM_PRELUDE = Buffer.from( + '3059301306072a8648ce3d020106082a8648ce3d030107034200', + 'hex' +); + +// Android Safetynet attestations are signed with this cert: +const GSR2 = `-----BEGIN CERTIFICATE----- +MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1 +MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL +v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8 +eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq +tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd +C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa +zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB +mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH +V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n +bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG +3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs +J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO +291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS +ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd +AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 +TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== +-----END CERTIFICATE-----\n`; + +function base64URLDecode(source: string) { + return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64'); +} + +function getCertSubject(certificate: string) { + const subjectCert = new jsrsasign.X509(); + subjectCert.readCertPEM(certificate); + + const subjectString = subjectCert.getSubjectString(); + const subjectFields = subjectString.slice(1).split('/'); + + const fields = {} as Record<string, string>; + for (const field of subjectFields) { + const eqIndex = field.indexOf('='); + fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1); + } + + return fields; +} + +function verifyCertificateChain(certificates: string[]) { + let valid = true; + + for (let i = 0; i < certificates.length; i++) { + const Cert = certificates[i]; + const certificate = new jsrsasign.X509(); + certificate.readCertPEM(Cert); + + const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1]; + + const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]); + const algorithm = certificate.getSignatureAlgorithmField(); + const signatureHex = certificate.getSignatureValueHex(); + + // Verify against CA + const Signature = new jsrsasign.KJUR.crypto.Signature({alg: algorithm}); + Signature.init(CACert); + Signature.updateHex(certStruct); + valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate + } + + return valid; +} + +function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') { + if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) { + pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91); + type = 'PUBLIC KEY'; + } + const cert = pemBuffer.toString('base64'); + + const keyParts = []; + const max = Math.ceil(cert.length / 64); + let start = 0; + for (let i = 0; i < max; i++) { + keyParts.push(cert.substring(start, start + 64)); + start += 64; + } + + return ( + `-----BEGIN ${type}-----\n` + + keyParts.join('\n') + + `\n-----END ${type}-----\n` + ); +} + +export function hash(data: Buffer) { + return crypto + .createHash('sha256') + .update(data) + .digest(); +} + +export function verifyLogin({ + publicKey, + authenticatorData, + clientDataJSON, + clientData, + signature, + challenge +}: { + publicKey: Buffer, + authenticatorData: Buffer, + clientDataJSON: Buffer, + clientData: any, + signature: Buffer, + challenge: string +}) { + if (clientData.type != 'webauthn.get') { + throw new Error('type is not webauthn.get'); + } + + if (hash(clientData.challenge).toString('hex') != challenge) { + throw new Error('challenge mismatch'); + } + if (clientData.origin != config.scheme + '://' + config.host) { + throw new Error('origin mismatch'); + } + + const verificationData = Buffer.concat( + [authenticatorData, hash(clientDataJSON)], + 32 + authenticatorData.length + ); + + return crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(publicKey), signature); +} + +export const procedures = { + none: { + verify({publicKey}: {publicKey: Map<number, Buffer>}) { + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + + return { + publicKey: publicKeyU2F, + valid: true + }; + } + }, + 'android-key': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + if (attStmt.alg != -7) { + throw new Error('alg mismatch'); + } + + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash + ]); + + const attCert: Buffer = attStmt.x5c[0]; + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + + if (!attCert.equals(publicKeyData)) { + throw new Error('public key mismatch'); + } + + const isValid = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + // TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON) + + return { + valid: isValid, + publicKey: publicKeyData + }; + } + }, + // what a stupid attestation + 'android-safetynet': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + const verificationData = hash( + Buffer.concat([authenticatorData, clientDataHash]) + ); + + const jwsParts = attStmt.response.toString('utf-8').split('.'); + + const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8')); + const response = JSON.parse( + base64URLDecode(jwsParts[1]).toString('utf-8') + ); + const signature = jwsParts[2]; + + if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) { + throw new Error('invalid nonce'); + } + + const certificateChain = header.x5c + .map((key: any) => PEMString(key)) + .concat([GSR2]); + + if (getCertSubject(certificateChain[0]).CN != 'attest.android.com') { + throw new Error('invalid common name'); + } + + if (!verifyCertificateChain(certificateChain)) { + throw new Error('Invalid certificate chain!'); + } + + const signatureBase = Buffer.from( + jwsParts[0] + '.' + jwsParts[1], + 'utf-8' + ); + + const valid = crypto + .createVerify('sha256') + .update(signatureBase) + .verify(certificateChain[0], base64URLDecode(signature)); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + return { + valid, + publicKey: publicKeyData + }; + } + }, + packed: { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>; + rpIdHash: Buffer, + credentialId: Buffer, + }) { + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash + ]); + + if (attStmt.x5c) { + const attCert = attStmt.x5c[0]; + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + + return { + valid: validSignature, + publicKey: publicKeyData + }; + } else if (attStmt.ecdaaKeyId) { + // https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation + throw new Error('ECDAA-Verify is not supported'); + } else { + if (attStmt.alg != -7) throw new Error('alg mismatch'); + + throw new Error('self attestation is not supported'); + } + } + }, + + 'fido-u2f': { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId + }: { + attStmt: any, + authenticatorData: Buffer, + clientDataHash: Buffer, + publicKey: Map<number, any>, + rpIdHash: Buffer, + credentialId: Buffer + }) { + const x5c: Buffer[] = attStmt.x5c; + if (x5c.length != 1) { + throw new Error('x5c length does not match expectation'); + } + + const attCert = x5c[0]; + + // TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve + + const negTwo: Buffer = publicKey.get(-2); + + if (!negTwo || negTwo.length != 32) { + throw new Error('invalid or no -2 key given'); + } + const negThree: Buffer = publicKey.get(-3); + if (!negThree || negThree.length != 32) { + throw new Error('invalid or no -3 key given'); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32 + ); + + const verificationData = Buffer.concat([ + NULL_BYTE, + rpIdHash, + clientDataHash, + credentialId, + publicKeyU2F + ]); + + const validSignature = crypto + .createVerify('SHA256') + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + return { + valid: validSignature, + publicKey: publicKeyU2F + }; + } + } +}; diff --git a/packages/backend/src/server/api/api-handler.ts b/packages/backend/src/server/api/api-handler.ts new file mode 100644 index 0000000000..cbace8917e --- /dev/null +++ b/packages/backend/src/server/api/api-handler.ts @@ -0,0 +1,51 @@ +import * as Koa from 'koa'; + +import { IEndpoint } from './endpoints'; +import authenticate, { AuthenticationError } from './authenticate'; +import call from './call'; +import { ApiError } from './error'; + +export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => { + const body = ctx.request.body; + + const reply = (x?: any, y?: ApiError) => { + if (x == null) { + ctx.status = 204; + } else if (typeof x === 'number' && y) { + ctx.status = x; + ctx.body = { + error: { + message: y!.message, + code: y!.code, + id: y!.id, + kind: y!.kind, + ...(y!.info ? { info: y!.info } : {}) + } + }; + } else { + // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない + ctx.body = typeof x === 'string' ? JSON.stringify(x) : x; + } + res(); + }; + + // Authentication + authenticate(body['i']).then(([user, app]) => { + // API invoking + call(endpoint.name, user, app, body, (ctx as any).file).then((res: any) => { + reply(res); + }).catch((e: ApiError) => { + reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); + }); + }).catch(e => { + if (e instanceof AuthenticationError) { + reply(403, new ApiError({ + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14' + })); + } else { + reply(500, new ApiError()); + } + }); +}); diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts new file mode 100644 index 0000000000..b8e216edc4 --- /dev/null +++ b/packages/backend/src/server/api/authenticate.ts @@ -0,0 +1,62 @@ +import isNativeToken from './common/is-native-token'; +import { User } from '@/models/entities/user'; +import { Users, AccessTokens, Apps } from '@/models/index'; +import { AccessToken } from '@/models/entities/access-token'; + +export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthenticationError'; + } +} + +export default async (token: string): Promise<[User | null | undefined, App | null | undefined]> => { + if (token == null) { + return [null, null]; + } + + if (isNativeToken(token)) { + // Fetch user + const user = await Users + .findOne({ token }); + + if (user == null) { + throw new AuthenticationError('user not found'); + } + + return [user, null]; + } else { + const accessToken = await AccessTokens.findOne({ + where: [{ + hash: token.toLowerCase() // app + }, { + token: token // miauth + }], + }); + + if (accessToken == null) { + throw new AuthenticationError('invalid signature'); + } + + AccessTokens.update(accessToken.id, { + lastUsedAt: new Date(), + }); + + const user = await Users + .findOne({ + id: accessToken.userId // findOne(accessToken.userId) のように書かないのは後方互換性のため + }); + + if (accessToken.appId) { + const app = await Apps + .findOneOrFail(accessToken.appId); + + return [user, { + id: accessToken.id, + permission: app.permission + } as AccessToken]; + } else { + return [user, accessToken]; + } + } +}; diff --git a/packages/backend/src/server/api/call.ts b/packages/backend/src/server/api/call.ts new file mode 100644 index 0000000000..bd86ffdc35 --- /dev/null +++ b/packages/backend/src/server/api/call.ts @@ -0,0 +1,109 @@ +import { performance } from 'perf_hooks'; +import limiter from './limiter'; +import { User } from '@/models/entities/user'; +import endpoints from './endpoints'; +import { ApiError } from './error'; +import { apiLogger } from './logger'; +import { AccessToken } from '@/models/entities/access-token'; + +const accessDenied = { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '56f35758-7dd5-468b-8439-5d6fb8ec9b8e' +}; + +export default async (endpoint: string, user: User | null | undefined, token: AccessToken | null | undefined, data: any, file?: any) => { + const isSecure = user != null && token == null; + + const ep = endpoints.find(e => e.name === endpoint); + + if (ep == null) { + throw new ApiError({ + message: 'No such endpoint.', + code: 'NO_SUCH_ENDPOINT', + id: 'f8080b67-5f9c-4eb7-8c18-7f1eeae8f709', + httpStatusCode: 404 + }); + } + + if (ep.meta.secure && !isSecure) { + throw new ApiError(accessDenied); + } + + if (ep.meta.requireCredential && user == null) { + throw new ApiError({ + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401 + }); + } + + if (ep.meta.requireCredential && user!.isSuspended) { + throw new ApiError({ + message: 'Your account has been suspended.', + code: 'YOUR_ACCOUNT_SUSPENDED', + id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', + httpStatusCode: 403 + }); + } + + if (ep.meta.requireAdmin && !user!.isAdmin) { + throw new ApiError(accessDenied, { reason: 'You are not the admin.' }); + } + + if (ep.meta.requireModerator && !user!.isAdmin && !user!.isModerator) { + throw new ApiError(accessDenied, { reason: 'You are not a moderator.' }); + } + + if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) { + throw new ApiError({ + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }); + } + + if (ep.meta.requireCredential && ep.meta.limit && !user!.isAdmin && !user!.isModerator) { + // Rate limit + await limiter(ep, user!).catch(e => { + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429 + }); + }); + } + + // API invoking + const before = performance.now(); + return await ep.exec(data, user, token, file).catch((e: Error) => { + if (e instanceof ApiError) { + throw e; + } else { + apiLogger.error(`Internal error occurred in ${ep.name}: ${e?.message}`, { + ep: ep.name, + ps: data, + e: { + message: e?.message, + code: e?.name, + stack: e?.stack + } + }); + throw new ApiError(null, { + e: { + message: e?.message, + code: e?.name, + stack: e?.stack + } + }); + } + }).finally(() => { + const after = performance.now(); + const time = after - before; + if (time > 1000) { + apiLogger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); + } + }); +}; diff --git a/packages/backend/src/server/api/common/generate-block-query.ts b/packages/backend/src/server/api/common/generate-block-query.ts new file mode 100644 index 0000000000..4fd6184738 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-block-query.ts @@ -0,0 +1,42 @@ +import { User } from '@/models/entities/user'; +import { Blockings } from '@/models/index'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +// ここでいうBlockedは被Blockedの意 +export function generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const blockingQuery = Blockings.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + // 投稿の作者にブロックされていない かつ + // 投稿の返信先の作者にブロックされていない かつ + // 投稿の引用元の作者にブロックされていない + q + .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where(`note.replyUserId IS NULL`) + .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where(`note.renoteUserId IS NULL`) + .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); + })); + + q.setParameters(blockingQuery.getParameters()); +} + +export function generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const blockingQuery = Blockings.createQueryBuilder('blocking') + .select('blocking.blockeeId') + .where('blocking.blockerId = :blockerId', { blockerId: me.id }); + + const blockedQuery = Blockings.createQueryBuilder('blocking') + .select('blocking.blockerId') + .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); + + q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`); + q.setParameters(blockingQuery.getParameters()); + + q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`); + q.setParameters(blockedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-channel-query.ts b/packages/backend/src/server/api/common/generate-channel-query.ts new file mode 100644 index 0000000000..80a0acf7f9 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-channel-query.ts @@ -0,0 +1,24 @@ +import { User } from '@/models/entities/user'; +import { ChannelFollowings } from '@/models/index'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) { + if (me == null) { + q.andWhere('note.channelId IS NULL'); + } else { + q.leftJoinAndSelect('note.channel', 'channel'); + + const channelFollowingQuery = ChannelFollowings.createQueryBuilder('channelFollowing') + .select('channelFollowing.followeeId') + .where('channelFollowing.followerId = :followerId', { followerId: me.id }); + + q.andWhere(new Brackets(qb => { qb + // チャンネルのノートではない + .where('note.channelId IS NULL') + // または自分がフォローしているチャンネルのノート + .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); + })); + + q.setParameters(channelFollowingQuery.getParameters()); + } +} diff --git a/packages/backend/src/server/api/common/generate-muted-note-query.ts b/packages/backend/src/server/api/common/generate-muted-note-query.ts new file mode 100644 index 0000000000..0737842613 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-note-query.ts @@ -0,0 +1,13 @@ +import { User } from '@/models/entities/user'; +import { MutedNotes } from '@/models/index'; +import { SelectQueryBuilder } from 'typeorm'; + +export function generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const mutedQuery = MutedNotes.createQueryBuilder('muted') + .select('muted.noteId') + .where('muted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts new file mode 100644 index 0000000000..7e2cbd498b --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts @@ -0,0 +1,17 @@ +import { User } from '@/models/entities/user'; +import { NoteThreadMutings } from '@/models/index'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const mutedQuery = NoteThreadMutings.createQueryBuilder('threadMuted') + .select('threadMuted.threadId') + .where('threadMuted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + q.andWhere(new Brackets(qb => { qb + .where(`note.threadId IS NULL`) + .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); + })); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-muted-user-query.ts b/packages/backend/src/server/api/common/generate-muted-user-query.ts new file mode 100644 index 0000000000..7e200b87ef --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-user-query.ts @@ -0,0 +1,40 @@ +import { User } from '@/models/entities/user'; +import { Mutings } from '@/models/index'; +import { SelectQueryBuilder, Brackets } from 'typeorm'; + +export function generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }, exclude?: User) { + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + if (exclude) { + mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); + } + + // 投稿の作者をミュートしていない かつ + // 投稿の返信先の作者をミュートしていない かつ + // 投稿の引用元の作者をミュートしていない + q + .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where(`note.replyUserId IS NULL`) + .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { qb + .where(`note.renoteUserId IS NULL`) + .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); + })); + + q.setParameters(mutingQuery.getParameters()); +} + +export function generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }) { + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: me.id }); + + q + .andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); + + q.setParameters(mutingQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-native-user-token.ts b/packages/backend/src/server/api/common/generate-native-user-token.ts new file mode 100644 index 0000000000..1f791c57ce --- /dev/null +++ b/packages/backend/src/server/api/common/generate-native-user-token.ts @@ -0,0 +1,3 @@ +import { secureRndstr } from '@/misc/secure-rndstr'; + +export default () => secureRndstr(16, true); diff --git a/packages/backend/src/server/api/common/generate-replies-query.ts b/packages/backend/src/server/api/common/generate-replies-query.ts new file mode 100644 index 0000000000..fbc41b2c25 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-replies-query.ts @@ -0,0 +1,27 @@ +import { User } from '@/models/entities/user'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateRepliesQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) { + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where(`note.replyId IS NULL`) // 返信ではない + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where(`note.replyId IS NOT NULL`) + .andWhere('note.replyUserId = note.userId'); + })); + })); + } else { + q.andWhere(new Brackets(qb => { qb + .where(`note.replyId IS NULL`) // 返信ではない + .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 + .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信 + .where(`note.replyId IS NOT NULL`) + .andWhere('note.userId = :meId', { meId: me.id }); + })) + .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 + .where(`note.replyId IS NOT NULL`) + .andWhere('note.replyUserId = note.userId'); + })); + })); + } +} diff --git a/packages/backend/src/server/api/common/generate-visibility-query.ts b/packages/backend/src/server/api/common/generate-visibility-query.ts new file mode 100644 index 0000000000..813e8b6c09 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-visibility-query.ts @@ -0,0 +1,40 @@ +import { User } from '@/models/entities/user'; +import { Followings } from '@/models/index'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; + +export function generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null) { + if (me == null) { + q.andWhere(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })); + } else { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + q.andWhere(new Brackets(qb => { qb + // 公開投稿である + .where(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })) + // または 自分自身 + .orWhere('note.userId = :userId1', { userId1: me.id }) + // または 自分宛て + .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`) + .orWhere(new Brackets(qb => { qb + // または フォロワー宛ての投稿であり、 + .where('note.visibility = \'followers\'') + .andWhere(new Brackets(qb => { qb + // 自分がフォロワーである + .where(`note.userId IN (${ followingQuery.getQuery() })`) + // または 自分の投稿へのリプライ + .orWhere('note.replyUserId = :userId3', { userId3: me.id }); + })); + })); + })); + + q.setParameters(followingQuery.getParameters()); + } +} diff --git a/packages/backend/src/server/api/common/getters.ts b/packages/backend/src/server/api/common/getters.ts new file mode 100644 index 0000000000..4b2ee8f1da --- /dev/null +++ b/packages/backend/src/server/api/common/getters.ts @@ -0,0 +1,56 @@ +import { IdentifiableError } from '@/misc/identifiable-error'; +import { User } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { Notes, Users } from '@/models/index'; + +/** + * Get note for API processing + */ +export async function getNote(noteId: Note['id']) { + const note = await Notes.findOne(noteId); + + if (note == null) { + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); + } + + return note; +} + +/** + * Get user for API processing + */ +export async function getUser(userId: User['id']) { + const user = await Users.findOne(userId); + + if (user == null) { + throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.'); + } + + return user; +} + +/** + * Get remote user for API processing + */ +export async function getRemoteUser(userId: User['id']) { + const user = await getUser(userId); + + if (!Users.isRemoteUser(user)) { + throw new Error('user is not a remote user'); + } + + return user; +} + +/** + * Get local user for API processing + */ +export async function getLocalUser(userId: User['id']) { + const user = await getUser(userId); + + if (!Users.isLocalUser(user)) { + throw new Error('user is not a local user'); + } + + return user; +} diff --git a/packages/backend/src/server/api/common/inject-featured.ts b/packages/backend/src/server/api/common/inject-featured.ts new file mode 100644 index 0000000000..1dc13c83ef --- /dev/null +++ b/packages/backend/src/server/api/common/inject-featured.ts @@ -0,0 +1,56 @@ +import rndstr from 'rndstr'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; +import { Notes, UserProfiles, NoteReactions } from '@/models/index'; +import { generateMutedUserQuery } from './generate-muted-user-query'; +import { generateBlockedUserQuery } from './generate-block-query'; + +// TODO: リアクション、Renote、返信などをしたノートは除外する + +export async function injectFeatured(timeline: Note[], user?: User | null) { + if (timeline.length < 5) return; + + if (user) { + const profile = await UserProfiles.findOneOrFail(user.id); + if (!profile.injectFeaturedNote) return; + } + + const max = 30; + const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで + + const query = Notes.createQueryBuilder('note') + .addSelect('note.score') + .where('note.userHost IS NULL') + .andWhere(`note.score > 0`) + .andWhere(`note.createdAt > :date`, { date: new Date(Date.now() - day) }) + .andWhere(`note.visibility = 'public'`) + .innerJoinAndSelect('note.user', 'user'); + + if (user) { + query.andWhere('note.userId != :userId', { userId: user.id }); + + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + + const reactionQuery = NoteReactions.createQueryBuilder('reaction') + .select('reaction.noteId') + .where('reaction.userId = :userId', { userId: user.id }); + + query.andWhere(`note.id NOT IN (${ reactionQuery.getQuery() })`); + } + + const notes = await query + .orderBy('note.score', 'DESC') + .take(max) + .getMany(); + + if (notes.length === 0) return; + + // Pick random one + const featured = notes[Math.floor(Math.random() * notes.length)]; + + (featured as any)._featuredId_ = rndstr('a-z0-9', 8); + + // Inject featured + timeline.splice(3, 0, featured); +} diff --git a/packages/backend/src/server/api/common/inject-promo.ts b/packages/backend/src/server/api/common/inject-promo.ts new file mode 100644 index 0000000000..87767a65bf --- /dev/null +++ b/packages/backend/src/server/api/common/inject-promo.ts @@ -0,0 +1,34 @@ +import rndstr from 'rndstr'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; +import { PromoReads, PromoNotes, Notes, Users } from '@/models/index'; + +export async function injectPromo(timeline: Note[], user?: User | null) { + if (timeline.length < 5) return; + + // TODO: readやexpireフィルタはクエリ側でやる + + const reads = user ? await PromoReads.find({ + userId: user.id + }) : []; + + let promos = await PromoNotes.find(); + + promos = promos.filter(n => n.expiresAt.getTime() > Date.now()); + promos = promos.filter(n => !reads.map(r => r.noteId).includes(n.noteId)); + + if (promos.length === 0) return; + + // Pick random promo + const promo = promos[Math.floor(Math.random() * promos.length)]; + + const note = await Notes.findOneOrFail(promo.noteId); + + // Join + note.user = await Users.findOneOrFail(note.userId); + + (note as any)._prId_ = rndstr('a-z0-9', 8); + + // Inject promo + timeline.splice(3, 0, note); +} diff --git a/packages/backend/src/server/api/common/is-native-token.ts b/packages/backend/src/server/api/common/is-native-token.ts new file mode 100644 index 0000000000..2833c570c8 --- /dev/null +++ b/packages/backend/src/server/api/common/is-native-token.ts @@ -0,0 +1 @@ +export default (token: string) => token.length === 16; diff --git a/packages/backend/src/server/api/common/make-pagination-query.ts b/packages/backend/src/server/api/common/make-pagination-query.ts new file mode 100644 index 0000000000..51c11e5dff --- /dev/null +++ b/packages/backend/src/server/api/common/make-pagination-query.ts @@ -0,0 +1,28 @@ +import { SelectQueryBuilder } from 'typeorm'; + +export function makePaginationQuery<T>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number) { + if (sinceId && untilId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${q.alias}.id`, 'ASC'); + } else if (untilId) { + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, 'DESC'); + } else if (sinceDate && untilDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else if (sinceDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) }); + q.orderBy(`${q.alias}.createdAt`, 'ASC'); + } else if (untilDate) { + q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) }); + q.orderBy(`${q.alias}.createdAt`, 'DESC'); + } else { + q.orderBy(`${q.alias}.id`, 'DESC'); + } + return q; +} diff --git a/packages/backend/src/server/api/common/read-messaging-message.ts b/packages/backend/src/server/api/common/read-messaging-message.ts new file mode 100644 index 0000000000..33f41b2770 --- /dev/null +++ b/packages/backend/src/server/api/common/read-messaging-message.ts @@ -0,0 +1,122 @@ +import { publishMainStream, publishGroupMessagingStream } from '@/services/stream'; +import { publishMessagingStream } from '@/services/stream'; +import { publishMessagingIndexStream } from '@/services/stream'; +import { User, IRemoteUser } from '@/models/entities/user'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { MessagingMessages, UserGroupJoinings, Users } from '@/models/index'; +import { In } from 'typeorm'; +import { IdentifiableError } from '@/misc/identifiable-error'; +import { UserGroup } from '@/models/entities/user-group'; +import { toArray } from '@/prelude/array'; +import { renderReadActivity } from '@/remote/activitypub/renderer/read'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import { deliver } from '@/queue/index'; +import orderedCollection from '@/remote/activitypub/renderer/ordered-collection'; + +/** + * Mark messages as read + */ +export async function readUserMessagingMessage( + userId: User['id'], + otherpartyId: User['id'], + messageIds: MessagingMessage['id'][] +) { + if (messageIds.length === 0) return; + + const messages = await MessagingMessages.find({ + id: In(messageIds) + }); + + for (const message of messages) { + if (message.recipientId !== userId) { + throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).'); + } + } + + // Update documents + await MessagingMessages.update({ + id: In(messageIds), + userId: otherpartyId, + recipientId: userId, + isRead: false + }, { + isRead: true + }); + + // Publish event + publishMessagingStream(otherpartyId, userId, 'read', messageIds); + publishMessagingIndexStream(userId, 'read', messageIds); + + if (!await Users.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishMainStream(userId, 'readAllMessagingMessages'); + } +} + +/** + * Mark messages as read + */ +export async function readGroupMessagingMessage( + userId: User['id'], + groupId: UserGroup['id'], + messageIds: MessagingMessage['id'][] +) { + if (messageIds.length === 0) return; + + // check joined + const joining = await UserGroupJoinings.findOne({ + userId: userId, + userGroupId: groupId + }); + + if (joining == null) { + throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); + } + + const messages = await MessagingMessages.find({ + id: In(messageIds) + }); + + const reads: MessagingMessage['id'][] = []; + + for (const message of messages) { + if (message.userId === userId) continue; + if (message.reads.includes(userId)) continue; + + // Update document + await MessagingMessages.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${joining.userId}')`) as any + }) + .where('id = :id', { id: message.id }) + .execute(); + + reads.push(message.id); + } + + // Publish event + publishGroupMessagingStream(groupId, 'read', { + ids: reads, + userId: userId + }); + publishMessagingIndexStream(userId, 'read', reads); + + if (!await Users.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishMainStream(userId, 'readAllMessagingMessages'); + } +} + +export async function deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) { + messages = toArray(messages).filter(x => x.uri); + const contents = messages.map(x => renderReadActivity(user, x)); + + if (contents.length > 1) { + const collection = orderedCollection(null, contents.length, undefined, undefined, contents); + deliver(user, renderActivity(collection), recipient.inbox); + } else { + for (const content of contents) { + deliver(user, renderActivity(content), recipient.inbox); + } + } +} diff --git a/packages/backend/src/server/api/common/read-notification.ts b/packages/backend/src/server/api/common/read-notification.ts new file mode 100644 index 0000000000..a4406c9eeb --- /dev/null +++ b/packages/backend/src/server/api/common/read-notification.ts @@ -0,0 +1,43 @@ +import { publishMainStream } from '@/services/stream'; +import { User } from '@/models/entities/user'; +import { Notification } from '@/models/entities/notification'; +import { Notifications, Users } from '@/models/index'; +import { In } from 'typeorm'; + +export async function readNotification( + userId: User['id'], + notificationIds: Notification['id'][] +) { + // Update documents + await Notifications.update({ + id: In(notificationIds), + isRead: false + }, { + isRead: true + }); + + post(userId); +} + +export async function readNotificationByQuery( + userId: User['id'], + query: Record<string, any> +) { + // Update documents + await Notifications.update({ + ...query, + notifieeId: userId, + isRead: false + }, { + isRead: true + }); + + post(userId); +} + +async function post(userId: User['id']) { + if (!await Users.getHasUnreadNotification(userId)) { + // 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行 + publishMainStream(userId, 'readAllNotifications'); + } +} diff --git a/packages/backend/src/server/api/common/signin.ts b/packages/backend/src/server/api/common/signin.ts new file mode 100644 index 0000000000..4c7aacf1cd --- /dev/null +++ b/packages/backend/src/server/api/common/signin.ts @@ -0,0 +1,44 @@ +import * as Koa from 'koa'; + +import config from '@/config/index'; +import { ILocalUser } from '@/models/entities/user'; +import { Signins } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { publishMainStream } from '@/services/stream'; + +export default function(ctx: Koa.Context, user: ILocalUser, redirect = false) { + if (redirect) { + //#region Cookie + ctx.cookies.set('igi', user.token, { + path: '/', + // SEE: https://github.com/koajs/koa/issues/974 + // When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header + secure: config.url.startsWith('https'), + httpOnly: false + }); + //#endregion + + ctx.redirect(config.url); + } else { + ctx.body = { + id: user.id, + i: user.token + }; + ctx.status = 200; + } + + (async () => { + // Append signin history + const record = await Signins.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: true + }); + + // Publish signin event + publishMainStream(user.id, 'signin', await Signins.pack(record)); + })(); +} diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts new file mode 100644 index 0000000000..2ba0d8e479 --- /dev/null +++ b/packages/backend/src/server/api/common/signup.ts @@ -0,0 +1,113 @@ +import * as bcrypt from 'bcryptjs'; +import { generateKeyPair } from 'crypto'; +import generateUserToken from './generate-native-user-token'; +import { User } from '@/models/entities/user'; +import { Users, UsedUsernames } from '@/models/index'; +import { UserProfile } from '@/models/entities/user-profile'; +import { getConnection } from 'typeorm'; +import { genId } from '@/misc/gen-id'; +import { toPunyNullable } from '@/misc/convert-host'; +import { UserKeypair } from '@/models/entities/user-keypair'; +import { usersChart } from '@/services/chart/index'; +import { UsedUsername } from '@/models/entities/used-username'; + +export async function signup(opts: { + username: User['username']; + password?: string | null; + passwordHash?: UserProfile['password'] | null; + host?: string | null; +}) { + const { username, password, passwordHash, host } = opts; + let hash = passwordHash; + + // Validate username + if (!Users.validateLocalUsername.ok(username)) { + throw new Error('INVALID_USERNAME'); + } + + if (password != null && passwordHash == null) { + // Validate password + if (!Users.validatePassword.ok(password)) { + throw new Error('INVALID_PASSWORD'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + hash = await bcrypt.hash(password, salt); + } + + // Generate secret + const secret = generateUserToken(); + + // Check username duplication + if (await Users.findOne({ usernameLower: username.toLowerCase(), host: null })) { + throw new Error('DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await UsedUsernames.findOne({ username: username.toLowerCase() })) { + throw new Error('USED_USERNAME'); + } + + const keyPair = await new Promise<string[]>((res, rej) => + generateKeyPair('rsa', { + modulusLength: 4096, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + cipher: undefined, + passphrase: undefined + } + } as any, (err, publicKey, privateKey) => + err ? rej(err) : res([publicKey, privateKey]) + )); + + let account!: User; + + // Start transaction + await getConnection().transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOne(User, { + usernameLower: username.toLowerCase(), + host: null + }); + + if (exist) throw new Error(' the username is already used'); + + account = await transactionalEntityManager.save(new User({ + id: genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: toPunyNullable(host), + token: secret, + isAdmin: (await Users.count({ + host: null, + })) === 0, + })); + + await transactionalEntityManager.save(new UserKeypair({ + publicKey: keyPair[0], + privateKey: keyPair[1], + userId: account.id + })); + + await transactionalEntityManager.save(new UserProfile({ + userId: account.id, + autoAcceptFollowed: true, + password: hash, + })); + + await transactionalEntityManager.save(new UsedUsername({ + createdAt: new Date(), + username: username.toLowerCase(), + })); + }); + + usersChart.update(account, true); + + return { account, secret }; +} diff --git a/packages/backend/src/server/api/define.ts b/packages/backend/src/server/api/define.ts new file mode 100644 index 0000000000..4bd8f95e31 --- /dev/null +++ b/packages/backend/src/server/api/define.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import { ILocalUser } from '@/models/entities/user'; +import { IEndpointMeta } from './endpoints'; +import { ApiError } from './error'; +import { SchemaType } from '@/misc/schema'; +import { AccessToken } from '@/models/entities/access-token'; + +type NonOptional<T> = T extends undefined ? never : T; + +type SimpleUserInfo = { + id: ILocalUser['id']; + host: ILocalUser['host']; + username: ILocalUser['username']; + uri: ILocalUser['uri']; + inbox: ILocalUser['inbox']; + sharedInbox: ILocalUser['sharedInbox']; + isAdmin: ILocalUser['isAdmin']; + isModerator: ILocalUser['isModerator']; + isSilenced: ILocalUser['isSilenced']; +}; + +type Params<T extends IEndpointMeta> = { + [P in keyof T['params']]: NonNullable<T['params']>[P]['transform'] extends Function + ? ReturnType<NonNullable<T['params']>[P]['transform']> + : NonNullable<T['params']>[P]['default'] extends null | number | string + ? NonOptional<ReturnType<NonNullable<T['params']>[P]['validator']['get']>[0]> + : ReturnType<NonNullable<T['params']>[P]['validator']['get']>[0]; +}; + +export type Response = Record<string, any> | void; + +type executor<T extends IEndpointMeta> = + (params: Params<T>, user: T['requireCredential'] extends true ? SimpleUserInfo : SimpleUserInfo | null, token: AccessToken | null, file?: any, cleanup?: Function) => + Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; + +export default function <T extends IEndpointMeta>(meta: T, cb: executor<T>) + : (params: any, user: T['requireCredential'] extends true ? SimpleUserInfo : SimpleUserInfo | null, token: AccessToken | null, file?: any) => Promise<any> { + return (params: any, user: T['requireCredential'] extends true ? SimpleUserInfo : SimpleUserInfo | null, token: AccessToken | null, file?: any) => { + function cleanup() { + fs.unlink(file.path, () => {}); + } + + if (meta.requireFile && file == null) return Promise.reject(new ApiError({ + message: 'File required.', + code: 'FILE_REQUIRED', + id: '4267801e-70d1-416a-b011-4ee502885d8b', + })); + + const [ps, pserr] = getParams(meta, params); + if (pserr) { + if (file) cleanup(); + return Promise.reject(pserr); + } + + return cb(ps, user, token, file, cleanup); + }; +} + +function getParams<T extends IEndpointMeta>(defs: T, params: any): [Params<T>, ApiError | null] { + if (defs.params == null) return [params, null]; + + const x: any = {}; + let err: ApiError | null = null; + Object.entries(defs.params).some(([k, def]) => { + const [v, e] = def.validator.get(params[k]); + if (e) { + err = new ApiError({ + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + }, { + param: k, + reason: e.message + }); + return true; + } else { + if (v === undefined && def.hasOwnProperty('default')) { + x[k] = def.default; + } else { + x[k] = v; + } + if (def.transform) x[k] = def.transform(x[k]); + return false; + } + }); + return [x, err]; +} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts new file mode 100644 index 0000000000..6d9d2b0782 --- /dev/null +++ b/packages/backend/src/server/api/endpoints.ts @@ -0,0 +1,124 @@ +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { Context } from 'cafy'; +import * as path from 'path'; +import * as glob from 'glob'; +import { SimpleSchema } from '@/misc/simple-schema'; + +//const _filename = fileURLToPath(import.meta.url); +const _filename = __filename; +const _dirname = dirname(_filename); + +export type Param = { + validator: Context<any>; + transform?: any; + default?: any; + deprecated?: boolean; + ref?: string; +}; + +export interface IEndpointMeta { + stability?: string; //'deprecated' | 'experimental' | 'stable'; + + tags?: string[]; + + params?: { + [key: string]: Param; + }; + + errors?: { + [key: string]: { + message: string; + code: string; + id: string; + }; + }; + + res?: SimpleSchema; + + /** + * このエンドポイントにリクエストするのにユーザー情報が必須か否か + * 省略した場合は false として解釈されます。 + */ + requireCredential?: boolean; + + /** + * 管理者のみ使えるエンドポイントか否か + */ + requireAdmin?: boolean; + + /** + * 管理者またはモデレーターのみ使えるエンドポイントか否か + */ + requireModerator?: boolean; + + /** + * エンドポイントのリミテーションに関するやつ + * 省略した場合はリミテーションは無いものとして解釈されます。 + * また、withCredential が false の場合はリミテーションを行うことはできません。 + */ + limit?: { + + /** + * 複数のエンドポイントでリミットを共有したい場合に指定するキー + */ + key?: string; + + /** + * リミットを適用する期間(ms) + * このプロパティを設定する場合、max プロパティも設定する必要があります。 + */ + duration?: number; + + /** + * durationで指定した期間内にいくつまでリクエストできるのか + * このプロパティを設定する場合、duration プロパティも設定する必要があります。 + */ + max?: number; + + /** + * 最低でもどれくらいの間隔を開けてリクエストしなければならないか(ms) + */ + minInterval?: number; + }; + + /** + * ファイルの添付を必要とするか否か + * 省略した場合は false として解釈されます。 + */ + requireFile?: boolean; + + /** + * サードパーティアプリからはリクエストすることができないか否か + * 省略した場合は false として解釈されます。 + */ + secure?: boolean; + + /** + * エンドポイントの種類 + * パーミッションの実現に利用されます。 + */ + kind?: string; +} + +export interface IEndpoint { + name: string; + exec: any; + meta: IEndpointMeta; +} + +const files = glob.sync('**/*.js', { + cwd: path.resolve(_dirname + '/endpoints/') +}); + +const endpoints: IEndpoint[] = files.map(f => { + const ep = require(`./endpoints/${f}`); + + return { + name: f.replace('.js', ''), + exec: ep.default, + meta: ep.meta || {} + }; +}); + +export default endpoints; diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts new file mode 100644 index 0000000000..403eb24191 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -0,0 +1,134 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { AbuseUserReports } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + state: { + validator: $.optional.nullable.str, + default: null, + }, + + reporterOrigin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'combined' + }, + + targetUserOrigin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'combined' + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'date-time', + }, + comment: { + type: 'string' as const, + nullable: false as const, optional: false as const, + }, + resolved: { + type: 'boolean' as const, + nullable: false as const, optional: false as const, + example: false + }, + reporterId: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + }, + targetUserId: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id', + }, + assigneeId: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'id', + }, + reporter: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'User' + }, + targetUser: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'User' + }, + assignee: { + type: 'object' as const, + nullable: true as const, optional: true as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(AbuseUserReports.createQueryBuilder('report'), ps.sinceId, ps.untilId); + + switch (ps.state) { + case 'resolved': query.andWhere('report.resolved = TRUE'); break; + case 'unresolved': query.andWhere('report.resolved = FALSE'); break; + } + + switch (ps.reporterOrigin) { + case 'local': query.andWhere('report.reporterHost IS NULL'); break; + case 'remote': query.andWhere('report.reporterHost IS NOT NULL'); break; + } + + switch (ps.targetUserOrigin) { + case 'local': query.andWhere('report.targetUserHost IS NULL'); break; + case 'remote': query.andWhere('report.targetUserHost IS NOT NULL'); break; + } + + const reports = await query.take(ps.limit!).getMany(); + + return await AbuseUserReports.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts new file mode 100644 index 0000000000..fa15e84f77 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -0,0 +1,51 @@ +import define from '../../../define'; +import { Users } from '@/models/index'; +import { signup } from '../../../common/signup'; + +export const meta = { + tags: ['admin'], + + params: { + username: { + validator: Users.validateLocalUsername, + }, + + password: { + validator: Users.validatePassword, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + properties: { + token: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } +}; + +export default define(meta, async (ps, _me) => { + const me = _me ? await Users.findOneOrFail(_me.id) : null; + const noUsers = (await Users.count({ + host: null, + })) === 0; + if (!noUsers && !me?.isAdmin) throw new Error('access denied'); + + const { account, secret } = await signup({ + username: ps.username, + password: ps.password, + }); + + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true + }); + + (res as any).token = secret; + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts new file mode 100644 index 0000000000..4e8a559805 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Users } from '@/models/index'; +import { doPostSuspend } from '@/services/suspend-user'; +import { publishUserEvent } from '@/services/stream'; +import { createDeleteAccountJob } from '@/queue'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } + + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + } + + if (Users.isLocalUser(user)) { + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); + + createDeleteAccountJob(user, { + soft: false + }); + } else { + createDeleteAccountJob(user, { + soft: true // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する + }); + } + + await Users.update(user.id, { + isDeleted: true, + }); + + if (Users.isLocalUser(user)) { + // Terminate streaming + publishUserEvent(user.id, 'terminate', {}); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts new file mode 100644 index 0000000000..27c7b5d318 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Ads } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + url: { + validator: $.str.min(1) + }, + memo: { + validator: $.str + }, + place: { + validator: $.str + }, + priority: { + validator: $.str + }, + ratio: { + validator: $.num.int().min(0) + }, + expiresAt: { + validator: $.num.int() + }, + imageUrl: { + validator: $.str.min(1) + } + }, +}; + +export default define(meta, async (ps) => { + await Ads.insert({ + id: genId(), + createdAt: new Date(), + expiresAt: new Date(ps.expiresAt), + url: ps.url, + imageUrl: ps.imageUrl, + priority: ps.priority, + ratio: ps.ratio, + place: ps.place, + memo: ps.memo, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/delete.ts b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts new file mode 100644 index 0000000000..91934e1aab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/delete.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Ads } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + } + }, + + errors: { + noSuchAd: { + message: 'No such ad.', + code: 'NO_SUCH_AD', + id: 'ccac9863-3a03-416e-b899-8a64041118b1' + } + } +}; + +export default define(meta, async (ps, me) => { + const ad = await Ads.findOne(ps.id); + + if (ad == null) throw new ApiError(meta.errors.noSuchAd); + + await Ads.delete(ad.id); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts new file mode 100644 index 0000000000..000aaaba9d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Ads } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(Ads.createQueryBuilder('ad'), ps.sinceId, ps.untilId) + .andWhere('ad.expiresAt > :now', { now: new Date() }); + + const ads = await query.take(ps.limit!).getMany(); + + return ads; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts new file mode 100644 index 0000000000..36c87895c2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -0,0 +1,63 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Ads } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + }, + memo: { + validator: $.str + }, + url: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.str.min(1) + }, + place: { + validator: $.str + }, + priority: { + validator: $.str + }, + ratio: { + validator: $.num.int().min(0) + }, + expiresAt: { + validator: $.num.int() + }, + }, + + errors: { + noSuchAd: { + message: 'No such ad.', + code: 'NO_SUCH_AD', + id: 'b7aa1727-1354-47bc-a182-3a9c3973d300' + } + } +}; + +export default define(meta, async (ps, me) => { + const ad = await Ads.findOne(ps.id); + + if (ad == null) throw new ApiError(meta.errors.noSuchAd); + + await Ads.update(ad.id, { + url: ps.url, + place: ps.place, + priority: ps.priority, + ratio: ps.ratio, + memo: ps.memo, + imageUrl: ps.imageUrl, + expiresAt: new Date(ps.expiresAt), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts new file mode 100644 index 0000000000..f1c07745f9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -0,0 +1,71 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Announcements } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + title: { + validator: $.str.min(1) + }, + text: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.nullable.str.min(1) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + text: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + } + } + } +}; + +export default define(meta, async (ps) => { + const announcement = await Announcements.save({ + id: genId(), + createdAt: new Date(), + updatedAt: null, + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }); + + return announcement; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts new file mode 100644 index 0000000000..7dbc05b4c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Announcements } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + } + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: 'ecad8040-a276-4e85-bda9-015a708d291e' + } + } +}; + +export default define(meta, async (ps, me) => { + const announcement = await Announcements.findOne(ps.id); + + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + + await Announcements.delete(announcement.id); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts new file mode 100644 index 0000000000..4039bcd88f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -0,0 +1,84 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Announcements, AnnouncementReads } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + text: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + reads: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + + const announcements = await query.take(ps.limit!).getMany(); + + for (const announcement of announcements) { + (announcement as any).reads = await AnnouncementReads.count({ + announcementId: announcement.id + }); + } + + return announcements; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts new file mode 100644 index 0000000000..343f37d626 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Announcements } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + }, + title: { + validator: $.str.min(1) + }, + text: { + validator: $.str.min(1) + }, + imageUrl: { + validator: $.nullable.str.min(1) + } + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: 'd3aae5a7-6372-4cb4-b61c-f511ffc2d7cc' + } + } +}; + +export default define(meta, async (ps, me) => { + const announcement = await Announcements.findOne(ps.id); + + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + + await Announcements.update(announcement.id, { + updatedAt: new Date(), + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts new file mode 100644 index 0000000000..988ab29558 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../define'; +import { deleteFile } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const files = await DriveFiles.find({ + userId: ps.userId + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/delete-logs.ts b/packages/backend/src/server/api/endpoints/admin/delete-logs.ts new file mode 100644 index 0000000000..9d37ceb434 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/delete-logs.ts @@ -0,0 +1,13 @@ +import define from '../../define'; +import { Logs } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps) => { + await Logs.clear(); // TRUNCATE +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts new file mode 100644 index 0000000000..76a6acff59 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -0,0 +1,13 @@ +import define from '../../../define'; +import { createCleanRemoteFilesJob } from '@/queue/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps, me) => { + createCleanRemoteFilesJob(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts new file mode 100644 index 0000000000..8497478da9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts @@ -0,0 +1,21 @@ +import { IsNull } from 'typeorm'; +import define from '../../../define'; +import { deleteFile } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps, me) => { + const files = await DriveFiles.find({ + userId: IsNull() + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/files.ts b/packages/backend/src/server/api/endpoints/admin/drive/files.ts new file mode 100644 index 0000000000..fe1c799805 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/files.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: false as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + type: { + validator: $.optional.nullable.str.match(/^[a-zA-Z0-9\/\-*]+$/) + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + }, + + hostname: { + validator: $.optional.nullable.str, + default: null + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile' + } + } +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId); + + if (ps.origin === 'local') { + query.andWhere('file.userHost IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('file.userHost IS NOT NULL'); + } + + if (ps.hostname) { + query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); + } + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit!).getMany(); + + return await DriveFiles.packMany(files, { detail: true, withUser: true, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts new file mode 100644 index 0000000000..270b89c4fa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -0,0 +1,180 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + fileId: { + validator: $.optional.type(ID), + }, + + url: { + validator: $.optional.str, + }, + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'caf3ca38-c6e5-472e-a30c-b05377dcc240' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + userHost: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + md5: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'md5', + example: '15eca7fba0480996e2245f5185bf39f2' + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'lenna.jpg' + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'image/jpeg' + }, + size: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 51469 + }, + comment: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + blurhash: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + properties: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + width: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 1280 + }, + height: { + type: 'number' as const, + optional: false as const, nullable: false as const, + example: 720 + }, + avgColor: { + type: 'string' as const, + optional: true as const, nullable: false as const, + example: 'rgb(40,65,87)' + } + } + }, + storedInternal: { + type: 'boolean' as const, + optional: false as const, nullable: true as const, + example: true + }, + url: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + thumbnailUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + webpublicUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'url', + }, + accessKey: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + thumbnailAccessKey: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + webpublicAccessKey: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + uri: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + src: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + folderId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + isSensitive: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + isLink: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, me) => { + const file = ps.fileId ? await DriveFiles.findOne(ps.fileId) : await DriveFiles.findOne({ + where: [{ + url: ps.url + }, { + thumbnailUrl: ps.url + }, { + webpublicUrl: ps.url + }] + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + return file; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts new file mode 100644 index 0000000000..1af81fe46d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -0,0 +1,64 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis, DriveFiles } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { getConnection } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { ApiError } from '../../../error'; +import { ID } from '@/misc/cafy-id'; +import rndstr from 'rndstr'; +import { publishBroadcastStream } from '@/services/stream'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + fileId: { + validator: $.type(ID) + }, + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'MO_SUCH_FILE', + id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf' + } + } +}; + +export default define(meta, async (ps, me) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + + const name = file.name.split('.')[0].match(/^[a-z0-9_]+$/) ? file.name.split('.')[0] : `_${rndstr('a-z0-9', 8)}_`; + + const emoji = await Emojis.save({ + id: genId(), + updatedAt: new Date(), + name: name, + category: null, + host: null, + aliases: [], + url: file.url, + type: file.type, + }); + + await getConnection().queryResultCache!.remove(['meta_emojis']); + + publishBroadcastStream('emojiAdded', { + emoji: await Emojis.pack(emoji.id) + }); + + insertModerationLog(me, 'addEmoji', { + emojiId: emoji.id + }); + + return { + id: emoji.id + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts new file mode 100644 index 0000000000..4c8ab99f7c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { getConnection } from 'typeorm'; +import { ApiError } from '../../../error'; +import { DriveFile } from '@/models/entities/drive-file'; +import { ID } from '@/misc/cafy-id'; +import uploadFromUrl from '@/services/drive/upload-from-url'; +import { publishBroadcastStream } from '@/services/stream'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + emojiId: { + validator: $.type(ID) + }, + }, + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: 'e2785b66-dca3-4087-9cac-b93c541cc425' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + } + } + } +}; + +export default define(meta, async (ps, me) => { + const emoji = await Emojis.findOne(ps.emojiId); + + if (emoji == null) { + throw new ApiError(meta.errors.noSuchEmoji); + } + + let driveFile: DriveFile; + + try { + // Create file + driveFile = await uploadFromUrl(emoji.url, null, null, null, false, true); + } catch (e) { + throw new ApiError(); + } + + const copied = await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: emoji.name, + host: null, + aliases: [], + url: driveFile.url, + type: driveFile.type, + fileId: driveFile.id, + }).then(x => Emojis.findOneOrFail(x.identifiers[0])); + + await getConnection().queryResultCache!.remove(['meta_emojis']); + + publishBroadcastStream('emojiAdded', { + emoji: await Emojis.pack(copied.id) + }); + + return { + id: copied.id + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts new file mode 100644 index 0000000000..3c8ca22170 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -0,0 +1,99 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + query: { + validator: $.optional.nullable.str, + default: null + }, + + host: { + validator: $.optional.nullable.str, + default: null + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + aliases: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + category: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + host: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async (ps) => { + const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId); + + if (ps.host == null) { + q.andWhere(`emoji.host IS NOT NULL`); + } else { + q.andWhere(`emoji.host = :host`, { host: toPuny(ps.host) }); + } + + if (ps.query) { + q.andWhere('emoji.name like :query', { query: '%' + ps.query + '%' }); + } + + const emojis = await q + .orderBy('emoji.id', 'DESC') + .take(ps.limit!) + .getMany(); + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts new file mode 100644 index 0000000000..cb1e79e0fe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -0,0 +1,98 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Emojis } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { ID } from '@/misc/cafy-id'; +import { Emoji } from '@/models/entities/emoji'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + query: { + validator: $.optional.nullable.str, + default: null + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + }, + aliases: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + category: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + host: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async (ps) => { + const q = makePaginationQuery(Emojis.createQueryBuilder('emoji'), ps.sinceId, ps.untilId) + .andWhere(`emoji.host IS NULL`); + + let emojis: Emoji[]; + + if (ps.query) { + //q.andWhere('emoji.name ILIKE :q', { q: `%${ps.query}%` }); + //const emojis = await q.take(ps.limit!).getMany(); + + emojis = await q.getMany(); + + emojis = emojis.filter(emoji => + emoji.name.includes(ps.query!) || + emoji.aliases.some(a => a.includes(ps.query!)) || + emoji.category?.includes(ps.query!)); + + emojis.splice(ps.limit! + 1); + } else { + emojis = await q.take(ps.limit!).getMany(); + } + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove.ts new file mode 100644 index 0000000000..259950e362 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove.ts @@ -0,0 +1,42 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Emojis } from '@/models/index'; +import { getConnection } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + } + }, + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: 'be83669b-773a-44b7-b1f8-e5e5170ac3c2' + } + } +}; + +export default define(meta, async (ps, me) => { + const emoji = await Emojis.findOne(ps.id); + + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + + await Emojis.delete(emoji.id); + + await getConnection().queryResultCache!.remove(['meta_emojis']); + + insertModerationLog(me, 'removeEmoji', { + emoji: emoji + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts new file mode 100644 index 0000000000..3fd547d7e5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { Emojis } from '@/models/index'; +import { getConnection } from 'typeorm'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + id: { + validator: $.type(ID) + }, + + name: { + validator: $.str + }, + + category: { + validator: $.optional.nullable.str + }, + + aliases: { + validator: $.arr($.str) + } + }, + + errors: { + noSuchEmoji: { + message: 'No such emoji.', + code: 'NO_SUCH_EMOJI', + id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8' + } + } +}; + +export default define(meta, async (ps) => { + const emoji = await Emojis.findOne(ps.id); + + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + + await Emojis.update(emoji.id, { + updatedAt: new Date(), + name: ps.name, + category: ps.category, + aliases: ps.aliases, + }); + + await getConnection().queryResultCache!.remove(['meta_emojis']); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts new file mode 100644 index 0000000000..82540c5447 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts @@ -0,0 +1,27 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { deleteFile } from '@/services/drive/delete-file'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, me) => { + const files = await DriveFiles.find({ + userHost: ps.host + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts new file mode 100644 index 0000000000..65a6947ba0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; +import { fetchInstanceMetadata } from '@/services/fetch-instance-metadata'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances.findOne({ host: toPuny(ps.host) }); + + if (instance == null) { + throw new Error('instance not found'); + } + + fetchInstanceMetadata(instance, true); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts new file mode 100644 index 0000000000..7935eaa631 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; +import define from '../../../define'; +import deleteFollowing from '@/services/following/delete'; +import { Followings, Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, me) => { + const followings = await Followings.find({ + followerHost: ps.host + }); + + const pairs = await Promise.all(followings.map(f => Promise.all([ + Users.findOneOrFail(f.followerId), + Users.findOneOrFail(f.followeeId) + ]))); + + for (const pair of pairs) { + deleteFollowing(pair[0], pair[1]); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts new file mode 100644 index 0000000000..34eab27c78 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + host: { + validator: $.str + }, + + isSuspended: { + validator: $.bool + }, + } +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances.findOne({ host: toPuny(ps.host) }); + + if (instance == null) { + throw new Error('instance not found'); + } + + Instances.update({ host: toPuny(ps.host) }, { + isSuspended: ps.isSuspended + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts new file mode 100644 index 0000000000..f2b06d0ef2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts @@ -0,0 +1,26 @@ +import define from '../../define'; +import { getConnection } from 'typeorm'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + tags: ['admin'], + + params: { + }, +}; + +export default define(meta, async () => { + const stats = await + getConnection().query(`SELECT * FROM pg_indexes;`) + .then(recs => { + const res = [] as { tablename: string; indexname: string; }[]; + for (const rec of recs) { + res.push(rec); + } + return res; + }); + + return stats; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts new file mode 100644 index 0000000000..bce813232b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts @@ -0,0 +1,45 @@ +import define from '../../define'; +import { getConnection } from 'typeorm'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + tags: ['admin'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + example: { + migrations: { + count: 66, + size: 32768 + }, + } + } +}; + +export default define(meta, async () => { + const sizes = await + getConnection().query(` + SELECT relname AS "table", reltuples as "count", pg_total_relation_size(C.oid) AS "size" + 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 <> 'i' + AND nspname !~ '^pg_toast';`) + .then(recs => { + const res = {} as Record<string, { count: number; size: number; }>; + for (const rec of recs) { + res[rec.table] = { + count: parseInt(rec.count, 10), + size: parseInt(rec.size, 10), + }; + } + return res; + }); + + return sizes; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/invite.ts b/packages/backend/src/server/api/endpoints/admin/invite.ts new file mode 100644 index 0000000000..2c69eec535 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/invite.ts @@ -0,0 +1,44 @@ +import rndstr from 'rndstr'; +import define from '../../define'; +import { RegistrationTickets } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: {}, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + code: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: '2ERUA5VR', + maxLength: 8, + minLength: 8 + } + } + } +}; + +export default define(meta, async () => { + const code = rndstr({ + length: 8, + chars: '2-9A-HJ-NP-Z', // [0-9A-Z] w/o [01IO] (32 patterns) + }); + + await RegistrationTickets.insert({ + id: genId(), + createdAt: new Date(), + code, + }); + + return { + code, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts new file mode 100644 index 0000000000..2b87fc217f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireAdmin: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot mark as moderator if admin user'); + } + + await Users.update(user.id, { + isModerator: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts new file mode 100644 index 0000000000..cbb0625224 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts @@ -0,0 +1,29 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireAdmin: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isModerator: false + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/promo/create.ts b/packages/backend/src/server/api/endpoints/admin/promo/create.ts new file mode 100644 index 0000000000..3bdaaad4d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/promo/create.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { PromoNotes } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + noteId: { + validator: $.type(ID), + }, + + expiresAt: { + validator: $.num.int() + }, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'ee449fbe-af2a-453b-9cae-cf2fe7c895fc' + }, + + alreadyPromoted: { + message: 'The note has already promoted.', + code: 'ALREADY_PROMOTED', + id: 'ae427aa2-7a41-484f-a18c-2c1104051604' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const exist = await PromoNotes.findOne(note.id); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyPromoted); + } + + await PromoNotes.insert({ + noteId: note.id, + createdAt: new Date(), + expiresAt: new Date(ps.expiresAt), + userId: note.userId, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts new file mode 100644 index 0000000000..fedb7065ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -0,0 +1,18 @@ +import define from '../../../define'; +import { destroy } from '@/queue/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: {} +}; + +export default define(meta, async (ps, me) => { + destroy(); + + insertModerationLog(me, 'clearQueue'); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts new file mode 100644 index 0000000000..cd7b640983 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -0,0 +1,55 @@ +import { deliverQueue } from '@/queue/queues'; +import { URL } from 'url'; +import define from '../../../define'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + anyOf: [ + { + type: 'string' as const, + }, + { + type: 'number' as const, + } + ] + } + }, + example: [[ + 'example.com', + 12 + ]] + } +}; + +export default define(meta, async (ps) => { + const jobs = await deliverQueue.getJobs(['delayed']); + + const res = [] as [string, number][]; + + for (const job of jobs) { + const host = new URL(job.data.to).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts new file mode 100644 index 0000000000..1925906c28 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -0,0 +1,55 @@ +import { URL } from 'url'; +import define from '../../../define'; +import { inboxQueue } from '@/queue/queues'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + anyOf: [ + { + type: 'string' as const, + }, + { + type: 'number' as const, + } + ] + } + }, + example: [[ + 'example.com', + 12 + ]] + } +}; + +export default define(meta, async (ps) => { + const jobs = await inboxQueue.getJobs(['delayed']); + + const res = [] as [string, number][]; + + for (const job of jobs) { + const host = new URL(job.data.signature.keyId).host; + if (res.find(x => x[0] === host)) { + res.find(x => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts new file mode 100644 index 0000000000..c426e5f39b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -0,0 +1,81 @@ +import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from '@/queue/queues'; +import $ from 'cafy'; +import define from '../../../define'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + domain: { + validator: $.str.or(['deliver', 'inbox', 'db', 'objectStorage']), + }, + + state: { + validator: $.str.or(['active', 'waiting', 'delayed']), + }, + + limit: { + validator: $.optional.num, + default: 50 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + data: { + type: 'object' as const, + optional: false as const, nullable: false as const + }, + attempts: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + maxAttempts: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + timestamp: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } + } +}; + +export default define(meta, async (ps) => { + const queue = + ps.domain === 'deliver' ? deliverQueue : + ps.domain === 'inbox' ? inboxQueue : + ps.domain === 'db' ? dbQueue : + ps.domain === 'objectStorage' ? objectStorageQueue : + null as never; + + const jobs = await queue.getJobs([ps.state], 0, ps.limit!); + + return jobs.map(job => { + const data = job.data; + delete data.content; + delete data.user; + return { + id: job.id, + data, + attempts: job.attemptsMade, + maxAttempts: job.opts ? job.opts.attempts : 0, + timestamp: job.timestamp, + }; + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts new file mode 100644 index 0000000000..38f18459dd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -0,0 +1,44 @@ +import { deliverQueue, inboxQueue, dbQueue, objectStorageQueue } from '@/queue/queues'; +import define from '../../../define'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: {}, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + deliver: { + ref: 'QueueCount' + }, + inbox: { + ref: 'QueueCount' + }, + db: { + ref: 'QueueCount' + }, + objectStorage: { + ref: 'QueueCount' + } + } + } +}; + +export default define(meta, async (ps) => { + const deliverJobCounts = await deliverQueue.getJobCounts(); + const inboxJobCounts = await inboxQueue.getJobCounts(); + const dbJobCounts = await dbQueue.getJobCounts(); + const objectStorageJobCounts = await objectStorageQueue.getJobCounts(); + + return { + deliver: deliverJobCounts, + inbox: inboxJobCounts, + db: dbJobCounts, + objectStorage: objectStorageJobCounts, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts new file mode 100644 index 0000000000..567035fd3a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -0,0 +1,63 @@ +import { URL } from 'url'; +import $ from 'cafy'; +import define from '../../../define'; +import { addRelay } from '@/services/relay'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + inbox: { + validator: $.str + }, + }, + + errors: { + invalidUrl: { + message: 'Invalid URL', + code: 'INVALID_URL', + id: 'fb8c92d3-d4e5-44e7-b3d4-800d5cef8b2c' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + inbox: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + status: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'requesting', + enum: [ + 'requesting', + 'accepted', + 'rejected' + ] + } + } + } +}; + +export default define(meta, async (ps, user) => { + try { + if (new URL(ps.inbox).protocol !== 'https:') throw 'https only'; + } catch { + throw new ApiError(meta.errors.invalidUrl); + } + + return await addRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/list.ts b/packages/backend/src/server/api/endpoints/admin/relays/list.ts new file mode 100644 index 0000000000..031ebe85d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/list.ts @@ -0,0 +1,47 @@ +import define from '../../../define'; +import { listRelay } from '@/services/relay'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + inbox: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + status: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'requesting', + enum: [ + 'requesting', + 'accepted', + 'rejected' + ] + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + return await listRelay(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts new file mode 100644 index 0000000000..c1c50f5dc0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts @@ -0,0 +1,20 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { removeRelay } from '@/services/relay'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true as const, + + params: { + inbox: { + validator: $.str + }, + }, +}; + +export default define(meta, async (ps, user) => { + return await removeRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts new file mode 100644 index 0000000000..0fc2c6a868 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import * as bcrypt from 'bcryptjs'; +import rndstr from 'rndstr'; +import { Users, UserProfiles } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + password: { + type: 'string' as const, + optional: false as const, nullable: false as const, + minLength: 8, + maxLength: 8 + } + } + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot reset password of admin'); + } + + const passwd = rndstr('a-zA-Z0-9', 8); + + // Generate hash of password + const hash = bcrypt.hashSync(passwd); + + await UserProfiles.update({ + userId: user.id + }, { + password: hash + }); + + return { + password: passwd + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts new file mode 100644 index 0000000000..7b71f8e000 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { AbuseUserReports } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + reportId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const report = await AbuseUserReports.findOne(ps.reportId); + + if (report == null) { + throw new Error('report not found'); + } + + await AbuseUserReports.update(report.id, { + resolved: true, + assigneeId: me.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/resync-chart.ts b/packages/backend/src/server/api/endpoints/admin/resync-chart.ts new file mode 100644 index 0000000000..e01dfce1b6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/resync-chart.ts @@ -0,0 +1,21 @@ +import define from '../../define'; +import { driveChart, notesChart, usersChart } from '@/services/chart/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, +}; + +export default define(meta, async (ps, me) => { + insertModerationLog(me, 'chartResync'); + + driveChart.resync(); + notesChart.resync(); + usersChart.resync(); + + // TODO: ユーザーごとのチャートもキューに入れて更新する + // TODO: インスタンスごとのチャートもキューに入れて更新する +}); diff --git a/packages/backend/src/server/api/endpoints/admin/send-email.ts b/packages/backend/src/server/api/endpoints/admin/send-email.ts new file mode 100644 index 0000000000..6f67b78542 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/send-email.ts @@ -0,0 +1,26 @@ +import $ from 'cafy'; +import define from '../../define'; +import { sendEmail } from '@/services/send-email'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + to: { + validator: $.str, + }, + subject: { + validator: $.str, + }, + text: { + validator: $.str, + }, + } +}; + +export default define(meta, async (ps) => { + await sendEmail(ps.to, ps.subject, ps.text, ps.text); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts new file mode 100644 index 0000000000..bb2d35e397 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -0,0 +1,119 @@ +import * as os from 'os'; +import * as si from 'systeminformation'; +import { getConnection } from 'typeorm'; +import define from '../../define'; +import { redisClient } from '../../../../db/redis'; + +export const meta = { + requireCredential: true as const, + requireModerator: true, + + tags: ['admin', 'meta'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + machine: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + os: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'linux' + }, + node: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + psql: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + cpu: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + model: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + cores: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + }, + mem: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + format: 'bytes', + } + } + }, + fs: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + format: 'bytes', + }, + used: { + type: 'number' as const, + optional: false as const, nullable: false as const, + format: 'bytes', + } + } + }, + net: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + interface: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: 'eth0' + } + } + } + } + } +}; + +export default define(meta, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); + const netInterface = await si.networkInterfaceDefault(); + + return { + machine: os.hostname(), + os: os.platform(), + node: process.version, + psql: await getConnection().query('SHOW server_version').then(x => x[0].server_version), + redis: redisClient.server_info.redis_version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length + }, + mem: { + total: memStats.total + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + net: { + interface: netInterface + } + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts new file mode 100644 index 0000000000..e9509568d0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -0,0 +1,74 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ModerationLogs } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + info: { + type: 'object' as const, + optional: false as const, nullable: false as const + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(ModerationLogs.createQueryBuilder('report'), ps.sinceId, ps.untilId); + + const reports = await query.take(ps.limit!).getMany(); + + return await ModerationLogs.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts new file mode 100644 index 0000000000..963c123255 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -0,0 +1,177 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + nullable: false as const, optional: false as const, + properties: { + id: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + nullable: false as const, optional: false as const, + format: 'date-time' + }, + updatedAt: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'date-time' + }, + lastFetchedAt: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + username: { + type: 'string' as const, + nullable: false as const, optional: false as const + }, + name: { + type: 'string' as const, + nullable: false as const, optional: false as const + }, + folowersCount: { + type: 'number' as const, + nullable: false as const, optional: false as const + }, + followingCount: { + type: 'number' as const, + nullable: false as const, optional: false as const + }, + notesCount: { + type: 'number' as const, + nullable: false as const, optional: false as const + }, + avatarId: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + bannerId: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + tags: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'string' as const, + nullable: false as const, optional: false as const + } + }, + avatarUrl: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'url' + }, + bannerUrl: { + type: 'string' as const, + nullable: true as const, optional: false as const, + format: 'url' + }, + avatarBlurhash: { + type: 'any' as const, + nullable: true as const, optional: false as const, + default: null + }, + bannerBlurhash: { + type: 'any' as const, + nullable: true as const, optional: false as const, + default: null + }, + isSuspended: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isSilenced: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isLocked: { + type: 'boolean' as const, + nullable: false as const, optional: false as const, + }, + isBot: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isCat: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isAdmin: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + isModerator: { + type: 'boolean' as const, + nullable: false as const, optional: false as const + }, + emojis: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'string' as const, + nullable: false as const, optional: false as const + } + }, + host: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + inbox: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + sharedInbox: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + featured: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + uri: { + type: 'string' as const, + nullable: true as const, optional: false as const + }, + token: { + type: 'string' as const, + nullable: false as const, optional: false as const, + default: '<MASKED>' + } + } + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if ((me.isModerator && !me.isAdmin) && user.isAdmin) { + throw new Error('cannot show info of admin'); + } + + return { + ...user, + token: user.token != null ? '<MASKED>' : user.token, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts new file mode 100644 index 0000000000..20b63e7be6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -0,0 +1,119 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + + sort: { + validator: $.optional.str.or([ + '+follower', + '-follower', + '+createdAt', + '-createdAt', + '+updatedAt', + '-updatedAt', + ]), + }, + + state: { + validator: $.optional.str.or([ + 'all', + 'available', + 'admin', + 'moderator', + 'adminOrModerator', + 'silenced', + 'suspended', + ]), + default: 'all' + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + }, + + username: { + validator: $.optional.str, + default: null + }, + + hostname: { + validator: $.optional.str, + default: null + } + }, + + res: { + type: 'array' as const, + nullable: false as const, optional: false as const, + items: { + type: 'object' as const, + nullable: false as const, optional: false as const, + ref: 'User' + } + } +}; + +export default define(meta, async (ps, me) => { + const query = Users.createQueryBuilder('user'); + + switch (ps.state) { + case 'available': query.where('user.isSuspended = FALSE'); break; + case 'admin': query.where('user.isAdmin = TRUE'); break; + case 'moderator': query.where('user.isModerator = TRUE'); break; + case 'adminOrModerator': query.where('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; + case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; + case 'silenced': query.where('user.isSilenced = TRUE'); break; + case 'suspended': query.where('user.isSuspended = TRUE'); break; + } + + switch (ps.origin) { + case 'local': query.andWhere('user.host IS NULL'); break; + case 'remote': query.andWhere('user.host IS NOT NULL'); break; + } + + if (ps.username) { + query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }); + } + + if (ps.hostname) { + query.andWhere('user.host like :hostname', { hostname: '%' + ps.hostname.toLowerCase() + '%' }); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.orderBy('user.updatedAt', 'DESC', 'NULLS LAST'); break; + case '-updatedAt': query.orderBy('user.updatedAt', 'ASC', 'NULLS FIRST'); break; + default: query.orderBy('user.id', 'ASC'); break; + } + + query.take(ps.limit!); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts new file mode 100644 index 0000000000..9bfed2310a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -0,0 +1,38 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot silence admin'); + } + + await Users.update(user.id, { + isSilenced: true + }); + + insertModerationLog(me, 'silence', { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts new file mode 100644 index 0000000000..364f258ce8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -0,0 +1,84 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import deleteFollowing from '@/services/following/delete'; +import { Users, Followings, Notifications } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { doPostSuspend } from '@/services/suspend-user'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } + + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + } + + await Users.update(user.id, { + isSuspended: true + }); + + insertModerationLog(me, 'suspend', { + targetId: user.id, + }); + + // Terminate streaming + if (Users.isLocalUser(user)) { + publishUserEvent(user.id, 'terminate', {}); + } + + (async () => { + await doPostSuspend(user).catch(e => {}); + await unFollowAll(user).catch(e => {}); + await readAllNotify(user).catch(e => {}); + })(); +}); + +async function unFollowAll(follower: User) { + const followings = await Followings.find({ + followerId: follower.id + }); + + for (const following of followings) { + const followee = await Users.findOne({ + id: following.followeeId + }); + + if (followee == null) { + throw `Cant find followee ${following.followeeId}`; + } + + await deleteFollowing(follower, followee, true); + } +} + +async function readAllNotify(notifier: User) { + await Notifications.update({ + notifierId: notifier.id, + isRead: false, + }, { + isRead: true + }); +} diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts new file mode 100644 index 0000000000..9994fbf462 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isSilenced: false + }); + + insertModerationLog(me, 'unsilence', { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts new file mode 100644 index 0000000000..ab4c2d3dfe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { doPostUnsuspend } from '@/services/unsuspend-user'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId as string); + + if (user == null) { + throw new Error('user not found'); + } + + await Users.update(user.id, { + isSuspended: false + }); + + insertModerationLog(me, 'unsuspend', { + targetId: user.id, + }); + + doPostUnsuspend(user); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts new file mode 100644 index 0000000000..55447098dc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -0,0 +1,608 @@ +import $ from 'cafy'; +import define from '../../define'; +import { getConnection } from 'typeorm'; +import { Meta } from '@/models/entities/meta'; +import { insertModerationLog } from '@/services/insert-moderation-log'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireAdmin: true, + + params: { + disableRegistration: { + validator: $.optional.nullable.bool, + }, + + disableLocalTimeline: { + validator: $.optional.nullable.bool, + }, + + disableGlobalTimeline: { + validator: $.optional.nullable.bool, + }, + + useStarForReactionFallback: { + validator: $.optional.nullable.bool, + }, + + pinnedUsers: { + validator: $.optional.nullable.arr($.str), + }, + + hiddenTags: { + validator: $.optional.nullable.arr($.str), + }, + + blockedHosts: { + validator: $.optional.nullable.arr($.str), + }, + + mascotImageUrl: { + validator: $.optional.nullable.str, + }, + + bannerUrl: { + validator: $.optional.nullable.str, + }, + + errorImageUrl: { + validator: $.optional.nullable.str, + }, + + iconUrl: { + validator: $.optional.nullable.str, + }, + + backgroundImageUrl: { + validator: $.optional.nullable.str, + }, + + logoImageUrl: { + validator: $.optional.nullable.str, + }, + + name: { + validator: $.optional.nullable.str, + }, + + description: { + validator: $.optional.nullable.str, + }, + + maxNoteTextLength: { + validator: $.optional.num.min(0).max(DB_MAX_NOTE_TEXT_LENGTH), + }, + + localDriveCapacityMb: { + validator: $.optional.num.min(0), + }, + + remoteDriveCapacityMb: { + validator: $.optional.num.min(0), + }, + + cacheRemoteFiles: { + validator: $.optional.bool, + }, + + proxyRemoteFiles: { + validator: $.optional.bool, + }, + + emailRequiredForSignup: { + validator: $.optional.bool, + }, + + enableHcaptcha: { + validator: $.optional.bool, + }, + + hcaptchaSiteKey: { + validator: $.optional.nullable.str, + }, + + hcaptchaSecretKey: { + validator: $.optional.nullable.str, + }, + + enableRecaptcha: { + validator: $.optional.bool, + }, + + recaptchaSiteKey: { + validator: $.optional.nullable.str, + }, + + recaptchaSecretKey: { + validator: $.optional.nullable.str, + }, + + proxyAccountId: { + validator: $.optional.nullable.type(ID), + }, + + maintainerName: { + validator: $.optional.nullable.str, + }, + + maintainerEmail: { + validator: $.optional.nullable.str, + }, + + pinnedPages: { + validator: $.optional.arr($.str), + }, + + pinnedClipId: { + validator: $.optional.nullable.type(ID), + }, + + langs: { + validator: $.optional.arr($.str), + }, + + summalyProxy: { + validator: $.optional.nullable.str, + }, + + deeplAuthKey: { + validator: $.optional.nullable.str, + }, + + deeplIsPro: { + validator: $.optional.bool, + }, + + enableTwitterIntegration: { + validator: $.optional.bool, + }, + + twitterConsumerKey: { + validator: $.optional.nullable.str, + }, + + twitterConsumerSecret: { + validator: $.optional.nullable.str, + }, + + enableGithubIntegration: { + validator: $.optional.bool, + }, + + githubClientId: { + validator: $.optional.nullable.str, + }, + + githubClientSecret: { + validator: $.optional.nullable.str, + }, + + enableDiscordIntegration: { + validator: $.optional.bool, + }, + + discordClientId: { + validator: $.optional.nullable.str, + }, + + discordClientSecret: { + validator: $.optional.nullable.str, + }, + + enableEmail: { + validator: $.optional.bool, + }, + + email: { + validator: $.optional.nullable.str, + }, + + smtpSecure: { + validator: $.optional.bool, + }, + + smtpHost: { + validator: $.optional.nullable.str, + }, + + smtpPort: { + validator: $.optional.nullable.num, + }, + + smtpUser: { + validator: $.optional.nullable.str, + }, + + smtpPass: { + validator: $.optional.nullable.str, + }, + + enableServiceWorker: { + validator: $.optional.bool, + }, + + swPublicKey: { + validator: $.optional.nullable.str, + }, + + swPrivateKey: { + validator: $.optional.nullable.str, + }, + + tosUrl: { + validator: $.optional.nullable.str, + }, + + repositoryUrl: { + validator: $.optional.str, + }, + + feedbackUrl: { + validator: $.optional.str, + }, + + useObjectStorage: { + validator: $.optional.bool + }, + + objectStorageBaseUrl: { + validator: $.optional.nullable.str + }, + + objectStorageBucket: { + validator: $.optional.nullable.str + }, + + objectStoragePrefix: { + validator: $.optional.nullable.str + }, + + objectStorageEndpoint: { + validator: $.optional.nullable.str + }, + + objectStorageRegion: { + validator: $.optional.nullable.str + }, + + objectStoragePort: { + validator: $.optional.nullable.num + }, + + objectStorageAccessKey: { + validator: $.optional.nullable.str + }, + + objectStorageSecretKey: { + validator: $.optional.nullable.str + }, + + objectStorageUseSSL: { + validator: $.optional.bool + }, + + objectStorageUseProxy: { + validator: $.optional.bool + }, + + objectStorageSetPublicRead: { + validator: $.optional.bool + }, + + objectStorageS3ForcePathStyle: { + validator: $.optional.bool + }, + } +}; + +export default define(meta, async (ps, me) => { + const set = {} as Partial<Meta>; + + if (typeof ps.disableRegistration === 'boolean') { + set.disableRegistration = ps.disableRegistration; + } + + if (typeof ps.disableLocalTimeline === 'boolean') { + set.disableLocalTimeline = ps.disableLocalTimeline; + } + + if (typeof ps.disableGlobalTimeline === 'boolean') { + set.disableGlobalTimeline = ps.disableGlobalTimeline; + } + + if (typeof ps.useStarForReactionFallback === 'boolean') { + set.useStarForReactionFallback = ps.useStarForReactionFallback; + } + + if (Array.isArray(ps.pinnedUsers)) { + set.pinnedUsers = ps.pinnedUsers.filter(Boolean); + } + + if (Array.isArray(ps.hiddenTags)) { + set.hiddenTags = ps.hiddenTags.filter(Boolean); + } + + if (Array.isArray(ps.blockedHosts)) { + set.blockedHosts = ps.blockedHosts.filter(Boolean); + } + + if (ps.mascotImageUrl !== undefined) { + set.mascotImageUrl = ps.mascotImageUrl; + } + + if (ps.bannerUrl !== undefined) { + set.bannerUrl = ps.bannerUrl; + } + + if (ps.iconUrl !== undefined) { + set.iconUrl = ps.iconUrl; + } + + if (ps.backgroundImageUrl !== undefined) { + set.backgroundImageUrl = ps.backgroundImageUrl; + } + + if (ps.logoImageUrl !== undefined) { + set.logoImageUrl = ps.logoImageUrl; + } + + if (ps.name !== undefined) { + set.name = ps.name; + } + + if (ps.description !== undefined) { + set.description = ps.description; + } + + if (ps.maxNoteTextLength) { + set.maxNoteTextLength = ps.maxNoteTextLength; + } + + if (ps.localDriveCapacityMb !== undefined) { + set.localDriveCapacityMb = ps.localDriveCapacityMb; + } + + if (ps.remoteDriveCapacityMb !== undefined) { + set.remoteDriveCapacityMb = ps.remoteDriveCapacityMb; + } + + if (ps.cacheRemoteFiles !== undefined) { + set.cacheRemoteFiles = ps.cacheRemoteFiles; + } + + if (ps.proxyRemoteFiles !== undefined) { + set.proxyRemoteFiles = ps.proxyRemoteFiles; + } + + if (ps.emailRequiredForSignup !== undefined) { + set.emailRequiredForSignup = ps.emailRequiredForSignup; + } + + if (ps.enableHcaptcha !== undefined) { + set.enableHcaptcha = ps.enableHcaptcha; + } + + if (ps.hcaptchaSiteKey !== undefined) { + set.hcaptchaSiteKey = ps.hcaptchaSiteKey; + } + + if (ps.hcaptchaSecretKey !== undefined) { + set.hcaptchaSecretKey = ps.hcaptchaSecretKey; + } + + if (ps.enableRecaptcha !== undefined) { + set.enableRecaptcha = ps.enableRecaptcha; + } + + if (ps.recaptchaSiteKey !== undefined) { + set.recaptchaSiteKey = ps.recaptchaSiteKey; + } + + if (ps.recaptchaSecretKey !== undefined) { + set.recaptchaSecretKey = ps.recaptchaSecretKey; + } + + if (ps.proxyAccountId !== undefined) { + set.proxyAccountId = ps.proxyAccountId; + } + + if (ps.maintainerName !== undefined) { + set.maintainerName = ps.maintainerName; + } + + if (ps.maintainerEmail !== undefined) { + set.maintainerEmail = ps.maintainerEmail; + } + + if (Array.isArray(ps.langs)) { + set.langs = ps.langs.filter(Boolean); + } + + if (Array.isArray(ps.pinnedPages)) { + set.pinnedPages = ps.pinnedPages.filter(Boolean); + } + + if (ps.pinnedClipId !== undefined) { + set.pinnedClipId = ps.pinnedClipId; + } + + if (ps.summalyProxy !== undefined) { + set.summalyProxy = ps.summalyProxy; + } + + if (ps.enableTwitterIntegration !== undefined) { + set.enableTwitterIntegration = ps.enableTwitterIntegration; + } + + if (ps.twitterConsumerKey !== undefined) { + set.twitterConsumerKey = ps.twitterConsumerKey; + } + + if (ps.twitterConsumerSecret !== undefined) { + set.twitterConsumerSecret = ps.twitterConsumerSecret; + } + + if (ps.enableGithubIntegration !== undefined) { + set.enableGithubIntegration = ps.enableGithubIntegration; + } + + if (ps.githubClientId !== undefined) { + set.githubClientId = ps.githubClientId; + } + + if (ps.githubClientSecret !== undefined) { + set.githubClientSecret = ps.githubClientSecret; + } + + if (ps.enableDiscordIntegration !== undefined) { + set.enableDiscordIntegration = ps.enableDiscordIntegration; + } + + if (ps.discordClientId !== undefined) { + set.discordClientId = ps.discordClientId; + } + + if (ps.discordClientSecret !== undefined) { + set.discordClientSecret = ps.discordClientSecret; + } + + if (ps.enableEmail !== undefined) { + set.enableEmail = ps.enableEmail; + } + + if (ps.email !== undefined) { + set.email = ps.email; + } + + if (ps.smtpSecure !== undefined) { + set.smtpSecure = ps.smtpSecure; + } + + if (ps.smtpHost !== undefined) { + set.smtpHost = ps.smtpHost; + } + + if (ps.smtpPort !== undefined) { + set.smtpPort = ps.smtpPort; + } + + if (ps.smtpUser !== undefined) { + set.smtpUser = ps.smtpUser; + } + + if (ps.smtpPass !== undefined) { + set.smtpPass = ps.smtpPass; + } + + if (ps.errorImageUrl !== undefined) { + set.errorImageUrl = ps.errorImageUrl; + } + + if (ps.enableServiceWorker !== undefined) { + set.enableServiceWorker = ps.enableServiceWorker; + } + + if (ps.swPublicKey !== undefined) { + set.swPublicKey = ps.swPublicKey; + } + + if (ps.swPrivateKey !== undefined) { + set.swPrivateKey = ps.swPrivateKey; + } + + if (ps.tosUrl !== undefined) { + set.ToSUrl = ps.tosUrl; + } + + if (ps.repositoryUrl !== undefined) { + set.repositoryUrl = ps.repositoryUrl; + } + + if (ps.feedbackUrl !== undefined) { + set.feedbackUrl = ps.feedbackUrl; + } + + if (ps.useObjectStorage !== undefined) { + set.useObjectStorage = ps.useObjectStorage; + } + + if (ps.objectStorageBaseUrl !== undefined) { + set.objectStorageBaseUrl = ps.objectStorageBaseUrl; + } + + if (ps.objectStorageBucket !== undefined) { + set.objectStorageBucket = ps.objectStorageBucket; + } + + if (ps.objectStoragePrefix !== undefined) { + set.objectStoragePrefix = ps.objectStoragePrefix; + } + + if (ps.objectStorageEndpoint !== undefined) { + set.objectStorageEndpoint = ps.objectStorageEndpoint; + } + + if (ps.objectStorageRegion !== undefined) { + set.objectStorageRegion = ps.objectStorageRegion; + } + + if (ps.objectStoragePort !== undefined) { + set.objectStoragePort = ps.objectStoragePort; + } + + if (ps.objectStorageAccessKey !== undefined) { + set.objectStorageAccessKey = ps.objectStorageAccessKey; + } + + if (ps.objectStorageSecretKey !== undefined) { + set.objectStorageSecretKey = ps.objectStorageSecretKey; + } + + if (ps.objectStorageUseSSL !== undefined) { + set.objectStorageUseSSL = ps.objectStorageUseSSL; + } + + if (ps.objectStorageUseProxy !== undefined) { + set.objectStorageUseProxy = ps.objectStorageUseProxy; + } + + if (ps.objectStorageSetPublicRead !== undefined) { + set.objectStorageSetPublicRead = ps.objectStorageSetPublicRead; + } + + if (ps.objectStorageS3ForcePathStyle !== undefined) { + set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; + } + + if (ps.deeplAuthKey !== undefined) { + if (ps.deeplAuthKey === '') { + set.deeplAuthKey = null; + } else { + set.deeplAuthKey = ps.deeplAuthKey; + } + } + + if (ps.deeplIsPro !== undefined) { + set.deeplIsPro = ps.deeplIsPro; + } + + await getConnection().transaction(async transactionalEntityManager => { + const meta = await transactionalEntityManager.findOne(Meta, { + order: { + id: 'DESC' + } + }); + + if (meta) { + await transactionalEntityManager.update(Meta, meta.id, set); + } else { + await transactionalEntityManager.save(Meta, set); + } + }); + + insertModerationLog(me, 'updateMeta'); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/vacuum.ts b/packages/backend/src/server/api/endpoints/admin/vacuum.ts new file mode 100644 index 0000000000..9a80d88c44 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/vacuum.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../define'; +import { getConnection } from 'typeorm'; +import { insertModerationLog } from '@/services/insert-moderation-log'; + +export const meta = { + tags: ['admin'], + + requireCredential: true as const, + requireModerator: true, + + params: { + full: { + validator: $.bool, + }, + analyze: { + validator: $.bool, + }, + } +}; + +export default define(meta, async (ps, me) => { + const params: string[] = []; + + if (ps.full) { + params.push('FULL'); + } + + if (ps.analyze) { + params.push('ANALYZE'); + } + + getConnection().query('VACUUM ' + params.join(' ')); + + insertModerationLog(me, 'vacuum', ps); +}); diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts new file mode 100644 index 0000000000..a67737b2ff --- /dev/null +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -0,0 +1,92 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../define'; +import { Announcements, AnnouncementReads } from '@/models/index'; +import { makePaginationQuery } from '../common/make-pagination-query'; + +export const meta = { + tags: ['meta'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + withUnreads: { + validator: $.optional.boolean, + default: false + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time', + }, + updatedAt: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'date-time', + }, + text: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + title: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + isRead: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Announcements.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + + const announcements = await query.take(ps.limit!).getMany(); + + if (user) { + const reads = (await AnnouncementReads.find({ + userId: user.id + })).map(x => x.announcementId); + + for (const announcement of announcements) { + (announcement as any).isRead = reads.includes(announcement.id); + } + } + + return ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements; +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts new file mode 100644 index 0000000000..4bdae8cc33 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -0,0 +1,127 @@ +import $ from 'cafy'; +import define from '../../define'; +import { genId } from '@/misc/gen-id'; +import { Antennas, UserLists, UserGroupJoinings } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { ApiError } from '../../error'; +import { publishInternalEvent } from '@/services/stream'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + name: { + validator: $.str.range(1, 100) + }, + + src: { + validator: $.str.or(['home', 'all', 'users', 'list', 'group']) + }, + + userListId: { + validator: $.nullable.optional.type(ID), + }, + + userGroupId: { + validator: $.nullable.optional.type(ID), + }, + + keywords: { + validator: $.arr($.arr($.str)) + }, + + excludeKeywords: { + validator: $.arr($.arr($.str)) + }, + + users: { + validator: $.arr($.str) + }, + + caseSensitive: { + validator: $.bool + }, + + withReplies: { + validator: $.bool + }, + + withFile: { + validator: $.bool + }, + + notify: { + validator: $.bool + } + }, + + errors: { + noSuchUserList: { + message: 'No such user list.', + code: 'NO_SUCH_USER_LIST', + id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f' + }, + + noSuchUserGroup: { + message: 'No such user group.', + code: 'NO_SUCH_USER_GROUP', + id: 'aa3c0b9a-8cae-47c0-92ac-202ce5906682' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Antenna' + } +}; + +export default define(meta, async (ps, user) => { + let userList; + let userGroupJoining; + + if (ps.src === 'list' && ps.userListId) { + userList = await UserLists.findOne({ + id: ps.userListId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await UserGroupJoinings.findOne({ + userGroupId: ps.userGroupId, + userId: user.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } + } + + const antenna = await Antennas.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }).then(x => Antennas.findOneOrFail(x.identifiers[0])); + + publishInternalEvent('antennaCreated', antenna); + + return await Antennas.pack(antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/delete.ts b/packages/backend/src/server/api/endpoints/antennas/delete.ts new file mode 100644 index 0000000000..1cd136183e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/delete.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Antennas } from '@/models/index'; +import { publishInternalEvent } from '@/services/stream'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + antennaId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'b34dcf9d-348f-44bb-99d0-6c9314cfe2df' + } + } +}; + +export default define(meta, async (ps, user) => { + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: user.id + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + await Antennas.delete(antenna.id); + + publishInternalEvent('antennaDeleted', antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/list.ts b/packages/backend/src/server/api/endpoints/antennas/list.ts new file mode 100644 index 0000000000..8baae8435b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/list.ts @@ -0,0 +1,28 @@ +import define from '../../define'; +import { Antennas } from '@/models/index'; + +export const meta = { + tags: ['antennas', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Antenna' + } + } +}; + +export default define(meta, async (ps, me) => { + const antennas = await Antennas.find({ + userId: me.id, + }); + + return await Promise.all(antennas.map(x => Antennas.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts new file mode 100644 index 0000000000..1759e95b4c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -0,0 +1,93 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import readNote from '@/services/note/read'; +import { Antennas, Notes, AntennaNotes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { ApiError } from '../../error'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['antennas', 'account', 'notes'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + antennaId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: '850926e0-fd3b-49b6-b69a-b28a5dbd82fe' + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note' + } + } +}; + +export default define(meta, async (ps, user) => { + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: user.id + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + const antennaQuery = AntennaNotes.createQueryBuilder('joining') + .select('joining.noteId') + .where('joining.antennaId = :antennaId', { antennaId: antenna.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.id IN (${ antennaQuery.getQuery() })`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(antennaQuery.getParameters()); + + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + + const notes = await query + .take(ps.limit!) + .getMany(); + + if (notes.length > 0) { + readNote(user.id, notes); + } + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/show.ts b/packages/backend/src/server/api/endpoints/antennas/show.ts new file mode 100644 index 0000000000..3cdf4dcb61 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/show.ts @@ -0,0 +1,47 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Antennas } from '@/models/index'; + +export const meta = { + tags: ['antennas', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + antennaId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: 'c06569fb-b025-4f23-b22d-1fcd20d2816b' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Antenna' + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the antenna + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: me.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + return await Antennas.pack(antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts new file mode 100644 index 0000000000..d69b4feee6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -0,0 +1,143 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Antennas, UserLists, UserGroupJoinings } from '@/models/index'; +import { publishInternalEvent } from '@/services/stream'; + +export const meta = { + tags: ['antennas'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + antennaId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100) + }, + + src: { + validator: $.str.or(['home', 'all', 'users', 'list', 'group']) + }, + + userListId: { + validator: $.nullable.optional.type(ID), + }, + + userGroupId: { + validator: $.nullable.optional.type(ID), + }, + + keywords: { + validator: $.arr($.arr($.str)) + }, + + excludeKeywords: { + validator: $.arr($.arr($.str)) + }, + + users: { + validator: $.arr($.str) + }, + + caseSensitive: { + validator: $.bool + }, + + withReplies: { + validator: $.bool + }, + + withFile: { + validator: $.bool + }, + + notify: { + validator: $.bool + } + }, + + errors: { + noSuchAntenna: { + message: 'No such antenna.', + code: 'NO_SUCH_ANTENNA', + id: '10c673ac-8852-48eb-aa1f-f5b67f069290' + }, + + noSuchUserList: { + message: 'No such user list.', + code: 'NO_SUCH_USER_LIST', + id: '1c6b35c9-943e-48c2-81e4-2844989407f7' + }, + + noSuchUserGroup: { + message: 'No such user group.', + code: 'NO_SUCH_USER_GROUP', + id: '109ed789-b6eb-456e-b8a9-6059d567d385' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Antenna' + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the antenna + const antenna = await Antennas.findOne({ + id: ps.antennaId, + userId: user.id + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + let userList; + let userGroupJoining; + + if (ps.src === 'list' && ps.userListId) { + userList = await UserLists.findOne({ + id: ps.userListId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === 'group' && ps.userGroupId) { + userGroupJoining = await UserGroupJoinings.findOne({ + userGroupId: ps.userGroupId, + userId: user.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } + } + + await Antennas.update(antenna.id, { + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }); + + publishInternalEvent('antennaUpdated', await Antennas.findOneOrFail(antenna.id)); + + return await Antennas.pack(antenna.id); +}); diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts new file mode 100644 index 0000000000..78919f43b0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../define'; +import Resolver from '@/remote/activitypub/resolver'; +import { ApiError } from '../../error'; +import * as ms from 'ms'; + +export const meta = { + tags: ['federation'], + + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 30 + }, + + params: { + uri: { + validator: $.str, + }, + }, + + errors: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + } +}; + +export default define(meta, async (ps) => { + const resolver = new Resolver(); + const object = await resolver.resolve(ps.uri); + return object; +}); diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts new file mode 100644 index 0000000000..2280d93724 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -0,0 +1,190 @@ +import $ from 'cafy'; +import define from '../../define'; +import config from '@/config/index'; +import { createPerson } from '@/remote/activitypub/models/person'; +import { createNote } from '@/remote/activitypub/models/note'; +import Resolver from '@/remote/activitypub/resolver'; +import { ApiError } from '../../error'; +import { extractDbHost } from '@/misc/convert-host'; +import { Users, Notes } from '@/models/index'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { isActor, isPost, getApId } from '@/remote/activitypub/type'; +import * as ms from 'ms'; + +export const meta = { + tags: ['federation'], + + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 30 + }, + + params: { + uri: { + validator: $.str, + }, + }, + + errors: { + noSuchObject: { + message: 'No such object.', + code: 'NO_SUCH_OBJECT', + id: 'dc94d745-1262-4e63-a17d-fecaa57efc82' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + type: { + type: 'string' as const, + optional: false as const, nullable: false as const, + enum: ['User', 'Note'] + }, + object: { + type: 'object' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps) => { + const object = await fetchAny(ps.uri); + if (object) { + return object; + } else { + throw new ApiError(meta.errors.noSuchObject); + } +}); + +/*** + * URIからUserかNoteを解決する + */ +async function fetchAny(uri: string) { + // URIがこのサーバーを指しているなら、ローカルユーザーIDとしてDBからフェッチ + if (uri.startsWith(config.url + '/')) { + const parts = uri.split('/'); + const id = parts.pop(); + const type = parts.pop(); + + if (type === 'notes') { + const note = await Notes.findOne(id); + + if (note) { + return { + type: 'Note', + object: await Notes.pack(note, null, { detail: true }) + }; + } + } else if (type === 'users') { + const user = await Users.findOne(id); + + if (user) { + return { + type: 'User', + object: await Users.pack(user, null, { detail: true }) + }; + } + } + } + + // ブロックしてたら中断 + const meta = await fetchMeta(); + if (meta.blockedHosts.includes(extractDbHost(uri))) return null; + + // URI(AP Object id)としてDB検索 + { + const [user, note] = await Promise.all([ + Users.findOne({ uri: uri }), + Notes.findOne({ uri: uri }) + ]); + + const packed = await mergePack(user, note); + if (packed !== null) return packed; + } + + // リモートから一旦オブジェクトフェッチ + const resolver = new Resolver(); + const object = await resolver.resolve(uri) as any; + + // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する + // これはDBに存在する可能性があるため再度DB検索 + if (uri !== object.id) { + if (object.id.startsWith(config.url + '/')) { + const parts = object.id.split('/'); + const id = parts.pop(); + const type = parts.pop(); + + if (type === 'notes') { + const note = await Notes.findOne(id); + + if (note) { + return { + type: 'Note', + object: await Notes.pack(note, null, { detail: true }) + }; + } + } else if (type === 'users') { + const user = await Users.findOne(id); + + if (user) { + return { + type: 'User', + object: await Users.pack(user, null, { detail: true }) + }; + } + } + } + + const [user, note] = await Promise.all([ + Users.findOne({ uri: object.id }), + Notes.findOne({ uri: object.id }) + ]); + + const packed = await mergePack(user, note); + if (packed !== null) return packed; + } + + // それでもみつからなければ新規であるため登録 + if (isActor(object)) { + const user = await createPerson(getApId(object)); + return { + type: 'User', + object: await Users.pack(user, null, { detail: true }) + }; + } + + if (isPost(object)) { + const note = await createNote(getApId(object), undefined, true); + return { + type: 'Note', + object: await Notes.pack(note!, null, { detail: true }) + }; + } + + return null; +} + +async function mergePack(user: User | null | undefined, note: Note | null | undefined) { + if (user != null) { + return { + type: 'User', + object: await Users.pack(user, null, { detail: true }) + }; + } + + if (note != null) { + return { + type: 'Note', + object: await Notes.pack(note, null, { detail: true }) + }; + } + + return null; +} diff --git a/packages/backend/src/server/api/endpoints/app/create.ts b/packages/backend/src/server/api/endpoints/app/create.ts new file mode 100644 index 0000000000..c2ce943dcc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/app/create.ts @@ -0,0 +1,63 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Apps } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { unique } from '@/prelude/array'; +import { secureRndstr } from '@/misc/secure-rndstr'; + +export const meta = { + tags: ['app'], + + requireCredential: false as const, + + params: { + name: { + validator: $.str, + }, + + description: { + validator: $.str, + }, + + permission: { + validator: $.arr($.str).unique(), + }, + + // TODO: Check it is valid url + callbackUrl: { + validator: $.optional.nullable.str, + default: null, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'App', + }, +}; + +export default define(meta, async (ps, user) => { + // Generate secret + const secret = secureRndstr(32, true); + + // for backward compatibility + const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1'))); + + // Create account + const app = await Apps.save({ + id: genId(), + createdAt: new Date(), + userId: user ? user.id : null, + name: ps.name, + description: ps.description, + permission, + callbackUrl: ps.callbackUrl, + secret: secret + }); + + return await Apps.pack(app, null, { + detail: true, + includeSecret: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/app/show.ts b/packages/backend/src/server/api/endpoints/app/show.ts new file mode 100644 index 0000000000..27f12eb44f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/app/show.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Apps } from '@/models/index'; + +export const meta = { + tags: ['app'], + + params: { + appId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'App', + }, + + errors: { + noSuchApp: { + message: 'No such app.', + code: 'NO_SUCH_APP', + id: 'dce83913-2dc6-4093-8a7b-71dbb11718a3' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'App' + } +}; + +export default define(meta, async (ps, user, token) => { + const isSecure = user != null && token == null; + + // Lookup app + const ap = await Apps.findOne(ps.appId); + + if (ap == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + return await Apps.pack(ap, user, { + detail: true, + includeSecret: isSecure && (ap.userId === user!.id) + }); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts new file mode 100644 index 0000000000..1d1d8ac227 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/accept.ts @@ -0,0 +1,76 @@ +import * as crypto from 'crypto'; +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { AuthSessions, AccessTokens, Apps } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { secureRndstr } from '@/misc/secure-rndstr'; + +export const meta = { + tags: ['auth'], + + requireCredential: true as const, + + secure: true, + + params: { + token: { + validator: $.str + } + }, + + errors: { + noSuchSession: { + message: 'No such session.', + code: 'NO_SUCH_SESSION', + id: '9c72d8de-391a-43c1-9d06-08d29efde8df' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch token + const session = await AuthSessions + .findOne({ token: ps.token }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + // Generate access token + const accessToken = secureRndstr(32, true); + + // Fetch exist access token + const exist = await AccessTokens.findOne({ + appId: session.appId, + userId: user.id, + }); + + if (exist == null) { + // Lookup app + const app = await Apps.findOneOrFail(session.appId); + + // Generate Hash + const sha256 = crypto.createHash('sha256'); + sha256.update(accessToken + app.secret); + const hash = sha256.digest('hex'); + + const now = new Date(); + + // Insert access token doc + await AccessTokens.insert({ + id: genId(), + createdAt: now, + lastUsedAt: now, + appId: session.appId, + userId: user.id, + token: accessToken, + hash: hash + }); + } + + // Update session + await AuthSessions.update(session.id, { + userId: user.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/generate.ts b/packages/backend/src/server/api/endpoints/auth/session/generate.ts new file mode 100644 index 0000000000..859cf52ed3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -0,0 +1,70 @@ +import { v4 as uuid } from 'uuid'; +import $ from 'cafy'; +import config from '@/config/index'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { Apps, AuthSessions } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['auth'], + + requireCredential: false as const, + + params: { + appSecret: { + validator: $.str, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + token: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url', + }, + } + }, + + errors: { + noSuchApp: { + message: 'No such app.', + code: 'NO_SUCH_APP', + id: '92f93e63-428e-4f2f-a5a4-39e1407fe998' + } + } +}; + +export default define(meta, async (ps) => { + // Lookup app + const app = await Apps.findOne({ + secret: ps.appSecret + }); + + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + // Generate token + const token = uuid(); + + // Create session token document + const doc = await AuthSessions.save({ + id: genId(), + createdAt: new Date(), + appId: app.id, + token: token + }); + + return { + token: doc.token, + url: `${config.authUrl}/${doc.token}` + }; +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/show.ts b/packages/backend/src/server/api/endpoints/auth/session/show.ts new file mode 100644 index 0000000000..23f1a56a37 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/show.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { AuthSessions } from '@/models/index'; + +export const meta = { + tags: ['auth'], + + requireCredential: false as const, + + params: { + token: { + validator: $.str, + } + }, + + errors: { + noSuchSession: { + message: 'No such session.', + code: 'NO_SUCH_SESSION', + id: 'bd72c97d-eba7-4adb-a467-f171b8847250' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + app: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'App' + }, + token: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, user) => { + // Lookup session + const session = await AuthSessions.findOne({ + token: ps.token + }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + return await AuthSessions.pack(session, user); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts new file mode 100644 index 0000000000..72201cb207 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts @@ -0,0 +1,98 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { Apps, AuthSessions, AccessTokens, Users } from '@/models/index'; + +export const meta = { + tags: ['auth'], + + requireCredential: false as const, + + params: { + appSecret: { + validator: $.str, + }, + + token: { + validator: $.str, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + accessToken: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + + user: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + }, + } + }, + + errors: { + noSuchApp: { + message: 'No such app.', + code: 'NO_SUCH_APP', + id: 'fcab192a-2c5a-43b7-8ad8-9b7054d8d40d' + }, + + noSuchSession: { + message: 'No such session.', + code: 'NO_SUCH_SESSION', + id: '5b5a1503-8bc8-4bd0-8054-dc189e8cdcb3' + }, + + pendingSession: { + message: 'This session is not completed yet.', + code: 'PENDING_SESSION', + id: '8c8a4145-02cc-4cca-8e66-29ba60445a8e' + } + } +}; + +export default define(meta, async (ps) => { + // Lookup app + const app = await Apps.findOne({ + secret: ps.appSecret + }); + + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + // Fetch token + const session = await AuthSessions.findOne({ + token: ps.token, + appId: app.id + }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + if (session.userId == null) { + throw new ApiError(meta.errors.pendingSession); + } + + // Lookup access token + const accessToken = await AccessTokens.findOneOrFail({ + appId: app.id, + userId: session.userId + }); + + // Delete session + AuthSessions.delete(session.id); + + return { + accessToken: accessToken.token, + user: await Users.pack(session.userId, null, { + detail: true + }) + }; +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/create.ts b/packages/backend/src/server/api/endpoints/blocking/create.ts new file mode 100644 index 0000000000..2953252394 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/blocking/create.ts @@ -0,0 +1,89 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import create from '@/services/blocking/create'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Blockings, NoteWatchings, Users } from '@/models/index'; + +export const meta = { + tags: ['account'], + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true as const, + + kind: 'write:blocks', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '7cc4f851-e2f1-4621-9633-ec9e1d00c01e' + }, + + blockeeIsYourself: { + message: 'Blockee is yourself.', + code: 'BLOCKEE_IS_YOURSELF', + id: '88b19138-f28d-42c0-8499-6a31bbd0fdc6' + }, + + alreadyBlocking: { + message: 'You are already blocking that user.', + code: 'ALREADY_BLOCKING', + id: '787fed64-acb9-464a-82eb-afbd745b9614' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + const blocker = await Users.findOneOrFail(user.id); + + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + // Get blockee + const blockee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already blocking + const exist = await Blockings.findOne({ + blockerId: blocker.id, + blockeeId: blockee.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyBlocking); + } + + await create(blocker, blockee); + + NoteWatchings.delete({ + userId: blocker.id, + noteUserId: blockee.id + }); + + return await Users.pack(blockee.id, blocker, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/delete.ts b/packages/backend/src/server/api/endpoints/blocking/delete.ts new file mode 100644 index 0000000000..a66e46fdf0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/blocking/delete.ts @@ -0,0 +1,85 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import deleteBlocking from '@/services/blocking/delete'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Blockings, Users } from '@/models/index'; + +export const meta = { + tags: ['account'], + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true as const, + + kind: 'write:blocks', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '8621d8bf-c358-4303-a066-5ea78610eb3f' + }, + + blockeeIsYourself: { + message: 'Blockee is yourself.', + code: 'BLOCKEE_IS_YOURSELF', + id: '06f6fac6-524b-473c-a354-e97a40ae6eac' + }, + + notBlocking: { + message: 'You are not blocking that user.', + code: 'NOT_BLOCKING', + id: '291b2efa-60c6-45c0-9f6a-045c8f9b02cd' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + }, +}; + +export default define(meta, async (ps, user) => { + const blocker = await Users.findOneOrFail(user.id); + + // Check if the blockee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + // Get blockee + const blockee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not blocking + const exist = await Blockings.findOne({ + blockerId: blocker.id, + blockeeId: blockee.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notBlocking); + } + + // Delete blocking + await deleteBlocking(blocker, blockee); + + return await Users.pack(blockee.id, blocker, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/list.ts b/packages/backend/src/server/api/endpoints/blocking/list.ts new file mode 100644 index 0000000000..fe25fdaba1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/blocking/list.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Blockings } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'read:blocks', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 30 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Blocking', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Blockings.createQueryBuilder('blocking'), ps.sinceId, ps.untilId) + .andWhere(`blocking.blockerId = :meId`, { meId: me.id }); + + const blockings = await query + .take(ps.limit!) + .getMany(); + + return await Blockings.packMany(blockings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts new file mode 100644 index 0000000000..0cedfd6c6a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -0,0 +1,68 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels, DriveFiles } from '@/models/index'; +import { Channel } from '@/models/entities/channel'; +import { genId } from '@/misc/gen-id'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['channels'], + + requireCredential: true as const, + + kind: 'write:channels', + + params: { + name: { + validator: $.str.range(1, 128) + }, + + description: { + validator: $.nullable.optional.str.range(1, 2048) + }, + + bannerId: { + validator: $.nullable.optional.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'cd1e9f3e-5a12-4ab4-96f6-5d0a2cc32050' + }, + } +}; + +export default define(meta, async (ps, user) => { + let banner = null; + if (ps.bannerId != null) { + banner = await DriveFiles.findOne({ + id: ps.bannerId, + userId: user.id + }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + const channel = await Channels.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + description: ps.description || null, + bannerId: banner ? banner.id : null, + } as Channel); + + return await Channels.pack(channel, user); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts new file mode 100644 index 0000000000..dc1f49f960 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -0,0 +1,28 @@ +import define from '../../define'; +import { Channels } from '@/models/index'; + +export const meta = { + tags: ['channels'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Channels.createQueryBuilder('channel') + .where('channel.lastNotedAt IS NOT NULL') + .orderBy('channel.lastNotedAt', 'DESC'); + + const channels = await query.take(10).getMany(); + + return await Promise.all(channels.map(x => Channels.pack(x, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts new file mode 100644 index 0000000000..d4664e6996 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels, ChannelFollowings } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['channels'], + + requireCredential: true as const, + + kind: 'write:channels', + + params: { + channelId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'c0031718-d573-4e85-928e-10039f1fbb68' + }, + } +}; + +export default define(meta, async (ps, user) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await ChannelFollowings.insert({ + id: genId(), + createdAt: new Date(), + followerId: user.id, + followeeId: channel.id, + }); + + publishUserEvent(user.id, 'followChannel', channel); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts new file mode 100644 index 0000000000..be239a01d6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Channels, ChannelFollowings } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['channels', 'account'], + + requireCredential: true as const, + + kind: 'read:channels', + + params: { + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 5 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(ChannelFollowings.createQueryBuilder(), ps.sinceId, ps.untilId) + .andWhere({ followerId: me.id }); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Promise.all(followings.map(x => Channels.pack(x.followeeId, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts new file mode 100644 index 0000000000..4a2e9db17b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Channels } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['channels', 'account'], + + requireCredential: true as const, + + kind: 'read:channels', + + params: { + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 5 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Channels.createQueryBuilder(), ps.sinceId, ps.untilId) + .andWhere({ userId: me.id }); + + const channels = await query + .take(ps.limit!) + .getMany(); + + return await Promise.all(channels.map(x => Channels.pack(x, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/pin-note.ts b/packages/backend/src/server/api/endpoints/channels/pin-note.ts new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/pin-note.ts diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts new file mode 100644 index 0000000000..803ce6363d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels } from '@/models/index'; + +export const meta = { + tags: ['channels'], + + requireCredential: false as const, + + params: { + channelId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '6f6c314b-7486-4897-8966-c04a66a02923' + }, + } +}; + +export default define(meta, async (ps, me) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + return await Channels.pack(channel, me); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts new file mode 100644 index 0000000000..0ed057a11e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -0,0 +1,85 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Notes, Channels } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { activeUsersChart } from '@/services/chart/index'; + +export const meta = { + tags: ['notes', 'channels'], + + requireCredential: false as const, + + params: { + channelId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f' + } + } +}; + +export default define(meta, async (ps, user) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.channelId = :channelId', { channelId: channel.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + if (user) activeUsersChart.update(user); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts new file mode 100644 index 0000000000..700f8e93ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels, ChannelFollowings } from '@/models/index'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['channels'], + + requireCredential: true as const, + + kind: 'write:channels', + + params: { + channelId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '19959ee9-0153-4c51-bbd9-a98c49dc59d6' + }, + } +}; + +export default define(meta, async (ps, user) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await ChannelFollowings.delete({ + followerId: user.id, + followeeId: channel.id, + }); + + publishUserEvent(user.id, 'unfollowChannel', channel); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts new file mode 100644 index 0000000000..9b447bd04b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -0,0 +1,94 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Channels, DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['channels'], + + requireCredential: true as const, + + kind: 'write:channels', + + params: { + channelId: { + validator: $.type(ID), + }, + + name: { + validator: $.optional.str.range(1, 128) + }, + + description: { + validator: $.nullable.optional.str.range(1, 2048) + }, + + bannerId: { + validator: $.nullable.optional.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Channel', + }, + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'f9c5467f-d492-4c3c-9a8d-a70dacc86512' + }, + + accessDenied: { + message: 'You do not have edit privilege of the channel.', + code: 'ACCESS_DENIED', + id: '1fb7cb09-d46a-4fdf-b8df-057788cce513' + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'e86c14a4-0da2-4032-8df3-e737a04c7f3b' + }, + } +}; + +export default define(meta, async (ps, me) => { + const channel = await Channels.findOne({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + if (channel.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + // tslint:disable-next-line:no-unnecessary-initializer + let banner = undefined; + if (ps.bannerId != null) { + banner = await DriveFiles.findOne({ + id: ps.bannerId, + userId: me.id + }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } else if (ps.bannerId === null) { + banner = null; + } + + await Channels.update(channel.id, { + ...(ps.name !== undefined ? { name: ps.name } : {}), + ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(banner ? { bannerId: banner.id } : {}), + }); + + return await Channels.pack(channel.id, me); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts new file mode 100644 index 0000000000..c4878f7d61 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { activeUsersChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(activeUsersChart.schema), +}; + +export default define(meta, async (ps) => { + return await activeUsersChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts new file mode 100644 index 0000000000..07bff82cf4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { driveChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'drive'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(driveChart.schema), +}; + +export default define(meta, async (ps) => { + return await driveChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts new file mode 100644 index 0000000000..9575f9a7b7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { federationChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(federationChart.schema), +}; + +export default define(meta, async (ps) => { + return await federationChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/hashtag.ts b/packages/backend/src/server/api/endpoints/charts/hashtag.ts new file mode 100644 index 0000000000..53dc61e51e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/hashtag.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { hashtagChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'hashtags'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + tag: { + validator: $.str, + }, + }, + + res: convertLog(hashtagChart.schema), +}; + +export default define(meta, async (ps) => { + return await hashtagChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.tag); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts new file mode 100644 index 0000000000..1835023188 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { instanceChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + host: { + validator: $.str, + } + }, + + res: convertLog(instanceChart.schema), +}; + +export default define(meta, async (ps) => { + return await instanceChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.host); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/network.ts b/packages/backend/src/server/api/endpoints/charts/network.ts new file mode 100644 index 0000000000..b524df93be --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/network.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { networkChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(networkChart.schema), +}; + +export default define(meta, async (ps) => { + return await networkChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts new file mode 100644 index 0000000000..676f302939 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { notesChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'notes'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(notesChart.schema), +}; + +export default define(meta, async (ps) => { + return await notesChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts new file mode 100644 index 0000000000..f2770e2df8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { convertLog } from '@/services/chart/core'; +import { perUserDriveChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'drive', 'users'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + userId: { + validator: $.type(ID), + } + }, + + res: convertLog(perUserDriveChart.schema), +}; + +export default define(meta, async (ps) => { + return await perUserDriveChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts new file mode 100644 index 0000000000..8c97b63e89 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { convertLog } from '@/services/chart/core'; +import { perUserFollowingChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users', 'following'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + userId: { + validator: $.type(ID), + } + }, + + res: convertLog(perUserFollowingChart.schema), +}; + +export default define(meta, async (ps) => { + return await perUserFollowingChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts new file mode 100644 index 0000000000..0d5f5a8b6a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { convertLog } from '@/services/chart/core'; +import { perUserNotesChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users', 'notes'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + userId: { + validator: $.type(ID), + } + }, + + res: convertLog(perUserNotesChart.schema), +}; + +export default define(meta, async (ps) => { + return await perUserNotesChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts new file mode 100644 index 0000000000..3cabe40d56 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ID } from '@/misc/cafy-id'; +import { convertLog } from '@/services/chart/core'; +import { perUserReactionsChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users', 'reactions'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + + userId: { + validator: $.type(ID), + } + }, + + res: convertLog(perUserReactionsChart.schema), +}; + +export default define(meta, async (ps) => { + return await perUserReactionsChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts new file mode 100644 index 0000000000..deac89b59d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -0,0 +1,30 @@ +import $ from 'cafy'; +import define from '../../define'; +import { convertLog } from '@/services/chart/core'; +import { usersChart } from '@/services/chart/index'; + +export const meta = { + tags: ['charts', 'users'], + + params: { + span: { + validator: $.str.or(['day', 'hour']), + }, + + limit: { + validator: $.optional.num.range(1, 500), + default: 30, + }, + + offset: { + validator: $.optional.nullable.num, + default: null, + }, + }, + + res: convertLog(usersChart.schema), +}; + +export default define(meta, async (ps) => { + return await usersChart.getChart(ps.span as any, ps.limit!, ps.offset ? new Date(ps.offset) : null); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts new file mode 100644 index 0000000000..79d7b8adde --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -0,0 +1,76 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ClipNotes, Clips } from '@/models/index'; +import { ApiError } from '../../error'; +import { genId } from '@/misc/gen-id'; +import { getNote } from '../../common/getters'; + +export const meta = { + tags: ['account', 'notes', 'clips'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + clipId: { + validator: $.type(ID), + }, + + noteId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf' + }, + + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b' + }, + + alreadyClipped: { + message: 'The note has already been clipped.', + code: 'ALREADY_CLIPPED', + id: '734806c4-542c-463a-9311-15c512803965' + }, + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.findOne({ + id: ps.clipId, + userId: user.id + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const exist = await ClipNotes.findOne({ + noteId: note.id, + clipId: clip.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyClipped); + } + + await ClipNotes.insert({ + id: genId(), + noteId: note.id, + clipId: clip.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts new file mode 100644 index 0000000000..02d2773709 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../define'; +import { genId } from '@/misc/gen-id'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + name: { + validator: $.str.range(1, 100) + }, + + isPublic: { + validator: $.optional.bool + }, + + description: { + validator: $.optional.nullable.str.range(1, 2048) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Clip' + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + isPublic: ps.isPublic, + description: ps.description, + }).then(x => Clips.findOneOrFail(x.identifiers[0])); + + return await Clips.pack(clip); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/delete.ts b/packages/backend/src/server/api/endpoints/clips/delete.ts new file mode 100644 index 0000000000..ca489af3bf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + clipId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754' + } + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.findOne({ + id: ps.clipId, + userId: user.id + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await Clips.delete(clip.id); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts new file mode 100644 index 0000000000..1f6db9b979 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -0,0 +1,28 @@ +import define from '../../define'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Clip' + } + } +}; + +export default define(meta, async (ps, me) => { + const clips = await Clips.find({ + userId: me.id, + }); + + return await Promise.all(clips.map(x => Clips.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts new file mode 100644 index 0000000000..5a9fed52fa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -0,0 +1,93 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ClipNotes, Clips, Notes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { ApiError } from '../../error'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['account', 'notes', 'clips'], + + requireCredential: false as const, + + kind: 'read:account', + + params: { + clipId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00' + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note' + } + } +}; + +export default define(meta, async (ps, user) => { + const clip = await Clips.findOne({ + id: ps.clipId, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + if (!clip.isPublic && (user == null || (clip.userId !== user.id))) { + throw new ApiError(meta.errors.noSuchClip); + } + + const clipQuery = ClipNotes.createQueryBuilder('joining') + .select('joining.noteId') + .where('joining.clipId = :clipId', { clipId: clip.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.id IN (${ clipQuery.getQuery() })`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(clipQuery.getParameters()); + + if (user) { + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + } + + const notes = await query + .take(ps.limit!) + .getMany(); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts new file mode 100644 index 0000000000..8f245cd18e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips', 'account'], + + requireCredential: false as const, + + kind: 'read:account', + + params: { + clipId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Clip' + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the clip + const clip = await Clips.findOne({ + id: ps.clipId, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + if (!clip.isPublic && (me == null || (clip.userId !== me.id))) { + throw new ApiError(meta.errors.noSuchClip); + } + + return await Clips.pack(clip); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts new file mode 100644 index 0000000000..7f645560bb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -0,0 +1,65 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Clips } from '@/models/index'; + +export const meta = { + tags: ['clips'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + clipId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100), + }, + + isPublic: { + validator: $.optional.bool + }, + + description: { + validator: $.optional.nullable.str.range(1, 2048) + } + }, + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Clip' + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the clip + const clip = await Clips.findOne({ + id: ps.clipId, + userId: user.id + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await Clips.update(clip.id, { + name: ps.name, + description: ps.description, + isPublic: ps.isPublic, + }); + + return await Clips.pack(clip.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts new file mode 100644 index 0000000000..2974ccfab9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -0,0 +1,38 @@ +import define from '../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive', 'account'], + + requireCredential: true as const, + + kind: 'read:drive', + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + capacity: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + usage: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + } +}; + +export default define(meta, async (ps, user) => { + const instance = await fetchMeta(true); + + // Calculate drive usage + const usage = await DriveFiles.calcDriveUsageOf(user.id); + + return { + capacity: 1024 * 1024 * instance.localDriveCapacityMb, + usage: usage + }; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts new file mode 100644 index 0000000000..95435e1e43 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -0,0 +1,70 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { DriveFiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + + type: { + validator: $.optional.nullable.str.match(/^[a-zA-Z\/\-*]+$/) + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) + .andWhere('file.userId = :userId', { userId: user.id }); + + if (ps.folderId) { + query.andWhere('file.folderId = :folderId', { folderId: ps.folderId }); + } else { + query.andWhere('file.folderId IS NULL'); + } + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit!).getMany(); + + return await DriveFiles.packMany(files, { detail: false, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts new file mode 100644 index 0000000000..eec7d7877e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFiles, Notes } from '@/models/index'; + +export const meta = { + tags: ['drive', 'notes'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + fileId: { + validator: $.type(ID), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'c118ece3-2e4b-4296-99d1-51756e32d232', + } + } +}; + +export default define(meta, async (ps, user) => { + // Fetch file + const file = await DriveFiles.findOne({ + id: ps.fileId, + userId: user.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + const notes = await Notes.createQueryBuilder('note') + .where(':file = ANY(note.fileIds)', { file: file.id }) + .getMany(); + + return await Notes.packMany(notes, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts new file mode 100644 index 0000000000..2c36078421 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/check-existence.ts @@ -0,0 +1,31 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + md5: { + validator: $.str, + } + }, + + res: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne({ + md5: ps.md5, + userId: user.id, + }); + + return file != null; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts new file mode 100644 index 0000000000..2abc104e6c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -0,0 +1,89 @@ +import * as ms from 'ms'; +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import create from '@/services/drive/add-file'; +import define from '../../../define'; +import { apiLogger } from '../../../logger'; +import { ApiError } from '../../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 120 + }, + + requireFile: true, + + kind: 'write:drive', + + params: { + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + + name: { + validator: $.optional.nullable.str, + default: null, + }, + + isSensitive: { + validator: $.optional.either($.bool, $.str), + default: false, + transform: (v: any): boolean => v === true || v === 'true', + }, + + force: { + validator: $.optional.either($.bool, $.str), + default: false, + transform: (v: any): boolean => v === true || v === 'true', + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + }, + + errors: { + invalidFileName: { + message: 'Invalid file name.', + code: 'INVALID_FILE_NAME', + id: 'f449b209-0c60-4e51-84d5-29486263bfd4' + } + } +}; + +export default define(meta, async (ps, user, _, file, cleanup) => { + // Get 'name' parameter + let name = ps.name || file.originalname; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (name === 'blob') { + name = null; + } else if (!DriveFiles.validateFileName(name)) { + throw new ApiError(meta.errors.invalidFileName); + } + } else { + name = null; + } + + try { + // Create file + const driveFile = await create(user, file.path, name, null, ps.folderId, ps.force, false, null, null, ps.isSensitive); + return await DriveFiles.pack(driveFile, { self: true }); + } catch (e) { + apiLogger.error(e); + throw new ApiError(); + } finally { + cleanup!(); + } +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/delete.ts b/packages/backend/src/server/api/endpoints/drive/files/delete.ts new file mode 100644 index 0000000000..038325694d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/delete.ts @@ -0,0 +1,53 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { deleteFile } from '@/services/drive/delete-file'; +import { publishDriveStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '908939ec-e52b-4458-b395-1025195cea58' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '5eb8d909-2540-4970-90b8-dd6f86088121' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if (!user.isAdmin && !user.isModerator && (file.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + // Delete + await deleteFile(file); + + // Publish fileDeleted event + publishDriveStream(user.id, 'fileDeleted', file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts b/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts new file mode 100644 index 0000000000..5fea7bbbb0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/find-by-hash.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + md5: { + validator: $.str, + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + } + }, +}; + +export default define(meta, async (ps, user) => { + const files = await DriveFiles.find({ + md5: ps.md5, + userId: user.id, + }); + + return await DriveFiles.packMany(files, { self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/find.ts b/packages/backend/src/server/api/endpoints/drive/files/find.ts new file mode 100644 index 0000000000..dd419f4c04 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/find.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + tags: ['drive'], + + kind: 'read:drive', + + params: { + name: { + validator: $.str + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + } + }, +}; + +export default define(meta, async (ps, user) => { + const files = await DriveFiles.find({ + name: ps.name, + userId: user.id, + folderId: ps.folderId + }); + + return await Promise.all(files.map(file => DriveFiles.pack(file, { self: true }))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/show.ts b/packages/backend/src/server/api/endpoints/drive/files/show.ts new file mode 100644 index 0000000000..a96ebaa123 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/show.ts @@ -0,0 +1,84 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + fileId: { + validator: $.optional.type(ID), + }, + + url: { + validator: $.optional.str, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '067bc436-2718-4795-b0fb-ecbe43949e31' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '25b73c73-68b1-41d0-bad1-381cfdf6579f' + }, + + fileIdOrUrlRequired: { + message: 'fileId or url required.', + code: 'INVALID_PARAM', + id: '89674805-722c-440c-8d88-5641830dc3e4' + } + } +}; + +export default define(meta, async (ps, user) => { + let file: DriveFile | undefined; + + if (ps.fileId) { + file = await DriveFiles.findOne(ps.fileId); + } else if (ps.url) { + file = await DriveFiles.findOne({ + where: [{ + url: ps.url + }, { + webpublicUrl: ps.url + }, { + thumbnailUrl: ps.url + }], + }); + } else { + throw new ApiError(meta.errors.fileIdOrUrlRequired); + } + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if (!user.isAdmin && !user.isModerator && (file.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + return await DriveFiles.pack(file, { + detail: true, + withUser: true, + self: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts new file mode 100644 index 0000000000..f277a9c3dc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -0,0 +1,116 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishDriveStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFiles, DriveFolders } from '@/models/index'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + fileId: { + validator: $.type(ID), + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: undefined as any, + }, + + name: { + validator: $.optional.str.pipe(DriveFiles.validateFileName), + default: undefined as any, + }, + + isSensitive: { + validator: $.optional.bool, + default: undefined as any, + }, + + comment: { + validator: $.optional.nullable.str.max(DB_MAX_IMAGE_COMMENT_LENGTH), + default: undefined as any, + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'e7778c7e-3af9-49cd-9690-6dbc3e6c972d' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '01a53b27-82fc-445b-a0c1-b558465a8ed2' + }, + + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: 'ea8fb7a5-af77-4a08-b608-c0218176cd73' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile' + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if (!user.isAdmin && !user.isModerator && (file.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + if (ps.name) file.name = ps.name; + + if (ps.comment !== undefined) file.comment = ps.comment; + + if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; + + if (ps.folderId !== undefined) { + if (ps.folderId === null) { + file.folderId = null; + } else { + const folder = await DriveFolders.findOne({ + id: ps.folderId, + userId: user.id + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + file.folderId = folder.id; + } + } + + await DriveFiles.update(file.id, { + name: file.name, + comment: file.comment, + folderId: file.folderId, + isSensitive: file.isSensitive + }); + + const fileObj = await DriveFiles.pack(file, { self: true }); + + // Publish fileUpdated event + publishDriveStream(user.id, 'fileUpdated', fileObj); + + return fileObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts new file mode 100644 index 0000000000..9f10a42d24 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -0,0 +1,64 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import uploadFromUrl from '@/services/drive/upload-from-url'; +import define from '../../../define'; +import { DriveFiles } from '@/models/index'; +import { publishMainStream } from '@/services/stream'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits'; + +export const meta = { + tags: ['drive'], + + limit: { + duration: ms('1hour'), + max: 60 + }, + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + url: { + // TODO: Validate this url + validator: $.str, + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + + isSensitive: { + validator: $.optional.bool, + default: false, + }, + + comment: { + validator: $.optional.nullable.str.max(DB_MAX_IMAGE_COMMENT_LENGTH), + default: null, + }, + + marker: { + validator: $.optional.nullable.str, + default: null, + }, + + force: { + validator: $.optional.bool, + default: false, + } + } +}; + +export default define(meta, async (ps, user) => { + uploadFromUrl(ps.url, user, ps.folderId, null, ps.isSensitive, ps.force, false, ps.comment).then(file => { + DriveFiles.pack(file, { self: true }).then(packedFile => { + publishMainStream(user.id, 'urlUploadFinished', { + marker: ps.marker, + file: packedFile + }); + }); + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts new file mode 100644 index 0000000000..6f16878b13 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { DriveFolders } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + folderId: { + validator: $.optional.nullable.type(ID), + default: null, + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(DriveFolders.createQueryBuilder('folder'), ps.sinceId, ps.untilId) + .andWhere('folder.userId = :userId', { userId: user.id }); + + if (ps.folderId) { + query.andWhere('folder.parentId = :parentId', { parentId: ps.folderId }); + } else { + query.andWhere('folder.parentId IS NULL'); + } + + const folders = await query.take(ps.limit!).getMany(); + + return await Promise.all(folders.map(folder => DriveFolders.pack(folder))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/create.ts b/packages/backend/src/server/api/endpoints/drive/folders/create.ts new file mode 100644 index 0000000000..80f96bd641 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/create.ts @@ -0,0 +1,72 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishDriveStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFolders } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + name: { + validator: $.optional.str.pipe(DriveFolders.validateFolderName), + default: 'Untitled', + }, + + parentId: { + validator: $.optional.nullable.type(ID), + } + }, + + errors: { + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: '53326628-a00d-40a6-a3cd-8975105c0f95' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder' + } +}; + +export default define(meta, async (ps, user) => { + // If the parent folder is specified + let parent = null; + if (ps.parentId) { + // Fetch parent folder + parent = await DriveFolders.findOne({ + id: ps.parentId, + userId: user.id + }); + + if (parent == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + } + + // Create folder + const folder = await DriveFolders.insert({ + id: genId(), + createdAt: new Date(), + name: ps.name, + parentId: parent !== null ? parent.id : null, + userId: user.id + }).then(x => DriveFolders.findOneOrFail(x.identifiers[0])); + + const folderObj = await DriveFolders.pack(folder); + + // Publish folderCreated event + publishDriveStream(user.id, 'folderCreated', folderObj); + + return folderObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/delete.ts b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts new file mode 100644 index 0000000000..38b4aef103 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { publishDriveStream } from '@/services/stream'; +import { ApiError } from '../../../error'; +import { DriveFolders, DriveFiles } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + folderId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: '1069098f-c281-440f-b085-f9932edbe091' + }, + + hasChildFilesOrFolders: { + message: 'This folder has child files or folders.', + code: 'HAS_CHILD_FILES_OR_FOLDERS', + id: 'b0fc8a17-963c-405d-bfbc-859a487295e1' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Get folder + const folder = await DriveFolders.findOne({ + id: ps.folderId, + userId: user.id + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + const [childFoldersCount, childFilesCount] = await Promise.all([ + DriveFolders.count({ parentId: folder.id }), + DriveFiles.count({ folderId: folder.id }) + ]); + + if (childFoldersCount !== 0 || childFilesCount !== 0) { + throw new ApiError(meta.errors.hasChildFilesOrFolders); + } + + await DriveFolders.delete(folder.id); + + // Publish folderCreated event + publishDriveStream(user.id, 'folderDeleted', folder.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/find.ts b/packages/backend/src/server/api/endpoints/drive/folders/find.ts new file mode 100644 index 0000000000..a6c5a49988 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/find.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { DriveFolders } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + name: { + validator: $.str + }, + + parentId: { + validator: $.optional.nullable.type(ID), + default: null, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder', + } + }, +}; + +export default define(meta, async (ps, user) => { + const folders = await DriveFolders.find({ + name: ps.name, + userId: user.id, + parentId: ps.parentId + }); + + return await Promise.all(folders.map(folder => DriveFolders.pack(folder))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/show.ts b/packages/backend/src/server/api/endpoints/drive/folders/show.ts new file mode 100644 index 0000000000..e907a24f05 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/show.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFolders } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + folderId: { + validator: $.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder', + }, + + errors: { + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: 'd74ab9eb-bb09-4bba-bf24-fb58f761e1e9' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Get folder + const folder = await DriveFolders.findOne({ + id: ps.folderId, + userId: user.id + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + return await DriveFolders.pack(folder, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts new file mode 100644 index 0000000000..612252e6df --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -0,0 +1,123 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishDriveStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { DriveFolders } from '@/models/index'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'write:drive', + + params: { + folderId: { + validator: $.type(ID), + }, + + name: { + validator: $.optional.str.pipe(DriveFolders.validateFolderName), + }, + + parentId: { + validator: $.optional.nullable.type(ID), + } + }, + + errors: { + noSuchFolder: { + message: 'No such folder.', + code: 'NO_SUCH_FOLDER', + id: 'f7974dac-2c0d-4a27-926e-23583b28e98e' + }, + + noSuchParentFolder: { + message: 'No such parent folder.', + code: 'NO_SUCH_PARENT_FOLDER', + id: 'ce104e3a-faaf-49d5-b459-10ff0cbbcaa1' + }, + + recursiveNesting: { + message: 'It can not be structured like nesting folders recursively.', + code: 'NO_SUCH_PARENT_FOLDER', + id: 'ce104e3a-faaf-49d5-b459-10ff0cbbcaa1' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFolder' + } +}; + +export default define(meta, async (ps, user) => { + // Fetch folder + const folder = await DriveFolders.findOne({ + id: ps.folderId, + userId: user.id + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + if (ps.name) folder.name = ps.name; + + if (ps.parentId !== undefined) { + if (ps.parentId === folder.id) { + throw new ApiError(meta.errors.recursiveNesting); + } else if (ps.parentId === null) { + folder.parentId = null; + } else { + // Get parent folder + const parent = await DriveFolders.findOne({ + id: ps.parentId, + userId: user.id + }); + + if (parent == null) { + throw new ApiError(meta.errors.noSuchParentFolder); + } + + // Check if the circular reference will occur + async function checkCircle(folderId: any): Promise<boolean> { + // Fetch folder + const folder2 = await DriveFolders.findOne({ + id: folderId + }); + + if (folder2!.id === folder!.id) { + return true; + } else if (folder2!.parentId) { + return await checkCircle(folder2!.parentId); + } else { + return false; + } + } + + if (parent.parentId !== null) { + if (await checkCircle(parent.parentId)) { + throw new ApiError(meta.errors.recursiveNesting); + } + } + + folder.parentId = parent.id; + } + } + + // Update + DriveFolders.update(folder.id, { + name: folder.name, + parentId: folder.parentId + }); + + const folderObj = await DriveFolders.pack(folder); + + // Publish folderUpdated event + publishDriveStream(user.id, 'folderUpdated', folderObj); + + return folderObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/stream.ts b/packages/backend/src/server/api/endpoints/drive/stream.ts new file mode 100644 index 0000000000..141e02f748 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/stream.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { DriveFiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['drive'], + + requireCredential: true as const, + + kind: 'read:drive', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + type: { + validator: $.optional.str.match(/^[a-zA-Z\/\-*]+$/) + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'DriveFile', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId) + .andWhere('file.userId = :userId', { userId: user.id }); + + if (ps.type) { + if (ps.type.endsWith('/*')) { + query.andWhere('file.type like :type', { type: ps.type.replace('/*', '/') + '%' }); + } else { + query.andWhere('file.type = :type', { type: ps.type }); + } + } + + const files = await query.take(ps.limit!).getMany(); + + return await DriveFiles.packMany(files, { detail: false, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/email-address/available.ts b/packages/backend/src/server/api/endpoints/email-address/available.ts new file mode 100644 index 0000000000..f6fccd59b0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/email-address/available.ts @@ -0,0 +1,34 @@ +import $ from 'cafy'; +import define from '../../define'; +import { validateEmailForAccount } from '@/services/validate-email-for-account'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + emailAddress: { + validator: $.str + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + available: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + reason: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + } + } +}; + +export default define(meta, async (ps) => { + return await validateEmailForAccount(ps.emailAddress); +}); diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts new file mode 100644 index 0000000000..1a04d8bee8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -0,0 +1,26 @@ +import $ from 'cafy'; +import define from '../define'; +import endpoints from '../endpoints'; + +export const meta = { + requireCredential: false as const, + + tags: ['meta'], + + params: { + endpoint: { + validator: $.str, + } + }, +}; + +export default define(meta, async (ps) => { + const ep = endpoints.find(x => x.name === ps.endpoint); + if (ep == null) return null; + return { + params: Object.entries(ep.meta.params || {}).map(([k, v]) => ({ + name: k, + type: v.validator.name === 'ID' ? 'String' : v.validator.name + })) + }; +}); diff --git a/packages/backend/src/server/api/endpoints/endpoints.ts b/packages/backend/src/server/api/endpoints/endpoints.ts new file mode 100644 index 0000000000..f7b9757d8d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/endpoints.ts @@ -0,0 +1,30 @@ +import define from '../define'; +import endpoints from '../endpoints'; + +export const meta = { + requireCredential: false as const, + + tags: ['meta'], + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + example: [ + 'admin/abuse-user-reports', + 'admin/accounts/create', + 'admin/announcements/create', + '...' + ] + } +}; + +export default define(meta, async () => { + return endpoints.map(x => x.name); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/dns.ts b/packages/backend/src/server/api/endpoints/federation/dns.ts new file mode 100644 index 0000000000..7ba566301a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/dns.ts @@ -0,0 +1,43 @@ +import { promises as dns } from 'dns'; +import $ from 'cafy'; +import define from '../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; + +const resolver = new dns.Resolver(); +resolver.setServers(['1.1.1.1']); + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + } + }, +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances.findOneOrFail({ host: toPuny(ps.host) }); + + const [ + resolved4, + resolved6, + resolvedCname, + resolvedTxt, + ] = await Promise.all([ + resolver.resolve4(instance.host).catch(() => []), + resolver.resolve6(instance.host).catch(() => []), + resolver.resolveCname(instance.host).catch(() => []), + resolver.resolveTxt(instance.host).catch(() => []), + ]); + + return { + a: resolved4, + aaaa: resolved6, + cname: resolvedCname, + txt: resolvedTxt, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/federation/followers.ts b/packages/backend/src/server/api/endpoints/federation/followers.ts new file mode 100644 index 0000000000..655e7b7b9a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/followers.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Followings } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Following', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followeeHost = :host`, { host: ps.host }); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/following.ts b/packages/backend/src/server/api/endpoints/federation/following.ts new file mode 100644 index 0000000000..5b283581a6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/following.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Followings } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Following', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followerHost = :host`, { host: ps.host }); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts new file mode 100644 index 0000000000..cf5e44ebd5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -0,0 +1,149 @@ +import $ from 'cafy'; +import config from '@/config/index'; +import define from '../../define'; +import { Instances } from '@/models/index'; +import { fetchMeta } from '@/misc/fetch-meta'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.optional.nullable.str, + }, + + blocked: { + validator: $.optional.nullable.bool, + }, + + notResponding: { + validator: $.optional.nullable.bool, + }, + + suspended: { + validator: $.optional.nullable.bool, + }, + + federating: { + validator: $.optional.nullable.bool, + }, + + subscribing: { + validator: $.optional.nullable.bool, + }, + + publishing: { + validator: $.optional.nullable.bool, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 30 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + + sort: { + validator: $.optional.str, + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'FederationInstance' + } + } +}; + +export default define(meta, async (ps, me) => { + const query = Instances.createQueryBuilder('instance'); + + switch (ps.sort) { + case '+pubSub': query.orderBy('instance.followingCount', 'DESC').orderBy('instance.followersCount', 'DESC'); break; + case '-pubSub': query.orderBy('instance.followingCount', 'ASC').orderBy('instance.followersCount', 'ASC'); break; + case '+notes': query.orderBy('instance.notesCount', 'DESC'); break; + case '-notes': query.orderBy('instance.notesCount', 'ASC'); break; + case '+users': query.orderBy('instance.usersCount', 'DESC'); break; + case '-users': query.orderBy('instance.usersCount', 'ASC'); break; + case '+following': query.orderBy('instance.followingCount', 'DESC'); break; + case '-following': query.orderBy('instance.followingCount', 'ASC'); break; + case '+followers': query.orderBy('instance.followersCount', 'DESC'); break; + case '-followers': query.orderBy('instance.followersCount', 'ASC'); break; + case '+caughtAt': query.orderBy('instance.caughtAt', 'DESC'); break; + case '-caughtAt': query.orderBy('instance.caughtAt', 'ASC'); break; + case '+lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'DESC'); break; + case '-lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'ASC'); break; + case '+driveUsage': query.orderBy('instance.driveUsage', 'DESC'); break; + case '-driveUsage': query.orderBy('instance.driveUsage', 'ASC'); break; + case '+driveFiles': query.orderBy('instance.driveFiles', 'DESC'); break; + case '-driveFiles': query.orderBy('instance.driveFiles', 'ASC'); break; + + default: query.orderBy('instance.id', 'DESC'); break; + } + + if (typeof ps.blocked === 'boolean') { + const meta = await fetchMeta(true); + if (ps.blocked) { + query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); + } else { + query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); + } + } + + if (typeof ps.notResponding === 'boolean') { + if (ps.notResponding) { + query.andWhere('instance.isNotResponding = TRUE'); + } else { + query.andWhere('instance.isNotResponding = FALSE'); + } + } + + if (typeof ps.suspended === 'boolean') { + if (ps.suspended) { + query.andWhere('instance.isSuspended = TRUE'); + } else { + query.andWhere('instance.isSuspended = FALSE'); + } + } + + if (typeof ps.federating === 'boolean') { + if (ps.federating) { + query.andWhere('((instance.followingCount > 0) OR (instance.followersCount > 0))'); + } else { + query.andWhere('((instance.followingCount = 0) AND (instance.followersCount = 0))'); + } + } + + if (typeof ps.subscribing === 'boolean') { + if (ps.subscribing) { + query.andWhere('instance.followersCount > 0'); + } else { + query.andWhere('instance.followersCount = 0'); + } + } + + if (typeof ps.publishing === 'boolean') { + if (ps.publishing) { + query.andWhere('instance.followingCount > 0'); + } else { + query.andWhere('instance.followingCount = 0'); + } + } + + if (ps.host) { + query.andWhere('instance.host like :host', { host: '%' + ps.host.toLowerCase() + '%' }); + } + + const instances = await query.take(ps.limit!).skip(ps.offset).getMany(); + + return instances; +}); diff --git a/packages/backend/src/server/api/endpoints/federation/show-instance.ts b/packages/backend/src/server/api/endpoints/federation/show-instance.ts new file mode 100644 index 0000000000..f8352aefb3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/show-instance.ts @@ -0,0 +1,29 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Instances } from '@/models/index'; +import { toPuny } from '@/misc/convert-host'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'FederationInstance' + } +}; + +export default define(meta, async (ps, me) => { + const instance = await Instances + .findOne({ host: toPuny(ps.host) }); + + return instance; +}); diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts new file mode 100644 index 0000000000..580c3cb3d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -0,0 +1,22 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getRemoteUser } from '../../common/getters'; +import { updatePerson } from '@/remote/activitypub/models/person'; + +export const meta = { + tags: ['federation'], + + requireCredential: true as const, + + params: { + userId: { + validator: $.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const user = await getRemoteUser(ps.userId); + await updatePerson(user.uri!); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/users.ts b/packages/backend/src/server/api/endpoints/federation/users.ts new file mode 100644 index 0000000000..0e35df3e1c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/users.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['federation'], + + requireCredential: false as const, + + params: { + host: { + validator: $.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Users.createQueryBuilder('user'), ps.sinceId, ps.untilId) + .andWhere(`user.host = :host`, { host: ps.host }); + + const users = await query + .take(ps.limit!) + .getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts new file mode 100644 index 0000000000..ba9ca1092d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -0,0 +1,100 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import create from '@/services/following/create'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Followings, Users } from '@/models/index'; + +export const meta = { + tags: ['following', 'users'], + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5' + }, + + followeeIsYourself: { + message: 'Followee is yourself.', + code: 'FOLLOWEE_IS_YOURSELF', + id: '26fbe7bb-a331-4857-af17-205b426669a9' + }, + + alreadyFollowing: { + message: 'You are already following that user.', + code: 'ALREADY_FOLLOWING', + id: '35387507-38c7-4cb9-9197-300b93783fa0' + }, + + blocking: { + message: 'You are blocking that user.', + code: 'BLOCKING', + id: '4e2206ec-aa4f-4960-b865-6c23ac38e2d9' + }, + + blocked: { + message: 'You are blocked by that user.', + code: 'BLOCKED', + id: 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + const follower = user; + + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); + } + + // Get followee + const followee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already following + const exist = await Followings.findOne({ + followerId: follower.id, + followeeId: followee.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFollowing); + } + + try { + await create(follower, followee); + } catch (e) { + if (e.id === '710e8fb0-b8c3-4922-be49-d5d93d8e6a6e') throw new ApiError(meta.errors.blocking); + if (e.id === '3338392a-f764-498d-8855-db939dcf8c48') throw new ApiError(meta.errors.blocked); + throw e; + } + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/delete.ts b/packages/backend/src/server/api/endpoints/following/delete.ts new file mode 100644 index 0000000000..0b0158b86e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/delete.ts @@ -0,0 +1,82 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import * as ms from 'ms'; +import deleteFollowing from '@/services/following/delete'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Followings, Users } from '@/models/index'; + +export const meta = { + tags: ['following', 'users'], + + limit: { + duration: ms('1hour'), + max: 100 + }, + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '5b12c78d-2b28-4dca-99d2-f56139b42ff8' + }, + + followeeIsYourself: { + message: 'Followee is yourself.', + code: 'FOLLOWEE_IS_YOURSELF', + id: 'd9e400b9-36b0-4808-b1d8-79e707f1296c' + }, + + notFollowing: { + message: 'You are not following that user.', + code: 'NOT_FOLLOWING', + id: '5dbf82f5-c92b-40b1-87d1-6c8c0741fd09' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + const follower = user; + + // Check if the followee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); + } + + // Get followee + const followee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not following + const exist = await Followings.findOne({ + followerId: follower.id, + followeeId: followee.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFollowing); + } + + await deleteFollowing(follower, followee); + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/accept.ts b/packages/backend/src/server/api/endpoints/following/requests/accept.ts new file mode 100644 index 0000000000..af39ea1d90 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/accept.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import acceptFollowRequest from '@/services/following/requests/accept'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; + +export const meta = { + tags: ['following', 'account'], + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '66ce1645-d66c-46bb-8b79-96739af885bd' + }, + noFollowRequest: { + message: 'No follow request.', + code: 'NO_FOLLOW_REQUEST', + id: 'bcde4f8b-0913-4614-8881-614e522fb041' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch follower + const follower = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + await acceptFollowRequest(user, follower).catch(e => { + if (e.id === '8884c2dd-5795-4ac9-b27e-6a01d38190f9') throw new ApiError(meta.errors.noFollowRequest); + throw e; + }); + + return; +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts new file mode 100644 index 0000000000..b69c9d2fe1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts @@ -0,0 +1,58 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import cancelFollowRequest from '@/services/following/requests/cancel'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['following', 'account'], + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '4e68c551-fc4c-4e46-bb41-7d4a37bf9dab' + }, + + followRequestNotFound: { + message: 'Follow request not found.', + code: 'FOLLOW_REQUEST_NOT_FOUND', + id: '089b125b-d338-482a-9a09-e2622ac9f8d4' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + // Fetch followee + const followee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + try { + await cancelFollowRequest(followee, user); + } catch (e) { + if (e.id === '17447091-ce07-46dd-b331-c1fd4f15b1e7') throw new ApiError(meta.errors.followRequestNotFound); + throw e; + } + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts new file mode 100644 index 0000000000..84440ccac7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts @@ -0,0 +1,44 @@ +import define from '../../../define'; +import { FollowRequests } from '@/models/index'; + +export const meta = { + tags: ['following', 'account'], + + requireCredential: true as const, + + kind: 'read:following', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + follower: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + followee: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const reqs = await FollowRequests.find({ + followeeId: user.id + }); + + return await Promise.all(reqs.map(req => FollowRequests.pack(req))); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/reject.ts b/packages/backend/src/server/api/endpoints/following/requests/reject.ts new file mode 100644 index 0000000000..620324361f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/reject.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import rejectFollowRequest from '@/services/following/requests/reject'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; + +export const meta = { + tags: ['following', 'account'], + + requireCredential: true as const, + + kind: 'write:following', + + params: { + userId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'abc2ffa6-25b2-4380-ba99-321ff3a94555' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch follower + const follower = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + await rejectFollowRequest(user, follower); + + return; +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/featured.ts b/packages/backend/src/server/api/endpoints/gallery/featured.ts new file mode 100644 index 0000000000..30ef8cedec --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/featured.ts @@ -0,0 +1,29 @@ +import define from '../../define'; +import { GalleryPosts } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder('post') + .andWhere('post.createdAt > :date', { date: new Date(Date.now() - (1000 * 60 * 60 * 24 * 3)) }) + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); + + const posts = await query.take(10).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/popular.ts b/packages/backend/src/server/api/endpoints/gallery/popular.ts new file mode 100644 index 0000000000..18449b9654 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/popular.ts @@ -0,0 +1,28 @@ +import define from '../../define'; +import { GalleryPosts } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder('post') + .andWhere('post.likedCount > 0') + .orderBy('post.likedCount', 'DESC'); + + const posts = await query.take(10).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts.ts b/packages/backend/src/server/api/endpoints/gallery/posts.ts new file mode 100644 index 0000000000..53d3236d2d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { GalleryPosts } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('post.user', 'user'); + + const posts = await query.take(ps.limit!).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts new file mode 100644 index 0000000000..38b487e6ea --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -0,0 +1,77 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../../define'; +import { ID } from '../../../../../misc/cafy-id'; +import { DriveFiles, GalleryPosts } from '@/models/index'; +import { genId } from '../../../../../misc/gen-id'; +import { GalleryPost } from '@/models/entities/gallery-post'; +import { ApiError } from '../../../error'; +import { DriveFile } from '@/models/entities/drive-file'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + title: { + validator: $.str.min(1), + }, + + description: { + validator: $.optional.nullable.str, + }, + + fileIds: { + validator: $.arr($.type(ID)).unique().range(1, 32), + }, + + isSensitive: { + validator: $.optional.bool, + default: false, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + DriveFiles.findOne({ + id: fileId, + userId: user.id + }) + ))).filter((file): file is DriveFile => file != null); + + if (files.length === 0) { + throw new Error(); + } + + const post = await GalleryPosts.insert(new GalleryPost({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + description: ps.description, + userId: user.id, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id) + })).then(x => GalleryPosts.findOneOrFail(x.identifiers[0])); + + return await GalleryPosts.pack(post, user); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts new file mode 100644 index 0000000000..e5b7c07f2f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery', + + params: { + postId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: 'ae52f367-4bd7-4ecd-afc6-5672fff427f5' + }, + } +}; + +export default define(meta, async (ps, user) => { + const post = await GalleryPosts.findOne({ + id: ps.postId, + userId: user.id, + }); + + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + await GalleryPosts.delete(post.id); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts new file mode 100644 index 0000000000..81a25c0ad1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -0,0 +1,71 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts, GalleryLikes } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery-likes', + + params: { + postId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: '56c06af3-1287-442f-9701-c93f7c4a62ff' + }, + + yourPost: { + message: 'You cannot like your post.', + code: 'YOUR_POST', + id: 'f78f1511-5ebc-4478-a888-1198d752da68' + }, + + alreadyLiked: { + message: 'The post has already been liked.', + code: 'ALREADY_LIKED', + id: '40e9ed56-a59c-473a-bf3f-f289c54fb5a7' + }, + } +}; + +export default define(meta, async (ps, user) => { + const post = await GalleryPosts.findOne(ps.postId); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + if (post.userId === user.id) { + throw new ApiError(meta.errors.yourPost); + } + + // if already liked + const exist = await GalleryLikes.findOne({ + postId: post.id, + userId: user.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await GalleryLikes.insert({ + id: genId(), + createdAt: new Date(), + postId: post.id, + userId: user.id + }); + + GalleryPosts.increment({ id: post.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/show.ts b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts new file mode 100644 index 0000000000..93852a5f8d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + requireCredential: false as const, + + params: { + postId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: '1137bf14-c5b0-4604-85bb-5b5371b1cd45' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } +}; + +export default define(meta, async (ps, me) => { + const post = await GalleryPosts.findOne({ + id: ps.postId, + }); + + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + return await GalleryPosts.pack(post, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts new file mode 100644 index 0000000000..0347cdf79e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { GalleryPosts, GalleryLikes } from '@/models/index'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery-likes', + + params: { + postId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPost: { + message: 'No such post.', + code: 'NO_SUCH_POST', + id: 'c32e6dd0-b555-4413-925e-b3757d19ed84' + }, + + notLiked: { + message: 'You have not liked that post.', + code: 'NOT_LIKED', + id: 'e3e8e06e-be37-41f7-a5b4-87a8250288f0' + }, + } +}; + +export default define(meta, async (ps, user) => { + const post = await GalleryPosts.findOne(ps.postId); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + const exist = await GalleryLikes.findOne({ + postId: post.id, + userId: user.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await GalleryLikes.delete(exist.id); + + GalleryPosts.decrement({ id: post.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts new file mode 100644 index 0000000000..54eea130d3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -0,0 +1,82 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../../define'; +import { ID } from '../../../../../misc/cafy-id'; +import { DriveFiles, GalleryPosts } from '@/models/index'; +import { GalleryPost } from '@/models/entities/gallery-post'; +import { ApiError } from '../../../error'; +import { DriveFile } from '@/models/entities/drive-file'; + +export const meta = { + tags: ['gallery'], + + requireCredential: true as const, + + kind: 'write:gallery', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + postId: { + validator: $.type(ID), + }, + + title: { + validator: $.str.min(1), + }, + + description: { + validator: $.optional.nullable.str, + }, + + fileIds: { + validator: $.arr($.type(ID)).unique().range(1, 32), + }, + + isSensitive: { + validator: $.optional.bool, + default: false, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost', + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + const files = (await Promise.all(ps.fileIds.map(fileId => + DriveFiles.findOne({ + id: fileId, + userId: user.id + }) + ))).filter((file): file is DriveFile => file != null); + + if (files.length === 0) { + throw new Error(); + } + + await GalleryPosts.update({ + id: ps.postId, + userId: user.id, + }, { + updatedAt: new Date(), + title: ps.title, + description: ps.description, + isSensitive: ps.isSensitive, + fileIds: files.map(file => file.id) + }); + + const post = await GalleryPosts.findOneOrFail(ps.postId); + + return await GalleryPosts.pack(post, user); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/games.ts b/packages/backend/src/server/api/endpoints/games/reversi/games.ts new file mode 100644 index 0000000000..4db9ecb69f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/games.ts @@ -0,0 +1,156 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ReversiGames } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['games'], + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + my: { + validator: $.optional.bool, + default: false + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + startedAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + isStarted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isEnded: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + form1: { + type: 'any' as const, + optional: false as const, nullable: true as const + }, + form2: { + type: 'any' as const, + optional: false as const, nullable: true as const + }, + user1Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + user2Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + user1Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user2Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user1: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + user2: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + winnerId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + winner: { + type: 'object' as const, + optional: false as const, nullable: true as const, + ref: 'User' + }, + surrendered: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + black: { + type: 'number' as const, + optional: false as const, nullable: true as const + }, + bw: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + isLlotheo: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + canPutEverywhere: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + loopedBoard: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(ReversiGames.createQueryBuilder('game'), ps.sinceId, ps.untilId) + .andWhere('game.isStarted = TRUE'); + + if (ps.my && user) { + query.andWhere(new Brackets(qb => { qb + .where('game.user1Id = :userId', { userId: user.id }) + .orWhere('game.user2Id = :userId', { userId: user.id }); + })); + } + + // Fetch games + const games = await query.take(ps.limit!).getMany(); + + return await Promise.all(games.map((g) => ReversiGames.pack(g, user, { + detail: false + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/games/show.ts b/packages/backend/src/server/api/endpoints/games/reversi/games/show.ts new file mode 100644 index 0000000000..93afffdb1f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/games/show.ts @@ -0,0 +1,168 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import Reversi from '../../../../../../games/reversi/core'; +import define from '../../../../define'; +import { ApiError } from '../../../../error'; +import { ReversiGames } from '@/models/index'; + +export const meta = { + tags: ['games'], + + params: { + gameId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchGame: { + message: 'No such game.', + code: 'NO_SUCH_GAME', + id: 'f13a03db-fae1-46c9-87f3-43c8165419e1' + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + startedAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + isStarted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isEnded: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + form1: { + type: 'any' as const, + optional: false as const, nullable: true as const + }, + form2: { + type: 'any' as const, + optional: false as const, nullable: true as const + }, + user1Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + user2Accepted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + user1Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user2Id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + user1: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + user2: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + winnerId: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + winner: { + type: 'object' as const, + optional: false as const, nullable: true as const, + ref: 'User' + }, + surrendered: { + type: 'string' as const, + optional: false as const, nullable: true as const, + format: 'id' + }, + black: { + type: 'number' as const, + optional: false as const, nullable: true as const + }, + bw: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + isLlotheo: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + canPutEverywhere: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + loopedBoard: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + board: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'any' as const, + optional: false as const, nullable: false as const + } + }, + turn: { + type: 'any' as const, + optional: false as const, nullable: false as const + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const game = await ReversiGames.findOne(ps.gameId); + + if (game == null) { + throw new ApiError(meta.errors.noSuchGame); + } + + const o = new Reversi(game.map, { + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard + }); + + for (const log of game.logs) { + o.put(log.color, log.pos); + } + + const packed = await ReversiGames.pack(game, user); + + return Object.assign({ + board: o.board, + turn: o.turn + }, packed); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/games/surrender.ts b/packages/backend/src/server/api/endpoints/games/reversi/games/surrender.ts new file mode 100644 index 0000000000..00d58b19e3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/games/surrender.ts @@ -0,0 +1,67 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishReversiGameStream } from '@/services/stream'; +import define from '../../../../define'; +import { ApiError } from '../../../../error'; +import { ReversiGames } from '@/models/index'; + +export const meta = { + tags: ['games'], + + requireCredential: true as const, + + params: { + gameId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchGame: { + message: 'No such game.', + code: 'NO_SUCH_GAME', + id: 'ace0b11f-e0a6-4076-a30d-e8284c81b2df' + }, + + alreadyEnded: { + message: 'That game has already ended.', + code: 'ALREADY_ENDED', + id: '6c2ad4a6-cbf1-4a5b-b187-b772826cfc6d' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '6e04164b-a992-4c93-8489-2123069973e1' + }, + } +}; + +export default define(meta, async (ps, user) => { + const game = await ReversiGames.findOne(ps.gameId); + + if (game == null) { + throw new ApiError(meta.errors.noSuchGame); + } + + if (game.isEnded) { + throw new ApiError(meta.errors.alreadyEnded); + } + + if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id; + + await ReversiGames.update(game.id, { + surrendered: user.id, + isEnded: true, + winnerId: winnerId + }); + + publishReversiGameStream(game.id, 'ended', { + winnerId: winnerId, + game: await ReversiGames.pack(game.id, user) + }); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/invitations.ts b/packages/backend/src/server/api/endpoints/games/reversi/invitations.ts new file mode 100644 index 0000000000..c8629377b2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/invitations.ts @@ -0,0 +1,58 @@ +import define from '../../../define'; +import { ReversiMatchings } from '@/models/index'; + +export const meta = { + tags: ['games'], + + requireCredential: true as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + createdAt: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'date-time' + }, + parentId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + parent: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + }, + childId: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + child: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + // Find session + const invitations = await ReversiMatchings.find({ + childId: user.id + }); + + return await Promise.all(invitations.map((i) => ReversiMatchings.pack(i, user))); +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/match.ts b/packages/backend/src/server/api/endpoints/games/reversi/match.ts new file mode 100644 index 0000000000..5ceb16c7d7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/match.ts @@ -0,0 +1,108 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishMainStream, publishReversiStream } from '@/services/stream'; +import { eighteight } from '../../../../../games/reversi/maps'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { genId } from '@/misc/gen-id'; +import { ReversiMatchings, ReversiGames } from '@/models/index'; +import { ReversiGame } from '@/models/entities/games/reversi/game'; +import { ReversiMatching } from '@/models/entities/games/reversi/matching'; + +export const meta = { + tags: ['games'], + + requireCredential: true as const, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '0b4f0559-b484-4e31-9581-3f73cee89b28' + }, + + isYourself: { + message: 'Target user is yourself.', + code: 'TARGET_IS_YOURSELF', + id: '96fd7bd6-d2bc-426c-a865-d055dcd2828e' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Myself + if (ps.userId === user.id) { + throw new ApiError(meta.errors.isYourself); + } + + // Find session + const exist = await ReversiMatchings.findOne({ + parentId: ps.userId, + childId: user.id + }); + + if (exist) { + // Destroy session + ReversiMatchings.delete(exist.id); + + // Create game + const game = await ReversiGames.save({ + id: genId(), + createdAt: new Date(), + user1Id: exist.parentId, + user2Id: user.id, + user1Accepted: false, + user2Accepted: false, + isStarted: false, + isEnded: false, + logs: [], + map: eighteight.data, + bw: 'random', + isLlotheo: false + } as Partial<ReversiGame>); + + publishReversiStream(exist.parentId, 'matched', await ReversiGames.pack(game, { id: exist.parentId })); + + const other = await ReversiMatchings.count({ + childId: user.id + }); + + if (other == 0) { + publishMainStream(user.id, 'reversiNoInvites'); + } + + return await ReversiGames.pack(game, user); + } else { + // Fetch child + const child = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // 以前のセッションはすべて削除しておく + await ReversiMatchings.delete({ + parentId: user.id + }); + + // セッションを作成 + const matching = await ReversiMatchings.save({ + id: genId(), + createdAt: new Date(), + parentId: user.id, + childId: child.id + } as ReversiMatching); + + const packed = await ReversiMatchings.pack(matching, child); + publishReversiStream(child.id, 'invited', packed); + publishMainStream(child.id, 'reversiInvited', packed); + + return; + } +}); diff --git a/packages/backend/src/server/api/endpoints/games/reversi/match/cancel.ts b/packages/backend/src/server/api/endpoints/games/reversi/match/cancel.ts new file mode 100644 index 0000000000..e4a138bb87 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/games/reversi/match/cancel.ts @@ -0,0 +1,14 @@ +import define from '../../../../define'; +import { ReversiMatchings } from '@/models/index'; + +export const meta = { + tags: ['games'], + + requireCredential: true as const +}; + +export default define(meta, async (ps, user) => { + await ReversiMatchings.delete({ + parentId: user.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/get-online-users-count.ts b/packages/backend/src/server/api/endpoints/get-online-users-count.ts new file mode 100644 index 0000000000..5c80d588d3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/get-online-users-count.ts @@ -0,0 +1,23 @@ +import { USER_ONLINE_THRESHOLD } from '@/const'; +import { Users } from '@/models/index'; +import { MoreThan } from 'typeorm'; +import define from '../define'; + +export const meta = { + tags: ['meta'], + + requireCredential: false as const, + + params: { + } +}; + +export default define(meta, async () => { + const count = await Users.count({ + lastActiveDate: MoreThan(new Date(Date.now() - USER_ONLINE_THRESHOLD)) + }); + + return { + count + }; +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/list.ts b/packages/backend/src/server/api/endpoints/hashtags/list.ts new file mode 100644 index 0000000000..821016a50c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/list.ts @@ -0,0 +1,95 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Hashtags } from '@/models/index'; + +export const meta = { + tags: ['hashtags'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + attachedToUserOnly: { + validator: $.optional.bool, + default: false + }, + + attachedToLocalUserOnly: { + validator: $.optional.bool, + default: false + }, + + attachedToRemoteUserOnly: { + validator: $.optional.bool, + default: false + }, + + sort: { + validator: $.str.or([ + '+mentionedUsers', + '-mentionedUsers', + '+mentionedLocalUsers', + '-mentionedLocalUsers', + '+mentionedRemoteUsers', + '-mentionedRemoteUsers', + '+attachedUsers', + '-attachedUsers', + '+attachedLocalUsers', + '-attachedLocalUsers', + '+attachedRemoteUsers', + '-attachedRemoteUsers', + ]), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Hashtag', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Hashtags.createQueryBuilder('tag'); + + if (ps.attachedToUserOnly) query.andWhere('tag.attachedUsersCount != 0'); + if (ps.attachedToLocalUserOnly) query.andWhere('tag.attachedLocalUsersCount != 0'); + if (ps.attachedToRemoteUserOnly) query.andWhere('tag.attachedRemoteUsersCount != 0'); + + switch (ps.sort) { + case '+mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'DESC'); break; + case '-mentionedUsers': query.orderBy('tag.mentionedUsersCount', 'ASC'); break; + case '+mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'DESC'); break; + case '-mentionedLocalUsers': query.orderBy('tag.mentionedLocalUsersCount', 'ASC'); break; + case '+mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'DESC'); break; + case '-mentionedRemoteUsers': query.orderBy('tag.mentionedRemoteUsersCount', 'ASC'); break; + case '+attachedUsers': query.orderBy('tag.attachedUsersCount', 'DESC'); break; + case '-attachedUsers': query.orderBy('tag.attachedUsersCount', 'ASC'); break; + case '+attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'DESC'); break; + case '-attachedLocalUsers': query.orderBy('tag.attachedLocalUsersCount', 'ASC'); break; + case '+attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'DESC'); break; + case '-attachedRemoteUsers': query.orderBy('tag.attachedRemoteUsersCount', 'ASC'); break; + } + + query.select([ + 'tag.name', + 'tag.mentionedUsersCount', + 'tag.mentionedLocalUsersCount', + 'tag.mentionedRemoteUsersCount', + 'tag.attachedUsersCount', + 'tag.attachedLocalUsersCount', + 'tag.attachedRemoteUsersCount', + ]); + + const tags = await query.take(ps.limit!).getMany(); + + return Hashtags.packMany(tags); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/search.ts b/packages/backend/src/server/api/endpoints/hashtags/search.ts new file mode 100644 index 0000000000..fd0cac3983 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/search.ts @@ -0,0 +1,46 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Hashtags } from '@/models/index'; + +export const meta = { + tags: ['hashtags'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + query: { + validator: $.str, + }, + + offset: { + validator: $.optional.num.min(0), + default: 0, + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + }, +}; + +export default define(meta, async (ps) => { + const hashtags = await Hashtags.createQueryBuilder('tag') + .where('tag.name like :q', { q: ps.query.toLowerCase() + '%' }) + .orderBy('tag.count', 'DESC') + .groupBy('tag.id') + .take(ps.limit!) + .skip(ps.offset) + .getMany(); + + return hashtags.map(tag => tag.name); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/show.ts b/packages/backend/src/server/api/endpoints/hashtags/show.ts new file mode 100644 index 0000000000..f22edbfffd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/show.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Hashtags } from '@/models/index'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +export const meta = { + tags: ['hashtags'], + + requireCredential: false as const, + + params: { + tag: { + validator: $.str, + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Hashtag', + }, + + errors: { + noSuchHashtag: { + message: 'No such hashtag.', + code: 'NO_SUCH_HASHTAG', + id: '110ee688-193e-4a3a-9ecf-c167b2e6981e' + } + } +}; + +export default define(meta, async (ps, user) => { + const hashtag = await Hashtags.findOne({ name: normalizeForSearch(ps.tag) }); + if (hashtag == null) { + throw new ApiError(meta.errors.noSuchHashtag); + } + + return await Hashtags.pack(hashtag); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/trend.ts b/packages/backend/src/server/api/endpoints/hashtags/trend.ts new file mode 100644 index 0000000000..3d67241ab6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/trend.ts @@ -0,0 +1,146 @@ +import { Brackets } from 'typeorm'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models/index'; +import { Note } from '@/models/entities/note'; +import { safeForSql } from '@/misc/safe-for-sql'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +/* +トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要 +ユニーク投稿数とはそのハッシュタグと投稿ユーザーのペアのカウントで、例えば同じユーザーが複数回同じハッシュタグを投稿してもそのハッシュタグのユニーク投稿数は1とカウントされる + +..が理想だけどPostgreSQLでどうするのか分からないので単に「直近Aの内に投稿されたユニーク投稿数が多いハッシュタグ」で妥協する +*/ + +const rangeA = 1000 * 60 * 60; // 60分 +//const rangeB = 1000 * 60 * 120; // 2時間 +//const coefficient = 1.25; // 「n倍」の部分 +//const requiredUsers = 3; // 最低何人がそのタグを投稿している必要があるか + +const max = 5; + +export const meta = { + tags: ['hashtags'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + tag: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + chart: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + }, + usersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + } + } + } + } +}; + +export default define(meta, async () => { + const instance = await fetchMeta(true); + const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); + + const now = new Date(); // 5分単位で丸めた現在日時 + now.setMinutes(Math.round(now.getMinutes() / 5) * 5, 0, 0); + + const tagNotes = await Notes.createQueryBuilder('note') + .where(`note.createdAt > :date`, { date: new Date(now.getTime() - rangeA) }) + .andWhere(new Brackets(qb => { qb + .where(`note.visibility = 'public'`) + .orWhere(`note.visibility = 'home'`); + })) + .andWhere(`note.tags != '{}'`) + .select(['note.tags', 'note.userId']) + .cache(60000) // 1 min + .getMany(); + + if (tagNotes.length === 0) { + return []; + } + + const tags: { + name: string; + users: Note['userId'][]; + }[] = []; + + for (const note of tagNotes) { + for (const tag of note.tags) { + if (hiddenTags.includes(tag)) continue; + + const x = tags.find(x => x.name === tag); + if (x) { + if (!x.users.includes(note.userId)) { + x.users.push(note.userId); + } + } else { + tags.push({ + name: tag, + users: [note.userId] + }); + } + } + } + + // タグを人気順に並べ替え + const hots = tags + .sort((a, b) => b.users.length - a.users.length) + .map(tag => tag.name) + .slice(0, max); + + //#region 2(または3)で話題と判定されたタグそれぞれについて過去の投稿数グラフを取得する + const countPromises: Promise<number[]>[] = []; + + const range = 20; + + // 10分 + const interval = 1000 * 60 * 10; + + for (let i = 0; i < range; i++) { + countPromises.push(Promise.all(hots.map(tag => Notes.createQueryBuilder('note') + .select('count(distinct note.userId)') + .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) + .andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) }) + .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) }) + .cache(60000) // 1 min + .getRawOne() + .then(x => parseInt(x.count, 10)) + ))); + } + + const countsLog = await Promise.all(countPromises); + //#endregion + + const totalCounts = await Promise.all(hots.map(tag => Notes.createQueryBuilder('note') + .select('count(distinct note.userId)') + .where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`) + .andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) }) + .cache(60000 * 60) // 60 min + .getRawOne() + .then(x => parseInt(x.count, 10)) + )); + + const stats = hots.map((tag, i) => ({ + tag, + chart: countsLog.map(counts => counts[i]), + usersCount: totalCounts[i] + })); + + return stats; +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts new file mode 100644 index 0000000000..8c8cd1510b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -0,0 +1,89 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Users } from '@/models/index'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +export const meta = { + requireCredential: false as const, + + tags: ['hashtags', 'users'], + + params: { + tag: { + validator: $.str, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sort: { + validator: $.str.or([ + '+follower', + '-follower', + '+createdAt', + '-createdAt', + '+updatedAt', + '-updatedAt', + ]), + }, + + state: { + validator: $.optional.str.or([ + 'all', + 'alive' + ]), + default: 'all' + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Users.createQueryBuilder('user') + .where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) }); + + const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); + + if (ps.state === 'alive') { + query.andWhere('user.updatedAt > :date', { date: recent }); + } + + if (ps.origin === 'local') { + query.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('user.host IS NOT NULL'); + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.orderBy('user.updatedAt', 'DESC'); break; + case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break; + } + + const users = await query.take(ps.limit!).getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts new file mode 100644 index 0000000000..0568a962d8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -0,0 +1,26 @@ +import define from '../define'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + params: {}, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + }, +}; + +export default define(meta, async (ps, user, token) => { + const isSecure = token == null; + + // ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す + return await Users.pack(user.id, user, { + detail: true, + includeSecrets: isSecure + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts new file mode 100644 index 0000000000..2bd2128cce --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import * as speakeasy from 'speakeasy'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + token: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const token = ps.token.replace(/\s/g, ''); + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.twoFactorTempSecret == null) { + throw new Error('二段階認証の設定が開始されていません'); + } + + const verified = (speakeasy as any).totp.verify({ + secret: profile.twoFactorTempSecret, + encoding: 'base32', + token: token + }); + + if (!verified) { + throw new Error('not verified'); + } + + await UserProfiles.update(user.id, { + twoFactorSecret: profile.twoFactorTempSecret, + twoFactorEnabled: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts new file mode 100644 index 0000000000..b4d3af235a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -0,0 +1,150 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import { promisify } from 'util'; +import * as cbor from 'cbor'; +import define from '../../../define'; +import { + UserProfiles, + UserSecurityKeys, + AttestationChallenges, + Users +} from '@/models/index'; +import config from '@/config/index'; +import { procedures, hash } from '../../../2fa'; +import { publishMainStream } from '@/services/stream'; + +const cborDecodeFirst = promisify(cbor.decodeFirst) as any; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + clientDataJSON: { + validator: $.str + }, + attestationObject: { + validator: $.str + }, + password: { + validator: $.str + }, + challengeId: { + validator: $.str + }, + name: { + validator: $.str + } + } +}; + +const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8')); + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + const clientData = JSON.parse(ps.clientDataJSON); + + if (clientData.type != 'webauthn.create') { + throw new Error('not a creation attestation'); + } + if (clientData.origin != config.scheme + '://' + config.host) { + throw new Error('origin mismatch'); + } + + const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, 'utf-8')); + + const attestation = await cborDecodeFirst(ps.attestationObject); + + const rpIdHash = attestation.authData.slice(0, 32); + if (!rpIdHashReal.equals(rpIdHash)) { + throw new Error('rpIdHash mismatch'); + } + + const flags = attestation.authData[32]; + + // tslint:disable-next-line:no-bitwise + if (!(flags & 1)) { + throw new Error('user not present'); + } + + const authData = Buffer.from(attestation.authData); + const credentialIdLength = authData.readUInt16BE(53); + const credentialId = authData.slice(55, 55 + credentialIdLength); + const publicKeyData = authData.slice(55 + credentialIdLength); + const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData); + if (publicKey.get(3) != -7) { + throw new Error('alg mismatch'); + } + + if (!(procedures as any)[attestation.fmt]) { + throw new Error('unsupported fmt'); + } + + const verificationData = (procedures as any)[attestation.fmt].verify({ + attStmt: attestation.attStmt, + authenticatorData: authData, + clientDataHash: clientDataJSONHash, + credentialId, + publicKey, + rpIdHash + }); + if (!verificationData.valid) throw new Error('signature invalid'); + + const attestationChallenge = await AttestationChallenges.findOne({ + userId: user.id, + id: ps.challengeId, + registrationChallenge: true, + challenge: hash(clientData.challenge).toString('hex') + }); + + if (!attestationChallenge) { + throw new Error('non-existent challenge'); + } + + await AttestationChallenges.delete({ + userId: user.id, + id: ps.challengeId + }); + + // Expired challenge (> 5min old) + if ( + new Date().getTime() - attestationChallenge.createdAt.getTime() >= + 5 * 60 * 1000 + ) { + throw new Error('expired challenge'); + } + + const credentialIdString = credentialId.toString('hex'); + + await UserSecurityKeys.save({ + userId: user.id, + id: credentialIdString, + lastUsed: new Date(), + name: ps.name, + publicKey: verificationData.publicKey.toString('hex') + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + })); + + return { + id: credentialIdString, + name: ps.name + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts new file mode 100644 index 0000000000..064828b638 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -0,0 +1,21 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + value: { + validator: $.boolean + } + } +}; + +export default define(meta, async (ps, user) => { + await UserProfiles.update(user.id, { + usePasswordLessLogin: ps.value + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts new file mode 100644 index 0000000000..1b385a10ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../../define'; +import { UserProfiles, AttestationChallenges } from '@/models/index'; +import { promisify } from 'util'; +import * as crypto from 'crypto'; +import { genId } from '@/misc/gen-id'; +import { hash } from '../../../2fa'; + +const randomBytes = promisify(crypto.randomBytes); + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + if (!profile.twoFactorEnabled) { + throw new Error('2fa not enabled'); + } + + // 32 byte challenge + const entropy = await randomBytes(32); + const challenge = entropy.toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const challengeId = genId(); + + await AttestationChallenges.save({ + userId: user.id, + id: challengeId, + challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), + createdAt: new Date(), + registrationChallenge: true + }); + + return { + challengeId, + challenge + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts new file mode 100644 index 0000000000..b03b98188a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import config from '@/config/index'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate user's secret key + const secret = speakeasy.generateSecret({ + length: 32 + }); + + await UserProfiles.update(user.id, { + twoFactorTempSecret: secret.base32 + }); + + // Get the data URL of the authenticator URL + const dataUrl = await QRCode.toDataURL(speakeasy.otpauthURL({ + secret: secret.base32, + encoding: 'base32', + label: user.username, + issuer: config.host + })); + + return { + qr: dataUrl, + secret: secret.base32, + label: user.username, + issuer: config.host + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts new file mode 100644 index 0000000000..dea56301ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../../define'; +import { UserProfiles, UserSecurityKeys, Users } from '@/models/index'; +import { publishMainStream } from '@/services/stream'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + }, + credentialId: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Make sure we only delete the user's own creds + await UserSecurityKeys.delete({ + userId: user.id, + id: ps.credentialId + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + })); + + return {}; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts new file mode 100644 index 0000000000..af53033daa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -0,0 +1,32 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + await UserProfiles.update(user.id, { + twoFactorSecret: null, + twoFactorEnabled: false + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts new file mode 100644 index 0000000000..994528e5c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + sort: { + validator: $.optional.str.or([ + '+createdAt', + '-createdAt', + '+lastUsedAt', + '-lastUsedAt', + ]), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = AccessTokens.createQueryBuilder('token') + .where('token.userId = :userId', { userId: user.id }); + + switch (ps.sort) { + case '+createdAt': query.orderBy('token.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('token.createdAt', 'ASC'); break; + case '+lastUsedAt': query.orderBy('token.lastUsedAt', 'DESC'); break; + case '-lastUsedAt': query.orderBy('token.lastUsedAt', 'ASC'); break; + default: query.orderBy('token.id', 'ASC'); break; + } + + const tokens = await query.getMany(); + + return await Promise.all(tokens.map(token => ({ + id: token.id, + name: token.name, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + permission: token.permission, + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/authorized-apps.ts b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts new file mode 100644 index 0000000000..042fcd14e8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens, Apps } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + offset: { + validator: $.optional.num.min(0), + default: 0, + }, + + sort: { + validator: $.optional.str.or('desc|asc'), + default: 'desc', + } + } +}; + +export default define(meta, async (ps, user) => { + // Get tokens + const tokens = await AccessTokens.find({ + where: { + userId: user.id + }, + take: ps.limit!, + skip: ps.offset, + order: { + id: ps.sort == 'asc' ? 1 : -1 + } + }); + + return await Promise.all(tokens.map(token => Apps.pack(token.appId, user, { + detail: true + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts new file mode 100644 index 0000000000..7ea5f8c488 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -0,0 +1,39 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../define'; +import { UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + currentPassword: { + validator: $.str + }, + + newPassword: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.currentPassword, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(ps.newPassword, salt); + + await UserProfiles.update(user.id, { + password: hash + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts new file mode 100644 index 0000000000..10e5adf64a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import define from '../../define'; +import { UserProfiles, Users } from '@/models/index'; +import { doPostSuspend } from '@/services/suspend-user'; +import { publishUserEvent } from '@/services/stream'; +import { createDeleteAccountJob } from '@/queue'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + const userDetailed = await Users.findOneOrFail(user.id); + if (userDetailed.isDeleted) { + return; + } + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); + + createDeleteAccountJob(user, { + soft: false + }); + + await Users.update(user.id, { + isDeleted: true, + }); + + // Terminate streaming + publishUserEvent(user.id, 'terminate', {}); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-blocking.ts b/packages/backend/src/server/api/endpoints/i/export-blocking.ts new file mode 100644 index 0000000000..e4797da0c1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-blocking.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportBlockingJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportBlockingJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-following.ts b/packages/backend/src/server/api/endpoints/i/export-following.ts new file mode 100644 index 0000000000..b0f154cda8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-following.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportFollowingJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportFollowingJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-mute.ts b/packages/backend/src/server/api/endpoints/i/export-mute.ts new file mode 100644 index 0000000000..46d547fa53 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-mute.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportMuteJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportMuteJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-notes.ts b/packages/backend/src/server/api/endpoints/i/export-notes.ts new file mode 100644 index 0000000000..441bf16896 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-notes.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportNotesJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1day'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportNotesJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts new file mode 100644 index 0000000000..24043a862a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts @@ -0,0 +1,16 @@ +import define from '../../define'; +import { createExportUserListsJob } from '@/queue/index'; +import * as ms from 'ms'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1min'), + max: 1, + }, +}; + +export default define(meta, async (ps, user) => { + createExportUserListsJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/favorites.ts b/packages/backend/src/server/api/endpoints/i/favorites.ts new file mode 100644 index 0000000000..b79d68ae73 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/favorites.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { NoteFavorites } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'notes', 'favorites'], + + requireCredential: true as const, + + kind: 'read:favorites', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'NoteFavorite', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(NoteFavorites.createQueryBuilder('favorite'), ps.sinceId, ps.untilId) + .andWhere(`favorite.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('favorite.note', 'note'); + + const favorites = await query + .take(ps.limit!) + .getMany(); + + return await NoteFavorites.packMany(favorites, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts new file mode 100644 index 0000000000..7a2935a5ec --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryLikes } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'gallery'], + + requireCredential: true as const, + + kind: 'read:gallery-likes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + page: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.post', 'post'); + + const likes = await query + .take(ps.limit!) + .getMany(); + + return await GalleryLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts new file mode 100644 index 0000000000..21bb8759fc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryPosts } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'gallery'], + + requireCredential: true as const, + + kind: 'read:gallery', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'GalleryPost' + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere(`post.userId = :meId`, { meId: user.id }); + + const posts = await query + .take(ps.limit!) + .getMany(); + + return await GalleryPosts.packMany(posts, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts new file mode 100644 index 0000000000..6b9be98582 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/get-word-muted-notes-count.ts @@ -0,0 +1,33 @@ +import define from '../../define'; +import { MutedNotes } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + count: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, user) => { + return { + count: await MutedNotes.count({ + userId: user.id, + reason: 'word' + }) + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts new file mode 100644 index 0000000000..d44d0b6077 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportBlockingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'ebb53e5f-6574-9c0c-0b92-7ca6def56d7e' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: 'b6fab7d6-d945-d67c-dfdb-32da1cd12cfe' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'b7fbf0b1-aeef-3b21-29ef-fadd4cb72ccf' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '6f3a4dcc-f060-a707-4950-806fbdbe60d6' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportBlockingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts new file mode 100644 index 0000000000..b3de397661 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportFollowingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b98644cf-a5ac-4277-a502-0b8054a709a3' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: '660f3599-bce0-4f95-9dde-311fd841c183' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'dee9d4ed-ad07-43ed-8b34-b2856398bc60' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '31a1b42c-06f7-42ae-8a38-a661c5c9f691' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportFollowingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts new file mode 100644 index 0000000000..c17434c587 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportMutingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'e674141e-bd2a-ba85-e616-aefb187c9c2a' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: '568c6e42-c86c-ba09-c004-517f83f9f1a8' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: '9b4ada6d-d7f7-0472-0713-4f558bd1ec9c' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: 'd2f12af1-e7b4-feac-86a3-519548f2728e' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportMutingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts new file mode 100644 index 0000000000..9069a019a9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportUserListsJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'ea9cc34f-c415-4bc6-a6fe-28ac40357049' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: 'a3c9edda-dd9b-4596-be6a-150ef813745c' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'ae6e7a22-971b-4b52-b2be-fc0b9b121fe9' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '99efe367-ce6e-4d44-93f8-5fae7b040356' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportUserListsJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts new file mode 100644 index 0000000000..56668d03b7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -0,0 +1,138 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { readNotification } from '../../common/read-notification'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notifications, Followings, Mutings, Users } from '@/models/index'; +import { notificationTypes } from '@/types'; +import read from '@/services/note/read'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['account', 'notifications'], + + requireCredential: true as const, + + kind: 'read:notifications', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + following: { + validator: $.optional.bool, + default: false + }, + + unreadOnly: { + validator: $.optional.bool, + default: false + }, + + markAsRead: { + validator: $.optional.bool, + default: true + }, + + includeTypes: { + validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])), + }, + + excludeTypes: { + validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Notification', + } + }, +}; + +export default define(meta, async (ps, user) => { + // includeTypes が空の場合はクエリしない + if (ps.includeTypes && ps.includeTypes.length === 0) { + return []; + } + // excludeTypes に全指定されている場合はクエリしない + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { + return []; + } + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: user.id }); + + const suspendedQuery = Users.createQueryBuilder('users') + .select('users.id') + .where('users.isSuspended = TRUE'); + + const query = makePaginationQuery(Notifications.createQueryBuilder('notification'), ps.sinceId, ps.untilId) + .andWhere(`notification.notifieeId = :meId`, { meId: user.id }) + .leftJoinAndSelect('notification.notifier', 'notifier') + .leftJoinAndSelect('notification.note', 'note') + .leftJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + query.setParameters(mutingQuery.getParameters()); + + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); + + if (ps.following) { + query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: user.id }); + query.setParameters(followingQuery.getParameters()); + } + + if (ps.includeTypes && ps.includeTypes.length > 0) { + query.andWhere(`notification.type IN (:...includeTypes)`, { includeTypes: ps.includeTypes }); + } else if (ps.excludeTypes && ps.excludeTypes.length > 0) { + query.andWhere(`notification.type NOT IN (:...excludeTypes)`, { excludeTypes: ps.excludeTypes }); + } + + if (ps.unreadOnly) { + query.andWhere(`notification.isRead = false`); + } + + const notifications = await query.take(ps.limit!).getMany(); + + // Mark all as read + if (notifications.length > 0 && ps.markAsRead) { + readNotification(user.id, notifications.map(x => x.id)); + } + + const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); + + if (notes.length > 0) { + read(user.id, notes); + } + + return await Notifications.packMany(notifications, user.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/page-likes.ts b/packages/backend/src/server/api/endpoints/i/page-likes.ts new file mode 100644 index 0000000000..fa2bc31730 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/page-likes.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { PageLikes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'pages'], + + requireCredential: true as const, + + kind: 'read:page-likes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + page: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page' + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(PageLikes.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere(`like.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('like.page', 'page'); + + const likes = await query + .take(ps.limit!) + .getMany(); + + return await PageLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pages.ts b/packages/backend/src/server/api/endpoints/i/pages.ts new file mode 100644 index 0000000000..ee87fffa2d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/pages.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Pages } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'pages'], + + requireCredential: true as const, + + kind: 'read:pages', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page' + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere(`page.userId = :meId`, { meId: user.id }); + + const pages = await query + .take(ps.limit!) + .getMany(); + + return await Pages.packMany(pages); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts new file mode 100644 index 0000000000..de94220ba9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -0,0 +1,59 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { addPinned } from '@/services/i/pin'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['account', 'notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '56734f8b-3928-431e-bf80-6ff87df40cb3' + }, + + pinLimitExceeded: { + message: 'You can not pin notes any more.', + code: 'PIN_LIMIT_EXCEEDED', + id: '72dab508-c64d-498f-8740-a8eec1ba385a' + }, + + alreadyPinned: { + message: 'That note has already been pinned.', + code: 'ALREADY_PINNED', + id: '8b18c2b7-68fe-4edb-9892-c0cbaeb6c913' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + await addPinned(user, ps.noteId).catch(e => { + if (e.id === '70c4e51f-5bea-449c-a030-53bee3cce202') throw new ApiError(meta.errors.noSuchNote); + if (e.id === '15a018eb-58e5-4da1-93be-330fcc5e4e1a') throw new ApiError(meta.errors.pinLimitExceeded); + if (e.id === '23f0cf4e-59a3-4276-a91d-61a5891c1514') throw new ApiError(meta.errors.alreadyPinned); + throw e; + }); + + return await Users.pack(user.id, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts new file mode 100644 index 0000000000..9aca7611c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts @@ -0,0 +1,37 @@ +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { MessagingMessages, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['account', 'messaging'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + } +}; + +export default define(meta, async (ps, user) => { + // Update documents + await MessagingMessages.update({ + recipientId: user.id, + isRead: false + }, { + isRead: true + }); + + const joinings = await UserGroupJoinings.find({ userId: user.id }); + + await Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${user.id}')`) as any + }) + .where(`groupId = :groupId`, { groupId: j.userGroupId }) + .andWhere('userId != :userId', { userId: user.id }) + .andWhere('NOT (:userId = ANY(reads))', { userId: user.id }) + .execute())); + + publishMainStream(user.id, 'readAllMessagingMessages'); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts new file mode 100644 index 0000000000..2a7102a590 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts @@ -0,0 +1,25 @@ +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { NoteUnreads } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + } +}; + +export default define(meta, async (ps, user) => { + // Remove documents + await NoteUnreads.delete({ + userId: user.id + }); + + // 全て既読になったイベントを発行 + publishMainStream(user.id, 'readAllUnreadMentions'); + publishMainStream(user.id, 'readAllUnreadSpecifiedNotes'); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts new file mode 100644 index 0000000000..2f5036f953 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { genId } from '@/misc/gen-id'; +import { AnnouncementReads, Announcements, Users } from '@/models/index'; +import { publishMainStream } from '@/services/stream'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + announcementId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: '184663db-df88-4bc2-8b52-fb85f0681939' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Check if announcement exists + const announcement = await Announcements.findOne(ps.announcementId); + + if (announcement == null) { + throw new ApiError(meta.errors.noSuchAnnouncement); + } + + // Check if already read + const read = await AnnouncementReads.findOne({ + announcementId: ps.announcementId, + userId: user.id + }); + + if (read != null) { + return; + } + + // Create read + await AnnouncementReads.insert({ + id: genId(), + createdAt: new Date(), + announcementId: ps.announcementId, + userId: user.id, + }); + + if (!await Users.getHasUnreadAnnouncement(user.id)) { + publishMainStream(user.id, 'readAllAnnouncements'); + } +}); diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts new file mode 100644 index 0000000000..1cce2d37be --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import generateUserToken from '../../common/generate-native-user-token'; +import define from '../../define'; +import { Users, UserProfiles } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + password: { + validator: $.str + } + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new Error('incorrect password'); + } + + // Generate secret + const secret = generateUserToken(); + + await Users.update(user.id, { + token: secret + }); + + // Publish event + publishMainStream(user.id, 'myTokenRegenerated'); + + // Terminate streaming + setTimeout(() => { + publishUserEvent(user.id, 'terminate', {}); + }, 5000); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts new file mode 100644 index 0000000000..c8eaf83a25 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts @@ -0,0 +1,33 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record<string, any>; + + for (const item of items) { + res[item.key] = item.value; + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts new file mode 100644 index 0000000000..992800c44c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: '97a1e8e7-c0f7-47d2-957a-92e61256e01a' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return { + updatedAt: item.updatedAt, + value: item.value, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get.ts b/packages/backend/src/server/api/endpoints/i/registry/get.ts new file mode 100644 index 0000000000..569c3a9280 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return item.value; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts new file mode 100644 index 0000000000..16a4fee374 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts @@ -0,0 +1,41 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record<string, string>; + + for (const item of items) { + const type = typeof item.value; + res[item.key] = + item.value === null ? 'null' : + Array.isArray(item.value) ? 'array' : + type === 'number' ? 'number' : + type === 'string' ? 'string' : + type === 'boolean' ? 'boolean' : + type === 'object' ? 'object' : + null as never; + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys.ts b/packages/backend/src/server/api/endpoints/i/registry/keys.ts new file mode 100644 index 0000000000..3a8aeaa195 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/keys.ts @@ -0,0 +1,28 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.key') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const items = await query.getMany(); + + return items.map(x => x.key); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/remove.ts b/packages/backend/src/server/api/endpoints/i/registry/remove.ts new file mode 100644 index 0000000000..07bc23d4a6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/remove.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { ApiError } from '../../../error'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + }, + + errors: { + noSuchKey: { + message: 'No such key.', + code: 'NO_SUCH_KEY', + id: '1fac4e8a-a6cd-4e39-a4a5-3a7e11f1b019' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + await RegistryItems.remove(item); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts new file mode 100644 index 0000000000..ecbdb05a8e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts @@ -0,0 +1,29 @@ +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .select('item.scope') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }); + + const items = await query.getMany(); + + const res = [] as string[][]; + + for (const item of items) { + if (res.some(scope => scope.join('.') === item.scope.join('.'))) continue; + res.push(item.scope); + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/set.ts b/packages/backend/src/server/api/endpoints/i/registry/set.ts new file mode 100644 index 0000000000..f129ee1b70 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/set.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../../../define'; +import { RegistryItems } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + key: { + validator: $.str.min(1) + }, + + value: { + validator: $.nullable.any + }, + + scope: { + validator: $.optional.arr($.str.match(/^[a-zA-Z0-9_]+$/)), + default: [], + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = RegistryItems.createQueryBuilder('item') + .where('item.domain IS NULL') + .andWhere('item.userId = :userId', { userId: user.id }) + .andWhere('item.key = :key', { key: ps.key }) + .andWhere('item.scope = :scope', { scope: ps.scope }); + + const existingItem = await query.getOne(); + + if (existingItem) { + await RegistryItems.update(existingItem.id, { + updatedAt: new Date(), + value: ps.value + }); + } else { + await RegistryItems.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + userId: user.id, + domain: null, + scope: ps.scope, + key: ps.key, + value: ps.value + }); + } + + // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする + publishMainStream(user.id, 'registryUpdated', { + scope: ps.scope, + key: ps.key, + value: ps.value + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts new file mode 100644 index 0000000000..bed868def4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -0,0 +1,31 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + tokenId: { + validator: $.type(ID) + } + } +}; + +export default define(meta, async (ps, user) => { + const token = await AccessTokens.findOne(ps.tokenId); + + if (token) { + await AccessTokens.delete({ + id: ps.tokenId, + userId: user.id, + }); + + // Terminate streaming + publishUserEvent(user.id, 'terminate'); + } +}); diff --git a/packages/backend/src/server/api/endpoints/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts new file mode 100644 index 0000000000..a2c10148c6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/signin-history.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Signins } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Signins.createQueryBuilder('signin'), ps.sinceId, ps.untilId) + .andWhere(`signin.userId = :meId`, { meId: user.id }); + + const history = await query.take(ps.limit!).getMany(); + + return await Promise.all(history.map(record => Signins.pack(record))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/unpin.ts b/packages/backend/src/server/api/endpoints/i/unpin.ts new file mode 100644 index 0000000000..dc79e255ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/unpin.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { removePinned } from '@/services/i/pin'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['account', 'notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '454170ce-9d63-4a43-9da1-ea10afe81e21' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, user) => { + await removePinned(user, ps.noteId).catch(e => { + if (e.id === 'b302d4cf-c050-400a-bbb3-be208681f40c') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + return await Users.pack(user.id, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts new file mode 100644 index 0000000000..9b6fb9c410 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -0,0 +1,94 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import rndstr from 'rndstr'; +import config from '@/config/index'; +import * as ms from 'ms'; +import * as bcrypt from 'bcryptjs'; +import { Users, UserProfiles } from '@/models/index'; +import { sendEmail } from '@/services/send-email'; +import { ApiError } from '../../error'; +import { validateEmailForAccount } from '@/services/validate-email-for-account'; + +export const meta = { + requireCredential: true as const, + + secure: true, + + limit: { + duration: ms('1hour'), + max: 3 + }, + + params: { + password: { + validator: $.str + }, + + email: { + validator: $.optional.nullable.str + }, + }, + + errors: { + incorrectPassword: { + message: 'Incorrect password.', + code: 'INCORRECT_PASSWORD', + id: 'e54c1d7e-e7d6-4103-86b6-0a95069b4ad3' + }, + + unavailable: { + message: 'Unavailable email address.', + code: 'UNAVAILABLE', + id: 'a2defefb-f220-8849-0af6-17f816099323' + }, + } +}; + +export default define(meta, async (ps, user) => { + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(ps.password, profile.password!); + + if (!same) { + throw new ApiError(meta.errors.incorrectPassword); + } + + if (ps.email != null) { + const available = await validateEmailForAccount(ps.email); + if (!available) { + throw new ApiError(meta.errors.unavailable); + } + } + + await UserProfiles.update(user.id, { + email: ps.email, + emailVerified: false, + emailVerifyCode: null + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + + if (ps.email != null) { + const code = rndstr('a-z0-9', 16); + + await UserProfiles.update(user.id, { + emailVerifyCode: code + }); + + const link = `${config.url}/verify-email/${code}`; + + sendEmail(ps.email, 'Email verification', + `To verify email, please click this link:<br><a href="${link}">${link}</a>`, + `To verify email, please click this link: ${link}`); + } + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts new file mode 100644 index 0000000000..d0f201ab60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -0,0 +1,294 @@ +import $ from 'cafy'; +import * as mfm from 'mfm-js'; +import { ID } from '@/misc/cafy-id'; +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import acceptAllFollowRequests from '@/services/following/requests/accept-all'; +import { publishToFollowers } from '@/services/i/update'; +import define from '../../define'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm'; +import { extractHashtags } from '@/misc/extract-hashtags'; +import * as langmap from 'langmap'; +import { updateUsertags } from '@/services/update-hashtag'; +import { ApiError } from '../../error'; +import { Users, DriveFiles, UserProfiles, Pages } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { UserProfile } from '@/models/entities/user-profile'; +import { notificationTypes } from '@/types'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + name: { + validator: $.optional.nullable.use(Users.validateName), + }, + + description: { + validator: $.optional.nullable.use(Users.validateDescription), + }, + + lang: { + validator: $.optional.nullable.str.or(Object.keys(langmap)), + }, + + location: { + validator: $.optional.nullable.use(Users.validateLocation), + }, + + birthday: { + validator: $.optional.nullable.use(Users.validateBirthday), + }, + + avatarId: { + validator: $.optional.nullable.type(ID), + }, + + bannerId: { + validator: $.optional.nullable.type(ID), + }, + + fields: { + validator: $.optional.arr($.object()).range(1, 4), + }, + + isLocked: { + validator: $.optional.bool, + }, + + isExplorable: { + validator: $.optional.bool, + }, + + hideOnlineStatus: { + validator: $.optional.bool, + }, + + publicReactions: { + validator: $.optional.bool, + }, + + ffVisibility: { + validator: $.optional.str, + }, + + carefulBot: { + validator: $.optional.bool, + }, + + autoAcceptFollowed: { + validator: $.optional.bool, + }, + + noCrawle: { + validator: $.optional.bool, + }, + + isBot: { + validator: $.optional.bool, + }, + + isCat: { + validator: $.optional.bool, + }, + + injectFeaturedNote: { + validator: $.optional.bool, + }, + + receiveAnnouncementEmail: { + validator: $.optional.bool, + }, + + alwaysMarkNsfw: { + validator: $.optional.bool, + }, + + pinnedPageId: { + validator: $.optional.nullable.type(ID), + }, + + mutedWords: { + validator: $.optional.arr($.arr($.str)) + }, + + mutingNotificationTypes: { + validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])) + }, + + emailNotificationTypes: { + validator: $.optional.arr($.str) + }, + }, + + errors: { + noSuchAvatar: { + message: 'No such avatar file.', + code: 'NO_SUCH_AVATAR', + id: '539f3a45-f215-4f81-a9a8-31293640207f' + }, + + noSuchBanner: { + message: 'No such banner file.', + code: 'NO_SUCH_BANNER', + id: '0d8f5629-f210-41c2-9433-735831a58595' + }, + + avatarNotAnImage: { + message: 'The file specified as an avatar is not an image.', + code: 'AVATAR_NOT_AN_IMAGE', + id: 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191' + }, + + bannerNotAnImage: { + message: 'The file specified as a banner is not an image.', + code: 'BANNER_NOT_AN_IMAGE', + id: '75aedb19-2afd-4e6d-87fc-67941256fa60' + }, + + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '8e01b590-7eb9-431b-a239-860e086c408e' + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User' + } +}; + +export default define(meta, async (ps, _user, token) => { + const user = await Users.findOneOrFail(_user.id); + const isSecure = token == null; + + const updates = {} as Partial<User>; + const profileUpdates = {} as Partial<UserProfile>; + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (ps.name !== undefined) updates.name = ps.name; + if (ps.description !== undefined) profileUpdates.description = ps.description; + if (ps.lang !== undefined) profileUpdates.lang = ps.lang; + if (ps.location !== undefined) profileUpdates.location = ps.location; + if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; + if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; + if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; + if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.mutedWords !== undefined) { + profileUpdates.mutedWords = ps.mutedWords; + profileUpdates.enableWordMute = ps.mutedWords.length > 0; + } + if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; + if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; + if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; + if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; + if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions; + if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; + if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; + if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; + if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; + if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; + if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; + if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; + if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; + + if (ps.avatarId) { + const avatar = await DriveFiles.findOne(ps.avatarId); + + if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); + if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); + + updates.avatarUrl = DriveFiles.getPublicUrl(avatar, true); + + if (avatar.blurhash) { + updates.avatarBlurhash = avatar.blurhash; + } + } + + if (ps.bannerId) { + const banner = await DriveFiles.findOne(ps.bannerId); + + if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); + if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); + + updates.bannerUrl = DriveFiles.getPublicUrl(banner, false); + + if (banner.blurhash) { + updates.bannerBlurhash = banner.blurhash; + } + } + + if (ps.pinnedPageId) { + const page = await Pages.findOne(ps.pinnedPageId); + + if (page == null || page.userId !== user.id) throw new ApiError(meta.errors.noSuchPage); + + profileUpdates.pinnedPageId = page.id; + } else if (ps.pinnedPageId === null) { + profileUpdates.pinnedPageId = null; + } + + if (ps.fields) { + profileUpdates.fields = ps.fields + .filter(x => typeof x.name === 'string' && x.name !== '' && typeof x.value === 'string' && x.value !== '') + .map(x => { + return { name: x.name, value: x.value }; + }); + } + + //#region emojis/tags + + let emojis = [] as string[]; + let tags = [] as string[]; + + const newName = updates.name === undefined ? user.name : updates.name; + const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; + + if (newName != null) { + const tokens = mfm.parsePlain(newName); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + } + + if (newDescription != null) { + const tokens = mfm.parse(newDescription); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!)); + tags = extractHashtags(tokens!).map(tag => normalizeForSearch(tag)).splice(0, 32); + } + + updates.emojis = emojis; + updates.tags = tags; + + // ハッシュタグ更新 + updateUsertags(user, tags); + //#endregion + + if (Object.keys(updates).length > 0) await Users.update(user.id, updates); + if (Object.keys(profileUpdates).length > 0) await UserProfiles.update(user.id, profileUpdates); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: isSecure + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + publishUserEvent(user.id, 'updateUserProfile', await UserProfiles.findOne(user.id)); + + // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 + if (user.isLocked && ps.isLocked === false) { + acceptAllFollowRequests(user); + } + + // フォロワーにUpdateを配信 + publishToFollowers(user.id); + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/user-group-invites.ts b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts new file mode 100644 index 0000000000..1ebde243ca --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { UserGroupInvitations } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['account', 'groups'], + + requireCredential: true as const, + + kind: 'read:user-groups', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + group: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup' + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(UserGroupInvitations.createQueryBuilder('invitation'), ps.sinceId, ps.untilId) + .andWhere(`invitation.userId = :meId`, { meId: user.id }) + .leftJoinAndSelect('invitation.userGroup', 'user_group'); + + const invitations = await query + .take(ps.limit!) + .getMany(); + + return await UserGroupInvitations.packMany(invitations); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/history.ts b/packages/backend/src/server/api/endpoints/messaging/history.ts new file mode 100644 index 0000000000..e447703546 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/history.ts @@ -0,0 +1,94 @@ +import $ from 'cafy'; +import define from '../../define'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { MessagingMessages, Mutings, UserGroupJoinings } from '@/models/index'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'read:messaging', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + group: { + validator: $.optional.bool, + default: false + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'MessagingMessage', + } + }, +}; + +export default define(meta, async (ps, user) => { + const mute = await Mutings.find({ + muterId: user.id, + }); + + const groups = ps.group ? await UserGroupJoinings.find({ + userId: user.id, + }).then(xs => xs.map(x => x.userGroupId)) : []; + + if (ps.group && groups.length === 0) { + return []; + } + + const history: MessagingMessage[] = []; + + for (let i = 0; i < ps.limit!; i++) { + const found = ps.group + ? history.map(m => m.groupId!) + : history.map(m => (m.userId === user.id) ? m.recipientId! : m.userId!); + + const query = MessagingMessages.createQueryBuilder('message') + .orderBy('message.createdAt', 'DESC'); + + if (ps.group) { + query.where(`message.groupId IN (:...groups)`, { groups: groups }); + + if (found.length > 0) { + query.andWhere(`message.groupId NOT IN (:...found)`, { found: found }); + } + } else { + query.where(new Brackets(qb => { qb + .where(`message.userId = :userId`, { userId: user.id }) + .orWhere(`message.recipientId = :userId`, { userId: user.id }); + })); + query.andWhere(`message.groupId IS NULL`); + + if (found.length > 0) { + query.andWhere(`message.userId NOT IN (:...found)`, { found: found }); + query.andWhere(`message.recipientId NOT IN (:...found)`, { found: found }); + } + + if (mute.length > 0) { + query.andWhere(`message.userId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); + query.andWhere(`message.recipientId NOT IN (:...mute)`, { mute: mute.map(m => m.muteeId) }); + } + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return await Promise.all(history.map(h => MessagingMessages.pack(h.id, user))); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages.ts b/packages/backend/src/server/api/endpoints/messaging/messages.ts new file mode 100644 index 0000000000..6baa24609e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages.ts @@ -0,0 +1,148 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { MessagingMessages, UserGroups, UserGroupJoinings, Users } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Brackets } from 'typeorm'; +import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '../../common/read-messaging-message'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'read:messaging', + + params: { + userId: { + validator: $.optional.type(ID), + }, + + groupId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + markAsRead: { + validator: $.optional.bool, + default: true + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'MessagingMessage', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d' + }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f' + }, + + groupAccessDenied: { + message: 'You can not read messages of groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'a053a8dd-a491-4718-8f87-50775aad9284' + }, + } +}; + +export default define(meta, async (ps, user) => { + if (ps.userId != null) { + // Fetch recipient (user) + const recipient = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(new Brackets(qb => { qb + .where('message.userId = :meId') + .andWhere('message.recipientId = :recipientId'); + })) + .orWhere(new Brackets(qb => { qb + .where('message.userId = :recipientId') + .andWhere('message.recipientId = :meId'); + })); + })) + .setParameter('meId', user.id) + .setParameter('recipientId', recipient.id); + + const messages = await query.take(ps.limit!).getMany(); + + // Mark all as read + if (ps.markAsRead) { + readUserMessagingMessage(user.id, recipient.id, messages.filter(m => m.recipientId === user.id).map(x => x.id)); + + // リモートユーザーとのメッセージだったら既読配信 + if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) { + deliverReadActivity(user, recipient, messages); + } + } + + return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { + populateRecipient: false + }))); + } else if (ps.groupId != null) { + // Fetch recipient (group) + const recipientGroup = await UserGroups.findOne(ps.groupId); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOne({ + userId: user.id, + userGroupId: recipientGroup.id + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + + const query = makePaginationQuery(MessagingMessages.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(`message.groupId = :groupId`, { groupId: recipientGroup.id }); + + const messages = await query.take(ps.limit!).getMany(); + + // Mark all as read + if (ps.markAsRead) { + readGroupMessagingMessage(user.id, recipientGroup.id, messages.map(x => x.id)); + } + + return await Promise.all(messages.map(message => MessagingMessages.pack(message, user, { + populateGroup: false + }))); + } else { + throw new Error(); + } +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/create.ts b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts new file mode 100644 index 0000000000..df0b455cbe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts @@ -0,0 +1,148 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { MessagingMessages, DriveFiles, UserGroups, UserGroupJoinings, Blockings } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { UserGroup } from '@/models/entities/user-group'; +import { createMessage } from '@/services/messages/create'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'write:messaging', + + params: { + userId: { + validator: $.optional.type(ID), + }, + + groupId: { + validator: $.optional.type(ID), + }, + + text: { + validator: $.optional.str.pipe(MessagingMessages.validateText) + }, + + fileId: { + validator: $.optional.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'MessagingMessage', + }, + + errors: { + recipientIsYourself: { + message: 'You can not send a message to yourself.', + code: 'RECIPIENT_IS_YOURSELF', + id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d' + }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537' + }, + + groupAccessDenied: { + message: 'You can not send messages to groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd' + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '4372b8e2-185d-4146-8749-2f68864a3e5f' + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '25587321-b0e6-449c-9239-f8925092942c' + }, + + youHaveBeenBlocked: { + message: 'You cannot send a message because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'c15a5199-7422-4968-941a-2a462c478f7d' + }, + } +}; + +export default define(meta, async (ps, user) => { + let recipientUser: User | undefined; + let recipientGroup: UserGroup | undefined; + + if (ps.userId != null) { + // Myself + if (ps.userId === user.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + // Fetch recipient (user) + recipientUser = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check blocking + const block = await Blockings.findOne({ + blockerId: recipientUser.id, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } else if (ps.groupId != null) { + // Fetch recipient (group) + recipientGroup = await UserGroups.findOne(ps.groupId); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOne({ + userId: user.id, + userGroupId: recipientGroup.id + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + } + + let file = null; + if (ps.fileId != null) { + file = await DriveFiles.findOne({ + id: ps.fileId, + userId: user.id + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + return await createMessage(user, recipientUser, recipientGroup, ps.text, file); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts new file mode 100644 index 0000000000..bd4890fc8a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import * as ms from 'ms'; +import { ApiError } from '../../../error'; +import { MessagingMessages } from '@/models/index'; +import { deleteMessage } from '@/services/messages/delete'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'write:messaging', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec') + }, + + params: { + messageId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '54b5b326-7925-42cf-8019-130fda8b56af' + }, + } +}; + +export default define(meta, async (ps, user) => { + const message = await MessagingMessages.findOne({ + id: ps.messageId, + userId: user.id + }); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + await deleteMessage(message); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/read.ts b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts new file mode 100644 index 0000000000..a1747310d3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts @@ -0,0 +1,48 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { MessagingMessages } from '@/models/index'; +import { readUserMessagingMessage, readGroupMessagingMessage } from '../../../common/read-messaging-message'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true as const, + + kind: 'write:messaging', + + params: { + messageId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '86d56a2f-a9c3-4afb-b13c-3e9bfef9aa14' + }, + } +}; + +export default define(meta, async (ps, user) => { + const message = await MessagingMessages.findOne(ps.messageId); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + if (message.recipientId) { + await readUserMessagingMessage(user.id, message.userId, [message.id]).catch(e => { + if (e.id === 'e140a4bf-49ce-4fb6-b67c-b78dadf6b52f') throw new ApiError(meta.errors.noSuchMessage); + throw e; + }); + } else if (message.groupId) { + await readGroupMessagingMessage(user.id, message.groupId, [message.id]).catch(e => { + if (e.id === '930a270c-714a-46b2-b776-ad27276dc569') throw new ApiError(meta.errors.noSuchMessage); + throw e; + }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts new file mode 100644 index 0000000000..ce21556243 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -0,0 +1,598 @@ +import $ from 'cafy'; +import config from '@/config/index'; +import define from '../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Ads, Emojis, Users } from '@/models/index'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits'; +import { MoreThan } from 'typeorm'; + +export const meta = { + tags: ['meta'], + + requireCredential: false as const, + + params: { + detail: { + validator: $.optional.bool, + default: true + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + maintainerName: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + maintainerEmail: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + version: { + type: 'string' as const, + optional: false as const, nullable: false as const, + example: config.version + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + uri: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url', + example: 'https://misskey.example.com' + }, + description: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + langs: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + tosUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + repositoryUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'https://github.com/misskey-dev/misskey' + }, + feedbackUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'https://github.com/misskey-dev/misskey/issues/new' + }, + secure: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + default: false + }, + disableRegistration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + disableLocalTimeline: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + disableGlobalTimeline: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + }, + driveCapacityPerLocalUserMb: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + driveCapacityPerRemoteUserMb: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + cacheRemoteFiles: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + proxyRemoteFiles: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + emailRequiredForSignup: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableHcaptcha: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hcaptchaSiteKey: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + enableRecaptcha: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + recaptchaSiteKey: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + swPublickey: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + mascotImageUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: '/assets/ai.png' + }, + bannerUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + errorImageUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + default: 'https://xn--931a.moe/aiart/yubitun.png' + }, + iconUrl: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + maxNoteTextLength: { + type: 'number' as const, + optional: false as const, nullable: false as const, + default: 500 + }, + emojis: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + aliases: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + category: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + host: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + } + } + } + }, + ads: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + place: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + imageUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'url' + }, + } + } + }, + requireSetup: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + example: false + }, + enableEmail: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableTwitterIntegration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableGithubIntegration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableDiscordIntegration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + enableServiceWorker: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + translatorAvailable: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + proxyAccountName: { + type: 'string' as const, + optional: false as const, nullable: true as const + }, + features: { + type: 'object' as const, + optional: true as const, nullable: false as const, + properties: { + registration: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + localTimeLine: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + globalTimeLine: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + elasticsearch: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hcaptcha: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + recaptcha: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + objectStorage: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + twitter: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + github: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + discord: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + serviceWorker: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + miauth: { + type: 'boolean' as const, + optional: true as const, nullable: false as const, + default: true + }, + } + }, + userStarForReactionFallback: { + type: 'boolean' as const, + optional: true as const, nullable: false as const, + }, + pinnedUsers: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + hiddenTags: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + blockedHosts: { + type: 'array' as const, + optional: true as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + hcaptchaSecretKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + recaptchaSecretKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + proxyAccountId: { + type: 'string' as const, + optional: true as const, nullable: true as const, + format: 'id' + }, + twitterConsumerKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + twitterConsumerSecret: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + githubClientId: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + githubClientSecret: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + discordClientId: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + discordClientSecret: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + summaryProxy: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + email: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + smtpSecure: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + smtpHost: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + smtpPort: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + smtpUser: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + smtpPass: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + swPrivateKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + useObjectStorage: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + objectStorageBaseUrl: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageBucket: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStoragePrefix: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageEndpoint: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageRegion: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStoragePort: { + type: 'number' as const, + optional: true as const, nullable: true as const + }, + objectStorageAccessKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageSecretKey: { + type: 'string' as const, + optional: true as const, nullable: true as const + }, + objectStorageUseSSL: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + objectStorageUseProxy: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + }, + objectStorageSetPublicRead: { + type: 'boolean' as const, + optional: true as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, me) => { + const instance = await fetchMeta(true); + + const emojis = await Emojis.find({ + where: { + host: null + }, + order: { + category: 'ASC', + name: 'ASC' + }, + cache: { + id: 'meta_emojis', + milliseconds: 3600000 // 1 hour + } + }); + + const ads = await Ads.find({ + where: { + expiresAt: MoreThan(new Date()) + }, + }); + + const response: any = { + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, + + version: config.version, + + name: instance.name, + uri: config.url, + description: instance.description, + langs: instance.langs, + tosUrl: instance.ToSUrl, + repositoryUrl: instance.repositoryUrl, + feedbackUrl: instance.feedbackUrl, + + secure: config.https != null, + + disableRegistration: instance.disableRegistration, + disableLocalTimeline: instance.disableLocalTimeline, + disableGlobalTimeline: instance.disableGlobalTimeline, + driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, + driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, + emailRequiredForSignup: instance.emailRequiredForSignup, + enableHcaptcha: instance.enableHcaptcha, + hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableRecaptcha: instance.enableRecaptcha, + recaptchaSiteKey: instance.recaptchaSiteKey, + swPublickey: instance.swPublicKey, + mascotImageUrl: instance.mascotImageUrl, + bannerUrl: instance.bannerUrl, + errorImageUrl: instance.errorImageUrl, + iconUrl: instance.iconUrl, + backgroundImageUrl: instance.backgroundImageUrl, + logoImageUrl: instance.logoImageUrl, + maxNoteTextLength: Math.min(instance.maxNoteTextLength, DB_MAX_NOTE_TEXT_LENGTH), + emojis: await Emojis.packMany(emojis), + ads: ads.map(ad => ({ + id: ad.id, + url: ad.url, + place: ad.place, + ratio: ad.ratio, + imageUrl: ad.imageUrl, + })), + enableEmail: instance.enableEmail, + + enableTwitterIntegration: instance.enableTwitterIntegration, + enableGithubIntegration: instance.enableGithubIntegration, + enableDiscordIntegration: instance.enableDiscordIntegration, + + enableServiceWorker: instance.enableServiceWorker, + + translatorAvailable: instance.deeplAuthKey != null, + + ...(ps.detail ? { + pinnedPages: instance.pinnedPages, + pinnedClipId: instance.pinnedClipId, + cacheRemoteFiles: instance.cacheRemoteFiles, + proxyRemoteFiles: instance.proxyRemoteFiles, + requireSetup: (await Users.count({ + host: null, + })) === 0, + } : {}) + }; + + if (ps.detail) { + const proxyAccount = instance.proxyAccountId ? await Users.pack(instance.proxyAccountId).catch(() => null) : null; + + response.proxyAccountName = proxyAccount ? proxyAccount.username : null; + response.features = { + registration: !instance.disableRegistration, + localTimeLine: !instance.disableLocalTimeline, + globalTimeLine: !instance.disableGlobalTimeline, + emailRequiredForSignup: instance.emailRequiredForSignup, + elasticsearch: config.elasticsearch ? true : false, + hcaptcha: instance.enableHcaptcha, + recaptcha: instance.enableRecaptcha, + objectStorage: instance.useObjectStorage, + twitter: instance.enableTwitterIntegration, + github: instance.enableGithubIntegration, + discord: instance.enableDiscordIntegration, + serviceWorker: instance.enableServiceWorker, + miauth: true, + }; + + if (me && me.isAdmin) { + response.useStarForReactionFallback = instance.useStarForReactionFallback; + response.pinnedUsers = instance.pinnedUsers; + response.hiddenTags = instance.hiddenTags; + response.blockedHosts = instance.blockedHosts; + response.hcaptchaSecretKey = instance.hcaptchaSecretKey; + response.recaptchaSecretKey = instance.recaptchaSecretKey; + response.proxyAccountId = instance.proxyAccountId; + response.twitterConsumerKey = instance.twitterConsumerKey; + response.twitterConsumerSecret = instance.twitterConsumerSecret; + response.githubClientId = instance.githubClientId; + response.githubClientSecret = instance.githubClientSecret; + response.discordClientId = instance.discordClientId; + response.discordClientSecret = instance.discordClientSecret; + response.summalyProxy = instance.summalyProxy; + response.email = instance.email; + response.smtpSecure = instance.smtpSecure; + response.smtpHost = instance.smtpHost; + response.smtpPort = instance.smtpPort; + response.smtpUser = instance.smtpUser; + response.smtpPass = instance.smtpPass; + response.swPrivateKey = instance.swPrivateKey; + response.useObjectStorage = instance.useObjectStorage; + response.objectStorageBaseUrl = instance.objectStorageBaseUrl; + response.objectStorageBucket = instance.objectStorageBucket; + response.objectStoragePrefix = instance.objectStoragePrefix; + response.objectStorageEndpoint = instance.objectStorageEndpoint; + response.objectStorageRegion = instance.objectStorageRegion; + response.objectStoragePort = instance.objectStoragePort; + response.objectStorageAccessKey = instance.objectStorageAccessKey; + response.objectStorageSecretKey = instance.objectStorageSecretKey; + response.objectStorageUseSSL = instance.objectStorageUseSSL; + response.objectStorageUseProxy = instance.objectStorageUseProxy; + response.objectStorageSetPublicRead = instance.objectStorageSetPublicRead; + response.objectStorageS3ForcePathStyle = instance.objectStorageS3ForcePathStyle; + response.deeplAuthKey = instance.deeplAuthKey; + response.deeplIsPro = instance.deeplIsPro; + } + } + + return response; +}); diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts new file mode 100644 index 0000000000..321fa42fc9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -0,0 +1,72 @@ +import $ from 'cafy'; +import define from '../../define'; +import { AccessTokens } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { secureRndstr } from '@/misc/secure-rndstr'; + +export const meta = { + tags: ['auth'], + + requireCredential: true as const, + + secure: true, + + params: { + session: { + validator: $.nullable.str + }, + + name: { + validator: $.nullable.optional.str + }, + + description: { + validator: $.nullable.optional.str, + }, + + iconUrl: { + validator: $.nullable.optional.str, + }, + + permission: { + validator: $.arr($.str).unique(), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + token: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, user) => { + // Generate access token + const accessToken = secureRndstr(32, true); + + const now = new Date(); + + // Insert access token doc + await AccessTokens.insert({ + id: genId(), + createdAt: now, + lastUsedAt: now, + session: ps.session, + userId: user.id, + token: accessToken, + hash: accessToken, + name: ps.name, + description: ps.description, + iconUrl: ps.iconUrl, + permission: ps.permission, + }); + + return { + token: accessToken + }; +}); diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts new file mode 100644 index 0000000000..3fc64d3eba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -0,0 +1,83 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { genId } from '@/misc/gen-id'; +import { Mutings, NoteWatchings } from '@/models/index'; +import { Muting } from '@/models/entities/muting'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:mutes', + + params: { + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '6fef56f3-e765-4957-88e5-c6f65329b8a5' + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: 'a4619cb2-5f23-484b-9301-94c903074e10' + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING', + id: '7e7359cb-160c-4956-b08f-4d1c653cd007' + }, + } +}; + +export default define(meta, async (ps, user) => { + const muter = user; + + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already muting + const exist = await Mutings.findOne({ + muterId: muter.id, + muteeId: mutee.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await Mutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as Muting); + + publishUserEvent(user.id, 'mute', mutee); + + NoteWatchings.delete({ + userId: muter.id, + noteUserId: mutee.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts new file mode 100644 index 0000000000..3ffd1f4562 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -0,0 +1,73 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Mutings } from '@/models/index'; +import { publishUserEvent } from '@/services/stream'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'write:mutes', + + params: { + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'b851d00b-8ab1-4a56-8b1b-e24187cb48ef' + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: 'f428b029-6b39-4d48-a1d2-cc1ae6dd5cf9' + }, + + notMuting: { + message: 'You are not muting that user.', + code: 'NOT_MUTING', + id: '5467d020-daa9-4553-81e1-135c0c35a96d' + }, + } +}; + +export default define(meta, async (ps, user) => { + const muter = user; + + // Check if the mutee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not muting + const exist = await Mutings.findOne({ + muterId: muter.id, + muteeId: mutee.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await Mutings.delete({ + id: exist.id + }); + + publishUserEvent(user.id, 'unmute', mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/mute/list.ts b/packages/backend/src/server/api/endpoints/mute/list.ts new file mode 100644 index 0000000000..ae4c3a719d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/list.ts @@ -0,0 +1,49 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Mutings } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + kind: 'read:mutes', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 30 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Muting', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Mutings.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere(`muting.muterId = :meId`, { meId: me.id }); + + const mutings = await query + .take(ps.limit!) + .getMany(); + + return await Mutings.packMany(mutings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/my/apps.ts b/packages/backend/src/server/api/endpoints/my/apps.ts new file mode 100644 index 0000000000..d91562b62f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/my/apps.ts @@ -0,0 +1,86 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Apps } from '@/models/index'; + +export const meta = { + tags: ['account', 'app'], + + requireCredential: true as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + name: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + callbackUrl: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + permission: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + }, + secret: { + type: 'string' as const, + optional: true as const, nullable: false as const + }, + isAuthorized: { + type: 'object' as const, + optional: true as const, nullable: false as const, + properties: { + appId: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + userId: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + } + } + } + } +}; + +export default define(meta, async (ps, user) => { + const query = { + userId: user.id + }; + + const apps = await Apps.find({ + where: query, + take: ps.limit!, + skip: ps.offset, + }); + + return await Promise.all(apps.map(app => Apps.pack(app, user, { + detail: true + }))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts new file mode 100644 index 0000000000..a3f6e187f2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -0,0 +1,94 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../define'; +import { makePaginationQuery } from '../common/make-pagination-query'; +import { Notes } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + params: { + local: { + validator: $.optional.bool, + }, + + reply: { + validator: $.optional.bool, + }, + + renote: { + validator: $.optional.bool, + }, + + withFiles: { + validator: $.optional.bool, + }, + + poll: { + validator: $.optional.bool, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.visibility = 'public'`) + .andWhere(`note.localOnly = FALSE`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (ps.local) { + query.andWhere('note.userHost IS NULL'); + } + + if (ps.reply != undefined) { + query.andWhere(ps.reply ? 'note.replyId IS NOT NULL' : 'note.replyId IS NULL'); + } + + if (ps.renote != undefined) { + query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL'); + } + + if (ps.withFiles != undefined) { + query.andWhere(ps.withFiles ? `note.fileIds != '{}'` : `note.fileIds = '{}'`); + } + + if (ps.poll != undefined) { + query.andWhere(ps.poll ? 'note.hasPoll = TRUE' : 'note.hasPoll = FALSE'); + } + + // TODO + //if (bot != undefined) { + // query.isBot = bot; + //} + + const notes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(notes); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts new file mode 100644 index 0000000000..68881fda9e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -0,0 +1,72 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { Brackets } from 'typeorm'; +import { Notes } from '@/models/index'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(`note.replyId = :noteId`, { noteId: ps.noteId }) + .orWhere(new Brackets(qb => { qb + .where(`note.renoteId = :noteId`, { noteId: ps.noteId }) + .andWhere(new Brackets(qb => { qb + .where(`note.text IS NOT NULL`) + .orWhere(`note.fileIds != '{}'`) + .orWhere(`note.hasPoll = TRUE`); + })); + })); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + const notes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts new file mode 100644 index 0000000000..6b303d87ec --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ClipNotes, Clips } from '@/models/index'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { In } from 'typeorm'; + +export const meta = { + tags: ['clips', 'notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '47db1a1c-b0af-458d-8fb4-986e4efafe1e' + } + } +}; + +export default define(meta, async (ps, me) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const clipNotes = await ClipNotes.find({ + noteId: note.id, + }); + + const clips = await Clips.find({ + id: In(clipNotes.map(x => x.clipId)), + isPublic: true + }); + + return await Promise.all(clips.map(x => Clips.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/conversation.ts b/packages/backend/src/server/api/endpoints/notes/conversation.ts new file mode 100644 index 0000000000..0fe323ea00 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/conversation.ts @@ -0,0 +1,81 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getNote } from '../../common/getters'; +import { Note } from '@/models/entities/note'; +import { Notes } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'e1035875-9551-45ec-afa8-1ded1fcb53c8' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const conversation: Note[] = []; + let i = 0; + + async function get(id: any) { + i++; + const p = await Notes.findOne(id); + if (p == null) return; + + if (i > ps.offset!) { + conversation.push(p); + } + + if (conversation.length == ps.limit!) { + return; + } + + if (p.replyId) { + await get(p.replyId); + } + } + + if (note.replyId) { + await get(note.replyId); + } + + return await Notes.packMany(conversation, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts new file mode 100644 index 0000000000..751673f955 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -0,0 +1,299 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import { length } from 'stringz'; +import create from '@/services/note/create'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { ApiError } from '../../error'; +import { ID } from '@/misc/cafy-id'; +import { User } from '@/models/entities/user'; +import { Users, DriveFiles, Notes, Channels, Blockings } from '@/models/index'; +import { DriveFile } from '@/models/entities/drive-file'; +import { Note } from '@/models/entities/note'; +import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits'; +import { noteVisibilities } from '../../../../types'; +import { Channel } from '@/models/entities/channel'; + +let maxNoteTextLength = 500; + +setInterval(() => { + fetchMeta().then(m => { + maxNoteTextLength = m.maxNoteTextLength; + }); +}, 3000); + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 300 + }, + + kind: 'write:notes', + + params: { + visibility: { + validator: $.optional.str.or(noteVisibilities as unknown as string[]), + default: 'public', + }, + + visibleUserIds: { + validator: $.optional.arr($.type(ID)).unique().min(0), + }, + + text: { + validator: $.optional.nullable.str.pipe(text => + text.trim() != '' + && length(text.trim()) <= maxNoteTextLength + && Array.from(text.trim()).length <= DB_MAX_NOTE_TEXT_LENGTH // DB limit + ), + default: null, + }, + + cw: { + validator: $.optional.nullable.str.pipe(Notes.validateCw), + }, + + viaMobile: { + validator: $.optional.bool, + default: false, + }, + + localOnly: { + validator: $.optional.bool, + default: false, + }, + + noExtractMentions: { + validator: $.optional.bool, + default: false, + }, + + noExtractHashtags: { + validator: $.optional.bool, + default: false, + }, + + noExtractEmojis: { + validator: $.optional.bool, + default: false, + }, + + fileIds: { + validator: $.optional.arr($.type(ID)).unique().range(1, 4), + }, + + mediaIds: { + validator: $.optional.arr($.type(ID)).unique().range(1, 4), + deprecated: true, + }, + + replyId: { + validator: $.optional.nullable.type(ID), + }, + + renoteId: { + validator: $.optional.nullable.type(ID), + }, + + channelId: { + validator: $.optional.nullable.type(ID), + }, + + poll: { + validator: $.optional.nullable.obj({ + choices: $.arr($.str) + .unique() + .range(2, 10) + .each(c => c.length > 0 && c.length < 50), + multiple: $.optional.bool, + expiresAt: $.optional.nullable.num.int(), + expiredAfter: $.optional.nullable.num.int().min(1) + }).strict(), + ref: 'poll' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + createdNote: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + } + }, + + errors: { + noSuchRenoteTarget: { + message: 'No such renote target.', + code: 'NO_SUCH_RENOTE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4' + }, + + cannotReRenote: { + message: 'You can not Renote a pure Renote.', + code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE', + id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a' + }, + + noSuchReplyTarget: { + message: 'No such reply target.', + code: 'NO_SUCH_REPLY_TARGET', + id: '749ee0f6-d3da-459a-bf02-282e2da4292c' + }, + + cannotReplyToPureRenote: { + message: 'You can not reply to a pure Renote.', + code: 'CANNOT_REPLY_TO_A_PURE_RENOTE', + id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15' + }, + + contentRequired: { + message: 'Content required. You need to set text, fileIds, renoteId or poll.', + code: 'CONTENT_REQUIRED', + id: '6f57e42b-c348-439b-bc45-993995cc515a' + }, + + cannotCreateAlreadyExpiredPoll: { + message: 'Poll is already expired.', + code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL', + id: '04da457d-b083-4055-9082-955525eda5a5' + }, + + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb' + }, + + youHaveBeenBlocked: { + message: 'You have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3' + }, + } +}; + +export default define(meta, async (ps, user) => { + let visibleUsers: User[] = []; + if (ps.visibleUserIds) { + visibleUsers = (await Promise.all(ps.visibleUserIds.map(id => Users.findOne(id)))) + .filter(x => x != null) as User[]; + } + + let files: DriveFile[] = []; + const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null; + if (fileIds != null) { + files = (await Promise.all(fileIds.map(fileId => + DriveFiles.findOne({ + id: fileId, + userId: user.id + }) + ))).filter(file => file != null) as DriveFile[]; + } + + let renote: Note | undefined; + if (ps.renoteId != null) { + // Fetch renote to note + renote = await Notes.findOne(ps.renoteId); + + if (renote == null) { + throw new ApiError(meta.errors.noSuchRenoteTarget); + } else if (renote.renoteId && !renote.text && !renote.fileIds) { + throw new ApiError(meta.errors.cannotReRenote); + } + + // Check blocking + if (renote.userId !== user.id) { + const block = await Blockings.findOne({ + blockerId: renote.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + let reply: Note | undefined; + if (ps.replyId != null) { + // Fetch reply + reply = await Notes.findOne(ps.replyId); + + if (reply == null) { + throw new ApiError(meta.errors.noSuchReplyTarget); + } + + // 返信対象が引用でないRenoteだったらエラー + if (reply.renoteId && !reply.text && !reply.fileIds) { + throw new ApiError(meta.errors.cannotReplyToPureRenote); + } + + // Check blocking + if (reply.userId !== user.id) { + const block = await Blockings.findOne({ + blockerId: reply.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + } + + if (ps.poll) { + if (typeof ps.poll.expiresAt === 'number') { + if (ps.poll.expiresAt < Date.now()) + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } else if (typeof ps.poll.expiredAfter === 'number') { + ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; + } + } + + // テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー + if (!(ps.text || files.length || renote || ps.poll)) { + throw new ApiError(meta.errors.contentRequired); + } + + let channel: Channel | undefined; + if (ps.channelId != null) { + channel = await Channels.findOne(ps.channelId); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + } + + // 投稿を作成 + const note = await create(user, { + createdAt: new Date(), + files: files, + poll: ps.poll ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple || false, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null + } : undefined, + text: ps.text || undefined, + reply, + renote, + cw: ps.cw, + viaMobile: ps.viaMobile, + localOnly: ps.localOnly, + visibility: ps.visibility, + visibleUsers, + channel, + apMentions: ps.noExtractMentions ? [] : undefined, + apHashtags: ps.noExtractHashtags ? [] : undefined, + apEmojis: ps.noExtractEmojis ? [] : undefined, + }); + + return { + createdNote: await Notes.pack(note, user) + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/delete.ts b/packages/backend/src/server/api/endpoints/notes/delete.ts new file mode 100644 index 0000000000..7163a2b9d2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/delete.ts @@ -0,0 +1,56 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import deleteNote from '@/services/note/delete'; +import define from '../../define'; +import * as ms from 'ms'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:notes', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec') + }, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '490be23f-8c1f-4796-819f-94cb4f9d1630' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: 'fe8d7103-0ea8-4ec3-814d-f8b401dc69e9' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + if (!user.isAdmin && !user.isModerator && (note.userId !== user.id)) { + throw new ApiError(meta.errors.accessDenied); + } + + // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため + await deleteNote(await Users.findOneOrFail(note.userId), note); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts new file mode 100644 index 0000000000..1bb25edd7f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { NoteFavorites } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['notes', 'favorites'], + + requireCredential: true as const, + + kind: 'write:favorites', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '6dd26674-e060-4816-909a-45ba3f4da458' + }, + + alreadyFavorited: { + message: 'The note has already been marked as a favorite.', + code: 'ALREADY_FAVORITED', + id: 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Get favoritee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + // if already favorited + const exist = await NoteFavorites.findOne({ + noteId: note.id, + userId: user.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + // Create favorite + await NoteFavorites.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts new file mode 100644 index 0000000000..75eb9a359a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { NoteFavorites } from '@/models/index'; + +export const meta = { + tags: ['notes', 'favorites'], + + requireCredential: true as const, + + kind: 'write:favorites', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '80848a2c-398f-4343-baa9-df1d57696c56' + }, + + notFavorited: { + message: 'You have not marked that note a favorite.', + code: 'NOT_FAVORITED', + id: 'b625fc69-635e-45e9-86f4-dbefbef35af5' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Get favoritee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + // if already favorited + const exist = await NoteFavorites.findOne({ + noteId: note.id, + userId: user.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + // Delete favorite + await NoteFavorites.delete(exist.id); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts new file mode 100644 index 0000000000..8d33c0e73d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -0,0 +1,64 @@ +import $ from 'cafy'; +import define from '../../define'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { Notes } from '@/models/index'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const max = 30; + const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで + + const query = Notes.createQueryBuilder('note') + .addSelect('note.score') + .where('note.userHost IS NULL') + .andWhere(`note.score > 0`) + .andWhere(`note.createdAt > :date`, { date: new Date(Date.now() - day) }) + .andWhere(`note.visibility = 'public'`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + let notes = await query + .orderBy('note.score', 'DESC') + .take(max) + .getMany(); + + notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + notes = notes.slice(ps.offset, ps.offset + ps.limit); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts new file mode 100644 index 0000000000..5902c0415c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -0,0 +1,101 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { ApiError } from '../../error'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notes } from '@/models/index'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + params: { + withFiles: { + validator: $.optional.bool, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num + }, + + untilDate: { + validator: $.optional.num + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + gtlDisabled: { + message: 'Global timeline has been disabled.', + code: 'GTL_DISABLED', + id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b' + }, + } +}; + +export default define(meta, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableGlobalTimeline) { + if (user == null || (!user.isAdmin && !user.isModerator)) { + throw new ApiError(meta.errors.gtlDisabled); + } + } + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.visibility = \'public\'') + .andWhere('note.channelId IS NULL') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateRepliesQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateMutedNoteQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.update(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts new file mode 100644 index 0000000000..47f08f208b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -0,0 +1,158 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { ApiError } from '../../error'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Followings, Notes } from '@/models/index'; +import { Brackets } from 'typeorm'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; +import { generateChannelQuery } from '../../common/generate-channel-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + + includeMyRenotes: { + validator: $.optional.bool, + default: true, + }, + + includeRenotedMyNotes: { + validator: $.optional.bool, + default: true, + }, + + includeLocalRenotes: { + validator: $.optional.bool, + default: true, + }, + + withFiles: { + validator: $.optional.bool, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + stlDisabled: { + message: 'Hybrid timeline has been disabled.', + code: 'STL_DISABLED', + id: '620763f4-f621-4533-ab33-0577a1a3c342' + }, + } +}; + +export default define(meta, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableLocalTimeline && !user.isAdmin && !user.isModerator) { + throw new ApiError(meta.errors.stlDisabled); + } + + //#region Construct query + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(new Brackets(qb => { + qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id }) + .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(followingQuery.getParameters()); + + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.update(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts new file mode 100644 index 0000000000..f670d478bf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -0,0 +1,129 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { ApiError } from '../../error'; +import { Notes } from '@/models/index'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { Brackets } from 'typeorm'; +import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; +import { generateChannelQuery } from '../../common/generate-channel-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + params: { + withFiles: { + validator: $.optional.bool, + }, + + fileType: { + validator: $.optional.arr($.str), + }, + + excludeNsfw: { + validator: $.optional.bool, + default: false, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + ltlDisabled: { + message: 'Local timeline has been disabled.', + code: 'LTL_DISABLED', + id: '45a6eb02-7695-4393-b023-dd3be9aaaefd' + }, + } +}; + +export default define(meta, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableLocalTimeline) { + if (user == null || (!user.isAdmin && !user.isModerator)) { + throw new ApiError(meta.errors.ltlDisabled); + } + } + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateMutedNoteQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.update(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts new file mode 100644 index 0000000000..ffaebd6c95 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -0,0 +1,88 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import read from '@/services/note/read'; +import { Notes, Followings } from '@/models/index'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Brackets } from 'typeorm'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; +import { generateMutedNoteThreadQuery } from '../../common/generate-muted-note-thread-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + following: { + validator: $.optional.bool, + default: false + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + visibility: { + validator: $.optional.str, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(`'{"${user.id}"}' <@ note.mentions`) + .orWhere(`'{"${user.id}"}' <@ note.visibleUserIds`); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteThreadQuery(query, user); + generateBlockedUserQuery(query, user); + + if (ps.visibility) { + query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); + } + + if (ps.following) { + query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: user.id }); + query.setParameters(followingQuery.getParameters()); + } + + const mentions = await query.take(ps.limit!).getMany(); + + read(user.id, mentions); + + return await Notes.packMany(mentions, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts new file mode 100644 index 0000000000..0763f0c8fd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -0,0 +1,77 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { Polls, Mutings, Notes, PollVotes } from '@/models/index'; +import { Brackets, In } from 'typeorm'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note' + } + } +}; + +export default define(meta, async (ps, user) => { + const query = Polls.createQueryBuilder('poll') + .where('poll.userHost IS NULL') + .andWhere(`poll.userId != :meId`, { meId: user.id }) + .andWhere(`poll.noteVisibility = 'public'`) + .andWhere(new Brackets(qb => { qb + .where('poll.expiresAt IS NULL') + .orWhere('poll.expiresAt > :now', { now: new Date() }); + })); + + //#region exclude arleady voted polls + const votedQuery = PollVotes.createQueryBuilder('vote') + .select('vote.noteId') + .where('vote.userId = :meId', { meId: user.id }); + + query + .andWhere(`poll.noteId NOT IN (${ votedQuery.getQuery() })`); + + query.setParameters(votedQuery.getParameters()); + //#endregion + + //#region mute + const mutingQuery = Mutings.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: user.id }); + + query + .andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`); + + query.setParameters(mutingQuery.getParameters()); + //#endregion + + const polls = await query.take(ps.limit!).skip(ps.offset).getMany(); + + if (polls.length === 0) return []; + + const notes = await Notes.find({ + id: In(polls.map(poll => poll.noteId)) + }); + + return await Notes.packMany(notes, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts new file mode 100644 index 0000000000..f670501385 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -0,0 +1,170 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishNoteStream } from '@/services/stream'; +import { createNotification } from '@/services/create-notification'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getNote } from '../../../common/getters'; +import { deliver } from '@/queue/index'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderVote from '@/remote/activitypub/renderer/vote'; +import { deliverQuestionUpdate } from '@/services/note/polls/update'; +import { PollVotes, NoteWatchings, Users, Polls, Blockings } from '@/models/index'; +import { Not } from 'typeorm'; +import { IRemoteUser } from '@/models/entities/user'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:votes', + + params: { + noteId: { + validator: $.type(ID), + }, + + choice: { + validator: $.num + }, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'ecafbd2e-c283-4d6d-aecb-1a0a33b75396' + }, + + noPoll: { + message: 'The note does not attach a poll.', + code: 'NO_POLL', + id: '5f979967-52d9-4314-a911-1c673727f92f' + }, + + invalidChoice: { + message: 'Choice ID is invalid.', + code: 'INVALID_CHOICE', + id: 'e0cc9a04-f2e8-41e4-a5f1-4127293260cc' + }, + + alreadyVoted: { + message: 'You have already voted.', + code: 'ALREADY_VOTED', + id: '0963fc77-efac-419b-9424-b391608dc6d8' + }, + + alreadyExpired: { + message: 'The poll is already expired.', + code: 'ALREADY_EXPIRED', + id: '1022a357-b085-4054-9083-8f8de358337e' + }, + + youHaveBeenBlocked: { + message: 'You cannot vote this poll because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: '85a5377e-b1e9-4617-b0b9-5bea73331e49' + }, + } +}; + +export default define(meta, async (ps, user) => { + const createdAt = new Date(); + + // Get votee + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + if (!note.hasPoll) { + throw new ApiError(meta.errors.noPoll); + } + + // Check blocking + if (note.userId !== user.id) { + const block = await Blockings.findOne({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + const poll = await Polls.findOneOrFail({ noteId: note.id }); + + if (poll.expiresAt && poll.expiresAt < createdAt) { + throw new ApiError(meta.errors.alreadyExpired); + } + + if (poll.choices[ps.choice] == null) { + throw new ApiError(meta.errors.invalidChoice); + } + + // if already voted + const exist = await PollVotes.find({ + noteId: note.id, + userId: user.id + }); + + if (exist.length) { + if (poll.multiple) { + if (exist.some(x => x.choice == ps.choice)) + throw new ApiError(meta.errors.alreadyVoted); + } else { + throw new ApiError(meta.errors.alreadyVoted); + } + } + + // Create vote + const vote = await PollVotes.insert({ + id: genId(), + createdAt, + noteId: note.id, + userId: user.id, + choice: ps.choice + }).then(x => PollVotes.findOneOrFail(x.identifiers[0])); + + // Increment votes count + const index = ps.choice + 1; // In SQL, array index is 1 based + await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); + + publishNoteStream(note.id, 'pollVoted', { + choice: ps.choice, + userId: user.id + }); + + // Notify + createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: ps.choice + }); + + // Fetch watchers + NoteWatchings.find({ + noteId: note.id, + userId: Not(user.id), + }).then(watchers => { + for (const watcher of watchers) { + createNotification(watcher.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: ps.choice + }); + } + }); + + // リモート投票の場合リプライ送信 + if (note.userHost != null) { + const pollOwner = await Users.findOneOrFail(note.userId) as IRemoteUser; + + deliver(user, renderActivity(await renderVote(user, vote, note, poll, pollOwner)), pollOwner.inbox); + } + + // リモートフォロワーにUpdate配信 + deliverQuestionUpdate(note.id); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts new file mode 100644 index 0000000000..09dd6b600b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -0,0 +1,90 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { NoteReactions } from '@/models/index'; +import { DeepPartial } from 'typeorm'; +import { NoteReaction } from '@/models/entities/note-reaction'; + +export const meta = { + tags: ['notes', 'reactions'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + type: { + validator: $.optional.nullable.str, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num, + default: 0 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'NoteReaction', + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '263fff3d-d0e1-4af4-bea7-8408059b451a' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const query = { + noteId: note.id + } as DeepPartial<NoteReaction>; + + if (ps.type) { + // ローカルリアクションはホスト名が . とされているが + // DB 上ではそうではないので、必要に応じて変換 + const suffix = '@.:'; + const type = ps.type.endsWith(suffix) ? ps.type.slice(0, ps.type.length - suffix.length) + ':' : ps.type; + query.reaction = type; + } + + const reactions = await NoteReactions.find({ + where: query, + take: ps.limit!, + skip: ps.offset, + order: { + id: -1 + } + }); + + return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, user))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts new file mode 100644 index 0000000000..24a73a8d4f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -0,0 +1,57 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import createReaction from '@/services/note/reaction/create'; +import define from '../../../define'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['reactions', 'notes'], + + requireCredential: true as const, + + kind: 'write:reactions', + + params: { + noteId: { + validator: $.type(ID), + }, + + reaction: { + validator: $.str, + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '033d0620-5bfe-4027-965d-980b0c85a3ea' + }, + + alreadyReacted: { + message: 'You are already reacting to that note.', + code: 'ALREADY_REACTED', + id: '71efcf98-86d6-4e2b-b2ad-9d032369366b' + }, + + youHaveBeenBlocked: { + message: 'You cannot react this note because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: '20ef5475-9f38-4e4c-bd33-de6d979498ec' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + await createReaction(user, note, ps.reaction).catch(e => { + if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted); + if (e.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked); + throw e; + }); + return; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts new file mode 100644 index 0000000000..69550f96de --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts @@ -0,0 +1,52 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import * as ms from 'ms'; +import deleteReaction from '@/services/note/reaction/delete'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['reactions', 'notes'], + + requireCredential: true as const, + + kind: 'write:reactions', + + limit: { + duration: ms('1hour'), + max: 60, + minInterval: ms('3sec') + }, + + params: { + noteId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '764d9fce-f9f2-4a0e-92b1-6ceac9a7ad37' + }, + + notReacted: { + message: 'You are not reacting to that note.', + code: 'NOT_REACTED', + id: '92f4426d-4196-4125-aa5b-02943e2ec8fc' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + await deleteReaction(user, note).catch(e => { + if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') throw new ApiError(meta.errors.notReacted); + throw e; + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts new file mode 100644 index 0000000000..26bfc1657d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -0,0 +1,76 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notes } from '@/models/index'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '12908022-2e21-46cd-ba6a-3edaf6093f46' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.renoteId = :renoteId`, { renoteId: note.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + const renotes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(renotes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts new file mode 100644 index 0000000000..0bb62413ae --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -0,0 +1,61 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Notes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + const timeline = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts new file mode 100644 index 0000000000..40e1499736 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -0,0 +1,134 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notes } from '@/models/index'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { Brackets } from 'typeorm'; +import { safeForSql } from '@/misc/safe-for-sql'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes', 'hashtags'], + + params: { + tag: { + validator: $.optional.str, + }, + + query: { + validator: $.optional.arr($.arr($.str)), + }, + + reply: { + validator: $.optional.nullable.bool, + default: null, + }, + + renote: { + validator: $.optional.nullable.bool, + default: null, + }, + + withFiles: { + validator: $.optional.bool, + }, + + poll: { + validator: $.optional.nullable.bool, + default: null, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me); + if (me) generateBlockedUserQuery(query, me); + + try { + if (ps.tag) { + if (!safeForSql(ps.tag)) throw 'Injection'; + query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`); + } else { + query.andWhere(new Brackets(qb => { + for (const tags of ps.query!) { + qb.orWhere(new Brackets(qb => { + for (const tag of tags) { + if (!safeForSql(tag)) throw 'Injection'; + qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`); + } + })); + } + })); + } + } catch (e) { + if (e === 'Injection') return []; + throw e; + } + + if (ps.reply != null) { + if (ps.reply) { + query.andWhere('note.replyId IS NOT NULL'); + } else { + query.andWhere('note.replyId IS NULL'); + } + } + + if (ps.renote != null) { + if (ps.renote) { + query.andWhere('note.renoteId IS NOT NULL'); + } else { + query.andWhere('note.renoteId IS NULL'); + } + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.poll != null) { + if (ps.poll) { + query.andWhere('note.hasPoll = TRUE'); + } else { + query.andWhere('note.hasPoll = FALSE'); + } + } + + // Search notes + const notes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(notes, me); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts new file mode 100644 index 0000000000..eb832a6b31 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -0,0 +1,152 @@ +import $ from 'cafy'; +import es from '../../../../db/elasticsearch'; +import define from '../../define'; +import { Notes } from '@/models/index'; +import { In } from 'typeorm'; +import { ID } from '@/misc/cafy-id'; +import config from '@/config/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + query: { + validator: $.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + host: { + validator: $.optional.nullable.str, + default: undefined + }, + + userId: { + validator: $.optional.nullable.type(ID), + default: null + }, + + channelId: { + validator: $.optional.nullable.type(ID), + default: null + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + } +}; + +export default define(meta, async (ps, me) => { + if (es == null) { + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId); + + if (ps.userId) { + query.andWhere('note.userId = :userId', { userId: ps.userId }); + } else if (ps.channelId) { + query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); + } + + query + .andWhere('note.text ILIKE :q', { q: `%${ps.query}%` }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me); + if (me) generateBlockedUserQuery(query, me); + + const notes = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(notes, me); + } else { + const userQuery = ps.userId != null ? [{ + term: { + userId: ps.userId + } + }] : []; + + const hostQuery = ps.userId == null ? + ps.host === null ? [{ + bool: { + must_not: { + exists: { + field: 'userHost' + } + } + } + }] : ps.host !== undefined ? [{ + term: { + userHost: ps.host + } + }] : [] + : []; + + const result = await es.search({ + index: config.elasticsearch.index || 'misskey_note', + body: { + size: ps.limit!, + from: ps.offset, + query: { + bool: { + must: [{ + simple_query_string: { + fields: ['text'], + query: ps.query.toLowerCase(), + default_operator: 'and' + }, + }, ...hostQuery, ...userQuery] + } + }, + sort: [{ + _doc: 'desc' + }] + } + }); + + const hits = result.body.hits.hits.map((hit: any) => hit._id); + + if (hits.length === 0) return []; + + // Fetch found notes + const notes = await Notes.find({ + where: { + id: In(hits) + }, + order: { + id: -1 + } + }); + + return await Notes.packMany(notes, me); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts new file mode 100644 index 0000000000..fad63d6483 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -0,0 +1,43 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { Notes } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + return await Notes.pack(note, user, { + detail: true + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts new file mode 100644 index 0000000000..b3913a5e79 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -0,0 +1,69 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { NoteFavorites, Notes, NoteThreadMutings, NoteWatchings } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + isFavorited: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isWatching: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isMutedThread: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await Notes.findOneOrFail(ps.noteId); + + const [favorite, watching, threadMuting] = await Promise.all([ + NoteFavorites.count({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1 + }), + NoteWatchings.count({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1 + }), + NoteThreadMutings.count({ + where: { + userId: user.id, + threadId: note.threadId || note.id, + }, + take: 1 + }), + ]); + + return { + isFavorited: favorite !== 0, + isWatching: watching !== 0, + isMutedThread: threadMuting !== 0, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts new file mode 100644 index 0000000000..2010d54331 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; +import { Notes, NoteThreadMutings } from '@/models'; +import { genId } from '@/misc/gen-id'; +import readNote from '@/services/note/read'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '5ff67ada-ed3b-2e71-8e87-a1a421e177d2' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const mutedNotes = await Notes.find({ + where: [{ + id: note.threadId || note.id, + }, { + threadId: note.threadId || note.id, + }], + }); + + await readNote(user.id, mutedNotes); + + await NoteThreadMutings.insert({ + id: genId(), + createdAt: new Date(), + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts new file mode 100644 index 0000000000..05d5691870 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; +import { NoteThreadMutings } from '@/models'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'bddd57ac-ceb3-b29d-4334-86ea5fae481a' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await NoteThreadMutings.delete({ + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts new file mode 100644 index 0000000000..1bd0e57d34 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -0,0 +1,150 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { Notes, Followings } from '@/models/index'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { Brackets } from 'typeorm'; +import { generateRepliesQuery } from '../../common/generate-replies-query'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; +import { generateChannelQuery } from '../../common/generate-channel-query'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + + includeMyRenotes: { + validator: $.optional.bool, + default: true, + }, + + includeRenotedMyNotes: { + validator: $.optional.bool, + default: true, + }, + + includeLocalRenotes: { + validator: $.optional.bool, + default: true, + }, + + withFiles: { + validator: $.optional.bool, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, +}; + +export default define(meta, async (ps, user) => { + const hasFollowing = (await Followings.count({ + where: { + followerId: user.id, + }, + take: 1 + })) !== 0; + + //#region Construct query + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: user.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(new Brackets(qb => { qb + .where('note.userId = :meId', { meId: user.id }); + if (hasFollowing) qb.orWhere(`note.userId IN (${ followingQuery.getQuery() })`); + })) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(followingQuery.getParameters()); + + generateChannelQuery(query, user); + generateRepliesQuery(query, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + process.nextTick(() => { + if (user) { + activeUsersChart.update(user); + } + }); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts new file mode 100644 index 0000000000..b56b1debdd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -0,0 +1,89 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import fetch from 'node-fetch'; +import config from '@/config/index'; +import { getAgentByUrl } from '@/misc/fetch'; +import { URLSearchParams } from 'url'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models'; + +export const meta = { + tags: ['notes'], + + requireCredential: false as const, + + params: { + noteId: { + validator: $.type(ID), + }, + targetLang: { + validator: $.str, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'bea9b03f-36e0-49c5-a4db-627a029f8971' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + if (!(await Notes.isVisibleForMe(note, user ? user.id : null))) { + return 204; // TODO: 良い感じのエラー返す + } + + if (note.text == null) { + return 204; + } + + const instance = await fetchMeta(); + + if (instance.deeplAuthKey == null) { + return 204; // TODO: 良い感じのエラー返す + } + + let targetLang = ps.targetLang; + if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; + + const params = new URLSearchParams(); + params.append('auth_key', instance.deeplAuthKey); + params.append('text', note.text); + params.append('target_lang', targetLang); + + const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': config.userAgent, + Accept: 'application/json, */*' + }, + body: params, + timeout: 10000, + agent: getAgentByUrl, + }); + + const json = await res.json(); + + return { + sourceLang: json.translations[0].detected_source_language, + text: json.translations[0].text + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts new file mode 100644 index 0000000000..dce43d9d9c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -0,0 +1,52 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import deleteNote from '@/services/note/delete'; +import define from '../../define'; +import * as ms from 'ms'; +import { getNote } from '../../common/getters'; +import { ApiError } from '../../error'; +import { Notes, Users } from '@/models/index'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:notes', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec') + }, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'efd4a259-2442-496b-8dd7-b255aa1a160f' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const renotes = await Notes.find({ + userId: user.id, + renoteId: note.id + }); + + for (const note of renotes) { + deleteNote(await Users.findOneOrFail(user.id), note); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts new file mode 100644 index 0000000000..32c370004c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -0,0 +1,147 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { UserLists, UserListJoinings, Notes } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { activeUsersChart } from '@/services/chart/index'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['notes', 'lists'], + + requireCredential: true as const, + + params: { + listId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + + includeMyRenotes: { + validator: $.optional.bool, + default: true, + }, + + includeRenotedMyNotes: { + validator: $.optional.bool, + default: true, + }, + + includeLocalRenotes: { + validator: $.optional.bool, + default: true, + }, + + withFiles: { + validator: $.optional.bool, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '8fb1fbd5-e476-4c37-9fb0-43d55b63a2ff' + } + } +}; + +export default define(meta, async (ps, user) => { + const list = await UserLists.findOne({ + id: ps.listId, + userId: user.id + }); + + if (list == null) { + throw new ApiError(meta.errors.noSuchList); + } + + //#region Construct query + const listQuery = UserListJoinings.createQueryBuilder('joining') + .select('joining.userId') + .where('joining.userListId = :userListId', { userListId: list.id }); + + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + .andWhere(`note.userId IN (${ listQuery.getQuery() })`) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .setParameters(listQuery.getParameters()); + + generateVisibilityQuery(query, user); + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserId != :meId', { meId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteUserHost IS NOT NULL'); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + activeUsersChart.update(user); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/watching/create.ts b/packages/backend/src/server/api/endpoints/notes/watching/create.ts new file mode 100644 index 0000000000..4d182d3715 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/watching/create.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import watch from '@/services/note/watch'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'ea0e37a6-90a3-4f58-ba6b-c328ca206fc7' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await watch(user.id, note); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/watching/delete.ts b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts new file mode 100644 index 0000000000..dd58c52b57 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import unwatch from '@/services/note/unwatch'; +import { getNote } from '../../../common/getters'; +import { ApiError } from '../../../error'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: '09b3695c-f72c-4731-a428-7cff825fc82e' + } + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await unwatch(user.id, note); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts new file mode 100644 index 0000000000..8003c497ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -0,0 +1,37 @@ +import $ from 'cafy'; +import define from '../../define'; +import { createNotification } from '@/services/create-notification'; + +export const meta = { + tags: ['notifications'], + + requireCredential: true as const, + + kind: 'write:notifications', + + params: { + body: { + validator: $.str + }, + + header: { + validator: $.optional.nullable.str + }, + + icon: { + validator: $.optional.nullable.str + }, + }, + + errors: { + } +}; + +export default define(meta, async (ps, user, token) => { + createNotification(user.id, 'app', { + appAccessTokenId: token ? token.id : null, + customBody: ps.body, + customHeader: ps.header, + customIcon: ps.icon, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts new file mode 100644 index 0000000000..8d4e512750 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -0,0 +1,24 @@ +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { Notifications } from '@/models/index'; + +export const meta = { + tags: ['notifications', 'account'], + + requireCredential: true as const, + + kind: 'write:notifications' +}; + +export default define(meta, async (ps, user) => { + // Update documents + await Notifications.update({ + notifieeId: user.id, + isRead: false, + }, { + isRead: true + }); + + // 全ての通知を読みましたよというイベントを発行 + publishMainStream(user.id, 'readAllNotifications'); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts new file mode 100644 index 0000000000..66bbc4efd7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/read.ts @@ -0,0 +1,42 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { Notifications } from '@/models/index'; +import { readNotification } from '../../common/read-notification'; +import { ApiError } from '../../error'; + +export const meta = { + tags: ['notifications', 'account'], + + requireCredential: true as const, + + kind: 'write:notifications', + + params: { + notificationId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchNotification: { + message: 'No such notification.', + code: 'NO_SUCH_NOTIFICATION', + id: 'efa929d5-05b5-47d1-beec-e6a4dbed011e' + }, + }, +}; + +export default define(meta, async (ps, user) => { + const notification = await Notifications.findOne({ + notifieeId: user.id, + id: ps.notificationId, + }); + + if (notification == null) { + throw new ApiError(meta.errors.noSuchNotification); + } + + readNotification(user.id, [notification.id]); +}); diff --git a/packages/backend/src/server/api/endpoints/page-push.ts b/packages/backend/src/server/api/endpoints/page-push.ts new file mode 100644 index 0000000000..a0412e89f1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/page-push.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import define from '../define'; +import { ID } from '@/misc/cafy-id'; +import { publishMainStream } from '@/services/stream'; +import { Users, Pages } from '@/models/index'; +import { ApiError } from '../error'; + +export const meta = { + requireCredential: true as const, + secure: true, + + params: { + pageId: { + validator: $.type(ID) + }, + + event: { + validator: $.str + }, + + var: { + validator: $.optional.nullable.any + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '4a13ad31-6729-46b4-b9af-e86b265c2e74' + } + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + publishMainStream(page.userId, 'pageEvent', { + pageId: ps.pageId, + event: ps.event, + var: ps.var, + userId: user.id, + user: await Users.pack(user.id, { id: page.userId }, { + detail: true + }) + }); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts new file mode 100644 index 0000000000..c23978f093 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -0,0 +1,128 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../define'; +import { ID } from '@/misc/cafy-id'; +import { Pages, DriveFiles } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { Page } from '@/models/entities/page'; +import { ApiError } from '../../error'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:pages', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + title: { + validator: $.str, + }, + + name: { + validator: $.str.min(1), + }, + + summary: { + validator: $.optional.nullable.str, + }, + + content: { + validator: $.arr($.obj()) + }, + + variables: { + validator: $.arr($.obj()) + }, + + script: { + validator: $.str, + }, + + eyeCatchingImageId: { + validator: $.optional.nullable.type(ID), + }, + + font: { + validator: $.optional.str.or(['serif', 'sans-serif']), + default: 'sans-serif' + }, + + alignCenter: { + validator: $.optional.bool, + default: false + }, + + hideTitleWhenPinned: { + validator: $.optional.bool, + default: false + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page', + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b7b97489-0f66-4b12-a5ff-b21bd63f6e1c' + }, + nameAlreadyExists: { + message: 'Specified name already exists.', + code: 'NAME_ALREADY_EXISTS', + id: '4650348e-301c-499a-83c9-6aa988c66bc1' + } + } +}; + +export default define(meta, async (ps, user) => { + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOne({ + id: ps.eyeCatchingImageId, + userId: user.id + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await Pages.find({ + userId: user.id, + name: ps.name + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + const page = await Pages.save(new Page({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + name: ps.name, + summary: ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, + userId: user.id, + visibility: 'public', + alignCenter: ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned, + font: ps.font + })); + + return await Pages.pack(page); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts new file mode 100644 index 0000000000..b1f8c8a709 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:pages', + + params: { + pageId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'eb0c6e1d-d519-4764-9486-52a7e1c6392a' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '8b741b3e-2c22-44b3-a15f-29949aa1601e' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await Pages.delete(page.id); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/featured.ts b/packages/backend/src/server/api/endpoints/pages/featured.ts new file mode 100644 index 0000000000..f891c45f05 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/featured.ts @@ -0,0 +1,29 @@ +import define from '../../define'; +import { Pages } from '@/models/index'; + +export const meta = { + tags: ['pages'], + + requireCredential: false as const, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Pages.createQueryBuilder('page') + .where('page.visibility = \'public\'') + .andWhere('page.likedCount > 0') + .orderBy('page.likedCount', 'DESC'); + + const pages = await query.take(10).getMany(); + + return await Pages.packMany(pages, me); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts new file mode 100644 index 0000000000..a95a377802 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -0,0 +1,71 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, PageLikes } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:page-likes', + + params: { + pageId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'cc98a8a2-0dc3-4123-b198-62c71df18ed3' + }, + + yourPage: { + message: 'You cannot like your page.', + code: 'YOUR_PAGE', + id: '28800466-e6db-40f2-8fae-bf9e82aa92b8' + }, + + alreadyLiked: { + message: 'The page has already been liked.', + code: 'ALREADY_LIKED', + id: 'cc98a8a2-0dc3-4123-b198-62c71df18ed3' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + if (page.userId === user.id) { + throw new ApiError(meta.errors.yourPage); + } + + // if already liked + const exist = await PageLikes.findOne({ + pageId: page.id, + userId: user.id + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await PageLikes.insert({ + id: genId(), + createdAt: new Date(), + pageId: page.id, + userId: user.id + }); + + Pages.increment({ id: page.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts new file mode 100644 index 0000000000..7c55d4a9e6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/show.ts @@ -0,0 +1,65 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, Users } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { Page } from '@/models/entities/page'; + +export const meta = { + tags: ['pages'], + + requireCredential: false as const, + + params: { + pageId: { + validator: $.optional.type(ID), + }, + + name: { + validator: $.optional.str, + }, + + username: { + validator: $.optional.str, + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Page', + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '222120c0-3ead-4528-811b-b96f233388d7' + } + } +}; + +export default define(meta, async (ps, user) => { + let page: Page | undefined; + + if (ps.pageId) { + page = await Pages.findOne(ps.pageId); + } else if (ps.name && ps.username) { + const author = await Users.findOne({ + host: null, + usernameLower: ps.username.toLowerCase() + }); + if (author) { + page = await Pages.findOne({ + name: ps.name, + userId: author.id + }); + } + } + + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + return await Pages.pack(page, user); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts new file mode 100644 index 0000000000..facf2d6d5f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, PageLikes } from '@/models/index'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:page-likes', + + params: { + pageId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: 'a0d41e20-1993-40bd-890e-f6e560ae648e' + }, + + notLiked: { + message: 'You have not liked that page.', + code: 'NOT_LIKED', + id: 'f5e586b0-ce93-4050-b0e3-7f31af5259ee' + }, + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + const exist = await PageLikes.findOne({ + pageId: page.id, + userId: user.id + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await PageLikes.delete(exist.id); + + Pages.decrement({ id: page.id }, 'likedCount', 1); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts new file mode 100644 index 0000000000..b3a7f26963 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -0,0 +1,141 @@ +import $ from 'cafy'; +import * as ms from 'ms'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Pages, DriveFiles } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { Not } from 'typeorm'; + +export const meta = { + tags: ['pages'], + + requireCredential: true as const, + + kind: 'write:pages', + + limit: { + duration: ms('1hour'), + max: 300 + }, + + params: { + pageId: { + validator: $.type(ID), + }, + + title: { + validator: $.str, + }, + + name: { + validator: $.str.min(1), + }, + + summary: { + validator: $.optional.nullable.str, + }, + + content: { + validator: $.arr($.obj()) + }, + + variables: { + validator: $.arr($.obj()) + }, + + script: { + validator: $.str, + }, + + eyeCatchingImageId: { + validator: $.optional.nullable.type(ID), + }, + + font: { + validator: $.optional.str.or(['serif', 'sans-serif']), + }, + + alignCenter: { + validator: $.optional.bool, + }, + + hideTitleWhenPinned: { + validator: $.optional.bool, + }, + }, + + errors: { + noSuchPage: { + message: 'No such page.', + code: 'NO_SUCH_PAGE', + id: '21149b9e-3616-4778-9592-c4ce89f5a864' + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '3c15cd52-3b4b-4274-967d-6456fc4f792b' + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'cfc23c7c-3887-490e-af30-0ed576703c82' + }, + nameAlreadyExists: { + message: 'Specified name already exists.', + code: 'NAME_ALREADY_EXISTS', + id: '2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab' + } + } +}; + +export default define(meta, async (ps, user) => { + const page = await Pages.findOne(ps.pageId); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOne({ + id: ps.eyeCatchingImageId, + userId: user.id + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await Pages.find({ + id: Not(ps.pageId), + userId: user.id, + name: ps.name + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + await Pages.update(page.id, { + updatedAt: new Date(), + title: ps.title, + name: ps.name === undefined ? page.name : ps.name, + summary: ps.name === undefined ? page.summary : ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, + font: ps.font === undefined ? page.font : ps.font, + eyeCatchingImageId: ps.eyeCatchingImageId === null + ? null + : ps.eyeCatchingImageId === undefined + ? page.eyeCatchingImageId + : eyeCatchingImage!.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/ping.ts b/packages/backend/src/server/api/endpoints/ping.ts new file mode 100644 index 0000000000..0b1bb6e164 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/ping.ts @@ -0,0 +1,27 @@ +import define from '../define'; + +export const meta = { + requireCredential: false as const, + + tags: ['meta'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + pong: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + } +}; + +export default define(meta, async () => { + return { + pong: Date.now(), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts new file mode 100644 index 0000000000..39cf7b0df1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -0,0 +1,32 @@ +import define from '../define'; +import { Users } from '@/models/index'; +import { fetchMeta } from '@/misc/fetch-meta'; +import * as Acct from 'misskey-js/built/acct'; +import { User } from '@/models/entities/user'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const meta = await fetchMeta(); + + const users = await Promise.all(meta.pinnedUsers.map(acct => Users.findOne(Acct.parse(acct)))); + + return await Users.packMany(users.filter(x => x !== undefined) as User[], me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/promo/read.ts b/packages/backend/src/server/api/endpoints/promo/read.ts new file mode 100644 index 0000000000..ae57bf9cf1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/promo/read.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getNote } from '../../common/getters'; +import { PromoReads } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['notes'], + + requireCredential: true as const, + + params: { + noteId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'd785b897-fcd3-4fe9-8fc3-b85c26e6c932' + }, + } +}; + +export default define(meta, async (ps, user) => { + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + const exist = await PromoReads.findOne({ + noteId: note.id, + userId: user.id + }); + + if (exist != null) { + return; + } + + await PromoReads.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/request-reset-password.ts b/packages/backend/src/server/api/endpoints/request-reset-password.ts new file mode 100644 index 0000000000..f9928c2ee6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/request-reset-password.ts @@ -0,0 +1,73 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../define'; +import rndstr from 'rndstr'; +import config from '@/config/index'; +import * as ms from 'ms'; +import { Users, UserProfiles, PasswordResetRequests } from '@/models/index'; +import { sendEmail } from '@/services/send-email'; +import { ApiError } from '../error'; +import { genId } from '@/misc/gen-id'; +import { IsNull } from 'typeorm'; + +export const meta = { + requireCredential: false as const, + + limit: { + duration: ms('1hour'), + max: 3 + }, + + params: { + username: { + validator: $.str + }, + + email: { + validator: $.str + }, + }, + + errors: { + + } +}; + +export default define(meta, async (ps) => { + const user = await Users.findOne({ + usernameLower: ps.username.toLowerCase(), + host: IsNull() + }); + + // 合致するユーザーが登録されていなかったら無視 + if (user == null) { + return; + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + // 合致するメアドが登録されていなかったら無視 + if (profile.email !== ps.email) { + return; + } + + // メアドが認証されていなかったら無視 + if (!profile.emailVerified) { + return; + } + + const token = rndstr('a-z0-9', 64); + + await PasswordResetRequests.insert({ + id: genId(), + createdAt: new Date(), + userId: profile.userId, + token + }); + + const link = `${config.url}/reset-password/${token}`; + + sendEmail(ps.email, 'Password reset requested', + `To reset password, please click this link:<br><a href="${link}">${link}</a>`, + `To reset password, please click this link: ${link}`); +}); diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts new file mode 100644 index 0000000000..f0a9dae4ff --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -0,0 +1,23 @@ +import $ from 'cafy'; +import define from '../define'; +import { ApiError } from '../error'; +import { resetDb } from '@/db/postgre'; + +export const meta = { + requireCredential: false as const, + + params: { + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test'; + + await resetDb(); + + await new Promise(resolve => setTimeout(resolve, 1000)); +}); diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts new file mode 100644 index 0000000000..53b0bfde0b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import * as bcrypt from 'bcryptjs'; +import { publishMainStream } from '@/services/stream'; +import define from '../define'; +import { Users, UserProfiles, PasswordResetRequests } from '@/models/index'; +import { ApiError } from '../error'; + +export const meta = { + requireCredential: false as const, + + params: { + token: { + validator: $.str + }, + + password: { + validator: $.str + } + }, + + errors: { + + } +}; + +export default define(meta, async (ps, user) => { + const req = await PasswordResetRequests.findOneOrFail({ + token: ps.token, + }); + + // 発行してから30分以上経過していたら無効 + if (Date.now() - req.createdAt.getTime() > 1000 * 60 * 30) { + throw new Error(); // TODO + } + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(ps.password, salt); + + await UserProfiles.update(req.userId, { + password: hash + }); + + PasswordResetRequests.delete(req.id); +}); diff --git a/packages/backend/src/server/api/endpoints/room/show.ts b/packages/backend/src/server/api/endpoints/room/show.ts new file mode 100644 index 0000000000..a6461d4a6e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/room/show.ts @@ -0,0 +1,159 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users, UserProfiles } from '@/models/index'; +import { ID } from '@/misc/cafy-id'; +import { toPunyNullable } from '@/misc/convert-host'; + +export const meta = { + tags: ['room'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.optional.type(ID), + }, + + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '7ad3fa3e-5e12-42f0-b23a-f3d13f10ee4b' + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + roomType: { + type: 'string' as const, + optional: false as const, nullable: false as const, + enum: ['default', 'washitsu'] + }, + furnitures: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + type: { + type: 'string' as const, + optional: false as const, nullable: false as const + }, + props: { + type: 'object' as const, + optional: true as const, nullable: false as const, + }, + position: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + x: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + y: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + z: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + }, + rotation: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + x: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + y: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + z: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } + } + } + }, + carpetColor: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'hex', + example: '#85CAF0' + } + } + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) }); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.room.furnitures == null) { + await UserProfiles.update(user.id, { + room: { + furnitures: [], + ...profile.room + } + }); + + profile.room.furnitures = []; + } + + if (profile.room.roomType == null) { + const initialType = 'default'; + await UserProfiles.update(user.id, { + room: { + roomType: initialType as any, + ...profile.room + } + }); + + profile.room.roomType = initialType; + } + + if (profile.room.carpetColor == null) { + const initialColor = '#85CAF0'; + await UserProfiles.update(user.id, { + room: { + carpetColor: initialColor as any, + ...profile.room + } + }); + + profile.room.carpetColor = initialColor; + } + + return profile.room; +}); diff --git a/packages/backend/src/server/api/endpoints/room/update.ts b/packages/backend/src/server/api/endpoints/room/update.ts new file mode 100644 index 0000000000..8c4cfbdea6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/room/update.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; +import { publishMainStream } from '@/services/stream'; +import define from '../../define'; +import { Users, UserProfiles } from '@/models/index'; + +export const meta = { + tags: ['room'], + + requireCredential: true as const, + + params: { + room: { + validator: $.obj({ + furnitures: $.arr($.obj({ + id: $.str, + type: $.str, + position: $.obj({ + x: $.num, + y: $.num, + z: $.num, + }), + rotation: $.obj({ + x: $.num, + y: $.num, + z: $.num, + }), + props: $.optional.nullable.obj(), + })), + roomType: $.str, + carpetColor: $.str + }) + }, + }, +}; + +export default define(meta, async (ps, user) => { + await UserProfiles.update(user.id, { + room: ps.room as any + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true + }); + + // Publish meUpdated event + publishMainStream(user.id, 'meUpdated', iObj); + + // TODO: レスポンスがおかしいと思う by YuzuRyo61 + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts new file mode 100644 index 0000000000..4e636d331c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -0,0 +1,35 @@ +import * as os from 'os'; +import * as si from 'systeminformation'; +import define from '../define'; + +export const meta = { + requireCredential: false as const, + + desc: { + }, + + tags: ['meta'], + + params: { + }, +}; + +export default define(meta, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); + + return { + machine: os.hostname(), + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length + }, + mem: { + total: memStats.total + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/stats.ts b/packages/backend/src/server/api/endpoints/stats.ts new file mode 100644 index 0000000000..15c8001742 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/stats.ts @@ -0,0 +1,83 @@ +import define from '../define'; +import { NoteReactions, Notes, Users } from '@/models/index'; +import { federationChart, driveChart } from '@/services/chart/index'; + +export const meta = { + requireCredential: false as const, + + tags: ['meta'], + + params: { + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + notesCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + originalNotesCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + usersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + originalUsersCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + instances: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + driveUsageLocal: { + type: 'number' as const, + optional: false as const, nullable: false as const + }, + driveUsageRemote: { + type: 'number' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async () => { + const [ + notesCount, + originalNotesCount, + usersCount, + originalUsersCount, + reactionsCount, + //originalReactionsCount, + instances, + driveUsageLocal, + driveUsageRemote + ] = await Promise.all([ + Notes.count({ cache: 3600000 }), // 1 hour + Notes.count({ where: { userHost: null }, cache: 3600000 }), + Users.count({ cache: 3600000 }), + Users.count({ where: { host: null }, cache: 3600000 }), + NoteReactions.count({ cache: 3600000 }), // 1 hour + //NoteReactions.count({ where: { userHost: null }, cache: 3600000 }), + federationChart.getChart('hour', 1, null).then(chart => chart.instance.total[0]), + driveChart.getChart('hour', 1, null).then(chart => chart.local.totalSize[0]), + driveChart.getChart('hour', 1, null).then(chart => chart.remote.totalSize[0]), + ]); + + return { + notesCount, + originalNotesCount, + usersCount, + originalUsersCount, + reactionsCount, + //originalReactionsCount, + instances, + driveUsageLocal, + driveUsageRemote + }; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts new file mode 100644 index 0000000000..6e14ba2669 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -0,0 +1,74 @@ +import $ from 'cafy'; +import define from '../../define'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { genId } from '@/misc/gen-id'; +import { SwSubscriptions } from '@/models/index'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + params: { + endpoint: { + validator: $.str + }, + + auth: { + validator: $.str + }, + + publickey: { + validator: $.str + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + state: { + type: 'string' as const, + optional: false as const, nullable: false as const, + enum: ['already-subscribed', 'subscribed'] + }, + key: { + type: 'string' as const, + optional: false as const, nullable: false as const + } + } + } +}; + +export default define(meta, async (ps, user) => { + // if already subscribed + const exist = await SwSubscriptions.findOne({ + userId: user.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey, + }); + + const instance = await fetchMeta(true); + + if (exist != null) { + return { + state: 'already-subscribed', + key: instance.swPublicKey + }; + } + + await SwSubscriptions.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey + }); + + return { + state: 'subscribed', + key: instance.swPublicKey + }; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/unregister.ts b/packages/backend/src/server/api/endpoints/sw/unregister.ts new file mode 100644 index 0000000000..817ad1f517 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/unregister.ts @@ -0,0 +1,22 @@ +import $ from 'cafy'; +import define from '../../define'; +import { SwSubscriptions } from '../../../../models'; + +export const meta = { + tags: ['account'], + + requireCredential: true as const, + + params: { + endpoint: { + validator: $.str + }, + } +}; + +export default define(meta, async (ps, user) => { + await SwSubscriptions.delete({ + userId: user.id, + endpoint: ps.endpoint, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts new file mode 100644 index 0000000000..1ae75448ea --- /dev/null +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Users, UsedUsernames } from '@/models/index'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + username: { + validator: $.use(Users.validateLocalUsername) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + available: { + type: 'boolean' as const, + optional: false as const, nullable: false as const, + } + } + } +}; + +export default define(meta, async (ps) => { + // Get exist + const exist = await Users.count({ + host: null, + usernameLower: ps.username.toLowerCase() + }); + + const exist2 = await UsedUsernames.count({ username: ps.username.toLowerCase() }); + + return { + available: exist === 0 && exist2 === 0 + }; +}); diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts new file mode 100644 index 0000000000..930dcc7616 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -0,0 +1,101 @@ +import $ from 'cafy'; +import define from '../define'; +import { Users } from '@/models/index'; +import { generateMutedUserQueryForUsers } from '../common/generate-muted-user-query'; +import { generateBlockQueryForUsers } from '../common/generate-block-query'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + }, + + sort: { + validator: $.optional.str.or([ + '+follower', + '-follower', + '+createdAt', + '-createdAt', + '+updatedAt', + '-updatedAt', + ]), + }, + + state: { + validator: $.optional.str.or([ + 'all', + 'admin', + 'moderator', + 'adminOrModerator', + 'alive' + ]), + default: 'all' + }, + + origin: { + validator: $.optional.str.or([ + 'combined', + 'local', + 'remote', + ]), + default: 'local' + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Users.createQueryBuilder('user'); + query.where('user.isExplorable = TRUE'); + + switch (ps.state) { + case 'admin': query.andWhere('user.isAdmin = TRUE'); break; + case 'moderator': query.andWhere('user.isModerator = TRUE'); break; + case 'adminOrModerator': query.andWhere('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; + case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; + } + + switch (ps.origin) { + case 'local': query.andWhere('user.host IS NULL'); break; + case 'remote': query.andWhere('user.host IS NOT NULL'); break; + } + + switch (ps.sort) { + case '+follower': query.orderBy('user.followersCount', 'DESC'); break; + case '-follower': query.orderBy('user.followersCount', 'ASC'); break; + case '+createdAt': query.orderBy('user.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('user.createdAt', 'ASC'); break; + case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break; + case '-updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'ASC'); break; + default: query.orderBy('user.id', 'ASC'); break; + } + + if (me) generateMutedUserQueryForUsers(query, me); + if (me) generateBlockQueryForUsers(query, me); + + query.take(ps.limit!); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts new file mode 100644 index 0000000000..8feca9422a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Clips } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['users', 'clips'], + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Clips.createQueryBuilder('clip'), ps.sinceId, ps.untilId) + .andWhere(`clip.userId = :userId`, { userId: ps.userId }) + .andWhere('clip.isPublic = true'); + + const clips = await query + .take(ps.limit!) + .getMany(); + + return await Clips.packMany(clips); +}); diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts new file mode 100644 index 0000000000..6d042a2861 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -0,0 +1,104 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users, Followings, UserProfiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { toPunyNullable } from '@/misc/convert-host'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.optional.type(ID), + }, + + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Following', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '27fa5435-88ab-43de-9360-387de88727cd' + }, + + forbidden: { + message: 'Forbidden.', + code: 'FORBIDDEN', + id: '3c6a84db-d619-26af-ca14-06232a21df8a' + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) }); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await Followings.findOne({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followeeId = :userId`, { userId: user.id }) + .innerJoinAndSelect('following.follower', 'follower'); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollower: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts new file mode 100644 index 0000000000..1033117ef8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -0,0 +1,104 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { Users, Followings, UserProfiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { toPunyNullable } from '@/misc/convert-host'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.optional.type(ID), + }, + + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Following', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '63e4aba4-4156-4e53-be25-c9559e42d71b' + }, + + forbidden: { + message: 'Forbidden.', + code: 'FORBIDDEN', + id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba' + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: toPunyNullable(ps.host) }); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + if (profile.ffVisibility === 'private') { + if (me == null || (me.id !== user.id)) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const following = await Followings.findOne({ + followeeId: user.id, + followerId: me.id, + }); + if (following == null) { + throw new ApiError(meta.errors.forbidden); + } + } + } + + const query = makePaginationQuery(Followings.createQueryBuilder('following'), ps.sinceId, ps.untilId) + .andWhere(`following.followerId = :userId`, { userId: user.id }) + .innerJoinAndSelect('following.followee', 'followee'); + + const followings = await query + .take(ps.limit!) + .getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts new file mode 100644 index 0000000000..845de1089c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts @@ -0,0 +1,39 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { GalleryPosts } from '@/models/index'; +import { makePaginationQuery } from '../../../common/make-pagination-query'; + +export const meta = { + tags: ['users', 'gallery'], + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(GalleryPosts.createQueryBuilder('post'), ps.sinceId, ps.untilId) + .andWhere(`post.userId = :userId`, { userId: ps.userId }); + + const posts = await query + .take(ps.limit!) + .getMany(); + + return await GalleryPosts.packMany(posts, user); +}); diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts new file mode 100644 index 0000000000..32ebfd683a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -0,0 +1,105 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { maximum } from '@/prelude/array'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { Not, In, IsNull } from 'typeorm'; +import { Notes, Users } from '@/models/index'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'e6965129-7b2a-40a4-bae2-cd84cd434822' + } + } +}; + +export default define(meta, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Fetch recent notes + const recentNotes = await Notes.find({ + where: { + userId: user.id, + replyId: Not(IsNull()) + }, + order: { + id: -1 + }, + take: 1000, + select: ['replyId'] + }); + + // 投稿が少なかったら中断 + if (recentNotes.length === 0) { + return []; + } + + // TODO ミュートを考慮 + const replyTargetNotes = await Notes.find({ + where: { + id: In(recentNotes.map(p => p.replyId)), + }, + select: ['userId'] + }); + + const repliedUsers: any = {}; + + // Extract replies from recent notes + for (const userId of replyTargetNotes.map(x => x.userId.toString())) { + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + } + + // Calc peak + const peak = maximum(Object.values(repliedUsers)); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); + + // Extract top replied users + const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit!); + + // Make replies object (includes weights) + const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ + user: await Users.pack(user, me, { detail: true }), + weight: repliedUsers[user] / peak + }))); + + return repliesObj; +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/create.ts b/packages/backend/src/server/api/endpoints/users/groups/create.ts new file mode 100644 index 0000000000..dc1ee3879e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/create.ts @@ -0,0 +1,45 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { UserGroup } from '@/models/entities/user-group'; +import { UserGroupJoining } from '@/models/entities/user-group-joining'; + +export const meta = { + tags: ['groups'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + name: { + validator: $.str.range(1, 100) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + }, +}; + +export default define(meta, async (ps, user) => { + const userGroup = await UserGroups.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + } as UserGroup).then(x => UserGroups.findOneOrFail(x.identifiers[0])); + + // Push the owner + await UserGroupJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id + } as UserGroupJoining); + + return await UserGroups.pack(userGroup); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/delete.ts b/packages/backend/src/server/api/endpoints/users/groups/delete.ts new file mode 100644 index 0000000000..7da1b4a273 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups } from '@/models/index'; + +export const meta = { + tags: ['groups'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '63dbd64c-cd77-413f-8e08-61781e210b38' + } + } +}; + +export default define(meta, async (ps, user) => { + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: user.id + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await UserGroups.delete(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts new file mode 100644 index 0000000000..09e6ae2647 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts @@ -0,0 +1,54 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../../define'; +import { ApiError } from '../../../../error'; +import { UserGroupJoinings, UserGroupInvitations } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { UserGroupJoining } from '@/models/entities/user-group-joining'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + invitationId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchInvitation: { + message: 'No such invitation.', + code: 'NO_SUCH_INVITATION', + id: '98c11eca-c890-4f42-9806-c8c8303ebb5e' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the invitation + const invitation = await UserGroupInvitations.findOne({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== user.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + // Push the user + await UserGroupJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: invitation.userGroupId + } as UserGroupJoining); + + UserGroupInvitations.delete(invitation.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts new file mode 100644 index 0000000000..741fcefb35 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts @@ -0,0 +1,44 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../../define'; +import { ApiError } from '../../../../error'; +import { UserGroupInvitations } from '@/models/index'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + invitationId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchInvitation: { + message: 'No such invitation.', + code: 'NO_SUCH_INVITATION', + id: 'ad7471d4-2cd9-44b4-ac68-e7136b4ce656' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the invitation + const invitation = await UserGroupInvitations.findOne({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== user.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + await UserGroupInvitations.delete(invitation.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invite.ts b/packages/backend/src/server/api/endpoints/users/groups/invite.ts new file mode 100644 index 0000000000..f1ee8bf8b7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invite.ts @@ -0,0 +1,102 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserGroups, UserGroupJoinings, UserGroupInvitations } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { UserGroupInvitation } from '@/models/entities/user-group-invitation'; +import { createNotification } from '@/services/create-notification'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '583f8bc0-8eee-4b78-9299-1e14fc91e409' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'da52de61-002c-475b-90e1-ba64f9cf13a8' + }, + + alreadyAdded: { + message: 'That user has already been added to that group.', + code: 'ALREADY_ADDED', + id: '7e35c6a0-39b2-4488-aea6-6ee20bd5da2c' + }, + + alreadyInvited: { + message: 'That user has already been invited to that group.', + code: 'ALREADY_INVITED', + id: 'ee0f58b4-b529-4d13-b761-b9a3e69f97e6' + } + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const joining = await UserGroupJoinings.findOne({ + userGroupId: userGroup.id, + userId: user.id + }); + + if (joining) { + throw new ApiError(meta.errors.alreadyAdded); + } + + const existInvitation = await UserGroupInvitations.findOne({ + userGroupId: userGroup.id, + userId: user.id + }); + + if (existInvitation) { + throw new ApiError(meta.errors.alreadyInvited); + } + + const invitation = await UserGroupInvitations.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id + } as UserGroupInvitation).then(x => UserGroupInvitations.findOneOrFail(x.identifiers[0])); + + // 通知を作成 + createNotification(user.id, 'groupInvited', { + notifierId: me.id, + userGroupInvitationId: invitation.id + }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/joined.ts b/packages/backend/src/server/api/endpoints/users/groups/joined.ts new file mode 100644 index 0000000000..d5e8fe4032 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/joined.ts @@ -0,0 +1,36 @@ +import define from '../../../define'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; +import { Not, In } from 'typeorm'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true as const, + + kind: 'read:user-groups', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + } + }, +}; + +export default define(meta, async (ps, me) => { + const ownedGroups = await UserGroups.find({ + userId: me.id, + }); + + const joinings = await UserGroupJoinings.find({ + userId: me.id, + ...(ownedGroups.length > 0 ? { + userGroupId: Not(In(ownedGroups.map(x => x.id))) + } : {}) + }); + + return await Promise.all(joinings.map(x => UserGroups.pack(x.userGroupId))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/leave.ts b/packages/backend/src/server/api/endpoints/users/groups/leave.ts new file mode 100644 index 0000000000..0e52f2abdf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/leave.ts @@ -0,0 +1,50 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '62780270-1f67-5dc0-daca-3eb510612e31' + }, + + youAreOwner: { + message: 'Your are the owner.', + code: 'YOU_ARE_OWNER', + id: 'b6d6e0c2-ef8a-9bb8-653d-79f4a3107c69' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + if (me.id === userGroup.userId) { + throw new ApiError(meta.errors.youAreOwner); + } + + await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: me.id }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/owned.ts b/packages/backend/src/server/api/endpoints/users/groups/owned.ts new file mode 100644 index 0000000000..17de370dbc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/owned.ts @@ -0,0 +1,28 @@ +import define from '../../../define'; +import { UserGroups } from '@/models/index'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true as const, + + kind: 'read:user-groups', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + } + }, +}; + +export default define(meta, async (ps, me) => { + const userGroups = await UserGroups.find({ + userId: me.id, + }); + + return await Promise.all(userGroups.map(x => UserGroups.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/pull.ts b/packages/backend/src/server/api/endpoints/users/groups/pull.ts new file mode 100644 index 0000000000..ce4d2e2881 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/pull.ts @@ -0,0 +1,69 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '4662487c-05b1-4b78-86e5-fd46998aba74' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '0b5cc374-3681-41da-861e-8bc1146f7a55' + }, + + isOwner: { + message: 'The user is the owner.', + code: 'IS_OWNER', + id: '1546eed5-4414-4dea-81c1-b0aec4f6d2af' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + if (user.id === userGroup.userId) { + throw new ApiError(meta.errors.isOwner); + } + + // Pull the user + await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: user.id }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/show.ts b/packages/backend/src/server/api/endpoints/users/groups/show.ts new file mode 100644 index 0000000000..3c030bf3a5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/show.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['groups', 'account'], + + requireCredential: true as const, + + kind: 'read:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'ea04751e-9b7e-487b-a509-330fb6bd6b9b' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + const joining = await UserGroupJoinings.findOne({ + userId: me.id, + userGroupId: userGroup.id + }); + + if (joining == null && userGroup.userId !== me.id) { + throw new ApiError(meta.errors.noSuchGroup); + } + + return await UserGroups.pack(userGroup); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts new file mode 100644 index 0000000000..17c42e1127 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts @@ -0,0 +1,83 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserGroups, UserGroupJoinings } from '@/models/index'; + +export const meta = { + tags: ['groups', 'users'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '8e31d36b-2f88-4ccd-a438-e2d78a9162db' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '711f7ebb-bbb9-4dfa-b540-b27809fed5e9' + }, + + noSuchGroupMember: { + message: 'No such group member.', + code: 'NO_SUCH_GROUP_MEMBER', + id: 'd31bebee-196d-42c2-9a3e-9474d4be6cc4' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const joining = await UserGroupJoinings.findOne({ + userGroupId: userGroup.id, + userId: user.id + }); + + if (joining == null) { + throw new ApiError(meta.errors.noSuchGroupMember); + } + + await UserGroups.update(userGroup.id, { + userId: ps.userId + }); + + return await UserGroups.pack(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/update.ts b/packages/backend/src/server/api/endpoints/users/groups/update.ts new file mode 100644 index 0000000000..127bbc47a1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/update.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups } from '@/models/index'; + +export const meta = { + tags: ['groups'], + + requireCredential: true as const, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserGroup', + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '9081cda3-7a9e-4fac-a6ce-908d70f282f6' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await UserGroups.update(userGroup.id, { + name: ps.name + }); + + return await UserGroups.pack(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts new file mode 100644 index 0000000000..e0bfe611fc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -0,0 +1,36 @@ +import $ from 'cafy'; +import define from '../../../define'; +import { UserLists } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { UserList } from '@/models/entities/user-list'; + +export const meta = { + tags: ['lists'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + name: { + validator: $.str.range(1, 100) + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserList', + }, +}; + +export default define(meta, async (ps, user) => { + const userList = await UserLists.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + } as UserList).then(x => UserLists.findOneOrFail(x.identifiers[0])); + + return await UserLists.pack(userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/delete.ts b/packages/backend/src/server/api/endpoints/users/lists/delete.ts new file mode 100644 index 0000000000..5fe3bfb03d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/delete.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserLists } from '@/models/index'; + +export const meta = { + tags: ['lists'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + listId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '78436795-db79-42f5-b1e2-55ea2cf19166' + } + } +}; + +export default define(meta, async (ps, user) => { + const userList = await UserLists.findOne({ + id: ps.listId, + userId: user.id + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + await UserLists.delete(userList.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts new file mode 100644 index 0000000000..cf0c92bb84 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts @@ -0,0 +1,28 @@ +import define from '../../../define'; +import { UserLists } from '@/models/index'; + +export const meta = { + tags: ['lists', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserList', + } + }, +}; + +export default define(meta, async (ps, me) => { + const userLists = await UserLists.find({ + userId: me.id, + }); + + return await Promise.all(userLists.map(x => UserLists.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts new file mode 100644 index 0000000000..d4357fc5e7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -0,0 +1,62 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import { publishUserListStream } from '@/services/stream'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserLists, UserListJoinings, Users } from '@/models/index'; + +export const meta = { + tags: ['lists', 'users'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + listId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '7f44670e-ab16-43b8-b4c1-ccd2ee89cc02' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '588e7f72-c744-4a61-b180-d354e912bda2' + } + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOne({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Pull the user + await UserListJoinings.delete({ userListId: userList.id, userId: user.id }); + + publishUserListStream(userList.id, 'userRemoved', await Users.pack(user)); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts new file mode 100644 index 0000000000..8e21059d3d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -0,0 +1,92 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { pushUserToUserList } from '@/services/user-list/push'; +import { UserLists, UserListJoinings, Blockings } from '@/models/index'; + +export const meta = { + tags: ['lists', 'users'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + listId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '2214501d-ac96-4049-b717-91e42272a711' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'a89abd3d-f0bc-4cce-beb1-2f446f4f1e6a' + }, + + alreadyAdded: { + message: 'That user has already been added to that list.', + code: 'ALREADY_ADDED', + id: '1de7c884-1595-49e9-857e-61f12f4d4fc5' + }, + + youHaveBeenBlocked: { + message: 'You cannot push this user because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: '990232c5-3f9d-4d83-9f3f-ef27b6332a4b' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOne({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check blocking + if (user.id !== me.id) { + const block = await Blockings.findOne({ + blockerId: user.id, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + const exist = await UserListJoinings.findOne({ + userListId: userList.id, + userId: user.id + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyAdded); + } + + // Push the user + await pushUserToUserList(user, userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts new file mode 100644 index 0000000000..f9a35cdab3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -0,0 +1,47 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserLists } from '@/models/index'; + +export const meta = { + tags: ['lists', 'account'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + listId: { + validator: $.type(ID), + }, + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserList', + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '7bc05c21-1d7a-41ae-88f1-66820f4dc686' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOne({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + return await UserLists.pack(userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts new file mode 100644 index 0000000000..1185af5043 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts @@ -0,0 +1,55 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserLists } from '@/models/index'; + +export const meta = { + tags: ['lists'], + + requireCredential: true as const, + + kind: 'write:account', + + params: { + listId: { + validator: $.type(ID), + }, + + name: { + validator: $.str.range(1, 100), + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'UserList', + }, + + errors: { + noSuchList: { + message: 'No such list.', + code: 'NO_SUCH_LIST', + id: '796666fe-3dff-4d39-becb-8a5932c1d5b7' + }, + } +}; + +export default define(meta, async (ps, user) => { + // Fetch the list + const userList = await UserLists.findOne({ + id: ps.listId, + userId: user.id + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + await UserLists.update(userList.id, { + name: ps.name + }); + + return await UserLists.pack(userList.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts new file mode 100644 index 0000000000..0afbad9d04 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -0,0 +1,144 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { Notes } from '@/models/index'; +import { generateMutedUserQuery } from '../../common/generate-muted-user-query'; +import { Brackets } from 'typeorm'; +import { generateBlockedUserQuery } from '../../common/generate-block-query'; + +export const meta = { + tags: ['users', 'notes'], + + params: { + userId: { + validator: $.type(ID), + }, + + includeReplies: { + validator: $.optional.bool, + default: true, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + + includeMyRenotes: { + validator: $.optional.bool, + default: true, + }, + + withFiles: { + validator: $.optional.bool, + default: false, + }, + + fileType: { + validator: $.optional.arr($.str), + }, + + excludeNsfw: { + validator: $.optional.bool, + default: false, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'Note', + } + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '27e494ba-2ac2-48e8-893b-10d4d8c2387b' + } + } +}; + +export default define(meta, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + //#region Construct query + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.userId = :userId', { userId: user.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me, user); + if (me) generateBlockedUserQuery(query, me); + + if (ps.withFiles) { + query.andWhere('note.fileIds != \'{}\''); + } + + if (ps.fileType != null) { + query.andWhere('note.fileIds != \'{}\''); + query.andWhere(new Brackets(qb => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } + })); + + if (ps.excludeNsfw) { + query.andWhere('note.cw IS NULL'); + query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)'); + } + } + + if (!ps.includeReplies) { + query.andWhere('note.replyId IS NULL'); + } + + if (ps.includeMyRenotes === false) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.userId != :userId', { userId: user.id }); + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); + })); + } + + //#endregion + + const timeline = await query.take(ps.limit!).getMany(); + + return await Notes.packMany(timeline, me); +}); diff --git a/packages/backend/src/server/api/endpoints/users/pages.ts b/packages/backend/src/server/api/endpoints/users/pages.ts new file mode 100644 index 0000000000..24e9e207fd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/pages.ts @@ -0,0 +1,40 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { Pages } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['users', 'pages'], + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps, user) => { + const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId) + .andWhere(`page.userId = :userId`, { userId: ps.userId }) + .andWhere('page.visibility = \'public\''); + + const pages = await query + .take(ps.limit!) + .getMany(); + + return await Pages.packMany(pages); +}); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts new file mode 100644 index 0000000000..fe5e4d84a9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -0,0 +1,79 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { NoteReactions, UserProfiles } from '@/models/index'; +import { makePaginationQuery } from '../../common/make-pagination-query'; +import { generateVisibilityQuery } from '../../common/generate-visibility-query'; +import { ApiError } from '../../error'; + +export const meta = { + tags: ['users', 'reactions'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.type(ID), + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + + sinceDate: { + validator: $.optional.num, + }, + + untilDate: { + validator: $.optional.num, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'NoteReaction', + } + }, + + errors: { + reactionsNotPublic: { + message: 'Reactions of the user is not public.', + code: 'REACTIONS_NOT_PUBLIC', + id: '673a7dd2-6924-1093-e0c0-e68456ceae5c' + }, + } +}; + +export default define(meta, async (ps, me) => { + const profile = await UserProfiles.findOneOrFail(ps.userId); + + if (me == null || (me.id !== ps.userId && !profile.publicReactions)) { + throw new ApiError(meta.errors.reactionsNotPublic); + } + + const query = makePaginationQuery(NoteReactions.createQueryBuilder('reaction'), + ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere(`reaction.userId = :userId`, { userId: ps.userId }) + .leftJoinAndSelect('reaction.note', 'note'); + + generateVisibilityQuery(query, me); + + const reactions = await query + .take(ps.limit!) + .getMany(); + + return await Promise.all(reactions.map(reaction => NoteReactions.pack(reaction, me, { withNote: true }))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts new file mode 100644 index 0000000000..dde6bb1037 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -0,0 +1,63 @@ +import * as ms from 'ms'; +import $ from 'cafy'; +import define from '../../define'; +import { Users, Followings } from '@/models/index'; +import { generateMutedUserQueryForUsers } from '../../common/generate-muted-user-query'; +import { generateBlockedUserQuery, generateBlockQueryForUsers } from '../../common/generate-block-query'; + +export const meta = { + tags: ['users'], + + requireCredential: true as const, + + kind: 'read:account', + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + offset: { + validator: $.optional.num.min(0), + default: 0 + } + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const query = Users.createQueryBuilder('user') + .where('user.isLocked = FALSE') + .andWhere('user.isExplorable = TRUE') + .andWhere('user.host IS NULL') + .andWhere('user.updatedAt >= :date', { date: new Date(Date.now() - ms('7days')) }) + .andWhere('user.id != :meId', { meId: me.id }) + .orderBy('user.followersCount', 'DESC'); + + generateMutedUserQueryForUsers(query, me); + generateBlockQueryForUsers(query, me); + generateBlockedUserQuery(query, me); + + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + query + .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); + + query.setParameters(followingQuery.getParameters()); + + const users = await query.take(ps.limit!).skip(ps.offset).getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts new file mode 100644 index 0000000000..32d76a5322 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -0,0 +1,111 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ID } from '@/misc/cafy-id'; +import { Users } from '@/models/index'; + +export const meta = { + tags: ['users'], + + requireCredential: true as const, + + params: { + userId: { + validator: $.either($.type(ID), $.arr($.type(ID)).unique()), + } + }, + + res: { + oneOf: [ + { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + isFollowing: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hasPendingFollowRequestFromYou: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hasPendingFollowRequestToYou: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isFollowed: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isBlocking: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isBlocked: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isMuted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + } + } + }, + { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + id: { + type: 'string' as const, + optional: false as const, nullable: false as const, + format: 'id' + }, + isFollowing: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hasPendingFollowRequestFromYou: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + hasPendingFollowRequestToYou: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isFollowed: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isBlocking: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isBlocked: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + }, + isMuted: { + type: 'boolean' as const, + optional: false as const, nullable: false as const + } + } + } + } + ] + } +}; + +export default define(meta, async (ps, me) => { + const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId]; + + const relations = await Promise.all(ids.map(id => Users.getRelation(me.id, id))); + + return Array.isArray(ps.userId) ? relations : relations[0]; +}); diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts new file mode 100644 index 0000000000..2c8672cd47 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -0,0 +1,90 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { publishAdminStream } from '@/services/stream'; +import { ApiError } from '../../error'; +import { getUser } from '../../common/getters'; +import { AbuseUserReports, Users } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export const meta = { + tags: ['users'], + + requireCredential: true as const, + + params: { + userId: { + validator: $.type(ID), + }, + + comment: { + validator: $.str.range(1, 2048), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '1acefcb5-0959-43fd-9685-b48305736cb5' + }, + + cannotReportYourself: { + message: 'Cannot report yourself.', + code: 'CANNOT_REPORT_YOURSELF', + id: '1e13149e-b1e8-43cf-902e-c01dbfcb202f' + }, + + cannotReportAdmin: { + message: 'Cannot report the admin.', + code: 'CANNOT_REPORT_THE_ADMIN', + id: '35e166f5-05fb-4f87-a2d5-adb42676d48f' + } + } +}; + +export default define(meta, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + if (user.id === me.id) { + throw new ApiError(meta.errors.cannotReportYourself); + } + + if (user.isAdmin) { + throw new ApiError(meta.errors.cannotReportAdmin); + } + + const report = await AbuseUserReports.save({ + id: genId(), + createdAt: new Date(), + targetUserId: user.id, + targetUserHost: user.host, + reporterId: me.id, + reporterHost: null, + comment: ps.comment, + }); + + // Publish event to moderators + setTimeout(async () => { + const moderators = await Users.find({ + where: [{ + isAdmin: true + }, { + isModerator: true + }] + }); + + for (const moderator of moderators) { + publishAdminStream(moderator.id, 'newAbuseUserReport', { + id: report.id, + targetUserId: report.targetUserId, + reporterId: report.reporterId, + comment: report.comment + }); + } + }, 1); +}); diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts new file mode 100644 index 0000000000..1ec5e1a743 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -0,0 +1,116 @@ +import $ from 'cafy'; +import define from '../../define'; +import { Followings, Users } from '@/models/index'; +import { Brackets } from 'typeorm'; +import { USER_ACTIVE_THRESHOLD } from '@/const'; +import { User } from '@/models/entities/user'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + username: { + validator: $.optional.nullable.str, + }, + + host: { + validator: $.optional.nullable.str, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + detail: { + validator: $.optional.bool, + default: true, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + + if (ps.host) { + const q = Users.createQueryBuilder('user') + .where('user.isSuspended = FALSE') + .andWhere('user.host LIKE :host', { host: ps.host.toLowerCase() + '%' }); + + if (ps.username) { + q.andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }); + } + + q.andWhere('user.updatedAt IS NOT NULL'); + q.orderBy('user.updatedAt', 'DESC'); + + const users = await q.take(ps.limit!).getMany(); + + return await Users.packMany(users, me, { detail: ps.detail }); + } else if (ps.username) { + let users: User[] = []; + + if (me) { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const query = Users.createQueryBuilder('user') + .where(`user.id IN (${ followingQuery.getQuery() })`) + .andWhere(`user.id != :meId`, { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })); + + query.setParameters(followingQuery.getParameters()); + + users = await query + .orderBy('user.usernameLower', 'ASC') + .take(ps.limit!) + .getMany(); + + if (users.length < ps.limit!) { + const otherQuery = await Users.createQueryBuilder('user') + .where(`user.id NOT IN (${ followingQuery.getQuery() })`) + .andWhere(`user.id != :meId`, { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.updatedAt IS NOT NULL'); + + otherQuery.setParameters(followingQuery.getParameters()); + + const otherUsers = await otherQuery + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit! - users.length) + .getMany(); + + users = users.concat(otherUsers); + } + } else { + users = await Users.createQueryBuilder('user') + .where('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.updatedAt IS NOT NULL') + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit! - users.length) + .getMany(); + } + + return await Users.packMany(users, me, { detail: ps.detail }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts new file mode 100644 index 0000000000..9aa988d9ed --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -0,0 +1,127 @@ +import $ from 'cafy'; +import define from '../../define'; +import { UserProfiles, Users } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { Brackets } from 'typeorm'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + query: { + validator: $.str, + }, + + offset: { + validator: $.optional.num.min(0), + default: 0, + }, + + limit: { + validator: $.optional.num.range(1, 100), + default: 10, + }, + + origin: { + validator: $.optional.str.or(['local', 'remote', 'combined']), + default: 'combined', + }, + + detail: { + validator: $.optional.bool, + default: true, + }, + }, + + res: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + } + }, +}; + +export default define(meta, async (ps, me) => { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + + const isUsername = ps.query.startsWith('@'); + + let users: User[] = []; + + if (isUsername) { + const usernameQuery = Users.createQueryBuilder('user') + .where('user.usernameLower LIKE :username', { username: ps.query.replace('@', '').toLowerCase() + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); + + if (ps.origin === 'local') { + usernameQuery.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + usernameQuery.andWhere('user.host IS NOT NULL'); + } + + users = await usernameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit!) + .skip(ps.offset) + .getMany(); + } else { + const nameQuery = Users.createQueryBuilder('user') + .where('user.name ILIKE :query', { query: '%' + ps.query + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); + + if (ps.origin === 'local') { + nameQuery.andWhere('user.host IS NULL'); + } else if (ps.origin === 'remote') { + nameQuery.andWhere('user.host IS NOT NULL'); + } + + users = await nameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit!) + .skip(ps.offset) + .getMany(); + + if (users.length < ps.limit!) { + const profQuery = UserProfiles.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.description ILIKE :query', { query: '%' + ps.query + '%' }); + + if (ps.origin === 'local') { + profQuery.andWhere('prof.userHost IS NULL'); + } else if (ps.origin === 'remote') { + profQuery.andWhere('prof.userHost IS NOT NULL'); + } + + const query = Users.createQueryBuilder('user') + .where(`user.id IN (${ profQuery.getQuery() })`) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE') + .setParameters(profQuery.getParameters()); + + users = users.concat(await query + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .take(ps.limit!) + .skip(ps.offset) + .getMany() + ); + } + } + + return await Users.packMany(users, me, { detail: ps.detail }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts new file mode 100644 index 0000000000..f056983636 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -0,0 +1,105 @@ +import $ from 'cafy'; +import { resolveUser } from '@/remote/resolve-user'; +import define from '../../define'; +import { apiLogger } from '../../logger'; +import { ApiError } from '../../error'; +import { ID } from '@/misc/cafy-id'; +import { Users } from '@/models/index'; +import { In } from 'typeorm'; +import { User } from '@/models/entities/user'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.optional.type(ID), + }, + + userIds: { + validator: $.optional.arr($.type(ID)).unique(), + }, + + username: { + validator: $.optional.str + }, + + host: { + validator: $.optional.nullable.str + } + }, + + res: { + type: 'object' as const, + optional: false as const, nullable: false as const, + ref: 'User', + }, + + errors: { + failedToResolveRemoteUser: { + message: 'Failed to resolve remote user.', + code: 'FAILED_TO_RESOLVE_REMOTE_USER', + id: 'ef7b9be4-9cba-4e6f-ab41-90ed171c7d3c', + kind: 'server' as const + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '4362f8dc-731f-4ad8-a694-be5a88922a24' + }, + } +}; + +export default define(meta, async (ps, me) => { + let user; + + const isAdminOrModerator = me && (me.isAdmin || me.isModerator); + + if (ps.userIds) { + if (ps.userIds.length === 0) { + return []; + } + + const users = await Users.find(isAdminOrModerator ? { + id: In(ps.userIds) + } : { + id: In(ps.userIds), + isSuspended: false + }); + + // リクエストされた通りに並べ替え + const _users: User[] = []; + for (const id of ps.userIds) { + _users.push(users.find(x => x.id === id)!); + } + + return await Promise.all(_users.map(u => Users.pack(u, me, { + detail: true + }))); + } else { + // Lookup user + if (typeof ps.host === 'string' && typeof ps.username === 'string') { + user = await resolveUser(ps.username, ps.host).catch(e => { + apiLogger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.failedToResolveRemoteUser); + }); + } else { + const q: any = ps.userId != null + ? { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: null }; + + user = await Users.findOne(q); + } + + if (user == null || (!isAdminOrModerator && user.isSuspended)) { + throw new ApiError(meta.errors.noSuchUser); + } + + return await Users.pack(user, me, { + detail: true + }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts new file mode 100644 index 0000000000..ef8afd5625 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -0,0 +1,144 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { ID } from '@/misc/cafy-id'; +import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, ReversiGames, Users } from '@/models/index'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9e638e45-3b25-4ef7-8f95-07e8498f1819' + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const [ + notesCount, + repliesCount, + renotesCount, + repliedCount, + renotedCount, + pollVotesCount, + pollVotedCount, + localFollowingCount, + remoteFollowingCount, + localFollowersCount, + remoteFollowersCount, + sentReactionsCount, + receivedReactionsCount, + noteFavoritesCount, + pageLikesCount, + pageLikedCount, + driveFilesCount, + driveUsage, + reversiCount, + ] = await Promise.all([ + Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.replyId IS NOT NULL') + .getCount(), + Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.renoteId IS NOT NULL') + .getCount(), + Notes.createQueryBuilder('note') + .where('note.replyUserId = :userId', { userId: user.id }) + .getCount(), + Notes.createQueryBuilder('note') + .where('note.renoteUserId = :userId', { userId: user.id }) + .getCount(), + PollVotes.createQueryBuilder('vote') + .where('vote.userId = :userId', { userId: user.id }) + .getCount(), + PollVotes.createQueryBuilder('vote') + .innerJoin('vote.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NULL') + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NOT NULL') + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NULL') + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NOT NULL') + .getCount(), + NoteReactions.createQueryBuilder('reaction') + .where('reaction.userId = :userId', { userId: user.id }) + .getCount(), + NoteReactions.createQueryBuilder('reaction') + .innerJoin('reaction.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + NoteFavorites.createQueryBuilder('favorite') + .where('favorite.userId = :userId', { userId: user.id }) + .getCount(), + PageLikes.createQueryBuilder('like') + .where('like.userId = :userId', { userId: user.id }) + .getCount(), + PageLikes.createQueryBuilder('like') + .innerJoin('like.page', 'page') + .where('page.userId = :userId', { userId: user.id }) + .getCount(), + DriveFiles.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .getCount(), + DriveFiles.calcDriveUsageOf(user), + ReversiGames.createQueryBuilder('game') + .where('game.user1Id = :userId', { userId: user.id }) + .orWhere('game.user2Id = :userId', { userId: user.id }) + .getCount(), + ]); + + return { + notesCount, + repliesCount, + renotesCount, + repliedCount, + renotedCount, + pollVotesCount, + pollVotedCount, + localFollowingCount, + remoteFollowingCount, + localFollowersCount, + remoteFollowersCount, + followingCount: localFollowingCount + remoteFollowingCount, + followersCount: localFollowersCount + remoteFollowersCount, + sentReactionsCount, + receivedReactionsCount, + noteFavoritesCount, + pageLikesCount, + pageLikedCount, + driveFilesCount, + driveUsage, + reversiCount, + }; +}); diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts new file mode 100644 index 0000000000..cb0bdc9f47 --- /dev/null +++ b/packages/backend/src/server/api/error.ts @@ -0,0 +1,28 @@ +type E = { message: string, code: string, id: string, kind?: 'client' | 'server', httpStatusCode?: number }; + +export class ApiError extends Error { + public message: string; + public code: string; + public id: string; + public kind: string; + public httpStatusCode?: number; + public info?: any; + + constructor(e?: E | null | undefined, info?: any | null | undefined) { + if (e == null) e = { + message: 'Internal error occurred. Please contact us if the error persists.', + code: 'INTERNAL_ERROR', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + kind: 'server', + httpStatusCode: 500 + }; + + super(e.message); + this.message = e.message; + this.code = e.code; + this.id = e.id; + this.kind = e.kind || 'client'; + this.httpStatusCode = e.httpStatusCode; + this.info = info; + } +} diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts new file mode 100644 index 0000000000..82579075eb --- /dev/null +++ b/packages/backend/src/server/api/index.ts @@ -0,0 +1,113 @@ +/** + * API Server + */ + +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import * as multer from '@koa/multer'; +import * as bodyParser from 'koa-bodyparser'; +import * as cors from '@koa/cors'; + +import endpoints from './endpoints'; +import handler from './api-handler'; +import signup from './private/signup'; +import signin from './private/signin'; +import signupPending from './private/signup-pending'; +import discord from './service/discord'; +import github from './service/github'; +import twitter from './service/twitter'; +import { Instances, AccessTokens, Users } from '@/models/index'; +import config from '@/config'; + +// Init app +const app = new Koa(); + +app.use(cors({ + origin: '*' +})); + +// No caching +app.use(async (ctx, next) => { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + await next(); +}); + +app.use(bodyParser({ + // リクエストが multipart/form-data でない限りはJSONだと見なす + detectJSON: ctx => !ctx.is('multipart/form-data') +})); + +// Init multer instance +const upload = multer({ + storage: multer.diskStorage({}), + limits: { + fileSize: config.maxFileSize || 262144000, + files: 1, + } +}); + +// Init router +const router = new Router(); + +/** + * Register endpoint handlers + */ +for (const endpoint of endpoints) { + if (endpoint.meta.requireFile) { + router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)); + } else { + if (endpoint.name.includes('-')) { + // 後方互換性のため + router.post(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint)); + } + router.post(`/${endpoint.name}`, handler.bind(null, endpoint)); + } +} + +router.post('/signup', signup); +router.post('/signin', signin); +router.post('/signup-pending', signupPending); + +router.use(discord.routes()); +router.use(github.routes()); +router.use(twitter.routes()); + +router.get('/v1/instance/peers', async ctx => { + const instances = await Instances.find({ + select: ['host'] + }); + + ctx.body = instances.map(instance => instance.host); +}); + +router.post('/miauth/:session/check', async ctx => { + const token = await AccessTokens.findOne({ + session: ctx.params.session + }); + + if (token && token.session != null && !token.fetched) { + AccessTokens.update(token.id, { + fetched: true + }); + + ctx.body = { + ok: true, + token: token.token, + user: await Users.pack(token.userId, null, { detail: true }) + }; + } else { + ctx.body = { + ok: false, + }; + } +}); + +// Return 404 for unknown API +router.all('(.*)', async ctx => { + ctx.status = 404; +}); + +// Register router +app.use(router.routes()); + +export default app; diff --git a/packages/backend/src/server/api/limiter.ts b/packages/backend/src/server/api/limiter.ts new file mode 100644 index 0000000000..1e2fe5bcb3 --- /dev/null +++ b/packages/backend/src/server/api/limiter.ts @@ -0,0 +1,83 @@ +import * as Limiter from 'ratelimiter'; +import { redisClient } from '../../db/redis'; +import { IEndpoint } from './endpoints'; +import * as Acct from 'misskey-js/built/acct'; +import { User } from '@/models/entities/user'; +import Logger from '@/services/logger'; + +const logger = new Logger('limiter'); + +export default (endpoint: IEndpoint, user: User) => new Promise<void>((ok, reject) => { + const limitation = endpoint.meta.limit!; + + const key = limitation.hasOwnProperty('key') + ? limitation.key + : endpoint.name; + + const hasShortTermLimit = + limitation.hasOwnProperty('minInterval'); + + const hasLongTermLimit = + limitation.hasOwnProperty('duration') && + limitation.hasOwnProperty('max'); + + if (hasShortTermLimit) { + min(); + } else if (hasLongTermLimit) { + max(); + } else { + ok(); + } + + // Short-term limit + function min() { + const minIntervalLimiter = new Limiter({ + id: `${user.id}:${key}:min`, + duration: limitation.minInterval, + max: 1, + db: redisClient + }); + + minIntervalLimiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + logger.debug(`@${Acct.toString(user)} ${endpoint.name} min remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('BRIEF_REQUEST_INTERVAL'); + } else { + if (hasLongTermLimit) { + max(); + } else { + ok(); + } + } + }); + } + + // Long term limit + function max() { + const limiter = new Limiter({ + id: `${user.id}:${key}`, + duration: limitation.duration, + max: limitation.max, + db: redisClient + }); + + limiter.get((err, info) => { + if (err) { + return reject('ERR'); + } + + logger.debug(`@${Acct.toString(user)} ${endpoint.name} max remaining: ${info.remaining}`); + + if (info.remaining === 0) { + reject('RATE_LIMIT_EXCEEDED'); + } else { + ok(); + } + }); + } +}); diff --git a/packages/backend/src/server/api/logger.ts b/packages/backend/src/server/api/logger.ts new file mode 100644 index 0000000000..750defe547 --- /dev/null +++ b/packages/backend/src/server/api/logger.ts @@ -0,0 +1,3 @@ +import Logger from '@/services/logger'; + +export const apiLogger = new Logger('api'); diff --git a/packages/backend/src/server/api/openapi/errors.ts b/packages/backend/src/server/api/openapi/errors.ts new file mode 100644 index 0000000000..43bcc323ba --- /dev/null +++ b/packages/backend/src/server/api/openapi/errors.ts @@ -0,0 +1,69 @@ + +export const errors = { + '400': { + 'INVALID_PARAM': { + value: { + error: { + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + } + } + } + }, + '401': { + 'CREDENTIAL_REQUIRED': { + value: { + error: { + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + } + } + } + }, + '403': { + 'AUTHENTICATION_FAILED': { + value: { + error: { + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + } + } + } + }, + '418': { + 'I_AM_AI': { + value: { + error: { + message: 'You sent a request to Ai-chan, Misskey\'s showgirl, instead of the server.', + code: 'I_AM_AI', + id: '60c46cd1-f23a-46b1-bebe-5d2b73951a84', + } + } + } + }, + '429': { + 'RATE_LIMIT_EXCEEDED': { + value: { + error: { + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + } + } + } + }, + '500': { + 'INTERNAL_ERROR': { + value: { + error: { + message: 'Internal error occurred. Please contact us if the error persists.', + code: 'INTERNAL_ERROR', + id: '5d37dbcb-891e-41ca-a3d6-e690c97775ac', + } + } + } + } +}; diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts new file mode 100644 index 0000000000..48b819727f --- /dev/null +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -0,0 +1,235 @@ +import endpoints from '../endpoints'; +import { Context } from 'cafy'; +import config from '@/config/index'; +import { errors as basicErrors } from './errors'; +import { schemas, convertSchemaToOpenApiSchema } from './schemas'; + +export function genOpenapiSpec(lang = 'ja-JP') { + const spec = { + openapi: '3.0.0', + + info: { + version: 'v1', + title: 'Misskey API', + 'x-logo': { url: '/static-assets/api-doc.png' } + }, + + externalDocs: { + description: 'Repository', + url: 'https://github.com/misskey-dev/misskey' + }, + + servers: [{ + url: config.apiUrl + }], + + paths: {} as any, + + components: { + schemas: schemas, + + securitySchemes: { + ApiKeyAuth: { + type: 'apiKey', + in: 'body', + name: 'i' + } + } + } + }; + + function genProps(props: { [key: string]: Context; }) { + const properties = {} as any; + + for (const [k, v] of Object.entries(props)) { + properties[k] = genProp(v); + } + + return properties; + } + + function genProp(param: Context): any { + const required = param.name === 'Object' ? (param as any).props ? Object.entries((param as any).props).filter(([k, v]: any) => !v.isOptional).map(([k, v]) => k) : [] : []; + return { + description: (param.data || {}).desc, + default: (param.data || {}).default, + deprecated: (param.data || {}).deprecated, + ...((param.data || {}).default ? { default: (param.data || {}).default } : {}), + type: param.name === 'ID' ? 'string' : param.name.toLowerCase(), + ...(param.name === 'ID' ? { example: 'xxxxxxxxxx', format: 'id' } : {}), + nullable: param.isNullable, + ...(param.name === 'String' ? { + ...((param as any).enum ? { enum: (param as any).enum } : {}), + ...((param as any).minLength ? { minLength: (param as any).minLength } : {}), + ...((param as any).maxLength ? { maxLength: (param as any).maxLength } : {}), + } : {}), + ...(param.name === 'Number' ? { + ...((param as any).minimum ? { minimum: (param as any).minimum } : {}), + ...((param as any).maximum ? { maximum: (param as any).maximum } : {}), + } : {}), + ...(param.name === 'Object' ? { + ...(required.length > 0 ? { required } : {}), + properties: (param as any).props ? genProps((param as any).props) : {} + } : {}), + ...(param.name === 'Array' ? { + items: (param as any).ctx ? genProp((param as any).ctx) : {} + } : {}) + }; + } + + for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { + const porops = {} as any; + const errors = {} as any; + + if (endpoint.meta.errors) { + for (const e of Object.values(endpoint.meta.errors)) { + errors[e.code] = { + value: { + error: e + } + }; + } + } + + if (endpoint.meta.params) { + for (const [k, v] of Object.entries(endpoint.meta.params)) { + if (v.validator.data == null) v.validator.data = {}; + if (v.desc) v.validator.data.desc = v.desc[lang]; + if (v.deprecated) v.validator.data.deprecated = v.deprecated; + if (v.default) v.validator.data.default = v.default; + porops[k] = v.validator; + } + } + + const required = endpoint.meta.params ? Object.entries(endpoint.meta.params).filter(([k, v]) => !v.validator.isOptional).map(([k, v]) => k) : []; + + const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; + + let desc = (endpoint.meta.desc ? endpoint.meta.desc[lang] : 'No description provided.') + '\n\n'; + desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`; + if (endpoint.meta.kind) { + const kind = endpoint.meta.kind; + desc += ` / **Permission**: *${kind}*`; + } + + const info = { + operationId: endpoint.name, + summary: endpoint.name, + description: desc, + externalDocs: { + description: 'Source code', + url: `https://github.com/misskey-dev/misskey/blob/develop/src/server/api/endpoints/${endpoint.name}.ts` + }, + ...(endpoint.meta.tags ? { + tags: [endpoint.meta.tags[0]] + } : {}), + ...(endpoint.meta.requireCredential ? { + security: [{ + ApiKeyAuth: [] + }] + } : {}), + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + ...(required.length > 0 ? { required } : {}), + properties: endpoint.meta.params ? genProps(porops) : {} + } + } + } + }, + responses: { + ...(endpoint.meta.res ? { + '200': { + description: 'OK (with results)', + content: { + 'application/json': { + schema: resSchema + } + } + } + } : { + '204': { + description: 'OK (without any results)', + } + }), + '400': { + description: 'Client error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: { ...errors, ...basicErrors['400'] } + } + } + }, + '401': { + description: 'Authentication error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['401'] + } + } + }, + '403': { + description: 'Forbidden error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['403'] + } + } + }, + '418': { + description: 'I\'m Ai', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['418'] + } + } + }, + ...(endpoint.meta.limit ? { + '429': { + description: 'To many requests', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['429'] + } + } + } + } : {}), + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error' + }, + examples: basicErrors['500'] + } + } + }, + } + }; + + spec.paths['/' + endpoint.name] = { + post: info + }; + } + + return spec; +} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts new file mode 100644 index 0000000000..12fc207c47 --- /dev/null +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -0,0 +1,56 @@ +import { refs, Schema } from '@/misc/schema'; + +export function convertSchemaToOpenApiSchema(schema: Schema) { + const res: any = schema; + + if (schema.type === 'object' && schema.properties) { + res.required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k); + + for (const k of Object.keys(schema.properties)) { + res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]); + } + } + + if (schema.type === 'array' && schema.items) { + res.items = convertSchemaToOpenApiSchema(schema.items); + } + + if (schema.ref) { + res.$ref = `#/components/schemas/${schema.ref}`; + } + + return res; +} + +export const schemas = { + Error: { + type: 'object', + properties: { + error: { + type: 'object', + description: 'An error object.', + properties: { + code: { + type: 'string', + description: 'An error code. Unique within the endpoint.', + }, + message: { + type: 'string', + description: 'An error message.', + }, + id: { + type: 'string', + format: 'uuid', + description: 'An error ID. This ID is static.', + } + }, + required: ['code', 'id', 'message'] + }, + }, + required: ['error'] + }, + + ...Object.fromEntries( + Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema)]) + ), +}; diff --git a/packages/backend/src/server/api/private/signin.ts b/packages/backend/src/server/api/private/signin.ts new file mode 100644 index 0000000000..83c3dfee94 --- /dev/null +++ b/packages/backend/src/server/api/private/signin.ts @@ -0,0 +1,232 @@ +import * as Koa from 'koa'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import signin from '../common/signin'; +import config from '@/config/index'; +import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '@/models/index'; +import { ILocalUser } from '@/models/entities/user'; +import { genId } from '@/misc/gen-id'; +import { verifyLogin, hash } from '../2fa'; +import { randomBytes } from 'crypto'; + +export default async (ctx: Koa.Context) => { + ctx.set('Access-Control-Allow-Origin', config.url); + ctx.set('Access-Control-Allow-Credentials', 'true'); + + const body = ctx.request.body as any; + const username = body['username']; + const password = body['password']; + const token = body['token']; + + function error(status: number, error: { id: string }) { + ctx.status = status; + ctx.body = { error }; + } + + if (typeof username != 'string') { + ctx.status = 400; + return; + } + + if (typeof password != 'string') { + ctx.status = 400; + return; + } + + if (token != null && typeof token != 'string') { + ctx.status = 400; + return; + } + + // Fetch user + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host: null + }) as ILocalUser; + + if (user == null) { + error(404, { + id: '6cc579cc-885d-43d8-95c2-b8c7fc963280', + }); + return; + } + + if (user.isSuspended) { + error(403, { + id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', + }); + return; + } + + const profile = await UserProfiles.findOneOrFail(user.id); + + // Compare password + const same = await bcrypt.compare(password, profile.password!); + + async function fail(status?: number, failure?: { id: string }) { + // Append signin history + await Signins.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: false + }); + + error(status || 500, failure || { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); + } + + if (!profile.twoFactorEnabled) { + if (same) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c' + }); + return; + } + } + + if (token) { + if (!same) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c' + }); + return; + } + + const verified = (speakeasy as any).totp.verify({ + secret: profile.twoFactorSecret, + encoding: 'base32', + token: token, + window: 2 + }); + + if (verified) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f' + }); + return; + } + } else if (body.credentialId) { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c' + }); + return; + } + + const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex'); + const clientData = JSON.parse(clientDataJSON.toString('utf-8')); + const challenge = await AttestationChallenges.findOne({ + userId: user.id, + id: body.challengeId, + registrationChallenge: false, + challenge: hash(clientData.challenge).toString('hex') + }); + + if (!challenge) { + await fail(403, { + id: '2715a88a-2125-4013-932f-aa6fe72792da' + }); + return; + } + + await AttestationChallenges.delete({ + userId: user.id, + id: body.challengeId + }); + + if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { + await fail(403, { + id: '2715a88a-2125-4013-932f-aa6fe72792da' + }); + return; + } + + const securityKey = await UserSecurityKeys.findOne({ + id: Buffer.from( + body.credentialId + .replace(/-/g, '+') + .replace(/_/g, '/'), + 'base64' + ).toString('hex') + }); + + if (!securityKey) { + await fail(403, { + id: '66269679-aeaf-4474-862b-eb761197e046' + }); + return; + } + + const isValid = verifyLogin({ + publicKey: Buffer.from(securityKey.publicKey, 'hex'), + authenticatorData: Buffer.from(body.authenticatorData, 'hex'), + clientDataJSON, + clientData, + signature: Buffer.from(body.signature, 'hex'), + challenge: challenge.challenge + }); + + if (isValid) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: '93b86c4b-72f9-40eb-9815-798928603d1e' + }); + return; + } + } else { + if (!same && !profile.usePasswordLessLogin) { + await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c' + }); + return; + } + + const keys = await UserSecurityKeys.find({ + userId: user.id + }); + + if (keys.length === 0) { + await fail(403, { + id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4' + }); + return; + } + + // 32 byte challenge + const challenge = randomBytes(32).toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const challengeId = genId(); + + await AttestationChallenges.insert({ + userId: user.id, + id: challengeId, + challenge: hash(Buffer.from(challenge, 'utf-8')).toString('hex'), + createdAt: new Date(), + registrationChallenge: false + }); + + ctx.body = { + challenge, + challengeId, + securityKeys: keys.map(key => ({ + id: key.id + })) + }; + ctx.status = 200; + return; + } + // never get here +}; diff --git a/packages/backend/src/server/api/private/signup-pending.ts b/packages/backend/src/server/api/private/signup-pending.ts new file mode 100644 index 0000000000..c0638a1cda --- /dev/null +++ b/packages/backend/src/server/api/private/signup-pending.ts @@ -0,0 +1,35 @@ +import * as Koa from 'koa'; +import { Users, UserPendings, UserProfiles } from '@/models/index'; +import { signup } from '../common/signup'; +import signin from '../common/signin'; + +export default async (ctx: Koa.Context) => { + const body = ctx.request.body; + + const code = body['code']; + + try { + const pendingUser = await UserPendings.findOneOrFail({ code }); + + const { account, secret } = await signup({ + username: pendingUser.username, + passwordHash: pendingUser.password, + }); + + UserPendings.delete({ + id: pendingUser.id, + }); + + const profile = await UserProfiles.findOneOrFail(account.id); + + await UserProfiles.update({ userId: profile.userId }, { + email: pendingUser.email, + emailVerified: true, + emailVerifyCode: null, + }); + + signin(ctx, account); + } catch (e) { + ctx.throw(400, e); + } +}; diff --git a/packages/backend/src/server/api/private/signup.ts b/packages/backend/src/server/api/private/signup.ts new file mode 100644 index 0000000000..2b6a3eb00c --- /dev/null +++ b/packages/backend/src/server/api/private/signup.ts @@ -0,0 +1,112 @@ +import * as Koa from 'koa'; +import rndstr from 'rndstr'; +import * as bcrypt from 'bcryptjs'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { verifyHcaptcha, verifyRecaptcha } from '@/misc/captcha'; +import { Users, RegistrationTickets, UserPendings } from '@/models/index'; +import { signup } from '../common/signup'; +import config from '@/config'; +import { sendEmail } from '@/services/send-email'; +import { genId } from '@/misc/gen-id'; +import { validateEmailForAccount } from '@/services/validate-email-for-account'; + +export default async (ctx: Koa.Context) => { + const body = ctx.request.body; + + const instance = await fetchMeta(true); + + // Verify *Captcha + // ただしテスト時はこの機構は障害となるため無効にする + if (process.env.NODE_ENV !== 'test') { + if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { + await verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => { + ctx.throw(400, e); + }); + } + + if (instance.enableRecaptcha && instance.recaptchaSecretKey) { + await verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => { + ctx.throw(400, e); + }); + } + } + + const username = body['username']; + const password = body['password']; + const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] || null) : null; + const invitationCode = body['invitationCode']; + const emailAddress = body['emailAddress']; + + if (instance.emailRequiredForSignup) { + if (emailAddress == null || typeof emailAddress != 'string') { + ctx.status = 400; + return; + } + + const available = await validateEmailForAccount(emailAddress); + if (!available) { + ctx.status = 400; + return; + } + } + + if (instance.disableRegistration) { + if (invitationCode == null || typeof invitationCode != 'string') { + ctx.status = 400; + return; + } + + const ticket = await RegistrationTickets.findOne({ + code: invitationCode + }); + + if (ticket == null) { + ctx.status = 400; + return; + } + + RegistrationTickets.delete(ticket.id); + } + + if (instance.emailRequiredForSignup) { + const code = rndstr('a-z0-9', 16); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + await UserPendings.insert({ + id: genId(), + createdAt: new Date(), + code, + email: emailAddress, + username: username, + password: hash, + }); + + const link = `${config.url}/signup-complete/${code}`; + + sendEmail(emailAddress, 'Signup', + `To complete signup, please click this link:<br><a href="${link}">${link}</a>`, + `To complete signup, please click this link: ${link}`); + + ctx.status = 204; + } else { + try { + const { account, secret } = await signup({ + username, password, host + }); + + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true + }); + + (res as any).token = secret; + + ctx.body = res; + } catch (e) { + ctx.throw(400, e); + } + } +}; diff --git a/packages/backend/src/server/api/service/discord.ts b/packages/backend/src/server/api/service/discord.ts new file mode 100644 index 0000000000..dd52a23376 --- /dev/null +++ b/packages/backend/src/server/api/service/discord.ts @@ -0,0 +1,286 @@ +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import { getJson } from '@/misc/fetch'; +import { OAuth2 } from 'oauth'; +import config from '@/config/index'; +import { publishMainStream } from '@/services/stream'; +import { redisClient } from '../../../db/redis'; +import { v4 as uuid } from 'uuid'; +import signin from '../common/signin'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Users, UserProfiles } from '@/models/index'; +import { ILocalUser } from '@/models/entities/user'; + +function getUserToken(ctx: Koa.Context) { + return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.Context) { + function normalizeUrl(url: string) { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); +} + +// Init router +const router = new Router(); + +router.get('/disconnect/discord', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + delete profile.integrations.discord; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = `Discordの連携を解除しました :v:`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); +}); + +async function getOAuth2() { + const meta = await fetchMeta(true); + + if (meta.enableDiscordIntegration) { + return new OAuth2( + meta.discordClientId!, + meta.discordClientSecret!, + 'https://discord.com/', + 'api/oauth2/authorize', + 'api/oauth2/token'); + } else { + return null; + } +} + +router.get('/connect/discord', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${config.url}/api/dc/cb`, + scope: ['identify'], + state: uuid(), + response_type: 'code' + }; + + redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/signin/discord', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}/api/dc/cb`, + scope: ['identify'], + state: uuid(), + response_type: 'code' + }; + + ctx.cookies.set('signin_with_discord_sid', sessid, { + path: '/', + secure: config.url.startsWith('https'), + httpOnly: true + }); + + redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/dc/cb', async ctx => { + const userToken = getUserToken(ctx); + + const oauth2 = await getOAuth2(); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_discord_sid'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise<any>((res, rej) => { + redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) => + oauth2!.getOAuthAccessToken(code, { + grant_type: 'authorization_code', + redirect_uri + }, (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000 + }); + } + })); + + const { id, username, discriminator } = await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { + 'Authorization': `Bearer ${accessToken}`, + }); + + if (!id || !username || !discriminator) { + ctx.throw(400, 'invalid session'); + return; + } + + const profile = await UserProfiles.createQueryBuilder() + .where(`"integrations"->'discord'->>'id' = :id`, { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (profile == null) { + ctx.throw(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + await UserProfiles.update(profile.userId, { + integrations: { + ...profile.integrations, + discord: { + id: id, + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + username: username, + discriminator: discriminator + } + }, + }); + + signin(ctx, await Users.findOne(profile.userId) as ILocalUser, true); + } else { + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise<any>((res, rej) => { + redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) => + oauth2!.getOAuthAccessToken(code, { + grant_type: 'authorization_code', + redirect_uri + }, (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000 + }); + } + })); + + const { id, username, discriminator } = await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, { + 'Authorization': `Bearer ${accessToken}`, + }); + if (!id || !username || !discriminator) { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + discord: { + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + id: id, + username: username, + discriminator: discriminator + } + } + }); + + ctx.body = `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/service/github.ts b/packages/backend/src/server/api/service/github.ts new file mode 100644 index 0000000000..0616f3f773 --- /dev/null +++ b/packages/backend/src/server/api/service/github.ts @@ -0,0 +1,257 @@ +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import { getJson } from '@/misc/fetch'; +import { OAuth2 } from 'oauth'; +import config from '@/config/index'; +import { publishMainStream } from '@/services/stream'; +import { redisClient } from '../../../db/redis'; +import { v4 as uuid } from 'uuid'; +import signin from '../common/signin'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Users, UserProfiles } from '@/models/index'; +import { ILocalUser } from '@/models/entities/user'; + +function getUserToken(ctx: Koa.Context) { + return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.Context) { + function normalizeUrl(url: string) { + return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : ''; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); +} + +// Init router +const router = new Router(); + +router.get('/disconnect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + delete profile.integrations.github; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = `GitHubの連携を解除しました :v:`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); +}); + +async function getOath2() { + const meta = await fetchMeta(true); + + if (meta.enableGithubIntegration && meta.githubClientId && meta.githubClientSecret) { + return new OAuth2( + meta.githubClientId, + meta.githubClientSecret, + 'https://github.com/', + 'login/oauth/authorize', + 'login/oauth/access_token'); + } else { + return null; + } +} + +router.get('/connect/github', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, 'signin required'); + return; + } + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ['read:user'], + state: uuid() + }; + + redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/signin/github', async ctx => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ['read:user'], + state: uuid() + }; + + ctx.cookies.set('signin_with_github_sid', sessid, { + path: '/', + secure: config.url.startsWith('https'), + httpOnly: true + }); + + redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get('/gh/cb', async ctx => { + const userToken = getUserToken(ctx); + + const oauth2 = await getOath2(); + + if (!userToken) { + const sessid = ctx.cookies.get('signin_with_github_sid'); + + if (!sessid) { + ctx.throw(400, 'invalid session'); + return; + } + + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise<any>((res, rej) => { + redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise<any>((res, rej) => + oauth2!.getOAuthAccessToken(code, { + redirect_uri + }, (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + })); + + const { login, id } = await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { + 'Authorization': `bearer ${accessToken}` + }); + if (!login || !id) { + ctx.throw(400, 'invalid session'); + return; + } + + const link = await UserProfiles.createQueryBuilder() + .where(`"integrations"->'github'->>'id' = :id`, { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(ctx, await Users.findOne(link.userId) as ILocalUser, true); + } else { + const code = ctx.query.code; + + if (!code) { + ctx.throw(400, 'invalid session'); + return; + } + + const { redirect_uri, state } = await new Promise<any>((res, rej) => { + redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, 'invalid session'); + return; + } + + const { accessToken } = await new Promise<any>((res, rej) => + oauth2!.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) + rej(err); + else if (result.error) + rej(result.error); + else + res({ accessToken }); + })); + + const { login, id } = await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, { + 'Authorization': `bearer ${accessToken}` + }); + + if (!login || !id) { + ctx.throw(400, 'invalid session'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + github: { + accessToken: accessToken, + id: id, + login: login, + } + } + }); + + ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/service/twitter.ts b/packages/backend/src/server/api/service/twitter.ts new file mode 100644 index 0000000000..8a6a58aeee --- /dev/null +++ b/packages/backend/src/server/api/service/twitter.ts @@ -0,0 +1,194 @@ +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import { v4 as uuid } from 'uuid'; +import autwh from 'autwh'; +import { redisClient } from '../../../db/redis'; +import { publishMainStream } from '@/services/stream'; +import config from '@/config/index'; +import signin from '../common/signin'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Users, UserProfiles } from '@/models/index'; +import { ILocalUser } from '@/models/entities/user'; + +function getUserToken(ctx: Koa.Context) { + return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.Context) { + function normalizeUrl(url: string) { + return url.endsWith('/') ? url.substr(0, url.length - 1) : url; + } + + const referer = ctx.headers['referer']; + + return (normalizeUrl(referer) == normalizeUrl(config.url)); +} + +// Init router +const router = new Router(); + +router.get('/disconnect/twitter', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (userToken == null) { + ctx.throw(400, 'signin required'); + return; + } + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + delete profile.integrations.twitter; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = `Twitterの連携を解除しました :v:`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); +}); + +async function getTwAuth() { + const meta = await fetchMeta(true); + + if (meta.enableTwitterIntegration && meta.twitterConsumerKey && meta.twitterConsumerSecret) { + return autwh({ + consumerKey: meta.twitterConsumerKey, + consumerSecret: meta.twitterConsumerSecret, + callbackUrl: `${config.url}/api/tw/cb` + }); + } else { + return null; + } +} + +router.get('/connect/twitter', async ctx => { + if (!compareOrigin(ctx)) { + ctx.throw(400, 'invalid origin'); + return; + } + + const userToken = getUserToken(ctx); + if (userToken == null) { + ctx.throw(400, 'signin required'); + return; + } + + const twAuth = await getTwAuth(); + const twCtx = await twAuth!.begin(); + redisClient.set(userToken, JSON.stringify(twCtx)); + ctx.redirect(twCtx.url); +}); + +router.get('/signin/twitter', async ctx => { + const twAuth = await getTwAuth(); + const twCtx = await twAuth!.begin(); + + const sessid = uuid(); + + redisClient.set(sessid, JSON.stringify(twCtx)); + + ctx.cookies.set('signin_with_twitter_sid', sessid, { + path: '/', + secure: config.url.startsWith('https'), + httpOnly: true + }); + + ctx.redirect(twCtx.url); +}); + +router.get('/tw/cb', async ctx => { + const userToken = getUserToken(ctx); + + const twAuth = await getTwAuth(); + + if (userToken == null) { + const sessid = ctx.cookies.get('signin_with_twitter_sid'); + + if (sessid == null) { + ctx.throw(400, 'invalid session'); + return; + } + + const get = new Promise<any>((res, rej) => { + redisClient.get(sessid, async (_, twCtx) => { + res(twCtx); + }); + }); + + const twCtx = await get; + + const result = await twAuth!.done(JSON.parse(twCtx), ctx.query.oauth_verifier); + + const link = await UserProfiles.createQueryBuilder() + .where(`"integrations"->'twitter'->>'userId' = :id`, { id: result.userId }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`); + return; + } + + signin(ctx, await Users.findOne(link.userId) as ILocalUser, true); + } else { + const verifier = ctx.query.oauth_verifier; + + if (verifier == null) { + ctx.throw(400, 'invalid session'); + return; + } + + const get = new Promise<any>((res, rej) => { + redisClient.get(userToken, async (_, twCtx) => { + res(twCtx); + }); + }); + + const twCtx = await get; + + const result = await twAuth!.done(JSON.parse(twCtx), verifier); + + const user = await Users.findOneOrFail({ + host: null, + token: userToken + }); + + const profile = await UserProfiles.findOneOrFail(user.id); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + twitter: { + accessToken: result.accessToken, + accessTokenSecret: result.accessTokenSecret, + userId: result.userId, + screenName: result.screenName, + } + }, + }); + + ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, { + detail: true, + includeSecrets: true + })); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts new file mode 100644 index 0000000000..2824d7d1b8 --- /dev/null +++ b/packages/backend/src/server/api/stream/channel.ts @@ -0,0 +1,62 @@ +import autobind from 'autobind-decorator'; +import Connection from '.'; + +/** + * Stream channel + */ +export default abstract class Channel { + protected connection: Connection; + public id: string; + public abstract readonly chName: string; + public static readonly shouldShare: boolean; + public static readonly requireCredential: boolean; + + protected get user() { + return this.connection.user; + } + + protected get userProfile() { + return this.connection.userProfile; + } + + protected get following() { + return this.connection.following; + } + + protected get muting() { + return this.connection.muting; + } + + protected get blocking() { + return this.connection.blocking; + } + + protected get followingChannels() { + return this.connection.followingChannels; + } + + protected get subscriber() { + return this.connection.subscriber; + } + + constructor(id: string, connection: Connection) { + this.id = id; + this.connection = connection; + } + + @autobind + public send(typeOrPayload: any, payload?: any) { + const type = payload === undefined ? typeOrPayload.type : typeOrPayload; + const body = payload === undefined ? typeOrPayload.body : payload; + + this.connection.sendMessageToWs('channel', { + id: this.id, + type: type, + body: body + }); + } + + public abstract init(params: any): void; + public dispose?(): void; + public onMessage?(type: string, body: any): void; +} diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts new file mode 100644 index 0000000000..1ff932d1dd --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -0,0 +1,16 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; + +export default class extends Channel { + public readonly chName = 'admin'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe admin stream + this.subscriber.on(`adminStream:${this.user!.id}`, data => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts new file mode 100644 index 0000000000..3cbdfebb43 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -0,0 +1,45 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; +import { Notes } from '@/models/index'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { StreamMessages } from '../types'; + +export default class extends Channel { + public readonly chName = 'antenna'; + public static shouldShare = false; + public static requireCredential = false; + private antennaId: string; + + @autobind + public async init(params: any) { + this.antennaId = params.antennaId as string; + + // Subscribe stream + this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); + } + + @autobind + private async onEvent(data: StreamMessages['antenna']['payload']) { + if (data.type === 'note') { + const note = await Notes.pack(data.body.id, this.user, { detail: true }); + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } else { + this.send(data.type, data.body); + } + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent); + } +} diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts new file mode 100644 index 0000000000..bf7942f522 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -0,0 +1,92 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; +import { Notes, Users } from '@/models/index'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { User } from '@/models/entities/user'; +import { StreamMessages } from '../types'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'channel'; + public static shouldShare = false; + public static requireCredential = false; + private channelId: string; + private typers: Record<User['id'], Date> = {}; + private emitTypersIntervalId: ReturnType<typeof setInterval>; + + @autobind + public async init(params: any) { + this.channelId = params.channelId as string; + + // Subscribe stream + this.subscriber.on('notesStream', this.onNote); + this.subscriber.on(`channelStream:${this.channelId}`, this.onEvent); + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (note.channelId !== this.channelId) return; + + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + private onEvent(data: StreamMessages['channel']['payload']) { + if (data.type === 'typing') { + const id = data.body; + const begin = this.typers[id] == null; + this.typers[id] = new Date(); + if (begin) { + this.emitTypers(); + } + } + } + + @autobind + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of Object.entries(this.typers)) { + if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; + } + + const users = await Users.packMany(Object.keys(this.typers), null, { detail: false }); + + this.send({ + type: 'typers', + body: users, + }); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + this.subscriber.off(`channelStream:${this.channelId}`, this.onEvent); + + clearInterval(this.emitTypersIntervalId); + } +} diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts new file mode 100644 index 0000000000..4112dd9b04 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -0,0 +1,16 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; + +export default class extends Channel { + public readonly chName = 'drive'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe drive stream + this.subscriber.on(`driveStream:${this.user!.id}`, data => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/games/reversi-game.ts b/packages/backend/src/server/api/stream/channels/games/reversi-game.ts new file mode 100644 index 0000000000..bfdbf1d266 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/games/reversi-game.ts @@ -0,0 +1,372 @@ +import autobind from 'autobind-decorator'; +import * as CRC32 from 'crc-32'; +import { publishReversiGameStream } from '@/services/stream'; +import Reversi from '../../../../../games/reversi/core'; +import * as maps from '../../../../../games/reversi/maps'; +import Channel from '../../channel'; +import { ReversiGame } from '@/models/entities/games/reversi/game'; +import { ReversiGames, Users } from '@/models/index'; +import { User } from '@/models/entities/user'; + +export default class extends Channel { + public readonly chName = 'gamesReversiGame'; + public static shouldShare = false; + public static requireCredential = false; + + private gameId: ReversiGame['id'] | null = null; + private watchers: Record<User['id'], Date> = {}; + private emitWatchersIntervalId: ReturnType<typeof setInterval>; + + @autobind + public async init(params: any) { + this.gameId = params.gameId; + + // Subscribe game stream + this.subscriber.on(`reversiGameStream:${this.gameId}`, this.onEvent); + this.emitWatchersIntervalId = setInterval(this.emitWatchers, 5000); + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + // 観戦者イベント + this.watch(game); + } + + @autobind + private onEvent(data: any) { + if (data.type === 'watching') { + const id = data.body; + this.watchers[id] = new Date(); + } else { + this.send(data); + } + } + + @autobind + private async emitWatchers() { + const now = new Date(); + + // Remove not watching users + for (const [userId, date] of Object.entries(this.watchers)) { + if (now.getTime() - date.getTime() > 5000) delete this.watchers[userId]; + } + + const users = await Users.packMany(Object.keys(this.watchers), null, { detail: false }); + + this.send({ + type: 'watchers', + body: users, + }); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off(`reversiGameStream:${this.gameId}`, this.onEvent); + clearInterval(this.emitWatchersIntervalId); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'accept': this.accept(true); break; + case 'cancelAccept': this.accept(false); break; + case 'updateSettings': this.updateSettings(body.key, body.value); break; + case 'initForm': this.initForm(body); break; + case 'updateForm': this.updateForm(body.id, body.value); break; + case 'message': this.message(body); break; + case 'set': this.set(body.pos); break; + case 'check': this.check(body.crc32); break; + } + } + + @autobind + private async updateSettings(key: string, value: any) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (game.isStarted) return; + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return; + if ((game.user1Id === this.user.id) && game.user1Accepted) return; + if ((game.user2Id === this.user.id) && game.user2Accepted) return; + + if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard'].includes(key)) return; + + await ReversiGames.update(this.gameId!, { + [key]: value + }); + + publishReversiGameStream(this.gameId!, 'updateSettings', { + key: key, + value: value + }); + } + + @autobind + private async initForm(form: any) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (game.isStarted) return; + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return; + + const set = game.user1Id === this.user.id ? { + form1: form + } : { + form2: form + }; + + await ReversiGames.update(this.gameId!, set); + + publishReversiGameStream(this.gameId!, 'initForm', { + userId: this.user.id, + form + }); + } + + @autobind + private async updateForm(id: string, value: any) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (game.isStarted) return; + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return; + + const form = game.user1Id === this.user.id ? game.form2 : game.form1; + + const item = form.find((i: any) => i.id == id); + + if (item == null) return; + + item.value = value; + + const set = game.user1Id === this.user.id ? { + form2: form + } : { + form1: form + }; + + await ReversiGames.update(this.gameId!, set); + + publishReversiGameStream(this.gameId!, 'updateForm', { + userId: this.user.id, + id, + value + }); + } + + @autobind + private async message(message: any) { + if (this.user == null) return; + + message.id = Math.random(); + publishReversiGameStream(this.gameId!, 'message', { + userId: this.user.id, + message + }); + } + + @autobind + private async accept(accept: boolean) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (game.isStarted) return; + + let bothAccepted = false; + + if (game.user1Id === this.user.id) { + await ReversiGames.update(this.gameId!, { + user1Accepted: accept + }); + + publishReversiGameStream(this.gameId!, 'changeAccepts', { + user1: accept, + user2: game.user2Accepted + }); + + if (accept && game.user2Accepted) bothAccepted = true; + } else if (game.user2Id === this.user.id) { + await ReversiGames.update(this.gameId!, { + user2Accepted: accept + }); + + publishReversiGameStream(this.gameId!, 'changeAccepts', { + user1: game.user1Accepted, + user2: accept + }); + + if (accept && game.user1Accepted) bothAccepted = true; + } else { + return; + } + + if (bothAccepted) { + // 3秒後、まだacceptされていたらゲーム開始 + setTimeout(async () => { + const freshGame = await ReversiGames.findOne(this.gameId!); + if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return; + if (!freshGame.user1Accepted || !freshGame.user2Accepted) return; + + let bw: number; + if (freshGame.bw == 'random') { + bw = Math.random() > 0.5 ? 1 : 2; + } else { + bw = parseInt(freshGame.bw, 10); + } + + function getRandomMap() { + const mapCount = Object.entries(maps).length; + const rnd = Math.floor(Math.random() * mapCount); + return Object.values(maps)[rnd].data; + } + + const map = freshGame.map != null ? freshGame.map : getRandomMap(); + + await ReversiGames.update(this.gameId!, { + startedAt: new Date(), + isStarted: true, + black: bw, + map: map + }); + + //#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理 + const o = new Reversi(map, { + isLlotheo: freshGame.isLlotheo, + canPutEverywhere: freshGame.canPutEverywhere, + loopedBoard: freshGame.loopedBoard + }); + + if (o.isEnded) { + let winner; + if (o.winner === true) { + winner = freshGame.black == 1 ? freshGame.user1Id : freshGame.user2Id; + } else if (o.winner === false) { + winner = freshGame.black == 1 ? freshGame.user2Id : freshGame.user1Id; + } else { + winner = null; + } + + await ReversiGames.update(this.gameId!, { + isEnded: true, + winnerId: winner + }); + + publishReversiGameStream(this.gameId!, 'ended', { + winnerId: winner, + game: await ReversiGames.pack(this.gameId!, this.user) + }); + } + //#endregion + + publishReversiGameStream(this.gameId!, 'started', + await ReversiGames.pack(this.gameId!, this.user)); + }, 3000); + } + } + + // 石を打つ + @autobind + private async set(pos: number) { + if (this.user == null) return; + + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (!game.isStarted) return; + if (game.isEnded) return; + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return; + + const myColor = + ((game.user1Id === this.user.id) && game.black == 1) || ((game.user2Id === this.user.id) && game.black == 2) + ? true + : false; + + const o = new Reversi(game.map, { + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard + }); + + // 盤面の状態を再生 + for (const log of game.logs) { + o.put(log.color, log.pos); + } + + if (o.turn !== myColor) return; + + if (!o.canPut(myColor, pos)) return; + o.put(myColor, pos); + + let winner; + if (o.isEnded) { + if (o.winner === true) { + winner = game.black == 1 ? game.user1Id : game.user2Id; + } else if (o.winner === false) { + winner = game.black == 1 ? game.user2Id : game.user1Id; + } else { + winner = null; + } + } + + const log = { + at: new Date(), + color: myColor, + pos + }; + + const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()).toString(); + + game.logs.push(log); + + await ReversiGames.update(this.gameId!, { + crc32, + isEnded: o.isEnded, + winnerId: winner, + logs: game.logs + }); + + publishReversiGameStream(this.gameId!, 'set', Object.assign(log, { + next: o.turn + })); + + if (o.isEnded) { + publishReversiGameStream(this.gameId!, 'ended', { + winnerId: winner, + game: await ReversiGames.pack(this.gameId!, this.user) + }); + } + } + + @autobind + private async check(crc32: string | number) { + const game = await ReversiGames.findOne(this.gameId!); + if (game == null) throw new Error('game not found'); + + if (!game.isStarted) return; + + if (crc32.toString() !== game.crc32) { + this.send('rescue', await ReversiGames.pack(game, this.user)); + } + + // ついでに観戦者イベントを発行 + this.watch(game); + } + + @autobind + private watch(game: ReversiGame) { + if (this.user != null) { + if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) { + publishReversiGameStream(this.gameId!, 'watching', this.user.id); + } + } + } +} diff --git a/packages/backend/src/server/api/stream/channels/games/reversi.ts b/packages/backend/src/server/api/stream/channels/games/reversi.ts new file mode 100644 index 0000000000..3b89aac35c --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/games/reversi.ts @@ -0,0 +1,33 @@ +import autobind from 'autobind-decorator'; +import { publishMainStream } from '@/services/stream'; +import Channel from '../../channel'; +import { ReversiMatchings } from '@/models/index'; + +export default class extends Channel { + public readonly chName = 'gamesReversi'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe reversi stream + this.subscriber.on(`reversiStream:${this.user!.id}`, data => { + this.send(data); + }); + } + + @autobind + public async onMessage(type: string, body: any) { + switch (type) { + case 'ping': + if (body.id == null) return; + const matching = await ReversiMatchings.findOne({ + parentId: this.user!.id, + childId: body.id + }); + if (matching == null) return; + publishMainStream(matching.childId, 'reversiInvited', await ReversiMatchings.pack(matching, { id: matching.childId })); + break; + } + } +} diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts new file mode 100644 index 0000000000..f5983ab472 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -0,0 +1,73 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models/index'; +import { checkWordMute } from '@/misc/check-word-mute'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'globalTimeline'; + public static shouldShare = true; + public static requireCredential = false; + + @autobind + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableGlobalTimeline) { + if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; + } + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (note.visibility !== 'public') return; + if (note.channelId != null) return; + + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + + // 関係ない返信は除外 + if (note.reply) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts new file mode 100644 index 0000000000..281be4f2eb --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -0,0 +1,53 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { Notes } from '@/models/index'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'hashtag'; + public static shouldShare = false; + public static requireCredential = false; + private q: string[][]; + + @autobind + public async init(params: any) { + this.q = params.q; + + if (this.q == null) return; + + // Subscribe stream + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : []; + const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag)))); + if (!matched) return; + + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts new file mode 100644 index 0000000000..52e9aec250 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -0,0 +1,81 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { Notes } from '@/models/index'; +import { checkWordMute } from '@/misc/check-word-mute'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'homeTimeline'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (note.channelId) { + if (!this.followingChannels.has(note.channelId)) return; + } else { + // その投稿のユーザーをフォローしていなかったら弾く + if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return; + } + + if (['followers', 'specified'].includes(note.visibility)) { + note = await Notes.pack(note.id, this.user!, { + detail: true + }); + + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user!, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user!, { + detail: true + }); + } + } + + // 関係ない返信は除外 + if (note.reply) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts new file mode 100644 index 0000000000..51f95fc0cd --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -0,0 +1,89 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models/index'; +import { checkWordMute } from '@/misc/check-word-mute'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'hybridTimeline'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline && !this.user!.isAdmin && !this.user!.isModerator) return; + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + // チャンネルの投稿ではなく、自分自身の投稿 または + // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または + // チャンネルの投稿ではなく、全体公開のローカルの投稿 または + // フォローしているチャンネルの投稿 の場合だけ + if (!( + (note.channelId == null && this.user!.id === note.userId) || + (note.channelId == null && this.following.has(note.userId)) || + (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || + (note.channelId != null && this.followingChannels.has(note.channelId)) + )) return; + + if (['followers', 'specified'].includes(note.visibility)) { + note = await Notes.pack(note.id, this.user!, { + detail: true + }); + + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user!, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user!, { + detail: true + }); + } + } + + // 関係ない返信は除外 + if (note.reply) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/index.ts b/packages/backend/src/server/api/stream/channels/index.ts new file mode 100644 index 0000000000..1841573043 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/index.ts @@ -0,0 +1,37 @@ +import main from './main'; +import homeTimeline from './home-timeline'; +import localTimeline from './local-timeline'; +import hybridTimeline from './hybrid-timeline'; +import globalTimeline from './global-timeline'; +import serverStats from './server-stats'; +import queueStats from './queue-stats'; +import userList from './user-list'; +import antenna from './antenna'; +import messaging from './messaging'; +import messagingIndex from './messaging-index'; +import drive from './drive'; +import hashtag from './hashtag'; +import channel from './channel'; +import admin from './admin'; +import gamesReversi from './games/reversi'; +import gamesReversiGame from './games/reversi-game'; + +export default { + main, + homeTimeline, + localTimeline, + hybridTimeline, + globalTimeline, + serverStats, + queueStats, + userList, + antenna, + messaging, + messagingIndex, + drive, + hashtag, + channel, + admin, + gamesReversi, + gamesReversiGame +}; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts new file mode 100644 index 0000000000..a6166c2be2 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -0,0 +1,74 @@ +import autobind from 'autobind-decorator'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import Channel from '../channel'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Notes } from '@/models/index'; +import { checkWordMute } from '@/misc/check-word-mute'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'localTimeline'; + public static shouldShare = true; + public static requireCredential = false; + + @autobind + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline) { + if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; + } + + // Subscribe events + this.subscriber.on('notesStream', this.onNote); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (note.user.host !== null) return; + if (note.visibility !== 'public') return; + if (note.channelId != null && !this.followingChannels.has(note.channelId)) return; + + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + + // 関係ない返信は除外 + if (note.reply) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + + this.connection.cacheNote(note); + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off('notesStream', this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts new file mode 100644 index 0000000000..131ac30472 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -0,0 +1,43 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; +import { Notes } from '@/models/index'; + +export default class extends Channel { + public readonly chName = 'main'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe main stream channel + this.subscriber.on(`mainStream:${this.user!.id}`, async data => { + switch (data.type) { + case 'notification': { + if (data.body.userId && this.muting.has(data.body.userId)) return; + + if (data.body.note && data.body.note.isHidden) { + const note = await Notes.pack(data.body.note.id, this.user, { + detail: true + }); + this.connection.cacheNote(note); + data.body.note = note; + } + break; + } + case 'mention': { + if (this.muting.has(data.body.userId)) return; + if (data.body.isHidden) { + const note = await Notes.pack(data.body.id, this.user, { + detail: true + }); + this.connection.cacheNote(note); + data.body = note; + } + break; + } + } + + this.send(data.type, data.body); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging-index.ts b/packages/backend/src/server/api/stream/channels/messaging-index.ts new file mode 100644 index 0000000000..0c495398ab --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging-index.ts @@ -0,0 +1,16 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; + +export default class extends Channel { + public readonly chName = 'messagingIndex'; + public static shouldShare = true; + public static requireCredential = true; + + @autobind + public async init(params: any) { + // Subscribe messaging index stream + this.subscriber.on(`messagingIndexStream:${this.user!.id}`, data => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging.ts b/packages/backend/src/server/api/stream/channels/messaging.ts new file mode 100644 index 0000000000..c049e880b9 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging.ts @@ -0,0 +1,106 @@ +import autobind from 'autobind-decorator'; +import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivity } from '../../common/read-messaging-message'; +import Channel from '../channel'; +import { UserGroupJoinings, Users, MessagingMessages } from '@/models/index'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user'; +import { UserGroup } from '@/models/entities/user-group'; +import { StreamMessages } from '../types'; + +export default class extends Channel { + public readonly chName = 'messaging'; + public static shouldShare = false; + public static requireCredential = true; + + private otherpartyId: string | null; + private otherparty: User | null; + private groupId: string | null; + private subCh: `messagingStream:${User['id']}-${User['id']}` | `messagingStream:${UserGroup['id']}`; + private typers: Record<User['id'], Date> = {}; + private emitTypersIntervalId: ReturnType<typeof setInterval>; + + @autobind + public async init(params: any) { + this.otherpartyId = params.otherparty; + this.otherparty = this.otherpartyId ? await Users.findOneOrFail({ id: this.otherpartyId }) : null; + this.groupId = params.group; + + // Check joining + if (this.groupId) { + const joining = await UserGroupJoinings.findOne({ + userId: this.user!.id, + userGroupId: this.groupId + }); + + if (joining == null) { + return; + } + } + + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); + + this.subCh = this.otherpartyId + ? `messagingStream:${this.user!.id}-${this.otherpartyId}` + : `messagingStream:${this.groupId}`; + + // Subscribe messaging stream + this.subscriber.on(this.subCh, this.onEvent); + } + + @autobind + private onEvent(data: StreamMessages['messaging']['payload'] | StreamMessages['groupMessaging']['payload']) { + if (data.type === 'typing') { + const id = data.body; + const begin = this.typers[id] == null; + this.typers[id] = new Date(); + if (begin) { + this.emitTypers(); + } + } else { + this.send(data); + } + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.otherpartyId) { + readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]); + + // リモートユーザーからのメッセージだったら既読配信 + if (Users.isLocalUser(this.user!) && Users.isRemoteUser(this.otherparty!)) { + MessagingMessages.findOne(body.id).then(message => { + if (message) deliverReadActivity(this.user as ILocalUser, this.otherparty as IRemoteUser, message); + }); + } + } else if (this.groupId) { + readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]); + } + break; + } + } + + @autobind + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of Object.entries(this.typers)) { + if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; + } + + const users = await Users.packMany(Object.keys(this.typers), null, { detail: false }); + + this.send({ + type: 'typers', + body: users, + }); + } + + @autobind + public dispose() { + this.subscriber.off(this.subCh, this.onEvent); + + clearInterval(this.emitTypersIntervalId); + } +} diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts new file mode 100644 index 0000000000..0bda0cfcb9 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -0,0 +1,41 @@ +import autobind from 'autobind-decorator'; +import Xev from 'xev'; +import Channel from '../channel'; + +const ev = new Xev(); + +export default class extends Channel { + public readonly chName = 'queueStats'; + public static shouldShare = true; + public static requireCredential = false; + + @autobind + public async init(params: any) { + ev.addListener('queueStats', this.onStats); + } + + @autobind + private onStats(stats: any) { + this.send('stats', stats); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'requestLog': + ev.once(`queueStatsLog:${body.id}`, statsLog => { + this.send('statsLog', statsLog); + }); + ev.emit('requestQueueStatsLog', { + id: body.id, + length: body.length + }); + break; + } + } + + @autobind + public dispose() { + ev.removeListener('queueStats', this.onStats); + } +} diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts new file mode 100644 index 0000000000..d245a7f70c --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -0,0 +1,41 @@ +import autobind from 'autobind-decorator'; +import Xev from 'xev'; +import Channel from '../channel'; + +const ev = new Xev(); + +export default class extends Channel { + public readonly chName = 'serverStats'; + public static shouldShare = true; + public static requireCredential = false; + + @autobind + public async init(params: any) { + ev.addListener('serverStats', this.onStats); + } + + @autobind + private onStats(stats: any) { + this.send('stats', stats); + } + + @autobind + public onMessage(type: string, body: any) { + switch (type) { + case 'requestLog': + ev.once(`serverStatsLog:${body.id}`, statsLog => { + this.send('statsLog', statsLog); + }); + ev.emit('requestServerStatsLog', { + id: body.id, + length: body.length + }); + break; + } + } + + @autobind + public dispose() { + ev.removeListener('serverStats', this.onStats); + } +} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts new file mode 100644 index 0000000000..63b254605b --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -0,0 +1,92 @@ +import autobind from 'autobind-decorator'; +import Channel from '../channel'; +import { Notes, UserListJoinings, UserLists } from '@/models/index'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import { User } from '@/models/entities/user'; +import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { Packed } from '@/misc/schema'; + +export default class extends Channel { + public readonly chName = 'userList'; + public static shouldShare = false; + public static requireCredential = false; + private listId: string; + public listUsers: User['id'][] = []; + private listUsersClock: NodeJS.Timer; + + @autobind + public async init(params: any) { + this.listId = params.listId as string; + + // Check existence and owner + const list = await UserLists.findOne({ + id: this.listId, + userId: this.user!.id + }); + if (!list) return; + + // Subscribe stream + this.subscriber.on(`userListStream:${this.listId}`, this.send); + + this.subscriber.on('notesStream', this.onNote); + + this.updateListUsers(); + this.listUsersClock = setInterval(this.updateListUsers, 5000); + } + + @autobind + private async updateListUsers() { + const users = await UserListJoinings.find({ + where: { + userListId: this.listId, + }, + select: ['userId'] + }); + + this.listUsers = users.map(x => x.userId); + } + + @autobind + private async onNote(note: Packed<'Note'>) { + if (!this.listUsers.includes(note.userId)) return; + + if (['followers', 'specified'].includes(note.visibility)) { + note = await Notes.pack(note.id, this.user, { + detail: true + }); + + if (note.isHidden) { + return; + } + } else { + // リプライなら再pack + if (note.replyId != null) { + note.reply = await Notes.pack(note.replyId, this.user, { + detail: true + }); + } + // Renoteなら再pack + if (note.renoteId != null) { + note.renote = await Notes.pack(note.renoteId, this.user, { + detail: true + }); + } + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isMutedUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isBlockerUserRelated(note, this.blocking)) return; + + this.send('note', note); + } + + @autobind + public dispose() { + // Unsubscribe events + this.subscriber.off(`userListStream:${this.listId}`, this.send); + this.subscriber.off('notesStream', this.onNote); + + clearInterval(this.listUsersClock); + } +} diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts new file mode 100644 index 0000000000..da4ea5ec99 --- /dev/null +++ b/packages/backend/src/server/api/stream/index.ts @@ -0,0 +1,421 @@ +import autobind from 'autobind-decorator'; +import * as websocket from 'websocket'; +import { readNotification } from '../common/read-notification'; +import call from '../call'; +import readNote from '@/services/note/read'; +import Channel from './channel'; +import channels from './channels/index'; +import { EventEmitter } from 'events'; +import { User } from '@/models/entities/user'; +import { Channel as ChannelModel } from '@/models/entities/channel'; +import { Users, Followings, Mutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index'; +import { ApiError } from '../error'; +import { AccessToken } from '@/models/entities/access-token'; +import { UserProfile } from '@/models/entities/user-profile'; +import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream'; +import { UserGroup } from '@/models/entities/user-group'; +import { StreamEventEmitter, StreamMessages } from './types'; +import { Packed } from '@/misc/schema'; + +/** + * Main stream connection + */ +export default class Connection { + public user?: User; + public userProfile?: UserProfile; + public following: Set<User['id']> = new Set(); + public muting: Set<User['id']> = new Set(); + public blocking: Set<User['id']> = new Set(); // "被"blocking + public followingChannels: Set<ChannelModel['id']> = new Set(); + public token?: AccessToken; + private wsConnection: websocket.connection; + public subscriber: StreamEventEmitter; + private channels: Channel[] = []; + private subscribingNotes: any = {}; + private cachedNotes: Packed<'Note'>[] = []; + + constructor( + wsConnection: websocket.connection, + subscriber: EventEmitter, + user: User | null | undefined, + token: AccessToken | null | undefined + ) { + this.wsConnection = wsConnection; + this.subscriber = subscriber; + if (user) this.user = user; + if (token) this.token = token; + + this.wsConnection.on('message', this.onWsConnectionMessage); + + this.subscriber.on('broadcast', data => { + this.onBroadcastMessage(data); + }); + + if (this.user) { + this.updateFollowing(); + this.updateMuting(); + this.updateBlocking(); + this.updateFollowingChannels(); + this.updateUserProfile(); + + this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); + } + } + + @autobind + private onUserEvent(data: StreamMessages['user']['payload']) { // { type, body }と展開するとそれぞれ型が分離してしまう + switch (data.type) { + case 'follow': + this.following.add(data.body.id); + break; + + case 'unfollow': + this.following.delete(data.body.id); + break; + + case 'mute': + this.muting.add(data.body.id); + break; + + case 'unmute': + this.muting.delete(data.body.id); + break; + + // TODO: block events + + case 'followChannel': + this.followingChannels.add(data.body.id); + break; + + case 'unfollowChannel': + this.followingChannels.delete(data.body.id); + break; + + case 'updateUserProfile': + this.userProfile = data.body; + break; + + case 'terminate': + this.wsConnection.close(); + this.dispose(); + break; + + default: + break; + } + } + + /** + * クライアントからメッセージ受信時 + */ + @autobind + private async onWsConnectionMessage(data: websocket.IMessage) { + if (data.utf8Data == null) return; + + let obj: Record<string, any>; + + try { + obj = JSON.parse(data.utf8Data); + } catch (e) { + return; + } + + const { type, body } = obj; + + switch (type) { + case 'api': this.onApiRequest(body); break; + case 'readNotification': this.onReadNotification(body); break; + case 'subNote': this.onSubscribeNote(body); break; + case 's': this.onSubscribeNote(body); break; // alias + case 'sr': this.onSubscribeNote(body); this.readNote(body); break; + case 'unsubNote': this.onUnsubscribeNote(body); break; + case 'un': this.onUnsubscribeNote(body); break; // alias + case 'connect': this.onChannelConnectRequested(body); break; + case 'disconnect': this.onChannelDisconnectRequested(body); break; + case 'channel': this.onChannelMessageRequested(body); break; + case 'ch': this.onChannelMessageRequested(body); break; // alias + + // 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、 + // クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別 + // なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。 + case 'typingOnChannel': this.typingOnChannel(body.channel); break; + case 'typingOnMessaging': this.typingOnMessaging(body); break; + } + } + + @autobind + private onBroadcastMessage(data: StreamMessages['broadcast']['payload']) { + this.sendMessageToWs(data.type, data.body); + } + + @autobind + public cacheNote(note: Packed<'Note'>) { + const add = (note: Packed<'Note'>) => { + const existIndex = this.cachedNotes.findIndex(n => n.id === note.id); + if (existIndex > -1) { + this.cachedNotes[existIndex] = note; + return; + } + + this.cachedNotes.unshift(note); + if (this.cachedNotes.length > 32) { + this.cachedNotes.splice(32); + } + }; + + add(note); + if (note.reply) add(note.reply); + if (note.renote) add(note.renote); + } + + @autobind + private readNote(body: any) { + const id = body.id; + + const note = this.cachedNotes.find(n => n.id === id); + if (note == null) return; + + if (this.user && (note.userId !== this.user.id)) { + readNote(this.user.id, [note], { + following: this.following, + followingChannels: this.followingChannels, + }); + } + } + + /** + * APIリクエスト要求時 + */ + @autobind + private async onApiRequest(payload: any) { + // 新鮮なデータを利用するためにユーザーをフェッチ + const user = this.user ? await Users.findOne(this.user.id) : null; + + const endpoint = payload.endpoint || payload.ep; // alias + + // 呼び出し + call(endpoint, user, this.token, payload.data).then(res => { + this.sendMessageToWs(`api:${payload.id}`, { res }); + }).catch((e: ApiError) => { + this.sendMessageToWs(`api:${payload.id}`, { + error: { + message: e.message, + code: e.code, + id: e.id, + kind: e.kind, + ...(e.info ? { info: e.info } : {}) + } + }); + }); + } + + @autobind + private onReadNotification(payload: any) { + if (!payload.id) return; + readNotification(this.user!.id, [payload.id]); + } + + /** + * 投稿購読要求時 + */ + @autobind + private onSubscribeNote(payload: any) { + if (!payload.id) return; + + if (this.subscribingNotes[payload.id] == null) { + this.subscribingNotes[payload.id] = 0; + } + + this.subscribingNotes[payload.id]++; + + if (this.subscribingNotes[payload.id] === 1) { + this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage); + } + } + + /** + * 投稿購読解除要求時 + */ + @autobind + private onUnsubscribeNote(payload: any) { + if (!payload.id) return; + + this.subscribingNotes[payload.id]--; + if (this.subscribingNotes[payload.id] <= 0) { + delete this.subscribingNotes[payload.id]; + this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage); + } + } + + @autobind + private async onNoteStreamMessage(data: StreamMessages['note']['payload']) { + this.sendMessageToWs('noteUpdated', { + id: data.body.id, + type: data.type, + body: data.body.body, + }); + } + + /** + * チャンネル接続要求時 + */ + @autobind + private onChannelConnectRequested(payload: any) { + const { channel, id, params, pong } = payload; + this.connectChannel(id, params, channel, pong); + } + + /** + * チャンネル切断要求時 + */ + @autobind + private onChannelDisconnectRequested(payload: any) { + const { id } = payload; + this.disconnectChannel(id); + } + + /** + * クライアントにメッセージ送信 + */ + @autobind + public sendMessageToWs(type: string, payload: any) { + this.wsConnection.send(JSON.stringify({ + type: type, + body: payload + })); + } + + /** + * チャンネルに接続 + */ + @autobind + public connectChannel(id: string, params: any, channel: string, pong = false) { + if ((channels as any)[channel].requireCredential && this.user == null) { + return; + } + + // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 + if ((channels as any)[channel].shouldShare && this.channels.some(c => c.chName === channel)) { + return; + } + + const ch: Channel = new (channels as any)[channel](id, this); + this.channels.push(ch); + ch.init(params); + + if (pong) { + this.sendMessageToWs('connected', { + id: id + }); + } + } + + /** + * チャンネルから切断 + * @param id チャンネルコネクションID + */ + @autobind + public disconnectChannel(id: string) { + const channel = this.channels.find(c => c.id === id); + + if (channel) { + if (channel.dispose) channel.dispose(); + this.channels = this.channels.filter(c => c.id !== id); + } + } + + /** + * チャンネルへメッセージ送信要求時 + * @param data メッセージ + */ + @autobind + private onChannelMessageRequested(data: any) { + const channel = this.channels.find(c => c.id === data.id); + if (channel != null && channel.onMessage != null) { + channel.onMessage(data.type, data.body); + } + } + + @autobind + private typingOnChannel(channel: ChannelModel['id']) { + if (this.user) { + publishChannelStream(channel, 'typing', this.user.id); + } + } + + @autobind + private typingOnMessaging(param: { partner?: User['id']; group?: UserGroup['id']; }) { + if (this.user) { + if (param.partner) { + publishMessagingStream(param.partner, this.user.id, 'typing', this.user.id); + } else if (param.group) { + publishGroupMessagingStream(param.group, 'typing', this.user.id); + } + } + } + + @autobind + private async updateFollowing() { + const followings = await Followings.find({ + where: { + followerId: this.user!.id + }, + select: ['followeeId'] + }); + + this.following = new Set<string>(followings.map(x => x.followeeId)); + } + + @autobind + private async updateMuting() { + const mutings = await Mutings.find({ + where: { + muterId: this.user!.id + }, + select: ['muteeId'] + }); + + this.muting = new Set<string>(mutings.map(x => x.muteeId)); + } + + @autobind + private async updateBlocking() { // ここでいうBlockingは被Blockingの意 + const blockings = await Blockings.find({ + where: { + blockeeId: this.user!.id + }, + select: ['blockerId'] + }); + + this.blocking = new Set<string>(blockings.map(x => x.blockerId)); + } + + @autobind + private async updateFollowingChannels() { + const followings = await ChannelFollowings.find({ + where: { + followerId: this.user!.id + }, + select: ['followeeId'] + }); + + this.followingChannels = new Set<string>(followings.map(x => x.followeeId)); + } + + @autobind + private async updateUserProfile() { + this.userProfile = await UserProfiles.findOne({ + userId: this.user!.id + }); + } + + /** + * ストリームが切れたとき + */ + @autobind + public dispose() { + for (const c of this.channels.filter(c => c.dispose)) { + if (c.dispose) c.dispose(); + } + } +} diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts new file mode 100644 index 0000000000..70eb5c5ce5 --- /dev/null +++ b/packages/backend/src/server/api/stream/types.ts @@ -0,0 +1,299 @@ +import { EventEmitter } from 'events'; +import Emitter from 'strict-event-emitter-types'; +import { Channel } from '@/models/entities/channel'; +import { User } from '@/models/entities/user'; +import { UserProfile } from '@/models/entities/user-profile'; +import { Note } from '@/models/entities/note'; +import { Antenna } from '@/models/entities/antenna'; +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFolder } from '@/models/entities/drive-folder'; +import { Emoji } from '@/models/entities/emoji'; +import { UserList } from '@/models/entities/user-list'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { UserGroup } from '@/models/entities/user-group'; +import { ReversiGame } from '@/models/entities/games/reversi/game'; +import { AbuseUserReport } from '@/models/entities/abuse-user-report'; +import { Signin } from '@/models/entities/signin'; +import { Page } from '@/models/entities/page'; +import { Packed } from '@/misc/schema'; + +//#region Stream type-body definitions +export interface InternalStreamTypes { + antennaCreated: Antenna; + antennaDeleted: Antenna; + antennaUpdated: Antenna; +} + +export interface BroadcastTypes { + emojiAdded: { + emoji: Packed<'Emoji'>; + }; +} + +export interface UserStreamTypes { + terminate: {}; + followChannel: Channel; + unfollowChannel: Channel; + updateUserProfile: UserProfile; + mute: User; + unmute: User; + follow: Packed<'User'>; + unfollow: Packed<'User'>; + userAdded: Packed<'User'>; +} + +export interface MainStreamTypes { + notification: Packed<'Notification'>; + mention: Packed<'Note'>; + reply: Packed<'Note'>; + renote: Packed<'Note'>; + follow: Packed<'User'>; + followed: Packed<'User'>; + unfollow: Packed<'User'>; + meUpdated: Packed<'User'>; + pageEvent: { + pageId: Page['id']; + event: string; + var: any; + userId: User['id']; + user: Packed<'User'>; + }; + urlUploadFinished: { + marker?: string | null; + file: Packed<'DriveFile'>; + }; + readAllNotifications: undefined; + unreadNotification: Packed<'Notification'>; + unreadMention: Note['id']; + readAllUnreadMentions: undefined; + unreadSpecifiedNote: Note['id']; + readAllUnreadSpecifiedNotes: undefined; + readAllMessagingMessages: undefined; + messagingMessage: Packed<'MessagingMessage'>; + unreadMessagingMessage: Packed<'MessagingMessage'>; + readAllAntennas: undefined; + unreadAntenna: Antenna; + readAllAnnouncements: undefined; + readAllChannels: undefined; + unreadChannel: Note['id']; + myTokenRegenerated: undefined; + reversiNoInvites: undefined; + reversiInvited: Packed<'ReversiMatching'>; + signin: Signin; + registryUpdated: { + scope?: string[]; + key: string; + value: any | null; + }; + driveFileCreated: Packed<'DriveFile'>; + readAntenna: Antenna; +} + +export interface DriveStreamTypes { + fileCreated: Packed<'DriveFile'>; + fileDeleted: DriveFile['id']; + fileUpdated: Packed<'DriveFile'>; + folderCreated: Packed<'DriveFolder'>; + folderDeleted: DriveFolder['id']; + folderUpdated: Packed<'DriveFolder'>; +} + +export interface NoteStreamTypes { + pollVoted: { + choice: number; + userId: User['id']; + }; + deleted: { + deletedAt: Date; + }; + reacted: { + reaction: string; + emoji?: Emoji; + userId: User['id']; + }; + unreacted: { + reaction: string; + userId: User['id']; + }; +} +type NoteStreamEventTypes = { + [key in keyof NoteStreamTypes]: { + id: Note['id']; + body: NoteStreamTypes[key]; + }; +}; + +export interface ChannelStreamTypes { + typing: User['id']; +} + +export interface UserListStreamTypes { + userAdded: Packed<'User'>; + userRemoved: Packed<'User'>; +} + +export interface AntennaStreamTypes { + note: Note; +} + +export interface MessagingStreamTypes { + read: MessagingMessage['id'][]; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface GroupMessagingStreamTypes { + read: { + ids: MessagingMessage['id'][]; + userId: User['id']; + }; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface MessagingIndexStreamTypes { + read: MessagingMessage['id'][]; + message: Packed<'MessagingMessage'>; +} + +export interface ReversiStreamTypes { + matched: Packed<'ReversiGame'>; + invited: Packed<'ReversiMatching'>; +} + +export interface ReversiGameStreamTypes { + started: Packed<'ReversiGame'>; + ended: { + winnerId?: User['id'] | null, + game: Packed<'ReversiGame'>; + }; + updateSettings: { + key: string; + value: FIXME; + }; + initForm: { + userId: User['id']; + form: FIXME; + }; + updateForm: { + userId: User['id']; + id: string; + value: FIXME; + }; + message: { + userId: User['id']; + message: FIXME; + }; + changeAccepts: { + user1: boolean; + user2: boolean; + }; + set: { + at: Date; + color: boolean; + pos: number; + next: boolean; + }; + watching: User['id']; +} + +export interface AdminStreamTypes { + newAbuseUserReport: { + id: AbuseUserReport['id']; + targetUserId: User['id'], + reporterId: User['id'], + comment: string; + }; +} +//#endregion + +// 辞書(interface or type)から{ type, body }ユニオンを定義 +// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type +// VS Codeの展開を防止するためにEvents型を定義 +type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } }; +type EventUnionFromDictionary< + T extends object, + U = Events<T> +> = U[keyof U]; + +// name/messages(spec) pairs dictionary +export type StreamMessages = { + internal: { + name: 'internal'; + payload: EventUnionFromDictionary<InternalStreamTypes>; + }; + broadcast: { + name: 'broadcast'; + payload: EventUnionFromDictionary<BroadcastTypes>; + }; + user: { + name: `user:${User['id']}`; + payload: EventUnionFromDictionary<UserStreamTypes>; + }; + main: { + name: `mainStream:${User['id']}`; + payload: EventUnionFromDictionary<MainStreamTypes>; + }; + drive: { + name: `driveStream:${User['id']}`; + payload: EventUnionFromDictionary<DriveStreamTypes>; + }; + note: { + name: `noteStream:${Note['id']}`; + payload: EventUnionFromDictionary<NoteStreamEventTypes>; + }; + channel: { + name: `channelStream:${Channel['id']}`; + payload: EventUnionFromDictionary<ChannelStreamTypes>; + }; + userList: { + name: `userListStream:${UserList['id']}`; + payload: EventUnionFromDictionary<UserListStreamTypes>; + }; + antenna: { + name: `antennaStream:${Antenna['id']}`; + payload: EventUnionFromDictionary<AntennaStreamTypes>; + }; + messaging: { + name: `messagingStream:${User['id']}-${User['id']}`; + payload: EventUnionFromDictionary<MessagingStreamTypes>; + }; + groupMessaging: { + name: `messagingStream:${UserGroup['id']}`; + payload: EventUnionFromDictionary<GroupMessagingStreamTypes>; + }; + messagingIndex: { + name: `messagingIndexStream:${User['id']}`; + payload: EventUnionFromDictionary<MessagingIndexStreamTypes>; + }; + reversi: { + name: `reversiStream:${User['id']}`; + payload: EventUnionFromDictionary<ReversiStreamTypes>; + }; + reversiGame: { + name: `reversiGameStream:${ReversiGame['id']}`; + payload: EventUnionFromDictionary<ReversiGameStreamTypes>; + }; + admin: { + name: `adminStream:${User['id']}`; + payload: EventUnionFromDictionary<AdminStreamTypes>; + }; + notes: { + name: 'notesStream'; + payload: Packed<'Note'>; + }; +}; + +// API event definitions +// ストリームごとのEmitterの辞書を用意 +type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter<EventEmitter, { [y in StreamMessages[x]['name']]: (e: StreamMessages[x]['payload']) => void }> }; +// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; +// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする +export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof StreamMessages]>; +// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる + +// provide stream channels union +export type StreamChannels = StreamMessages[keyof StreamMessages]['name']; diff --git a/packages/backend/src/server/api/streaming.ts b/packages/backend/src/server/api/streaming.ts new file mode 100644 index 0000000000..8808bc9860 --- /dev/null +++ b/packages/backend/src/server/api/streaming.ts @@ -0,0 +1,67 @@ +import * as http from 'http'; +import * as websocket from 'websocket'; + +import MainStreamConnection from './stream/index'; +import { ParsedUrlQuery } from 'querystring'; +import authenticate from './authenticate'; +import { EventEmitter } from 'events'; +import { subsdcriber as redisClient } from '../../db/redis'; +import { Users } from '@/models/index'; + +module.exports = (server: http.Server) => { + // Init websocket server + const ws = new websocket.server({ + httpServer: server + }); + + ws.on('request', async (request) => { + const q = request.resourceURL.query as ParsedUrlQuery; + + // TODO: トークンが間違ってるなどしてauthenticateに失敗したら + // コネクション切断するなりエラーメッセージ返すなりする + // (現状はエラーがキャッチされておらずサーバーのログに流れて邪魔なので) + const [user, app] = await authenticate(q.i as string); + + if (user?.isSuspended) { + request.reject(400); + return; + } + + const connection = request.accept(); + + const ev = new EventEmitter(); + + async function onRedisMessage(_: string, data: string) { + const parsed = JSON.parse(data); + ev.emit(parsed.channel, parsed.message); + } + + redisClient.on('message', onRedisMessage); + + const main = new MainStreamConnection(connection, ev, user, app); + + const intervalId = user ? setInterval(() => { + Users.update(user.id, { + lastActiveDate: new Date(), + }); + }, 1000 * 60 * 5) : null; + if (user) { + Users.update(user.id, { + lastActiveDate: new Date(), + }); + } + + connection.once('close', () => { + ev.removeAllListeners(); + main.dispose(); + redisClient.off('message', onRedisMessage); + if (intervalId) clearInterval(intervalId); + }); + + connection.on('message', async (data) => { + if (data.utf8Data === 'ping') { + connection.send('pong'); + } + }); + }); +}; diff --git a/packages/backend/src/server/file/assets/bad-egg.png b/packages/backend/src/server/file/assets/bad-egg.png Binary files differnew file mode 100644 index 0000000000..e96ba0dcc1 --- /dev/null +++ b/packages/backend/src/server/file/assets/bad-egg.png diff --git a/packages/backend/src/server/file/assets/cache-expired.png b/packages/backend/src/server/file/assets/cache-expired.png Binary files differnew file mode 100644 index 0000000000..5d988c502b --- /dev/null +++ b/packages/backend/src/server/file/assets/cache-expired.png diff --git a/packages/backend/src/server/file/assets/dummy.png b/packages/backend/src/server/file/assets/dummy.png Binary files differnew file mode 100644 index 0000000000..39332b0c1b --- /dev/null +++ b/packages/backend/src/server/file/assets/dummy.png diff --git a/packages/backend/src/server/file/assets/not-an-image.png b/packages/backend/src/server/file/assets/not-an-image.png Binary files differnew file mode 100644 index 0000000000..39e4aa0892 --- /dev/null +++ b/packages/backend/src/server/file/assets/not-an-image.png diff --git a/packages/backend/src/server/file/assets/thumbnail-not-available.png b/packages/backend/src/server/file/assets/thumbnail-not-available.png Binary files differnew file mode 100644 index 0000000000..07cad9919c --- /dev/null +++ b/packages/backend/src/server/file/assets/thumbnail-not-available.png diff --git a/packages/backend/src/server/file/assets/tombstone.png b/packages/backend/src/server/file/assets/tombstone.png Binary files differnew file mode 100644 index 0000000000..83159d6b3c --- /dev/null +++ b/packages/backend/src/server/file/assets/tombstone.png diff --git a/packages/backend/src/server/file/index.ts b/packages/backend/src/server/file/index.ts new file mode 100644 index 0000000000..a455acd1cf --- /dev/null +++ b/packages/backend/src/server/file/index.ts @@ -0,0 +1,41 @@ +/** + * File Server + */ + +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import * as Koa from 'koa'; +import * as cors from '@koa/cors'; +import * as Router from '@koa/router'; +import sendDriveFile from './send-drive-file'; + +//const _filename = fileURLToPath(import.meta.url); +const _filename = __filename; +const _dirname = dirname(_filename); + +// Init app +const app = new Koa(); +app.use(cors()); +app.use(async (ctx, next) => { + ctx.set('Content-Security-Policy', `default-src 'none'; style-src 'unsafe-inline'`); + await next(); +}); + +// Init router +const router = new Router(); + +router.get('/app-default.jpg', ctx => { + const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); + ctx.body = file; + ctx.set('Content-Type', 'image/jpeg'); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); +}); + +router.get('/:key', sendDriveFile); +router.get('/:key/(.*)', sendDriveFile); + +// Register router +app.use(router.routes()); + +module.exports = app; diff --git a/packages/backend/src/server/file/send-drive-file.ts b/packages/backend/src/server/file/send-drive-file.ts new file mode 100644 index 0000000000..1908c969a5 --- /dev/null +++ b/packages/backend/src/server/file/send-drive-file.ts @@ -0,0 +1,126 @@ +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import * as Koa from 'koa'; +import * as send from 'koa-send'; +import * as rename from 'rename'; +import * as tmp from 'tmp'; +import { serverLogger } from '../index'; +import { contentDisposition } from '@/misc/content-disposition'; +import { DriveFiles } from '@/models/index'; +import { InternalStorage } from '@/services/drive/internal-storage'; +import { downloadUrl } from '@/misc/download-url'; +import { detectType } from '@/misc/get-file-info'; +import { convertToJpeg, convertToPngOrJpeg } from '@/services/drive/image-processor'; +import { GenerateVideoThumbnail } from '@/services/drive/generate-video-thumbnail'; +import { StatusError } from '@/misc/fetch'; + +//const _filename = fileURLToPath(import.meta.url); +const _filename = __filename; +const _dirname = dirname(_filename); + +const assets = `${_dirname}/../../server/file/assets/`; + +const commonReadableHandlerGenerator = (ctx: Koa.Context) => (e: Error): void => { + serverLogger.error(e); + ctx.status = 500; + ctx.set('Cache-Control', 'max-age=300'); +}; + +export default async function(ctx: Koa.Context) { + const key = ctx.params.key; + + // Fetch drive file + const file = await DriveFiles.createQueryBuilder('file') + .where('file.accessKey = :accessKey', { accessKey: key }) + .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) + .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) + .getOne(); + + if (file == null) { + ctx.status = 404; + ctx.set('Cache-Control', 'max-age=86400'); + await send(ctx as any, '/dummy.png', { root: assets }); + return; + } + + const isThumbnail = file.thumbnailAccessKey === key; + const isWebpublic = file.webpublicAccessKey === key; + + if (!file.storedInternal) { + if (file.isLink && file.uri) { // 期限切れリモートファイル + const [path, cleanup] = await new Promise<[string, any]>((res, rej) => { + tmp.file((e, path, fd, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); + + try { + await downloadUrl(file.uri, path); + + const { mime, ext } = await detectType(path); + + const convertFile = async () => { + if (isThumbnail) { + if (['image/jpeg', 'image/webp'].includes(mime)) { + return await convertToJpeg(path, 498, 280); + } else if (['image/png'].includes(mime)) { + return await convertToPngOrJpeg(path, 498, 280); + } else if (mime.startsWith('video/')) { + return await GenerateVideoThumbnail(path); + } + } + + return { + data: fs.readFileSync(path), + ext, + type: mime, + }; + }; + + const image = await convertFile(); + ctx.body = image.data; + ctx.set('Content-Type', image.type); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + } catch (e) { + serverLogger.error(`${e}`); + + if (e instanceof StatusError && e.isClientError) { + ctx.status = e.statusCode; + ctx.set('Cache-Control', 'max-age=86400'); + } else { + ctx.status = 500; + ctx.set('Cache-Control', 'max-age=300'); + } + } finally { + cleanup(); + } + return; + } + + ctx.status = 204; + ctx.set('Cache-Control', 'max-age=86400'); + return; + } + + if (isThumbnail || isWebpublic) { + const { mime, ext } = await detectType(InternalStorage.resolvePath(key)); + const filename = rename(file.name, { + suffix: isThumbnail ? '-thumb' : '-web', + extname: ext ? `.${ext}` : undefined + }).toString(); + + ctx.body = InternalStorage.read(key); + ctx.set('Content-Type', mime); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.set('Content-Disposition', contentDisposition('inline', filename)); + } else { + const readable = InternalStorage.read(file.accessKey!); + readable.on('error', commonReadableHandlerGenerator(ctx)); + ctx.body = readable; + ctx.set('Content-Type', file.type); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.set('Content-Disposition', contentDisposition('inline', file.name)); + } +} diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts new file mode 100644 index 0000000000..507178a46c --- /dev/null +++ b/packages/backend/src/server/index.ts @@ -0,0 +1,179 @@ +/** + * Core Server + */ + +import * as fs from 'fs'; +import * as http from 'http'; +import * as http2 from 'http2'; +import * as https from 'https'; +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import * as mount from 'koa-mount'; +import * as koaLogger from 'koa-logger'; +import * as requestStats from 'request-stats'; +import * as slow from 'koa-slow'; + +import activityPub from './activitypub'; +import nodeinfo from './nodeinfo'; +import wellKnown from './well-known'; +import config from '@/config/index'; +import apiServer from './api/index'; +import { sum } from '@/prelude/array'; +import Logger from '@/services/logger'; +import { envOption } from '../env'; +import { UserProfiles, Users } from '@/models/index'; +import { networkChart } from '@/services/chart/index'; +import { genAvatar } from '@/misc/gen-avatar'; +import { createTemp } from '@/misc/create-temp'; +import { publishMainStream } from '@/services/stream'; +import * as Acct from 'misskey-js/built/acct'; + +export const serverLogger = new Logger('server', 'gray', false); + +// Init app +const app = new Koa(); +app.proxy = true; + +if (!['production', 'test'].includes(process.env.NODE_ENV || '')) { + // Logger + app.use(koaLogger(str => { + serverLogger.info(str); + })); + + // Delay + if (envOption.slow) { + app.use(slow({ + delay: 3000 + })); + } +} + +// HSTS +// 6months (15552000sec) +if (config.url.startsWith('https') && !config.disableHsts) { + app.use(async (ctx, next) => { + ctx.set('strict-transport-security', 'max-age=15552000; preload'); + await next(); + }); +} + +app.use(mount('/api', apiServer)); +app.use(mount('/files', require('./file'))); +app.use(mount('/proxy', require('./proxy'))); + +// Init router +const router = new Router(); + +// Routing +router.use(activityPub.routes()); +router.use(nodeinfo.routes()); +router.use(wellKnown.routes()); + +router.get('/avatar/@:acct', async ctx => { + const { username, host } = Acct.parse(ctx.params.acct); + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host: host === config.host ? null : host, + isSuspended: false + }); + + if (user) { + ctx.redirect(Users.getAvatarUrl(user)); + } else { + ctx.redirect('/static-assets/user-unknown.png'); + } +}); + +router.get('/random-avatar/:x', async ctx => { + const [temp] = await createTemp(); + await genAvatar(ctx.params.x, fs.createWriteStream(temp)); + ctx.set('Content-Type', 'image/png'); + ctx.body = fs.createReadStream(temp); +}); + +router.get('/verify-email/:code', async ctx => { + const profile = await UserProfiles.findOne({ + emailVerifyCode: ctx.params.code + }); + + if (profile != null) { + ctx.body = 'Verify succeeded!'; + ctx.status = 200; + + await UserProfiles.update({ userId: profile.userId }, { + emailVerified: true, + emailVerifyCode: null + }); + + publishMainStream(profile.userId, 'meUpdated', await Users.pack(profile.userId, { id: profile.userId }, { + detail: true, + includeSecrets: true + })); + } else { + ctx.status = 404; + } +}); + +// Register router +app.use(router.routes()); + +app.use(mount(require('./web'))); + +function createServer() { + if (config.https) { + const certs: any = {}; + for (const k of Object.keys(config.https)) { + certs[k] = fs.readFileSync(config.https[k]); + } + certs['allowHTTP1'] = true; + return http2.createSecureServer(certs, app.callback()) as https.Server; + } else { + return http.createServer(app.callback()); + } +} + +// For testing +export const startServer = () => { + const server = createServer(); + + // Init stream server + require('./api/streaming')(server); + + // Listen + server.listen(config.port); + + return server; +}; + +export default () => new Promise(resolve => { + const server = createServer(); + + // Init stream server + require('./api/streaming')(server); + + // Listen + server.listen(config.port, resolve); + + //#region Network stats + let queue: any[] = []; + + requestStats(server, (stats: any) => { + if (stats.ok) { + queue.push(stats); + } + }); + + // Bulk write + setInterval(() => { + if (queue.length === 0) return; + + const requests = queue.length; + const time = sum(queue.map(x => x.time)); + const incomingBytes = sum(queue.map(x => x.req.byets)); + const outgoingBytes = sum(queue.map(x => x.res.byets)); + queue = []; + + networkChart.update(requests, time, incomingBytes, outgoingBytes); + }, 5000); + //#endregion +}); diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts new file mode 100644 index 0000000000..6a864fcc52 --- /dev/null +++ b/packages/backend/src/server/nodeinfo.ts @@ -0,0 +1,101 @@ +import * as Router from '@koa/router'; +import config from '@/config/index'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Users } from '@/models/index'; +// import User from '../models/user'; +// import Note from '../models/note'; + +const router = new Router(); + +const nodeinfo2_1path = '/nodeinfo/2.1'; +const nodeinfo2_0path = '/nodeinfo/2.0'; + +export const links = [/* (awaiting release) { + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', + href: config.url + nodeinfo2_1path +}, */{ + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', + href: config.url + nodeinfo2_0path +}]; + +const nodeinfo2 = async () => { + const [ + meta, + // total, + // activeHalfyear, + // activeMonth, + // localPosts, + // localComments + ] = await Promise.all([ + fetchMeta(true), + // User.count({ host: null }), + // User.count({ host: null, updatedAt: { $gt: new Date(Date.now() - 15552000000) } }), + // User.count({ host: null, updatedAt: { $gt: new Date(Date.now() - 2592000000) } }), + // Note.count({ '_user.host': null, replyId: null }), + // Note.count({ '_user.host': null, replyId: { $ne: null } }) + ]); + + const proxyAccount = meta.proxyAccountId ? await Users.pack(meta.proxyAccountId).catch(() => null) : null; + + return { + software: { + name: 'misskey', + version: config.version, + repository: meta.repositoryUrl, + }, + protocols: ['activitypub'], + services: { + inbound: [] as string[], + outbound: ['atom1.0', 'rss2.0'] + }, + openRegistrations: !meta.disableRegistration, + usage: { + users: {} // { total, activeHalfyear, activeMonth }, + // localPosts, + // localComments + }, + metadata: { + nodeName: meta.name, + nodeDescription: meta.description, + maintainer: { + name: meta.maintainerName, + email: meta.maintainerEmail + }, + langs: meta.langs, + tosUrl: meta.ToSUrl, + repositoryUrl: meta.repositoryUrl, + feedbackUrl: meta.feedbackUrl, + disableRegistration: meta.disableRegistration, + disableLocalTimeline: meta.disableLocalTimeline, + disableGlobalTimeline: meta.disableGlobalTimeline, + emailRequiredForSignup: meta.emailRequiredForSignup, + enableHcaptcha: meta.enableHcaptcha, + enableRecaptcha: meta.enableRecaptcha, + maxNoteTextLength: meta.maxNoteTextLength, + enableTwitterIntegration: meta.enableTwitterIntegration, + enableGithubIntegration: meta.enableGithubIntegration, + enableDiscordIntegration: meta.enableDiscordIntegration, + enableEmail: meta.enableEmail, + enableServiceWorker: meta.enableServiceWorker, + proxyAccountName: proxyAccount ? proxyAccount.username : null, + } + }; +}; + +router.get(nodeinfo2_1path, async ctx => { + const base = await nodeinfo2(); + + ctx.body = { version: '2.1', ...base }; + ctx.set('Cache-Control', 'public, max-age=600'); +}); + +router.get(nodeinfo2_0path, async ctx => { + const base = await nodeinfo2(); + + delete base.software.repository; + + ctx.body = { version: '2.0', ...base }; + ctx.set('Cache-Control', 'public, max-age=600'); +}); + +export default router; diff --git a/packages/backend/src/server/proxy/index.ts b/packages/backend/src/server/proxy/index.ts new file mode 100644 index 0000000000..b8993f19f8 --- /dev/null +++ b/packages/backend/src/server/proxy/index.ts @@ -0,0 +1,26 @@ +/** + * Media Proxy + */ + +import * as Koa from 'koa'; +import * as cors from '@koa/cors'; +import * as Router from '@koa/router'; +import { proxyMedia } from './proxy-media'; + +// Init app +const app = new Koa(); +app.use(cors()); +app.use(async (ctx, next) => { + ctx.set('Content-Security-Policy', `default-src 'none'; style-src 'unsafe-inline'`); + await next(); +}); + +// Init router +const router = new Router(); + +router.get('/:url*', proxyMedia); + +// Register router +app.use(router.routes()); + +module.exports = app; diff --git a/packages/backend/src/server/proxy/proxy-media.ts b/packages/backend/src/server/proxy/proxy-media.ts new file mode 100644 index 0000000000..9e13c0877f --- /dev/null +++ b/packages/backend/src/server/proxy/proxy-media.ts @@ -0,0 +1,51 @@ +import * as fs from 'fs'; +import * as Koa from 'koa'; +import { serverLogger } from '../index'; +import { IImage, convertToPng, convertToJpeg } from '@/services/drive/image-processor'; +import { createTemp } from '@/misc/create-temp'; +import { downloadUrl } from '@/misc/download-url'; +import { detectType } from '@/misc/get-file-info'; +import { StatusError } from '@/misc/fetch'; + +export async function proxyMedia(ctx: Koa.Context) { + const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url; + + // Create temp file + const [path, cleanup] = await createTemp(); + + try { + await downloadUrl(url, path); + + const { mime, ext } = await detectType(path); + + if (!mime.startsWith('image/')) throw 403; + + let image: IImage; + + if ('static' in ctx.query && ['image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp'].includes(mime)) { + image = await convertToPng(path, 498, 280); + } else if ('preview' in ctx.query && ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng'].includes(mime)) { + image = await convertToJpeg(path, 200, 200); + } else { + image = { + data: fs.readFileSync(path), + ext, + type: mime, + }; + } + + ctx.set('Content-Type', image.type); + ctx.set('Cache-Control', 'max-age=31536000, immutable'); + ctx.body = image.data; + } catch (e) { + serverLogger.error(`${e}`); + + if (e instanceof StatusError && e.isClientError) { + ctx.status = e.statusCode; + } else { + ctx.status = 500; + } + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/server/web/bios.css b/packages/backend/src/server/web/bios.css new file mode 100644 index 0000000000..b0da3ee39b --- /dev/null +++ b/packages/backend/src/server/web/bios.css @@ -0,0 +1,40 @@ +* { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; +} + +html { + background: #ffb4e1; +} + +main { + background: #dedede; +} +main > .tabs { + padding: 16px; + border-bottom: solid 4px #c3c3c3; +} + +#lsEditor > .adder { + margin: 16px; + padding: 16px; + border: solid 2px #c3c3c3; +} +#lsEditor > .adder > textarea { + display: block; + width: 100%; + min-height: 5em; + box-sizing: border-box; +} +#lsEditor > .record { + padding: 16px; + border-bottom: solid 1px #c3c3c3; +} +#lsEditor > .record > header { + font-weight: bold; +} +#lsEditor > .record > textarea { + display: block; + width: 100%; + min-height: 5em; + box-sizing: border-box; +} diff --git a/packages/backend/src/server/web/bios.js b/packages/backend/src/server/web/bios.js new file mode 100644 index 0000000000..d06dee801a --- /dev/null +++ b/packages/backend/src/server/web/bios.js @@ -0,0 +1,87 @@ +'use strict'; + +window.onload = async () => { + const account = JSON.parse(localStorage.getItem('account')); + const i = account.token; + + const api = (endpoint, data = {}) => { + const promise = new Promise((resolve, reject) => { + // Append a credential + if (i) data.i = i; + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache' + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + return promise; + }; + + const content = document.getElementById('content'); + + document.getElementById('ls').addEventListener('click', () => { + content.innerHTML = ''; + + const lsEditor = document.createElement('div'); + lsEditor.id = 'lsEditor'; + + const adder = document.createElement('div'); + adder.classList.add('adder'); + const addKeyInput = document.createElement('input'); + const addValueTextarea = document.createElement('textarea'); + const addButton = document.createElement('button'); + addButton.textContent = 'add'; + addButton.addEventListener('click', () => { + localStorage.setItem(addKeyInput.value, addValueTextarea.value); + location.reload(); + }); + + adder.appendChild(addKeyInput); + adder.appendChild(addValueTextarea); + adder.appendChild(addButton); + lsEditor.appendChild(adder); + + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + const record = document.createElement('div'); + record.classList.add('record'); + const header = document.createElement('header'); + header.textContent = k; + const textarea = document.createElement('textarea'); + textarea.textContent = localStorage.getItem(k); + const saveButton = document.createElement('button'); + saveButton.textContent = 'save'; + saveButton.addEventListener('click', () => { + localStorage.setItem(k, textarea.value); + location.reload(); + }); + const removeButton = document.createElement('button'); + removeButton.textContent = 'remove'; + removeButton.addEventListener('click', () => { + localStorage.removeItem(k); + location.reload(); + }); + record.appendChild(header); + record.appendChild(textarea); + record.appendChild(saveButton); + record.appendChild(removeButton); + lsEditor.appendChild(record); + } + + content.appendChild(lsEditor); + }); +}; diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js new file mode 100644 index 0000000000..d4a2529e63 --- /dev/null +++ b/packages/backend/src/server/web/boot.js @@ -0,0 +1,166 @@ +/** + * BOOT LOADER + * サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで、以下の役割を持ちます。 + * - 翻訳ファイルをフェッチする。 + * - バージョンに基づいて適切なメインスクリプトを読み込む。 + * - キャッシュされたコンパイル済みテーマを適用する。 + * - クライアントの設定値に基づいて対応するHTMLクラス等を設定する。 + * テーマをこの段階で設定するのは、メインスクリプトが読み込まれる間もテーマを適用したいためです。 + * 注: webpackは介さないため、このファイルではrequireやimportは使えません。 + */ + +'use strict'; + +// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので +(async () => { + window.onerror = (e) => { + renderError('SOMETHING_HAPPENED', e.toString()); + }; + window.onunhandledrejection = (e) => { + renderError('SOMETHING_HAPPENED_IN_PROMISE', e.toString()); + }; + + const v = localStorage.getItem('v') || VERSION; + + //#region Detect language & fetch translations + const localeVersion = localStorage.getItem('localeVersion'); + const localeOutdated = (localeVersion == null || localeVersion !== v); + + if (!localStorage.hasOwnProperty('locale') || localeOutdated) { + const supportedLangs = LANGS; + let lang = localStorage.getItem('lang'); + if (lang == null || !supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; + } else { + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + + // Fallback + if (lang == null) lang = 'en-US'; + } + } + + const res = await fetch(`/assets/locales/${lang}.${v}.json`); + if (res.status === 200) { + localStorage.setItem('lang', lang); + localStorage.setItem('locale', await res.text()); + localStorage.setItem('localeVersion', v); + } else if (localeOutdated) { + // nop + } else { + await checkUpdate(); + renderError('LOCALE_FETCH_FAILED'); + return; + } + } + //#endregion + + //#region Script + const salt = localStorage.getItem('salt') + ? `?salt=${localStorage.getItem('salt')}` + : ''; + + const script = document.createElement('script'); + script.setAttribute('src', `/assets/app.${v}.js${salt}`); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + script.addEventListener('error', async () => { + await checkUpdate(); + renderError('APP_FETCH_FAILED'); + }); + document.head.appendChild(script); + //#endregion + + //#region Theme + const theme = localStorage.getItem('theme'); + if (theme) { + for (const [k, v] of Object.entries(JSON.parse(theme))) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + + // HTMLの theme-color 適用 + if (k === 'htmlThemeColor') { + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', v); + break; + } + } + } + } + } + //#endregion + + const fontSize = localStorage.getItem('fontSize'); + if (fontSize) { + document.documentElement.classList.add('f-' + fontSize); + } + + const useSystemFont = localStorage.getItem('useSystemFont'); + if (useSystemFont) { + document.documentElement.classList.add('useSystemFont'); + } + + const wallpaper = localStorage.getItem('wallpaper'); + if (wallpaper) { + document.documentElement.style.backgroundImage = `url(${wallpaper})`; + } + + const customCss = localStorage.getItem('customCss'); + if (customCss && customCss.length > 0) { + const style = document.createElement('style'); + style.innerHTML = customCss; + document.head.appendChild(style); + } + + // eslint-disable-next-line no-inner-declarations + function renderError(code, details) { + document.documentElement.innerHTML = ` + <h1>⚠エラーが発生しました</h1> + <p>問題が解決しない場合は管理者までお問い合わせください。以下のオプションを試すこともできます:</p> + <ul> + <li><a href="/cli">簡易クライアント</a>を起動</li> + <li><a href="/bios">BIOS</a>で修復を試みる</li> + <li><a href="/flush">キャッシュをクリア</a>する</li> + </ul> + <hr> + <code>ERROR CODE: ${code}</code> + <details> + ${details} + </details> + `; + } + + // eslint-disable-next-line no-inner-declarations + async function checkUpdate() { + // TODO: サーバーが落ちている場合などのエラーハンドリング + const res = await fetch('/api/meta', { + method: 'POST', + cache: 'no-cache' + }); + + const meta = await res.json(); + + if (meta.version != v) { + localStorage.setItem('v', meta.version); + refresh(); + } + } + + // eslint-disable-next-line no-inner-declarations + function refresh() { + // Random + localStorage.setItem('salt', Math.random().toString().substr(2, 8)); + + // Clear cache (service worker) + try { + navigator.serviceWorker.controller.postMessage('clear'); + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + location.reload(); + } +})(); diff --git a/packages/backend/src/server/web/cli.css b/packages/backend/src/server/web/cli.css new file mode 100644 index 0000000000..07cd27830b --- /dev/null +++ b/packages/backend/src/server/web/cli.css @@ -0,0 +1,19 @@ +* { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; +} + +html { + background: #ffb4e1; +} + +main { + background: #dedede; +} + +#tl > div { + padding: 16px; + border-bottom: solid 1px #c3c3c3; +} +#tl > div > header { + font-weight: bold; +} diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js new file mode 100644 index 0000000000..3dff1d4860 --- /dev/null +++ b/packages/backend/src/server/web/cli.js @@ -0,0 +1,55 @@ +'use strict'; + +window.onload = async () => { + const account = JSON.parse(localStorage.getItem('account')); + const i = account.token; + + const api = (endpoint, data = {}) => { + const promise = new Promise((resolve, reject) => { + // Append a credential + if (i) data.i = i; + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache' + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + return promise; + }; + + document.getElementById('submit').addEventListener('click', () => { + api('notes/create', { + text: document.getElementById('text').value + }).then(() => { + location.reload(); + }); + }); + + api('notes/timeline').then(notes => { + const tl = document.getElementById('tl'); + for (const note of notes) { + const el = document.createElement('div'); + const name = document.createElement('header'); + name.textContent = `${note.user.name} @${note.user.username}`; + const text = document.createElement('div'); + text.textContent = `${note.text}`; + el.appendChild(name); + el.appendChild(text); + tl.appendChild(el); + } + }); +}; diff --git a/packages/backend/src/server/web/feed.ts b/packages/backend/src/server/web/feed.ts new file mode 100644 index 0000000000..1d4c47dafb --- /dev/null +++ b/packages/backend/src/server/web/feed.ts @@ -0,0 +1,58 @@ +import { Feed } from 'feed'; +import config from '@/config/index'; +import { User } from '@/models/entities/user'; +import { Notes, DriveFiles, UserProfiles } from '@/models/index'; +import { In } from 'typeorm'; + +export default async function(user: User) { + const author = { + link: `${config.url}/@${user.username}`, + name: user.name || user.username + }; + + const profile = await UserProfiles.findOneOrFail(user.id); + + const notes = await Notes.find({ + where: { + userId: user.id, + renoteId: null, + visibility: In(['public', 'home']) + }, + order: { createdAt: -1 }, + take: 20 + }); + + const feed = new Feed({ + id: author.link, + title: `${author.name} (@${user.username}@${config.host})`, + updated: notes[0].createdAt, + generator: 'Misskey', + description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, + link: author.link, + image: user.avatarUrl ? user.avatarUrl : undefined, + feedLinks: { + json: `${author.link}.json`, + atom: `${author.link}.atom`, + }, + author, + copyright: user.name || user.username + }); + + for (const note of notes) { + const files = note.fileIds.length > 0 ? await DriveFiles.find({ + id: In(note.fileIds) + }) : []; + const file = files.find(file => file.type.startsWith('image/')); + + feed.addItem({ + title: `New note by ${author.name}`, + link: `${config.url}/notes/${note.id}`, + date: note.createdAt, + description: note.cw || undefined, + content: note.text || undefined, + image: file ? DriveFiles.getPublicUrl(file) || undefined : undefined + }); + } + + return feed; +} diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts new file mode 100644 index 0000000000..969b155d4d --- /dev/null +++ b/packages/backend/src/server/web/index.ts @@ -0,0 +1,414 @@ +/** + * Web Client Server + */ + +import { dirname } from 'path'; +import * as ms from 'ms'; +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import * as send from 'koa-send'; +import * as favicon from 'koa-favicon'; +import * as views from 'koa-views'; + +import packFeed from './feed'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { genOpenapiSpec } from '../api/openapi/gen-spec'; +import config from '@/config/index'; +import { Users, Notes, UserProfiles, Pages, Channels, Clips, GalleryPosts } from '@/models/index'; +import * as Acct from 'misskey-js/built/acct'; +import { getNoteSummary } from '@/misc/get-note-summary'; + +//const _filename = fileURLToPath(import.meta.url); +const _filename = __filename; +const _dirname = dirname(_filename); + +const staticAssets = `${_dirname}/../../../assets/`; +const assets = `${_dirname}/../../../../../built/_client_dist_/`; + +// Init app +const app = new Koa(); + +// Init renderer +app.use(views(_dirname + '/views', { + extension: 'pug', + options: { + version: config.version, + config + } +})); + +// Serve favicon +app.use(favicon(`${_dirname}/../../../assets/favicon.ico`)); + +// Common request handler +app.use(async (ctx, next) => { + // IFrameの中に入れられないようにする + ctx.set('X-Frame-Options', 'DENY'); + await next(); +}); + +// Init router +const router = new Router(); + +//#region static assets + +router.get('/static-assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/static-assets/', ''), { + root: staticAssets, + maxage: ms('7 days'), + }); +}); + +router.get('/assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/assets/', ''), { + root: assets, + maxage: ms('7 days'), + }); +}); + +// Apple touch icon +router.get('/apple-touch-icon.png', async ctx => { + await send(ctx as any, '/apple-touch-icon.png', { + root: staticAssets + }); +}); + +router.get('/twemoji/(.*)', async ctx => { + const path = ctx.path.replace('/twemoji/', ''); + + if (!path.match(/^[0-9a-f-]+\.svg$/)) { + ctx.status = 404; + return; + } + + ctx.set('Content-Security-Policy', `default-src 'none'; style-src 'unsafe-inline'`); + + await send(ctx as any, path, { + root: `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, + maxage: ms('30 days'), + }); +}); + +// ServiceWorker +router.get('/sw.js', async ctx => { + await send(ctx as any, `/sw.${config.version}.js`, { + root: assets + }); +}); + +// Manifest +router.get('/manifest.json', require('./manifest')); + +router.get('/robots.txt', async ctx => { + await send(ctx as any, '/robots.txt', { + root: staticAssets + }); +}); + +//#endregion + +// Docs +router.get('/api-doc', async ctx => { + await send(ctx as any, '/redoc.html', { + root: staticAssets + }); +}); + +// URL preview endpoint +router.get('/url', require('./url-preview')); + +router.get('/api.json', async ctx => { + ctx.body = genOpenapiSpec(); +}); + +const getFeed = async (acct: string) => { + const { username, host } = Acct.parse(acct); + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host, + isSuspended: false + }); + + return user && await packFeed(user); +}; + +// Atom +router.get('/@:user.atom', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/atom+xml; charset=utf-8'); + ctx.body = feed.atom1(); + } else { + ctx.status = 404; + } +}); + +// RSS +router.get('/@:user.rss', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/rss+xml; charset=utf-8'); + ctx.body = feed.rss2(); + } else { + ctx.status = 404; + } +}); + +// JSON +router.get('/@:user.json', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/json; charset=utf-8'); + ctx.body = feed.json1(); + } else { + ctx.status = 404; + } +}); + +//#region SSR (for crawlers) +// User +router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { + const { username, host } = Acct.parse(ctx.params.user); + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host, + isSuspended: false + }); + + if (user != null) { + const profile = await UserProfiles.findOneOrFail(user.id); + const meta = await fetchMeta(); + const me = profile.fields + ? profile.fields + .filter(filed => filed.value != null && filed.value.match(/^https?:/)) + .map(field => field.value) + : []; + + await ctx.render('user', { + user, profile, me, + sub: ctx.params.sub, + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl + }); + ctx.set('Cache-Control', 'public, max-age=30'); + } else { + // リモートユーザーなので + // モデレータがAPI経由で参照可能にするために404にはしない + await next(); + } +}); + +router.get('/users/:user', async ctx => { + const user = await Users.findOne({ + id: ctx.params.user, + host: null, + isSuspended: false + }); + + if (user == null) { + ctx.status = 404; + return; + } + + ctx.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); +}); + +// Note +router.get('/notes/:note', async (ctx, next) => { + const note = await Notes.findOne(ctx.params.note); + + if (note) { + const _note = await Notes.pack(note); + const profile = await UserProfiles.findOneOrFail(note.userId); + const meta = await fetchMeta(); + await ctx.render('note', { + note: _note, + profile, + // TODO: Let locale changeable by instance setting + summary: getNoteSummary(_note), + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl + }); + + if (['public', 'home'].includes(note.visibility)) { + ctx.set('Cache-Control', 'public, max-age=180'); + } else { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } + + return; + } + + await next(); +}); + +// Page +router.get('/@:user/pages/:page', async (ctx, next) => { + const { username, host } = Acct.parse(ctx.params.user); + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host + }); + + if (user == null) return; + + const page = await Pages.findOne({ + name: ctx.params.page, + userId: user.id + }); + + if (page) { + const _page = await Pages.pack(page); + const profile = await UserProfiles.findOneOrFail(page.userId); + const meta = await fetchMeta(); + await ctx.render('page', { + page: _page, + profile, + instanceName: meta.name || 'Misskey' + }); + + if (['public'].includes(page.visibility)) { + ctx.set('Cache-Control', 'public, max-age=180'); + } else { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } + + return; + } + + await next(); +}); + +// Clip +// TODO: 非publicなclipのハンドリング +router.get('/clips/:clip', async (ctx, next) => { + const clip = await Clips.findOne({ + id: ctx.params.clip, + }); + + if (clip) { + const _clip = await Clips.pack(clip); + const profile = await UserProfiles.findOneOrFail(clip.userId); + const meta = await fetchMeta(); + await ctx.render('clip', { + clip: _clip, + profile, + instanceName: meta.name || 'Misskey' + }); + + ctx.set('Cache-Control', 'public, max-age=180'); + + return; + } + + await next(); +}); + +// Gallery post +router.get('/gallery/:post', async (ctx, next) => { + const post = await GalleryPosts.findOne(ctx.params.post); + + if (post) { + const _post = await GalleryPosts.pack(post); + const profile = await UserProfiles.findOneOrFail(post.userId); + const meta = await fetchMeta(); + await ctx.render('gallery-post', { + post: _post, + profile, + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl + }); + + ctx.set('Cache-Control', 'public, max-age=180'); + + return; + } + + await next(); +}); + +// Channel +router.get('/channels/:channel', async (ctx, next) => { + const channel = await Channels.findOne({ + id: ctx.params.channel, + }); + + if (channel) { + const _channel = await Channels.pack(channel); + const meta = await fetchMeta(); + await ctx.render('channel', { + channel: _channel, + instanceName: meta.name || 'Misskey' + }); + + ctx.set('Cache-Control', 'public, max-age=180'); + + return; + } + + await next(); +}); +//#endregion + +router.get('/_info_card_', async ctx => { + const meta = await fetchMeta(true); + + ctx.remove('X-Frame-Options'); + + await ctx.render('info-card', { + version: config.version, + host: config.host, + meta: meta, + originalUsersCount: await Users.count({ host: null }), + originalNotesCount: await Notes.count({ userHost: null }) + }); +}); + +router.get('/bios', async ctx => { + await ctx.render('bios', { + version: config.version, + }); +}); + +router.get('/cli', async ctx => { + await ctx.render('cli', { + version: config.version, + }); +}); + +const override = (source: string, target: string, depth: number = 0) => + [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); + +router.get('/othello', async ctx => ctx.redirect(override(ctx.URL.pathname, 'games/reversi', 1))); +router.get('/reversi', async ctx => ctx.redirect(override(ctx.URL.pathname, 'games'))); + +router.get('/flush', async ctx => { + await ctx.render('flush'); +}); + +// streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる +router.get('/streaming', async ctx => { + ctx.status = 503; + ctx.set('Cache-Control', 'private, max-age=0'); +}); + +// Render base html for all requests +router.get('(.*)', async ctx => { + const meta = await fetchMeta(); + await ctx.render('base', { + img: meta.bannerUrl, + title: meta.name || 'Misskey', + instanceName: meta.name || 'Misskey', + desc: meta.description, + icon: meta.iconUrl + }); + ctx.set('Cache-Control', 'public, max-age=300'); +}); + +// Register router +app.use(router.routes()); + +module.exports = app; diff --git a/packages/backend/src/server/web/manifest.json b/packages/backend/src/server/web/manifest.json new file mode 100644 index 0000000000..48030a2980 --- /dev/null +++ b/packages/backend/src/server/web/manifest.json @@ -0,0 +1,28 @@ +{ + "short_name": "Misskey", + "name": "Misskey", + "start_url": "/", + "display": "standalone", + "background_color": "#313a42", + "theme_color": "#86b300", + "icons": [ + { + "src": "/static-assets/icons/192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static-assets/icons/512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "share_target": { + "action": "/share/", + "params": { + "title": "title", + "text": "text", + "url": "url" + } + } +} diff --git a/packages/backend/src/server/web/manifest.ts b/packages/backend/src/server/web/manifest.ts new file mode 100644 index 0000000000..918fe27c03 --- /dev/null +++ b/packages/backend/src/server/web/manifest.ts @@ -0,0 +1,15 @@ +import * as Koa from 'koa'; +import * as manifest from './manifest.json'; +import { fetchMeta } from '@/misc/fetch-meta'; + +module.exports = async (ctx: Koa.Context) => { + const json = JSON.parse(JSON.stringify(manifest)); + + const instance = await fetchMeta(true); + + json.short_name = instance.name || 'Misskey'; + json.name = instance.name || 'Misskey'; + + ctx.set('Cache-Control', 'max-age=300'); + ctx.body = json; +}; diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css new file mode 100644 index 0000000000..43fbe1ab06 --- /dev/null +++ b/packages/backend/src/server/web/style.css @@ -0,0 +1,29 @@ +html { + background-color: var(--bg); + color: var(--fg); +} + +#splash { + position: fixed; + z-index: 10000; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + cursor: wait; + background-color: var(--bg); + opacity: 1; + transition: opacity 0.5s ease; +} + +#splash > img { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + width: 64px; + height: 64px; + pointer-events: none; +} diff --git a/packages/backend/src/server/web/url-preview.ts b/packages/backend/src/server/web/url-preview.ts new file mode 100644 index 0000000000..1375420c0a --- /dev/null +++ b/packages/backend/src/server/web/url-preview.ts @@ -0,0 +1,53 @@ +import * as Koa from 'koa'; +import summaly from 'summaly'; +import { fetchMeta } from '@/misc/fetch-meta'; +import Logger from '@/services/logger'; +import config from '@/config/index'; +import { query } from '@/prelude/url'; +import { getJson } from '@/misc/fetch'; + +const logger = new Logger('url-preview'); + +module.exports = async (ctx: Koa.Context) => { + const meta = await fetchMeta(); + + logger.info(meta.summalyProxy + ? `(Proxy) Getting preview of ${ctx.query.url}@${ctx.query.lang} ...` + : `Getting preview of ${ctx.query.url}@${ctx.query.lang} ...`); + + try { + const summary = meta.summalyProxy ? await getJson(`${meta.summalyProxy}?${query({ + url: ctx.query.url, + lang: ctx.query.lang || 'ja-JP' + })}`) : await summaly(ctx.query.url, { + followRedirects: false, + lang: ctx.query.lang || 'ja-JP' + }); + + logger.succ(`Got preview of ${ctx.query.url}: ${summary.title}`); + + summary.icon = wrap(summary.icon); + summary.thumbnail = wrap(summary.thumbnail); + + // Cache 7days + ctx.set('Cache-Control', 'max-age=604800, immutable'); + + ctx.body = summary; + } catch (e) { + logger.warn(`Failed to get preview of ${ctx.query.url}: ${e}`); + ctx.status = 200; + ctx.set('Cache-Control', 'max-age=86400, immutable'); + ctx.body = '{}'; + } +}; + +function wrap(url?: string): string | null { + return url != null + ? url.match(/^https?:\/\//) + ? `${config.url}/proxy/preview.jpg?${query({ + url, + preview: '1' + })}` + : url + : null; +} diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug new file mode 100644 index 0000000000..42c068c403 --- /dev/null +++ b/packages/backend/src/server/web/views/base.pug @@ -0,0 +1,60 @@ +block vars + +doctype html + +!= '<!--\n' +!= ' _____ _ _ \n' +!= ' | |_|___ ___| |_ ___ _ _ \n' +!= ' | | | | |_ -|_ -| \'_| -_| | |\n' +!= ' |_|_|_|_|___|___|_,_|___|_ |\n' +!= ' |___|\n' +!= ' Thank you for using Misskey!\n' +!= ' If you are reading this message... how about joining the development?\n' +!= ' https://github.com/misskey-dev/misskey' +!= '\n-->\n' + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + meta(name='referrer' content='origin') + meta(name='theme-color' content='#86b300') + meta(name='theme-color-orig' content='#86b300') + meta(property='og:site_name' content= instanceName || 'Misskey') + meta(name='viewport' content='width=device-width, initial-scale=1') + link(rel='icon' href= icon || '/favicon.ico') + link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png') + link(rel='manifest' href='/manifest.json') + link(rel='prefetch' href='https://xn--931a.moe/assets/info.jpg') + link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') + link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') + link(rel='preload' href='https://use.fontawesome.com/releases/v5.15.3/css/all.css' as='style') + link(rel='stylesheet' href='https://use.fontawesome.com/releases/v5.15.3/css/all.css') + + title + block title + = title || 'Misskey' + + block desc + meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') + + block meta + + block og + meta(property='og:image' content=img) + + style + include ../style.css + + script + include ../boot.js + + body + noscript: p + | JavaScriptを有効にしてください + br + | Please turn on your JavaScript + div#splash + img(src='/favicon.ico') + block content diff --git a/packages/backend/src/server/web/views/bios.pug b/packages/backend/src/server/web/views/bios.pug new file mode 100644 index 0000000000..d81a3ee67f --- /dev/null +++ b/packages/backend/src/server/web/views/bios.pug @@ -0,0 +1,20 @@ +doctype html + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + title Misskey BIOS + style + include ../bios.css + script + include ../bios.js + + body + header + h1 Misskey BIOS #{version} + main + div.tabs + button#ls edit local storage + div#content diff --git a/packages/backend/src/server/web/views/channel.pug b/packages/backend/src/server/web/views/channel.pug new file mode 100644 index 0000000000..273632f0e0 --- /dev/null +++ b/packages/backend/src/server/web/views/channel.pug @@ -0,0 +1,21 @@ +extends ./base + +block vars + - const title = channel.name; + - const url = `${config.url}/channels/${channel.id}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= channel.description) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= channel.description) + meta(property='og:url' content= url) + meta(property='og:image' content= channel.bannerUrl) + +block meta + meta(name='twitter:card' content='summary') diff --git a/packages/backend/src/server/web/views/cli.pug b/packages/backend/src/server/web/views/cli.pug new file mode 100644 index 0000000000..d2cf7c4335 --- /dev/null +++ b/packages/backend/src/server/web/views/cli.pug @@ -0,0 +1,21 @@ +doctype html + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + title Misskey Cli + style + include ../cli.css + script + include ../cli.js + + body + header + h1 Misskey Cli #{version} + main + div#form + textarea#text + button#submit submit + div#tl diff --git a/packages/backend/src/server/web/views/clip.pug b/packages/backend/src/server/web/views/clip.pug new file mode 100644 index 0000000000..8de53f19d6 --- /dev/null +++ b/packages/backend/src/server/web/views/clip.pug @@ -0,0 +1,33 @@ +extends ./base + +block vars + - const user = clip.user; + - const title = clip.name; + - const url = `${config.url}/clips/${clip.id}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= clip.description) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= clip.description) + meta(property='og:url' content= url) + meta(property='og:image' content= user.avatarUrl) + +block meta + if profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + meta(name='misskey:clip-id' content=clip.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/src/server/web/views/flush.pug b/packages/backend/src/server/web/views/flush.pug new file mode 100644 index 0000000000..ec585a34db --- /dev/null +++ b/packages/backend/src/server/web/views/flush.pug @@ -0,0 +1,47 @@ +doctype html + +html + #msg + script. + const msg = document.getElementById('msg'); + const successText = `\nSuccess Flush! <a href="/">Back to Misskey</a>\n成功しました。<a href="/">Misskeyを開き直してください。</a>`; + + message('Start flushing.'); + + (async function() { + try { + localStorage.clear(); + message('localStorage cleared.'); + + const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => { + const delidb = indexedDB.deleteDatabase(name); + delidb.onsuccess = () => res(message(`indexedDB "${name}" cleared. (${i + 1}/${arr.length})`)); + delidb.onerror = e => rej(e) + })); + + await Promise.all(idbPromises); + + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage('clear'); + await navigator.serviceWorker.getRegistrations() + .then(registrations => { + return Promise.all(registrations.map(registration => registration.unregister())); + }) + .catch(e => { throw Error(e) }); + } + + message(successText); + } catch (e) { + message(`\n${e}\n\nFlush Failed. <a href="/flush">Please retry.</a>\n失敗しました。<a href="/flush">もう一度試してみてください。</a>`); + message(`\nIf you retry more than 3 times, clear the browser cache or contact to instance admin.\n3回以上試しても失敗する場合、ブラウザのキャッシュを消去し、それでもだめならインスタンス管理者に連絡してみてください。\n`) + + console.error(e); + setTimeout(() => { + location = '/'; + }, 10000) + } + })(); + + function message(text) { + msg.insertAdjacentHTML('beforeend', `<p>[${(new Date()).toString()}] ${text.replace(/\n/g,'<br>')}</p>`) + } diff --git a/packages/backend/src/server/web/views/gallery-post.pug b/packages/backend/src/server/web/views/gallery-post.pug new file mode 100644 index 0000000000..95bbb2437c --- /dev/null +++ b/packages/backend/src/server/web/views/gallery-post.pug @@ -0,0 +1,35 @@ +extends ./base + +block vars + - const user = post.user; + - const title = post.title; + - const url = `${config.url}/gallery/${post.id}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= post.description) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= post.description) + meta(property='og:url' content= url) + meta(property='og:image' content= post.files[0].thumbnailUrl) + +block meta + if user.host || profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) + + if !user.host + link(rel='alternate' href=url type='application/activity+json') diff --git a/packages/backend/src/server/web/views/info-card.pug b/packages/backend/src/server/web/views/info-card.pug new file mode 100644 index 0000000000..1d62778ce1 --- /dev/null +++ b/packages/backend/src/server/web/views/info-card.pug @@ -0,0 +1,50 @@ +doctype html + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + title= meta.name || host + style. + html, body { + margin: 0; + padding: 0; + min-height: 100vh; + background: #fff; + } + + #a { + display: block; + } + + #banner { + background-size: cover; + background-position: center center; + } + + #title { + display: inline-block; + margin: 24px; + padding: 0.5em 0.8em; + color: #fff; + background: rgba(0, 0, 0, 0.5); + font-weight: bold; + font-size: 1.3em; + } + + #content { + overflow: auto; + color: #353c3e; + } + + #description { + margin: 24px; + } + + body + a#a(href=`https://${host}` target="_blank") + header#banner(style=`background-image: url(${meta.bannerUrl})`) + div#title= meta.name || host + div#content + div#description= meta.description diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug new file mode 100644 index 0000000000..7030936975 --- /dev/null +++ b/packages/backend/src/server/web/views/note.pug @@ -0,0 +1,43 @@ +extends ./base + +block vars + - const user = note.user; + - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; + - const url = `${config.url}/notes/${note.id}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= summary) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= summary) + meta(property='og:url' content= url) + meta(property='og:image' content= user.avatarUrl) + +block meta + if user.host || profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + meta(name='misskey:note-id' content=note.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) + + if note.prev + link(rel='prev' href=`${config.url}/notes/${note.prev}`) + if note.next + link(rel='next' href=`${config.url}/notes/${note.next}`) + + if !user.host + link(rel='alternate' href=url type='application/activity+json') + if note.uri + link(rel='alternate' href=note.uri type='application/activity+json') diff --git a/packages/backend/src/server/web/views/page.pug b/packages/backend/src/server/web/views/page.pug new file mode 100644 index 0000000000..cb9e1039e1 --- /dev/null +++ b/packages/backend/src/server/web/views/page.pug @@ -0,0 +1,33 @@ +extends ./base + +block vars + - const user = page.user; + - const title = page.title; + - const url = `${config.url}/@${user.username}/${page.name}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= page.summary) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= page.summary) + meta(property='og:url' content= url) + meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : user.avatarUrl) + +block meta + if profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + meta(name='misskey:page-id' content=page.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/src/server/web/views/user.pug b/packages/backend/src/server/web/views/user.pug new file mode 100644 index 0000000000..1a8a6b4413 --- /dev/null +++ b/packages/backend/src/server/web/views/user.pug @@ -0,0 +1,42 @@ +extends ./base + +block vars + - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; + - const url = `${config.url}/@${(user.host ? `${user.username}@${user.host}` : user.username)}`; + - const img = user.avatarUrl || null; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= profile.description) + +block og + meta(property='og:type' content='blog') + meta(property='og:title' content= title) + meta(property='og:description' content= profile.description) + meta(property='og:url' content= url) + meta(property='og:image' content= img) + +block meta + if user.host || profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + + meta(name='twitter:card' content='summary') + + if profile.twitter + meta(name='twitter:creator' content=`@${profile.twitter.screenName}`) + + if !sub + if !user.host + link(rel='alternate' href=`${config.url}/users/${user.id}` type='application/activity+json') + if user.uri + link(rel='alternate' href=user.uri type='application/activity+json') + if profile.url + link(rel='alternate' href=profile.url type='text/html') + + each m in me + link(rel='me' href=`${m}`) diff --git a/packages/backend/src/server/well-known.ts b/packages/backend/src/server/well-known.ts new file mode 100644 index 0000000000..5e99b0065c --- /dev/null +++ b/packages/backend/src/server/well-known.ts @@ -0,0 +1,149 @@ +import * as Router from '@koa/router'; + +import config from '@/config/index'; +import * as Acct from 'misskey-js/built/acct'; +import { links } from './nodeinfo'; +import { escapeAttribute, escapeValue } from '@/prelude/xml'; +import { Users } from '@/models/index'; +import { User } from '@/models/entities/user'; + +// Init router +const router = new Router(); + +const XRD = (...x: { element: string, value?: string, attributes?: Record<string, string> }[]) => + `<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">${x.map(({ element, value, attributes }) => + `<${ + Object.entries(typeof attributes === 'object' && attributes || {}).reduce((a, [k, v]) => `${a} ${k}="${escapeAttribute(v)}"`, element) + }${ + typeof value === 'string' ? `>${escapeValue(value)}</${element}` : '/' + }>`).reduce((a, c) => a + c, '')}</XRD>`; + +const allPath = '/.well-known/(.*)'; +const webFingerPath = '/.well-known/webfinger'; +const jrd = 'application/jrd+json'; +const xrd = 'application/xrd+xml'; + +router.use(allPath, async (ctx, next) => { + ctx.set({ + 'Access-Control-Allow-Headers': 'Accept', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': 'Vary', + }); + await next(); +}); + +router.options(allPath, async ctx => { + ctx.status = 204; +}); + +router.get('/.well-known/host-meta', async ctx => { + ctx.set('Content-Type', xrd); + ctx.body = XRD({ element: 'Link', attributes: { + type: xrd, + template: `${config.url}${webFingerPath}?resource={uri}` + }}); +}); + +router.get('/.well-known/host-meta.json', async ctx => { + ctx.set('Content-Type', jrd); + ctx.body = { + links: [{ + rel: 'lrdd', + type: jrd, + template: `${config.url}${webFingerPath}?resource={uri}` + }] + }; +}); + +router.get('/.well-known/nodeinfo', async ctx => { + ctx.body = { links }; +}); + +/* TODO +router.get('/.well-known/change-password', async ctx => { +}); +*/ + +router.get(webFingerPath, async ctx => { + const fromId = (id: User['id']): Record<string, any> => ({ + id, + host: null, + isSuspended: false + }); + + const generateQuery = (resource: string) => + resource.startsWith(`${config.url.toLowerCase()}/users/`) ? + fromId(resource.split('/').pop()!) : + fromAcct(Acct.parse( + resource.startsWith(`${config.url.toLowerCase()}/@`) ? resource.split('/').pop()! : + resource.startsWith('acct:') ? resource.slice('acct:'.length) : + resource)); + + const fromAcct = (acct: Acct.Acct): Record<string, any> | number => + !acct.host || acct.host === config.host.toLowerCase() ? { + usernameLower: acct.username, + host: null, + isSuspended: false + } : 422; + + if (typeof ctx.query.resource !== 'string') { + ctx.status = 400; + return; + } + + const query = generateQuery(ctx.query.resource.toLowerCase()); + + if (typeof query === 'number') { + ctx.status = query; + return; + } + + const user = await Users.findOne(query); + + if (user == null) { + ctx.status = 404; + return; + } + + const subject = `acct:${user.username}@${config.host}`; + const self = { + rel: 'self', + type: 'application/activity+json', + href: `${config.url}/users/${user.id}` + }; + const profilePage = { + rel: 'http://webfinger.net/rel/profile-page', + type: 'text/html', + href: `${config.url}/@${user.username}` + }; + const subscribe = { + rel: 'http://ostatus.org/schema/1.0/subscribe', + template: `${config.url}/authorize-follow?acct={uri}` + }; + + if (ctx.accepts(jrd, xrd) === xrd) { + ctx.body = XRD( + { element: 'Subject', value: subject }, + { element: 'Link', attributes: self }, + { element: 'Link', attributes: profilePage }, + { element: 'Link', attributes: subscribe }); + ctx.type = xrd; + } else { + ctx.body = { + subject, + links: [self, profilePage, subscribe] + }; + ctx.type = jrd; + } + + ctx.vary('Accept'); + ctx.set('Cache-Control', 'public, max-age=180'); +}); + +// Return 404 for other .well-known +router.all(allPath, async ctx => { + ctx.status = 404; +}); + +export default router; diff --git a/packages/backend/src/services/add-note-to-antenna.ts b/packages/backend/src/services/add-note-to-antenna.ts new file mode 100644 index 0000000000..3aedbd2c32 --- /dev/null +++ b/packages/backend/src/services/add-note-to-antenna.ts @@ -0,0 +1,54 @@ +import { Antenna } from '@/models/entities/antenna'; +import { Note } from '@/models/entities/note'; +import { AntennaNotes, Mutings, Notes } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { isMutedUserRelated } from '@/misc/is-muted-user-related'; +import { publishAntennaStream, publishMainStream } from '@/services/stream'; +import { User } from '@/models/entities/user'; + +export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }) { + // 通知しない設定になっているか、自分自身の投稿なら既読にする + const read = !antenna.notify || (antenna.userId === noteUser.id); + + AntennaNotes.insert({ + id: genId(), + antennaId: antenna.id, + noteId: note.id, + read: read, + }); + + publishAntennaStream(antenna.id, 'note', note); + + if (!read) { + const mutings = await Mutings.find({ + where: { + muterId: antenna.userId + }, + select: ['muteeId'] + }); + + // Copy + const _note: Note = { + ...note + }; + + if (note.replyId != null) { + _note.reply = await Notes.findOneOrFail(note.replyId); + } + if (note.renoteId != null) { + _note.renote = await Notes.findOneOrFail(note.renoteId); + } + + if (isMutedUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) { + return; + } + + // 2秒経っても既読にならなかったら通知 + setTimeout(async () => { + const unread = await AntennaNotes.findOne({ antennaId: antenna.id, read: false }); + if (unread) { + publishMainStream(antenna.userId, 'unreadAntenna', antenna); + } + }, 2000); + } +} diff --git a/packages/backend/src/services/blocking/create.ts b/packages/backend/src/services/blocking/create.ts new file mode 100644 index 0000000000..6aadc847a9 --- /dev/null +++ b/packages/backend/src/services/blocking/create.ts @@ -0,0 +1,129 @@ +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderFollow from '@/remote/activitypub/renderer/follow'; +import renderUndo from '@/remote/activitypub/renderer/undo'; +import renderBlock from '@/remote/activitypub/renderer/block'; +import { deliver } from '@/queue/index'; +import renderReject from '@/remote/activitypub/renderer/reject'; +import { User } from '@/models/entities/user'; +import { Blockings, Users, FollowRequests, Followings, UserListJoinings, UserLists } from '@/models/index'; +import { perUserFollowingChart } from '@/services/chart/index'; +import { genId } from '@/misc/gen-id'; +import { IdentifiableError } from '@/misc/identifiable-error'; + +export default async function(blocker: User, blockee: User) { + await Promise.all([ + cancelRequest(blocker, blockee), + cancelRequest(blockee, blocker), + unFollow(blocker, blockee), + unFollow(blockee, blocker), + removeFromList(blockee, blocker), + ]); + + await Blockings.insert({ + id: genId(), + createdAt: new Date(), + blockerId: blocker.id, + blockeeId: blockee.id, + }); + + if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) { + const content = renderActivity(renderBlock(blocker, blockee)); + deliver(blocker, content, blockee.inbox); + } +} + +async function cancelRequest(follower: User, followee: User) { + const request = await FollowRequests.findOne({ + followeeId: followee.id, + followerId: follower.id + }); + + if (request == null) { + return; + } + + await FollowRequests.delete({ + followeeId: followee.id, + followerId: follower.id + }); + + if (Users.isLocalUser(followee)) { + Users.pack(followee, followee, { + detail: true + }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); + } + + if (Users.isLocalUser(follower)) { + Users.pack(followee, follower, { + detail: true + }).then(packed => { + publishUserEvent(follower.id, 'unfollow', packed); + publishMainStream(follower.id, 'unfollow', packed); + }); + } + + // リモートにフォローリクエストをしていたらUndoFollow送信 + if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); + deliver(follower, content, followee.inbox); + } + + // リモートからフォローリクエストを受けていたらReject送信 + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + const content = renderActivity(renderReject(renderFollow(follower, followee, request.requestId!), followee)); + deliver(followee, content, follower.inbox); + } +} + +async function unFollow(follower: User, followee: User) { + const following = await Followings.findOne({ + followerId: follower.id, + followeeId: followee.id + }); + + if (following == null) { + return; + } + + Followings.delete(following.id); + + //#region Decrement following count + Users.decrement({ id: follower.id }, 'followingCount', 1); + //#endregion + + //#region Decrement followers count + Users.decrement({ id: followee.id }, 'followersCount', 1); + //#endregion + + perUserFollowingChart.update(follower, followee, false); + + // Publish unfollow event + if (Users.isLocalUser(follower)) { + Users.pack(followee, follower, { + detail: true + }).then(packed => { + publishUserEvent(follower.id, 'unfollow', packed); + publishMainStream(follower.id, 'unfollow', packed); + }); + } + + // リモートにフォローをしていたらUndoFollow送信 + if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); + deliver(follower, content, followee.inbox); + } +} + +async function removeFromList(listOwner: User, user: User) { + const userLists = await UserLists.find({ + userId: listOwner.id, + }); + + for (const userList of userLists) { + await UserListJoinings.delete({ + userListId: userList.id, + userId: user.id, + }); + } +} diff --git a/packages/backend/src/services/blocking/delete.ts b/packages/backend/src/services/blocking/delete.ts new file mode 100644 index 0000000000..de7efb1558 --- /dev/null +++ b/packages/backend/src/services/blocking/delete.ts @@ -0,0 +1,29 @@ +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderBlock from '@/remote/activitypub/renderer/block'; +import renderUndo from '@/remote/activitypub/renderer/undo'; +import { deliver } from '@/queue/index'; +import Logger from '../logger'; +import { User } from '@/models/entities/user'; +import { Blockings, Users } from '@/models/index'; + +const logger = new Logger('blocking/delete'); + +export default async function(blocker: User, blockee: User) { + const blocking = await Blockings.findOne({ + blockerId: blocker.id, + blockeeId: blockee.id + }); + + if (blocking == null) { + logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした'); + return; + } + + Blockings.delete(blocking.id); + + // deliver if remote bloking + if (Users.isLocalUser(blocker) && Users.isRemoteUser(blockee)) { + const content = renderActivity(renderUndo(renderBlock(blocker, blockee), blocker)); + deliver(blocker, content, blockee.inbox); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/active-users.ts b/packages/backend/src/services/chart/charts/classes/active-users.ts new file mode 100644 index 0000000000..f80d8a3322 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/active-users.ts @@ -0,0 +1,47 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { User } from '@/models/entities/user'; +import { SchemaType } from '@/misc/schema'; +import { Users } from '@/models/index'; +import { name, schema } from '../schemas/active-users'; + +type ActiveUsersLog = SchemaType<typeof schema>; + +export default class ActiveUsersChart extends Chart<ActiveUsersLog> { + constructor() { + super(name, schema); + } + + @autobind + protected genNewLog(latest: ActiveUsersLog): DeepPartial<ActiveUsersLog> { + return {}; + } + + @autobind + protected aggregate(logs: ActiveUsersLog[]): ActiveUsersLog { + return { + local: { + users: logs.reduce((a, b) => a.concat(b.local.users), [] as ActiveUsersLog['local']['users']), + }, + remote: { + users: logs.reduce((a, b) => a.concat(b.remote.users), [] as ActiveUsersLog['remote']['users']), + }, + }; + } + + @autobind + protected async fetchActual(): Promise<DeepPartial<ActiveUsersLog>> { + return {}; + } + + @autobind + public async update(user: { id: User['id'], host: User['host'] }) { + const update: Obj = { + users: [user.id] + }; + + await this.inc({ + [Users.isLocalUser(user) ? 'local' : 'remote']: update + }); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/drive.ts b/packages/backend/src/services/chart/charts/classes/drive.ts new file mode 100644 index 0000000000..93eabf3096 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/drive.ts @@ -0,0 +1,91 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { DriveFiles } from '@/models/index'; +import { Not, IsNull } from 'typeorm'; +import { DriveFile } from '@/models/entities/drive-file'; +import { name, schema } from '../schemas/drive'; + +type DriveLog = SchemaType<typeof schema>; + +export default class DriveChart extends Chart<DriveLog> { + constructor() { + super(name, schema); + } + + @autobind + protected genNewLog(latest: DriveLog): DeepPartial<DriveLog> { + return { + local: { + totalCount: latest.local.totalCount, + totalSize: latest.local.totalSize, + }, + remote: { + totalCount: latest.remote.totalCount, + totalSize: latest.remote.totalSize, + } + }; + } + + @autobind + protected aggregate(logs: DriveLog[]): DriveLog { + return { + local: { + totalCount: logs[0].local.totalCount, + totalSize: logs[0].local.totalSize, + incCount: logs.reduce((a, b) => a + b.local.incCount, 0), + incSize: logs.reduce((a, b) => a + b.local.incSize, 0), + decCount: logs.reduce((a, b) => a + b.local.decCount, 0), + decSize: logs.reduce((a, b) => a + b.local.decSize, 0), + }, + remote: { + totalCount: logs[0].remote.totalCount, + totalSize: logs[0].remote.totalSize, + incCount: logs.reduce((a, b) => a + b.remote.incCount, 0), + incSize: logs.reduce((a, b) => a + b.remote.incSize, 0), + decCount: logs.reduce((a, b) => a + b.remote.decCount, 0), + decSize: logs.reduce((a, b) => a + b.remote.decSize, 0), + }, + }; + } + + @autobind + protected async fetchActual(): Promise<DeepPartial<DriveLog>> { + const [localCount, remoteCount, localSize, remoteSize] = await Promise.all([ + DriveFiles.count({ userHost: null }), + DriveFiles.count({ userHost: Not(IsNull()) }), + DriveFiles.calcDriveUsageOfLocal(), + DriveFiles.calcDriveUsageOfRemote() + ]); + + return { + local: { + totalCount: localCount, + totalSize: localSize, + }, + remote: { + totalCount: remoteCount, + totalSize: remoteSize, + } + }; + } + + @autobind + public async update(file: DriveFile, isAdditional: boolean) { + const update: Obj = {}; + + update.totalCount = isAdditional ? 1 : -1; + update.totalSize = isAdditional ? file.size : -file.size; + if (isAdditional) { + update.incCount = 1; + update.incSize = file.size; + } else { + update.decCount = 1; + update.decSize = file.size; + } + + await this.inc({ + [file.userHost === null ? 'local' : 'remote']: update + }); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/federation.ts b/packages/backend/src/services/chart/charts/classes/federation.ts new file mode 100644 index 0000000000..5f918b294f --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/federation.ts @@ -0,0 +1,62 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { Instances } from '@/models/index'; +import { name, schema } from '../schemas/federation'; + +type FederationLog = SchemaType<typeof schema>; + +export default class FederationChart extends Chart<FederationLog> { + constructor() { + super(name, schema); + } + + @autobind + protected genNewLog(latest: FederationLog): DeepPartial<FederationLog> { + return { + instance: { + total: latest.instance.total, + } + }; + } + + @autobind + protected aggregate(logs: FederationLog[]): FederationLog { + return { + instance: { + total: logs[0].instance.total, + inc: logs.reduce((a, b) => a + b.instance.inc, 0), + dec: logs.reduce((a, b) => a + b.instance.dec, 0), + }, + }; + } + + @autobind + protected async fetchActual(): Promise<DeepPartial<FederationLog>> { + const [total] = await Promise.all([ + Instances.count({}) + ]); + + return { + instance: { + total: total, + } + }; + } + + @autobind + public async update(isAdditional: boolean) { + const update: Obj = {}; + + update.total = isAdditional ? 1 : -1; + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + await this.inc({ + instance: update + }); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/hashtag.ts b/packages/backend/src/services/chart/charts/classes/hashtag.ts new file mode 100644 index 0000000000..f7f5e17dec --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/hashtag.ts @@ -0,0 +1,47 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { User } from '@/models/entities/user'; +import { SchemaType } from '@/misc/schema'; +import { Users } from '@/models/index'; +import { name, schema } from '../schemas/hashtag'; + +type HashtagLog = SchemaType<typeof schema>; + +export default class HashtagChart extends Chart<HashtagLog> { + constructor() { + super(name, schema, true); + } + + @autobind + protected genNewLog(latest: HashtagLog): DeepPartial<HashtagLog> { + return {}; + } + + @autobind + protected aggregate(logs: HashtagLog[]): HashtagLog { + return { + local: { + users: logs.reduce((a, b) => a.concat(b.local.users), [] as HashtagLog['local']['users']), + }, + remote: { + users: logs.reduce((a, b) => a.concat(b.remote.users), [] as HashtagLog['remote']['users']), + }, + }; + } + + @autobind + protected async fetchActual(): Promise<DeepPartial<HashtagLog>> { + return {}; + } + + @autobind + public async update(hashtag: string, user: { id: User['id'], host: User['host'] }) { + const update: Obj = { + users: [user.id] + }; + + await this.inc({ + [Users.isLocalUser(user) ? 'local' : 'remote']: update + }, hashtag); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/instance.ts b/packages/backend/src/services/chart/charts/classes/instance.ts new file mode 100644 index 0000000000..1032de7bc0 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/instance.ts @@ -0,0 +1,217 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { DriveFiles, Followings, Users, Notes } from '@/models/index'; +import { DriveFile } from '@/models/entities/drive-file'; +import { name, schema } from '../schemas/instance'; +import { Note } from '@/models/entities/note'; +import { toPuny } from '@/misc/convert-host'; + +type InstanceLog = SchemaType<typeof schema>; + +export default class InstanceChart extends Chart<InstanceLog> { + constructor() { + super(name, schema); + } + + @autobind + protected genNewLog(latest: InstanceLog): DeepPartial<InstanceLog> { + return { + notes: { + total: latest.notes.total, + }, + users: { + total: latest.users.total, + }, + following: { + total: latest.following.total, + }, + followers: { + total: latest.followers.total, + }, + drive: { + totalFiles: latest.drive.totalFiles, + totalUsage: latest.drive.totalUsage, + } + }; + } + + @autobind + protected aggregate(logs: InstanceLog[]): InstanceLog { + return { + requests: { + failed: logs.reduce((a, b) => a + b.requests.failed, 0), + succeeded: logs.reduce((a, b) => a + b.requests.succeeded, 0), + received: logs.reduce((a, b) => a + b.requests.received, 0), + }, + notes: { + total: logs[0].notes.total, + inc: logs.reduce((a, b) => a + b.notes.inc, 0), + dec: logs.reduce((a, b) => a + b.notes.dec, 0), + diffs: { + reply: logs.reduce((a, b) => a + b.notes.diffs.reply, 0), + renote: logs.reduce((a, b) => a + b.notes.diffs.renote, 0), + normal: logs.reduce((a, b) => a + b.notes.diffs.normal, 0), + }, + }, + users: { + total: logs[0].users.total, + inc: logs.reduce((a, b) => a + b.users.inc, 0), + dec: logs.reduce((a, b) => a + b.users.dec, 0), + }, + following: { + total: logs[0].following.total, + inc: logs.reduce((a, b) => a + b.following.inc, 0), + dec: logs.reduce((a, b) => a + b.following.dec, 0), + }, + followers: { + total: logs[0].followers.total, + inc: logs.reduce((a, b) => a + b.followers.inc, 0), + dec: logs.reduce((a, b) => a + b.followers.dec, 0), + }, + drive: { + totalFiles: logs[0].drive.totalFiles, + totalUsage: logs[0].drive.totalUsage, + incFiles: logs.reduce((a, b) => a + b.drive.incFiles, 0), + incUsage: logs.reduce((a, b) => a + b.drive.incUsage, 0), + decFiles: logs.reduce((a, b) => a + b.drive.decFiles, 0), + decUsage: logs.reduce((a, b) => a + b.drive.decUsage, 0), + }, + }; + } + + @autobind + protected async fetchActual(group: string): Promise<DeepPartial<InstanceLog>> { + const [ + notesCount, + usersCount, + followingCount, + followersCount, + driveFiles, + driveUsage, + ] = await Promise.all([ + Notes.count({ userHost: group }), + Users.count({ host: group }), + Followings.count({ followerHost: group }), + Followings.count({ followeeHost: group }), + DriveFiles.count({ userHost: group }), + DriveFiles.calcDriveUsageOfHost(group), + ]); + + return { + notes: { + total: notesCount, + }, + users: { + total: usersCount, + }, + following: { + total: followingCount, + }, + followers: { + total: followersCount, + }, + drive: { + totalFiles: driveFiles, + totalUsage: driveUsage, + } + }; + } + + @autobind + public async requestReceived(host: string) { + await this.inc({ + requests: { + received: 1 + } + }, toPuny(host)); + } + + @autobind + public async requestSent(host: string, isSucceeded: boolean) { + const update: Obj = {}; + + if (isSucceeded) { + update.succeeded = 1; + } else { + update.failed = 1; + } + + await this.inc({ + requests: update + }, toPuny(host)); + } + + @autobind + public async newUser(host: string) { + await this.inc({ + users: { + total: 1, + inc: 1 + } + }, toPuny(host)); + } + + @autobind + public async updateNote(host: string, note: Note, isAdditional: boolean) { + const diffs = {} as any; + + if (note.replyId != null) { + diffs.reply = isAdditional ? 1 : -1; + } else if (note.renoteId != null) { + diffs.renote = isAdditional ? 1 : -1; + } else { + diffs.normal = isAdditional ? 1 : -1; + } + + await this.inc({ + notes: { + total: isAdditional ? 1 : -1, + inc: isAdditional ? 1 : 0, + dec: isAdditional ? 0 : 1, + diffs: diffs + } + }, toPuny(host)); + } + + @autobind + public async updateFollowing(host: string, isAdditional: boolean) { + await this.inc({ + following: { + total: isAdditional ? 1 : -1, + inc: isAdditional ? 1 : 0, + dec: isAdditional ? 0 : 1, + } + }, toPuny(host)); + } + + @autobind + public async updateFollowers(host: string, isAdditional: boolean) { + await this.inc({ + followers: { + total: isAdditional ? 1 : -1, + inc: isAdditional ? 1 : 0, + dec: isAdditional ? 0 : 1, + } + }, toPuny(host)); + } + + @autobind + public async updateDrive(file: DriveFile, isAdditional: boolean) { + const update: Obj = {}; + + update.totalFiles = isAdditional ? 1 : -1; + update.totalUsage = isAdditional ? file.size : -file.size; + if (isAdditional) { + update.incFiles = 1; + update.incUsage = file.size; + } else { + update.decFiles = 1; + update.decUsage = file.size; + } + + await this.inc({ + drive: update + }, file.userHost); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/network.ts b/packages/backend/src/services/chart/charts/classes/network.ts new file mode 100644 index 0000000000..2ce75e0b34 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/network.ts @@ -0,0 +1,45 @@ +import autobind from 'autobind-decorator'; +import Chart, { DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { name, schema } from '../schemas/network'; + +type NetworkLog = SchemaType<typeof schema>; + +export default class NetworkChart extends Chart<NetworkLog> { + constructor() { + super(name, schema); + } + + @autobind + protected genNewLog(latest: NetworkLog): DeepPartial<NetworkLog> { + return {}; + } + + @autobind + protected aggregate(logs: NetworkLog[]): NetworkLog { + return { + incomingRequests: logs.reduce((a, b) => a + b.incomingRequests, 0), + outgoingRequests: logs.reduce((a, b) => a + b.outgoingRequests, 0), + totalTime: logs.reduce((a, b) => a + b.totalTime, 0), + incomingBytes: logs.reduce((a, b) => a + b.incomingBytes, 0), + outgoingBytes: logs.reduce((a, b) => a + b.outgoingBytes, 0), + }; + } + + @autobind + protected async fetchActual(): Promise<DeepPartial<NetworkLog>> { + return {}; + } + + @autobind + public async update(incomingRequests: number, time: number, incomingBytes: number, outgoingBytes: number) { + const inc: DeepPartial<NetworkLog> = { + incomingRequests: incomingRequests, + totalTime: time, + incomingBytes: incomingBytes, + outgoingBytes: outgoingBytes + }; + + await this.inc(inc); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/notes.ts b/packages/backend/src/services/chart/charts/classes/notes.ts new file mode 100644 index 0000000000..0675d346d1 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/notes.ts @@ -0,0 +1,97 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { Notes } from '@/models/index'; +import { Not, IsNull } from 'typeorm'; +import { Note } from '@/models/entities/note'; +import { name, schema } from '../schemas/notes'; + +type NotesLog = SchemaType<typeof schema>; + +export default class NotesChart extends Chart<NotesLog> { + constructor() { + super(name, schema); + } + + @autobind + protected genNewLog(latest: NotesLog): DeepPartial<NotesLog> { + return { + local: { + total: latest.local.total, + }, + remote: { + total: latest.remote.total, + } + }; + } + + @autobind + protected aggregate(logs: NotesLog[]): NotesLog { + return { + local: { + total: logs[0].local.total, + inc: logs.reduce((a, b) => a + b.local.inc, 0), + dec: logs.reduce((a, b) => a + b.local.dec, 0), + diffs: { + reply: logs.reduce((a, b) => a + b.local.diffs.reply, 0), + renote: logs.reduce((a, b) => a + b.local.diffs.renote, 0), + normal: logs.reduce((a, b) => a + b.local.diffs.normal, 0), + }, + }, + remote: { + total: logs[0].remote.total, + inc: logs.reduce((a, b) => a + b.remote.inc, 0), + dec: logs.reduce((a, b) => a + b.remote.dec, 0), + diffs: { + reply: logs.reduce((a, b) => a + b.remote.diffs.reply, 0), + renote: logs.reduce((a, b) => a + b.remote.diffs.renote, 0), + normal: logs.reduce((a, b) => a + b.remote.diffs.normal, 0), + }, + }, + }; + } + + @autobind + protected async fetchActual(): Promise<DeepPartial<NotesLog>> { + const [localCount, remoteCount] = await Promise.all([ + Notes.count({ userHost: null }), + Notes.count({ userHost: Not(IsNull()) }) + ]); + + return { + local: { + total: localCount, + }, + remote: { + total: remoteCount, + } + }; + } + + @autobind + public async update(note: Note, isAdditional: boolean) { + const update: Obj = { + diffs: {} + }; + + update.total = isAdditional ? 1 : -1; + + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + if (note.replyId != null) { + update.diffs.reply = isAdditional ? 1 : -1; + } else if (note.renoteId != null) { + update.diffs.renote = isAdditional ? 1 : -1; + } else { + update.diffs.normal = isAdditional ? 1 : -1; + } + + await this.inc({ + [note.userHost === null ? 'local' : 'remote']: update + }); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/per-user-drive.ts b/packages/backend/src/services/chart/charts/classes/per-user-drive.ts new file mode 100644 index 0000000000..f28987191b --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/per-user-drive.ts @@ -0,0 +1,64 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { DriveFiles } from '@/models/index'; +import { DriveFile } from '@/models/entities/drive-file'; +import { name, schema } from '../schemas/per-user-drive'; + +type PerUserDriveLog = SchemaType<typeof schema>; + +export default class PerUserDriveChart extends Chart<PerUserDriveLog> { + constructor() { + super(name, schema, true); + } + + @autobind + protected genNewLog(latest: PerUserDriveLog): DeepPartial<PerUserDriveLog> { + return { + totalCount: latest.totalCount, + totalSize: latest.totalSize, + }; + } + + @autobind + protected aggregate(logs: PerUserDriveLog[]): PerUserDriveLog { + return { + totalCount: logs[0].totalCount, + totalSize: logs[0].totalSize, + incCount: logs.reduce((a, b) => a + b.incCount, 0), + incSize: logs.reduce((a, b) => a + b.incSize, 0), + decCount: logs.reduce((a, b) => a + b.decCount, 0), + decSize: logs.reduce((a, b) => a + b.decSize, 0), + }; + } + + @autobind + protected async fetchActual(group: string): Promise<DeepPartial<PerUserDriveLog>> { + const [count, size] = await Promise.all([ + DriveFiles.count({ userId: group }), + DriveFiles.calcDriveUsageOf(group) + ]); + + return { + totalCount: count, + totalSize: size, + }; + } + + @autobind + public async update(file: DriveFile, isAdditional: boolean) { + const update: Obj = {}; + + update.totalCount = isAdditional ? 1 : -1; + update.totalSize = isAdditional ? file.size : -file.size; + if (isAdditional) { + update.incCount = 1; + update.incSize = file.size; + } else { + update.decCount = 1; + update.decSize = file.size; + } + + await this.inc(update, file.userId); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/per-user-following.ts b/packages/backend/src/services/chart/charts/classes/per-user-following.ts new file mode 100644 index 0000000000..08a9ad1d2b --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/per-user-following.ts @@ -0,0 +1,121 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { Followings, Users } from '@/models/index'; +import { Not, IsNull } from 'typeorm'; +import { User } from '@/models/entities/user'; +import { name, schema } from '../schemas/per-user-following'; + +type PerUserFollowingLog = SchemaType<typeof schema>; + +export default class PerUserFollowingChart extends Chart<PerUserFollowingLog> { + constructor() { + super(name, schema, true); + } + + @autobind + protected genNewLog(latest: PerUserFollowingLog): DeepPartial<PerUserFollowingLog> { + return { + local: { + followings: { + total: latest.local.followings.total, + }, + followers: { + total: latest.local.followers.total, + } + }, + remote: { + followings: { + total: latest.remote.followings.total, + }, + followers: { + total: latest.remote.followers.total, + } + } + }; + } + + @autobind + protected aggregate(logs: PerUserFollowingLog[]): PerUserFollowingLog { + return { + local: { + followings: { + total: logs[0].local.followings.total, + inc: logs.reduce((a, b) => a + b.local.followings.inc, 0), + dec: logs.reduce((a, b) => a + b.local.followings.dec, 0), + }, + followers: { + total: logs[0].local.followers.total, + inc: logs.reduce((a, b) => a + b.local.followers.inc, 0), + dec: logs.reduce((a, b) => a + b.local.followers.dec, 0), + }, + }, + remote: { + followings: { + total: logs[0].remote.followings.total, + inc: logs.reduce((a, b) => a + b.remote.followings.inc, 0), + dec: logs.reduce((a, b) => a + b.remote.followings.dec, 0), + }, + followers: { + total: logs[0].remote.followers.total, + inc: logs.reduce((a, b) => a + b.remote.followers.inc, 0), + dec: logs.reduce((a, b) => a + b.remote.followers.dec, 0), + }, + }, + }; + } + + @autobind + protected async fetchActual(group: string): Promise<DeepPartial<PerUserFollowingLog>> { + const [ + localFollowingsCount, + localFollowersCount, + remoteFollowingsCount, + remoteFollowersCount + ] = await Promise.all([ + Followings.count({ followerId: group, followeeHost: null }), + Followings.count({ followeeId: group, followerHost: null }), + Followings.count({ followerId: group, followeeHost: Not(IsNull()) }), + Followings.count({ followeeId: group, followerHost: Not(IsNull()) }) + ]); + + return { + local: { + followings: { + total: localFollowingsCount, + }, + followers: { + total: localFollowersCount, + } + }, + remote: { + followings: { + total: remoteFollowingsCount, + }, + followers: { + total: remoteFollowersCount, + } + } + }; + } + + @autobind + public async update(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, isFollow: boolean) { + const update: Obj = {}; + + update.total = isFollow ? 1 : -1; + + if (isFollow) { + update.inc = 1; + } else { + update.dec = 1; + } + + this.inc({ + [Users.isLocalUser(follower) ? 'local' : 'remote']: { followings: update } + }, follower.id); + this.inc({ + [Users.isLocalUser(followee) ? 'local' : 'remote']: { followers: update } + }, followee.id); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/per-user-notes.ts b/packages/backend/src/services/chart/charts/classes/per-user-notes.ts new file mode 100644 index 0000000000..0e808766f5 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/per-user-notes.ts @@ -0,0 +1,72 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { User } from '@/models/entities/user'; +import { SchemaType } from '@/misc/schema'; +import { Notes } from '@/models/index'; +import { Note } from '@/models/entities/note'; +import { name, schema } from '../schemas/per-user-notes'; + +type PerUserNotesLog = SchemaType<typeof schema>; + +export default class PerUserNotesChart extends Chart<PerUserNotesLog> { + constructor() { + super(name, schema, true); + } + + @autobind + protected genNewLog(latest: PerUserNotesLog): DeepPartial<PerUserNotesLog> { + return { + total: latest.total, + }; + } + + @autobind + protected aggregate(logs: PerUserNotesLog[]): PerUserNotesLog { + return { + total: logs[0].total, + inc: logs.reduce((a, b) => a + b.inc, 0), + dec: logs.reduce((a, b) => a + b.dec, 0), + diffs: { + reply: logs.reduce((a, b) => a + b.diffs.reply, 0), + renote: logs.reduce((a, b) => a + b.diffs.renote, 0), + normal: logs.reduce((a, b) => a + b.diffs.normal, 0), + }, + }; + } + + @autobind + protected async fetchActual(group: string): Promise<DeepPartial<PerUserNotesLog>> { + const [count] = await Promise.all([ + Notes.count({ userId: group }), + ]); + + return { + total: count, + }; + } + + @autobind + public async update(user: { id: User['id'] }, note: Note, isAdditional: boolean) { + const update: Obj = { + diffs: {} + }; + + update.total = isAdditional ? 1 : -1; + + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + if (note.replyId != null) { + update.diffs.reply = isAdditional ? 1 : -1; + } else if (note.renoteId != null) { + update.diffs.renote = isAdditional ? 1 : -1; + } else { + update.diffs.normal = isAdditional ? 1 : -1; + } + + await this.inc(update, user.id); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/per-user-reactions.ts b/packages/backend/src/services/chart/charts/classes/per-user-reactions.ts new file mode 100644 index 0000000000..e71bcb71c4 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/per-user-reactions.ts @@ -0,0 +1,44 @@ +import autobind from 'autobind-decorator'; +import Chart, { DeepPartial } from '../../core'; +import { User } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { SchemaType } from '@/misc/schema'; +import { Users } from '@/models/index'; +import { name, schema } from '../schemas/per-user-reactions'; + +type PerUserReactionsLog = SchemaType<typeof schema>; + +export default class PerUserReactionsChart extends Chart<PerUserReactionsLog> { + constructor() { + super(name, schema, true); + } + + @autobind + protected genNewLog(latest: PerUserReactionsLog): DeepPartial<PerUserReactionsLog> { + return {}; + } + + @autobind + protected aggregate(logs: PerUserReactionsLog[]): PerUserReactionsLog { + return { + local: { + count: logs.reduce((a, b) => a + b.local.count, 0), + }, + remote: { + count: logs.reduce((a, b) => a + b.remote.count, 0), + }, + }; + } + + @autobind + protected async fetchActual(group: string): Promise<DeepPartial<PerUserReactionsLog>> { + return {}; + } + + @autobind + public async update(user: { id: User['id'], host: User['host'] }, note: Note) { + this.inc({ + [Users.isLocalUser(user) ? 'local' : 'remote']: { count: 1 } + }, note.userId); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/test-grouped.ts b/packages/backend/src/services/chart/charts/classes/test-grouped.ts new file mode 100644 index 0000000000..84e6d5e33f --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/test-grouped.ts @@ -0,0 +1,58 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { name, schema } from '../schemas/test-grouped'; + +type TestGroupedLog = SchemaType<typeof schema>; + +export default class TestGroupedChart extends Chart<TestGroupedLog> { + private total = {} as Record<string, number>; + + constructor() { + super(name, schema, true); + } + + @autobind + protected genNewLog(latest: TestGroupedLog): DeepPartial<TestGroupedLog> { + return { + foo: { + total: latest.foo.total, + }, + }; + } + + @autobind + protected aggregate(logs: TestGroupedLog[]): TestGroupedLog { + return { + foo: { + total: logs[0].foo.total, + inc: logs.reduce((a, b) => a + b.foo.inc, 0), + dec: logs.reduce((a, b) => a + b.foo.dec, 0), + }, + }; + } + + @autobind + protected async fetchActual(group: string): Promise<DeepPartial<TestGroupedLog>> { + return { + foo: { + total: this.total[group], + }, + }; + } + + @autobind + public async increment(group: string) { + if (this.total[group] == null) this.total[group] = 0; + + const update: Obj = {}; + + update.total = 1; + update.inc = 1; + this.total[group]++; + + await this.inc({ + foo: update + }, group); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/test-unique.ts b/packages/backend/src/services/chart/charts/classes/test-unique.ts new file mode 100644 index 0000000000..559fda13c9 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/test-unique.ts @@ -0,0 +1,36 @@ +import autobind from 'autobind-decorator'; +import Chart, { DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { name, schema } from '../schemas/test-unique'; + +type TestUniqueLog = SchemaType<typeof schema>; + +export default class TestUniqueChart extends Chart<TestUniqueLog> { + constructor() { + super(name, schema); + } + + @autobind + protected genNewLog(latest: TestUniqueLog): DeepPartial<TestUniqueLog> { + return {}; + } + + @autobind + protected aggregate(logs: TestUniqueLog[]): TestUniqueLog { + return { + foo: logs.reduce((a, b) => a.concat(b.foo), [] as TestUniqueLog['foo']), + }; + } + + @autobind + protected async fetchActual(): Promise<DeepPartial<TestUniqueLog>> { + return {}; + } + + @autobind + public async uniqueIncrement(key: string) { + await this.inc({ + foo: [key] + }); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/test.ts b/packages/backend/src/services/chart/charts/classes/test.ts new file mode 100644 index 0000000000..a91d5e1895 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/test.ts @@ -0,0 +1,69 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { name, schema } from '../schemas/test'; + +type TestLog = SchemaType<typeof schema>; + +export default class TestChart extends Chart<TestLog> { + public total = 0; // publicにするのはテストのため + + constructor() { + super(name, schema); + } + + @autobind + protected genNewLog(latest: TestLog): DeepPartial<TestLog> { + return { + foo: { + total: latest.foo.total, + }, + }; + } + + @autobind + protected aggregate(logs: TestLog[]): TestLog { + return { + foo: { + total: logs[0].foo.total, + inc: logs.reduce((a, b) => a + b.foo.inc, 0), + dec: logs.reduce((a, b) => a + b.foo.dec, 0), + }, + }; + } + + @autobind + protected async fetchActual(): Promise<DeepPartial<TestLog>> { + return { + foo: { + total: this.total, + }, + }; + } + + @autobind + public async increment() { + const update: Obj = {}; + + update.total = 1; + update.inc = 1; + this.total++; + + await this.inc({ + foo: update + }); + } + + @autobind + public async decrement() { + const update: Obj = {}; + + update.total = -1; + update.dec = 1; + this.total--; + + await this.inc({ + foo: update + }); + } +} diff --git a/packages/backend/src/services/chart/charts/classes/users.ts b/packages/backend/src/services/chart/charts/classes/users.ts new file mode 100644 index 0000000000..89b480ef77 --- /dev/null +++ b/packages/backend/src/services/chart/charts/classes/users.ts @@ -0,0 +1,76 @@ +import autobind from 'autobind-decorator'; +import Chart, { Obj, DeepPartial } from '../../core'; +import { SchemaType } from '@/misc/schema'; +import { Users } from '@/models/index'; +import { Not, IsNull } from 'typeorm'; +import { User } from '@/models/entities/user'; +import { name, schema } from '../schemas/users'; + +type UsersLog = SchemaType<typeof schema>; + +export default class UsersChart extends Chart<UsersLog> { + constructor() { + super(name, schema); + } + + @autobind + protected genNewLog(latest: UsersLog): DeepPartial<UsersLog> { + return { + local: { + total: latest.local.total, + }, + remote: { + total: latest.remote.total, + } + }; + } + + @autobind + protected aggregate(logs: UsersLog[]): UsersLog { + return { + local: { + total: logs[0].local.total, + inc: logs.reduce((a, b) => a + b.local.inc, 0), + dec: logs.reduce((a, b) => a + b.local.dec, 0), + }, + remote: { + total: logs[0].remote.total, + inc: logs.reduce((a, b) => a + b.remote.inc, 0), + dec: logs.reduce((a, b) => a + b.remote.dec, 0), + }, + }; + } + + @autobind + protected async fetchActual(): Promise<DeepPartial<UsersLog>> { + const [localCount, remoteCount] = await Promise.all([ + Users.count({ host: null }), + Users.count({ host: Not(IsNull()) }) + ]); + + return { + local: { + total: localCount, + }, + remote: { + total: remoteCount, + } + }; + } + + @autobind + public async update(user: { id: User['id'], host: User['host'] }, isAdditional: boolean) { + const update: Obj = {}; + + update.total = isAdditional ? 1 : -1; + if (isAdditional) { + update.inc = 1; + } else { + update.dec = 1; + } + + await this.inc({ + [Users.isLocalUser(user) ? 'local' : 'remote']: update + }); + } +} diff --git a/packages/backend/src/services/chart/charts/schemas/active-users.ts b/packages/backend/src/services/chart/charts/schemas/active-users.ts new file mode 100644 index 0000000000..1d65f280b0 --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/active-users.ts @@ -0,0 +1,35 @@ +export const logSchema = { + /** + * アクティブユーザー + */ + users: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + }, +}; + +/** + * アクティブユーザーに関するチャート + */ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + local: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + remote: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + } +}; + +export const name = 'activeUsers'; diff --git a/packages/backend/src/services/chart/charts/schemas/drive.ts b/packages/backend/src/services/chart/charts/schemas/drive.ts new file mode 100644 index 0000000000..133b47846a --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/drive.ts @@ -0,0 +1,68 @@ +const logSchema = { + /** + * 集計期間時点での、全ドライブファイル数 + */ + totalCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 集計期間時点での、全ドライブファイルの合計サイズ + */ + totalSize: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 増加したドライブファイル数 + */ + incCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 増加したドライブ使用量 + */ + incSize: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 減少したドライブファイル数 + */ + decCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 減少したドライブ使用量 + */ + decSize: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, +}; + +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + local: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + remote: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + } +}; + +export const name = 'drive'; diff --git a/packages/backend/src/services/chart/charts/schemas/federation.ts b/packages/backend/src/services/chart/charts/schemas/federation.ts new file mode 100644 index 0000000000..dca4587cac --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/federation.ts @@ -0,0 +1,29 @@ +/** + * フェデレーションに関するチャート + */ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + instance: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + } + } +}; + +export const name = 'federation'; diff --git a/packages/backend/src/services/chart/charts/schemas/hashtag.ts b/packages/backend/src/services/chart/charts/schemas/hashtag.ts new file mode 100644 index 0000000000..4e7c542bbc --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/hashtag.ts @@ -0,0 +1,35 @@ +export const logSchema = { + /** + * 投稿したユーザー + */ + users: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + }, +}; + +/** + * ハッシュタグに関するチャート + */ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + local: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + remote: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + } +}; + +export const name = 'hashtag'; diff --git a/packages/backend/src/services/chart/charts/schemas/instance.ts b/packages/backend/src/services/chart/charts/schemas/instance.ts new file mode 100644 index 0000000000..785d6ae7ce --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/instance.ts @@ -0,0 +1,157 @@ +/** + * インスタンスごとのチャート + */ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + requests: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + failed: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + succeeded: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + received: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, + + notes: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + diffs: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + normal: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + reply: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + renote: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, + } + }, + + users: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, + + following: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, + + followers: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, + + drive: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + totalFiles: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + totalUsage: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + incFiles: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + incUsage: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + decFiles: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + decUsage: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, + } +}; + +export const name = 'instance'; diff --git a/packages/backend/src/services/chart/charts/schemas/network.ts b/packages/backend/src/services/chart/charts/schemas/network.ts new file mode 100644 index 0000000000..49a364debc --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/network.ts @@ -0,0 +1,31 @@ +/** + * ネットワークに関するチャート + */ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + incomingRequests: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + outgoingRequests: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + totalTime: { // TIP: (totalTime / incomingRequests) でひとつのリクエストに平均でどれくらいの時間がかかったか知れる + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + incomingBytes: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + outgoingBytes: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } +}; + +export const name = 'network'; diff --git a/packages/backend/src/services/chart/charts/schemas/notes.ts b/packages/backend/src/services/chart/charts/schemas/notes.ts new file mode 100644 index 0000000000..2b5105348c --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/notes.ts @@ -0,0 +1,56 @@ +const logSchema = { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + diffs: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + normal: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + reply: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + renote: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, +}; + +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + local: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + remote: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + } +}; + +export const name = 'notes'; diff --git a/packages/backend/src/services/chart/charts/schemas/per-user-drive.ts b/packages/backend/src/services/chart/charts/schemas/per-user-drive.ts new file mode 100644 index 0000000000..856f1e0439 --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/per-user-drive.ts @@ -0,0 +1,55 @@ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + /** + * 集計期間時点での、全ドライブファイル数 + */ + totalCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 集計期間時点での、全ドライブファイルの合計サイズ + */ + totalSize: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 増加したドライブファイル数 + */ + incCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 増加したドライブ使用量 + */ + incSize: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 減少したドライブファイル数 + */ + decCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 減少したドライブ使用量 + */ + decSize: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } +}; + +export const name = 'perUserDrive'; diff --git a/packages/backend/src/services/chart/charts/schemas/per-user-following.ts b/packages/backend/src/services/chart/charts/schemas/per-user-following.ts new file mode 100644 index 0000000000..eaf74aaf77 --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/per-user-following.ts @@ -0,0 +1,86 @@ +export const logSchema = { + /** + * フォローしている + */ + followings: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + /** + * フォローしている合計 + */ + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * フォローした数 + */ + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * フォロー解除した数 + */ + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, + + /** + * フォローされている + */ + followers: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + /** + * フォローされている合計 + */ + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * フォローされた数 + */ + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * フォロー解除された数 + */ + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, +}; + +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + local: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + remote: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + } +}; + +export const name = 'perUserFollowing'; diff --git a/packages/backend/src/services/chart/charts/schemas/per-user-notes.ts b/packages/backend/src/services/chart/charts/schemas/per-user-notes.ts new file mode 100644 index 0000000000..72b3ff0210 --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/per-user-notes.ts @@ -0,0 +1,43 @@ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + diffs: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + normal: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + reply: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + renote: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + }, + } +}; + +export const name = 'perUserNotes'; diff --git a/packages/backend/src/services/chart/charts/schemas/per-user-reactions.ts b/packages/backend/src/services/chart/charts/schemas/per-user-reactions.ts new file mode 100644 index 0000000000..2a8520db37 --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/per-user-reactions.ts @@ -0,0 +1,31 @@ +export const logSchema = { + /** + * フォローしている合計 + */ + count: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, +}; + +/** + * ユーザーごとのリアクションに関するチャート + */ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + local: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + remote: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + } +}; + +export const name = 'perUserReaction'; diff --git a/packages/backend/src/services/chart/charts/schemas/test-grouped.ts b/packages/backend/src/services/chart/charts/schemas/test-grouped.ts new file mode 100644 index 0000000000..f8c8250e79 --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/test-grouped.ts @@ -0,0 +1,28 @@ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + foo: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + } + } +}; + +export const name = 'testGrouped'; diff --git a/packages/backend/src/services/chart/charts/schemas/test-unique.ts b/packages/backend/src/services/chart/charts/schemas/test-unique.ts new file mode 100644 index 0000000000..51280400ac --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/test-unique.ts @@ -0,0 +1,16 @@ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + foo: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'string' as const, + optional: false as const, nullable: false as const, + } + }, + } +}; + +export const name = 'testUnique'; diff --git a/packages/backend/src/services/chart/charts/schemas/test.ts b/packages/backend/src/services/chart/charts/schemas/test.ts new file mode 100644 index 0000000000..4b48d4d417 --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/test.ts @@ -0,0 +1,28 @@ +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + foo: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + } + } + } +}; + +export const name = 'test'; diff --git a/packages/backend/src/services/chart/charts/schemas/users.ts b/packages/backend/src/services/chart/charts/schemas/users.ts new file mode 100644 index 0000000000..2bf9d3c50f --- /dev/null +++ b/packages/backend/src/services/chart/charts/schemas/users.ts @@ -0,0 +1,44 @@ +const logSchema = { + /** + * 集計期間時点での、全ユーザー数 + */ + total: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 増加したユーザー数 + */ + inc: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + + /** + * 減少したユーザー数 + */ + dec: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, +}; + +export const schema = { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + local: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + remote: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: logSchema + }, + } +}; + +export const name = 'users'; diff --git a/packages/backend/src/services/chart/core.ts b/packages/backend/src/services/chart/core.ts new file mode 100644 index 0000000000..c0d3280c2b --- /dev/null +++ b/packages/backend/src/services/chart/core.ts @@ -0,0 +1,563 @@ +/** + * チャートエンジン + * + * Tests located in test/chart + */ + +import * as nestedProperty from 'nested-property'; +import autobind from 'autobind-decorator'; +import Logger from '../logger'; +import { SimpleSchema } from '@/misc/simple-schema'; +import { EntitySchema, getRepository, Repository, LessThan, Between } from 'typeorm'; +import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/prelude/time'; +import { getChartInsertLock } from '@/misc/app-lock'; + +const logger = new Logger('chart', 'white', process.env.NODE_ENV !== 'test'); + +export type Obj = { [key: string]: any }; + +export type DeepPartial<T> = { + [P in keyof T]?: DeepPartial<T[P]>; +}; + +type ArrayValue<T> = { + [P in keyof T]: T[P] extends number ? T[P][] : ArrayValue<T[P]>; +}; + +type Log = { + id: number; + + /** + * 集計のグループ + */ + group: string | null; + + /** + * 集計日時のUnixタイムスタンプ(秒) + */ + date: number; +}; + +const camelToSnake = (str: string) => { + return str.replace(/([A-Z])/g, s => '_' + s.charAt(0).toLowerCase()); +}; + +const removeDuplicates = (array: any[]) => Array.from(new Set(array)); + +/** + * 様々なチャートの管理を司るクラス + */ +export default abstract class Chart<T extends Record<string, any>> { + private static readonly columnPrefix = '___'; + private static readonly columnDot = '_'; + + private name: string; + private buffer: { + diff: DeepPartial<T>; + group: string | null; + }[] = []; + public schema: SimpleSchema; + protected repository: Repository<Log>; + + protected abstract genNewLog(latest: T): DeepPartial<T>; + + /** + * @param logs 日時が新しい方が先頭 + */ + protected abstract aggregate(logs: T[]): T; + + protected abstract fetchActual(group: string | null): Promise<DeepPartial<T>>; + + @autobind + private static convertSchemaToFlatColumnDefinitions(schema: SimpleSchema) { + const columns = {} as any; + const flatColumns = (x: Obj, path?: string) => { + for (const [k, v] of Object.entries(x)) { + const p = path ? `${path}${this.columnDot}${k}` : k; + if (v.type === 'object') { + flatColumns(v.properties, p); + } else if (v.type === 'number') { + columns[this.columnPrefix + p] = { + type: 'bigint', + }; + } else if (v.type === 'array' && v.items.type === 'string') { + columns[this.columnPrefix + p] = { + type: 'varchar', + array: true, + }; + } + } + }; + flatColumns(schema.properties!); + return columns; + } + + @autobind + private static convertFlattenColumnsToObject(x: Record<string, any>): Record<string, any> { + const obj = {} as any; + for (const k of Object.keys(x).filter(k => k.startsWith(Chart.columnPrefix))) { + // now k is ___x_y_z + const path = k.substr(Chart.columnPrefix.length).split(Chart.columnDot).join('.'); + nestedProperty.set(obj, path, x[k]); + } + return obj; + } + + @autobind + private static convertObjectToFlattenColumns(x: Record<string, any>) { + const columns = {} as Record<string, number | unknown[]>; + const flatten = (x: Obj, path?: string) => { + for (const [k, v] of Object.entries(x)) { + const p = path ? `${path}${this.columnDot}${k}` : k; + if (typeof v === 'object' && !Array.isArray(v)) { + flatten(v, p); + } else { + columns[this.columnPrefix + p] = v; + } + } + }; + flatten(x); + return columns; + } + + @autobind + private static countUniqueFields(x: Record<string, any>) { + const exec = (x: Obj) => { + const res = {} as Record<string, any>; + for (const [k, v] of Object.entries(x)) { + if (typeof v === 'object' && !Array.isArray(v)) { + res[k] = exec(v); + } else if (Array.isArray(v)) { + res[k] = Array.from(new Set(v)).length; + } else { + res[k] = v; + } + } + return res; + }; + return exec(x); + } + + @autobind + private static convertQuery(diff: Record<string, number | unknown[]>) { + const query: Record<string, Function> = {}; + + for (const [k, v] of Object.entries(diff)) { + if (typeof v === 'number') { + if (v > 0) query[k] = () => `"${k}" + ${v}`; + if (v < 0) query[k] = () => `"${k}" - ${Math.abs(v)}`; + } else if (Array.isArray(v)) { + // TODO: item が文字列以外の場合も対応 + // TODO: item をSQLエスケープ + const items = v.map(item => `"${item}"`).join(','); + query[k] = () => `array_cat("${k}", '{${items}}'::varchar[])`; + } + } + + return query; + } + + @autobind + private static dateToTimestamp(x: Date): Log['date'] { + return Math.floor(x.getTime() / 1000); + } + + @autobind + private static parseDate(date: Date): [number, number, number, number, number, number, number] { + const y = date.getUTCFullYear(); + const m = date.getUTCMonth(); + const d = date.getUTCDate(); + const h = date.getUTCHours(); + const _m = date.getUTCMinutes(); + const _s = date.getUTCSeconds(); + const _ms = date.getUTCMilliseconds(); + + return [y, m, d, h, _m, _s, _ms]; + } + + @autobind + private static getCurrentDate() { + return Chart.parseDate(new Date()); + } + + @autobind + public static schemaToEntity(name: string, schema: SimpleSchema): EntitySchema { + return new EntitySchema({ + name: `__chart__${camelToSnake(name)}`, + columns: { + id: { + type: 'integer', + primary: true, + generated: true + }, + date: { + type: 'integer', + }, + group: { + type: 'varchar', + length: 128, + nullable: true + }, + ...Chart.convertSchemaToFlatColumnDefinitions(schema) + }, + indices: [{ + columns: ['date', 'group'], + unique: true, + }, { // groupにnullが含まれると↑のuniqueは機能しないので↓の部分インデックスでカバー + columns: ['date'], + unique: true, + where: '"group" IS NULL' + }] + }); + } + + constructor(name: string, schema: SimpleSchema, grouped = false) { + this.name = name; + this.schema = schema; + const entity = Chart.schemaToEntity(name, schema); + + const keys = ['date']; + if (grouped) keys.push('group'); + + entity.options.uniques = [{ + columns: keys + }]; + + this.repository = getRepository<Log>(entity); + } + + @autobind + private getNewLog(latest: T | null): T { + const log = latest ? this.genNewLog(latest) : {}; + const flatColumns = (x: Obj, path?: string) => { + for (const [k, v] of Object.entries(x)) { + const p = path ? `${path}.${k}` : k; + if (v.type === 'object') { + flatColumns(v.properties, p); + } else { + if (nestedProperty.get(log, p) == null) { + const emptyValue = v.type === 'number' ? 0 : []; + nestedProperty.set(log, p, emptyValue); + } + } + } + }; + flatColumns(this.schema.properties!); + return log as T; + } + + @autobind + private getLatestLog(group: string | null = null): Promise<Log | null> { + return this.repository.findOne({ + group: group, + }, { + order: { + date: -1 + } + }).then(x => x || null); + } + + @autobind + private async getCurrentLog(group: string | null = null): Promise<Log> { + const [y, m, d, h] = Chart.getCurrentDate(); + + const current = dateUTC([y, m, d, h]); + + // 現在(=今のHour)のログ + const currentLog = await this.repository.findOne({ + date: Chart.dateToTimestamp(current), + ...(group ? { group: group } : {}) + }); + + // ログがあればそれを返して終了 + if (currentLog != null) { + return currentLog; + } + + let log: Log; + let data: T; + + // 集計期間が変わってから、初めてのチャート更新なら + // 最も最近のログを持ってくる + // * 例えば集計期間が「日」である場合で考えると、 + // * 昨日何もチャートを更新するような出来事がなかった場合は、 + // * ログがそもそも作られずドキュメントが存在しないということがあり得るため、 + // * 「昨日の」と決め打ちせずに「もっとも最近の」とします + const latest = await this.getLatestLog(group); + + if (latest != null) { + const obj = Chart.convertFlattenColumnsToObject(latest) as T; + + // 空ログデータを作成 + data = this.getNewLog(obj); + } else { + // ログが存在しなかったら + // (Misskeyインスタンスを建てて初めてのチャート更新時など) + + // 初期ログデータを作成 + data = this.getNewLog(null); + + logger.info(`${this.name + (group ? `:${group}` : '')}: Initial commit created`); + } + + const date = Chart.dateToTimestamp(current); + const lockKey = `${this.name}:${date}:${group}`; + + const unlock = await getChartInsertLock(lockKey); + try { + // ロック内でもう1回チェックする + const currentLog = await this.repository.findOne({ + date: date, + ...(group ? { group: group } : {}) + }); + + // ログがあればそれを返して終了 + if (currentLog != null) return currentLog; + + // 新規ログ挿入 + log = await this.repository.insert({ + group: group, + date: date, + ...Chart.convertObjectToFlattenColumns(data) + }).then(x => this.repository.findOneOrFail(x.identifiers[0])); + + logger.info(`${this.name + (group ? `:${group}` : '')}: New commit created`); + + return log; + } finally { + unlock(); + } + } + + @autobind + protected commit(diff: DeepPartial<T>, group: string | null = null): void { + this.buffer.push({ + diff, group, + }); + } + + @autobind + public async save() { + if (this.buffer.length === 0) { + logger.info(`${this.name}: Write skipped`); + return; + } + + // TODO: 前の時間のログがbufferにあった場合のハンドリング + // 例えば、save が20分ごとに行われるとして、前回行われたのは 01:50 だったとする。 + // 次に save が行われるのは 02:10 ということになるが、もし 01:55 に新規ログが buffer に追加されたとすると、 + // そのログは本来は 01:00~ のログとしてDBに保存されて欲しいのに、02:00~ のログ扱いになってしまう。 + // これを回避するための実装は複雑になりそうなため、一旦保留。 + + const update = async (log: Log) => { + const finalDiffs = {} as Record<string, number | unknown[]>; + + for (const diff of this.buffer.filter(q => q.group === log.group).map(q => q.diff)) { + const columns = Chart.convertObjectToFlattenColumns(diff); + + for (const [k, v] of Object.entries(columns)) { + if (finalDiffs[k] == null) { + finalDiffs[k] = v; + } else { + if (typeof finalDiffs[k] === 'number') { + (finalDiffs[k] as number) += v as number; + } else { + (finalDiffs[k] as unknown[]) = (finalDiffs[k] as unknown[]).concat(v); + } + } + } + } + + const query = Chart.convertQuery(finalDiffs); + + // ログ更新 + await this.repository.createQueryBuilder() + .update() + .set(query) + .where('id = :id', { id: log.id }) + .execute(); + + logger.info(`${this.name + (log.group ? `:${log.group}` : '')}: Updated`); + + // TODO: この一連の処理が始まった後に新たにbufferに入ったものは消さないようにする + this.buffer = this.buffer.filter(q => q.group !== log.group); + }; + + const groups = removeDuplicates(this.buffer.map(log => log.group)); + + await Promise.all(groups.map(group => this.getCurrentLog(group).then(log => update(log)))); + } + + @autobind + public async resync(group: string | null = null): Promise<any> { + const data = await this.fetchActual(group); + + const update = async (log: Log) => { + await this.repository.createQueryBuilder() + .update() + .set(Chart.convertObjectToFlattenColumns(data)) + .where('id = :id', { id: log.id }) + .execute(); + }; + + return this.getCurrentLog(group).then(log => update(log)); + } + + @autobind + protected async inc(inc: DeepPartial<T>, group: string | null = null): Promise<void> { + await this.commit(inc, group); + } + + @autobind + public async getChart(span: 'hour' | 'day', amount: number, cursor: Date | null, group: string | null = null): Promise<ArrayValue<T>> { + const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate(); + const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never; + + const lt = dateUTC([y, m, d, h, _m, _s, _ms]); + + const gt = + span === 'day' ? subtractTime(cursor ? dateUTC([y2, m2, d2, 0]) : dateUTC([y, m, d, 0]), amount - 1, 'day') : + span === 'hour' ? subtractTime(cursor ? dateUTC([y2, m2, d2, h2]) : dateUTC([y, m, d, h]), amount - 1, 'hour') : + null as never; + + // ログ取得 + let logs = await this.repository.find({ + where: { + group: group, + date: Between(Chart.dateToTimestamp(gt), Chart.dateToTimestamp(lt)) + }, + order: { + date: -1 + }, + }); + + // 要求された範囲にログがひとつもなかったら + if (logs.length === 0) { + // もっとも新しいログを持ってくる + // (すくなくともひとつログが無いと隙間埋めできないため) + const recentLog = await this.repository.findOne({ + group: group, + }, { + order: { + date: -1 + }, + }); + + if (recentLog) { + logs = [recentLog]; + } + + // 要求された範囲の最も古い箇所に位置するログが存在しなかったら + } else if (!isTimeSame(new Date(logs[logs.length - 1].date * 1000), gt)) { + // 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する + // (隙間埋めできないため) + const outdatedLog = await this.repository.findOne({ + group: group, + date: LessThan(Chart.dateToTimestamp(gt)) + }, { + order: { + date: -1 + }, + }); + + if (outdatedLog) { + logs.push(outdatedLog); + } + } + + const chart: T[] = []; + + if (span === 'hour') { + for (let i = (amount - 1); i >= 0; i--) { + const current = subtractTime(dateUTC([y, m, d, h]), i, 'hour'); + + const log = logs.find(l => isTimeSame(new Date(l.date * 1000), current)); + + if (log) { + const data = Chart.convertFlattenColumnsToObject(log); + chart.unshift(Chart.countUniqueFields(data) as T); + } else { + // 隙間埋め + const latest = logs.find(l => isTimeBefore(new Date(l.date * 1000), current)); + const data = latest ? Chart.convertFlattenColumnsToObject(latest) as T : null; + chart.unshift(Chart.countUniqueFields(this.getNewLog(data)) as T); + } + } + } else if (span === 'day') { + const logsForEachDays: T[][] = []; + let currentDay = -1; + let currentDayIndex = -1; + for (let i = ((amount - 1) * 24) + h; i >= 0; i--) { + const current = subtractTime(dateUTC([y, m, d, h]), i, 'hour'); + const _currentDay = Chart.parseDate(current)[2]; + if (currentDay != _currentDay) currentDayIndex++; + currentDay = _currentDay; + + const log = logs.find(l => isTimeSame(new Date(l.date * 1000), current)); + + if (log) { + if (logsForEachDays[currentDayIndex]) { + logsForEachDays[currentDayIndex].unshift(Chart.convertFlattenColumnsToObject(log) as T); + } else { + logsForEachDays[currentDayIndex] = [Chart.convertFlattenColumnsToObject(log) as T]; + } + } else { + // 隙間埋め + const latest = logs.find(l => isTimeBefore(new Date(l.date * 1000), current)); + const data = latest ? Chart.convertFlattenColumnsToObject(latest) as T : null; + const newLog = this.getNewLog(data); + if (logsForEachDays[currentDayIndex]) { + logsForEachDays[currentDayIndex].unshift(newLog); + } else { + logsForEachDays[currentDayIndex] = [newLog]; + } + } + } + + for (const logs of logsForEachDays) { + const log = this.aggregate(logs); + chart.unshift(Chart.countUniqueFields(log) as T); + } + } + + const res: ArrayValue<T> = {} as any; + + /** + * [{ foo: 1, bar: 5 }, { foo: 2, bar: 6 }, { foo: 3, bar: 7 }] + * を + * { foo: [1, 2, 3], bar: [5, 6, 7] } + * にする + */ + const compact = (x: Obj, path?: string) => { + for (const [k, v] of Object.entries(x)) { + const p = path ? `${path}.${k}` : k; + if (typeof v === 'object' && !Array.isArray(v)) { + compact(v, p); + } else { + const values = chart.map(s => nestedProperty.get(s, p)); + nestedProperty.set(res, p, values); + } + } + }; + + compact(chart[0]); + + return res; + } +} + +export function convertLog(logSchema: SimpleSchema): SimpleSchema { + const v: SimpleSchema = JSON.parse(JSON.stringify(logSchema)); // copy + if (v.type === 'number') { + v.type = 'array'; + v.items = { + type: 'number' as const, + optional: false as const, nullable: false as const, + }; + } else if (v.type === 'object') { + for (const k of Object.keys(v.properties!)) { + v.properties![k] = convertLog(v.properties![k]); + } + } + return v; +} diff --git a/packages/backend/src/services/chart/entities.ts b/packages/backend/src/services/chart/entities.ts new file mode 100644 index 0000000000..23a97607eb --- /dev/null +++ b/packages/backend/src/services/chart/entities.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import Chart from './core'; + +//const _filename = fileURLToPath(import.meta.url); +const _filename = __filename; +const _dirname = dirname(_filename); + +export const entities = Object.values(require('require-all')({ + dirname: _dirname + '/charts/schemas', + filter: /^.+\.[jt]s$/, + resolve: (x: any) => { + return Chart.schemaToEntity(x.name, x.schema); + } +})); diff --git a/packages/backend/src/services/chart/index.ts b/packages/backend/src/services/chart/index.ts new file mode 100644 index 0000000000..61eb431ea3 --- /dev/null +++ b/packages/backend/src/services/chart/index.ts @@ -0,0 +1,50 @@ +import FederationChart from './charts/classes/federation'; +import NotesChart from './charts/classes/notes'; +import UsersChart from './charts/classes/users'; +import NetworkChart from './charts/classes/network'; +import ActiveUsersChart from './charts/classes/active-users'; +import InstanceChart from './charts/classes/instance'; +import PerUserNotesChart from './charts/classes/per-user-notes'; +import DriveChart from './charts/classes/drive'; +import PerUserReactionsChart from './charts/classes/per-user-reactions'; +import HashtagChart from './charts/classes/hashtag'; +import PerUserFollowingChart from './charts/classes/per-user-following'; +import PerUserDriveChart from './charts/classes/per-user-drive'; +import { beforeShutdown } from '@/misc/before-shutdown'; + +export const federationChart = new FederationChart(); +export const notesChart = new NotesChart(); +export const usersChart = new UsersChart(); +export const networkChart = new NetworkChart(); +export const activeUsersChart = new ActiveUsersChart(); +export const instanceChart = new InstanceChart(); +export const perUserNotesChart = new PerUserNotesChart(); +export const driveChart = new DriveChart(); +export const perUserReactionsChart = new PerUserReactionsChart(); +export const hashtagChart = new HashtagChart(); +export const perUserFollowingChart = new PerUserFollowingChart(); +export const perUserDriveChart = new PerUserDriveChart(); + +const charts = [ + federationChart, + notesChart, + usersChart, + networkChart, + activeUsersChart, + instanceChart, + perUserNotesChart, + driveChart, + perUserReactionsChart, + hashtagChart, + perUserFollowingChart, + perUserDriveChart, +]; + +// 20分おきにメモリ情報をDBに書き込み +setInterval(() => { + for (const chart of charts) { + chart.save(); + } +}, 1000 * 60 * 20); + +beforeShutdown(() => Promise.all(charts.map(chart => chart.save()))); diff --git a/packages/backend/src/services/create-notification.ts b/packages/backend/src/services/create-notification.ts new file mode 100644 index 0000000000..5398d486c0 --- /dev/null +++ b/packages/backend/src/services/create-notification.ts @@ -0,0 +1,61 @@ +import { publishMainStream } from '@/services/stream'; +import pushSw from './push-notification'; +import { Notifications, Mutings, UserProfiles, Users } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { User } from '@/models/entities/user'; +import { Notification } from '@/models/entities/notification'; +import { sendEmailNotification } from './send-email-notification'; + +export async function createNotification( + notifieeId: User['id'], + type: Notification['type'], + data: Partial<Notification> +) { + if (data.notifierId && (notifieeId === data.notifierId)) { + return null; + } + + const profile = await UserProfiles.findOne({ userId: notifieeId }); + + const isMuted = profile?.mutingNotificationTypes.includes(type); + + // Create notification + const notification = await Notifications.save({ + id: genId(), + createdAt: new Date(), + notifieeId: notifieeId, + type: type, + // 相手がこの通知をミュートしているようなら、既読を予めつけておく + isRead: isMuted, + ...data + } as Partial<Notification>); + + const packed = await Notifications.pack(notification, {}); + + // Publish notification event + publishMainStream(notifieeId, 'notification', packed); + + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + setTimeout(async () => { + const fresh = await Notifications.findOne(notification.id); + if (fresh == null) return; // 既に削除されているかもしれない + if (fresh.isRead) return; + + //#region ただしミュートしているユーザーからの通知なら無視 + const mutings = await Mutings.find({ + muterId: notifieeId + }); + if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { + return; + } + //#endregion + + publishMainStream(notifieeId, 'unreadNotification', packed); + + pushSw(notifieeId, 'notification', packed); + if (type === 'follow') sendEmailNotification.follow(notifieeId, await Users.findOneOrFail(data.notifierId!)); + if (type === 'receiveFollowRequest') sendEmailNotification.receiveFollowRequest(notifieeId, await Users.findOneOrFail(data.notifierId!)); + }, 2000); + + return notification; +} diff --git a/packages/backend/src/services/create-system-user.ts b/packages/backend/src/services/create-system-user.ts new file mode 100644 index 0000000000..71be8d4abf --- /dev/null +++ b/packages/backend/src/services/create-system-user.ts @@ -0,0 +1,67 @@ +import * as bcrypt from 'bcryptjs'; +import { v4 as uuid } from 'uuid'; +import generateNativeUserToken from '../server/api/common/generate-native-user-token'; +import { genRsaKeyPair } from '@/misc/gen-key-pair'; +import { User } from '@/models/entities/user'; +import { UserProfile } from '@/models/entities/user-profile'; +import { getConnection } from 'typeorm'; +import { genId } from '@/misc/gen-id'; +import { UserKeypair } from '@/models/entities/user-keypair'; +import { UsedUsername } from '@/models/entities/used-username'; + +export async function createSystemUser(username: string) { + const password = uuid(); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + // Generate secret + const secret = generateNativeUserToken(); + + const keyPair = await genRsaKeyPair(4096); + + let account!: User; + + // Start transaction + await getConnection().transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOne(User, { + usernameLower: username.toLowerCase(), + host: null + }); + + if (exist) throw new Error('the user is already exists'); + + account = await transactionalEntityManager.insert(User, { + id: genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: null, + token: secret, + isAdmin: false, + isLocked: true, + isExplorable: false, + isBot: true, + }).then(x => transactionalEntityManager.findOneOrFail(User, x.identifiers[0])); + + await transactionalEntityManager.insert(UserKeypair, { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + userId: account.id + }); + + await transactionalEntityManager.insert(UserProfile, { + userId: account.id, + autoAcceptFollowed: false, + password: hash, + }); + + await transactionalEntityManager.insert(UsedUsername, { + createdAt: new Date(), + username: username.toLowerCase(), + }); + }); + + return account; +} diff --git a/packages/backend/src/services/drive/add-file.ts b/packages/backend/src/services/drive/add-file.ts new file mode 100644 index 0000000000..6c5fefd4ad --- /dev/null +++ b/packages/backend/src/services/drive/add-file.ts @@ -0,0 +1,466 @@ +import * as fs from 'fs'; + +import { v4 as uuid } from 'uuid'; + +import { publishMainStream, publishDriveStream } from '@/services/stream'; +import { deleteFile } from './delete-file'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { GenerateVideoThumbnail } from './generate-video-thumbnail'; +import { driveLogger } from './logger'; +import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng, convertSharpToPngOrJpeg } from './image-processor'; +import { contentDisposition } from '@/misc/content-disposition'; +import { getFileInfo } from '@/misc/get-file-info'; +import { DriveFiles, DriveFolders, Users, Instances, UserProfiles } from '@/models/index'; +import { InternalStorage } from './internal-storage'; +import { DriveFile } from '@/models/entities/drive-file'; +import { IRemoteUser, User } from '@/models/entities/user'; +import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index'; +import { genId } from '@/misc/gen-id'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error'; +import * as S3 from 'aws-sdk/clients/s3'; +import { getS3 } from './s3'; +import * as sharp from 'sharp'; + +const logger = driveLogger.createSubLogger('register', 'yellow'); + +/*** + * Save file + * @param path Path for original + * @param name Name for original + * @param type Content-Type for original + * @param hash Hash for original + * @param size Size for original + */ +async function save(file: DriveFile, path: string, name: string, type: string, hash: string, size: number): Promise<DriveFile> { + // thunbnail, webpublic を必要なら生成 + const alts = await generateAlts(path, type, !file.uri); + + const meta = await fetchMeta(); + + if (meta.useObjectStorage) { + //#region ObjectStorage params + let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) || ['']); + + if (ext === '') { + if (type === 'image/jpeg') ext = '.jpg'; + if (type === 'image/png') ext = '.png'; + if (type === 'image/webp') ext = '.webp'; + if (type === 'image/apng') ext = '.apng'; + if (type === 'image/vnd.mozilla.apng') ext = '.apng'; + } + + const baseUrl = meta.objectStorageBaseUrl + || `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`; + + // for original + const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`; + const url = `${ baseUrl }/${ key }`; + + // for alts + let webpublicKey: string | null = null; + let webpublicUrl: string | null = null; + let thumbnailKey: string | null = null; + let thumbnailUrl: string | null = null; + //#endregion + + //#region Uploads + logger.info(`uploading original: ${key}`); + const uploads = [ + upload(key, fs.createReadStream(path), type, name) + ]; + + if (alts.webpublic) { + webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`; + webpublicUrl = `${ baseUrl }/${ webpublicKey }`; + + logger.info(`uploading webpublic: ${webpublicKey}`); + uploads.push(upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); + } + + if (alts.thumbnail) { + thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`; + thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; + + logger.info(`uploading thumbnail: ${thumbnailKey}`); + uploads.push(upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); + } + + await Promise.all(uploads); + //#endregion + + file.url = url; + file.thumbnailUrl = thumbnailUrl; + file.webpublicUrl = webpublicUrl; + file.accessKey = key; + file.thumbnailAccessKey = thumbnailKey; + file.webpublicAccessKey = webpublicKey; + file.name = name; + file.type = type; + file.md5 = hash; + file.size = size; + file.storedInternal = false; + + return await DriveFiles.save(file); + } else { // use internal storage + const accessKey = uuid(); + const thumbnailAccessKey = 'thumbnail-' + uuid(); + const webpublicAccessKey = 'webpublic-' + uuid(); + + const url = InternalStorage.saveFromPath(accessKey, path); + + let thumbnailUrl: string | null = null; + let webpublicUrl: string | null = null; + + if (alts.thumbnail) { + thumbnailUrl = InternalStorage.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data); + logger.info(`thumbnail stored: ${thumbnailAccessKey}`); + } + + if (alts.webpublic) { + webpublicUrl = InternalStorage.saveFromBuffer(webpublicAccessKey, alts.webpublic.data); + logger.info(`web stored: ${webpublicAccessKey}`); + } + + file.storedInternal = true; + file.url = url; + file.thumbnailUrl = thumbnailUrl; + file.webpublicUrl = webpublicUrl; + file.accessKey = accessKey; + file.thumbnailAccessKey = thumbnailAccessKey; + file.webpublicAccessKey = webpublicAccessKey; + file.name = name; + file.type = type; + file.md5 = hash; + file.size = size; + + return await DriveFiles.save(file); + } +} + +/** + * Generate webpublic, thumbnail, etc + * @param path Path for original + * @param type Content-Type for original + * @param generateWeb Generate webpublic or not + */ +export async function generateAlts(path: string, type: string, generateWeb: boolean) { + if (type.startsWith('video/')) { + try { + const thumbnail = await GenerateVideoThumbnail(path); + return { + webpublic: null, + thumbnail + }; + } catch (e) { + logger.warn(`GenerateVideoThumbnail failed: ${e}`); + return { + webpublic: null, + thumbnail: null + }; + } + } + + if (!['image/jpeg', 'image/png', 'image/webp'].includes(type)) { + logger.debug(`web image and thumbnail not created (not an required file)`); + return { + webpublic: null, + thumbnail: null + }; + } + + let img: sharp.Sharp | null = null; + + try { + img = sharp(path); + const metadata = await img.metadata(); + const isAnimated = metadata.pages && metadata.pages > 1; + + // skip animated + if (isAnimated) { + return { + webpublic: null, + thumbnail: null + }; + } + } catch (e) { + logger.warn(`sharp failed: ${e}`); + return { + webpublic: null, + thumbnail: null + }; + } + + // #region webpublic + let webpublic: IImage | null = null; + + if (generateWeb) { + logger.info(`creating web image`); + + try { + if (['image/jpeg'].includes(type)) { + webpublic = await convertSharpToJpeg(img, 2048, 2048); + } else if (['image/webp'].includes(type)) { + webpublic = await convertSharpToWebp(img, 2048, 2048); + } else if (['image/png'].includes(type)) { + webpublic = await convertSharpToPng(img, 2048, 2048); + } else { + logger.debug(`web image not created (not an required image)`); + } + } catch (e) { + logger.warn(`web image not created (an error occured)`, e); + } + } else { + logger.info(`web image not created (from remote)`); + } + // #endregion webpublic + + // #region thumbnail + let thumbnail: IImage | null = null; + + try { + if (['image/jpeg', 'image/webp'].includes(type)) { + thumbnail = await convertSharpToJpeg(img, 498, 280); + } else if (['image/png'].includes(type)) { + thumbnail = await convertSharpToPngOrJpeg(img, 498, 280); + } else { + logger.debug(`thumbnail not created (not an required file)`); + } + } catch (e) { + logger.warn(`thumbnail not created (an error occured)`, e); + } + // #endregion thumbnail + + return { + webpublic, + thumbnail, + }; +} + +/** + * Upload to ObjectStorage + */ +async function upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { + if (type === 'image/apng') type = 'image/png'; + + const meta = await fetchMeta(); + + const params = { + Bucket: meta.objectStorageBucket, + Key: key, + Body: stream, + ContentType: type, + CacheControl: 'max-age=31536000, immutable', + } as S3.PutObjectRequest; + + if (filename) params.ContentDisposition = contentDisposition('inline', filename); + if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; + + const s3 = getS3(meta); + + const upload = s3.upload(params, { + partSize: s3.endpoint?.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024 + }); + + const result = await upload.promise(); + if (result) logger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); +} + +async function deleteOldFile(user: IRemoteUser) { + const q = DriveFiles.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .andWhere('file.isLink = FALSE'); + + if (user.avatarId) { + q.andWhere('file.id != :avatarId', { avatarId: user.avatarId }); + } + + if (user.bannerId) { + q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); + } + + q.orderBy('file.id', 'ASC'); + + const oldFile = await q.getOne(); + + if (oldFile) { + deleteFile(oldFile, true); + } +} + +/** + * Add file to drive + * + * @param user User who wish to add file + * @param path File path + * @param name Name + * @param comment Comment + * @param folderId Folder ID + * @param force If set to true, forcibly upload the file even if there is a file with the same hash. + * @param isLink Do not save file to local + * @param url URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL) + * @param uri URL of source (リモートインスタンスのURLからアップロードされた場合の元URL) + * @param sensitive Mark file as sensitive + * @return Created drive file + */ +export default async function( + user: { id: User['id']; host: User['host'] } | null, + path: string, + name: string | null = null, + comment: string | null = null, + folderId: any = null, + force: boolean = false, + isLink: boolean = false, + url: string | null = null, + uri: string | null = null, + sensitive: boolean | null = null +): Promise<DriveFile> { + const info = await getFileInfo(path); + logger.info(`${JSON.stringify(info)}`); + + // detect name + const detectedName = name || (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); + + if (user && !force) { + // Check if there is a file with the same hash + const much = await DriveFiles.findOne({ + md5: info.md5, + userId: user.id, + }); + + if (much) { + logger.info(`file with same hash is found: ${much.id}`); + return much; + } + } + + //#region Check drive usage + if (user && !isLink) { + const usage = await DriveFiles.calcDriveUsageOf(user); + + const instance = await fetchMeta(); + const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); + + logger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); + + // If usage limit exceeded + if (usage + info.size > driveCapacity) { + if (Users.isLocalUser(user)) { + throw new Error('no-free-space'); + } else { + // (アバターまたはバナーを含まず)最も古いファイルを削除する + deleteOldFile(await Users.findOneOrFail(user.id) as IRemoteUser); + } + } + } + //#endregion + + const fetchFolder = async () => { + if (!folderId) { + return null; + } + + const driveFolder = await DriveFolders.findOne({ + id: folderId, + userId: user ? user.id : null + }); + + if (driveFolder == null) throw new Error('folder-not-found'); + + return driveFolder; + }; + + const properties: { + width?: number; + height?: number; + } = {}; + + if (info.width) { + properties['width'] = info.width; + properties['height'] = info.height; + } + + const profile = user ? await UserProfiles.findOne(user.id) : null; + + const folder = await fetchFolder(); + + let file = new DriveFile(); + file.id = genId(); + file.createdAt = new Date(); + file.userId = user ? user.id : null; + file.userHost = user ? user.host : null; + file.folderId = folder !== null ? folder.id : null; + file.comment = comment; + file.properties = properties; + file.blurhash = info.blurhash || null; + file.isLink = isLink; + file.isSensitive = user + ? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : + (sensitive !== null && sensitive !== undefined) + ? sensitive + : false + : false; + + if (url !== null) { + file.src = url; + + if (isLink) { + file.url = url; + // ローカルプロキシ用 + file.accessKey = uuid(); + file.thumbnailAccessKey = 'thumbnail-' + uuid(); + file.webpublicAccessKey = 'webpublic-' + uuid(); + } + } + + if (uri !== null) { + file.uri = uri; + } + + if (isLink) { + try { + file.size = 0; + file.md5 = info.md5; + file.name = detectedName; + file.type = info.type.mime; + file.storedInternal = false; + + file = await DriveFiles.save(file); + } catch (e) { + // duplicate key error (when already registered) + if (isDuplicateKeyValueError(e)) { + logger.info(`already registered ${file.uri}`); + + file = await DriveFiles.findOne({ + uri: file.uri, + userId: user ? user.id : null + }) as DriveFile; + } else { + logger.error(e); + throw e; + } + } + } else { + file = await (save(file, path, detectedName, info.type.mime, info.md5, info.size)); + } + + logger.succ(`drive file has been created ${file.id}`); + + if (user) { + DriveFiles.pack(file, { self: true }).then(packedFile => { + // Publish driveFileCreated event + publishMainStream(user.id, 'driveFileCreated', packedFile); + publishDriveStream(user.id, 'fileCreated', packedFile); + }); + } + + // 統計を更新 + driveChart.update(file, true); + perUserDriveChart.update(file, true); + if (file.userHost !== null) { + instanceChart.updateDrive(file, true); + Instances.increment({ host: file.userHost }, 'driveUsage', file.size); + Instances.increment({ host: file.userHost }, 'driveFiles', 1); + } + + return file; +} diff --git a/packages/backend/src/services/drive/delete-file.ts b/packages/backend/src/services/drive/delete-file.ts new file mode 100644 index 0000000000..2ac11b8295 --- /dev/null +++ b/packages/backend/src/services/drive/delete-file.ts @@ -0,0 +1,103 @@ +import { DriveFile } from '@/models/entities/drive-file'; +import { InternalStorage } from './internal-storage'; +import { DriveFiles, Instances } from '@/models/index'; +import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index'; +import { createDeleteObjectStorageFileJob } from '@/queue/index'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { getS3 } from './s3'; +import { v4 as uuid } from 'uuid'; + +export async function deleteFile(file: DriveFile, isExpired = false) { + if (file.storedInternal) { + InternalStorage.del(file.accessKey!); + + if (file.thumbnailUrl) { + InternalStorage.del(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + InternalStorage.del(file.webpublicAccessKey!); + } + } else if (!file.isLink) { + createDeleteObjectStorageFileJob(file.accessKey!); + + if (file.thumbnailUrl) { + createDeleteObjectStorageFileJob(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + createDeleteObjectStorageFileJob(file.webpublicAccessKey!); + } + } + + postProcess(file, isExpired); +} + +export async function deleteFileSync(file: DriveFile, isExpired = false) { + if (file.storedInternal) { + InternalStorage.del(file.accessKey!); + + if (file.thumbnailUrl) { + InternalStorage.del(file.thumbnailAccessKey!); + } + + if (file.webpublicUrl) { + InternalStorage.del(file.webpublicAccessKey!); + } + } else if (!file.isLink) { + const promises = []; + + promises.push(deleteObjectStorageFile(file.accessKey!)); + + if (file.thumbnailUrl) { + promises.push(deleteObjectStorageFile(file.thumbnailAccessKey!)); + } + + if (file.webpublicUrl) { + promises.push(deleteObjectStorageFile(file.webpublicAccessKey!)); + } + + await Promise.all(promises); + } + + postProcess(file, isExpired); +} + +async function postProcess(file: DriveFile, isExpired = false) { + // リモートファイル期限切れ削除後は直リンクにする + if (isExpired && file.userHost !== null && file.uri != null) { + DriveFiles.update(file.id, { + isLink: true, + url: file.uri, + thumbnailUrl: null, + webpublicUrl: null, + storedInternal: false, + // ローカルプロキシ用 + accessKey: uuid(), + thumbnailAccessKey: 'thumbnail-' + uuid(), + webpublicAccessKey: 'webpublic-' + uuid(), + }); + } else { + DriveFiles.delete(file.id); + } + + // 統計を更新 + driveChart.update(file, false); + perUserDriveChart.update(file, false); + if (file.userHost !== null) { + instanceChart.updateDrive(file, false); + Instances.decrement({ host: file.userHost }, 'driveUsage', file.size); + Instances.decrement({ host: file.userHost }, 'driveFiles', 1); + } +} + +export async function deleteObjectStorageFile(key: string) { + const meta = await fetchMeta(); + + const s3 = getS3(meta); + + await s3.deleteObject({ + Bucket: meta.objectStorageBucket!, + Key: key + }).promise(); +} diff --git a/packages/backend/src/services/drive/generate-video-thumbnail.ts b/packages/backend/src/services/drive/generate-video-thumbnail.ts new file mode 100644 index 0000000000..f0adc7c338 --- /dev/null +++ b/packages/backend/src/services/drive/generate-video-thumbnail.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as tmp from 'tmp'; +import { IImage, convertToJpeg } from './image-processor'; +import * as FFmpeg from 'fluent-ffmpeg'; + +export async function GenerateVideoThumbnail(path: string): Promise<IImage> { + const [outDir, cleanup] = await new Promise<[string, any]>((res, rej) => { + tmp.dir((e, path, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); + + await new Promise((res, rej) => { + FFmpeg({ + source: path + }) + .on('end', res) + .on('error', rej) + .screenshot({ + folder: outDir, + filename: 'output.png', + count: 1, + timestamps: ['5%'] + }); + }); + + const outPath = `${outDir}/output.png`; + + const thumbnail = await convertToJpeg(outPath, 498, 280); + + // cleanup + await fs.promises.unlink(outPath); + cleanup(); + + return thumbnail; +} diff --git a/packages/backend/src/services/drive/image-processor.ts b/packages/backend/src/services/drive/image-processor.ts new file mode 100644 index 0000000000..493bf5c1cc --- /dev/null +++ b/packages/backend/src/services/drive/image-processor.ts @@ -0,0 +1,107 @@ +import * as sharp from 'sharp'; + +export type IImage = { + data: Buffer; + ext: string | null; + type: string; +}; + +/** + * Convert to JPEG + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function convertToJpeg(path: string, width: number, height: number): Promise<IImage> { + return convertSharpToJpeg(await sharp(path), width, height); +} + +export async function convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .jpeg({ + quality: 85, + progressive: true + }) + .toBuffer(); + + return { + data, + ext: 'jpg', + type: 'image/jpeg' + }; +} + +/** + * Convert to WebP + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function convertToWebp(path: string, width: number, height: number): Promise<IImage> { + return convertSharpToWebp(await sharp(path), width, height); +} + +export async function convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .webp({ + quality: 85 + }) + .toBuffer(); + + return { + data, + ext: 'webp', + type: 'image/webp' + }; +} + +/** + * Convert to PNG + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function convertToPng(path: string, width: number, height: number): Promise<IImage> { + return convertSharpToPng(await sharp(path), width, height); +} + +export async function convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> { + const data = await sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true + }) + .rotate() + .png() + .toBuffer(); + + return { + data, + ext: 'png', + type: 'image/png' + }; +} + +/** + * Convert to PNG or JPEG + * with resize, remove metadata, resolve orientation, stop animation + */ +export async function convertToPngOrJpeg(path: string, width: number, height: number): Promise<IImage> { + return convertSharpToPngOrJpeg(await sharp(path), width, height); +} + +export async function convertSharpToPngOrJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> { + const stats = await sharp.stats(); + const metadata = await sharp.metadata(); + + // 不透明で300x300pxの範囲を超えていればJPEG + if (stats.isOpaque && ((metadata.width && metadata.width >= 300) || (metadata.height && metadata!.height >= 300))) { + return await convertSharpToJpeg(sharp, width, height); + } else { + return await convertSharpToPng(sharp, width, height); + } +} diff --git a/packages/backend/src/services/drive/internal-storage.ts b/packages/backend/src/services/drive/internal-storage.ts new file mode 100644 index 0000000000..fe190a028c --- /dev/null +++ b/packages/backend/src/services/drive/internal-storage.ts @@ -0,0 +1,35 @@ +import * as fs from 'fs'; +import * as Path from 'path'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import config from '@/config/index'; + +//const _filename = fileURLToPath(import.meta.url); +const _filename = __filename; +const _dirname = dirname(_filename); + +export class InternalStorage { + private static readonly path = Path.resolve(_dirname, '../../../../../files'); + + public static resolvePath = (key: string) => Path.resolve(InternalStorage.path, key); + + public static read(key: string) { + return fs.createReadStream(InternalStorage.resolvePath(key)); + } + + public static saveFromPath(key: string, srcPath: string) { + fs.mkdirSync(InternalStorage.path, { recursive: true }); + fs.copyFileSync(srcPath, InternalStorage.resolvePath(key)); + return `${config.url}/files/${key}`; + } + + public static saveFromBuffer(key: string, data: Buffer) { + fs.mkdirSync(InternalStorage.path, { recursive: true }); + fs.writeFileSync(InternalStorage.resolvePath(key), data); + return `${config.url}/files/${key}`; + } + + public static del(key: string) { + fs.unlink(InternalStorage.resolvePath(key), () => {}); + } +} diff --git a/packages/backend/src/services/drive/logger.ts b/packages/backend/src/services/drive/logger.ts new file mode 100644 index 0000000000..655d074d6e --- /dev/null +++ b/packages/backend/src/services/drive/logger.ts @@ -0,0 +1,3 @@ +import Logger from '../logger'; + +export const driveLogger = new Logger('drive', 'blue'); diff --git a/packages/backend/src/services/drive/s3.ts b/packages/backend/src/services/drive/s3.ts new file mode 100644 index 0000000000..f473c4a203 --- /dev/null +++ b/packages/backend/src/services/drive/s3.ts @@ -0,0 +1,24 @@ +import { URL } from 'url'; +import * as S3 from 'aws-sdk/clients/s3'; +import { Meta } from '@/models/entities/meta'; +import { getAgentByUrl } from '@/misc/fetch'; + +export function getS3(meta: Meta) { + const u = meta.objectStorageEndpoint != null + ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` + : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; + + return new S3({ + endpoint: meta.objectStorageEndpoint || undefined, + accessKeyId: meta.objectStorageAccessKey!, + secretAccessKey: meta.objectStorageSecretKey!, + region: meta.objectStorageRegion || undefined, + sslEnabled: meta.objectStorageUseSSL, + s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted + ? false + : meta.objectStorageS3ForcePathStyle, + httpOptions: { + agent: getAgentByUrl(new URL(u), !meta.objectStorageUseProxy) + } + }); +} diff --git a/packages/backend/src/services/drive/upload-from-url.ts b/packages/backend/src/services/drive/upload-from-url.ts new file mode 100644 index 0000000000..29788c4af4 --- /dev/null +++ b/packages/backend/src/services/drive/upload-from-url.ts @@ -0,0 +1,62 @@ +import { URL } from 'url'; +import create from './add-file'; +import { User } from '@/models/entities/user'; +import { driveLogger } from './logger'; +import { createTemp } from '@/misc/create-temp'; +import { downloadUrl } from '@/misc/download-url'; +import { DriveFolder } from '@/models/entities/drive-folder'; +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFiles } from '@/models/index'; + +const logger = driveLogger.createSubLogger('downloader'); + +export default async ( + url: string, + user: { id: User['id']; host: User['host'] } | null, + folderId: DriveFolder['id'] | null = null, + uri: string | null = null, + sensitive = false, + force = false, + link = false, + comment = null +): Promise<DriveFile> => { + let name = new URL(url).pathname.split('/').pop() || null; + if (name == null || !DriveFiles.validateFileName(name)) { + name = null; + } + + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name == comment) { + comment = null; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + // write content at URL to temp file + await downloadUrl(url, path); + + let driveFile: DriveFile; + let error; + + try { + driveFile = await create(user, path, name, comment, folderId, force, link, url, uri, sensitive); + logger.succ(`Got: ${driveFile.id}`); + } catch (e) { + error = e; + logger.error(`Failed to create drive file: ${e}`, { + url: url, + e: e + }); + } + + // clean-up + cleanup(); + + if (error) { + throw error; + } else { + return driveFile!; + } +}; diff --git a/packages/backend/src/services/fetch-instance-metadata.ts b/packages/backend/src/services/fetch-instance-metadata.ts new file mode 100644 index 0000000000..2c401508a9 --- /dev/null +++ b/packages/backend/src/services/fetch-instance-metadata.ts @@ -0,0 +1,265 @@ +import { DOMWindow, JSDOM } from 'jsdom'; +import fetch from 'node-fetch'; +import { getJson, getHtml, getAgentByUrl } from '@/misc/fetch'; +import { Instance } from '@/models/entities/instance'; +import { Instances } from '@/models/index'; +import { getFetchInstanceMetadataLock } from '@/misc/app-lock'; +import Logger from './logger'; +import { URL } from 'url'; + +const logger = new Logger('metadata', 'cyan'); + +export async function fetchInstanceMetadata(instance: Instance, force = false): Promise<void> { + const unlock = await getFetchInstanceMetadataLock(instance.host); + + if (!force) { + const _instance = await Instances.findOne({ host: instance.host }); + const now = Date.now(); + if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { + unlock(); + return; + } + } + + logger.info(`Fetching metadata of ${instance.host} ...`); + + try { + const [info, dom, manifest] = await Promise.all([ + fetchNodeinfo(instance).catch(() => null), + fetchDom(instance).catch(() => null), + fetchManifest(instance).catch(() => null), + ]); + + const [favicon, icon, themeColor, name, description] = await Promise.all([ + fetchFaviconUrl(instance, dom).catch(() => null), + fetchIconUrl(instance, dom, manifest).catch(() => null), + getThemeColor(dom, manifest).catch(() => null), + getSiteName(info, dom, manifest).catch(() => null), + getDescription(info, dom, manifest).catch(() => null), + ]); + + logger.succ(`Successfuly fetched metadata of ${instance.host}`); + + const updates = { + infoUpdatedAt: new Date(), + } as Record<string, any>; + + if (info) { + updates.softwareName = info.software?.name.toLowerCase(); + updates.softwareVersion = info.software?.version; + updates.openRegistrations = info.openRegistrations; + updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name || null) : null : null; + updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email || null) : null : null; + } + + if (name) updates.name = name; + if (description) updates.description = description; + if (icon || favicon) updates.iconUrl = icon || favicon; + if (favicon) updates.faviconUrl = favicon; + if (themeColor) updates.themeColor = themeColor; + + await Instances.update(instance.id, updates); + + logger.succ(`Successfuly updated metadata of ${instance.host}`); + } catch (e) { + logger.error(`Failed to update metadata of ${instance.host}: ${e}`); + } finally { + unlock(); + } +} + +type NodeInfo = { + openRegistrations?: any; + software?: { + name?: any; + version?: any; + }; + metadata?: { + name?: any; + nodeName?: any; + nodeDescription?: any; + description?: any; + maintainer?: { + name?: any; + email?: any; + }; + }; +}; + +async function fetchNodeinfo(instance: Instance): Promise<NodeInfo> { + logger.info(`Fetching nodeinfo of ${instance.host} ...`); + + try { + const wellknown = await getJson('https://' + instance.host + '/.well-known/nodeinfo') + .catch(e => { + if (e.statusCode === 404) { + throw 'No nodeinfo provided'; + } else { + throw e.statusCode || e.message; + } + }); + + if (wellknown.links == null || !Array.isArray(wellknown.links)) { + throw 'No wellknown links'; + } + + const links = wellknown.links as any[]; + + const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); + const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); + const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1'); + const link = lnik2_1 || lnik2_0 || lnik1_0; + + if (link == null) { + throw 'No nodeinfo link provided'; + } + + const info = await getJson(link.href) + .catch(e => { + throw e.statusCode || e.message; + }); + + logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`); + + return info; + } catch (e) { + logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${e}`); + + throw e; + } +} + +async function fetchDom(instance: Instance): Promise<DOMWindow['document']> { + logger.info(`Fetching HTML of ${instance.host} ...`); + + const url = 'https://' + instance.host; + + const html = await getHtml(url); + + const { window } = new JSDOM(html); + const doc = window.document; + + return doc; +} + +async function fetchManifest(instance: Instance): Promise<Record<string, any> | null> { + const url = 'https://' + instance.host; + + const manifestUrl = url + '/manifest.json'; + + const manifest = await getJson(manifestUrl); + + return manifest; +} + +async function fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise<string | null> { + const url = 'https://' + instance.host; + + if (doc) { + const href = doc.querySelector('link[rel="icon"]')?.getAttribute('href'); + + if (href) { + return (new URL(href, url)).href; + } + } + + const faviconUrl = url + '/favicon.ico'; + + const favicon = await fetch(faviconUrl, { + timeout: 10000, + agent: getAgentByUrl, + }); + + if (favicon.ok) { + return faviconUrl; + } + + return null; +} + +async function fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { + const url = 'https://' + instance.host; + return (new URL(manifest.icons[0].src, url)).href; + } + + if (doc) { + const url = 'https://' + instance.host; + + const hrefAppleTouchIconPrecomposed = doc.querySelector('link[rel="apple-touch-icon-precomposed"]')?.getAttribute('href'); + const hrefAppleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href'); + const hrefIcon = doc.querySelector('link[rel="icon"]')?.getAttribute('href'); + + const href = hrefAppleTouchIconPrecomposed || hrefAppleTouchIcon || hrefIcon; + + if (href) { + return (new URL(href, url)).href; + } + } + + return null; +} + +async function getThemeColor(doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + if (doc) { + const themeColor = doc.querySelector('meta[name="theme-color"]')?.getAttribute('content'); + + if (themeColor) { + return themeColor; + } + } + + if (manifest) { + return manifest.theme_color; + } + + return null; +} + +async function getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + if (info && info.metadata) { + if (info.metadata.nodeName || info.metadata.name) { + return info.metadata.nodeName || info.metadata.name; + } + } + + if (doc) { + const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content'); + + if (og) { + return og; + } + } + + if (manifest) { + return manifest?.name || manifest?.short_name; + } + + return null; +} + +async function getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { + if (info && info.metadata) { + if (info.metadata.nodeDescription || info.metadata.description) { + return info.metadata.nodeDescription || info.metadata.description; + } + } + + if (doc) { + const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content'); + if (meta) { + return meta; + } + + const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content'); + if (og) { + return og; + } + } + + if (manifest) { + return manifest?.name || manifest?.short_name; + } + + return null; +} diff --git a/packages/backend/src/services/following/create.ts b/packages/backend/src/services/following/create.ts new file mode 100644 index 0000000000..4d0754b504 --- /dev/null +++ b/packages/backend/src/services/following/create.ts @@ -0,0 +1,180 @@ +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderFollow from '@/remote/activitypub/renderer/follow'; +import renderAccept from '@/remote/activitypub/renderer/accept'; +import renderReject from '@/remote/activitypub/renderer/reject'; +import { deliver } from '@/queue/index'; +import createFollowRequest from './requests/create'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; +import Logger from '../logger'; +import { IdentifiableError } from '@/misc/identifiable-error'; +import { User } from '@/models/entities/user'; +import { Followings, Users, FollowRequests, Blockings, Instances, UserProfiles } from '@/models/index'; +import { instanceChart, perUserFollowingChart } from '@/services/chart/index'; +import { genId } from '@/misc/gen-id'; +import { createNotification } from '../create-notification'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error'; + +const logger = new Logger('following/create'); + +export async function insertFollowingDoc(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }) { + if (follower.id === followee.id) return; + + let alreadyFollowed = false; + + await Followings.insert({ + id: genId(), + createdAt: new Date(), + followerId: follower.id, + followeeId: followee.id, + + // 非正規化 + followerHost: follower.host, + followerInbox: Users.isRemoteUser(follower) ? follower.inbox : null, + followerSharedInbox: Users.isRemoteUser(follower) ? follower.sharedInbox : null, + followeeHost: followee.host, + followeeInbox: Users.isRemoteUser(followee) ? followee.inbox : null, + followeeSharedInbox: Users.isRemoteUser(followee) ? followee.sharedInbox : null + }).catch(e => { + if (isDuplicateKeyValueError(e) && Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`); + alreadyFollowed = true; + } else { + throw e; + } + }); + + const req = await FollowRequests.findOne({ + followeeId: followee.id, + followerId: follower.id + }); + + if (req) { + await FollowRequests.delete({ + followeeId: followee.id, + followerId: follower.id + }); + + // 通知を作成 + createNotification(follower.id, 'followRequestAccepted', { + notifierId: followee.id, + }); + } + + if (alreadyFollowed) return; + + //#region Increment counts + Users.increment({ id: follower.id }, 'followingCount', 1); + Users.increment({ id: followee.id }, 'followersCount', 1); + //#endregion + + //#region Update instance stats + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + registerOrFetchInstanceDoc(follower.host).then(i => { + Instances.increment({ id: i.id }, 'followingCount', 1); + instanceChart.updateFollowing(i.host, true); + }); + } else if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + registerOrFetchInstanceDoc(followee.host).then(i => { + Instances.increment({ id: i.id }, 'followersCount', 1); + instanceChart.updateFollowers(i.host, true); + }); + } + //#endregion + + perUserFollowingChart.update(follower, followee, true); + + // Publish follow event + if (Users.isLocalUser(follower)) { + Users.pack(followee.id, follower, { + detail: true + }).then(packed => { + publishUserEvent(follower.id, 'follow', packed); + publishMainStream(follower.id, 'follow', packed); + }); + } + + // Publish followed event + if (Users.isLocalUser(followee)) { + Users.pack(follower.id, followee).then(packed => publishMainStream(followee.id, 'followed', packed)); + + // 通知を作成 + createNotification(followee.id, 'follow', { + notifierId: follower.id + }); + } +} + +export default async function(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string) { + const [follower, followee] = await Promise.all([ + Users.findOneOrFail(_follower.id), + Users.findOneOrFail(_followee.id) + ]); + + // check blocking + const [blocking, blocked] = await Promise.all([ + Blockings.findOne({ + blockerId: follower.id, + blockeeId: followee.id, + }), + Blockings.findOne({ + blockerId: followee.id, + blockeeId: follower.id, + }) + ]); + + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocked) { + // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 + const content = renderActivity(renderReject(renderFollow(follower, followee, requestId), followee)); + deliver(followee , content, follower.inbox); + return; + } else if (Users.isRemoteUser(follower) && Users.isLocalUser(followee) && blocking) { + // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 + await Blockings.delete(blocking.id); + } else { + // それ以外は単純に例外 + if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking'); + if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); + } + + const followeeProfile = await UserProfiles.findOneOrFail(followee.id); + + // フォロー対象が鍵アカウントである or + // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or + // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである + // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく + if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (Users.isLocalUser(follower) && Users.isRemoteUser(followee))) { + let autoAccept = false; + + // 鍵アカウントであっても、既にフォローされていた場合はスルー + const following = await Followings.findOne({ + followerId: follower.id, + followeeId: followee.id, + }); + if (following) { + autoAccept = true; + } + + // フォローしているユーザーは自動承認オプション + if (!autoAccept && (Users.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) { + const followed = await Followings.findOne({ + followerId: followee.id, + followeeId: follower.id + }); + + if (followed) autoAccept = true; + } + + if (!autoAccept) { + await createFollowRequest(follower, followee, requestId); + return; + } + } + + await insertFollowingDoc(followee, follower); + + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + const content = renderActivity(renderAccept(renderFollow(follower, followee, requestId), followee)); + deliver(followee, content, follower.inbox); + } +} diff --git a/packages/backend/src/services/following/delete.ts b/packages/backend/src/services/following/delete.ts new file mode 100644 index 0000000000..29e3372b6a --- /dev/null +++ b/packages/backend/src/services/following/delete.ts @@ -0,0 +1,69 @@ +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderFollow from '@/remote/activitypub/renderer/follow'; +import renderUndo from '@/remote/activitypub/renderer/undo'; +import { deliver } from '@/queue/index'; +import Logger from '../logger'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; +import { User } from '@/models/entities/user'; +import { Followings, Users, Instances } from '@/models/index'; +import { instanceChart, perUserFollowingChart } from '@/services/chart/index'; + +const logger = new Logger('following/delete'); + +export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, silent = false) { + const following = await Followings.findOne({ + followerId: follower.id, + followeeId: followee.id + }); + + if (following == null) { + logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); + return; + } + + await Followings.delete(following.id); + + decrementFollowing(follower, followee); + + // Publish unfollow event + if (!silent && Users.isLocalUser(follower)) { + Users.pack(followee.id, follower, { + detail: true + }).then(packed => { + publishUserEvent(follower.id, 'unfollow', packed); + publishMainStream(follower.id, 'unfollow', packed); + }); + } + + if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); + deliver(follower, content, followee.inbox); + } +} + +export async function decrementFollowing(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }) { + //#region Decrement following count + Users.decrement({ id: follower.id }, 'followingCount', 1); + //#endregion + + //#region Decrement followers count + Users.decrement({ id: followee.id }, 'followersCount', 1); + //#endregion + + //#region Update instance stats + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + registerOrFetchInstanceDoc(follower.host).then(i => { + Instances.decrement({ id: i.id }, 'followingCount', 1); + instanceChart.updateFollowing(i.host, false); + }); + } else if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + registerOrFetchInstanceDoc(followee.host).then(i => { + Instances.decrement({ id: i.id }, 'followersCount', 1); + instanceChart.updateFollowers(i.host, false); + }); + } + //#endregion + + perUserFollowingChart.update(follower, followee, false); +} diff --git a/packages/backend/src/services/following/requests/accept-all.ts b/packages/backend/src/services/following/requests/accept-all.ts new file mode 100644 index 0000000000..23b4fd0a46 --- /dev/null +++ b/packages/backend/src/services/following/requests/accept-all.ts @@ -0,0 +1,18 @@ +import accept from './accept'; +import { User } from '@/models/entities/user'; +import { FollowRequests, Users } from '@/models/index'; + +/** + * 指定したユーザー宛てのフォローリクエストをすべて承認 + * @param user ユーザー + */ +export default async function(user: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }) { + const requests = await FollowRequests.find({ + followeeId: user.id + }); + + for (const request of requests) { + const follower = await Users.findOneOrFail(request.followerId); + accept(user, follower); + } +} diff --git a/packages/backend/src/services/following/requests/accept.ts b/packages/backend/src/services/following/requests/accept.ts new file mode 100644 index 0000000000..316a6f1c12 --- /dev/null +++ b/packages/backend/src/services/following/requests/accept.ts @@ -0,0 +1,31 @@ +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderFollow from '@/remote/activitypub/renderer/follow'; +import renderAccept from '@/remote/activitypub/renderer/accept'; +import { deliver } from '@/queue/index'; +import { publishMainStream } from '@/services/stream'; +import { insertFollowingDoc } from '../create'; +import { User, ILocalUser } from '@/models/entities/user'; +import { FollowRequests, Users } from '@/models/index'; +import { IdentifiableError } from '@/misc/identifiable-error'; + +export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, follower: User) { + const request = await FollowRequests.findOne({ + followeeId: followee.id, + followerId: follower.id + }); + + if (request == null) { + throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); + } + + await insertFollowingDoc(followee, follower); + + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + const content = renderActivity(renderAccept(renderFollow(follower, followee, request.requestId!), followee)); + deliver(followee, content, follower.inbox); + } + + Users.pack(followee.id, followee, { + detail: true + }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); +} diff --git a/packages/backend/src/services/following/requests/cancel.ts b/packages/backend/src/services/following/requests/cancel.ts new file mode 100644 index 0000000000..8895849857 --- /dev/null +++ b/packages/backend/src/services/following/requests/cancel.ts @@ -0,0 +1,36 @@ +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderFollow from '@/remote/activitypub/renderer/follow'; +import renderUndo from '@/remote/activitypub/renderer/undo'; +import { deliver } from '@/queue/index'; +import { publishMainStream } from '@/services/stream'; +import { IdentifiableError } from '@/misc/identifiable-error'; +import { User, ILocalUser } from '@/models/entities/user'; +import { Users, FollowRequests } from '@/models/index'; + +export default async function(followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox'] }, follower: { id: User['id']; host: User['host']; uri: User['host'] }) { + if (Users.isRemoteUser(followee)) { + const content = renderActivity(renderUndo(renderFollow(follower, followee), follower)); + + if (Users.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので + deliver(follower, content, followee.inbox); + } + } + + const request = await FollowRequests.findOne({ + followeeId: followee.id, + followerId: follower.id + }); + + if (request == null) { + throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); + } + + await FollowRequests.delete({ + followeeId: followee.id, + followerId: follower.id + }); + + Users.pack(followee.id, followee, { + detail: true + }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); +} diff --git a/packages/backend/src/services/following/requests/create.ts b/packages/backend/src/services/following/requests/create.ts new file mode 100644 index 0000000000..507cb2b7d1 --- /dev/null +++ b/packages/backend/src/services/following/requests/create.ts @@ -0,0 +1,63 @@ +import { publishMainStream } from '@/services/stream'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderFollow from '@/remote/activitypub/renderer/follow'; +import { deliver } from '@/queue/index'; +import { User } from '@/models/entities/user'; +import { Blockings, FollowRequests, Users } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { createNotification } from '../../create-notification'; + +export default async function(follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, followee: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']; }, requestId?: string) { + if (follower.id === followee.id) return; + + // check blocking + const [blocking, blocked] = await Promise.all([ + Blockings.findOne({ + blockerId: follower.id, + blockeeId: followee.id, + }), + Blockings.findOne({ + blockerId: followee.id, + blockeeId: follower.id, + }) + ]); + + if (blocking != null) throw new Error('blocking'); + if (blocked != null) throw new Error('blocked'); + + const followRequest = await FollowRequests.save({ + id: genId(), + createdAt: new Date(), + followerId: follower.id, + followeeId: followee.id, + requestId, + + // 非正規化 + followerHost: follower.host, + followerInbox: Users.isRemoteUser(follower) ? follower.inbox : undefined, + followerSharedInbox: Users.isRemoteUser(follower) ? follower.sharedInbox : undefined, + followeeHost: followee.host, + followeeInbox: Users.isRemoteUser(followee) ? followee.inbox : undefined, + followeeSharedInbox: Users.isRemoteUser(followee) ? followee.sharedInbox : undefined + }); + + // Publish receiveRequest event + if (Users.isLocalUser(followee)) { + Users.pack(follower.id, followee).then(packed => publishMainStream(followee.id, 'receiveFollowRequest', packed)); + + Users.pack(followee.id, followee, { + detail: true + }).then(packed => publishMainStream(followee.id, 'meUpdated', packed)); + + // 通知を作成 + createNotification(followee.id, 'receiveFollowRequest', { + notifierId: follower.id, + followRequestId: followRequest.id + }); + } + + if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) { + const content = renderActivity(renderFollow(follower, followee)); + deliver(follower, content, followee.inbox); + } +} diff --git a/packages/backend/src/services/following/requests/reject.ts b/packages/backend/src/services/following/requests/reject.ts new file mode 100644 index 0000000000..41cebd9e41 --- /dev/null +++ b/packages/backend/src/services/following/requests/reject.ts @@ -0,0 +1,46 @@ +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderFollow from '@/remote/activitypub/renderer/follow'; +import renderReject from '@/remote/activitypub/renderer/reject'; +import { deliver } from '@/queue/index'; +import { publishMainStream, publishUserEvent } from '@/services/stream'; +import { User, ILocalUser } from '@/models/entities/user'; +import { Users, FollowRequests, Followings } from '@/models/index'; +import { decrementFollowing } from '../delete'; + +export default async function(followee: { id: User['id']; host: User['host']; uri: User['host'] }, follower: User) { + if (Users.isRemoteUser(follower) && Users.isLocalUser(followee)) { + const request = await FollowRequests.findOne({ + followeeId: followee.id, + followerId: follower.id + }); + + const content = renderActivity(renderReject(renderFollow(follower, followee, request!.requestId!), followee)); + deliver(followee, content, follower.inbox); + } + + const request = await FollowRequests.findOne({ + followeeId: followee.id, + followerId: follower.id + }); + + if (request) { + await FollowRequests.delete(request.id); + } else { + const following = await Followings.findOne({ + followeeId: followee.id, + followerId: follower.id + }); + + if (following) { + await Followings.delete(following.id); + decrementFollowing(follower, followee); + } + } + + Users.pack(followee.id, follower, { + detail: true + }).then(packed => { + publishUserEvent(follower.id, 'unfollow', packed); + publishMainStream(follower.id, 'unfollow', packed); + }); +} diff --git a/packages/backend/src/services/i/pin.ts b/packages/backend/src/services/i/pin.ts new file mode 100644 index 0000000000..b31beb6e1a --- /dev/null +++ b/packages/backend/src/services/i/pin.ts @@ -0,0 +1,92 @@ +import config from '@/config/index'; +import renderAdd from '@/remote/activitypub/renderer/add'; +import renderRemove from '@/remote/activitypub/renderer/remove'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import { IdentifiableError } from '@/misc/identifiable-error'; +import { User } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { Notes, UserNotePinings, Users } from '@/models/index'; +import { UserNotePining } from '@/models/entities/user-note-pining'; +import { genId } from '@/misc/gen-id'; +import { deliverToFollowers } from '@/remote/activitypub/deliver-manager'; +import { deliverToRelays } from '../relay'; + +/** + * 指定した投稿をピン留めします + * @param user + * @param noteId + */ +export async function addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { + // Fetch pinee + const note = await Notes.findOne({ + id: noteId, + userId: user.id + }); + + if (note == null) { + throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.'); + } + + const pinings = await UserNotePinings.find({ userId: user.id }); + + if (pinings.length >= 5) { + throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.'); + } + + if (pinings.some(pining => pining.noteId === note.id)) { + throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.'); + } + + await UserNotePinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + noteId: note.id + } as UserNotePining); + + // Deliver to remote followers + if (Users.isLocalUser(user)) { + deliverPinnedChange(user.id, note.id, true); + } +} + +/** + * 指定した投稿のピン留めを解除します + * @param user + * @param noteId + */ +export async function removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) { + // Fetch unpinee + const note = await Notes.findOne({ + id: noteId, + userId: user.id + }); + + if (note == null) { + throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.'); + } + + UserNotePinings.delete({ + userId: user.id, + noteId: note.id + }); + + // Deliver to remote followers + if (Users.isLocalUser(user)) { + deliverPinnedChange(user.id, noteId, false); + } +} + +export async function deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) { + const user = await Users.findOne(userId); + if (user == null) throw new Error('user not found'); + + if (!Users.isLocalUser(user)) return; + + const target = `${config.url}/users/${user.id}/collections/featured`; + const item = `${config.url}/notes/${noteId}`; + const content = renderActivity(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item)); + + deliverToFollowers(user, content); + deliverToRelays(user, content); +} diff --git a/packages/backend/src/services/i/update.ts b/packages/backend/src/services/i/update.ts new file mode 100644 index 0000000000..f700d9b48b --- /dev/null +++ b/packages/backend/src/services/i/update.ts @@ -0,0 +1,19 @@ +import renderUpdate from '@/remote/activitypub/renderer/update'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import { Users } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { renderPerson } from '@/remote/activitypub/renderer/person'; +import { deliverToFollowers } from '@/remote/activitypub/deliver-manager'; +import { deliverToRelays } from '../relay'; + +export async function publishToFollowers(userId: User['id']) { + const user = await Users.findOne(userId); + if (user == null) throw new Error('user not found'); + + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 + if (Users.isLocalUser(user)) { + const content = renderActivity(renderUpdate(await renderPerson(user), user)); + deliverToFollowers(user, content); + deliverToRelays(user, content); + } +} diff --git a/packages/backend/src/services/insert-moderation-log.ts b/packages/backend/src/services/insert-moderation-log.ts new file mode 100644 index 0000000000..00397652ee --- /dev/null +++ b/packages/backend/src/services/insert-moderation-log.ts @@ -0,0 +1,13 @@ +import { ModerationLogs } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { User } from '@/models/entities/user'; + +export async function insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record<string, any>) { + await ModerationLogs.insert({ + id: genId(), + createdAt: new Date(), + userId: moderator.id, + type: type, + info: info || {} + }); +} diff --git a/packages/backend/src/services/instance-actor.ts b/packages/backend/src/services/instance-actor.ts new file mode 100644 index 0000000000..b3625226c3 --- /dev/null +++ b/packages/backend/src/services/instance-actor.ts @@ -0,0 +1,27 @@ +import { createSystemUser } from './create-system-user'; +import { ILocalUser } from '@/models/entities/user'; +import { Users } from '@/models/index'; +import { Cache } from '@/misc/cache'; + +const ACTOR_USERNAME = 'instance.actor' as const; + +const cache = new Cache<ILocalUser>(Infinity); + +export async function getInstanceActor(): Promise<ILocalUser> { + const cached = cache.get(null); + if (cached) return cached; + + const user = await Users.findOne({ + host: null, + username: ACTOR_USERNAME + }) as ILocalUser | undefined; + + if (user) { + cache.set(null, user); + return user; + } else { + const created = await createSystemUser(ACTOR_USERNAME) as ILocalUser; + cache.set(null, created); + return created; + } +} diff --git a/packages/backend/src/services/logger.ts b/packages/backend/src/services/logger.ts new file mode 100644 index 0000000000..709b9bae5c --- /dev/null +++ b/packages/backend/src/services/logger.ts @@ -0,0 +1,127 @@ +import * as cluster from 'cluster'; +import * as chalk from 'chalk'; +import * as dateformat from 'dateformat'; +import { envOption } from '../env'; +import config from '@/config/index'; + +import * as SyslogPro from 'syslog-pro'; + +type Domain = { + name: string; + color?: string; +}; + +type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; + +export default class Logger { + private domain: Domain; + private parentLogger: Logger | null = null; + private store: boolean; + private syslogClient: any | null = null; + + constructor(domain: string, color?: string, store = true) { + this.domain = { + name: domain, + color: color, + }; + this.store = store; + + if (config.syslog) { + this.syslogClient = new SyslogPro.RFC5424({ + applacationName: 'Misskey', + timestamp: true, + encludeStructuredData: true, + color: true, + extendedColor: true, + server: { + target: config.syslog.host, + port: config.syslog.port, + } + }); + } + } + + public createSubLogger(domain: string, color?: string, store = true): Logger { + const logger = new Logger(domain, color, store); + logger.parentLogger = this; + return logger; + } + + private log(level: Level, message: string, data?: Record<string, any> | null, important = false, subDomains: Domain[] = [], store = true): void { + if (envOption.quiet) return; + if (!this.store) store = false; + if (level === 'debug') store = false; + + if (this.parentLogger) { + this.parentLogger.log(level, message, data, important, [this.domain].concat(subDomains), store); + return; + } + + const time = dateformat(new Date(), 'HH:MM:ss'); + const worker = cluster.isPrimary ? '*' : cluster.worker.id; + const l = + level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') : + level === 'warning' ? chalk.yellow('WARN') : + level === 'success' ? important ? chalk.bgGreen.white('DONE') : chalk.green('DONE') : + level === 'debug' ? chalk.gray('VERB') : + level === 'info' ? chalk.blue('INFO') : + null; + const domains = [this.domain].concat(subDomains).map(d => d.color ? chalk.keyword(d.color)(d.name) : chalk.white(d.name)); + const m = + level === 'error' ? chalk.red(message) : + level === 'warning' ? chalk.yellow(message) : + level === 'success' ? chalk.green(message) : + level === 'debug' ? chalk.gray(message) : + level === 'info' ? message : + null; + + let log = `${l} ${worker}\t[${domains.join(' ')}]\t${m}`; + if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log; + + console.log(important ? chalk.bold(log) : log); + + if (store) { + if (this.syslogClient) { + const send = + level === 'error' ? this.syslogClient.error : + level === 'warning' ? this.syslogClient.warning : + level === 'success' ? this.syslogClient.info : + level === 'debug' ? this.syslogClient.info : + level === 'info' ? this.syslogClient.info : + null as never; + + send.bind(this.syslogClient)(message).catch(() => {}); + } + } + } + + public error(x: string | Error, data?: Record<string, any> | null, important = false): void { // 実行を継続できない状況で使う + if (x instanceof Error) { + data = data || {}; + data.e = x; + this.log('error', x.toString(), data, important); + } else if (typeof x === 'object') { + this.log('error', `${(x as any).message || (x as any).name || x}`, data, important); + } else { + this.log('error', `${x}`, data, important); + } + } + + public warn(message: string, data?: Record<string, any> | null, important = false): void { // 実行を継続できるが改善すべき状況で使う + this.log('warning', message, data, important); + } + + public succ(message: string, data?: Record<string, any> | null, important = false): void { // 何かに成功した状況で使う + this.log('success', message, data, important); + } + + public debug(message: string, data?: Record<string, any> | null, important = false): void { // デバッグ用に使う(開発者に必要だが利用者に不要な情報) + if (process.env.NODE_ENV != 'production' || envOption.verbose) { + this.log('debug', message, data, important); + } + } + + public info(message: string, data?: Record<string, any> | null, important = false): void { // それ以外 + this.log('info', message, data, important); + } +} diff --git a/packages/backend/src/services/messages/create.ts b/packages/backend/src/services/messages/create.ts new file mode 100644 index 0000000000..948b6726b9 --- /dev/null +++ b/packages/backend/src/services/messages/create.ts @@ -0,0 +1,108 @@ +import { User } from '@/models/entities/user'; +import { UserGroup } from '@/models/entities/user-group'; +import { DriveFile } from '@/models/entities/drive-file'; +import { MessagingMessages, UserGroupJoinings, Mutings, Users } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { publishMessagingStream, publishMessagingIndexStream, publishMainStream, publishGroupMessagingStream } from '@/services/stream'; +import pushNotification from '../push-notification'; +import { Not } from 'typeorm'; +import { Note } from '@/models/entities/note'; +import renderNote from '@/remote/activitypub/renderer/note'; +import renderCreate from '@/remote/activitypub/renderer/create'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import { deliver } from '@/queue/index'; + +export async function createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: User | undefined, recipientGroup: UserGroup | undefined, text: string | undefined, file: DriveFile | null, uri?: string) { + const message = { + id: genId(), + createdAt: new Date(), + fileId: file ? file.id : null, + recipientId: recipientUser ? recipientUser.id : null, + groupId: recipientGroup ? recipientGroup.id : null, + text: text ? text.trim() : null, + userId: user.id, + isRead: false, + reads: [] as any[], + uri + } as MessagingMessage; + + await MessagingMessages.insert(message); + + const messageObj = await MessagingMessages.pack(message); + + if (recipientUser) { + if (Users.isLocalUser(user)) { + // 自分のストリーム + publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj); + publishMessagingIndexStream(message.userId, 'message', messageObj); + publishMainStream(message.userId, 'messagingMessage', messageObj); + } + + if (Users.isLocalUser(recipientUser)) { + // 相手のストリーム + publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj); + publishMessagingIndexStream(recipientUser.id, 'message', messageObj); + publishMainStream(recipientUser.id, 'messagingMessage', messageObj); + } + } else if (recipientGroup) { + // グループのストリーム + publishGroupMessagingStream(recipientGroup.id, 'message', messageObj); + + // メンバーのストリーム + const joinings = await UserGroupJoinings.find({ userGroupId: recipientGroup.id }); + for (const joining of joinings) { + publishMessagingIndexStream(joining.userId, 'message', messageObj); + publishMainStream(joining.userId, 'messagingMessage', messageObj); + } + } + + // 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await MessagingMessages.findOne(message.id); + if (freshMessage == null) return; // メッセージが削除されている場合もある + + if (recipientUser && Users.isLocalUser(recipientUser)) { + if (freshMessage.isRead) return; // 既読 + + //#region ただしミュートされているなら発行しない + const mute = await Mutings.find({ + muterId: recipientUser.id, + }); + if (mute.map(m => m.muteeId).includes(user.id)) return; + //#endregion + + publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj); + pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj); + } else if (recipientGroup) { + const joinings = await UserGroupJoinings.find({ userGroupId: recipientGroup.id, userId: Not(user.id) }); + for (const joining of joinings) { + if (freshMessage.reads.includes(joining.userId)) return; // 既読 + publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj); + pushNotification(joining.userId, 'unreadMessagingMessage', messageObj); + } + } + }, 2000); + + if (recipientUser && Users.isLocalUser(user) && Users.isRemoteUser(recipientUser)) { + const note = { + id: message.id, + createdAt: message.createdAt, + fileIds: message.fileId ? [ message.fileId ] : [], + text: message.text, + userId: message.userId, + visibility: 'specified', + mentions: [ recipientUser ].map(u => u.id), + mentionedRemoteUsers: JSON.stringify([ recipientUser ].map(u => ({ + uri: u.uri, + username: u.username, + host: u.host + }))), + } as Note; + + const activity = renderActivity(renderCreate(await renderNote(note, false, true), note)); + + deliver(user, activity, recipientUser.inbox); + } + return messageObj; +} diff --git a/packages/backend/src/services/messages/delete.ts b/packages/backend/src/services/messages/delete.ts new file mode 100644 index 0000000000..5c299c9a50 --- /dev/null +++ b/packages/backend/src/services/messages/delete.ts @@ -0,0 +1,30 @@ +import config from '@/config/index'; +import { MessagingMessages, Users } from '@/models/index'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { publishGroupMessagingStream, publishMessagingStream } from '@/services/stream'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderDelete from '@/remote/activitypub/renderer/delete'; +import renderTombstone from '@/remote/activitypub/renderer/tombstone'; +import { deliver } from '@/queue/index'; + +export async function deleteMessage(message: MessagingMessage) { + await MessagingMessages.delete(message.id); + postDeleteMessage(message); +} + +async function postDeleteMessage(message: MessagingMessage) { + if (message.recipientId) { + const user = await Users.findOneOrFail(message.userId); + const recipient = await Users.findOneOrFail(message.recipientId); + + if (Users.isLocalUser(user)) publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id); + if (Users.isLocalUser(recipient)) publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id); + + if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) { + const activity = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${message.id}`), user)); + deliver(user, activity, recipient.inbox); + } + } else if (message.groupId) { + publishGroupMessagingStream(message.groupId, 'deleted', message.id); + } +} diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts new file mode 100644 index 0000000000..69d854ab1a --- /dev/null +++ b/packages/backend/src/services/note/create.ts @@ -0,0 +1,645 @@ +import * as mfm from 'mfm-js'; +import es from '../../db/elasticsearch'; +import { publishMainStream, publishNotesStream } from '@/services/stream'; +import DeliverManager from '@/remote/activitypub/deliver-manager'; +import renderNote from '@/remote/activitypub/renderer/note'; +import renderCreate from '@/remote/activitypub/renderer/create'; +import renderAnnounce from '@/remote/activitypub/renderer/announce'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import { resolveUser } from '@/remote/resolve-user'; +import config from '@/config/index'; +import { updateHashtags } from '../update-hashtag'; +import { concat } from '@/prelude/array'; +import { insertNoteUnread } from '@/services/note/unread'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; +import { extractMentions } from '@/misc/extract-mentions'; +import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm'; +import { extractHashtags } from '@/misc/extract-hashtags'; +import { Note, IMentionedRemoteUsers } from '@/models/entities/note'; +import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes, Channels, ChannelFollowings, Blockings, NoteThreadMutings } from '@/models/index'; +import { DriveFile } from '@/models/entities/drive-file'; +import { App } from '@/models/entities/app'; +import { Not, getConnection, In } from 'typeorm'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user'; +import { genId } from '@/misc/gen-id'; +import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from '@/services/chart/index'; +import { Poll, IPoll } from '@/models/entities/poll'; +import { createNotification } from '../create-notification'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error'; +import { checkHitAntenna } from '@/misc/check-hit-antenna'; +import { checkWordMute } from '@/misc/check-word-mute'; +import { addNoteToAntenna } from '../add-note-to-antenna'; +import { countSameRenotes } from '@/misc/count-same-renotes'; +import { deliverToRelays } from '../relay'; +import { Channel } from '@/models/entities/channel'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; +import { getAntennas } from '@/misc/antenna-cache'; + +type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; + +class NotificationManager { + private notifier: { id: User['id']; }; + private note: Note; + private queue: { + target: ILocalUser['id']; + reason: NotificationType; + }[]; + + constructor(notifier: { id: User['id']; }, note: Note) { + this.notifier = notifier; + this.note = note; + this.queue = []; + } + + public push(notifiee: ILocalUser['id'], reason: NotificationType) { + // 自分自身へは通知しない + if (this.notifier.id === notifiee) return; + + const exist = this.queue.find(x => x.target === notifiee); + + if (exist) { + // 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする + if (reason != 'mention') { + exist.reason = reason; + } + } else { + this.queue.push({ + reason: reason, + target: notifiee + }); + } + } + + public async deliver() { + for (const x of this.queue) { + // ミュート情報を取得 + const mentioneeMutes = await Mutings.find({ + muterId: x.target + }); + + const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); + + // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する + if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + createNotification(x.target, x.reason, { + notifierId: this.notifier.id, + noteId: this.note.id + }); + } + } + } +} + +type Option = { + createdAt?: Date | null; + name?: string | null; + text?: string | null; + reply?: Note | null; + renote?: Note | null; + files?: DriveFile[] | null; + poll?: IPoll | null; + viaMobile?: boolean | null; + localOnly?: boolean | null; + cw?: string | null; + visibility?: string; + visibleUsers?: User[] | null; + channel?: Channel | null; + apMentions?: User[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + uri?: string | null; + url?: string | null; + app?: App | null; +}; + +export default async (user: { id: User['id']; username: User['username']; host: User['host']; isSilenced: User['isSilenced']; }, data: Option, silent = false) => new Promise<Note>(async (res, rej) => { + // チャンネル外にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && data.channel && data.reply.channelId !== data.channel.id) { + if (data.reply.channelId) { + data.channel = await Channels.findOne(data.reply.channelId); + } else { + data.channel = null; + } + } + + // チャンネル内にリプライしたら対象のスコープに合わせる + // (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで) + if (data.reply && (data.channel == null) && data.reply.channelId) { + data.channel = await Channels.findOne(data.reply.channelId); + } + + if (data.createdAt == null) data.createdAt = new Date(); + if (data.visibility == null) data.visibility = 'public'; + if (data.viaMobile == null) data.viaMobile = false; + if (data.localOnly == null) data.localOnly = false; + if (data.channel != null) data.visibility = 'public'; + if (data.channel != null) data.visibleUsers = []; + if (data.channel != null) data.localOnly = true; + + // サイレンス + if (user.isSilenced && data.visibility === 'public' && data.channel == null) { + data.visibility = 'home'; + } + + // Renote対象が「ホームまたは全体」以外の公開範囲ならreject + if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) { + return rej('Renote target is not public or home'); + } + + // Renote対象がpublicではないならhomeにする + if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // Renote対象がfollowersならfollowersにする + if (data.renote && data.renote.visibility === 'followers') { + data.visibility = 'followers'; + } + + // 返信対象がpublicではないならhomeにする + if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') { + data.visibility = 'home'; + } + + // ローカルのみをRenoteしたらローカルのみにする + if (data.renote && data.renote.localOnly && data.channel == null) { + data.localOnly = true; + } + + // ローカルのみにリプライしたらローカルのみにする + if (data.reply && data.reply.localOnly && data.channel == null) { + data.localOnly = true; + } + + if (data.text) { + data.text = data.text.trim(); + } + + let tags = data.apHashtags; + let emojis = data.apEmojis; + let mentionedUsers = data.apMentions; + + // Parse MFM if needed + if (!tags || !emojis || !mentionedUsers) { + const tokens = data.text ? mfm.parse(data.text)! : []; + const cwTokens = data.cw ? mfm.parse(data.cw)! : []; + const choiceTokens = data.poll && data.poll.choices + ? concat(data.poll.choices.map(choice => mfm.parse(choice)!)) + : []; + + const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens); + + tags = data.apHashtags || extractHashtags(combinedTokens); + + emojis = data.apEmojis || extractCustomEmojisFromMfm(combinedTokens); + + mentionedUsers = data.apMentions || await extractMentionedUsers(user, combinedTokens); + } + + tags = tags.filter(tag => Array.from(tag || '').length <= 128).splice(0, 32); + + if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { + mentionedUsers.push(await Users.findOneOrFail(data.reply.userId)); + } + + if (data.visibility == 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + if (!mentionedUsers.some(x => x.id === u.id)) { + mentionedUsers.push(u); + } + } + + if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { + data.visibleUsers.push(await Users.findOneOrFail(data.reply.userId)); + } + } + + const note = await insertNote(user, data, tags, emojis, mentionedUsers); + + res(note); + + // 統計を更新 + notesChart.update(note, true); + perUserNotesChart.update(user, note, true); + + // Register host + if (Users.isRemoteUser(user)) { + registerOrFetchInstanceDoc(user.host).then(i => { + Instances.increment({ id: i.id }, 'notesCount', 1); + instanceChart.updateNote(i.host, note, true); + }); + } + + // ハッシュタグ更新 + if (data.visibility === 'public' || data.visibility === 'home') { + updateHashtags(user, tags); + } + + // Increment notes count (user) + incNotesCountOfUser(user); + + // Word mute + // TODO: cache + UserProfiles.find({ + enableWordMute: true + }).then(us => { + for (const u of us) { + checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { + if (shouldMute) { + MutedNotes.insert({ + id: genId(), + userId: u.userId, + noteId: note.id, + reason: 'word', + }); + } + }); + } + }); + + // Antenna + Followings.createQueryBuilder('following') + .andWhere(`following.followeeId = :userId`, { userId: note.userId }) + .getMany() + .then(async followings => { + const blockings = await Blockings.find({ blockerId: user.id }); // TODO: キャッシュしたい + const followers = followings.map(f => f.followerId); + for (const antenna of (await getAntennas())) { + if (blockings.some(blocking => blocking.blockeeId === antenna.userId)) continue; // この処理は checkHitAntenna 内でやるようにしてもいいかも + checkHitAntenna(antenna, note, user, followers).then(hit => { + if (hit) { + addNoteToAntenna(antenna, note, user); + } + }); + } + }); + + // Channel + if (note.channelId) { + ChannelFollowings.find({ followeeId: note.channelId }).then(followings => { + for (const following of followings) { + insertNoteUnread(following.followerId, note, { + isSpecified: false, + isMentioned: false, + }); + } + }); + } + + if (data.reply) { + saveReply(data.reply, note); + } + + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき + if (data.renote && (await countSameRenotes(user.id, data.renote.id, note.id) === 0)) { + incRenoteCount(data.renote); + } + + if (!silent) { + // ローカルユーザーのチャートはタイムライン取得時に更新しているのでリモートユーザーの場合だけでよい + if (Users.isRemoteUser(user)) activeUsersChart.update(user); + + // 未読通知を作成 + if (data.visibility == 'specified') { + if (data.visibleUsers == null) throw new Error('invalid param'); + + for (const u of data.visibleUsers) { + // ローカルユーザーのみ + if (!Users.isLocalUser(u)) continue; + + insertNoteUnread(u.id, note, { + isSpecified: true, + isMentioned: false, + }); + } + } else { + for (const u of mentionedUsers) { + // ローカルユーザーのみ + if (!Users.isLocalUser(u)) continue; + + insertNoteUnread(u.id, note, { + isSpecified: false, + isMentioned: true, + }); + } + } + + // Pack the note + const noteObj = await Notes.pack(note); + + publishNotesStream(noteObj); + + const nm = new NotificationManager(user, note); + const nmRelatedPromises = []; + + await createMentionedEvents(mentionedUsers, note, nm); + + // If has in reply to note + if (data.reply) { + // Fetch watchers + nmRelatedPromises.push(notifyToWatchersOfReplyee(data.reply, user, nm)); + + // 通知 + if (data.reply.userHost === null) { + const threadMuted = await NoteThreadMutings.findOne({ + userId: data.reply.userId, + threadId: data.reply.threadId || data.reply.id, + }); + + if (!threadMuted) { + nm.push(data.reply.userId, 'reply'); + publishMainStream(data.reply.userId, 'reply', noteObj); + } + } + } + + // If it is renote + if (data.renote) { + const type = data.text ? 'quote' : 'renote'; + + // Notify + if (data.renote.userHost === null) { + nm.push(data.renote.userId, type); + } + + // Fetch watchers + nmRelatedPromises.push(notifyToWatchersOfRenotee(data.renote, user, nm, type)); + + // Publish event + if ((user.id !== data.renote.userId) && data.renote.userHost === null) { + publishMainStream(data.renote.userId, 'renote', noteObj); + } + } + + Promise.all(nmRelatedPromises).then(() => { + nm.deliver(); + }); + + //#region AP deliver + if (Users.isLocalUser(user)) { + (async () => { + const noteActivity = await renderNoteOrRenoteActivity(data, note); + const dm = new DeliverManager(user, noteActivity); + + // メンションされたリモートユーザーに配送 + for (const u of mentionedUsers.filter(u => Users.isRemoteUser(u))) { + dm.addDirectRecipe(u as IRemoteUser); + } + + // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 + if (data.reply && data.reply.userHost !== null) { + const u = await Users.findOne(data.reply.userId); + if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + if (data.renote && data.renote.userHost !== null) { + const u = await Users.findOne(data.renote.userId); + if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u); + } + + // フォロワーに配送 + if (['public', 'home', 'followers'].includes(note.visibility)) { + dm.addFollowersRecipe(); + } + + if (['public'].includes(note.visibility)) { + deliverToRelays(user, noteActivity); + } + + dm.execute(); + })(); + } + //#endregion + } + + if (data.channel) { + Channels.increment({ id: data.channel.id }, 'notesCount', 1); + Channels.update(data.channel.id, { + lastNotedAt: new Date(), + }); + + Notes.count({ + userId: user.id, + channelId: data.channel.id, + }).then(count => { + // この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる + // TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい + if (count === 1) { + Channels.increment({ id: data.channel!.id }, 'usersCount', 1); + } + }); + } + + // Register to search database + index(note); +}); + +async function renderNoteOrRenoteActivity(data: Option, note: Note) { + if (data.localOnly) return null; + + const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length == 0) + ? renderAnnounce(data.renote.uri ? data.renote.uri : `${config.url}/notes/${data.renote.id}`, note) + : renderCreate(await renderNote(note, false), note); + + return renderActivity(content); +} + +function incRenoteCount(renote: Note) { + Notes.createQueryBuilder().update() + .set({ + renoteCount: () => '"renoteCount" + 1', + score: () => '"score" + 1' + }) + .where('id = :id', { id: renote.id }) + .execute(); +} + +async function insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: User[]) { + const insert = new Note({ + id: genId(data.createdAt!), + createdAt: data.createdAt!, + fileIds: data.files ? data.files.map(file => file.id) : [], + replyId: data.reply ? data.reply.id : null, + renoteId: data.renote ? data.renote.id : null, + channelId: data.channel ? data.channel.id : null, + threadId: data.reply + ? data.reply.threadId + ? data.reply.threadId + : data.reply.id + : null, + name: data.name, + text: data.text, + hasPoll: data.poll != null, + cw: data.cw == null ? null : data.cw, + tags: tags.map(tag => normalizeForSearch(tag)), + emojis, + userId: user.id, + viaMobile: data.viaMobile!, + localOnly: data.localOnly!, + visibility: data.visibility as any, + visibleUserIds: data.visibility == 'specified' + ? data.visibleUsers + ? data.visibleUsers.map(u => u.id) + : [] + : [], + + attachedFileTypes: data.files ? data.files.map(file => file.type) : [], + + // 以下非正規化データ + replyUserId: data.reply ? data.reply.userId : null, + replyUserHost: data.reply ? data.reply.userHost : null, + renoteUserId: data.renote ? data.renote.userId : null, + renoteUserHost: data.renote ? data.renote.userHost : null, + userHost: user.host, + }); + + if (data.uri != null) insert.uri = data.uri; + if (data.url != null) insert.url = data.url; + + // Append mentions data + if (mentionedUsers.length > 0) { + insert.mentions = mentionedUsers.map(u => u.id); + const profiles = await UserProfiles.find({ userId: In(insert.mentions) }); + insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => Users.isRemoteUser(u)).map(u => { + const profile = profiles.find(p => p.userId == u.id); + const url = profile != null ? profile.url : null; + return { + uri: u.uri, + url: url == null ? undefined : url, + username: u.username, + host: u.host + } as IMentionedRemoteUsers[0]; + })); + } + + // 投稿を作成 + try { + if (insert.hasPoll) { + // Start transaction + await getConnection().transaction(async transactionalEntityManager => { + await transactionalEntityManager.insert(Note, insert); + + const poll = new Poll({ + noteId: insert.id, + choices: data.poll!.choices, + expiresAt: data.poll!.expiresAt, + multiple: data.poll!.multiple, + votes: new Array(data.poll!.choices.length).fill(0), + noteVisibility: insert.visibility, + userId: user.id, + userHost: user.host + }); + + await transactionalEntityManager.insert(Poll, poll); + }); + } else { + await Notes.insert(insert); + } + + return insert; + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + const err = new Error('Duplicated note'); + err.name = 'duplicated'; + throw err; + } + + console.error(e); + + throw e; + } +} + +function index(note: Note) { + if (note.text == null || config.elasticsearch == null) return; + + es!.index({ + index: config.elasticsearch.index || 'misskey_note', + id: note.id.toString(), + body: { + text: normalizeForSearch(note.text), + userId: note.userId, + userHost: note.userHost + } + }); +} + +async function notifyToWatchersOfRenotee(renote: Note, user: { id: User['id']; }, nm: NotificationManager, type: NotificationType) { + const watchers = await NoteWatchings.find({ + noteId: renote.id, + userId: Not(user.id) + }); + + for (const watcher of watchers) { + nm.push(watcher.userId, type); + } +} + +async function notifyToWatchersOfReplyee(reply: Note, user: { id: User['id']; }, nm: NotificationManager) { + const watchers = await NoteWatchings.find({ + noteId: reply.id, + userId: Not(user.id) + }); + + for (const watcher of watchers) { + nm.push(watcher.userId, 'reply'); + } +} + +async function createMentionedEvents(mentionedUsers: User[], note: Note, nm: NotificationManager) { + for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) { + const threadMuted = await NoteThreadMutings.findOne({ + userId: u.id, + threadId: note.threadId || note.id, + }); + + if (threadMuted) { + continue; + } + + const detailPackedNote = await Notes.pack(note, u, { + detail: true + }); + + publishMainStream(u.id, 'mention', detailPackedNote); + + // Create notification + nm.push(u.id, 'mention'); + } +} + +function saveReply(reply: Note, note: Note) { + Notes.increment({ id: reply.id }, 'repliesCount', 1); +} + +function incNotesCountOfUser(user: { id: User['id']; }) { + Users.createQueryBuilder().update() + .set({ + updatedAt: new Date(), + notesCount: () => '"notesCount" + 1' + }) + .where('id = :id', { id: user.id }) + .execute(); +} + +async function extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> { + if (tokens == null) return []; + + const mentions = extractMentions(tokens); + + let mentionedUsers = (await Promise.all(mentions.map(m => + resolveUser(m.username, m.host || user.host).catch(() => null) + ))).filter(x => x != null) as User[]; + + // Drop duplicate users + mentionedUsers = mentionedUsers.filter((u, i, self) => + i === self.findIndex(u2 => u.id === u2.id) + ); + + return mentionedUsers; +} diff --git a/packages/backend/src/services/note/delete.ts b/packages/backend/src/services/note/delete.ts new file mode 100644 index 0000000000..a14d84e7b2 --- /dev/null +++ b/packages/backend/src/services/note/delete.ts @@ -0,0 +1,137 @@ +import { publishNoteStream } from '@/services/stream'; +import renderDelete from '@/remote/activitypub/renderer/delete'; +import renderAnnounce from '@/remote/activitypub/renderer/announce'; +import renderUndo from '@/remote/activitypub/renderer/undo'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderTombstone from '@/remote/activitypub/renderer/tombstone'; +import config from '@/config/index'; +import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc'; +import { User, ILocalUser, IRemoteUser } from '@/models/entities/user'; +import { Note, IMentionedRemoteUsers } from '@/models/entities/note'; +import { Notes, Users, Instances } from '@/models/index'; +import { notesChart, perUserNotesChart, instanceChart } from '@/services/chart/index'; +import { deliverToFollowers, deliverToUser } from '@/remote/activitypub/deliver-manager'; +import { countSameRenotes } from '@/misc/count-same-renotes'; +import { deliverToRelays } from '../relay'; +import { Brackets, In } from 'typeorm'; + +/** + * 投稿を削除します。 + * @param user 投稿者 + * @param note 投稿 + */ +export default async function(user: User, note: Note, quiet = false) { + const deletedAt = new Date(); + + // この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき + if (note.renoteId && (await countSameRenotes(user.id, note.renoteId, note.id)) === 0) { + Notes.decrement({ id: note.renoteId }, 'renoteCount', 1); + Notes.decrement({ id: note.renoteId }, 'score', 1); + } + + if (!quiet) { + publishNoteStream(note.id, 'deleted', { + deletedAt: deletedAt + }); + + //#region ローカルの投稿なら削除アクティビティを配送 + if (Users.isLocalUser(user) && !note.localOnly) { + let renote: Note | undefined; + + // if deletd note is renote + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length == 0)) { + renote = await Notes.findOne({ + id: note.renoteId + }); + } + + const content = renderActivity(renote + ? renderUndo(renderAnnounce(renote.uri || `${config.url}/notes/${renote.id}`, note), user) + : renderDelete(renderTombstone(`${config.url}/notes/${note.id}`), user)); + + deliverToConcerned(user, note, content); + } + + // also deliever delete activity to cascaded notes + const cascadingNotes = (await findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes + for (const cascadingNote of cascadingNotes) { + if (!cascadingNote.user) continue; + if (!Users.isLocalUser(cascadingNote.user)) continue; + const content = renderActivity(renderDelete(renderTombstone(`${config.url}/notes/${cascadingNote.id}`), cascadingNote.user)); + deliverToConcerned(cascadingNote.user, cascadingNote, content); + } + //#endregion + + // 統計を更新 + notesChart.update(note, false); + perUserNotesChart.update(user, note, false); + + if (Users.isRemoteUser(user)) { + registerOrFetchInstanceDoc(user.host).then(i => { + Instances.decrement({ id: i.id }, 'notesCount', 1); + instanceChart.updateNote(i.host, note, false); + }); + } + } + + await Notes.delete({ + id: note.id, + userId: user.id + }); +} + +async function findCascadingNotes(note: Note) { + const cascadingNotes: Note[] = []; + + const recursive = async (noteId: string) => { + const query = Notes.createQueryBuilder('note') + .where('note.replyId = :noteId', { noteId }) + .orWhere(new Brackets(q => { + q.where('note.renoteId = :noteId', { noteId }) + .andWhere('note.text IS NOT NULL'); + })) + .leftJoinAndSelect('note.user', 'user'); + const replies = await query.getMany(); + for (const reply of replies) { + cascadingNotes.push(reply); + await recursive(reply.id); + } + }; + await recursive(note.id); + + return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users +} + +async function getMentionedRemoteUsers(note: Note) { + const where = [] as any[]; + + // mention / reply / dm + const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); + if (uris.length > 0) { + where.push( + { uri: In(uris) } + ); + } + + // renote / quote + if (note.renoteUserId) { + where.push({ + id: note.renoteUserId + }); + } + + if (where.length === 0) return []; + + return await Users.find({ + where + }) as IRemoteUser[]; +} + +async function deliverToConcerned(user: ILocalUser, note: Note, content: any) { + deliverToFollowers(user, content); + deliverToRelays(user, content); + const remoteUsers = await getMentionedRemoteUsers(note); + for (const remoteUser of remoteUsers) { + deliverToUser(user, content, remoteUser); + } +} diff --git a/packages/backend/src/services/note/polls/update.ts b/packages/backend/src/services/note/polls/update.ts new file mode 100644 index 0000000000..a22ce8e373 --- /dev/null +++ b/packages/backend/src/services/note/polls/update.ts @@ -0,0 +1,22 @@ +import renderUpdate from '@/remote/activitypub/renderer/update'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import renderNote from '@/remote/activitypub/renderer/note'; +import { Users, Notes } from '@/models/index'; +import { Note } from '@/models/entities/note'; +import { deliverToFollowers } from '@/remote/activitypub/deliver-manager'; +import { deliverToRelays } from '../../relay'; + +export async function deliverQuestionUpdate(noteId: Note['id']) { + const note = await Notes.findOne(noteId); + if (note == null) throw new Error('note not found'); + + const user = await Users.findOne(note.userId); + if (user == null) throw new Error('note not found'); + + if (Users.isLocalUser(user)) { + + const content = renderActivity(renderUpdate(await renderNote(note, false), user)); + deliverToFollowers(user, content); + deliverToRelays(user, content); + } +} diff --git a/packages/backend/src/services/note/polls/vote.ts b/packages/backend/src/services/note/polls/vote.ts new file mode 100644 index 0000000000..886a09dde9 --- /dev/null +++ b/packages/backend/src/services/note/polls/vote.ts @@ -0,0 +1,81 @@ +import { publishNoteStream } from '@/services/stream'; +import { User } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { PollVotes, NoteWatchings, Polls, Blockings } from '@/models/index'; +import { Not } from 'typeorm'; +import { genId } from '@/misc/gen-id'; +import { createNotification } from '../../create-notification'; + +export default async function(user: User, note: Note, choice: number) { + const poll = await Polls.findOne(note.id); + + if (poll == null) throw new Error('poll not found'); + + // Check whether is valid choice + if (poll.choices[choice] == null) throw new Error('invalid choice param'); + + // Check blocking + if (note.userId !== user.id) { + const block = await Blockings.findOne({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new Error('blocked'); + } + } + + // if already voted + const exist = await PollVotes.find({ + noteId: note.id, + userId: user.id + }); + + if (poll.multiple) { + if (exist.some(x => x.choice === choice)) { + throw new Error('already voted'); + } + } else if (exist.length !== 0) { + throw new Error('already voted'); + } + + // Create vote + await PollVotes.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + choice: choice + }); + + // Increment votes count + const index = choice + 1; // In SQL, array index is 1 based + await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); + + publishNoteStream(note.id, 'pollVoted', { + choice: choice, + userId: user.id + }); + + // Notify + createNotification(note.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: choice + }); + + // Fetch watchers + NoteWatchings.find({ + noteId: note.id, + userId: Not(user.id), + }) + .then(watchers => { + for (const watcher of watchers) { + createNotification(watcher.userId, 'pollVote', { + notifierId: user.id, + noteId: note.id, + choice: choice + }); + } + }); +} diff --git a/packages/backend/src/services/note/reaction/create.ts b/packages/backend/src/services/note/reaction/create.ts new file mode 100644 index 0000000000..308bd4dff7 --- /dev/null +++ b/packages/backend/src/services/note/reaction/create.ts @@ -0,0 +1,135 @@ +import { publishNoteStream } from '@/services/stream'; +import { renderLike } from '@/remote/activitypub/renderer/like'; +import DeliverManager from '@/remote/activitypub/deliver-manager'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import { toDbReaction, decodeReaction } from '@/misc/reaction-lib'; +import { User, IRemoteUser } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { NoteReactions, Users, NoteWatchings, Notes, Emojis, Blockings } from '@/models/index'; +import { Not } from 'typeorm'; +import { perUserReactionsChart } from '@/services/chart/index'; +import { genId } from '@/misc/gen-id'; +import { createNotification } from '../../create-notification'; +import deleteReaction from './delete'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error'; +import { NoteReaction } from '@/models/entities/note-reaction'; +import { IdentifiableError } from '@/misc/identifiable-error'; + +export default async (user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) => { + // Check blocking + if (note.userId !== user.id) { + const block = await Blockings.findOne({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) { + throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); + } + } + + // TODO: cache + reaction = await toDbReaction(reaction, user.host); + + const record: NoteReaction = { + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + reaction + }; + + // Create reaction + try { + await NoteReactions.insert(record); + } catch (e) { + if (isDuplicateKeyValueError(e)) { + const exists = await NoteReactions.findOneOrFail({ + noteId: note.id, + userId: user.id, + }); + + if (exists.reaction !== reaction) { + // 別のリアクションがすでにされていたら置き換える + await deleteReaction(user, note); + await NoteReactions.insert(record); + } else { + // 同じリアクションがすでにされていたらエラー + throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298'); + } + } else { + throw e; + } + } + + // Increment reactions count + const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; + await Notes.createQueryBuilder().update() + .set({ + reactions: () => sql, + score: () => '"score" + 1' + }) + .where('id = :id', { id: note.id }) + .execute(); + + perUserReactionsChart.update(user, note); + + // カスタム絵文字リアクションだったら絵文字情報も送る + const decodedReaction = decodeReaction(reaction); + + let emoji = await Emojis.findOne({ + where: { + name: decodedReaction.name, + host: decodedReaction.host + }, + select: ['name', 'host', 'url'] + }); + + if (emoji) { + emoji = { + name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, + url: emoji.url + } as any; + } + + publishNoteStream(note.id, 'reacted', { + reaction: decodedReaction.reaction, + emoji: emoji, + userId: user.id + }); + + // リアクションされたユーザーがローカルユーザーなら通知を作成 + if (note.userHost === null) { + createNotification(note.userId, 'reaction', { + notifierId: user.id, + noteId: note.id, + reaction: reaction + }); + } + + // Fetch watchers + NoteWatchings.find({ + noteId: note.id, + userId: Not(user.id) + }).then(watchers => { + for (const watcher of watchers) { + createNotification(watcher.userId, 'reaction', { + notifierId: user.id, + noteId: note.id, + reaction: reaction + }); + } + }); + + //#region 配信 + if (Users.isLocalUser(user) && !note.localOnly) { + const content = renderActivity(await renderLike(record, note)); + const dm = new DeliverManager(user, content); + if (note.userHost !== null) { + const reactee = await Users.findOne(note.userId); + dm.addDirectRecipe(reactee as IRemoteUser); + } + dm.addFollowersRecipe(); + dm.execute(); + } + //#endregion +}; diff --git a/packages/backend/src/services/note/reaction/delete.ts b/packages/backend/src/services/note/reaction/delete.ts new file mode 100644 index 0000000000..062dbad6f1 --- /dev/null +++ b/packages/backend/src/services/note/reaction/delete.ts @@ -0,0 +1,58 @@ +import { publishNoteStream } from '@/services/stream'; +import { renderLike } from '@/remote/activitypub/renderer/like'; +import renderUndo from '@/remote/activitypub/renderer/undo'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import DeliverManager from '@/remote/activitypub/deliver-manager'; +import { IdentifiableError } from '@/misc/identifiable-error'; +import { User, IRemoteUser } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { NoteReactions, Users, Notes } from '@/models/index'; +import { decodeReaction } from '@/misc/reaction-lib'; + +export default async (user: { id: User['id']; host: User['host']; }, note: Note) => { + // if already unreacted + const exist = await NoteReactions.findOne({ + noteId: note.id, + userId: user.id, + }); + + if (exist == null) { + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + } + + // Delete reaction + const result = await NoteReactions.delete(exist.id); + + if (result.affected !== 1) { + throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); + } + + // Decrement reactions count + const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; + await Notes.createQueryBuilder().update() + .set({ + reactions: () => sql, + }) + .where('id = :id', { id: note.id }) + .execute(); + + Notes.decrement({ id: note.id }, 'score', 1); + + publishNoteStream(note.id, 'unreacted', { + reaction: decodeReaction(exist.reaction).reaction, + userId: user.id + }); + + //#region 配信 + if (Users.isLocalUser(user) && !note.localOnly) { + const content = renderActivity(renderUndo(await renderLike(exist, note), user)); + const dm = new DeliverManager(user, content); + if (note.userHost !== null) { + const reactee = await Users.findOne(note.userId); + dm.addDirectRecipe(reactee as IRemoteUser); + } + dm.addFollowersRecipe(); + dm.execute(); + } + //#endregion +}; diff --git a/packages/backend/src/services/note/read.ts b/packages/backend/src/services/note/read.ts new file mode 100644 index 0000000000..f25f86da9c --- /dev/null +++ b/packages/backend/src/services/note/read.ts @@ -0,0 +1,132 @@ +import { publishMainStream } from '@/services/stream'; +import { Note } from '@/models/entities/note'; +import { User } from '@/models/entities/user'; +import { NoteUnreads, AntennaNotes, Users, Followings, ChannelFollowings } from '@/models/index'; +import { Not, IsNull, In } from 'typeorm'; +import { Channel } from '@/models/entities/channel'; +import { checkHitAntenna } from '@/misc/check-hit-antenna'; +import { getAntennas } from '@/misc/antenna-cache'; +import { readNotificationByQuery } from '@/server/api/common/read-notification'; +import { Packed } from '@/misc/schema'; + +/** + * Mark notes as read + */ +export default async function( + userId: User['id'], + notes: (Note | Packed<'Note'>)[], + info?: { + following: Set<User['id']>; + followingChannels: Set<Channel['id']>; + } +) { + const following = info?.following ? info.following : new Set<string>((await Followings.find({ + where: { + followerId: userId + }, + select: ['followeeId'] + })).map(x => x.followeeId)); + const followingChannels = info?.followingChannels ? info.followingChannels : new Set<string>((await ChannelFollowings.find({ + where: { + followerId: userId + }, + select: ['followeeId'] + })).map(x => x.followeeId)); + + const myAntennas = (await getAntennas()).filter(a => a.userId === userId); + const readMentions: (Note | Packed<'Note'>)[] = []; + const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; + const readChannelNotes: (Note | Packed<'Note'>)[] = []; + const readAntennaNotes: (Note | Packed<'Note'>)[] = []; + + for (const note of notes) { + if (note.mentions && note.mentions.includes(userId)) { + readMentions.push(note); + } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { + readSpecifiedNotes.push(note); + } + + if (note.channelId && followingChannels.has(note.channelId)) { + readChannelNotes.push(note); + } + + if (note.user != null) { // たぶんnullになることは無いはずだけど一応 + for (const antenna of myAntennas) { + if (await checkHitAntenna(antenna, note, note.user as any, undefined, Array.from(following))) { + readAntennaNotes.push(note); + } + } + } + } + + if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { + // Remove the record + await NoteUnreads.delete({ + userId: userId, + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), + }); + + // TODO: ↓まとめてクエリしたい + + NoteUnreads.count({ + userId: userId, + isMentioned: true + }).then(mentionsCount => { + if (mentionsCount === 0) { + // 全て既読になったイベントを発行 + publishMainStream(userId, 'readAllUnreadMentions'); + } + }); + + NoteUnreads.count({ + userId: userId, + isSpecified: true + }).then(specifiedCount => { + if (specifiedCount === 0) { + // 全て既読になったイベントを発行 + publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); + } + }); + + NoteUnreads.count({ + userId: userId, + noteChannelId: Not(IsNull()) + }).then(channelNoteCount => { + if (channelNoteCount === 0) { + // 全て既読になったイベントを発行 + publishMainStream(userId, 'readAllChannels'); + } + }); + + readNotificationByQuery(userId, { + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), + }); + } + + if (readAntennaNotes.length > 0) { + await AntennaNotes.update({ + antennaId: In(myAntennas.map(a => a.id)), + noteId: In(readAntennaNotes.map(n => n.id)) + }, { + read: true + }); + + // TODO: まとめてクエリしたい + for (const antenna of myAntennas) { + const count = await AntennaNotes.count({ + antennaId: antenna.id, + read: false + }); + + if (count === 0) { + publishMainStream(userId, 'readAntenna', antenna); + } + } + + Users.getHasUnreadAntenna(userId).then(unread => { + if (!unread) { + publishMainStream(userId, 'readAllAntennas'); + } + }); + } +} diff --git a/packages/backend/src/services/note/unread.ts b/packages/backend/src/services/note/unread.ts new file mode 100644 index 0000000000..29d2b54af8 --- /dev/null +++ b/packages/backend/src/services/note/unread.ts @@ -0,0 +1,55 @@ +import { Note } from '@/models/entities/note'; +import { publishMainStream } from '@/services/stream'; +import { User } from '@/models/entities/user'; +import { Mutings, NoteThreadMutings, NoteUnreads } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +export async function insertNoteUnread(userId: User['id'], note: Note, params: { + // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse + isSpecified: boolean; + isMentioned: boolean; +}) { + //#region ミュートしているなら無視 + // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする + const mute = await Mutings.find({ + muterId: userId + }); + if (mute.map(m => m.muteeId).includes(note.userId)) return; + //#endregion + + // スレッドミュート + const threadMute = await NoteThreadMutings.findOne({ + userId: userId, + threadId: note.threadId || note.id, + }); + if (threadMute) return; + + const unread = { + id: genId(), + noteId: note.id, + userId: userId, + isSpecified: params.isSpecified, + isMentioned: params.isMentioned, + noteChannelId: note.channelId, + noteUserId: note.userId, + }; + + await NoteUnreads.insert(unread); + + // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する + setTimeout(async () => { + const exist = await NoteUnreads.findOne(unread.id); + + if (exist == null) return; + + if (params.isMentioned) { + publishMainStream(userId, 'unreadMention', note.id); + } + if (params.isSpecified) { + publishMainStream(userId, 'unreadSpecifiedNote', note.id); + } + if (note.channelId) { + publishMainStream(userId, 'unreadChannel', note.id); + } + }, 2000); +} diff --git a/packages/backend/src/services/note/unwatch.ts b/packages/backend/src/services/note/unwatch.ts new file mode 100644 index 0000000000..8ea02fe33c --- /dev/null +++ b/packages/backend/src/services/note/unwatch.ts @@ -0,0 +1,10 @@ +import { User } from '@/models/entities/user'; +import { NoteWatchings } from '@/models/index'; +import { Note } from '@/models/entities/note'; + +export default async (me: User['id'], note: Note) => { + await NoteWatchings.delete({ + noteId: note.id, + userId: me + }); +}; diff --git a/packages/backend/src/services/note/watch.ts b/packages/backend/src/services/note/watch.ts new file mode 100644 index 0000000000..e457191d99 --- /dev/null +++ b/packages/backend/src/services/note/watch.ts @@ -0,0 +1,20 @@ +import { User } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { NoteWatchings } from '@/models/index'; +import { genId } from '@/misc/gen-id'; +import { NoteWatching } from '@/models/entities/note-watching'; + +export default async (me: User['id'], note: Note) => { + // 自分の投稿はwatchできない + if (me === note.userId) { + return; + } + + await NoteWatchings.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: me, + noteUserId: note.userId + } as NoteWatching); +}; diff --git a/packages/backend/src/services/push-notification.ts b/packages/backend/src/services/push-notification.ts new file mode 100644 index 0000000000..5949d11b3b --- /dev/null +++ b/packages/backend/src/services/push-notification.ts @@ -0,0 +1,53 @@ +import * as push from 'web-push'; +import config from '@/config/index'; +import { SwSubscriptions } from '@/models/index'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { Packed } from '@/misc/schema'; + +type notificationType = 'notification' | 'unreadMessagingMessage'; +type notificationBody = Packed<'Notification'> | Packed<'MessagingMessage'>; + +export default async function(userId: string, type: notificationType, body: notificationBody) { + const meta = await fetchMeta(); + + if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; + + // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 + push.setVapidDetails(config.url, + meta.swPublicKey, + meta.swPrivateKey); + + // Fetch + const subscriptions = await SwSubscriptions.find({ + userId: userId + }); + + for (const subscription of subscriptions) { + const pushSubscription = { + endpoint: subscription.endpoint, + keys: { + auth: subscription.auth, + p256dh: subscription.publickey + } + }; + + push.sendNotification(pushSubscription, JSON.stringify({ + type, body + }), { + proxy: config.proxy + }).catch((err: any) => { + //swLogger.info(err.statusCode); + //swLogger.info(err.headers); + //swLogger.info(err.body); + + if (err.statusCode === 410) { + SwSubscriptions.delete({ + userId: userId, + endpoint: subscription.endpoint, + auth: subscription.auth, + publickey: subscription.publickey + }); + } + }); + } +} diff --git a/packages/backend/src/services/register-or-fetch-instance-doc.ts b/packages/backend/src/services/register-or-fetch-instance-doc.ts new file mode 100644 index 0000000000..a548ab0497 --- /dev/null +++ b/packages/backend/src/services/register-or-fetch-instance-doc.ts @@ -0,0 +1,34 @@ +import { Instance } from '@/models/entities/instance'; +import { Instances } from '@/models/index'; +import { federationChart } from '@/services/chart/index'; +import { genId } from '@/misc/gen-id'; +import { toPuny } from '@/misc/convert-host'; +import { Cache } from '@/misc/cache'; + +const cache = new Cache<Instance>(1000 * 60 * 60); + +export async function registerOrFetchInstanceDoc(host: string): Promise<Instance> { + host = toPuny(host); + + const cached = cache.get(host); + if (cached) return cached; + + const index = await Instances.findOne({ host }); + + if (index == null) { + const i = await Instances.save({ + id: genId(), + host, + caughtAt: new Date(), + lastCommunicatedAt: new Date(), + }); + + federationChart.update(true); + + cache.set(host, i); + return i; + } else { + cache.set(host, index); + return index; + } +} diff --git a/packages/backend/src/services/relay.ts b/packages/backend/src/services/relay.ts new file mode 100644 index 0000000000..04775524fa --- /dev/null +++ b/packages/backend/src/services/relay.ts @@ -0,0 +1,94 @@ +import { createSystemUser } from './create-system-user'; +import { renderFollowRelay } from '@/remote/activitypub/renderer/follow-relay'; +import { renderActivity, attachLdSignature } from '@/remote/activitypub/renderer/index'; +import renderUndo from '@/remote/activitypub/renderer/undo'; +import { deliver } from '@/queue/index'; +import { ILocalUser, User } from '@/models/entities/user'; +import { Users, Relays } from '@/models/index'; +import { genId } from '@/misc/gen-id'; + +const ACTOR_USERNAME = 'relay.actor' as const; + +export async function getRelayActor(): Promise<ILocalUser> { + const user = await Users.findOne({ + host: null, + username: ACTOR_USERNAME + }); + + if (user) return user as ILocalUser; + + const created = await createSystemUser(ACTOR_USERNAME); + return created as ILocalUser; +} + +export async function addRelay(inbox: string) { + const relay = await Relays.save({ + id: genId(), + inbox, + status: 'requesting' + }); + + const relayActor = await getRelayActor(); + const follow = await renderFollowRelay(relay, relayActor); + const activity = renderActivity(follow); + deliver(relayActor, activity, relay.inbox); + + return relay; +} + +export async function removeRelay(inbox: string) { + const relay = await Relays.findOne({ + inbox + }); + + if (relay == null) { + throw 'relay not found'; + } + + const relayActor = await getRelayActor(); + const follow = renderFollowRelay(relay, relayActor); + const undo = renderUndo(follow, relayActor); + const activity = renderActivity(undo); + deliver(relayActor, activity, relay.inbox); + + await Relays.delete(relay.id); +} + +export async function listRelay() { + const relays = await Relays.find(); + return relays; +} + +export async function relayAccepted(id: string) { + const result = await Relays.update(id, { + status: 'accepted' + }); + + return JSON.stringify(result); +} + +export async function relayRejected(id: string) { + const result = await Relays.update(id, { + status: 'rejected' + }); + + return JSON.stringify(result); +} + +export async function deliverToRelays(user: { id: User['id']; host: null; }, activity: any) { + if (activity == null) return; + + const relays = await Relays.find({ + status: 'accepted' + }); + if (relays.length === 0) return; + + const copy = JSON.parse(JSON.stringify(activity)); + if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; + + const signed = await attachLdSignature(copy, user); + + for (const relay of relays) { + deliver(user, signed, relay.inbox); + } +} diff --git a/packages/backend/src/services/send-email-notification.ts b/packages/backend/src/services/send-email-notification.ts new file mode 100644 index 0000000000..157bacb46d --- /dev/null +++ b/packages/backend/src/services/send-email-notification.ts @@ -0,0 +1,31 @@ +import { UserProfiles } from '@/models/index'; +import { User } from '@/models/entities/user'; +import { sendEmail } from './send-email'; +import { I18n } from '@/misc/i18n'; +import * as Acct from 'misskey-js/built/acct'; +const locales = require('../../../../locales/index.js'); + +// TODO: locale ファイルをクライアント用とサーバー用で分けたい + +async function follow(userId: User['id'], follower: User) { + const userProfile = await UserProfiles.findOneOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; + const locale = locales[userProfile.lang || 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); +} + +async function receiveFollowRequest(userId: User['id'], follower: User) { + const userProfile = await UserProfiles.findOneOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; + const locale = locales[userProfile.lang || 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); +} + +export const sendEmailNotification = { + follow, + receiveFollowRequest, +}; diff --git a/packages/backend/src/services/send-email.ts b/packages/backend/src/services/send-email.ts new file mode 100644 index 0000000000..d24168ec46 --- /dev/null +++ b/packages/backend/src/services/send-email.ts @@ -0,0 +1,123 @@ +import * as nodemailer from 'nodemailer'; +import { fetchMeta } from '@/misc/fetch-meta'; +import Logger from './logger'; +import config from '@/config/index'; + +export const logger = new Logger('email'); + +export async function sendEmail(to: string, subject: string, html: string, text: string) { + const meta = await fetchMeta(true); + + const iconUrl = `${config.url}/static-assets/mi-white.png`; + const emailSettingUrl = `${config.url}/settings/email`; + + const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; + + const transporter = nodemailer.createTransport({ + host: meta.smtpHost, + port: meta.smtpPort, + secure: meta.smtpSecure, + ignoreTLS: !enableAuth, + proxy: config.proxySmtp, + auth: enableAuth ? { + user: meta.smtpUser, + pass: meta.smtpPass + } : undefined + } as any); + + try { + // TODO: htmlサニタイズ + const info = await transporter.sendMail({ + from: meta.email!, + to: to, + subject: subject, + text: text, + html: `<!doctype html> + <html> + <head> + <meta charset="utf-8"> + <title>${ subject }</title> + <style> + html { + background: #eee; + } + + body { + padding: 16px; + margin: 0; + font-family: sans-serif; + font-size: 14px; + } + + a { + text-decoration: none; + color: #86b300; + } + a:hover { + text-decoration: underline; + } + + main { + max-width: 500px; + margin: 0 auto; + background: #fff; + color: #555; + } + main > header { + padding: 32px; + background: #86b300; + } + main > header > img { + max-width: 128px; + max-height: 28px; + vertical-align: bottom; + } + main > article { + padding: 32px; + } + main > article > h1 { + margin: 0 0 1em 0; + } + main > footer { + padding: 32px; + border-top: solid 1px #eee; + } + + nav { + box-sizing: border-box; + max-width: 500px; + margin: 16px auto 0 auto; + padding: 0 32px; + } + nav > a { + color: #888; + } + </style> + </head> + <body> + <main> + <header> + <img src="${ meta.logoImageUrl || meta.iconUrl || iconUrl }"/> + </header> + <article> + <h1>${ subject }</h1> + <div>${ html }</div> + </article> + <footer> + <a href="${ emailSettingUrl }">${ 'Email setting' }</a> + </footer> + </main> + <nav> + <a href="${ config.url }">${ config.host }</a> + </nav> + </body> + </html> + ` + }); + + logger.info('Message sent: %s', info.messageId); + } catch (e) { + logger.error(e); + throw e; + } +} diff --git a/packages/backend/src/services/stream.ts b/packages/backend/src/services/stream.ts new file mode 100644 index 0000000000..2c308a1b54 --- /dev/null +++ b/packages/backend/src/services/stream.ts @@ -0,0 +1,129 @@ +import { redisClient } from '../db/redis'; +import { User } from '@/models/entities/user'; +import { Note } from '@/models/entities/note'; +import { UserList } from '@/models/entities/user-list'; +import { ReversiGame } from '@/models/entities/games/reversi/game'; +import { UserGroup } from '@/models/entities/user-group'; +import config from '@/config/index'; +import { Antenna } from '@/models/entities/antenna'; +import { Channel } from '@/models/entities/channel'; +import { + StreamChannels, + AdminStreamTypes, + AntennaStreamTypes, + BroadcastTypes, + ChannelStreamTypes, + DriveStreamTypes, + GroupMessagingStreamTypes, + InternalStreamTypes, + MainStreamTypes, + MessagingIndexStreamTypes, + MessagingStreamTypes, + NoteStreamTypes, + ReversiGameStreamTypes, + ReversiStreamTypes, + UserListStreamTypes, + UserStreamTypes +} from '@/server/api/stream/types'; +import { Packed } from '@/misc/schema'; + +class Publisher { + private publish = (channel: StreamChannels, type: string | null, value?: any): void => { + const message = type == null ? value : value == null ? + { type: type, body: null } : + { type: type, body: value }; + + redisClient.publish(config.host, JSON.stringify({ + channel: channel, + message: message + })); + } + + public publishInternalEvent = <K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void => { + this.publish('internal', type, typeof value === 'undefined' ? null : value); + } + + public publishUserEvent = <K extends keyof UserStreamTypes>(userId: User['id'], type: K, value?: UserStreamTypes[K]): void => { + this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishBroadcastStream = <K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void => { + this.publish('broadcast', type, typeof value === 'undefined' ? null : value); + } + + public publishMainStream = <K extends keyof MainStreamTypes>(userId: User['id'], type: K, value?: MainStreamTypes[K]): void => { + this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishDriveStream = <K extends keyof DriveStreamTypes>(userId: User['id'], type: K, value?: DriveStreamTypes[K]): void => { + this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishNoteStream = <K extends keyof NoteStreamTypes>(noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void => { + this.publish(`noteStream:${noteId}`, type, { + id: noteId, + body: value + }); + } + + public publishChannelStream = <K extends keyof ChannelStreamTypes>(channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void => { + this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishUserListStream = <K extends keyof UserListStreamTypes>(listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void => { + this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishAntennaStream = <K extends keyof AntennaStreamTypes>(antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void => { + this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingStream = <K extends keyof MessagingStreamTypes>(userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void => { + this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishGroupMessagingStream = <K extends keyof GroupMessagingStreamTypes>(groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void => { + this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingIndexStream = <K extends keyof MessagingIndexStreamTypes>(userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void => { + this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishReversiStream = <K extends keyof ReversiStreamTypes>(userId: User['id'], type: K, value?: ReversiStreamTypes[K]): void => { + this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishReversiGameStream = <K extends keyof ReversiGameStreamTypes>(gameId: ReversiGame['id'], type: K, value?: ReversiGameStreamTypes[K]): void => { + this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishNotesStream = (note: Packed<'Note'>): void => { + this.publish('notesStream', null, note); + } + + public publishAdminStream = <K extends keyof AdminStreamTypes>(userId: User['id'], type: K, value?: AdminStreamTypes[K]): void => { + this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } +} + +const publisher = new Publisher(); + +export default publisher; + +export const publishInternalEvent = publisher.publishInternalEvent; +export const publishUserEvent = publisher.publishUserEvent; +export const publishBroadcastStream = publisher.publishBroadcastStream; +export const publishMainStream = publisher.publishMainStream; +export const publishDriveStream = publisher.publishDriveStream; +export const publishNoteStream = publisher.publishNoteStream; +export const publishNotesStream = publisher.publishNotesStream; +export const publishChannelStream = publisher.publishChannelStream; +export const publishUserListStream = publisher.publishUserListStream; +export const publishAntennaStream = publisher.publishAntennaStream; +export const publishMessagingStream = publisher.publishMessagingStream; +export const publishGroupMessagingStream = publisher.publishGroupMessagingStream; +export const publishMessagingIndexStream = publisher.publishMessagingIndexStream; +export const publishReversiStream = publisher.publishReversiStream; +export const publishReversiGameStream = publisher.publishReversiGameStream; +export const publishAdminStream = publisher.publishAdminStream; diff --git a/packages/backend/src/services/suspend-user.ts b/packages/backend/src/services/suspend-user.ts new file mode 100644 index 0000000000..55be63172f --- /dev/null +++ b/packages/backend/src/services/suspend-user.ts @@ -0,0 +1,34 @@ +import renderDelete from '@/remote/activitypub/renderer/delete'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import { deliver } from '@/queue/index'; +import config from '@/config/index'; +import { User } from '@/models/entities/user'; +import { Users, Followings } from '@/models/index'; +import { Not, IsNull } from 'typeorm'; + +export async function doPostSuspend(user: { id: User['id']; host: User['host'] }) { + if (Users.isLocalUser(user)) { + // 知り得る全SharedInboxにDelete配信 + const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user)); + + const queue: string[] = []; + + const followings = await Followings.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) } + ], + select: ['followerSharedInbox', 'followeeSharedInbox'] + }); + + const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + deliver(user, content, inbox); + } + } +} diff --git a/packages/backend/src/services/unsuspend-user.ts b/packages/backend/src/services/unsuspend-user.ts new file mode 100644 index 0000000000..bfffa036e5 --- /dev/null +++ b/packages/backend/src/services/unsuspend-user.ts @@ -0,0 +1,35 @@ +import renderDelete from '@/remote/activitypub/renderer/delete'; +import renderUndo from '@/remote/activitypub/renderer/undo'; +import { renderActivity } from '@/remote/activitypub/renderer/index'; +import { deliver } from '@/queue/index'; +import config from '@/config/index'; +import { User } from '@/models/entities/user'; +import { Users, Followings } from '@/models/index'; +import { Not, IsNull } from 'typeorm'; + +export async function doPostUnsuspend(user: User) { + if (Users.isLocalUser(user)) { + // 知り得る全SharedInboxにUndo Delete配信 + const content = renderActivity(renderUndo(renderDelete(`${config.url}/users/${user.id}`, user), user)); + + const queue: string[] = []; + + const followings = await Followings.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) } + ], + select: ['followerSharedInbox', 'followeeSharedInbox'] + }); + + const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + deliver(user as any, content, inbox); + } + } +} diff --git a/packages/backend/src/services/update-hashtag.ts b/packages/backend/src/services/update-hashtag.ts new file mode 100644 index 0000000000..e8504f6ff0 --- /dev/null +++ b/packages/backend/src/services/update-hashtag.ts @@ -0,0 +1,128 @@ +import { User } from '@/models/entities/user'; +import { Hashtags, Users } from '@/models/index'; +import { hashtagChart } from '@/services/chart/index'; +import { genId } from '@/misc/gen-id'; +import { Hashtag } from '@/models/entities/hashtag'; +import { normalizeForSearch } from '@/misc/normalize-for-search'; + +export async function updateHashtags(user: { id: User['id']; host: User['host']; }, tags: string[]) { + for (const tag of tags) { + await updateHashtag(user, tag); + } +} + +export async function updateUsertags(user: User, tags: string[]) { + for (const tag of tags) { + await updateHashtag(user, tag, true, true); + } + + for (const tag of (user.tags || []).filter(x => !tags.includes(x))) { + await updateHashtag(user, tag, true, false); + } +} + +export async function updateHashtag(user: { id: User['id']; host: User['host']; }, tag: string, isUserAttached = false, inc = true) { + tag = normalizeForSearch(tag); + + const index = await Hashtags.findOne({ name: tag }); + + if (index == null && !inc) return; + + if (index != null) { + const q = Hashtags.createQueryBuilder('tag').update() + .where('name = :name', { name: tag }); + + const set = {} as any; + + if (isUserAttached) { + if (inc) { + // 自分が初めてこのタグを使ったなら + if (!index.attachedUserIds.some(id => id === user.id)) { + set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`; + set.attachedUsersCount = () => `"attachedUsersCount" + 1`; + } + // 自分が(ローカル内で)初めてこのタグを使ったなら + if (Users.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) { + set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`; + set.attachedLocalUsersCount = () => `"attachedLocalUsersCount" + 1`; + } + // 自分が(リモートで)初めてこのタグを使ったなら + if (Users.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) { + set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`; + set.attachedRemoteUsersCount = () => `"attachedRemoteUsersCount" + 1`; + } + } else { + set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`; + set.attachedUsersCount = () => `"attachedUsersCount" - 1`; + if (Users.isLocalUser(user)) { + set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`; + set.attachedLocalUsersCount = () => `"attachedLocalUsersCount" - 1`; + } else { + set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`; + set.attachedRemoteUsersCount = () => `"attachedRemoteUsersCount" - 1`; + } + } + } else { + // 自分が初めてこのタグを使ったなら + if (!index.mentionedUserIds.some(id => id === user.id)) { + set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`; + set.mentionedUsersCount = () => `"mentionedUsersCount" + 1`; + } + // 自分が(ローカル内で)初めてこのタグを使ったなら + if (Users.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) { + set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`; + set.mentionedLocalUsersCount = () => `"mentionedLocalUsersCount" + 1`; + } + // 自分が(リモートで)初めてこのタグを使ったなら + if (Users.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) { + set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`; + set.mentionedRemoteUsersCount = () => `"mentionedRemoteUsersCount" + 1`; + } + } + + if (Object.keys(set).length > 0) { + q.set(set); + q.execute(); + } + } else { + if (isUserAttached) { + Hashtags.insert({ + id: genId(), + name: tag, + mentionedUserIds: [], + mentionedUsersCount: 0, + mentionedLocalUserIds: [], + mentionedLocalUsersCount: 0, + mentionedRemoteUserIds: [], + mentionedRemoteUsersCount: 0, + attachedUserIds: [user.id], + attachedUsersCount: 1, + attachedLocalUserIds: Users.isLocalUser(user) ? [user.id] : [], + attachedLocalUsersCount: Users.isLocalUser(user) ? 1 : 0, + attachedRemoteUserIds: Users.isRemoteUser(user) ? [user.id] : [], + attachedRemoteUsersCount: Users.isRemoteUser(user) ? 1 : 0, + } as Hashtag); + } else { + Hashtags.insert({ + id: genId(), + name: tag, + mentionedUserIds: [user.id], + mentionedUsersCount: 1, + mentionedLocalUserIds: Users.isLocalUser(user) ? [user.id] : [], + mentionedLocalUsersCount: Users.isLocalUser(user) ? 1 : 0, + mentionedRemoteUserIds: Users.isRemoteUser(user) ? [user.id] : [], + mentionedRemoteUsersCount: Users.isRemoteUser(user) ? 1 : 0, + attachedUserIds: [], + attachedUsersCount: 0, + attachedLocalUserIds: [], + attachedLocalUsersCount: 0, + attachedRemoteUserIds: [], + attachedRemoteUsersCount: 0, + } as Hashtag); + } + } + + if (!isUserAttached) { + hashtagChart.update(tag, user); + } +} diff --git a/packages/backend/src/services/user-list/push.ts b/packages/backend/src/services/user-list/push.ts new file mode 100644 index 0000000000..29d561b519 --- /dev/null +++ b/packages/backend/src/services/user-list/push.ts @@ -0,0 +1,27 @@ +import { publishUserListStream } from '@/services/stream'; +import { User } from '@/models/entities/user'; +import { UserList } from '@/models/entities/user-list'; +import { UserListJoinings, Users } from '@/models/index'; +import { UserListJoining } from '@/models/entities/user-list-joining'; +import { genId } from '@/misc/gen-id'; +import { fetchProxyAccount } from '@/misc/fetch-proxy-account'; +import createFollowing from '../following/create'; + +export async function pushUserToUserList(target: User, list: UserList) { + await UserListJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: target.id, + userListId: list.id + } as UserListJoining); + + publishUserListStream(list.id, 'userAdded', await Users.pack(target)); + + // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする + if (Users.isRemoteUser(target)) { + const proxy = await fetchProxyAccount(); + if (proxy) { + createFollowing(proxy, target); + } + } +} diff --git a/packages/backend/src/services/validate-email-for-account.ts b/packages/backend/src/services/validate-email-for-account.ts new file mode 100644 index 0000000000..1d039fb263 --- /dev/null +++ b/packages/backend/src/services/validate-email-for-account.ts @@ -0,0 +1,34 @@ +import validateEmail from 'deep-email-validator'; +import { UserProfiles } from '@/models'; + +export async function validateEmailForAccount(emailAddress: string): Promise<{ + available: boolean; + reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp'; +}> { + const exist = await UserProfiles.count({ + emailVerified: true, + email: emailAddress, + }); + + const validated = await validateEmail({ + email: emailAddress, + validateRegex: true, + validateMx: true, + validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので + validateDisposable: true, // 捨てアドかどうかチェック + validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので + }); + + const available = exist === 0 && validated.valid; + + return { + available, + reason: available ? null : + exist !== 0 ? 'used' : + validated.reason === 'regex' ? 'format' : + validated.reason === 'disposable' ? 'disposable' : + validated.reason === 'mx' ? 'mx' : + validated.reason === 'smtp' ? 'smtp' : + null, + }; +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts new file mode 100644 index 0000000000..20f6f8bb88 --- /dev/null +++ b/packages/backend/src/types.ts @@ -0,0 +1,7 @@ +export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; + +export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; + +export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; + +export const ffVisibility = ['public', 'followers', 'private'] as const; |