Merge branch 'develop' into 'nodeinfostats'
# Conflicts: # packages/backend/src/server/NodeinfoServerService.ts
This commit is contained in:
commit
239a4a7a7b
1760 changed files with 69477 additions and 49515 deletions
|
|
@ -14,6 +14,7 @@ import { createPostgresDataSource } from './postgres.js';
|
|||
import { RepositoryModule } from './models/RepositoryModule.js';
|
||||
import { allSettled } from './misc/promise-tracker.js';
|
||||
import { GlobalEvents } from './core/GlobalEventService.js';
|
||||
import Logger from './logger.js';
|
||||
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const $config: Provider = {
|
||||
|
|
@ -24,8 +25,13 @@ const $config: Provider = {
|
|||
const $db: Provider = {
|
||||
provide: DI.db,
|
||||
useFactory: async (config) => {
|
||||
const db = createPostgresDataSource(config);
|
||||
return await db.initialize();
|
||||
try {
|
||||
const db = createPostgresDataSource(config);
|
||||
return await db.initialize();
|
||||
} catch (e) {
|
||||
console.error('failed to initialize database connection', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
|
@ -141,7 +147,7 @@ const $meta: Provider = {
|
|||
for (const key in body.after) {
|
||||
(meta as any)[key] = (body.after as any)[key];
|
||||
}
|
||||
meta.proxyAccount = null; // joinなカラムは通常取ってこないので
|
||||
meta.rootUser = null; // joinなカラムは通常取ってこないので
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
|
@ -164,6 +170,8 @@ const $meta: Provider = {
|
|||
exports: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, $redisForRateLimit, RepositoryModule],
|
||||
})
|
||||
export class GlobalModule implements OnApplicationShutdown {
|
||||
private readonly logger = new Logger('global');
|
||||
|
||||
constructor(
|
||||
@Inject(DI.db) private db: DataSource,
|
||||
@Inject(DI.redis) private redisClient: Redis.Redis,
|
||||
|
|
@ -176,17 +184,18 @@ export class GlobalModule implements OnApplicationShutdown {
|
|||
|
||||
public async dispose(): Promise<void> {
|
||||
// Wait for all potential DB queries
|
||||
this.logger.info('Finalizing active promises...');
|
||||
await allSettled();
|
||||
// And then disconnect from DB
|
||||
await Promise.all([
|
||||
this.db.destroy(),
|
||||
this.redisClient.disconnect(),
|
||||
this.redisForPub.disconnect(),
|
||||
this.redisForSub.disconnect(),
|
||||
this.redisForTimelines.disconnect(),
|
||||
this.redisForReactions.disconnect(),
|
||||
this.redisForRateLimit.disconnect(),
|
||||
]);
|
||||
this.logger.info('Disconnected from data sources...');
|
||||
await this.db.destroy();
|
||||
this.redisClient.disconnect();
|
||||
this.redisForPub.disconnect();
|
||||
this.redisForSub.disconnect();
|
||||
this.redisForTimelines.disconnect();
|
||||
this.redisForReactions.disconnect();
|
||||
this.redisForRateLimit.disconnect();
|
||||
this.logger.info('Global module disposed.');
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export async function server() {
|
|||
const app = await NestFactory.createApplicationContext(MainModule, {
|
||||
logger: new NestLogger(),
|
||||
});
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const serverService = app.get(ServerService);
|
||||
await serverService.launch();
|
||||
|
|
@ -39,6 +40,7 @@ export async function jobQueue() {
|
|||
const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, {
|
||||
logger: new NestLogger(),
|
||||
});
|
||||
jobQueue.enableShutdownHooks();
|
||||
|
||||
jobQueue.get(QueueProcessorService).start();
|
||||
jobQueue.get(ChartManagementService).start();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import cluster from 'node:cluster';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { inspect } from 'node:util';
|
||||
import chalk from 'chalk';
|
||||
import Xev from 'xev';
|
||||
import Logger from '@/logger.js';
|
||||
|
|
@ -53,18 +54,41 @@ async function main() {
|
|||
|
||||
// Display detail of unhandled promise rejection
|
||||
if (!envOption.quiet) {
|
||||
process.on('unhandledRejection', console.dir);
|
||||
process.on('unhandledRejection', e => {
|
||||
logger.error('Unhandled rejection:', inspect(e));
|
||||
});
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
// Workaround for https://github.com/node-fetch/node-fetch/issues/954
|
||||
if (String(err).match(/^TypeError: .+ is an? url with embedded credentials.$/)) {
|
||||
logger.debug('Suppressed node-fetch issue#954, but the current job may fail.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Workaround for https://github.com/node-fetch/node-fetch/issues/1845
|
||||
if (String(err) === 'TypeError: Cannot read properties of undefined (reading \'body\')') {
|
||||
logger.debug('Suppressed node-fetch issue#1845, but the current job may fail.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Throw all other errors to avoid inconsistent state.
|
||||
// (per NodeJS docs, it's unsafe to suppress arbitrary errors in an uncaughtException handler.)
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Display detail of uncaught exception
|
||||
process.on('uncaughtException', err => {
|
||||
try {
|
||||
logger.error(err);
|
||||
console.trace(err);
|
||||
} catch { }
|
||||
process.on('uncaughtExceptionMonitor', (err, origin) => {
|
||||
logger.error(`Uncaught exception (${origin}):`, err);
|
||||
});
|
||||
|
||||
// Dying away...
|
||||
process.on('disconnect', () => {
|
||||
logger.warn('IPC channel disconnected! The process may soon die.');
|
||||
});
|
||||
process.on('beforeExit', code => {
|
||||
logger.warn(`Event loop died! Process will exit with code ${code}.`);
|
||||
});
|
||||
process.on('exit', code => {
|
||||
logger.info(`The process is going to exit with code ${code}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|||
import { dirname } from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import cluster from 'node:cluster';
|
||||
import * as net from 'node:net';
|
||||
import chalk from 'chalk';
|
||||
import chalkTemplate from 'chalk-template';
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
|
@ -18,7 +19,6 @@ import type { Config } from '@/config.js';
|
|||
import { showMachineInfo } from '@/misc/show-machine-info.js';
|
||||
import { envOption } from '@/env.js';
|
||||
import { jobQueue, server } from './common.js';
|
||||
import * as net from 'node:net';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
|
@ -51,7 +51,7 @@ function greet() {
|
|||
}
|
||||
|
||||
bootLogger.info('Welcome to Sharkey!');
|
||||
bootLogger.info(`Sharkey v${meta.version}`, null, true);
|
||||
bootLogger.info(`Sharkey v${meta.gitVersion ?? meta.version}`, null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -74,7 +74,7 @@ export async function masterMain() {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
bootLogger.succ('Sharkey initialized');
|
||||
bootLogger.info('Sharkey initialized');
|
||||
|
||||
if (config.sentryForBackend) {
|
||||
Sentry.init({
|
||||
|
|
@ -91,7 +91,7 @@ export async function masterMain() {
|
|||
maxBreadcrumbs: 0,
|
||||
|
||||
// Set release version
|
||||
release: 'Sharkey@' + meta.version,
|
||||
release: 'Sharkey@' + (meta.gitVersion ?? meta.version),
|
||||
|
||||
...config.sentryForBackend.options,
|
||||
});
|
||||
|
|
@ -140,10 +140,10 @@ export async function masterMain() {
|
|||
}
|
||||
|
||||
if (envOption.onlyQueue) {
|
||||
bootLogger.succ('Queue started', null, true);
|
||||
bootLogger.info('Queue started', null, true);
|
||||
} else {
|
||||
const addressString = net.isIPv6(config.address) ? `[${config.address}]` : config.address;
|
||||
bootLogger.succ(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on ${addressString}:${config.port} on ${config.url}`, null, true);
|
||||
bootLogger.info(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on ${addressString}:${config.port} on ${config.url}`, null, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ function loadConfigBoot(): Config {
|
|||
config = loadConfig();
|
||||
} catch (exception) {
|
||||
if (typeof exception === 'string') {
|
||||
configLogger.error(exception);
|
||||
configLogger.error('Exception loading config:', exception);
|
||||
process.exit(1);
|
||||
} else if ((exception as any).code === 'ENOENT') {
|
||||
configLogger.error('Configuration file not found', null, true);
|
||||
|
|
@ -181,7 +181,7 @@ function loadConfigBoot(): Config {
|
|||
throw exception;
|
||||
}
|
||||
|
||||
configLogger.succ('Loaded');
|
||||
configLogger.info('Loaded');
|
||||
|
||||
return config;
|
||||
}
|
||||
|
|
@ -195,7 +195,7 @@ async function connectDb(): Promise<void> {
|
|||
dbLogger.info('Connecting...');
|
||||
await initDb();
|
||||
const v = await db.query('SHOW server_version').then(x => x[0].server_version);
|
||||
dbLogger.succ(`Connected: v${v}`);
|
||||
dbLogger.info(`Connected: v${v}`);
|
||||
} catch (err) {
|
||||
dbLogger.error('Cannot connect', null, true);
|
||||
dbLogger.error(err);
|
||||
|
|
@ -211,7 +211,7 @@ async function spawnWorkers(limit = 1) {
|
|||
|
||||
bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`);
|
||||
await Promise.all([...Array(workers)].map(spawnWorker));
|
||||
bootLogger.succ('All workers started');
|
||||
bootLogger.info('All workers started');
|
||||
}
|
||||
|
||||
function spawnWorker(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function workerMain() {
|
|||
maxBreadcrumbs: 0,
|
||||
|
||||
// Set release version
|
||||
release: "Sharkey@" + meta.version,
|
||||
release: "Sharkey@" + (meta.gitVersion ?? meta.version),
|
||||
|
||||
...config.sentryForBackend.options,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ import { fileURLToPath } from 'node:url';
|
|||
import { dirname, resolve } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { globSync } from 'glob';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import Logger from './logger.js';
|
||||
import type * as Sentry from '@sentry/node';
|
||||
import type * as SentryVue from '@sentry/vue';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
import type { IPv4, IPv6 } from 'ipaddr.js';
|
||||
|
||||
type RedisOptionsSource = Partial<RedisOptions> & {
|
||||
host?: string;
|
||||
|
|
@ -37,6 +41,7 @@ type Source = {
|
|||
db?: string;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
slowQueryThreshold?: number;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
|
|
@ -66,7 +71,12 @@ type Source = {
|
|||
scope?: 'local' | 'global' | string[];
|
||||
};
|
||||
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
|
||||
sentryForFrontend?: { options: Partial<Sentry.NodeOptions> };
|
||||
sentryForFrontend?: {
|
||||
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
|
||||
vueIntegration?: SentryVue.VueIntegrationOptions | null;
|
||||
browserTracingIntegration?: Parameters<typeof SentryVue.browserTracingIntegration>[0] | null;
|
||||
replayIntegration?: Parameters<typeof SentryVue.replayIntegration>[0] | null;
|
||||
};
|
||||
|
||||
publishTarballInsteadOfProvideRepositoryUrl?: boolean;
|
||||
|
||||
|
|
@ -76,7 +86,8 @@ type Source = {
|
|||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
allowedPrivateNetworks?: PrivateNetworkSource[];
|
||||
disallowExternalApRedirect?: boolean;
|
||||
|
||||
maxFileSize?: number;
|
||||
maxNoteLength?: number;
|
||||
|
|
@ -85,6 +96,8 @@ type Source = {
|
|||
maxRemoteNoteLength?: number;
|
||||
maxAltTextLength?: number;
|
||||
maxRemoteAltTextLength?: number;
|
||||
maxBioLength?: number;
|
||||
maxRemoteBioLength?: number;
|
||||
|
||||
clusterLimit?: number;
|
||||
|
||||
|
|
@ -102,6 +115,7 @@ type Source = {
|
|||
deliverJobMaxAttempts?: number;
|
||||
inboxJobMaxAttempts?: number;
|
||||
|
||||
mediaDirectory?: string;
|
||||
mediaProxy?: string;
|
||||
proxyRemoteFiles?: boolean;
|
||||
videoThumbnailGenerator?: string;
|
||||
|
|
@ -126,9 +140,10 @@ type Source = {
|
|||
|
||||
logging?: {
|
||||
sql?: {
|
||||
disableQueryTruncation? : boolean,
|
||||
enableQueryParamLogging? : boolean,
|
||||
}
|
||||
disableQueryTruncation?: boolean,
|
||||
enableQueryParamLogging?: boolean,
|
||||
};
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
activityLogging?: {
|
||||
|
|
@ -136,8 +151,70 @@ type Source = {
|
|||
preSave?: boolean;
|
||||
maxAge?: number;
|
||||
};
|
||||
|
||||
websocketCompression?: boolean;
|
||||
|
||||
customHtml?: {
|
||||
head?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const configLogger = new Logger('config');
|
||||
|
||||
export type PrivateNetworkSource = string | { network?: string, ports?: number[] };
|
||||
|
||||
export type PrivateNetwork = {
|
||||
/**
|
||||
* CIDR IP/netmask definition of the IP range to match.
|
||||
*/
|
||||
cidr: CIDR;
|
||||
|
||||
/**
|
||||
* List of ports to match.
|
||||
* If undefined, then all ports match.
|
||||
* If empty, then NO ports match.
|
||||
*/
|
||||
ports?: number[];
|
||||
};
|
||||
|
||||
export type CIDR = [ip: IPv4 | IPv6, prefixLength: number];
|
||||
|
||||
export function parsePrivateNetworks(patterns: PrivateNetworkSource[]): PrivateNetwork[];
|
||||
export function parsePrivateNetworks(patterns: undefined): undefined;
|
||||
export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefined): PrivateNetwork[] | undefined;
|
||||
export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefined): PrivateNetwork[] | undefined {
|
||||
if (!patterns) return undefined;
|
||||
return patterns
|
||||
.map(e => {
|
||||
if (typeof(e) === 'string') {
|
||||
const cidr = parseIpOrMask(e);
|
||||
if (cidr) {
|
||||
return { cidr } satisfies PrivateNetwork;
|
||||
}
|
||||
} else if (e.network) {
|
||||
const cidr = parseIpOrMask(e.network);
|
||||
if (cidr) {
|
||||
return { cidr, ports: e.ports } satisfies PrivateNetwork;
|
||||
}
|
||||
}
|
||||
|
||||
configLogger.warn('Skipping invalid entry in allowedPrivateNetworks: ', e);
|
||||
return null;
|
||||
})
|
||||
.filter(p => p != null);
|
||||
}
|
||||
|
||||
function parseIpOrMask(ipOrMask: string): CIDR | null {
|
||||
if (ipaddr.isValidCIDR(ipOrMask)) {
|
||||
return ipaddr.parseCIDR(ipOrMask);
|
||||
}
|
||||
if (ipaddr.isValid(ipOrMask)) {
|
||||
const ip = ipaddr.parse(ipOrMask);
|
||||
return [ip, 32];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type Config = {
|
||||
url: string;
|
||||
port: number;
|
||||
|
|
@ -151,6 +228,7 @@ export type Config = {
|
|||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
slowQueryThreshold?: number;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
|
|
@ -176,7 +254,8 @@ export type Config = {
|
|||
proxy: string | undefined;
|
||||
proxySmtp: string | undefined;
|
||||
proxyBypassHosts: string[] | undefined;
|
||||
allowedPrivateNetworks: string[] | undefined;
|
||||
allowedPrivateNetworks: PrivateNetwork[] | undefined;
|
||||
disallowExternalApRedirect: boolean;
|
||||
maxFileSize: number;
|
||||
maxNoteLength: number;
|
||||
maxRemoteNoteLength: number;
|
||||
|
|
@ -184,6 +263,8 @@ export type Config = {
|
|||
maxRemoteCwLength: number;
|
||||
maxAltTextLength: number;
|
||||
maxRemoteAltTextLength: number;
|
||||
maxBioLength: number;
|
||||
maxRemoteBioLength: number;
|
||||
clusterLimit: number | undefined;
|
||||
id: string;
|
||||
outgoingAddress: string | undefined;
|
||||
|
|
@ -200,12 +281,14 @@ export type Config = {
|
|||
customMOTD: string[] | undefined;
|
||||
signToActivityPubGet: boolean;
|
||||
attachLdSignatureForRelays: boolean;
|
||||
/** @deprecated Use MiMeta.allowUnsignedFetch instead */
|
||||
checkActivityPubGetSignature: boolean | undefined;
|
||||
logging?: {
|
||||
sql?: {
|
||||
disableQueryTruncation? : boolean,
|
||||
enableQueryParamLogging? : boolean,
|
||||
}
|
||||
disableQueryTruncation?: boolean,
|
||||
enableQueryParamLogging?: boolean,
|
||||
};
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
version: string;
|
||||
|
|
@ -224,6 +307,7 @@ export type Config = {
|
|||
frontendManifestExists: boolean;
|
||||
frontendEmbedEntry: string;
|
||||
frontendEmbedManifestExists: boolean;
|
||||
mediaDirectory: string;
|
||||
mediaProxy: string;
|
||||
externalMediaProxyEnabled: boolean;
|
||||
videoThumbnailGenerator: string | null;
|
||||
|
|
@ -234,7 +318,12 @@ export type Config = {
|
|||
redisForReactions: RedisOptions & RedisOptionsSource;
|
||||
redisForRateLimit: RedisOptions & RedisOptionsSource;
|
||||
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
|
||||
sentryForFrontend: { options: Partial<Sentry.NodeOptions> } | undefined;
|
||||
sentryForFrontend: {
|
||||
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
|
||||
vueIntegration?: SentryVue.VueIntegrationOptions | null;
|
||||
browserTracingIntegration?: Parameters<typeof SentryVue.browserTracingIntegration>[0] | null;
|
||||
replayIntegration?: Parameters<typeof SentryVue.replayIntegration>[0] | null;
|
||||
} | undefined;
|
||||
perChannelMaxNoteCacheCount: number;
|
||||
perUserNotificationsMaxCount: number;
|
||||
deactivateAntennaThreshold: number;
|
||||
|
|
@ -252,6 +341,12 @@ export type Config = {
|
|||
preSave: boolean;
|
||||
maxAge: number;
|
||||
};
|
||||
|
||||
websocketCompression?: boolean;
|
||||
|
||||
customHtml: {
|
||||
head: string;
|
||||
}
|
||||
};
|
||||
|
||||
export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch' | 'sqlTsvector';
|
||||
|
|
@ -262,7 +357,7 @@ const _dirname = dirname(_filename);
|
|||
/**
|
||||
* Path of configuration directory
|
||||
*/
|
||||
const dir = `${_dirname}/../../../.config`;
|
||||
const dir = process.env.MISSKEY_CONFIG_DIR ?? `${_dirname}/../../../.config`;
|
||||
|
||||
/**
|
||||
* Path of configuration file
|
||||
|
|
@ -289,11 +384,14 @@ export function loadConfig(): Config {
|
|||
|
||||
if (configFiles.length === 0
|
||||
&& !process.env['MK_WARNED_ABOUT_CONFIG']) {
|
||||
console.log('No config files loaded, check if this is intentional');
|
||||
configLogger.warn('No config files loaded, check if this is intentional');
|
||||
process.env['MK_WARNED_ABOUT_CONFIG'] = '1';
|
||||
}
|
||||
|
||||
const config = configFiles.map(path => fs.readFileSync(path, 'utf-8'))
|
||||
const config = configFiles.map(path => {
|
||||
configLogger.info(`Reading configuration from ${path}`);
|
||||
return fs.readFileSync(path, 'utf-8');
|
||||
})
|
||||
.map(contents => yaml.load(contents) as Source)
|
||||
.reduce(
|
||||
(acc: Source, cur: Source) => Object.assign(acc, cur),
|
||||
|
|
@ -303,7 +401,7 @@ export function loadConfig(): Config {
|
|||
applyEnvOverrides(config);
|
||||
|
||||
const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
|
||||
const version = meta.version;
|
||||
const version = meta.gitVersion ?? meta.version;
|
||||
const host = url.host;
|
||||
const hostname = url.hostname;
|
||||
const scheme = url.protocol.replace(/:$/, '');
|
||||
|
|
@ -319,6 +417,10 @@ export function loadConfig(): Config {
|
|||
const internalMediaProxy = `${scheme}://${host}/proxy`;
|
||||
const redis = convertRedisOptions(config.redis, host);
|
||||
|
||||
// nullish => 300 (default)
|
||||
// 0 => undefined (disabled)
|
||||
const slowQueryThreshold = (config.db.slowQueryThreshold ?? 300) || undefined;
|
||||
|
||||
return {
|
||||
version,
|
||||
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||
|
|
@ -337,7 +439,7 @@ export function loadConfig(): Config {
|
|||
apiUrl: `${scheme}://${host}/api`,
|
||||
authUrl: `${scheme}://${host}/auth`,
|
||||
driveUrl: `${scheme}://${host}/files`,
|
||||
db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass },
|
||||
db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass, slowQueryThreshold },
|
||||
dbReplications: config.dbReplications,
|
||||
dbSlaves: config.dbSlaves,
|
||||
fulltextSearch: config.fulltextSearch,
|
||||
|
|
@ -354,7 +456,8 @@ export function loadConfig(): Config {
|
|||
proxy: config.proxy,
|
||||
proxySmtp: config.proxySmtp,
|
||||
proxyBypassHosts: config.proxyBypassHosts,
|
||||
allowedPrivateNetworks: config.allowedPrivateNetworks,
|
||||
allowedPrivateNetworks: parsePrivateNetworks(config.allowedPrivateNetworks),
|
||||
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
|
||||
maxFileSize: config.maxFileSize ?? 262144000,
|
||||
maxNoteLength: config.maxNoteLength ?? 3000,
|
||||
maxRemoteNoteLength: config.maxRemoteNoteLength ?? 100000,
|
||||
|
|
@ -362,6 +465,8 @@ export function loadConfig(): Config {
|
|||
maxRemoteCwLength: config.maxRemoteCwLength ?? 5000,
|
||||
maxAltTextLength: config.maxAltTextLength ?? 20000,
|
||||
maxRemoteAltTextLength: config.maxRemoteAltTextLength ?? 100000,
|
||||
maxBioLength: config.maxBioLength ?? 1500,
|
||||
maxRemoteBioLength: config.maxRemoteBioLength ?? 15000,
|
||||
clusterLimit: config.clusterLimit,
|
||||
outgoingAddress: config.outgoingAddress,
|
||||
outgoingAddressFamily: config.outgoingAddressFamily,
|
||||
|
|
@ -378,6 +483,7 @@ export function loadConfig(): Config {
|
|||
signToActivityPubGet: config.signToActivityPubGet ?? true,
|
||||
attachLdSignatureForRelays: config.attachLdSignatureForRelays ?? true,
|
||||
checkActivityPubGetSignature: config.checkActivityPubGetSignature,
|
||||
mediaDirectory: config.mediaDirectory ?? resolve(_dirname, '../../../files'),
|
||||
mediaProxy: externalMediaProxy ?? internalMediaProxy,
|
||||
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,
|
||||
videoThumbnailGenerator: config.videoThumbnailGenerator ?
|
||||
|
|
@ -400,10 +506,18 @@ export function loadConfig(): Config {
|
|||
preSave: config.activityLogging?.preSave ?? false,
|
||||
maxAge: config.activityLogging?.maxAge ?? (1000 * 60 * 60 * 24 * 30),
|
||||
},
|
||||
websocketCompression: config.websocketCompression ?? false,
|
||||
customHtml: {
|
||||
head: config.customHtml?.head ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function tryCreateUrl(url: string) {
|
||||
if (!url) {
|
||||
throw new Error('Failed to load: no "url" property found in config. Please check the value of "MISSKEY_CONFIG_DIR" and "MISSKEY_CONFIG_YML", and verify that all configuration files are correct.');
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
|
|
@ -478,7 +592,7 @@ function applyEnvOverrides(config: Source) {
|
|||
}
|
||||
}
|
||||
|
||||
function _step2name(step: string|number): string {
|
||||
function _step2name(step: string | number): string {
|
||||
return step.toString().replaceAll(/[^a-z0-9]+/gi, '').toUpperCase();
|
||||
}
|
||||
|
||||
|
|
@ -534,22 +648,27 @@ function applyEnvOverrides(config: Source) {
|
|||
|
||||
// these are all the settings that can be overridden
|
||||
|
||||
_apply_top([['url', 'port', 'address', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications']]);
|
||||
_apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'disableCache']]);
|
||||
_apply_top([['url', 'port', 'address', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications', 'websocketCompression']]);
|
||||
_apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'slowQueryThreshold', 'disableCache']]);
|
||||
_apply_top(['dbSlaves', Array.from((config.dbSlaves ?? []).keys()), ['host', 'port', 'db', 'user', 'pass']]);
|
||||
_apply_top([
|
||||
['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines', 'redisForReactions', 'redisForRateLimit'],
|
||||
['host', 'port', 'username', 'pass', 'db', 'prefix'],
|
||||
]);
|
||||
_apply_top(['fulltextSearch', 'provider']);
|
||||
_apply_top(['meilisearch', ['host', 'port', 'apikey', 'ssl', 'index', 'scope']]);
|
||||
_apply_top(['meilisearch', ['host', 'port', 'apiKey', 'ssl', 'index', 'scope']]);
|
||||
_apply_top([['sentryForFrontend', 'sentryForBackend'], 'options', ['dsn', 'profileSampleRate', 'serverName', 'includeLocalVariables', 'proxy', 'keepAlive', 'caCerts']]);
|
||||
_apply_top(['sentryForBackend', 'enableNodeProfiling']);
|
||||
_apply_top(['sentryForFrontend', 'vueIntegration', ['attachProps', 'attachErrorHandler']]);
|
||||
_apply_top(['sentryForFrontend', 'vueIntegration', 'tracingOptions', 'timeout']);
|
||||
_apply_top(['sentryForFrontend', 'browserTracingIntegration', 'routeLabel']);
|
||||
_apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]);
|
||||
_apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]);
|
||||
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]);
|
||||
_apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaDirectory', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]);
|
||||
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'maxBioLength', 'maxRemoteBioLength', 'pidFile', 'filePermissionBits']]);
|
||||
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
|
||||
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword']]);
|
||||
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]);
|
||||
_apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]);
|
||||
_apply_top(['logging', ['verbose']]);
|
||||
_apply_top(['activityLogging', ['enabled', 'preSave', 'maxAge']]);
|
||||
_apply_top(['customHtml', ['head']]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,3 +70,9 @@ https://github.com/sindresorhus/file-type/blob/main/supported.js
|
|||
https://github.com/sindresorhus/file-type/blob/main/core.js
|
||||
https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
|
||||
*/
|
||||
|
||||
export const instanceUnsignedFetchOptions = ['never', 'always', 'essential'] as const;
|
||||
export type InstanceUnsignedFetchOption = (typeof instanceUnsignedFetchOptions)[number];
|
||||
|
||||
export const userUnsignedFetchOptions = ['never', 'always', 'essential', 'staff'] as const;
|
||||
export type UserUnsignedFetchOption = (typeof userUnsignedFetchOptions)[number];
|
||||
|
|
|
|||
|
|
@ -82,6 +82,28 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all email addresses that a abuse report should be sent to.
|
||||
*/
|
||||
@bindThis
|
||||
public async getRecipientEMailAddresses(): Promise<string[]> {
|
||||
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
|
||||
.filter(it => it.isActive && it.userProfile?.emailVerified)
|
||||
.map(it => it.userProfile?.email)
|
||||
.filter(x => x != null),
|
||||
);
|
||||
|
||||
if (this.meta.email) {
|
||||
recipientEMailAddresses.push(this.meta.email);
|
||||
}
|
||||
|
||||
if (this.meta.maintainerEmail) {
|
||||
recipientEMailAddresses.push(this.meta.maintainerEmail);
|
||||
}
|
||||
|
||||
return recipientEMailAddresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailを用いて{@link abuseReports}の内容を管理者各位に通知する.
|
||||
* メールアドレスの送信先は以下の通り.
|
||||
|
|
@ -96,15 +118,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
return;
|
||||
}
|
||||
|
||||
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
|
||||
.filter(it => it.isActive && it.userProfile?.emailVerified)
|
||||
.map(it => it.userProfile?.email)
|
||||
.filter(x => x != null),
|
||||
);
|
||||
|
||||
recipientEMailAddresses.push(
|
||||
...(this.meta.email ? [this.meta.email] : []),
|
||||
);
|
||||
const recipientEMailAddresses = await this.getRecipientEMailAddresses();
|
||||
|
||||
if (recipientEMailAddresses.length <= 0) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ import { bindThis } from '@/decorators.js';
|
|||
import type { AbuseUserReportsRepository, MiAbuseUserReport, MiUser, UsersRepository } from '@/models/_.js';
|
||||
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { IdService } from './IdService.js';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -27,7 +29,7 @@ export class AbuseReportService {
|
|||
private idService: IdService,
|
||||
private abuseReportNotificationService: AbuseReportNotificationService,
|
||||
private queueService: QueueService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private apRendererService: ApRendererService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
|
|
@ -67,11 +69,11 @@ export class AbuseReportService {
|
|||
reports.push(report);
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
trackPromise(Promise.all([
|
||||
this.abuseReportNotificationService.notifyAdminStream(reports),
|
||||
this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReport'),
|
||||
this.abuseReportNotificationService.notifyMail(reports),
|
||||
]);
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -125,18 +127,18 @@ export class AbuseReportService {
|
|||
const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
|
||||
|
||||
if (report.targetUserHost == null) {
|
||||
throw new Error('The target user host is null.');
|
||||
throw new IdentifiableError('0b1ce202-b2c1-4ee4-8af4-2742a51b383d', 'The target user host is null.');
|
||||
}
|
||||
|
||||
if (report.forwarded) {
|
||||
throw new Error('The report has already been forwarded.');
|
||||
throw new IdentifiableError('5c008bdf-f0e8-4154-9f34-804e114516d7', 'The report has already been forwarded.');
|
||||
}
|
||||
|
||||
await this.abuseUserReportsRepository.update(report.id, {
|
||||
forwarded: true,
|
||||
});
|
||||
|
||||
const actor = await this.instanceActorService.getInstanceActor();
|
||||
const actor = await this.systemAccountService.fetch('actor');
|
||||
const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId });
|
||||
|
||||
const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment);
|
||||
|
|
|
|||
|
|
@ -20,10 +20,13 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
|||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ProxyAccountService } from '@/core/ProxyAccountService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { AntennaService } from '@/core/AntennaService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
@Injectable()
|
||||
export class AccountMoveService {
|
||||
|
|
@ -58,12 +61,15 @@ export class AccountMoveService {
|
|||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private proxyAccountService: ProxyAccountService,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private instanceChart: InstanceChart,
|
||||
private relayService: RelayService,
|
||||
private queueService: QueueService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private roleService: RoleService,
|
||||
private antennaService: AntennaService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -91,23 +97,22 @@ export class AccountMoveService {
|
|||
const srcPerson = await this.apRendererService.renderPerson(src);
|
||||
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
|
||||
await this.apDeliverManagerService.deliverToFollowers(src, updateAct);
|
||||
this.relayService.deliverToRelays(src, updateAct);
|
||||
await this.relayService.deliverToRelays(src, updateAct);
|
||||
|
||||
// Deliver Move activity to the followers of the old account
|
||||
const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst));
|
||||
await this.apDeliverManagerService.deliverToFollowers(src, moveAct);
|
||||
await this.relayService.deliverToRelays(src, moveAct);
|
||||
|
||||
// Publish meUpdated event
|
||||
const iObj = await this.userEntityService.pack(src.id, src, { schema: 'MeDetailed', includeSecrets: true });
|
||||
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
|
||||
|
||||
// Unfollow after 24 hours
|
||||
const followings = await this.followingsRepository.findBy({
|
||||
followerId: src.id,
|
||||
});
|
||||
this.queueService.createDelayedUnfollowJob(followings.map(following => ({
|
||||
const followings = await this.cacheService.userFollowingsCache.fetch(src.id);
|
||||
this.queueService.createDelayedUnfollowJob(Array.from(followings.keys()).map(followeeId => ({
|
||||
from: { id: src.id },
|
||||
to: { id: following.followeeId },
|
||||
to: { id: followeeId },
|
||||
})), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24);
|
||||
|
||||
await this.postMoveProcess(src, dst);
|
||||
|
|
@ -123,19 +128,19 @@ export class AccountMoveService {
|
|||
this.copyBlocking(src, dst),
|
||||
this.copyMutings(src, dst),
|
||||
this.deleteScheduledNotes(src),
|
||||
this.copyRoles(src, dst),
|
||||
this.updateLists(src, dst),
|
||||
this.antennaService.onMoveAccount(src, dst),
|
||||
]);
|
||||
} catch {
|
||||
/* skip if any error happens */
|
||||
}
|
||||
|
||||
// follow the new account
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
const followings = await this.followingsRepository.findBy({
|
||||
followeeId: src.id,
|
||||
followerHost: IsNull(), // follower is local
|
||||
followerId: proxy ? Not(proxy.id) : undefined,
|
||||
});
|
||||
const proxy = await this.systemAccountService.fetch('proxy');
|
||||
const followings = await this.cacheService.userFollowersCache.fetch(src.id)
|
||||
.then(fs => Array.from(fs.values())
|
||||
.filter(f => f.followerHost == null && f.followerId !== proxy.id));
|
||||
const followJobs = followings.map(following => ({
|
||||
from: { id: following.followerId },
|
||||
to: { id: dst.id },
|
||||
|
|
@ -220,6 +225,32 @@ export class AccountMoveService {
|
|||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async copyRoles(src: ThinUser, dst: ThinUser): Promise<void> {
|
||||
// Insert new roles with the same values except userId
|
||||
// role service may have cache for roles so retrieve roles from service
|
||||
const [oldRoleAssignments, roles] = await Promise.all([
|
||||
this.roleService.getUserAssigns(src.id),
|
||||
this.roleService.getRoles(),
|
||||
]);
|
||||
|
||||
if (oldRoleAssignments.length === 0) return;
|
||||
|
||||
// No promise all since the only async operation is writing to the database
|
||||
for (const oldRoleAssignment of oldRoleAssignments) {
|
||||
const role = roles.find(x => x.id === oldRoleAssignment.roleId);
|
||||
if (role == null) continue; // Very unlikely however removing role may cause this case
|
||||
if (!role.preserveAssignmentOnMoveAccount) continue;
|
||||
|
||||
try {
|
||||
await this.roleService.assign(dst.id, role.id, oldRoleAssignment.expiresAt);
|
||||
} catch (e) {
|
||||
if (e instanceof RoleService.AlreadyAssignedError) continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update lists while moving accounts.
|
||||
* - No removal of the old account from the lists
|
||||
|
|
@ -269,10 +300,8 @@ export class AccountMoveService {
|
|||
|
||||
// Have the proxy account follow the new account in the same way as UserListService.push
|
||||
if (this.userEntityService.isRemoteUser(dst)) {
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
if (proxy) {
|
||||
this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]);
|
||||
}
|
||||
const proxy = await this.systemAccountService.fetch('proxy');
|
||||
this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -287,9 +316,9 @@ export class AccountMoveService {
|
|||
await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1);
|
||||
|
||||
// Decrease follower counts of local followees by 1.
|
||||
const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id });
|
||||
if (oldFollowings.length > 0) {
|
||||
await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1);
|
||||
const oldFollowings = await this.cacheService.userFollowingsCache.fetch(oldAccount.id);
|
||||
if (oldFollowings.size > 0) {
|
||||
await this.usersRepository.decrement({ id: In(Array.from(oldFollowings.keys())) }, 'followersCount', 1);
|
||||
}
|
||||
|
||||
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
|
||||
|
|
|
|||
|
|
@ -27,15 +27,12 @@ export class AccountUpdateService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async publishToFollowers(userId: MiUser['id']) {
|
||||
const user = await this.usersRepository.findOneBy({ id: userId });
|
||||
if (user == null) throw new Error('user not found');
|
||||
|
||||
public async publishToFollowers(user: MiUser) {
|
||||
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
await this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
await this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,87 +9,7 @@ import type { MiUser } from '@/models/User.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
||||
export const ACHIEVEMENT_TYPES = [
|
||||
'notes1',
|
||||
'notes10',
|
||||
'notes100',
|
||||
'notes500',
|
||||
'notes1000',
|
||||
'notes5000',
|
||||
'notes10000',
|
||||
'notes20000',
|
||||
'notes30000',
|
||||
'notes40000',
|
||||
'notes50000',
|
||||
'notes60000',
|
||||
'notes70000',
|
||||
'notes80000',
|
||||
'notes90000',
|
||||
'notes100000',
|
||||
'login3',
|
||||
'login7',
|
||||
'login15',
|
||||
'login30',
|
||||
'login60',
|
||||
'login100',
|
||||
'login200',
|
||||
'login300',
|
||||
'login400',
|
||||
'login500',
|
||||
'login600',
|
||||
'login700',
|
||||
'login800',
|
||||
'login900',
|
||||
'login1000',
|
||||
'passedSinceAccountCreated1',
|
||||
'passedSinceAccountCreated2',
|
||||
'passedSinceAccountCreated3',
|
||||
'loggedInOnBirthday',
|
||||
'loggedInOnNewYearsDay',
|
||||
'noteClipped1',
|
||||
'noteFavorited1',
|
||||
'myNoteFavorited1',
|
||||
'profileFilled',
|
||||
'markedAsCat',
|
||||
'following1',
|
||||
'following10',
|
||||
'following50',
|
||||
'following100',
|
||||
'following300',
|
||||
'followers1',
|
||||
'followers10',
|
||||
'followers50',
|
||||
'followers100',
|
||||
'followers300',
|
||||
'followers500',
|
||||
'followers1000',
|
||||
'collectAchievements30',
|
||||
'viewAchievements3min',
|
||||
'iLoveMisskey',
|
||||
'foundTreasure',
|
||||
'client30min',
|
||||
'client60min',
|
||||
'noteDeletedWithin1min',
|
||||
'postedAtLateNight',
|
||||
'postedAt0min0sec',
|
||||
'selfQuote',
|
||||
'htl20npm',
|
||||
'viewInstanceChart',
|
||||
'outputHelloWorldOnScratchpad',
|
||||
'open3windows',
|
||||
'driveFolderCircularReference',
|
||||
'reactWithoutRead',
|
||||
'clickedClickHere',
|
||||
'justPlainLucky',
|
||||
'setNameToSyuilo',
|
||||
'cookieClicked',
|
||||
'brainDiver',
|
||||
'smashTestNotificationButton',
|
||||
'tutorialCompleted',
|
||||
'bubbleGameExplodingHead',
|
||||
'bubbleGameDoubleExplodingHead',
|
||||
] as const;
|
||||
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
|
||||
|
||||
@Injectable()
|
||||
export class AchievementService {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
@Injectable()
|
||||
export class AnnouncementService {
|
||||
|
|
@ -31,6 +32,7 @@ export class AnnouncementService {
|
|||
private globalEventService: GlobalEventService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private announcementEntityService: AnnouncementEntityService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ export class AnnouncementService {
|
|||
const readsQuery = this.announcementReadsRepository.createQueryBuilder('read')
|
||||
.select('read.announcementId')
|
||||
.where('read.userId = :userId', { userId: user.id });
|
||||
const roles = await this.roleService.getUserRoles(user);
|
||||
|
||||
const q = this.announcementsRepository.createQueryBuilder('announcement')
|
||||
.where('announcement.isActive = true')
|
||||
|
|
@ -58,6 +61,10 @@ export class AnnouncementService {
|
|||
qb.orWhere('announcement.forExistingUsers = false');
|
||||
qb.orWhere('announcement.id > :userId', { userId: user.id });
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('announcement.forRoles && :roles', { roles: roles.map((r) => r.id) });
|
||||
qb.orWhere('announcement.forRoles = \'{}\'');
|
||||
}))
|
||||
.andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(readsQuery.getParameters());
|
||||
|
|
@ -76,8 +83,10 @@ export class AnnouncementService {
|
|||
icon: values.icon,
|
||||
display: values.display,
|
||||
forExistingUsers: values.forExistingUsers,
|
||||
forRoles: values.forRoles,
|
||||
silence: values.silence,
|
||||
needConfirmationToRead: values.needConfirmationToRead,
|
||||
confetti: values.confetti,
|
||||
userId: values.userId,
|
||||
});
|
||||
|
||||
|
|
@ -128,8 +137,10 @@ export class AnnouncementService {
|
|||
display: values.display,
|
||||
icon: values.icon,
|
||||
forExistingUsers: values.forExistingUsers,
|
||||
forRoles: values.forRoles,
|
||||
silence: values.silence,
|
||||
needConfirmationToRead: values.needConfirmationToRead,
|
||||
confetti: values.confetti,
|
||||
isActive: values.isActive,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,18 +5,20 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { In } from 'typeorm';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
|
||||
import type { MiAntenna } from '@/models/Antenna.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { CacheService } from './CacheService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -37,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
private utilityService: UtilityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
|
|
@ -111,8 +114,7 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
|
||||
if (note.visibility === 'specified') return false;
|
||||
if (note.visibility === 'followers') return false;
|
||||
if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false;
|
||||
|
||||
if (antenna.excludeBots && noteUser.isBot) return false;
|
||||
|
||||
|
|
@ -120,6 +122,19 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
|
||||
if (!antenna.withReplies && note.replyId != null) return false;
|
||||
|
||||
if (note.visibility === 'specified') {
|
||||
if (note.userId !== antenna.userId) {
|
||||
if (note.visibleUserIds == null) return false;
|
||||
if (!note.visibleUserIds.includes(antenna.userId)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (note.visibility === 'followers') {
|
||||
const followings = await this.cacheService.userFollowingsCache.fetch(antenna.userId);
|
||||
const isFollowing = followings.has(note.userId);
|
||||
if (!isFollowing && antenna.userId !== note.userId) return false;
|
||||
}
|
||||
|
||||
if (antenna.src === 'home') {
|
||||
// TODO
|
||||
} else if (antenna.src === 'list') {
|
||||
|
|
@ -210,6 +225,41 @@ export class AntennaService implements OnApplicationShutdown {
|
|||
return this.antennas;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async onMoveAccount(src: MiUser, dst: MiUser): Promise<void> {
|
||||
// There is a possibility for users to add the srcUser to their antennas, but it's low, so we don't check it.
|
||||
|
||||
// Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list
|
||||
const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase();
|
||||
const antennasToMigrate = (await this.getAntennas()).filter(antenna => {
|
||||
return antenna.users.some(user => {
|
||||
const { username, host } = Acct.parse(user);
|
||||
return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct;
|
||||
});
|
||||
});
|
||||
|
||||
if (antennasToMigrate.length === 0) return;
|
||||
|
||||
const antennaIds = antennasToMigrate.map(x => x.id);
|
||||
|
||||
// Update the antennas by appending dst users acct to the users list
|
||||
const dstUserAcct = '@' + Acct.toString({ username: dst.username, host: dst.host });
|
||||
|
||||
await this.antennasRepository.createQueryBuilder('antenna')
|
||||
.update()
|
||||
.set({
|
||||
users: () => 'array_append(antenna.users, :dstUserAcct)',
|
||||
})
|
||||
.where('antenna.id IN (:...antennaIds)', { antennaIds })
|
||||
.setParameters({ dstUserAcct })
|
||||
.execute();
|
||||
|
||||
// announce update to event
|
||||
for (const newAntenna of await this.antennasRepository.findBy({ id: In(antennaIds) })) {
|
||||
this.globalEventService.publishInternalEvent('antennaUpdated', newAntenna);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onRedisMessage);
|
||||
|
|
|
|||
|
|
@ -139,6 +139,24 @@ export class ApLogService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all logged inbox activities from a user or users
|
||||
* @param userIds IDs of the users to delete
|
||||
*/
|
||||
public async deleteInboxLogs(userIds: string | string[]): Promise<number> {
|
||||
if (Array.isArray(userIds)) {
|
||||
const logsDeleted = await this.apInboxLogsRepository.delete({
|
||||
authUserId: In(userIds),
|
||||
});
|
||||
return logsDeleted.affected ?? 0;
|
||||
} else {
|
||||
const logsDeleted = await this.apInboxLogsRepository.delete({
|
||||
authUserId: userIds,
|
||||
});
|
||||
return logsDeleted.affected ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all expired AP logs and garbage-collects the AP context cache.
|
||||
* Returns the total number of deleted rows.
|
||||
|
|
|
|||
101
packages/backend/src/core/BunnyService.ts
Normal file
101
packages/backend/src/core/BunnyService.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: marie and sharkey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as https from 'node:https';
|
||||
import * as fs from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { MiMeta } from '@/models/Meta.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import Logger from '@/logger.js';
|
||||
|
||||
@Injectable()
|
||||
export class BunnyService {
|
||||
private bunnyCdnLogger: Logger;
|
||||
|
||||
constructor(
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
this.bunnyCdnLogger = new Logger('bunnycdn', 'blue');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getBunnyInfo(meta: MiMeta) {
|
||||
if (!meta.objectStorageEndpoint || !meta.objectStorageBucket || !meta.objectStorageSecretKey) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140bf90', 'Failed to use BunnyCDN, One of the required fields is missing.');
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: meta.objectStorageEndpoint,
|
||||
/*
|
||||
The way S3 works is that the Secret Key is essentially the password for the API but Bunny calls their password AccessKey so we call it accessKey here.
|
||||
Bunny also doesn't specify a username/s3 access key when doing HTTP API requests so we end up not using our Access Key field from the form.
|
||||
*/
|
||||
accessKey: meta.objectStorageSecretKey,
|
||||
zone: meta.objectStorageBucket,
|
||||
fullUrl: `https://${meta.objectStorageEndpoint}/${meta.objectStorageBucket}`,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public usingBunnyCDN(meta: MiMeta) {
|
||||
return meta.objectStorageEndpoint && meta.objectStorageEndpoint.endsWith('bunnycdn.com');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async upload(meta: MiMeta, path: string, input: fs.ReadStream | Buffer) {
|
||||
const client = this.getBunnyInfo(meta);
|
||||
|
||||
// Required to convert the buffer from webpublic and thumbnail to a ReadableStream for PUT
|
||||
const data = Buffer.isBuffer(input) ? Readable.from(input) : input;
|
||||
|
||||
const agent = this.httpRequestService.getAgentByUrl(new URL(`${client.fullUrl}/${path}`), !meta.objectStorageUseProxy, true);
|
||||
|
||||
// Seperation of path and host/domain is required here
|
||||
const options = {
|
||||
method: 'PUT',
|
||||
host: client.endpoint,
|
||||
path: `/${client.zone}/${path}`,
|
||||
headers: {
|
||||
AccessKey: client.accessKey,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
agent: agent,
|
||||
};
|
||||
|
||||
const req = https.request(options);
|
||||
|
||||
// Log and return if BunnyCDN detects wrong data (return is used to prevent console spam as this event occurs multiple times)
|
||||
req.on('response', (res) => {
|
||||
if (res.statusCode === 401) {
|
||||
this.bunnyCdnLogger.error('Invalid AccessKey or region hostname');
|
||||
data.destroy();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
this.bunnyCdnLogger.error('Unhandled error', error);
|
||||
data.destroy();
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140bf91', 'An error has occurred while connecting to BunnyCDN', true, error);
|
||||
});
|
||||
|
||||
data.pipe(req).on('finish', () => {
|
||||
data.destroy();
|
||||
});
|
||||
|
||||
// wait till stream gets destroyed upon finish of piping to prevent the UI from showing the upload as success way too early
|
||||
await finished(data);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public delete(meta: MiMeta, file: string) {
|
||||
const client = this.getBunnyInfo(meta);
|
||||
return this.httpRequestService.send(`${client.fullUrl}/${file}`, { method: 'DELETE', headers: { AccessKey: client.accessKey } });
|
||||
}
|
||||
}
|
||||
|
|
@ -5,27 +5,54 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing, NoteThreadMutingsRepository } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
export interface FollowStats {
|
||||
localFollowing: number;
|
||||
localFollowers: number;
|
||||
remoteFollowing: number;
|
||||
remoteFollowers: number;
|
||||
}
|
||||
|
||||
export interface CachedTranslation {
|
||||
sourceLang: string | undefined;
|
||||
text: string | undefined;
|
||||
}
|
||||
|
||||
export interface CachedTranslationEntity {
|
||||
l?: string;
|
||||
t?: string;
|
||||
u?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CacheService implements OnApplicationShutdown {
|
||||
public userByIdCache: MemoryKVCache<MiUser>;
|
||||
public localUserByNativeTokenCache: MemoryKVCache<MiLocalUser | null>;
|
||||
public localUserByIdCache: MemoryKVCache<MiLocalUser>;
|
||||
public uriPersonCache: MemoryKVCache<MiUser | null>;
|
||||
public userProfileCache: RedisKVCache<MiUserProfile>;
|
||||
public userMutingsCache: RedisKVCache<Set<string>>;
|
||||
public userBlockingCache: RedisKVCache<Set<string>>;
|
||||
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||
public renoteMutingsCache: RedisKVCache<Set<string>>;
|
||||
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
|
||||
public userProfileCache: QuantumKVCache<MiUserProfile>;
|
||||
public userMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public userBlockingCache: QuantumKVCache<Set<string>>;
|
||||
public userBlockedCache: QuantumKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||
public renoteMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public threadMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public noteMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public userFollowingsCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public userFollowersCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public hibernatedUserCache: QuantumKVCache<boolean>;
|
||||
protected userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
|
||||
protected translationsCache: RedisKVCache<CachedTranslationEntity>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
|
|
@ -52,7 +79,11 @@ export class CacheService implements OnApplicationShutdown {
|
|||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private readonly noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
|
|
@ -61,76 +92,202 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m
|
||||
this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m
|
||||
|
||||
this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', {
|
||||
this.userProfileCache = new QuantumKVCache(this.internalEventService, 'userProfile', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮
|
||||
bulkFetcher: userIds => this.userProfilesRepository.findBy({ userId: In(userIds) }).then(ps => ps.map(p => [p.userId, p])),
|
||||
});
|
||||
|
||||
this.userMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userMutings', {
|
||||
this.userMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
bulkFetcher: muterIds => this.mutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."muterId"', 'muterId')
|
||||
.addSelect('array_agg("muting"."muteeId")', 'muteeIds')
|
||||
.where({ muterId: In(muterIds) })
|
||||
.groupBy('muting.muterId')
|
||||
.getRawMany<{ muterId: string, muteeIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
|
||||
});
|
||||
|
||||
this.userBlockingCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocking', {
|
||||
this.userBlockingCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userBlocking', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
bulkFetcher: blockerIds => this.blockingsRepository
|
||||
.createQueryBuilder('blocking')
|
||||
.select('"blocking"."blockerId"', 'blockerId')
|
||||
.addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds')
|
||||
.where({ blockerId: In(blockerIds) })
|
||||
.groupBy('blocking.blockerId')
|
||||
.getRawMany<{ blockerId: string, blockeeIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.blockerId, new Set(m.blockeeIds)])),
|
||||
});
|
||||
|
||||
this.userBlockedCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocked', {
|
||||
this.userBlockedCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userBlocked', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
bulkFetcher: blockeeIds => this.blockingsRepository
|
||||
.createQueryBuilder('blocking')
|
||||
.select('"blocking"."blockeeId"', 'blockeeId')
|
||||
.addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds')
|
||||
.where({ blockeeId: In(blockeeIds) })
|
||||
.groupBy('blocking.blockeeId')
|
||||
.getRawMany<{ blockeeId: string, blockerIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.blockeeId, new Set(m.blockerIds)])),
|
||||
});
|
||||
|
||||
this.renoteMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'renoteMutings', {
|
||||
this.renoteMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'renoteMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
bulkFetcher: muterIds => this.renoteMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."muterId"', 'muterId')
|
||||
.addSelect('array_agg("muting"."muteeId")', 'muteeIds')
|
||||
.where({ muterId: In(muterIds) })
|
||||
.groupBy('muting.muterId')
|
||||
.getRawMany<{ muterId: string, muteeIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
|
||||
});
|
||||
|
||||
this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>(this.redisClient, 'userFollowings', {
|
||||
this.threadMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'threadMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => {
|
||||
const obj: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
||||
for (const x of xs) {
|
||||
obj[x.followeeId] = { withReplies: x.withReplies };
|
||||
fetcher: muterId => this.noteThreadMutingsRepository
|
||||
.find({ where: { userId: muterId, isPostMute: false }, select: { threadId: true } })
|
||||
.then(ms => new Set(ms.map(m => m.threadId))),
|
||||
bulkFetcher: muterIds => this.noteThreadMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."userId"', 'userId')
|
||||
.addSelect('array_agg("muting"."threadId")', 'threadIds')
|
||||
.groupBy('"muting"."userId"')
|
||||
.where({ userId: In(muterIds), isPostMute: false })
|
||||
.getRawMany<{ userId: string, threadIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
|
||||
});
|
||||
|
||||
this.noteMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'noteMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: muterId => this.noteThreadMutingsRepository
|
||||
.find({ where: { userId: muterId, isPostMute: true }, select: { threadId: true } })
|
||||
.then(ms => new Set(ms.map(m => m.threadId))),
|
||||
bulkFetcher: muterIds => this.noteThreadMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."userId"', 'userId')
|
||||
.addSelect('array_agg("muting"."threadId")', 'threadIds')
|
||||
.groupBy('"muting"."userId"')
|
||||
.where({ userId: In(muterIds), isPostMute: true })
|
||||
.getRawMany<{ userId: string, threadIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
|
||||
});
|
||||
|
||||
this.userFollowingsCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.followingsRepository.findBy({ followerId: key }).then(xs => new Map(xs.map(f => [f.followeeId, f]))),
|
||||
bulkFetcher: followerIds => this.followingsRepository
|
||||
.findBy({ followerId: In(followerIds) })
|
||||
.then(fs => fs
|
||||
.reduce((groups, f) => {
|
||||
let group = groups.get(f.followerId);
|
||||
if (!group) {
|
||||
group = new Map();
|
||||
groups.set(f.followerId, group);
|
||||
}
|
||||
group.set(f.followeeId, f);
|
||||
return groups;
|
||||
}, new Map<string, Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>)),
|
||||
});
|
||||
|
||||
this.userFollowersCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowers', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: followeeId => this.followingsRepository.findBy({ followeeId: followeeId }).then(xs => new Map(xs.map(x => [x.followerId, x]))),
|
||||
bulkFetcher: followeeIds => this.followingsRepository
|
||||
.findBy({ followeeId: In(followeeIds) })
|
||||
.then(fs => fs
|
||||
.reduce((groups, f) => {
|
||||
let group = groups.get(f.followeeId);
|
||||
if (!group) {
|
||||
group = new Map();
|
||||
groups.set(f.followeeId, group);
|
||||
}
|
||||
group.set(f.followerId, f);
|
||||
return groups;
|
||||
}, new Map<string, Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>)),
|
||||
});
|
||||
|
||||
this.hibernatedUserCache = new QuantumKVCache<boolean>(this.internalEventService, 'hibernatedUsers', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: async userId => {
|
||||
const { isHibernated } = await this.usersRepository.findOneOrFail({
|
||||
where: { id: userId },
|
||||
select: { isHibernated: true },
|
||||
});
|
||||
return isHibernated;
|
||||
},
|
||||
bulkFetcher: async userIds => {
|
||||
const results = await this.usersRepository.find({
|
||||
where: { id: In(userIds) },
|
||||
select: { id: true, isHibernated: true },
|
||||
});
|
||||
return results.map(({ id, isHibernated }) => [id, isHibernated]);
|
||||
},
|
||||
onChanged: async userIds => {
|
||||
// We only update local copies since each process will get this event, but we can have user objects in multiple different caches.
|
||||
// Before doing anything else we must "find" all the objects to update.
|
||||
const userObjects = new Map<string, MiUser[]>();
|
||||
const toUpdate: string[] = [];
|
||||
for (const uid of userIds) {
|
||||
const toAdd: MiUser[] = [];
|
||||
|
||||
const localUserById = this.localUserByIdCache.get(uid);
|
||||
if (localUserById) toAdd.push(localUserById);
|
||||
|
||||
const userById = this.userByIdCache.get(uid);
|
||||
if (userById) toAdd.push(userById);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
toUpdate.push(uid);
|
||||
userObjects.set(uid, toAdd);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
|
||||
// In many cases, we won't have to do anything.
|
||||
// Skipping the DB fetch ensures that this remains a single-step synchronous process.
|
||||
if (toUpdate.length > 0) {
|
||||
const hibernations = await this.usersRepository.find({ where: { id: In(toUpdate) }, select: { id: true, isHibernated: true } });
|
||||
for (const { id, isHibernated } of hibernations) {
|
||||
const users = userObjects.get(id);
|
||||
if (users) {
|
||||
for (const u of users) {
|
||||
u.isHibernated = isHibernated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.translationsCache = new RedisKVCache<CachedTranslationEntity>(this.redisClient, 'translations', {
|
||||
lifetime: 1000 * 60 * 60 * 24 * 7, // 1 week,
|
||||
memoryCacheLifetime: 1000 * 60, // 1 minute
|
||||
});
|
||||
|
||||
// NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
this.internalEventService.on('userChangeSuspendedState', this.onUserEvent);
|
||||
this.internalEventService.on('userChangeDeletedState', this.onUserEvent);
|
||||
this.internalEventService.on('remoteUserUpdated', this.onUserEvent);
|
||||
this.internalEventService.on('localUserUpdated', this.onUserEvent);
|
||||
this.internalEventService.on('userChangeSuspendedState', this.onUserEvent);
|
||||
this.internalEventService.on('userTokenRegenerated', this.onTokenEvent);
|
||||
this.internalEventService.on('follow', this.onFollowEvent);
|
||||
this.internalEventService.on('unfollow', this.onFollowEvent);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'userChangeSuspendedState':
|
||||
case 'userChangeDeletedState':
|
||||
case 'remoteUserUpdated':
|
||||
case 'localUserUpdated': {
|
||||
private async onUserEvent<E extends 'userChangeSuspendedState' | 'userChangeDeletedState' | 'remoteUserUpdated' | 'localUserUpdated'>(body: InternalEventTypes[E], _: E, isLocal: boolean): Promise<void> {
|
||||
{
|
||||
{
|
||||
{
|
||||
const user = await this.usersRepository.findOneBy({ id: body.id });
|
||||
if (user == null) {
|
||||
this.userByIdCache.delete(body.id);
|
||||
|
|
@ -140,6 +297,20 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.uriPersonCache.delete(k);
|
||||
}
|
||||
}
|
||||
if (isLocal) {
|
||||
await Promise.all([
|
||||
this.userProfileCache.delete(body.id),
|
||||
this.userMutingsCache.delete(body.id),
|
||||
this.userBlockingCache.delete(body.id),
|
||||
this.userBlockedCache.delete(body.id),
|
||||
this.renoteMutingsCache.delete(body.id),
|
||||
this.userFollowingsCache.delete(body.id),
|
||||
this.userFollowersCache.delete(body.id),
|
||||
this.hibernatedUserCache.delete(body.id),
|
||||
this.threadMutingsCache.delete(body.id),
|
||||
this.noteMutingsCache.delete(body.id),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
this.userByIdCache.set(user.id, user);
|
||||
for (const [k, v] of this.uriPersonCache.entries) {
|
||||
|
|
@ -152,24 +323,54 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.localUserByIdCache.set(user.id, user);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'userTokenRegenerated': {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onTokenEvent<E extends 'userTokenRegenerated'>(body: InternalEventTypes[E]): Promise<void> {
|
||||
{
|
||||
{
|
||||
{
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as MiLocalUser;
|
||||
this.localUserByNativeTokenCache.delete(body.oldToken);
|
||||
this.localUserByNativeTokenCache.set(body.newToken, user);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onFollowEvent<E extends 'follow' | 'unfollow'>(body: InternalEventTypes[E], type: E): Promise<void> {
|
||||
{
|
||||
switch (type) {
|
||||
case 'follow': {
|
||||
const follower = this.userByIdCache.get(body.followerId);
|
||||
if (follower) follower.followingCount++;
|
||||
const followee = this.userByIdCache.get(body.followeeId);
|
||||
if (followee) followee.followersCount++;
|
||||
this.userFollowingsCache.delete(body.followerId);
|
||||
await Promise.all([
|
||||
this.userFollowingsCache.delete(body.followerId),
|
||||
this.userFollowersCache.delete(body.followeeId),
|
||||
]);
|
||||
this.userFollowStatsCache.delete(body.followerId);
|
||||
this.userFollowStatsCache.delete(body.followeeId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
case 'unfollow': {
|
||||
const follower = this.userByIdCache.get(body.followerId);
|
||||
if (follower) follower.followingCount--;
|
||||
const followee = this.userByIdCache.get(body.followeeId);
|
||||
if (followee) followee.followersCount--;
|
||||
await Promise.all([
|
||||
this.userFollowingsCache.delete(body.followerId),
|
||||
this.userFollowersCache.delete(body.followeeId),
|
||||
]);
|
||||
this.userFollowStatsCache.delete(body.followerId);
|
||||
this.userFollowStatsCache.delete(body.followeeId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -179,9 +380,212 @@ export class CacheService implements OnApplicationShutdown {
|
|||
return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId }));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findLocalUserById(userId: MiUser['id']): Promise<MiLocalUser | null> {
|
||||
return await this.localUserByIdCache.fetchMaybe(userId, async () => {
|
||||
return await this.usersRepository.findOneBy({ id: userId, host: IsNull() }) as MiLocalUser | null ?? undefined;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findRemoteUserById(userId: MiUser['id']): Promise<MiRemoteUser | null> {
|
||||
const user = await this.findUserById(userId);
|
||||
|
||||
if (user.host == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user as MiRemoteUser;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public findOptionalUserById(userId: MiUser['id']) {
|
||||
return this.userByIdCache.fetchMaybe(userId, async () => await this.usersRepository.findOneBy({ id: userId }) ?? undefined);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getFollowStats(userId: MiUser['id']): Promise<FollowStats> {
|
||||
return await this.userFollowStatsCache.fetch(userId, async () => {
|
||||
const stats = {
|
||||
localFollowing: 0,
|
||||
localFollowers: 0,
|
||||
remoteFollowing: 0,
|
||||
remoteFollowers: 0,
|
||||
};
|
||||
|
||||
const followings = await this.followingsRepository.findBy([
|
||||
{ followerId: userId },
|
||||
{ followeeId: userId },
|
||||
]);
|
||||
|
||||
for (const following of followings) {
|
||||
if (following.followerId === userId) {
|
||||
// increment following; user is a follower of someone else
|
||||
if (following.followeeHost == null) {
|
||||
stats.localFollowing++;
|
||||
} else {
|
||||
stats.remoteFollowing++;
|
||||
}
|
||||
} else if (following.followeeId === userId) {
|
||||
// increment followers; user is followed by someone else
|
||||
if (following.followerHost == null) {
|
||||
stats.localFollowers++;
|
||||
} else {
|
||||
stats.remoteFollowers++;
|
||||
}
|
||||
} else {
|
||||
// Should never happen
|
||||
}
|
||||
}
|
||||
|
||||
// Infer remote-remote followers heuristically, since we don't track that info directly.
|
||||
const user = await this.findUserById(userId);
|
||||
if (user.host !== null) {
|
||||
stats.remoteFollowing = Math.max(0, user.followingCount - stats.localFollowing);
|
||||
stats.remoteFollowers = Math.max(0, user.followersCount - stats.localFollowers);
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getCachedTranslation(note: MiNote, targetLang: string): Promise<CachedTranslation | null> {
|
||||
const cacheKey = `${note.id}@${targetLang}`;
|
||||
|
||||
// Use cached translation, if present and up-to-date
|
||||
const cached = await this.translationsCache.get(cacheKey);
|
||||
if (cached && cached.u === note.updatedAt?.valueOf()) {
|
||||
return {
|
||||
sourceLang: cached.l,
|
||||
text: cached.t,
|
||||
};
|
||||
}
|
||||
|
||||
// No cache entry :(
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async setCachedTranslation(note: MiNote, targetLang: string, translation: CachedTranslation): Promise<void> {
|
||||
const cacheKey = `${note.id}@${targetLang}`;
|
||||
|
||||
await this.translationsCache.set(cacheKey, {
|
||||
l: translation.sourceLang,
|
||||
t: translation.text,
|
||||
u: note.updatedAt?.valueOf(),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getUsers(userIds: Iterable<string>): Promise<Map<string, MiUser>> {
|
||||
const users = new Map<string, MiUser>;
|
||||
|
||||
const toFetch: string[] = [];
|
||||
for (const userId of userIds) {
|
||||
const fromCache = this.userByIdCache.get(userId);
|
||||
if (fromCache) {
|
||||
users.set(userId, fromCache);
|
||||
} else {
|
||||
toFetch.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length > 0) {
|
||||
const fetched = await this.usersRepository.findBy({
|
||||
id: In(toFetch),
|
||||
});
|
||||
|
||||
for (const user of fetched) {
|
||||
users.set(user.id, user);
|
||||
this.userByIdCache.set(user.id, user);
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isFollowing(follower: string | { id: string }, followee: string | { id: string }): Promise<boolean> {
|
||||
const followerId = typeof(follower) === 'string' ? follower : follower.id;
|
||||
const followeeId = typeof(followee) === 'string' ? followee : followee.id;
|
||||
|
||||
// This lets us use whichever one is in memory, falling back to DB fetch via userFollowingsCache.
|
||||
return this.userFollowersCache.get(followeeId)?.has(followerId)
|
||||
?? (await this.userFollowingsCache.fetch(followerId)).has(followeeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all hibernated followers.
|
||||
*/
|
||||
@bindThis
|
||||
public async getHibernatedFollowers(followeeId: string): Promise<MiFollowing[]> {
|
||||
const followers = await this.getFollowersWithHibernation(followeeId);
|
||||
return followers.filter(f => f.isFollowerHibernated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all non-hibernated followers.
|
||||
*/
|
||||
@bindThis
|
||||
public async getNonHibernatedFollowers(followeeId: string): Promise<MiFollowing[]> {
|
||||
const followers = await this.getFollowersWithHibernation(followeeId);
|
||||
return followers.filter(f => !f.isFollowerHibernated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns follower relations with populated isFollowerHibernated.
|
||||
* If you don't need this field, then please use userFollowersCache directly for reduced overhead.
|
||||
*/
|
||||
@bindThis
|
||||
public async getFollowersWithHibernation(followeeId: string): Promise<MiFollowing[]> {
|
||||
const followers = await this.userFollowersCache.fetch(followeeId);
|
||||
const hibernations = await this.hibernatedUserCache.fetchMany(followers.keys()).then(fs => fs.reduce((map, f) => {
|
||||
map.set(f[0], f[1]);
|
||||
return map;
|
||||
}, new Map<string, boolean>));
|
||||
return Array.from(followers.values()).map(following => ({
|
||||
...following,
|
||||
isFollowerHibernated: hibernations.get(following.followerId) ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes follower and following relations for the given user.
|
||||
*/
|
||||
@bindThis
|
||||
public async refreshFollowRelationsFor(userId: string): Promise<void> {
|
||||
const followings = await this.userFollowingsCache.refresh(userId);
|
||||
const followees = Array.from(followings.values()).map(f => f.followeeId);
|
||||
await this.userFollowersCache.deleteMany(followees);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public clear(): void {
|
||||
this.userByIdCache.clear();
|
||||
this.localUserByNativeTokenCache.clear();
|
||||
this.localUserByIdCache.clear();
|
||||
this.uriPersonCache.clear();
|
||||
this.userProfileCache.clear();
|
||||
this.userMutingsCache.clear();
|
||||
this.userBlockingCache.clear();
|
||||
this.userBlockedCache.clear();
|
||||
this.renoteMutingsCache.clear();
|
||||
this.userFollowingsCache.clear();
|
||||
this.userFollowStatsCache.clear();
|
||||
this.translationsCache.clear();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
this.internalEventService.off('userChangeSuspendedState', this.onUserEvent);
|
||||
this.internalEventService.off('userChangeDeletedState', this.onUserEvent);
|
||||
this.internalEventService.off('remoteUserUpdated', this.onUserEvent);
|
||||
this.internalEventService.off('localUserUpdated', this.onUserEvent);
|
||||
this.internalEventService.off('userChangeSuspendedState', this.onUserEvent);
|
||||
this.internalEventService.off('userTokenRegenerated', this.onTokenEvent);
|
||||
this.internalEventService.off('follow', this.onFollowEvent);
|
||||
this.internalEventService.off('unfollow', this.onFollowEvent);
|
||||
this.userByIdCache.dispose();
|
||||
this.localUserByNativeTokenCache.dispose();
|
||||
this.localUserByIdCache.dispose();
|
||||
|
|
@ -191,7 +595,11 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.userBlockingCache.dispose();
|
||||
this.userBlockedCache.dispose();
|
||||
this.renoteMutingsCache.dispose();
|
||||
this.threadMutingsCache.dispose();
|
||||
this.noteMutingsCache.dispose();
|
||||
this.userFollowingsCache.dispose();
|
||||
this.userFollowersCache.dispose();
|
||||
this.hibernatedUserCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { bindThis } from '@/decorators.js';
|
|||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { LoggerService } from './LoggerService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { CaptchaError } from '@/misc/captcha-error.js';
|
||||
|
||||
export { CaptchaError } from '@/misc/captcha-error.js';
|
||||
|
||||
export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'fc', 'testcaptcha'] as const;
|
||||
export type CaptchaProvider = typeof supportedCaptchaProviders[number];
|
||||
|
|
@ -47,27 +50,15 @@ export type CaptchaSetting = {
|
|||
siteKey: string | null;
|
||||
secretKey: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
export class CaptchaError extends Error {
|
||||
public readonly code: CaptchaErrorCode;
|
||||
public readonly cause?: unknown;
|
||||
|
||||
constructor(code: CaptchaErrorCode, message: string, cause?: unknown) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.cause = cause;
|
||||
this.name = 'CaptchaError';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export type CaptchaSaveSuccess = {
|
||||
success: true;
|
||||
}
|
||||
};
|
||||
export type CaptchaSaveFailure = {
|
||||
success: false;
|
||||
error: CaptchaError;
|
||||
}
|
||||
};
|
||||
export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure;
|
||||
|
||||
type CaptchaResponse = {
|
||||
|
|
@ -117,7 +108,7 @@ export class CaptchaService {
|
|||
}
|
||||
|
||||
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`);
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`, err);
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
|
|
@ -133,7 +124,7 @@ export class CaptchaService {
|
|||
}
|
||||
|
||||
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`);
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`, err);
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
|
|
@ -209,7 +200,7 @@ export class CaptchaService {
|
|||
}
|
||||
|
||||
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`);
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`, err);
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
|
|
@ -375,7 +366,7 @@ export class CaptchaService {
|
|||
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'frc-failed: secret and captureResult are required');
|
||||
}
|
||||
|
||||
await this.verifyFriendlyCaptcha(params.captchaResult, params.captchaResult);
|
||||
await this.verifyFriendlyCaptcha(params.secret, params.captchaResult);
|
||||
await this.updateMeta(provider, params);
|
||||
},
|
||||
}[provider];
|
||||
|
|
@ -386,7 +377,7 @@ export class CaptchaService {
|
|||
this.logger.info(err);
|
||||
const error = err instanceof CaptchaError
|
||||
? err
|
||||
: new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`);
|
||||
: new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`, err);
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
|
|
|
|||
|
|
@ -9,14 +9,15 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { ChannelFollowingsRepository } from '@/models/_.js';
|
||||
import { MiChannel } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { GlobalEvents, GlobalEventService, InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||
import { InternalEventService } from './InternalEventService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelFollowingService implements OnModuleInit {
|
||||
public userFollowingChannelsCache: RedisKVCache<Set<string>>;
|
||||
public userFollowingChannelsCache: QuantumKVCache<Set<string>>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
|
|
@ -27,19 +28,18 @@ export class ChannelFollowingService implements OnModuleInit {
|
|||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
) {
|
||||
this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', {
|
||||
this.userFollowingChannelsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userFollowingChannels', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.channelFollowingsRepository.find({
|
||||
where: { followerId: key },
|
||||
select: ['followeeId'],
|
||||
}).then(xs => new Set(xs.map(x => x.followeeId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
});
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
this.internalEventService.on('followChannel', this.onMessage);
|
||||
this.internalEventService.on('unfollowChannel', this.onMessage);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
|
|
@ -79,18 +79,15 @@ export class ChannelFollowingService implements OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
private async onMessage<E extends 'followChannel' | 'unfollowChannel'>(body: InternalEventTypes[E], type: E): Promise<void> {
|
||||
{
|
||||
switch (type) {
|
||||
case 'followChannel': {
|
||||
this.userFollowingChannelsCache.refresh(body.userId);
|
||||
await this.userFollowingChannelsCache.delete(body.userId);
|
||||
break;
|
||||
}
|
||||
case 'unfollowChannel': {
|
||||
this.userFollowingChannelsCache.delete(body.userId);
|
||||
await this.userFollowingChannelsCache.delete(body.userId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +96,8 @@ export class ChannelFollowingService implements OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.internalEventService.off('followChannel', this.onMessage);
|
||||
this.internalEventService.off('unfollowChannel', this.onMessage);
|
||||
this.userFollowingChannelsCache.dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
925
packages/backend/src/core/ChatService.ts
Normal file
925
packages/backend/src/core/ChatService.ts
Normal file
|
|
@ -0,0 +1,925 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { PushNotificationService } from '@/core/PushNotificationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomInvitationsRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiChatRoomMembership, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { emojiRegex } from '@/misc/emoji-regex.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
||||
const MAX_ROOM_MEMBERS = 30;
|
||||
const MAX_REACTIONS_PER_MESSAGE = 100;
|
||||
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
|
||||
|
||||
// TODO: ReactionServiceのやつと共通化
|
||||
function normalizeEmojiString(x: string) {
|
||||
const match = emojiRegex.exec(x);
|
||||
if (match) {
|
||||
// 合字を含む1つの絵文字
|
||||
const unicode = match[0];
|
||||
|
||||
// 異体字セレクタ除去
|
||||
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
|
||||
} else {
|
||||
throw new Error('invalid emoji');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.chatMessagesRepository)
|
||||
private chatMessagesRepository: ChatMessagesRepository,
|
||||
|
||||
@Inject(DI.chatApprovalsRepository)
|
||||
private chatApprovalsRepository: ChatApprovalsRepository,
|
||||
|
||||
@Inject(DI.chatRoomsRepository)
|
||||
private chatRoomsRepository: ChatRoomsRepository,
|
||||
|
||||
@Inject(DI.chatRoomInvitationsRepository)
|
||||
private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
|
||||
|
||||
@Inject(DI.chatRoomMembershipsRepository)
|
||||
private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private chatEntityService: ChatEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private queueService: QueueService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
private notificationService: NotificationService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private customEmojiService: CustomEmojiService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getChatAvailability(userId: MiUser['id']): Promise<{ read: boolean; write: boolean; }> {
|
||||
const policies = await this.roleService.getUserPolicies(userId);
|
||||
|
||||
switch (policies.chatAvailability) {
|
||||
case 'available':
|
||||
return {
|
||||
read: true,
|
||||
write: true,
|
||||
};
|
||||
case 'readonly':
|
||||
return {
|
||||
read: true,
|
||||
write: false,
|
||||
};
|
||||
case 'unavailable':
|
||||
return {
|
||||
read: false,
|
||||
write: false,
|
||||
};
|
||||
default:
|
||||
throw new Error('invalid chat availability (unreachable)');
|
||||
}
|
||||
}
|
||||
|
||||
/** getChatAvailabilityの糖衣。主にAPI呼び出し時に走らせて、権限的に問題ない場合はそのまま続行する */
|
||||
@bindThis
|
||||
public async checkChatAvailability(userId: MiUser['id'], permission: 'read' | 'write') {
|
||||
const policy = await this.getChatAvailability(userId);
|
||||
if (policy[permission] === false) {
|
||||
throw new Error('ROLE_PERMISSION_DENIED');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
|
||||
text?: string | null;
|
||||
file?: MiDriveFile | null;
|
||||
uri?: string | null;
|
||||
}): Promise<Packed<'ChatMessageLiteFor1on1'>> {
|
||||
if (fromUser.id === toUser.id) {
|
||||
throw new Error('yourself');
|
||||
}
|
||||
|
||||
const approvals = await this.chatApprovalsRepository.createQueryBuilder('approval')
|
||||
.where(new Brackets(qb => { // 自分が相手を許可しているか
|
||||
qb.where('approval.userId = :fromUserId', { fromUserId: fromUser.id })
|
||||
.andWhere('approval.otherId = :toUserId', { toUserId: toUser.id });
|
||||
}))
|
||||
.orWhere(new Brackets(qb => { // 相手が自分を許可しているか
|
||||
qb.where('approval.userId = :toUserId', { toUserId: toUser.id })
|
||||
.andWhere('approval.otherId = :fromUserId', { fromUserId: fromUser.id });
|
||||
}))
|
||||
.take(2)
|
||||
.getMany();
|
||||
|
||||
const otherApprovedMe = approvals.some(approval => approval.userId === toUser.id);
|
||||
const iApprovedOther = approvals.some(approval => approval.userId === fromUser.id);
|
||||
|
||||
if (!otherApprovedMe) {
|
||||
if (toUser.chatScope === 'none') {
|
||||
throw new Error('recipient is cannot chat (none)');
|
||||
} else if (toUser.chatScope === 'followers') {
|
||||
const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id);
|
||||
if (!isFollower) {
|
||||
throw new Error('recipient is cannot chat (followers)');
|
||||
}
|
||||
} else if (toUser.chatScope === 'following') {
|
||||
const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id);
|
||||
if (!isFollowing) {
|
||||
throw new Error('recipient is cannot chat (following)');
|
||||
}
|
||||
} else if (toUser.chatScope === 'mutual') {
|
||||
const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id);
|
||||
if (!isMutual) {
|
||||
throw new Error('recipient is cannot chat (mutual)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await this.getChatAvailability(toUser.id)).write) {
|
||||
throw new Error('recipient is cannot chat (policy)');
|
||||
}
|
||||
|
||||
const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id);
|
||||
if (blocked) {
|
||||
throw new Error('blocked');
|
||||
}
|
||||
|
||||
const message = {
|
||||
id: this.idService.gen(),
|
||||
fromUserId: fromUser.id,
|
||||
toUserId: toUser.id,
|
||||
text: params.text ? params.text.trim() : null,
|
||||
fileId: params.file ? params.file.id : null,
|
||||
reads: [],
|
||||
uri: params.uri ?? null,
|
||||
} satisfies Partial<MiChatMessage>;
|
||||
|
||||
const inserted = await this.chatMessagesRepository.insertOne(message);
|
||||
|
||||
// 相手を許可しておく
|
||||
if (!iApprovedOther) {
|
||||
this.chatApprovalsRepository.insertOne({
|
||||
id: this.idService.gen(),
|
||||
userId: fromUser.id,
|
||||
otherId: toUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
const packedMessage = await this.chatEntityService.packMessageLiteFor1on1(inserted);
|
||||
|
||||
if (this.userEntityService.isLocalUser(toUser)) {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.set(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`, message.id);
|
||||
redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`);
|
||||
redisPipeline.exec();
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(fromUser)) {
|
||||
// 自分のストリーム
|
||||
this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(toUser)) {
|
||||
// 相手のストリーム
|
||||
this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage);
|
||||
}
|
||||
|
||||
// 3秒経っても既読にならなかったらイベント発行
|
||||
if (this.userEntityService.isLocalUser(toUser)) {
|
||||
setTimeout(async () => {
|
||||
const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`);
|
||||
|
||||
if (marker == null) return; // 既読
|
||||
|
||||
const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser);
|
||||
this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
|
||||
this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return packedMessage;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: {
|
||||
text?: string | null;
|
||||
file?: MiDriveFile | null;
|
||||
uri?: string | null;
|
||||
}): Promise<Packed<'ChatMessageLiteForRoom'>> {
|
||||
const memberships = (await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id })).map(m => ({
|
||||
userId: m.userId,
|
||||
isMuted: m.isMuted,
|
||||
})).concat({ // ownerはmembershipレコードを作らないため
|
||||
userId: toRoom.ownerId,
|
||||
isMuted: false,
|
||||
});
|
||||
|
||||
if (!memberships.some(member => member.userId === fromUser.id)) {
|
||||
throw new Error('you are not a member of the room');
|
||||
}
|
||||
|
||||
const membershipsOtherThanMe = memberships.filter(member => member.userId !== fromUser.id);
|
||||
|
||||
const message = {
|
||||
id: this.idService.gen(),
|
||||
fromUserId: fromUser.id,
|
||||
toRoomId: toRoom.id,
|
||||
text: params.text ? params.text.trim() : null,
|
||||
fileId: params.file ? params.file.id : null,
|
||||
reads: [],
|
||||
uri: params.uri ?? null,
|
||||
} satisfies Partial<MiChatMessage>;
|
||||
|
||||
const inserted = await this.chatMessagesRepository.insertOne(message);
|
||||
|
||||
const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted);
|
||||
|
||||
this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
for (const membership of membershipsOtherThanMe) {
|
||||
if (membership.isMuted) continue;
|
||||
|
||||
redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id);
|
||||
redisPipeline.sadd(`newChatMessagesExists:${membership.userId}`, `room:${toRoom.id}`);
|
||||
}
|
||||
redisPipeline.exec();
|
||||
|
||||
// 3秒経っても既読にならなかったらイベント発行
|
||||
setTimeout(async () => {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
for (const membership of membershipsOtherThanMe) {
|
||||
redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`);
|
||||
}
|
||||
const markers = await redisPipeline.exec();
|
||||
if (markers == null) throw new Error('redis error');
|
||||
|
||||
if (markers.every(marker => marker[1] == null)) return;
|
||||
|
||||
const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted);
|
||||
|
||||
for (let i = 0; i < membershipsOtherThanMe.length; i++) {
|
||||
const marker = markers[i][1];
|
||||
if (marker == null) continue;
|
||||
|
||||
this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
|
||||
this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return packedMessage;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async readUserChatMessage(
|
||||
readerId: MiUser['id'],
|
||||
senderId: MiUser['id'],
|
||||
): Promise<void> {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.del(`newUserChatMessageExists:${readerId}:${senderId}`);
|
||||
redisPipeline.srem(`newChatMessagesExists:${readerId}`, `user:${senderId}`);
|
||||
await redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async readRoomChatMessage(
|
||||
readerId: MiUser['id'],
|
||||
roomId: MiChatRoom['id'],
|
||||
): Promise<void> {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.del(`newRoomChatMessageExists:${readerId}:${roomId}`);
|
||||
redisPipeline.srem(`newChatMessagesExists:${readerId}`, `room:${roomId}`);
|
||||
await redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public findMessageById(messageId: MiChatMessage['id']) {
|
||||
return this.chatMessagesRepository.findOneBy({ id: messageId });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public findMyMessageById(userId: MiUser['id'], messageId: MiChatMessage['id']) {
|
||||
return this.chatMessagesRepository.findOneBy({ id: messageId, fromUserId: userId });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) {
|
||||
if (await this.isRoomMember(room, meId)) {
|
||||
return true;
|
||||
} else {
|
||||
const iAmModerator = await this.roleService.isModerator({ id: meId });
|
||||
if (iAmModerator) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async deleteMessage(message: MiChatMessage) {
|
||||
await this.chatMessagesRepository.delete(message.id);
|
||||
|
||||
if (message.toUserId) {
|
||||
const [fromUser, toUser] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: message.fromUserId }),
|
||||
this.usersRepository.findOneByOrFail({ id: message.toUserId }),
|
||||
]);
|
||||
|
||||
if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId, 'deleted', message.id);
|
||||
if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatUserStream(message.toUserId, message.fromUserId, 'deleted', message.id);
|
||||
|
||||
if (this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) {
|
||||
//const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser));
|
||||
//this.queueService.deliver(fromUser, activity, toUser.inbox);
|
||||
}
|
||||
} else if (message.toRoomId) {
|
||||
this.globalEventService.publishChatRoomStream(message.toRoomId, 'deleted', message.id);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(new Brackets(qb => {
|
||||
qb
|
||||
.where('message.fromUserId = :meId')
|
||||
.andWhere('message.toUserId = :otherId');
|
||||
}))
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('message.fromUserId = :otherId')
|
||||
.andWhere('message.toUserId = :meId');
|
||||
}));
|
||||
}))
|
||||
.setParameter('meId', meId)
|
||||
.setParameter('otherId', otherId);
|
||||
|
||||
const messages = await query.take(limit).getMany();
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async roomTimeline(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
|
||||
.andWhere('message.toRoomId = :roomId', { roomId })
|
||||
.leftJoinAndSelect('message.file', 'file')
|
||||
.leftJoinAndSelect('message.fromUser', 'fromUser');
|
||||
|
||||
const messages = await query.take(limit).getMany();
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async userHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
|
||||
const history: MiChatMessage[] = [];
|
||||
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: meId });
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const found = history.map(m => (m.fromUserId === meId) ? m.toUserId! : m.fromUserId!);
|
||||
|
||||
const query = this.chatMessagesRepository.createQueryBuilder('message')
|
||||
.orderBy('message.id', 'DESC')
|
||||
.where(new Brackets(qb => {
|
||||
qb
|
||||
.where('message.fromUserId = :meId', { meId: meId })
|
||||
.orWhere('message.toUserId = :meId', { meId: meId });
|
||||
}))
|
||||
.andWhere('message.toRoomId IS NULL')
|
||||
.andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`)
|
||||
.andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
|
||||
if (found.length > 0) {
|
||||
query.andWhere('message.fromUserId NOT IN (:...found)', { found: found });
|
||||
query.andWhere('message.toUserId NOT IN (:...found)', { found: found });
|
||||
}
|
||||
|
||||
query.setParameters(mutingQuery.getParameters());
|
||||
|
||||
const message = await query.getOne();
|
||||
|
||||
if (message) {
|
||||
history.push(message);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async roomHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
|
||||
// TODO: 一回のクエリにまとめられるかも
|
||||
const [memberRoomIds, ownedRoomIds] = await Promise.all([
|
||||
this.chatRoomMembershipsRepository.findBy({
|
||||
userId: meId,
|
||||
}).then(xs => xs.map(x => x.roomId)),
|
||||
this.chatRoomsRepository.findBy({
|
||||
ownerId: meId,
|
||||
}).then(xs => xs.map(x => x.id)),
|
||||
]);
|
||||
|
||||
const roomIds = memberRoomIds.concat(ownedRoomIds);
|
||||
|
||||
if (memberRoomIds.length === 0 && ownedRoomIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const history: MiChatMessage[] = [];
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const found = history.map(m => m.toRoomId!);
|
||||
|
||||
const query = this.chatMessagesRepository.createQueryBuilder('message')
|
||||
.orderBy('message.id', 'DESC')
|
||||
.where('message.toRoomId IN (:...roomIds)', { roomIds });
|
||||
|
||||
if (found.length > 0) {
|
||||
query.andWhere('message.toRoomId NOT IN (:...found)', { found: found });
|
||||
}
|
||||
|
||||
const message = await query.getOne();
|
||||
|
||||
if (message) {
|
||||
history.push(message);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserReadStateMap(userId: MiUser['id'], otherIds: MiUser['id'][]) {
|
||||
const readStateMap: Record<MiUser['id'], boolean> = {};
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (const otherId of otherIds) {
|
||||
redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`);
|
||||
}
|
||||
|
||||
const markers = await redisPipeline.exec();
|
||||
if (markers == null) throw new Error('redis error');
|
||||
|
||||
for (let i = 0; i < otherIds.length; i++) {
|
||||
const marker = markers[i][1];
|
||||
readStateMap[otherIds[i]] = marker == null;
|
||||
}
|
||||
|
||||
return readStateMap;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getRoomReadStateMap(userId: MiUser['id'], roomIds: MiChatRoom['id'][]) {
|
||||
const readStateMap: Record<MiChatRoom['id'], boolean> = {};
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (const roomId of roomIds) {
|
||||
redisPipeline.get(`newRoomChatMessageExists:${userId}:${roomId}`);
|
||||
}
|
||||
|
||||
const markers = await redisPipeline.exec();
|
||||
if (markers == null) throw new Error('redis error');
|
||||
|
||||
for (let i = 0; i < roomIds.length; i++) {
|
||||
const marker = markers[i][1];
|
||||
readStateMap[roomIds[i]] = marker == null;
|
||||
}
|
||||
|
||||
return readStateMap;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async hasUnreadMessages(userId: MiUser['id']) {
|
||||
const card = await this.redisClient.scard(`newChatMessagesExists:${userId}`);
|
||||
return card > 0;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createRoom(owner: MiUser, params: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
}>) {
|
||||
const room = {
|
||||
id: this.idService.gen(),
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
ownerId: owner.id,
|
||||
} satisfies Partial<MiChatRoom>;
|
||||
|
||||
const created = await this.chatRoomsRepository.insertOne(room);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) {
|
||||
if (room.ownerId === meId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const iAmModerator = await this.roleService.isModerator({ id: meId });
|
||||
if (iAmModerator) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async deleteRoom(room: MiChatRoom, deleter?: MiUser) {
|
||||
await this.chatRoomsRepository.delete(room.id);
|
||||
|
||||
if (deleter) {
|
||||
const deleterIsModerator = await this.roleService.isModerator(deleter);
|
||||
|
||||
if (deleterIsModerator) {
|
||||
this.moderationLogService.log(deleter, 'deleteChatRoom', {
|
||||
roomId: room.id,
|
||||
room: room,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) {
|
||||
return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findRoomById(roomId: MiChatRoom['id']) {
|
||||
return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isRoomMember(room: MiChatRoom, userId: MiUser['id']) {
|
||||
if (room.ownerId === userId) return true;
|
||||
const membership = await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId });
|
||||
return membership != null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createRoomInvitation(inviterId: MiUser['id'], roomId: MiChatRoom['id'], inviteeId: MiUser['id']) {
|
||||
if (inviterId === inviteeId) {
|
||||
throw new Error('yourself');
|
||||
}
|
||||
|
||||
const room = await this.chatRoomsRepository.findOneByOrFail({ id: roomId, ownerId: inviterId });
|
||||
|
||||
if (await this.isRoomMember(room, inviteeId)) {
|
||||
throw new Error('already member');
|
||||
}
|
||||
|
||||
const existingInvitation = await this.chatRoomInvitationsRepository.findOneBy({ roomId, userId: inviteeId });
|
||||
if (existingInvitation) {
|
||||
throw new Error('already invited');
|
||||
}
|
||||
|
||||
const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId });
|
||||
if (membershipsCount >= MAX_ROOM_MEMBERS) {
|
||||
throw new Error('room is full');
|
||||
}
|
||||
|
||||
// TODO: cehck block
|
||||
|
||||
const invitation = {
|
||||
id: this.idService.gen(),
|
||||
roomId: room.id,
|
||||
userId: inviteeId,
|
||||
} satisfies Partial<MiChatRoomInvitation>;
|
||||
|
||||
const created = await this.chatRoomInvitationsRepository.insertOne(invitation);
|
||||
|
||||
this.notificationService.createNotification(inviteeId, 'chatRoomInvitationReceived', {
|
||||
invitationId: invitation.id,
|
||||
}, inviterId);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getSentRoomInvitationsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId)
|
||||
.andWhere('invitation.roomId = :roomId', { roomId });
|
||||
|
||||
const invitations = await query.take(limit).getMany();
|
||||
|
||||
return invitations;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getOwnedRoomsWithPagination(ownerId: MiUser['id'], limit: number, sinceId?: MiChatRoom['id'] | null, untilId?: MiChatRoom['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.chatRoomsRepository.createQueryBuilder('room'), sinceId, untilId)
|
||||
.andWhere('room.ownerId = :ownerId', { ownerId });
|
||||
|
||||
const rooms = await query.take(limit).getMany();
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getReceivedRoomInvitationsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId)
|
||||
.andWhere('invitation.userId = :userId', { userId })
|
||||
.andWhere('invitation.ignored = FALSE');
|
||||
|
||||
const invitations = await query.take(limit).getMany();
|
||||
|
||||
return invitations;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async joinToRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
|
||||
const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId });
|
||||
|
||||
const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId });
|
||||
if (membershipsCount >= MAX_ROOM_MEMBERS) {
|
||||
throw new Error('room is full');
|
||||
}
|
||||
|
||||
const membership = {
|
||||
id: this.idService.gen(),
|
||||
roomId: roomId,
|
||||
userId: userId,
|
||||
} satisfies Partial<MiChatRoomMembership>;
|
||||
|
||||
// TODO: transaction
|
||||
await this.chatRoomMembershipsRepository.insertOne(membership);
|
||||
await this.chatRoomInvitationsRepository.delete(invitation.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async ignoreRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) {
|
||||
const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId });
|
||||
await this.chatRoomInvitationsRepository.update(invitation.id, { ignored: true });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
|
||||
const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
|
||||
await this.chatRoomMembershipsRepository.delete(membership.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async muteRoom(userId: MiUser['id'], roomId: MiChatRoom['id'], mute: boolean) {
|
||||
const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
|
||||
await this.chatRoomMembershipsRepository.update(membership.id, { isMuted: mute });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateRoom(room: MiChatRoom, params: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}): Promise<MiChatRoom> {
|
||||
return this.chatRoomsRepository.createQueryBuilder().update()
|
||||
.set(params)
|
||||
.where('id = :id', { id: room.id })
|
||||
.returning('*')
|
||||
.execute()
|
||||
.then((response) => {
|
||||
return response.raw[0];
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getRoomMembershipsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
|
||||
.andWhere('membership.roomId = :roomId', { roomId });
|
||||
|
||||
const memberships = await query.take(limit).getMany();
|
||||
|
||||
return memberships;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async searchMessages(meId: MiUser['id'], query: string, limit: number, params: {
|
||||
userId?: MiUser['id'] | null;
|
||||
roomId?: MiChatRoom['id'] | null;
|
||||
}) {
|
||||
const q = this.chatMessagesRepository.createQueryBuilder('message');
|
||||
|
||||
if (params.userId) {
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(new Brackets(qb => {
|
||||
qb
|
||||
.where('message.fromUserId = :meId')
|
||||
.andWhere('message.toUserId = :otherId');
|
||||
}))
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('message.fromUserId = :otherId')
|
||||
.andWhere('message.toUserId = :meId');
|
||||
}));
|
||||
}))
|
||||
.setParameter('meId', meId)
|
||||
.setParameter('otherId', params.userId);
|
||||
} else if (params.roomId) {
|
||||
q.where('message.toRoomId = :roomId', { roomId: params.roomId });
|
||||
} else {
|
||||
const membershipsQuery = this.chatRoomMembershipsRepository.createQueryBuilder('membership')
|
||||
.select('membership.roomId')
|
||||
.where('membership.userId = :meId', { meId: meId });
|
||||
|
||||
const ownedRoomsQuery = this.chatRoomsRepository.createQueryBuilder('room')
|
||||
.select('room.id')
|
||||
.where('room.ownerId = :meId', { meId });
|
||||
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('message.fromUserId = :meId')
|
||||
.orWhere('message.toUserId = :meId')
|
||||
.orWhere(`message.toRoomId IN (${membershipsQuery.getQuery()})`)
|
||||
.orWhere(`message.toRoomId IN (${ownedRoomsQuery.getQuery()})`);
|
||||
}));
|
||||
|
||||
q.setParameters(membershipsQuery.getParameters());
|
||||
q.setParameters(ownedRoomsQuery.getParameters());
|
||||
}
|
||||
|
||||
q.andWhere('LOWER(message.text) LIKE :q', { q: `%${ sqlLikeEscape(query.toLowerCase()) }%` });
|
||||
|
||||
q.leftJoinAndSelect('message.file', 'file');
|
||||
q.leftJoinAndSelect('message.fromUser', 'fromUser');
|
||||
q.leftJoinAndSelect('message.toUser', 'toUser');
|
||||
q.leftJoinAndSelect('message.toRoom', 'toRoom');
|
||||
q.leftJoinAndSelect('toRoom.owner', 'toRoomOwner');
|
||||
|
||||
const messages = await q.orderBy('message.id', 'DESC').take(limit).getMany();
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
|
||||
let reaction;
|
||||
|
||||
const custom = reaction_.match(isCustomEmojiRegexp);
|
||||
|
||||
if (custom == null) {
|
||||
reaction = normalizeEmojiString(reaction_);
|
||||
} else {
|
||||
const name = custom[1];
|
||||
const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
|
||||
|
||||
if (emoji == null) {
|
||||
throw new Error('no such emoji');
|
||||
} else {
|
||||
reaction = `:${name}:`;
|
||||
}
|
||||
}
|
||||
|
||||
const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId });
|
||||
|
||||
if (message.fromUserId === userId) {
|
||||
throw new Error('cannot react to own message');
|
||||
}
|
||||
|
||||
if (message.toRoomId === null && message.toUserId !== userId) {
|
||||
throw new Error('cannot react to others message');
|
||||
}
|
||||
|
||||
if (message.reactions.length >= MAX_REACTIONS_PER_MESSAGE) {
|
||||
throw new Error('too many reactions');
|
||||
}
|
||||
|
||||
const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null;
|
||||
|
||||
if (room) {
|
||||
if (!await this.isRoomMember(room, userId)) {
|
||||
throw new Error('cannot react to others message');
|
||||
}
|
||||
}
|
||||
|
||||
await this.chatMessagesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => `array_append("reactions", '${userId}/${reaction}')`,
|
||||
})
|
||||
.where('id = :id', { id: message.id })
|
||||
.execute();
|
||||
|
||||
if (room) {
|
||||
this.globalEventService.publishChatRoomStream(room.id, 'react', {
|
||||
messageId: message.id,
|
||||
user: await this.userEntityService.pack(userId),
|
||||
reaction,
|
||||
});
|
||||
} else {
|
||||
this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'react', {
|
||||
messageId: message.id,
|
||||
reaction,
|
||||
});
|
||||
this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'react', {
|
||||
messageId: message.id,
|
||||
reaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async unreact(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
|
||||
let reaction;
|
||||
|
||||
const custom = reaction_.match(isCustomEmojiRegexp);
|
||||
|
||||
if (custom == null) {
|
||||
reaction = normalizeEmojiString(reaction_);
|
||||
} else { // 削除されたカスタム絵文字のリアクションを削除したいかもしれないので絵文字の存在チェックはする必要なし
|
||||
const name = custom[1];
|
||||
reaction = `:${name}:`;
|
||||
}
|
||||
|
||||
// NOTE: 自分のリアクションを(あれば)削除するだけなので諸々の権限チェックは必要なし
|
||||
|
||||
const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId });
|
||||
|
||||
const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null;
|
||||
|
||||
await this.chatMessagesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => `array_remove("reactions", '${userId}/${reaction}')`,
|
||||
})
|
||||
.where('id = :id', { id: message.id })
|
||||
.execute();
|
||||
|
||||
// TODO: 実際に削除が行われたときのみイベントを発行する
|
||||
|
||||
if (room) {
|
||||
this.globalEventService.publishChatRoomStream(room.id, 'unreact', {
|
||||
messageId: message.id,
|
||||
user: await this.userEntityService.pack(userId),
|
||||
reaction,
|
||||
});
|
||||
} else {
|
||||
this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'unreact', {
|
||||
messageId: message.id,
|
||||
reaction,
|
||||
});
|
||||
this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'unreact', {
|
||||
messageId: message.id,
|
||||
reaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
|
||||
.andWhere('membership.userId = :userId', { userId });
|
||||
|
||||
const memberships = await query.take(limit).getMany();
|
||||
|
||||
return memberships;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { TimeService } from '@/core/TimeService.js';
|
|||
import { EnvService } from '@/core/EnvService.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import { ApLogService } from '@/core/ApLogService.js';
|
||||
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
|
||||
import { AccountMoveService } from './AccountMoveService.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AnnouncementService } from './AnnouncementService.js';
|
||||
|
|
@ -27,7 +28,6 @@ import { AppLockService } from './AppLockService.js';
|
|||
import { AchievementService } from './AchievementService.js';
|
||||
import { AvatarDecorationService } from './AvatarDecorationService.js';
|
||||
import { CaptchaService } from './CaptchaService.js';
|
||||
import { CreateSystemUserService } from './CreateSystemUserService.js';
|
||||
import { CustomEmojiService } from './CustomEmojiService.js';
|
||||
import { DeleteAccountService } from './DeleteAccountService.js';
|
||||
import { DownloadService } from './DownloadService.js';
|
||||
|
|
@ -40,7 +40,8 @@ import { HashtagService } from './HashtagService.js';
|
|||
import { HttpRequestService } from './HttpRequestService.js';
|
||||
import { IdService } from './IdService.js';
|
||||
import { ImageProcessingService } from './ImageProcessingService.js';
|
||||
import { InstanceActorService } from './InstanceActorService.js';
|
||||
import { SystemAccountService } from './SystemAccountService.js';
|
||||
import { InternalEventService } from './InternalEventService.js';
|
||||
import { InternalStorageService } from './InternalStorageService.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
import { MfmService } from './MfmService.js';
|
||||
|
|
@ -50,7 +51,6 @@ import { NoteEditService } from './NoteEditService.js';
|
|||
import { NoteDeleteService } from './NoteDeleteService.js';
|
||||
import { LatestNoteService } from './LatestNoteService.js';
|
||||
import { NotePiningService } from './NotePiningService.js';
|
||||
import { NoteReadService } from './NoteReadService.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { PollService } from './PollService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
|
|
@ -60,6 +60,7 @@ import { ReactionsBufferingService } from './ReactionsBufferingService.js';
|
|||
import { RelayService } from './RelayService.js';
|
||||
import { RoleService } from './RoleService.js';
|
||||
import { S3Service } from './S3Service.js';
|
||||
import { BunnyService } from './BunnyService.js';
|
||||
import { SignupService } from './SignupService.js';
|
||||
import { WebAuthnService } from './WebAuthnService.js';
|
||||
import { UserBlockingService } from './UserBlockingService.js';
|
||||
|
|
@ -74,7 +75,6 @@ import { UserSuspendService } from './UserSuspendService.js';
|
|||
import { UserAuthService } from './UserAuthService.js';
|
||||
import { VideoProcessingService } from './VideoProcessingService.js';
|
||||
import { UserWebhookService } from './UserWebhookService.js';
|
||||
import { ProxyAccountService } from './ProxyAccountService.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
import { FileInfoService } from './FileInfoService.js';
|
||||
import { SearchService } from './SearchService.js';
|
||||
|
|
@ -82,6 +82,7 @@ import { ClipService } from './ClipService.js';
|
|||
import { FeaturedService } from './FeaturedService.js';
|
||||
import { FanoutTimelineService } from './FanoutTimelineService.js';
|
||||
import { ChannelFollowingService } from './ChannelFollowingService.js';
|
||||
import { ChatService } from './ChatService.js';
|
||||
import { RegistryApiService } from './RegistryApiService.js';
|
||||
import { ReversiService } from './ReversiService.js';
|
||||
|
||||
|
|
@ -107,6 +108,7 @@ import { AppEntityService } from './entities/AppEntityService.js';
|
|||
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
|
||||
import { BlockingEntityService } from './entities/BlockingEntityService.js';
|
||||
import { ChannelEntityService } from './entities/ChannelEntityService.js';
|
||||
import { ChatEntityService } from './entities/ChatEntityService.js';
|
||||
import { ClipEntityService } from './entities/ClipEntityService.js';
|
||||
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
|
||||
import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js';
|
||||
|
|
@ -173,7 +175,6 @@ const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppL
|
|||
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
|
||||
const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService };
|
||||
const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
|
||||
const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService };
|
||||
const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService };
|
||||
const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService };
|
||||
const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService };
|
||||
|
|
@ -186,7 +187,7 @@ const $HashtagService: Provider = { provide: 'HashtagService', useExisting: Hash
|
|||
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
|
||||
const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
|
||||
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService };
|
||||
const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService };
|
||||
const $InternalEventService: Provider = { provide: 'InternalEventService', useExisting: InternalEventService };
|
||||
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
|
||||
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
|
||||
const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService };
|
||||
|
|
@ -196,10 +197,9 @@ const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: No
|
|||
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
|
||||
const $LatestNoteService: Provider = { provide: 'LatestNoteService', useExisting: LatestNoteService };
|
||||
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
|
||||
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
|
||||
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
|
||||
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
|
||||
const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService };
|
||||
const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService };
|
||||
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService };
|
||||
const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService };
|
||||
const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService };
|
||||
|
|
@ -207,6 +207,7 @@ const $ReactionsBufferingService: Provider = { provide: 'ReactionsBufferingServi
|
|||
const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService };
|
||||
const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
|
||||
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
|
||||
const $BunnyService: Provider = { provide: 'BunnyService', useExisting: BunnyService };
|
||||
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
|
||||
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
|
||||
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
|
||||
|
|
@ -220,6 +221,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService',
|
|||
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
|
||||
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
|
||||
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
|
||||
const $UpdateInstanceQueue: Provider = { provide: 'UpdateInstanceQueue', useExisting: UpdateInstanceQueue };
|
||||
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
|
||||
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
|
||||
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
|
||||
|
|
@ -233,6 +235,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe
|
|||
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
|
||||
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
|
||||
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
|
||||
const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
|
||||
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
|
||||
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
|
||||
const $TimeService: Provider = { provide: 'TimeService', useExisting: TimeService };
|
||||
|
|
@ -261,6 +264,7 @@ const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting:
|
|||
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
|
||||
const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService };
|
||||
const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService };
|
||||
const $ChatEntityService: Provider = { provide: 'ChatEntityService', useExisting: ChatEntityService };
|
||||
const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService };
|
||||
const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService };
|
||||
const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService };
|
||||
|
|
@ -331,7 +335,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
AchievementService,
|
||||
AvatarDecorationService,
|
||||
CaptchaService,
|
||||
CreateSystemUserService,
|
||||
CustomEmojiService,
|
||||
DeleteAccountService,
|
||||
DownloadService,
|
||||
|
|
@ -344,7 +347,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
HttpRequestService,
|
||||
IdService,
|
||||
ImageProcessingService,
|
||||
InstanceActorService,
|
||||
InternalEventService,
|
||||
InternalStorageService,
|
||||
MetaService,
|
||||
MfmService,
|
||||
|
|
@ -354,10 +357,9 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
NoteDeleteService,
|
||||
LatestNoteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
SystemAccountService,
|
||||
PushNotificationService,
|
||||
QueryService,
|
||||
ReactionService,
|
||||
|
|
@ -365,6 +367,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
RelayService,
|
||||
RoleService,
|
||||
S3Service,
|
||||
BunnyService,
|
||||
SignupService,
|
||||
WebAuthnService,
|
||||
UserBlockingService,
|
||||
|
|
@ -378,6 +381,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
UserSearchService,
|
||||
UserSuspendService,
|
||||
UserAuthService,
|
||||
UpdateInstanceQueue,
|
||||
VideoProcessingService,
|
||||
UserWebhookService,
|
||||
SystemWebhookService,
|
||||
|
|
@ -391,6 +395,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
TimeService,
|
||||
|
|
@ -419,6 +424,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
AuthSessionEntityService,
|
||||
BlockingEntityService,
|
||||
ChannelEntityService,
|
||||
ChatEntityService,
|
||||
ClipEntityService,
|
||||
DriveFileEntityService,
|
||||
DriveFolderEntityService,
|
||||
|
|
@ -485,7 +491,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$AchievementService,
|
||||
$AvatarDecorationService,
|
||||
$CaptchaService,
|
||||
$CreateSystemUserService,
|
||||
$CustomEmojiService,
|
||||
$DeleteAccountService,
|
||||
$DownloadService,
|
||||
|
|
@ -498,7 +503,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$HttpRequestService,
|
||||
$IdService,
|
||||
$ImageProcessingService,
|
||||
$InstanceActorService,
|
||||
$InternalEventService,
|
||||
$InternalStorageService,
|
||||
$MetaService,
|
||||
$MfmService,
|
||||
|
|
@ -508,10 +513,9 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$NoteDeleteService,
|
||||
$LatestNoteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$SystemAccountService,
|
||||
$PushNotificationService,
|
||||
$QueryService,
|
||||
$ReactionService,
|
||||
|
|
@ -519,6 +523,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$RelayService,
|
||||
$RoleService,
|
||||
$S3Service,
|
||||
$BunnyService,
|
||||
$SignupService,
|
||||
$WebAuthnService,
|
||||
$UserBlockingService,
|
||||
|
|
@ -532,6 +537,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$UserSearchService,
|
||||
$UserSuspendService,
|
||||
$UserAuthService,
|
||||
$UpdateInstanceQueue,
|
||||
$VideoProcessingService,
|
||||
$UserWebhookService,
|
||||
$SystemWebhookService,
|
||||
|
|
@ -545,6 +551,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$TimeService,
|
||||
|
|
@ -573,6 +580,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$AuthSessionEntityService,
|
||||
$BlockingEntityService,
|
||||
$ChannelEntityService,
|
||||
$ChatEntityService,
|
||||
$ClipEntityService,
|
||||
$DriveFileEntityService,
|
||||
$DriveFolderEntityService,
|
||||
|
|
@ -640,7 +648,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
AchievementService,
|
||||
AvatarDecorationService,
|
||||
CaptchaService,
|
||||
CreateSystemUserService,
|
||||
CustomEmojiService,
|
||||
DeleteAccountService,
|
||||
DownloadService,
|
||||
|
|
@ -653,7 +660,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
HttpRequestService,
|
||||
IdService,
|
||||
ImageProcessingService,
|
||||
InstanceActorService,
|
||||
InternalEventService,
|
||||
InternalStorageService,
|
||||
MetaService,
|
||||
MfmService,
|
||||
|
|
@ -663,10 +670,9 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
NoteDeleteService,
|
||||
LatestNoteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
SystemAccountService,
|
||||
PushNotificationService,
|
||||
QueryService,
|
||||
ReactionService,
|
||||
|
|
@ -674,6 +680,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
RelayService,
|
||||
RoleService,
|
||||
S3Service,
|
||||
BunnyService,
|
||||
SignupService,
|
||||
WebAuthnService,
|
||||
UserBlockingService,
|
||||
|
|
@ -687,6 +694,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
UserSearchService,
|
||||
UserSuspendService,
|
||||
UserAuthService,
|
||||
UpdateInstanceQueue,
|
||||
VideoProcessingService,
|
||||
UserWebhookService,
|
||||
SystemWebhookService,
|
||||
|
|
@ -700,6 +708,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
TimeService,
|
||||
|
|
@ -727,6 +736,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
AuthSessionEntityService,
|
||||
BlockingEntityService,
|
||||
ChannelEntityService,
|
||||
ChatEntityService,
|
||||
ClipEntityService,
|
||||
DriveFileEntityService,
|
||||
DriveFolderEntityService,
|
||||
|
|
@ -793,7 +803,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$AchievementService,
|
||||
$AvatarDecorationService,
|
||||
$CaptchaService,
|
||||
$CreateSystemUserService,
|
||||
$CustomEmojiService,
|
||||
$DeleteAccountService,
|
||||
$DownloadService,
|
||||
|
|
@ -806,7 +815,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$HttpRequestService,
|
||||
$IdService,
|
||||
$ImageProcessingService,
|
||||
$InstanceActorService,
|
||||
$InternalEventService,
|
||||
$InternalStorageService,
|
||||
$MetaService,
|
||||
$MfmService,
|
||||
|
|
@ -816,10 +825,9 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$NoteDeleteService,
|
||||
$LatestNoteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$SystemAccountService,
|
||||
$PushNotificationService,
|
||||
$QueryService,
|
||||
$ReactionService,
|
||||
|
|
@ -827,6 +835,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$RelayService,
|
||||
$RoleService,
|
||||
$S3Service,
|
||||
$BunnyService,
|
||||
$SignupService,
|
||||
$WebAuthnService,
|
||||
$UserBlockingService,
|
||||
|
|
@ -840,6 +849,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$UserSearchService,
|
||||
$UserSuspendService,
|
||||
$UserAuthService,
|
||||
$UpdateInstanceQueue,
|
||||
$VideoProcessingService,
|
||||
$UserWebhookService,
|
||||
$SystemWebhookService,
|
||||
|
|
@ -852,6 +862,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$TimeService,
|
||||
|
|
@ -879,6 +890,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$AuthSessionEntityService,
|
||||
$BlockingEntityService,
|
||||
$ChannelEntityService,
|
||||
$ChatEntityService,
|
||||
$ClipEntityService,
|
||||
$DriveFileEntityService,
|
||||
$DriveFolderEntityService,
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as argon2 from 'argon2';
|
||||
//import bcrypt from 'bcryptjs';
|
||||
import { IsNull, DataSource } from 'typeorm';
|
||||
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { MiUserProfile } from '@/models/UserProfile.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { MiUserKeypair } from '@/models/UserKeypair.js';
|
||||
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import generateNativeUserToken from '@/misc/generate-native-user-token.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class CreateSystemUserService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createSystemUser(username: string): Promise<MiUser> {
|
||||
const password = randomUUID();
|
||||
|
||||
// Generate hash of password
|
||||
//const salt = await bcrypt.genSalt(8);
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
// Generate secret
|
||||
const secret = generateNativeUserToken();
|
||||
|
||||
const keyPair = await genRsaKeyPair();
|
||||
|
||||
let account!: MiUser;
|
||||
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
const exist = await transactionalEntityManager.findOneBy(MiUser, {
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
});
|
||||
|
||||
if (exist) throw new Error('the user is already exists');
|
||||
|
||||
account = await transactionalEntityManager.insert(MiUser, {
|
||||
id: this.idService.gen(),
|
||||
username: username,
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: null,
|
||||
token: secret,
|
||||
isRoot: false,
|
||||
isLocked: true,
|
||||
isExplorable: false,
|
||||
approved: true,
|
||||
isBot: true,
|
||||
}).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
|
||||
|
||||
await transactionalEntityManager.insert(MiUserKeypair, {
|
||||
publicKey: keyPair.publicKey,
|
||||
privateKey: keyPair.privateKey,
|
||||
userId: account.id,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(MiUserProfile, {
|
||||
userId: account.id,
|
||||
autoAcceptFollowed: false,
|
||||
password: hash,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(MiUsedUsername, {
|
||||
createdAt: new Date(),
|
||||
username: username.toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import type { FollowingsRepository, MiUser, UsersRepository } from '@/models/_.js';
|
||||
import type { FollowingsRepository, MiMeta, MiUser, UsersRepository } from '@/models/_.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
|
@ -13,11 +13,15 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteAccountService {
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
|
@ -29,6 +33,7 @@ export class DeleteAccountService {
|
|||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -37,9 +42,13 @@ export class DeleteAccountService {
|
|||
id: string;
|
||||
host: string | null;
|
||||
}, moderator?: MiUser): Promise<void> {
|
||||
if (this.meta.rootUserId === user.id) throw new Error('cannot delete a root account');
|
||||
|
||||
const _user = await this.usersRepository.findOneByOrFail({ id: user.id });
|
||||
if (_user.isRoot) throw new Error('cannot delete a root account');
|
||||
if (isSystemAccount(_user)) throw new Error('cannot delete a system account');
|
||||
|
||||
if (isSystemAccount(_user)) {
|
||||
throw new Error('cannot delete a system account');
|
||||
}
|
||||
|
||||
if (moderator != null) {
|
||||
this.moderationLogService.log(moderator, 'deleteAccount', {
|
||||
|
|
@ -54,8 +63,6 @@ export class DeleteAccountService {
|
|||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
|
|
@ -64,22 +71,17 @@ export class DeleteAccountService {
|
|||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
const inboxes = followings.map(x => [x.followerSharedInbox ?? x.followeeSharedInbox as string, true] as const);
|
||||
const queue = new Map<string, true>(inboxes);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
await this.queueService.deliverMany(user, content, queue);
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user, content, inbox, true);
|
||||
}
|
||||
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
await this.queueService.createDeleteAccountJob(user, {
|
||||
soft: false,
|
||||
});
|
||||
} else {
|
||||
// リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
await this.queueService.createDeleteAccountJob(user, {
|
||||
soft: true,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { LoggerService } from '@/core/LoggerService.js';
|
|||
import type Logger from '@/logger.js';
|
||||
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class DownloadService {
|
||||
|
|
@ -29,15 +31,18 @@ export class DownloadService {
|
|||
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
private readonly utilityService: UtilityService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('download');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number} = {} ): Promise<{
|
||||
public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number } = {} ): Promise<{
|
||||
filename: string;
|
||||
}> {
|
||||
this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
|
||||
this.utilityService.assertUrl(url);
|
||||
|
||||
this.logger.debug(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
|
||||
|
||||
const timeout = options.timeout ?? 30 * 1000;
|
||||
const operationTimeout = options.operationTimeout ?? 60 * 1000;
|
||||
|
|
@ -60,8 +65,8 @@ export class DownloadService {
|
|||
request: operationTimeout, // whole operation timeout
|
||||
},
|
||||
agent: {
|
||||
http: this.httpRequestService.httpAgent,
|
||||
https: this.httpRequestService.httpsAgent,
|
||||
http: this.httpRequestService.getAgentForHttp(urlObj, true),
|
||||
https: this.httpRequestService.getAgentForHttps(urlObj, true),
|
||||
},
|
||||
http2: false, // default
|
||||
retry: {
|
||||
|
|
@ -86,7 +91,7 @@ export class DownloadService {
|
|||
filename = parsed.parameters.filename;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to parse content-disposition: ${contentDisposition}`, { stack: e });
|
||||
this.logger.warn(`Failed to parse content-disposition ${contentDisposition}: ${renderInlineError(e)}`);
|
||||
}
|
||||
}
|
||||
}).on('downloadProgress', (progress: Got.Progress) => {
|
||||
|
|
@ -100,13 +105,17 @@ export class DownloadService {
|
|||
await stream.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 new StatusError(`download error from ${url}`, e.response.statusCode, e.response.statusMessage, e);
|
||||
} else if (e instanceof Got.RequestError || e instanceof Got.AbortError) {
|
||||
throw new Error(String(e), { cause: e });
|
||||
} else if (e instanceof Error) {
|
||||
throw e;
|
||||
} else {
|
||||
throw new Error(String(e), { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
|
||||
this.logger.info(`Download finished: ${chalk.cyan(url)}`);
|
||||
|
||||
return {
|
||||
filename,
|
||||
|
|
@ -118,7 +127,7 @@ export class DownloadService {
|
|||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
this.logger.info(`text file: Temp file is ${path}`);
|
||||
this.logger.debug(`text file: Temp file is ${path}`);
|
||||
|
||||
try {
|
||||
// write content at URL to temp file
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ import { correctFilename } from '@/misc/correct-filename.js';
|
|||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { BunnyService } from '@/core/BunnyService.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { LoggerService } from './LoggerService.js';
|
||||
|
||||
type AddFileArgs = {
|
||||
/** User who wish to add file */
|
||||
|
|
@ -121,6 +124,7 @@ export class DriveService {
|
|||
private downloadService: DownloadService,
|
||||
private internalStorageService: InternalStorageService,
|
||||
private s3Service: S3Service,
|
||||
private bunnyService: BunnyService,
|
||||
private imageProcessingService: ImageProcessingService,
|
||||
private videoProcessingService: VideoProcessingService,
|
||||
private globalEventService: GlobalEventService,
|
||||
|
|
@ -131,8 +135,10 @@ export class DriveService {
|
|||
private perUserDriveChart: PerUserDriveChart,
|
||||
private instanceChart: InstanceChart,
|
||||
private utilityService: UtilityService,
|
||||
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
const logger = new Logger('drive', 'blue');
|
||||
const logger = loggerService.getLogger('drive', 'blue');
|
||||
this.registerLogger = logger.createSubLogger('register', 'yellow');
|
||||
this.downloaderLogger = logger.createSubLogger('downloader');
|
||||
this.deleteLogger = logger.createSubLogger('delete');
|
||||
|
|
@ -148,12 +154,23 @@ export class DriveService {
|
|||
@bindThis
|
||||
private async save(file: MiDriveFile, path: string, name: string, info: FileInfo): Promise<MiDriveFile> {
|
||||
const type = info.type.mime;
|
||||
const hash = info.md5;
|
||||
const size = info.size;
|
||||
let hash = info.md5;
|
||||
let size = info.size;
|
||||
|
||||
// thunbnail, webpublic を必要なら生成
|
||||
const alts = await this.generateAlts(path, type, !file.uri);
|
||||
|
||||
if (type && type.startsWith('video/')) {
|
||||
try {
|
||||
await this.videoProcessingService.webOptimizeVideo(path, type);
|
||||
const newInfo = await this.fileInfoService.getFileInfo(path);
|
||||
hash = newInfo.md5;
|
||||
size = newInfo.size;
|
||||
} catch (err) {
|
||||
this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.meta.useObjectStorage) {
|
||||
//#region ObjectStorage params
|
||||
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
|
||||
|
|
@ -177,7 +194,8 @@ export class DriveService {
|
|||
?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`;
|
||||
|
||||
// for original
|
||||
const key = `${this.meta.objectStoragePrefix}/${randomUUID()}${ext}`;
|
||||
const prefix = this.meta.objectStoragePrefix ? `${this.meta.objectStoragePrefix}/` : '';
|
||||
const key = `${prefix}${randomUUID()}${ext}`;
|
||||
const url = `${ baseUrl }/${ key }`;
|
||||
|
||||
// for alts
|
||||
|
|
@ -188,24 +206,24 @@ export class DriveService {
|
|||
//#endregion
|
||||
|
||||
//#region Uploads
|
||||
this.registerLogger.info(`uploading original: ${key}`);
|
||||
this.registerLogger.debug(`uploading original: ${key}`);
|
||||
const uploads = [
|
||||
this.upload(key, fs.createReadStream(path), type, null, name),
|
||||
];
|
||||
|
||||
if (alts.webpublic) {
|
||||
webpublicKey = `${this.meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
||||
webpublicKey = `${prefix}webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
||||
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
|
||||
|
||||
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
|
||||
this.registerLogger.debug(`uploading webpublic: ${webpublicKey}`);
|
||||
uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name));
|
||||
}
|
||||
|
||||
if (alts.thumbnail) {
|
||||
thumbnailKey = `${this.meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
||||
thumbnailKey = `${prefix}thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
||||
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
|
||||
|
||||
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
|
||||
this.registerLogger.debug(`uploading thumbnail: ${thumbnailKey}`);
|
||||
uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`));
|
||||
}
|
||||
|
||||
|
|
@ -249,11 +267,11 @@ export class DriveService {
|
|||
const [url, thumbnailUrl, webpublicUrl] = await Promise.all(promises);
|
||||
|
||||
if (thumbnailUrl) {
|
||||
this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`);
|
||||
this.registerLogger.debug(`thumbnail stored: ${thumbnailAccessKey}`);
|
||||
}
|
||||
|
||||
if (webpublicUrl) {
|
||||
this.registerLogger.info(`web stored: ${webpublicAccessKey}`);
|
||||
this.registerLogger.debug(`web stored: ${webpublicAccessKey}`);
|
||||
}
|
||||
|
||||
file.storedInternal = true;
|
||||
|
|
@ -297,7 +315,7 @@ export class DriveService {
|
|||
thumbnail,
|
||||
};
|
||||
} catch (err) {
|
||||
this.registerLogger.warn(`GenerateVideoThumbnail failed: ${err}`);
|
||||
this.registerLogger.warn(`GenerateVideoThumbnail failed: ${renderInlineError(err)}`);
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
|
|
@ -330,7 +348,7 @@ export class DriveService {
|
|||
metadata.height && metadata.height <= 2048
|
||||
);
|
||||
} catch (err) {
|
||||
this.registerLogger.warn(`sharp failed: ${err}`);
|
||||
this.registerLogger.warn(`sharp failed: ${renderInlineError(err)}`);
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
|
|
@ -341,7 +359,7 @@ export class DriveService {
|
|||
let webpublic: IImage | null = null;
|
||||
|
||||
if (generateWeb && !satisfyWebpublic && !isAnimated) {
|
||||
this.registerLogger.info('creating web image');
|
||||
this.registerLogger.debug('creating web image');
|
||||
|
||||
try {
|
||||
if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) {
|
||||
|
|
@ -352,12 +370,12 @@ export class DriveService {
|
|||
this.registerLogger.debug('web image not created (not an required image)');
|
||||
}
|
||||
} catch (err) {
|
||||
this.registerLogger.warn('web image not created (an error occurred)', err as Error);
|
||||
this.registerLogger.warn(`web image not created: ${renderInlineError(err)}`);
|
||||
}
|
||||
} else {
|
||||
if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)');
|
||||
else if (isAnimated) this.registerLogger.info('web image not created (animated image)');
|
||||
else this.registerLogger.info('web image not created (from remote)');
|
||||
if (satisfyWebpublic) this.registerLogger.debug('web image not created (original satisfies webpublic)');
|
||||
else if (isAnimated) this.registerLogger.debug('web image not created (animated image)');
|
||||
else this.registerLogger.debug('web image not created (from remote)');
|
||||
}
|
||||
// #endregion webpublic
|
||||
|
||||
|
|
@ -371,7 +389,7 @@ export class DriveService {
|
|||
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422);
|
||||
}
|
||||
} catch (err) {
|
||||
this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error);
|
||||
this.registerLogger.warn(`Error creating thumbnail: ${renderInlineError(err)}`);
|
||||
}
|
||||
// #endregion thumbnail
|
||||
|
||||
|
|
@ -405,20 +423,22 @@ export class DriveService {
|
|||
);
|
||||
if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read';
|
||||
|
||||
await this.s3Service.upload(this.meta, params)
|
||||
.then(
|
||||
result => {
|
||||
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
|
||||
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
|
||||
} else { // AbortMultipartUploadCommandOutput
|
||||
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
|
||||
}
|
||||
})
|
||||
.catch(
|
||||
err => {
|
||||
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
|
||||
},
|
||||
);
|
||||
try {
|
||||
if (this.bunnyService.usingBunnyCDN(this.meta)) {
|
||||
await this.bunnyService.upload(this.meta, key, stream);
|
||||
} else {
|
||||
const result = await this.s3Service.upload(this.meta, params);
|
||||
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
|
||||
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
|
||||
} else { // AbortMultipartUploadCommandOutput
|
||||
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
|
||||
throw new Error('S3 upload aborted');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}: ${renderInlineError(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Expire oldest file (without avatar or banner) of remote user
|
||||
|
|
@ -476,7 +496,6 @@ export class DriveService {
|
|||
}: AddFileArgs): Promise<MiDriveFile> {
|
||||
const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw;
|
||||
const info = await this.fileInfoService.getFileInfo(path);
|
||||
this.registerLogger.info(`${JSON.stringify(info)}`);
|
||||
|
||||
// detect name
|
||||
const detectedName = correctFilename(
|
||||
|
|
@ -486,6 +505,8 @@ export class DriveService {
|
|||
ext ?? info.type.ext,
|
||||
);
|
||||
|
||||
this.registerLogger.debug(`Detected file info: ${JSON.stringify(info)}`);
|
||||
|
||||
if (user && !force) {
|
||||
// Check if there is a file with the same hash
|
||||
const matched = await this.driveFilesRepository.findOneBy({
|
||||
|
|
@ -494,7 +515,7 @@ export class DriveService {
|
|||
});
|
||||
|
||||
if (matched) {
|
||||
this.registerLogger.info(`file with same hash is found: ${matched.id}`);
|
||||
this.registerLogger.debug(`file with same hash is found: ${matched.id}`);
|
||||
if (sensitive && !matched.isSensitive) {
|
||||
// The file is federated as sensitive for this time, but was federated as non-sensitive before.
|
||||
// Therefore, update the file to sensitive.
|
||||
|
|
@ -514,11 +535,23 @@ export class DriveService {
|
|||
|
||||
const policies = await this.roleService.getUserPolicies(user.id);
|
||||
const driveCapacity = 1024 * 1024 * policies.driveCapacityMb;
|
||||
const maxFileSize = 1024 * 1024 * policies.maxFileSizeMb;
|
||||
this.registerLogger.debug('drive capacity override applied');
|
||||
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
|
||||
|
||||
if (maxFileSize < info.size) {
|
||||
if (isLocalUser) {
|
||||
throw new IdentifiableError('f9e4e5f3-4df4-40b5-b400-f236945f7073', 'Max file size exceeded.');
|
||||
} else {
|
||||
// For remote users, throwing an exception will break Activity processing.
|
||||
// Instead, force "link" mode which does not cache the file locally.
|
||||
isLink = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If usage limit exceeded
|
||||
if (driveCapacity < usage + info.size) {
|
||||
// Repeat the "!isLink" check because it could be set to true by the previous block.
|
||||
if (driveCapacity < usage + info.size && !isLink) {
|
||||
if (isLocalUser) {
|
||||
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.', true);
|
||||
}
|
||||
|
|
@ -610,14 +643,14 @@ export class DriveService {
|
|||
} catch (err) {
|
||||
// duplicate key error (when already registered)
|
||||
if (isDuplicateKeyValueError(err)) {
|
||||
this.registerLogger.info(`already registered ${file.uri}`);
|
||||
this.registerLogger.debug(`already registered ${file.uri}`);
|
||||
|
||||
file = await this.driveFilesRepository.findOneBy({
|
||||
uri: file.uri!,
|
||||
userId: user ? user.id : IsNull(),
|
||||
}) as MiDriveFile;
|
||||
} else {
|
||||
this.registerLogger.error(err as Error);
|
||||
this.registerLogger.error('Error in drive register', err as Error);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -625,7 +658,7 @@ export class DriveService {
|
|||
file = await (this.save(file, path, detectedName, info));
|
||||
}
|
||||
|
||||
this.registerLogger.succ(`drive file has been created ${file.id}`);
|
||||
this.registerLogger.info(`Created file ${file.id} (${detectedName}) of type ${info.type.mime} for user ${user?.id ?? '<none>'}`);
|
||||
|
||||
if (user) {
|
||||
this.driveFileEntityService.pack(file, { self: true }).then(packedFile => {
|
||||
|
|
@ -708,14 +741,14 @@ export class DriveService {
|
|||
@bindThis
|
||||
public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
|
||||
if (file.storedInternal) {
|
||||
this.internalStorageService.del(file.accessKey!);
|
||||
this.deleteLocalFile(file.accessKey!);
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
this.internalStorageService.del(file.thumbnailAccessKey!);
|
||||
this.deleteLocalFile(file.thumbnailAccessKey!);
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
this.internalStorageService.del(file.webpublicAccessKey!);
|
||||
this.deleteLocalFile(file.webpublicAccessKey!);
|
||||
}
|
||||
} else if (!file.isLink) {
|
||||
this.queueService.createDeleteObjectStorageFileJob(file.accessKey!);
|
||||
|
|
@ -737,14 +770,14 @@ export class DriveService {
|
|||
const promises = [];
|
||||
|
||||
if (file.storedInternal) {
|
||||
promises.push(this.internalStorageService.del(file.accessKey!));
|
||||
promises.push(this.deleteLocalFile(file.accessKey!));
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
promises.push(this.internalStorageService.del(file.thumbnailAccessKey!));
|
||||
promises.push(this.deleteLocalFile(file.thumbnailAccessKey!));
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
promises.push(this.internalStorageService.del(file.webpublicAccessKey!));
|
||||
promises.push(this.deleteLocalFile(file.webpublicAccessKey!));
|
||||
}
|
||||
} else if (!file.isLink) {
|
||||
promises.push(this.deleteObjectStorageFile(file.accessKey!));
|
||||
|
|
@ -814,11 +847,14 @@ export class DriveService {
|
|||
Bucket: this.meta.objectStorageBucket,
|
||||
Key: key,
|
||||
} as DeleteObjectCommandInput;
|
||||
|
||||
await this.s3Service.delete(this.meta, param);
|
||||
if (this.bunnyService.usingBunnyCDN(this.meta)) {
|
||||
await this.bunnyService.delete(this.meta, key);
|
||||
} else {
|
||||
await this.s3Service.delete(this.meta, param);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'NoSuchKey') {
|
||||
this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error);
|
||||
this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {
|
||||
|
|
@ -828,6 +864,22 @@ export class DriveService {
|
|||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async deleteLocalFile(key: string) {
|
||||
try {
|
||||
await this.internalStorageService.del(key);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
this.deleteLogger.warn(`The file to delete did not exist: ${key}. Skipping this.`);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Failed to delete the file: ${key}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async uploadFromUrl({
|
||||
url,
|
||||
|
|
@ -855,13 +907,10 @@ export class DriveService {
|
|||
}
|
||||
|
||||
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
|
||||
this.downloaderLogger.succ(`Got: ${driveFile.id}`);
|
||||
this.downloaderLogger.debug(`Upload succeeded: created file ${driveFile.id}`);
|
||||
return driveFile!;
|
||||
} catch (err) {
|
||||
this.downloaderLogger.error(`Failed to create drive file: ${err}`, {
|
||||
url: url,
|
||||
e: err,
|
||||
});
|
||||
this.downloaderLogger.error(`Failed to create drive file from ${url}: ${renderInlineError(err)}`);
|
||||
throw err;
|
||||
} finally {
|
||||
cleanup();
|
||||
|
|
|
|||
|
|
@ -164,6 +164,13 @@ export class EmailService {
|
|||
available: boolean;
|
||||
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | 'banned' | 'network' | 'blacklist';
|
||||
}> {
|
||||
if (!this.utilityService.validateEmailFormat(emailAddress)) {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'format',
|
||||
};
|
||||
}
|
||||
|
||||
const exist = await this.userProfilesRepository.countBy({
|
||||
emailVerified: true,
|
||||
email: emailAddress,
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import { DI } from '@/di-symbols.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { MiMeta } from '@/models/Meta.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
|
@ -30,10 +32,12 @@ type TimelineOptions = {
|
|||
alwaysIncludeMyNotes?: boolean;
|
||||
ignoreAuthorFromBlock?: boolean;
|
||||
ignoreAuthorFromMute?: boolean;
|
||||
ignoreAuthorFromInstanceBlock?: boolean;
|
||||
excludeNoFiles?: boolean;
|
||||
excludeReplies?: boolean;
|
||||
excludeBots?: boolean;
|
||||
excludePureRenotes: boolean;
|
||||
ignoreAuthorFromUserSuspension?: boolean;
|
||||
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
|
||||
};
|
||||
|
||||
|
|
@ -43,9 +47,13 @@ export class FanoutTimelineEndpointService {
|
|||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private cacheService: CacheService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +63,7 @@ export class FanoutTimelineEndpointService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
|
||||
async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
|
||||
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
|
||||
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);
|
||||
|
||||
|
|
@ -125,6 +133,36 @@ export class FanoutTimelineEndpointService {
|
|||
};
|
||||
}
|
||||
|
||||
{
|
||||
const parentFilter = filter;
|
||||
filter = (note) => {
|
||||
if (!ps.ignoreAuthorFromInstanceBlock) {
|
||||
if (note.userInstance?.isBlocked) return false;
|
||||
}
|
||||
if (note.userId !== note.renoteUserId && note.renoteUserInstance?.isBlocked) return false;
|
||||
if (note.userId !== note.replyUserId && note.replyUserInstance?.isBlocked) return false;
|
||||
|
||||
return parentFilter(note);
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const parentFilter = filter;
|
||||
filter = (note) => {
|
||||
const noteJoined = note as MiNote & {
|
||||
renoteUser: MiUser | null;
|
||||
replyUser: MiUser | null;
|
||||
};
|
||||
if (!ps.ignoreAuthorFromUserSuspension) {
|
||||
if (note.user!.isSuspended) return false;
|
||||
}
|
||||
if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false;
|
||||
if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false;
|
||||
|
||||
return parentFilter(note);
|
||||
};
|
||||
}
|
||||
|
||||
const redisTimeline: MiNote[] = [];
|
||||
let readFromRedis = 0;
|
||||
let lastSuccessfulRate = 1; // rateをキャッシュする?
|
||||
|
|
@ -174,7 +212,10 @@ export class FanoutTimelineEndpointService {
|
|||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
.leftJoinAndSelect('note.channel', 'channel')
|
||||
.leftJoinAndSelect('note.userInstance', 'userInstance')
|
||||
.leftJoinAndSelect('note.replyUserInstance', 'replyUserInstance')
|
||||
.leftJoinAndSelect('note.renoteUserInstance', 'renoteUserInstance');
|
||||
|
||||
const notes = (await query.getMany()).filter(noteFilter);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
export type FanoutTimelineName =
|
||||
export type FanoutTimelineName = (
|
||||
// home timeline
|
||||
| `homeTimeline:${string}`
|
||||
| `homeTimelineWithFiles:${string}` // only notes with files are included
|
||||
|
|
@ -37,6 +37,7 @@ export type FanoutTimelineName =
|
|||
|
||||
// role timelines
|
||||
| `roleTimeline:${string}` // any notes are included
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class FanoutTimelineService {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import * as Redis from 'ioredis';
|
|||
import type { MiGalleryPost, MiNote, MiUser } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||
export const GALLERY_POSTS_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||
|
|
@ -21,6 +22,8 @@ export class FeaturedService {
|
|||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +34,14 @@ export class FeaturedService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async updateRankingOf(name: string, windowRange: number, element: string, score = 1): Promise<void> {
|
||||
private async updateRankingOf(name: string, windowRange: number, element: string, score: number, userId: string | null): Promise<void> {
|
||||
if (userId) {
|
||||
const policies = await this.roleService.getUserPolicies(userId);
|
||||
if (!policies.canTrend) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const currentWindow = this.getCurrentWindow(windowRange);
|
||||
const redisTransaction = this.redisClient.multi();
|
||||
redisTransaction.zincrby(
|
||||
|
|
@ -89,28 +99,28 @@ export class FeaturedService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public updateGlobalNotesRanking(noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
public updateGlobalNotesRanking(note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateGalleryPostsRanking(galleryPostId: MiGalleryPost['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPostId, score);
|
||||
public updateGalleryPostsRanking(galleryPost: Pick<MiGalleryPost, 'id' | 'userId'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredGalleryPostsRanking', GALLERY_POSTS_RANKING_WINDOW, galleryPost.id, score, galleryPost.userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateInChannelNotesRanking(channelId: MiNote['channelId'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, noteId, score);
|
||||
public updateInChannelNotesRanking(channelId: MiNote['channelId'], note: Pick<MiNote, 'id' | 'userId'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredInChannelNotesRanking:${channelId}`, GLOBAL_NOTES_RANKING_WINDOW, note.id, score, note.userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updatePerUserNotesRanking(userId: MiUser['id'], noteId: MiNote['id'], score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
||||
public updatePerUserNotesRanking(userId: MiUser['id'], note: Pick<MiNote, 'id'>, score = 1): Promise<void> {
|
||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, note.id, score, userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score);
|
||||
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score, null);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -5,23 +5,24 @@
|
|||
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import type { InstancesRepository } from '@/models/_.js';
|
||||
import type { InstancesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { Serialized } from '@/types.js';
|
||||
import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js';
|
||||
|
||||
@Injectable()
|
||||
export class FederatedInstanceService implements OnApplicationShutdown {
|
||||
public federatedInstanceCache: RedisKVCache<MiInstance | null>;
|
||||
private readonly federatedInstanceCache: MemoryKVCache<MiInstance | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
|
@ -29,67 +30,46 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.federatedInstanceCache = new RedisKVCache<MiInstance | null>(this.redisClient, 'federatedInstance', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60 * 3, // 3m
|
||||
fetcher: (key) => this.instancesRepository.findOneBy({ host: key }),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed == null) return null;
|
||||
return {
|
||||
...parsed,
|
||||
firstRetrievedAt: new Date(parsed.firstRetrievedAt),
|
||||
latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null,
|
||||
infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null,
|
||||
notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null,
|
||||
};
|
||||
},
|
||||
});
|
||||
this.federatedInstanceCache = new MemoryKVCache(1000 * 60 * 3); // 3m
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetchOrRegister(host: string): Promise<MiInstance> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
const cached = this.federatedInstanceCache.get(host);
|
||||
if (cached) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
||||
let index = await this.instancesRepository.findOneBy({ host });
|
||||
if (index == null) {
|
||||
let i;
|
||||
try {
|
||||
i = await this.instancesRepository.insertOne({
|
||||
await this.instancesRepository.createQueryBuilder('instance')
|
||||
.insert()
|
||||
.values({
|
||||
id: this.idService.gen(),
|
||||
host,
|
||||
firstRetrievedAt: new Date(),
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof QueryFailedError) {
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
i = await this.instancesRepository.findOneBy({ host });
|
||||
}
|
||||
}
|
||||
isBlocked: this.utilityService.isBlockedHost(host),
|
||||
isSilenced: this.utilityService.isSilencedHost(host),
|
||||
isMediaSilenced: this.utilityService.isMediaSilencedHost(host),
|
||||
isAllowListed: this.utilityService.isAllowListedHost(host),
|
||||
isBubbled: this.utilityService.isBubbledHost(host),
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
if (i == null) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
this.federatedInstanceCache.set(host, i);
|
||||
return i;
|
||||
} else {
|
||||
this.federatedInstanceCache.set(host, index);
|
||||
return index;
|
||||
index = await this.instancesRepository.findOneByOrFail({ host });
|
||||
}
|
||||
|
||||
this.federatedInstanceCache.set(host, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance | null> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
const cached = this.federatedInstanceCache.get(host);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
|
@ -117,8 +97,35 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
this.federatedInstanceCache.set(result.host, result);
|
||||
}
|
||||
|
||||
private syncCache(before: Serialized<MiMeta | undefined>, after: Serialized<MiMeta>): void {
|
||||
const changed =
|
||||
diffArraysSimple(before?.blockedHosts, after.blockedHosts) ||
|
||||
diffArraysSimple(before?.silencedHosts, after.silencedHosts) ||
|
||||
diffArraysSimple(before?.mediaSilencedHosts, after.mediaSilencedHosts) ||
|
||||
diffArraysSimple(before?.federationHosts, after.federationHosts) ||
|
||||
diffArraysSimple(before?.bubbleInstances, after.bubbleInstances);
|
||||
|
||||
if (changed) {
|
||||
// We have to clear the whole thing, otherwise subdomains won't be synced.
|
||||
this.federatedInstanceCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
if (type === 'metaUpdated') {
|
||||
this.syncCache(body.before, body.after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
this.federatedInstanceCache.dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import * as Redis from 'ioredis';
|
||||
import { load as cheerio } from 'cheerio/slim';
|
||||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
|
@ -15,7 +15,8 @@ import { LoggerService } from '@/core/LoggerService.js';
|
|||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import type { DOMWindow } from 'jsdom';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import type { CheerioAPI } from 'cheerio/slim';
|
||||
|
||||
type NodeInfo = {
|
||||
openRegistrations?: unknown;
|
||||
|
|
@ -90,7 +91,7 @@ export class FetchInstanceMetadataService {
|
|||
}
|
||||
}
|
||||
|
||||
this.logger.info(`Fetching metadata of ${instance.host} ...`);
|
||||
this.logger.debug(`Fetching metadata of ${instance.host} ...`);
|
||||
|
||||
const [info, dom, manifest] = await Promise.all([
|
||||
this.fetchNodeinfo(instance).catch(() => null),
|
||||
|
|
@ -106,7 +107,7 @@ export class FetchInstanceMetadataService {
|
|||
this.getDescription(info, dom, manifest).catch(() => null),
|
||||
]);
|
||||
|
||||
this.logger.succ(`Successfuly fetched metadata of ${instance.host}`);
|
||||
this.logger.debug(`Successfuly fetched metadata of ${instance.host}`);
|
||||
|
||||
const updates = {
|
||||
infoUpdatedAt: new Date(),
|
||||
|
|
@ -128,9 +129,9 @@ export class FetchInstanceMetadataService {
|
|||
|
||||
await this.federatedInstanceService.update(instance.id, updates);
|
||||
|
||||
this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
|
||||
this.logger.info(`Successfully updated metadata of ${instance.host}`);
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
|
||||
this.logger.error(`Failed to update metadata of ${instance.host}: ${renderInlineError(e)}`);
|
||||
} finally {
|
||||
await this.unlock(host);
|
||||
}
|
||||
|
|
@ -138,7 +139,7 @@ export class FetchInstanceMetadataService {
|
|||
|
||||
@bindThis
|
||||
private async fetchNodeinfo(instance: MiInstance): Promise<NodeInfo> {
|
||||
this.logger.info(`Fetching nodeinfo of ${instance.host} ...`);
|
||||
this.logger.debug(`Fetching nodeinfo of ${instance.host} ...`);
|
||||
|
||||
try {
|
||||
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
|
||||
|
|
@ -170,28 +171,25 @@ export class FetchInstanceMetadataService {
|
|||
throw err.statusCode ?? err.message;
|
||||
});
|
||||
|
||||
this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`);
|
||||
this.logger.debug(`Successfuly fetched nodeinfo of ${instance.host}`);
|
||||
|
||||
return info as NodeInfo;
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`);
|
||||
this.logger.warn(`Failed to fetch nodeinfo of ${instance.host}: ${renderInlineError(err)}`);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchDom(instance: MiInstance): Promise<Document> {
|
||||
this.logger.info(`Fetching HTML of ${instance.host} ...`);
|
||||
private async fetchDom(instance: MiInstance): Promise<CheerioAPI> {
|
||||
this.logger.debug(`Fetching HTML of ${instance.host} ...`);
|
||||
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
const html = await this.httpRequestService.getHtml(url);
|
||||
|
||||
const { window } = new JSDOM(html);
|
||||
const doc = window.document;
|
||||
|
||||
return doc;
|
||||
return cheerio(html);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -206,12 +204,15 @@ export class FetchInstanceMetadataService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise<string | null> {
|
||||
private async fetchFaviconUrl(instance: MiInstance, doc: CheerioAPI | null): Promise<string | null> {
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
if (doc) {
|
||||
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
|
||||
const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href;
|
||||
const href = doc('link[rel][href]')
|
||||
.filter((_, link) => link.attribs.rel.split(' ').includes('icon'))
|
||||
.last()
|
||||
.attr('href');
|
||||
|
||||
if (href) {
|
||||
return (new URL(href, url)).href;
|
||||
|
|
@ -232,7 +233,7 @@ export class FetchInstanceMetadataService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async fetchIconUrl(instance: MiInstance, doc: CheerioAPI | 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;
|
||||
|
|
@ -242,13 +243,16 @@ export class FetchInstanceMetadataService {
|
|||
const url = 'https://' + instance.host;
|
||||
|
||||
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
|
||||
const links = Array.from(doc.getElementsByTagName('link')).reverse();
|
||||
const links = Array.from(doc('link[rel][href]')).reverse().map(link => ({
|
||||
rel: link.attribs.rel.split(' '),
|
||||
href: link.attribs.href,
|
||||
}));
|
||||
// https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559
|
||||
const href =
|
||||
[
|
||||
links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href,
|
||||
links.find(link => link.relList.contains('apple-touch-icon'))?.href,
|
||||
links.find(link => link.relList.contains('icon'))?.href,
|
||||
links.find(link => link.rel.includes('apple-touch-icon-precomposed'))?.href,
|
||||
links.find(link => link.rel.includes('apple-touch-icon'))?.href,
|
||||
links.find(link => link.rel.includes('icon'))?.href,
|
||||
]
|
||||
.find(href => href);
|
||||
|
||||
|
|
@ -261,8 +265,8 @@ export class FetchInstanceMetadataService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
|
||||
private async getThemeColor(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
const themeColor = info?.metadata?.themeColor ?? doc?.('meta[name="theme-color"][content]').attr('content') ?? manifest?.theme_color;
|
||||
|
||||
if (themeColor) {
|
||||
const color = new tinycolor(themeColor);
|
||||
|
|
@ -273,7 +277,7 @@ export class FetchInstanceMetadataService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async getSiteName(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (typeof info.metadata.nodeName === 'string') {
|
||||
return info.metadata.nodeName;
|
||||
|
|
@ -283,7 +287,7 @@ export class FetchInstanceMetadataService {
|
|||
}
|
||||
|
||||
if (doc) {
|
||||
const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content');
|
||||
const og = doc('meta[property="og:title"][content]').attr('content');
|
||||
|
||||
if (og) {
|
||||
return og;
|
||||
|
|
@ -298,7 +302,7 @@ export class FetchInstanceMetadataService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async getDescription(info: NodeInfo | null, doc: CheerioAPI | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (typeof info.metadata.nodeDescription === 'string') {
|
||||
return info.metadata.nodeDescription;
|
||||
|
|
@ -308,12 +312,12 @@ export class FetchInstanceMetadataService {
|
|||
}
|
||||
|
||||
if (doc) {
|
||||
const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content');
|
||||
const meta = doc('meta[name="description"][content]').attr('content');
|
||||
if (meta) {
|
||||
return meta;
|
||||
}
|
||||
|
||||
const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
|
||||
const og = doc('meta[property="og:description"][content]').attr('content');
|
||||
if (og) {
|
||||
return og;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,11 +46,13 @@ const TYPE_SVG = {
|
|||
@Injectable()
|
||||
export class FileInfoService {
|
||||
private logger: Logger;
|
||||
private ffprobeLogger: Logger;
|
||||
|
||||
constructor(
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('file-info');
|
||||
this.ffprobeLogger = this.logger.createSubLogger('ffprobe');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -162,20 +164,19 @@ export class FileInfoService {
|
|||
*/
|
||||
@bindThis
|
||||
private hasVideoTrackOnVideoFile(path: string): Promise<boolean> {
|
||||
const sublogger = this.logger.createSubLogger('ffprobe');
|
||||
sublogger.info(`Checking the video file. File path: ${path}`);
|
||||
this.ffprobeLogger.debug(`Checking the video file. File path: ${path}`);
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
FFmpeg.ffprobe(path, (err, metadata) => {
|
||||
if (err) {
|
||||
sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err);
|
||||
this.ffprobeLogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err);
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
resolve(metadata.streams.some((stream) => stream.codec_type === 'video'));
|
||||
});
|
||||
} catch (err) {
|
||||
sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error);
|
||||
this.ffprobeLogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js';
|
|||
import type { MiWebhook } from '@/models/Webhook.js';
|
||||
import type { MiSystemWebhook } from '@/models/SystemWebhook.js';
|
||||
import type { MiMeta } from '@/models/Meta.js';
|
||||
import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
|
||||
import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
|
@ -72,12 +72,8 @@ export interface MainEventTypes {
|
|||
readAllNotifications: undefined;
|
||||
notificationFlushed: undefined;
|
||||
unreadNotification: Packed<'Notification'>;
|
||||
unreadMention: MiNote['id'];
|
||||
readAllUnreadMentions: undefined;
|
||||
unreadSpecifiedNote: MiNote['id'];
|
||||
readAllUnreadSpecifiedNotes: undefined;
|
||||
readAllAntennas: undefined;
|
||||
unreadAntenna: MiAntenna;
|
||||
newChatMessage: Packed<'ChatMessage'>;
|
||||
readAllAnnouncements: undefined;
|
||||
myTokenRegenerated: undefined;
|
||||
signin: {
|
||||
|
|
@ -168,6 +164,21 @@ export interface AdminEventTypes {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ChatEventTypes {
|
||||
message: Packed<'ChatMessageLite'>;
|
||||
deleted: Packed<'ChatMessageLite'>['id'];
|
||||
react: {
|
||||
reaction: string;
|
||||
user?: Packed<'UserLite'>;
|
||||
messageId: MiChatMessage['id'];
|
||||
};
|
||||
unreact: {
|
||||
reaction: string;
|
||||
user?: Packed<'UserLite'>;
|
||||
messageId: MiChatMessage['id'];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReversiEventTypes {
|
||||
matched: {
|
||||
game: Packed<'ReversiGameDetailed'>;
|
||||
|
|
@ -207,7 +218,7 @@ export interface ReversiGameEventTypes {
|
|||
type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
|
||||
type EventUnionFromDictionary<
|
||||
T extends object,
|
||||
U = Events<T>
|
||||
U = Events<T>,
|
||||
> = U[keyof U];
|
||||
|
||||
type SerializedAll<T> = {
|
||||
|
|
@ -216,7 +227,7 @@ type SerializedAll<T> = {
|
|||
|
||||
type UndefinedAsNullAll<T> = {
|
||||
[K in keyof T]: T[K] extends undefined ? null : T[K];
|
||||
}
|
||||
};
|
||||
|
||||
export interface InternalEventTypes {
|
||||
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
|
||||
|
|
@ -254,6 +265,7 @@ export interface InternalEventTypes {
|
|||
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
||||
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
||||
quantumCacheUpdated: { name: string, keys: string[] };
|
||||
}
|
||||
|
||||
type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>;
|
||||
|
|
@ -300,6 +312,14 @@ export type GlobalEvents = {
|
|||
name: 'notesStream';
|
||||
payload: Serialized<Packed<'Note'>>;
|
||||
};
|
||||
chatUser: {
|
||||
name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`;
|
||||
payload: EventTypesToEventPayload<ChatEventTypes>;
|
||||
};
|
||||
chatRoom: {
|
||||
name: `chatRoomStream:${MiChatRoom['id']}`;
|
||||
payload: EventTypesToEventPayload<ChatEventTypes>;
|
||||
};
|
||||
reversi: {
|
||||
name: `reversiStream:${MiUser['id']}`;
|
||||
payload: EventTypesToEventPayload<ReversiEventTypes>;
|
||||
|
|
@ -334,12 +354,12 @@ export class GlobalEventService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private publish(channel: StreamChannels, type: string | null, value?: any): void {
|
||||
private async publish(channel: StreamChannels, type: string | null, value?: any): Promise<void> {
|
||||
const message = type == null ? value : value == null ?
|
||||
{ type: type, body: null } :
|
||||
{ type: type, body: value };
|
||||
|
||||
this.redisForPub.publish(this.config.host, JSON.stringify({
|
||||
await this.redisForPub.publish(this.config.host, JSON.stringify({
|
||||
channel: channel,
|
||||
message: message,
|
||||
}));
|
||||
|
|
@ -350,6 +370,11 @@ export class GlobalEventService {
|
|||
this.publish('internal', type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async publishInternalEventAsync<K extends keyof InternalEventTypes>(type: K, value?: InternalEventTypes[K]): Promise<void> {
|
||||
await this.publish('internal', type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishBroadcastStream<K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void {
|
||||
this.publish('broadcast', type, typeof value === 'undefined' ? null : value);
|
||||
|
|
@ -398,6 +423,16 @@ export class GlobalEventService {
|
|||
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishChatUserStream<K extends keyof ChatEventTypes>(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void {
|
||||
this.publish(`chatUserStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishChatRoomStream<K extends keyof ChatEventTypes>(toRoomId: MiChatRoom['id'], type: K, value?: ChatEventTypes[K]): void {
|
||||
this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public publishReversiStream<K extends keyof ReversiEventTypes>(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void {
|
||||
this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
|
|
|
|||
|
|
@ -12,20 +12,64 @@ import fetch from 'node-fetch';
|
|||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Config, PrivateNetwork } from '@/config.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import { IObject } from '@/core/activitypub/type.js';
|
||||
import { ApUtilityService } from './activitypub/ApUtilityService.js';
|
||||
import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
export type HttpRequestSendOptions = {
|
||||
throwErrorWhenResponseNotOk: boolean;
|
||||
validators?: ((res: Response) => void)[];
|
||||
};
|
||||
|
||||
export async function isPrivateUrl(url: URL, lookup: net.LookupFunction): Promise<boolean> {
|
||||
const ip = await resolveIp(url, lookup);
|
||||
return ip.range() !== 'unicast';
|
||||
}
|
||||
|
||||
export async function resolveIp(url: URL, lookup: net.LookupFunction) {
|
||||
if (ipaddr.isValid(url.hostname)) {
|
||||
return ipaddr.parse(url.hostname);
|
||||
}
|
||||
|
||||
const resolvedIp = await new Promise<string>((resolve, reject) => {
|
||||
lookup(url.hostname, {}, (err, address) => {
|
||||
if (err) reject(err);
|
||||
else resolve(address as string);
|
||||
});
|
||||
});
|
||||
|
||||
return ipaddr.parse(resolvedIp);
|
||||
}
|
||||
|
||||
export function isAllowedPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const { cidr, ports } of allowedPrivateNetworks ?? []) {
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(cidr)) {
|
||||
if (ports == null || (port != null && ports.includes(port))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
|
||||
export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void {
|
||||
const address = socket.remoteAddress;
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (isAllowedPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'node:http' {
|
||||
interface Agent {
|
||||
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
|
||||
|
|
@ -44,31 +88,12 @@ class HttpRequestServiceAgent extends http.Agent {
|
|||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||
const socket = super.createConnection(options, callback)
|
||||
.on('connect', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(address)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
validateSocketConnect(this.config.allowedPrivateNetworks, socket);
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
}
|
||||
|
||||
class HttpsRequestServiceAgent extends https.Agent {
|
||||
|
|
@ -83,31 +108,12 @@ class HttpsRequestServiceAgent extends https.Agent {
|
|||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||
const socket = super.createConnection(options, callback)
|
||||
.on('connect', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(address)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
validateSocketConnect(this.config.allowedPrivateNetworks, socket);
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -115,37 +121,43 @@ export class HttpRequestService {
|
|||
/**
|
||||
* Get http non-proxy agent (without local address filtering)
|
||||
*/
|
||||
private httpNative: http.Agent;
|
||||
private readonly httpNative: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https non-proxy agent (without local address filtering)
|
||||
*/
|
||||
private httpsNative: https.Agent;
|
||||
private readonly httpsNative: https.Agent;
|
||||
|
||||
/**
|
||||
* Get http non-proxy agent
|
||||
*/
|
||||
private http: http.Agent;
|
||||
private readonly http: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https non-proxy agent
|
||||
*/
|
||||
private https: https.Agent;
|
||||
private readonly https: https.Agent;
|
||||
|
||||
/**
|
||||
* Get http proxy or non-proxy agent
|
||||
*/
|
||||
public httpAgent: http.Agent;
|
||||
public readonly httpAgent: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https proxy or non-proxy agent
|
||||
*/
|
||||
public httpsAgent: https.Agent;
|
||||
public readonly httpsAgent: https.Agent;
|
||||
|
||||
/**
|
||||
* Get shared DNS resolver
|
||||
*/
|
||||
public readonly lookup: net.LookupFunction;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
private readonly apUtilityService: ApUtilityService,
|
||||
private readonly utilityService: UtilityService,
|
||||
) {
|
||||
const cache = new CacheableLookup({
|
||||
maxTtl: 3600, // 1hours
|
||||
|
|
@ -153,6 +165,8 @@ export class HttpRequestService {
|
|||
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||
});
|
||||
|
||||
this.lookup = cache.lookup as unknown as net.LookupFunction;
|
||||
|
||||
const agentOption = {
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
|
|
@ -198,7 +212,7 @@ export class HttpRequestService {
|
|||
/**
|
||||
* Get agent by URL
|
||||
* @param url URL
|
||||
* @param bypassProxy Allways bypass proxy
|
||||
* @param bypassProxy Always bypass proxy
|
||||
* @param isLocalAddressAllowed
|
||||
*/
|
||||
@bindThis
|
||||
|
|
@ -216,8 +230,40 @@ export class HttpRequestService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent for http by URL
|
||||
* @param url URL
|
||||
* @param isLocalAddressAllowed
|
||||
*/
|
||||
@bindThis
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
|
||||
public getAgentForHttp(url: URL, isLocalAddressAllowed = false): http.Agent {
|
||||
if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
||||
return isLocalAddressAllowed
|
||||
? this.httpNative
|
||||
: this.http;
|
||||
} else {
|
||||
return this.httpAgent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent for https by URL
|
||||
* @param url URL
|
||||
* @param isLocalAddressAllowed
|
||||
*/
|
||||
@bindThis
|
||||
public getAgentForHttps(url: URL, isLocalAddressAllowed = false): https.Agent {
|
||||
if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
||||
return isLocalAddressAllowed
|
||||
? this.httpsNative
|
||||
: this.https;
|
||||
} else {
|
||||
return this.httpsAgent;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise<IObjectWithId> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -235,9 +281,13 @@ export class HttpRequestService {
|
|||
|
||||
// Make sure the object ID matches the final URL (which is where it actually exists).
|
||||
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
||||
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
||||
if (allowAnonymous && activity.id == null) {
|
||||
activity.id = res.url;
|
||||
} else {
|
||||
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
||||
}
|
||||
|
||||
return activity;
|
||||
return activity as IObjectWithId;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -279,6 +329,7 @@ export class HttpRequestService {
|
|||
timeout?: number,
|
||||
size?: number,
|
||||
isLocalAddressAllowed?: boolean,
|
||||
allowHttp?: boolean,
|
||||
} = {},
|
||||
extra: HttpRequestSendOptions = {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
|
|
@ -287,6 +338,10 @@ export class HttpRequestService {
|
|||
): Promise<Response> {
|
||||
const timeout = args.timeout ?? 5000;
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const allowHttp = args.allowHttp || await isPrivateUrl(parsedUrl, this.lookup);
|
||||
this.utilityService.assertUrl(parsedUrl, allowHttp);
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
|
|
@ -294,7 +349,7 @@ export class HttpRequestService {
|
|||
|
||||
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
|
||||
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(parsedUrl, {
|
||||
method: args.method ?? 'GET',
|
||||
headers: {
|
||||
'User-Agent': this.config.userAgent,
|
||||
|
|
@ -307,7 +362,7 @@ export class HttpRequestService {
|
|||
});
|
||||
|
||||
if (!res.ok && extra.throwErrorWhenResponseNotOk) {
|
||||
throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
|
||||
throw new StatusError(`request error from ${url}`, res.status, res.statusText);
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { ulid } from 'ulid';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { genAid, isSafeAidT, parseAid } from '@/misc/id/aid.js';
|
||||
import { genAidx, isSafeAidxT, parseAidx } from '@/misc/id/aidx.js';
|
||||
import { genMeid, isSafeMeidT, parseMeid } from '@/misc/id/meid.js';
|
||||
import { genMeidg, isSafeMeidgT, parseMeidg } from '@/misc/id/meidg.js';
|
||||
import { genObjectId, isSafeObjectIdT, parseObjectId } from '@/misc/id/object-id.js';
|
||||
import { genAid, isSafeAidT, parseAid, parseAidFull } from '@/misc/id/aid.js';
|
||||
import { genAidx, isSafeAidxT, parseAidx, parseAidxFull } from '@/misc/id/aidx.js';
|
||||
import { genMeid, isSafeMeidT, parseMeid, parseMeidFull } from '@/misc/id/meid.js';
|
||||
import { genMeidg, isSafeMeidgT, parseMeidg, parseMeidgFull } from '@/misc/id/meidg.js';
|
||||
import { genObjectId, isSafeObjectIdT, parseObjectId, parseObjectIdFull } from '@/misc/id/object-id.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { parseUlid } from '@/misc/id/ulid.js';
|
||||
import { parseUlid, parseUlidFull } from '@/misc/id/ulid.js';
|
||||
|
||||
@Injectable()
|
||||
export class IdService {
|
||||
|
|
@ -70,4 +70,18 @@ export class IdService {
|
|||
default: throw new Error('unrecognized id generation method');
|
||||
}
|
||||
}
|
||||
|
||||
// Note: additional is at most 64 bits
|
||||
@bindThis
|
||||
public parseFull(id: string): { date: number; additional: bigint; } {
|
||||
switch (this.method) {
|
||||
case 'aid': return parseAidFull(id);
|
||||
case 'aidx': return parseAidxFull(id);
|
||||
case 'objectid': return parseObjectIdFull(id);
|
||||
case 'meid': return parseMeidFull(id);
|
||||
case 'meidg': return parseMeidgFull(id);
|
||||
case 'ulid': return parseUlidFull(id);
|
||||
default: throw new Error('unrecognized id generation method');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const webpDefault: sharp.WebpOptions = {
|
|||
smartSubsample: true,
|
||||
mixed: true,
|
||||
effort: 2,
|
||||
loop: 0,
|
||||
};
|
||||
|
||||
export const avifDefault: sharp.AvifOptions = {
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import { MemorySingleCache } from '@/misc/cache.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
const ACTOR_USERNAME = 'instance.actor' as const;
|
||||
|
||||
@Injectable()
|
||||
export class InstanceActorService {
|
||||
private cache: MemorySingleCache<MiLocalUser>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
) {
|
||||
this.cache = new MemorySingleCache<MiLocalUser>(Infinity);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async realLocalUsersPresent(): Promise<boolean> {
|
||||
return await this.usersRepository.existsBy({
|
||||
host: IsNull(),
|
||||
username: Not(ACTOR_USERNAME),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getInstanceActor(): Promise<MiLocalUser> {
|
||||
const cached = this.cache.get();
|
||||
if (cached) return cached;
|
||||
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
username: ACTOR_USERNAME,
|
||||
}) as MiLocalUser | undefined;
|
||||
|
||||
if (user) {
|
||||
this.cache.set(user);
|
||||
return user;
|
||||
} else {
|
||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as MiLocalUser;
|
||||
this.cache.set(created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
}
|
||||
103
packages/backend/src/core/InternalEventService.ts
Normal file
103
packages/backend/src/core/InternalEventService.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { GlobalEvents, InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
export type Listener<K extends keyof InternalEventTypes> = (value: InternalEventTypes[K], key: K, isLocal: boolean) => void | Promise<void>;
|
||||
|
||||
export interface ListenerProps {
|
||||
ignoreLocal?: boolean,
|
||||
ignoreRemote?: boolean,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InternalEventService implements OnApplicationShutdown {
|
||||
private readonly listeners = new Map<keyof InternalEventTypes, Map<Listener<keyof InternalEventTypes>, ListenerProps>>();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisForSub)
|
||||
private readonly redisForSub: Redis.Redis,
|
||||
|
||||
private readonly globalEventService: GlobalEventService,
|
||||
) {
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public on<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>, props?: ListenerProps): void {
|
||||
let set = this.listeners.get(type);
|
||||
if (!set) {
|
||||
set = new Map();
|
||||
this.listeners.set(type, set);
|
||||
}
|
||||
|
||||
// Functionally, this is just a set with metadata on the values.
|
||||
set.set(listener as Listener<keyof InternalEventTypes>, props ?? {});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public off<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>): void {
|
||||
this.listeners.get(type)?.delete(listener as Listener<keyof InternalEventTypes>);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async emit<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K]): Promise<void> {
|
||||
await this.emitInternal(type, value, true);
|
||||
await this.globalEventService.publishInternalEventAsync(type, { ...value, _pid: process.pid });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async emitInternal<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K], isLocal: boolean): Promise<void> {
|
||||
const listeners = this.listeners.get(type);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [listener, props] of listeners) {
|
||||
if ((isLocal && !props.ignoreLocal) || (!isLocal && !props.ignoreRemote)) {
|
||||
const promise = Promise.resolve(listener(value, type, isLocal));
|
||||
promises.push(promise);
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
if (!isLocalInternalEvent(body) || body._pid !== process.pid) {
|
||||
await this.emitInternal(type, body as InternalEventTypes[keyof InternalEventTypes], false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
interface LocalInternalEvent {
|
||||
_pid: number;
|
||||
}
|
||||
|
||||
function isLocalInternalEvent(body: object): body is LocalInternalEvent {
|
||||
return '_pid' in body && typeof(body._pid) === 'number';
|
||||
}
|
||||
|
|
@ -6,18 +6,11 @@
|
|||
import * as fs from 'node:fs';
|
||||
import { copyFile, unlink, writeFile, chmod } from 'node:fs/promises';
|
||||
import * as Path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const path = Path.resolve(_dirname, '../../../../files');
|
||||
|
||||
@Injectable()
|
||||
export class InternalStorageService {
|
||||
constructor(
|
||||
|
|
@ -25,12 +18,12 @@ export class InternalStorageService {
|
|||
private config: Config,
|
||||
) {
|
||||
// No one should erase the working directory *while the server is running*.
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
fs.mkdirSync(this.config.mediaDirectory, { recursive: true });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public resolvePath(key: string) {
|
||||
return Path.resolve(path, key);
|
||||
return Path.resolve(this.config.mediaDirectory, key);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { QueryService } from './QueryService.js';
|
||||
|
||||
@Injectable()
|
||||
export class LatestNoteService {
|
||||
|
|
@ -14,11 +15,12 @@ export class LatestNoteService {
|
|||
|
||||
constructor(
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
private readonly notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.latestNotesRepository)
|
||||
private latestNotesRepository: LatestNotesRepository,
|
||||
private readonly latestNotesRepository: LatestNotesRepository,
|
||||
|
||||
private readonly queryService: QueryService,
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('LatestNoteService');
|
||||
|
|
@ -91,7 +93,7 @@ export class LatestNoteService {
|
|||
|
||||
// Find the newest remaining note for the user.
|
||||
// We exclude DMs and pure renotes.
|
||||
const nextLatest = await this.notesRepository
|
||||
const query = this.notesRepository
|
||||
.createQueryBuilder('note')
|
||||
.select()
|
||||
.where({
|
||||
|
|
@ -106,18 +108,11 @@ export class LatestNoteService {
|
|||
? Not(null)
|
||||
: null,
|
||||
})
|
||||
.andWhere(`
|
||||
(
|
||||
note."renoteId" IS NULL
|
||||
OR note.text IS NOT NULL
|
||||
OR note.cw IS NOT NULL
|
||||
OR note."replyId" IS NOT NULL
|
||||
OR note."hasPoll"
|
||||
OR note."fileIds" != '{}'
|
||||
)
|
||||
`)
|
||||
.orderBy({ id: 'DESC' })
|
||||
.getOne();
|
||||
.orderBy({ id: 'DESC' });
|
||||
|
||||
this.queryService.andIsNotRenote(query, 'note');
|
||||
|
||||
const nextLatest = await query.getOne();
|
||||
if (!nextLatest) return;
|
||||
|
||||
// Record it as the latest
|
||||
|
|
|
|||
|
|
@ -3,19 +3,25 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { KEYWORD } from 'color-convert/conversions.js';
|
||||
import { envOption } from '@/env.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
||||
@Injectable()
|
||||
export class LoggerService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getLogger(domain: string, color?: KEYWORD | undefined) {
|
||||
return new Logger(domain, color);
|
||||
const verbose = this.config.logging?.verbose || envOption.verbose;
|
||||
return new Logger(domain, color, verbose);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
|
|
@ -12,6 +12,9 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import { diffArrays } from '@/misc/diff-arrays.js';
|
||||
import type { MetasRepository } from '@/models/_.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -26,6 +29,9 @@ export class MetaService implements OnApplicationShutdown {
|
|||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.metasRepository)
|
||||
private readonly metasRepository: MetasRepository,
|
||||
|
||||
private featuredService: FeaturedService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
|
|
@ -53,7 +59,7 @@ export class MetaService implements OnApplicationShutdown {
|
|||
case 'metaUpdated': {
|
||||
this.cache = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
||||
...(body.after),
|
||||
proxyAccount: null, // joinなカラムは通常取ってこないので
|
||||
rootUser: null, // joinなカラムは通常取ってこないので
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
|
@ -67,35 +73,35 @@ export class MetaService implements OnApplicationShutdown {
|
|||
public async fetch(noCache = false): Promise<MiMeta> {
|
||||
if (!noCache && this.cache) return this.cache;
|
||||
|
||||
return await this.db.transaction(async transactionalEntityManager => {
|
||||
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
|
||||
const metas = await transactionalEntityManager.find(MiMeta, {
|
||||
order: {
|
||||
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
|
||||
let meta = await this.metasRepository.createQueryBuilder('meta')
|
||||
.select()
|
||||
.orderBy({
|
||||
id: 'DESC',
|
||||
})
|
||||
.limit(1)
|
||||
.getOne();
|
||||
|
||||
if (!meta) {
|
||||
await this.metasRepository.createQueryBuilder('meta')
|
||||
.insert()
|
||||
.values({
|
||||
id: 'x',
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
meta = await this.metasRepository.createQueryBuilder('meta')
|
||||
.select()
|
||||
.orderBy({
|
||||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
})
|
||||
.limit(1)
|
||||
.getOneOrFail();
|
||||
}
|
||||
|
||||
const meta = metas[0];
|
||||
|
||||
if (meta) {
|
||||
this.cache = meta;
|
||||
return meta;
|
||||
} else {
|
||||
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
|
||||
const saved = await transactionalEntityManager
|
||||
.upsert(
|
||||
MiMeta,
|
||||
{
|
||||
id: 'x',
|
||||
},
|
||||
['id'],
|
||||
)
|
||||
.then((x) => transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0]));
|
||||
|
||||
this.cache = saved;
|
||||
return saved;
|
||||
}
|
||||
});
|
||||
this.cache = meta;
|
||||
return meta;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -103,7 +109,7 @@ export class MetaService implements OnApplicationShutdown {
|
|||
let before: MiMeta | undefined;
|
||||
|
||||
const updated = await this.db.transaction(async transactionalEntityManager => {
|
||||
const metas = await transactionalEntityManager.find(MiMeta, {
|
||||
const metas: (MiMeta | undefined)[] = await transactionalEntityManager.find(MiMeta, {
|
||||
order: {
|
||||
id: 'DESC',
|
||||
},
|
||||
|
|
@ -113,17 +119,24 @@ export class MetaService implements OnApplicationShutdown {
|
|||
|
||||
if (before) {
|
||||
await transactionalEntityManager.update(MiMeta, before.id, data);
|
||||
|
||||
const metas = await transactionalEntityManager.find(MiMeta, {
|
||||
order: {
|
||||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
return metas[0];
|
||||
} else {
|
||||
return await transactionalEntityManager.save(MiMeta, data);
|
||||
await transactionalEntityManager.save(MiMeta, {
|
||||
...data,
|
||||
id: 'x',
|
||||
});
|
||||
}
|
||||
|
||||
const afters = await transactionalEntityManager.find(MiMeta, {
|
||||
order: {
|
||||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
// Propagate changes to blockedHosts, silencedHosts, mediaSilencedHosts, federationInstances, and bubbleInstances to the relevant instance rows
|
||||
// Do this inside the transaction to avoid potential race condition (when an instance gets registered while we're updating).
|
||||
await this.persistBlocks(transactionalEntityManager, before ?? {}, afters[0]);
|
||||
|
||||
return afters[0];
|
||||
});
|
||||
|
||||
if (data.hiddenTags) {
|
||||
|
|
@ -156,4 +169,49 @@ export class MetaService implements OnApplicationShutdown {
|
|||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
private async persistBlocks(tem: EntityManager, before: Partial<MiMeta>, after: Partial<MiMeta>): Promise<void> {
|
||||
await this.persistBlock(tem, before.blockedHosts, after.blockedHosts, 'isBlocked');
|
||||
await this.persistBlock(tem, before.silencedHosts, after.silencedHosts, 'isSilenced');
|
||||
await this.persistBlock(tem, before.mediaSilencedHosts, after.mediaSilencedHosts, 'isMediaSilenced');
|
||||
await this.persistBlock(tem, before.federationHosts, after.federationHosts, 'isAllowListed');
|
||||
await this.persistBlock(tem, before.bubbleInstances, after.bubbleInstances, 'isBubbled');
|
||||
}
|
||||
|
||||
private async persistBlock(tem: EntityManager, before: string[] | undefined, after: string[] | undefined, field: keyof MiInstance): Promise<void> {
|
||||
const { added, removed } = diffArrays(before, after);
|
||||
|
||||
if (removed.length > 0) {
|
||||
await this.updateInstancesByHost(tem, field, false, removed);
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
await this.updateInstancesByHost(tem, field, true, added);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateInstancesByHost(tem: EntityManager, field: keyof MiInstance, value: boolean, hosts: string[]): Promise<void> {
|
||||
// Use non-array queries when possible, as they are indexed and can be much faster.
|
||||
if (hosts.length === 1) {
|
||||
const pattern = genHostPattern(hosts[0]);
|
||||
await tem
|
||||
.createQueryBuilder(MiInstance, 'instance')
|
||||
.update()
|
||||
.set({ [field]: value })
|
||||
.where('(lower(reverse("host")) || \'.\') LIKE :pattern', { pattern })
|
||||
.execute();
|
||||
} else if (hosts.length > 1) {
|
||||
const patterns = hosts.map(host => genHostPattern(host));
|
||||
await tem
|
||||
.createQueryBuilder(MiInstance, 'instance')
|
||||
.update()
|
||||
.set({ [field]: value })
|
||||
.where('(lower(reverse("host")) || \'.\') LIKE ANY (:patterns)', { patterns })
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function genHostPattern(host: string): string {
|
||||
return host.toLowerCase().split('').reverse().join('') + '.%';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,24 +5,23 @@
|
|||
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as parse5 from 'parse5';
|
||||
import { Window, XMLSerializer } from 'happy-dom';
|
||||
import { isText, isTag, Text } from 'domhandler';
|
||||
import * as htmlparser2 from 'htmlparser2';
|
||||
import { Node, Document, ChildNode, Element, ParentNode } from 'domhandler';
|
||||
import * as domserializer from 'dom-serializer';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { intersperse } from '@/misc/prelude/array.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { DefaultTreeAdapterMap } from 'parse5';
|
||||
import type * as mfm from '@transfem-org/sfm-js';
|
||||
|
||||
const treeAdapter = parse5.defaultTreeAdapter;
|
||||
type Node = DefaultTreeAdapterMap['node'];
|
||||
type ChildNode = DefaultTreeAdapterMap['childNode'];
|
||||
import type * as mfm from 'mfm-js';
|
||||
|
||||
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
||||
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
||||
|
||||
export type Appender = (document: Document, body: Element) => void;
|
||||
|
||||
@Injectable()
|
||||
export class MfmService {
|
||||
constructor(
|
||||
|
|
@ -38,7 +37,7 @@ export class MfmService {
|
|||
|
||||
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
|
||||
|
||||
const dom = parse5.parseFragment(html);
|
||||
const dom = htmlparser2.parseDocument(html);
|
||||
|
||||
let text = '';
|
||||
|
||||
|
|
@ -49,57 +48,50 @@ export class MfmService {
|
|||
return text.trim();
|
||||
|
||||
function getText(node: Node): string {
|
||||
if (treeAdapter.isTextNode(node)) return node.value;
|
||||
if (!treeAdapter.isElementNode(node)) return '';
|
||||
if (node.nodeName === 'br') return '\n';
|
||||
if (isText(node)) return node.data;
|
||||
if (!isTag(node)) return '';
|
||||
if (node.tagName === 'br') return '\n';
|
||||
|
||||
if (node.childNodes) {
|
||||
return node.childNodes.map(n => getText(n)).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
return node.childNodes.map(n => getText(n)).join('');
|
||||
}
|
||||
|
||||
function appendChildren(childNodes: ChildNode[]): void {
|
||||
if (childNodes) {
|
||||
for (const n of childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
for (const n of childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
}
|
||||
|
||||
function analyze(node: Node) {
|
||||
if (treeAdapter.isTextNode(node)) {
|
||||
text += node.value;
|
||||
if (isText(node)) {
|
||||
text += node.data;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip comment or document type node
|
||||
if (!treeAdapter.isElementNode(node)) {
|
||||
if (!isTag(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (node.nodeName) {
|
||||
switch (node.tagName) {
|
||||
case 'br': {
|
||||
text += '\n';
|
||||
break;
|
||||
return;
|
||||
}
|
||||
|
||||
case 'a': {
|
||||
const txt = getText(node);
|
||||
const rel = node.attrs.find(x => x.name === 'rel');
|
||||
const href = node.attrs.find(x => x.name === 'href');
|
||||
const rel = node.attribs.rel;
|
||||
const href = node.attribs.href;
|
||||
|
||||
// ハッシュタグ
|
||||
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
|
||||
text += txt;
|
||||
// メンション
|
||||
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
|
||||
} else if (txt.startsWith('@') && !(rel && rel.startsWith('me '))) {
|
||||
const part = txt.split('@');
|
||||
|
||||
if (part.length === 2 && href) {
|
||||
//#region ホスト名部分が省略されているので復元する
|
||||
const acct = `${txt}@${(new URL(href.value)).hostname}`;
|
||||
const acct = `${txt}@${(new URL(href)).hostname}`;
|
||||
text += acct;
|
||||
//#endregion
|
||||
} else if (part.length === 3) {
|
||||
|
|
@ -114,25 +106,32 @@ export class MfmService {
|
|||
if (!href) {
|
||||
return txt;
|
||||
}
|
||||
if (!txt || txt === href.value) { // #6383: Missing text node
|
||||
if (href.value.match(urlRegexFull)) {
|
||||
return href.value;
|
||||
if (!txt || txt === href) { // #6383: Missing text node
|
||||
if (href.match(urlRegexFull)) {
|
||||
return href;
|
||||
} else {
|
||||
return `<${href.value}>`;
|
||||
return `<${href}>`;
|
||||
}
|
||||
}
|
||||
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
|
||||
return `[${txt}](<${href.value}>)`; // #6846
|
||||
if (href.match(urlRegex) && !href.match(urlRegexFull)) {
|
||||
return `[${txt}](<${href}>)`; // #6846
|
||||
} else {
|
||||
return `[${txt}](${href.value})`;
|
||||
return `[${txt}](${href})`;
|
||||
}
|
||||
};
|
||||
|
||||
text += generateLink();
|
||||
}
|
||||
break;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't produce invalid empty MFM
|
||||
if (node.childNodes.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (node.tagName) {
|
||||
case 'h1': {
|
||||
text += '**【';
|
||||
appendChildren(node.childNodes);
|
||||
|
|
@ -179,18 +178,21 @@ export class MfmService {
|
|||
break;
|
||||
}
|
||||
|
||||
// this is here only to catch upstream changes!
|
||||
// this is here only to catch upstream changes!
|
||||
case 'ruby--': {
|
||||
let ruby: [string, string][] = [];
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeName === 'rp') {
|
||||
if (isText(child) && !/\s|\[|\]/.test(child.data)) {
|
||||
ruby.push([child.data, '']);
|
||||
continue;
|
||||
}
|
||||
if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
|
||||
ruby.push([child.value, '']);
|
||||
if (!isTag(child)) {
|
||||
continue;
|
||||
}
|
||||
if (child.nodeName === 'rt' && ruby.length > 0) {
|
||||
if (child.tagName === 'rp') {
|
||||
continue;
|
||||
}
|
||||
if (child.tagName === 'rt' && ruby.length > 0) {
|
||||
const rt = getText(child);
|
||||
if (/\s|\[|\]/.test(rt)) {
|
||||
// If any space is included in rt, it is treated as a normal text
|
||||
|
|
@ -215,7 +217,7 @@ export class MfmService {
|
|||
|
||||
// block code (<pre><code>)
|
||||
case 'pre': {
|
||||
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
|
||||
if (node.childNodes.length === 1 && isTag(node.childNodes[0]) && node.childNodes[0].tagName === 'code') {
|
||||
text += '\n```\n';
|
||||
text += getText(node.childNodes[0]);
|
||||
text += '\n```\n';
|
||||
|
|
@ -300,17 +302,17 @@ export class MfmService {
|
|||
let nonRtNodes = [];
|
||||
// scan children, ignore `rp`, split on `rt`
|
||||
for (const child of node.childNodes) {
|
||||
if (treeAdapter.isTextNode(child)) {
|
||||
if (isText(child)) {
|
||||
nonRtNodes.push(child);
|
||||
continue;
|
||||
}
|
||||
if (!treeAdapter.isElementNode(child)) {
|
||||
if (!isTag(child)) {
|
||||
continue;
|
||||
}
|
||||
if (child.nodeName === 'rp') {
|
||||
if (child.tagName === 'rp') {
|
||||
continue;
|
||||
}
|
||||
if (child.nodeName === 'rt') {
|
||||
if (child.tagName === 'rt') {
|
||||
// the only case in which we don't need a `$[group ]`
|
||||
// is when both sides of the ruby are simple words
|
||||
const needsGroup = nonRtNodes.length > 1 ||
|
||||
|
|
@ -333,6 +335,38 @@ export class MfmService {
|
|||
break;
|
||||
}
|
||||
|
||||
// Replace iframe with link so we can generate previews.
|
||||
// We shouldn't normally see this, but federated blogging platforms (WordPress, MicroBlog.Pub) can send it.
|
||||
case 'iframe': {
|
||||
const txt: string | undefined = node.attribs.title || node.attribs.alt;
|
||||
const href: string | undefined = node.attribs.src;
|
||||
if (href) {
|
||||
if (href.match(/[\s>]/)) {
|
||||
if (txt) {
|
||||
// href is invalid + has a label => render a pseudo-link
|
||||
text += `${text} (${href})`;
|
||||
} else {
|
||||
// href is invalid + no label => render plain text
|
||||
text += href;
|
||||
}
|
||||
} else {
|
||||
if (txt) {
|
||||
// href is valid + has a label => render a link
|
||||
const label = txt
|
||||
.replaceAll('[', '(')
|
||||
.replaceAll(']', ')')
|
||||
.replaceAll(/\r?\n/, ' ')
|
||||
.replaceAll('`', '\'');
|
||||
text += `[${label}](<${href}>)`;
|
||||
} else {
|
||||
// href is valid + no label => render a plain URL
|
||||
text += `<${href}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: // includes inline elements
|
||||
{
|
||||
appendChildren(node.childNodes);
|
||||
|
|
@ -343,50 +377,49 @@ export class MfmService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
|
||||
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = [], inline = false) {
|
||||
if (nodes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { happyDOM, window } = new Window();
|
||||
const doc = new Document([]);
|
||||
|
||||
const doc = window.document;
|
||||
const body = new Element('p', {});
|
||||
doc.childNodes.push(body);
|
||||
|
||||
const body = doc.createElement('p');
|
||||
|
||||
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);
|
||||
function appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
|
||||
for (const child of children.map(x => handle(x))) {
|
||||
targetElement.childNodes.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
function fnDefault(node: mfm.MfmFn) {
|
||||
const el = doc.createElement('i');
|
||||
const el = new Element('i', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
}
|
||||
|
||||
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
|
||||
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => ChildNode } = {
|
||||
bold: (node) => {
|
||||
const el = doc.createElement('b');
|
||||
const el = new Element('b', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
small: (node) => {
|
||||
const el = doc.createElement('small');
|
||||
const el = new Element('small', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
strike: (node) => {
|
||||
const el = doc.createElement('del');
|
||||
const el = new Element('del', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
italic: (node) => {
|
||||
const el = doc.createElement('i');
|
||||
const el = new Element('i', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
|
@ -397,11 +430,12 @@ export class MfmService {
|
|||
const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
|
||||
try {
|
||||
const date = new Date(parseInt(text, 10) * 1000);
|
||||
const el = doc.createElement('time');
|
||||
el.setAttribute('datetime', date.toISOString());
|
||||
el.textContent = date.toISOString();
|
||||
const el = new Element('time', {
|
||||
datetime: date.toISOString(),
|
||||
});
|
||||
el.childNodes.push(new Text(date.toISOString()));
|
||||
return el;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return fnDefault(node);
|
||||
}
|
||||
}
|
||||
|
|
@ -410,20 +444,20 @@ export class MfmService {
|
|||
if (node.children.length === 1) {
|
||||
const child = node.children[0];
|
||||
const text = child.type === 'text' ? child.props.text : '';
|
||||
const rubyEl = doc.createElement('ruby');
|
||||
const rtEl = doc.createElement('rt');
|
||||
const rubyEl = new Element('ruby', {});
|
||||
const rtEl = new Element('rt', {});
|
||||
|
||||
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
|
||||
const rpStartEl = doc.createElement('rp');
|
||||
rpStartEl.appendChild(doc.createTextNode('('));
|
||||
const rpEndEl = doc.createElement('rp');
|
||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
||||
const rpStartEl = new Element('rp', {});
|
||||
rpStartEl.childNodes.push(new Text('('));
|
||||
const rpEndEl = new Element('rp', {});
|
||||
rpEndEl.childNodes.push(new Text(')'));
|
||||
|
||||
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
|
||||
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
|
||||
rubyEl.appendChild(rpStartEl);
|
||||
rubyEl.appendChild(rtEl);
|
||||
rubyEl.appendChild(rpEndEl);
|
||||
rubyEl.childNodes.push(new Text(text.split(' ')[0]));
|
||||
rtEl.childNodes.push(new Text(text.split(' ')[1]));
|
||||
rubyEl.childNodes.push(rpStartEl);
|
||||
rubyEl.childNodes.push(rtEl);
|
||||
rubyEl.childNodes.push(rpEndEl);
|
||||
return rubyEl;
|
||||
} else {
|
||||
const rt = node.children.at(-1);
|
||||
|
|
@ -433,20 +467,20 @@ export class MfmService {
|
|||
}
|
||||
|
||||
const text = rt.type === 'text' ? rt.props.text : '';
|
||||
const rubyEl = doc.createElement('ruby');
|
||||
const rtEl = doc.createElement('rt');
|
||||
const rubyEl = new Element('ruby', {});
|
||||
const rtEl = new Element('rt', {});
|
||||
|
||||
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
|
||||
const rpStartEl = doc.createElement('rp');
|
||||
rpStartEl.appendChild(doc.createTextNode('('));
|
||||
const rpEndEl = doc.createElement('rp');
|
||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
||||
const rpStartEl = new Element('rp', {});
|
||||
rpStartEl.childNodes.push(new Text('('));
|
||||
const rpEndEl = new Element('rp', {});
|
||||
rpEndEl.childNodes.push(new Text(')'));
|
||||
|
||||
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
|
||||
rtEl.appendChild(doc.createTextNode(text.trim()));
|
||||
rubyEl.appendChild(rpStartEl);
|
||||
rubyEl.appendChild(rtEl);
|
||||
rubyEl.appendChild(rpEndEl);
|
||||
rtEl.childNodes.push(new Text(text.trim()));
|
||||
rubyEl.childNodes.push(rpStartEl);
|
||||
rubyEl.childNodes.push(rtEl);
|
||||
rubyEl.childNodes.push(rpEndEl);
|
||||
return rubyEl;
|
||||
}
|
||||
}
|
||||
|
|
@ -454,7 +488,7 @@ export class MfmService {
|
|||
// hack for ruby, should never be needed because we should
|
||||
// never send this out to other instances
|
||||
case 'group': {
|
||||
const el = doc.createElement('span');
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
}
|
||||
|
|
@ -466,394 +500,432 @@ export class MfmService {
|
|||
},
|
||||
|
||||
blockCode: (node) => {
|
||||
const pre = doc.createElement('pre');
|
||||
const inner = doc.createElement('code');
|
||||
inner.textContent = node.props.code;
|
||||
pre.appendChild(inner);
|
||||
const pre = new Element('pre', {});
|
||||
const inner = new Element('code', {});
|
||||
inner.childNodes.push(new Text(node.props.code));
|
||||
pre.childNodes.push(inner);
|
||||
return pre;
|
||||
},
|
||||
|
||||
center: (node) => {
|
||||
const el = doc.createElement('div');
|
||||
const el = new Element('div', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
emojiCode: (node) => {
|
||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||
return new Text(`\u200B:${node.props.name}:\u200B`);
|
||||
},
|
||||
|
||||
unicodeEmoji: (node) => {
|
||||
return doc.createTextNode(node.props.emoji);
|
||||
return new Text(node.props.emoji);
|
||||
},
|
||||
|
||||
hashtag: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
|
||||
a.textContent = `#${node.props.hashtag}`;
|
||||
a.setAttribute('rel', 'tag');
|
||||
const a = new Element('a', {
|
||||
href: `${this.config.url}/tags/${node.props.hashtag}`,
|
||||
rel: 'tag',
|
||||
});
|
||||
a.childNodes.push(new Text(`#${node.props.hashtag}`));
|
||||
return a;
|
||||
},
|
||||
|
||||
inlineCode: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.code;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.code));
|
||||
return el;
|
||||
},
|
||||
|
||||
mathInline: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.formula));
|
||||
return el;
|
||||
},
|
||||
|
||||
mathBlock: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.formula));
|
||||
return el;
|
||||
},
|
||||
|
||||
link: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', node.props.url);
|
||||
const a = new Element('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.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
|
||||
a.setAttribute('href', remoteUserInfo
|
||||
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
|
||||
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
|
||||
a.className = 'u-url mention';
|
||||
a.textContent = acct;
|
||||
|
||||
const a = new Element('a', {
|
||||
href: remoteUserInfo
|
||||
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
|
||||
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`,
|
||||
class: 'u-url mention',
|
||||
});
|
||||
a.childNodes.push(new Text(acct));
|
||||
return a;
|
||||
},
|
||||
|
||||
quote: (node) => {
|
||||
const el = doc.createElement('blockquote');
|
||||
const el = new Element('blockquote', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
text: (node) => {
|
||||
if (!node.props.text.match(/[\r\n]/)) {
|
||||
return doc.createTextNode(node.props.text);
|
||||
return new Text(node.props.text);
|
||||
}
|
||||
|
||||
const el = doc.createElement('span');
|
||||
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
|
||||
const el = new Element('span', {});
|
||||
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => new Text(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
url: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', node.props.url);
|
||||
a.textContent = node.props.url;
|
||||
const a = new Element('a', {
|
||||
href: node.props.url,
|
||||
});
|
||||
a.childNodes.push(new Text(node.props.url));
|
||||
return a;
|
||||
},
|
||||
|
||||
search: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
|
||||
a.textContent = node.props.content;
|
||||
const a = new Element('a', {
|
||||
href: `https://www.google.com/search?q=${node.props.query}`,
|
||||
});
|
||||
a.childNodes.push(new Text(node.props.content));
|
||||
return a;
|
||||
},
|
||||
|
||||
plain: (node) => {
|
||||
const el = doc.createElement('span');
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
};
|
||||
|
||||
// Utility function to make TypeScript behave
|
||||
function handle<T extends mfm.MfmNode>(node: T): ChildNode {
|
||||
const handler = handlers[node.type] as (node: T) => ChildNode;
|
||||
return handler(node);
|
||||
}
|
||||
|
||||
appendChildren(nodes, body);
|
||||
|
||||
const serialized = new XMLSerializer().serializeToString(body);
|
||||
for (const additionalAppender of additionalAppenders) {
|
||||
additionalAppender(doc, body);
|
||||
}
|
||||
|
||||
happyDOM.close().catch(err => {});
|
||||
let result = domserializer.render(body, {
|
||||
encodeEntities: 'utf8'
|
||||
});
|
||||
|
||||
return serialized;
|
||||
if (inline) {
|
||||
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
|
||||
// additionally modified by hazelnoot to remove async
|
||||
|
||||
@bindThis
|
||||
public async toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) {
|
||||
public toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) {
|
||||
if (nodes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { happyDOM, window } = new Window();
|
||||
const doc = new Document([]);
|
||||
|
||||
const doc = window.document;
|
||||
const body = new Element('p', {});
|
||||
doc.childNodes.push(body);
|
||||
|
||||
const body = doc.createElement('p');
|
||||
|
||||
async function appendChildren(children: mfm.MfmNode[], targetElement: any): Promise<void> {
|
||||
if (children) {
|
||||
for (const child of await Promise.all(children.map(async (x) => await (handlers as any)[x.type](x)))) targetElement.appendChild(child);
|
||||
function appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
|
||||
for (const child of children) {
|
||||
const result = handle(child);
|
||||
targetElement.childNodes.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const handlers: {
|
||||
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any;
|
||||
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => ChildNode;
|
||||
} = {
|
||||
async bold(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '**';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '**';
|
||||
bold(node) {
|
||||
const el = new Element('span', {});
|
||||
el.childNodes.push(new Text('**'));
|
||||
appendChildren(node.children, el);
|
||||
el.childNodes.push(new Text('**'));
|
||||
return el;
|
||||
},
|
||||
|
||||
async small(node) {
|
||||
const el = doc.createElement('small');
|
||||
await appendChildren(node.children, el);
|
||||
small(node) {
|
||||
const el = new Element('small', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
async strike(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '~~';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '~~';
|
||||
strike(node) {
|
||||
const el = new Element('span', {});
|
||||
el.childNodes.push(new Text('~~'));
|
||||
appendChildren(node.children, el);
|
||||
el.childNodes.push(new Text('~~'));
|
||||
return el;
|
||||
},
|
||||
|
||||
async italic(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '*';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '*';
|
||||
italic(node) {
|
||||
const el = new Element('span', {});
|
||||
el.childNodes.push(new Text('*'));
|
||||
appendChildren(node.children, el);
|
||||
el.childNodes.push(new Text('*'));
|
||||
return el;
|
||||
},
|
||||
|
||||
async fn(node) {
|
||||
fn(node) {
|
||||
switch (node.props.name) {
|
||||
case 'group': { // hack for ruby
|
||||
const el = doc.createElement('span');
|
||||
await appendChildren(node.children, el);
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
}
|
||||
case 'ruby': {
|
||||
if (node.children.length === 1) {
|
||||
const child = node.children[0];
|
||||
const text = child.type === 'text' ? child.props.text : '';
|
||||
const rubyEl = doc.createElement('ruby');
|
||||
const rtEl = doc.createElement('rt');
|
||||
const rubyEl = new Element('ruby', {});
|
||||
const rtEl = new Element('rt', {});
|
||||
|
||||
const rpStartEl = doc.createElement('rp');
|
||||
rpStartEl.appendChild(doc.createTextNode('('));
|
||||
const rpEndEl = doc.createElement('rp');
|
||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
||||
const rpStartEl = new Element('rp', {});
|
||||
rpStartEl.childNodes.push(new Text('('));
|
||||
const rpEndEl = new Element('rp', {});
|
||||
rpEndEl.childNodes.push(new Text(')'));
|
||||
|
||||
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
|
||||
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
|
||||
rubyEl.appendChild(rpStartEl);
|
||||
rubyEl.appendChild(rtEl);
|
||||
rubyEl.appendChild(rpEndEl);
|
||||
rubyEl.childNodes.push(new Text(text.split(' ')[0]));
|
||||
rtEl.childNodes.push(new Text(text.split(' ')[1]));
|
||||
rubyEl.childNodes.push(rpStartEl);
|
||||
rubyEl.childNodes.push(rtEl);
|
||||
rubyEl.childNodes.push(rpEndEl);
|
||||
return rubyEl;
|
||||
} else {
|
||||
const rt = node.children.at(-1);
|
||||
|
||||
if (!rt) {
|
||||
const el = doc.createElement('span');
|
||||
await appendChildren(node.children, el);
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
}
|
||||
|
||||
const text = rt.type === 'text' ? rt.props.text : '';
|
||||
const rubyEl = doc.createElement('ruby');
|
||||
const rtEl = doc.createElement('rt');
|
||||
const rubyEl = new Element('ruby', {});
|
||||
const rtEl = new Element('rt', {});
|
||||
|
||||
const rpStartEl = doc.createElement('rp');
|
||||
rpStartEl.appendChild(doc.createTextNode('('));
|
||||
const rpEndEl = doc.createElement('rp');
|
||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
||||
const rpStartEl = new Element('rp', {});
|
||||
rpStartEl.childNodes.push(new Text('('));
|
||||
const rpEndEl = new Element('rp', {});
|
||||
rpEndEl.childNodes.push(new Text(')'));
|
||||
|
||||
await appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
|
||||
rtEl.appendChild(doc.createTextNode(text.trim()));
|
||||
rubyEl.appendChild(rpStartEl);
|
||||
rubyEl.appendChild(rtEl);
|
||||
rubyEl.appendChild(rpEndEl);
|
||||
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
|
||||
rtEl.childNodes.push(new Text(text.trim()));
|
||||
rubyEl.childNodes.push(rpStartEl);
|
||||
rubyEl.childNodes.push(rtEl);
|
||||
rubyEl.childNodes.push(rpEndEl);
|
||||
return rubyEl;
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '*';
|
||||
await appendChildren(node.children, el);
|
||||
el.textContent += '*';
|
||||
const el = new Element('span', {});
|
||||
el.childNodes.push(new Text('*'));
|
||||
appendChildren(node.children, el);
|
||||
el.childNodes.push(new Text('*'));
|
||||
return el;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
blockCode(node) {
|
||||
const pre = doc.createElement('pre');
|
||||
const inner = doc.createElement('code');
|
||||
const pre = new Element('pre', {});
|
||||
const inner = new Element('code', {});
|
||||
|
||||
const nodes = node.props.code
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((x) => doc.createTextNode(x));
|
||||
.map((x) => new Text(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
inner.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
inner.childNodes.push(x === 'br' ? new Element('br', {}) : x);
|
||||
}
|
||||
|
||||
pre.appendChild(inner);
|
||||
pre.childNodes.push(inner);
|
||||
return pre;
|
||||
},
|
||||
|
||||
async center(node) {
|
||||
const el = doc.createElement('div');
|
||||
await appendChildren(node.children, el);
|
||||
center(node) {
|
||||
const el = new Element('div', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
emojiCode(node) {
|
||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||
return new Text(`\u200B:${node.props.name}:\u200B`);
|
||||
},
|
||||
|
||||
unicodeEmoji(node) {
|
||||
return doc.createTextNode(node.props.emoji);
|
||||
return new Text(node.props.emoji);
|
||||
},
|
||||
|
||||
hashtag: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
|
||||
a.textContent = `#${node.props.hashtag}`;
|
||||
a.setAttribute('rel', 'tag');
|
||||
a.setAttribute('class', 'hashtag');
|
||||
const a = new Element('a', {
|
||||
href: `${this.config.url}/tags/${node.props.hashtag}`,
|
||||
rel: 'tag',
|
||||
class: 'hashtag',
|
||||
});
|
||||
a.childNodes.push(new Text(`#${node.props.hashtag}`));
|
||||
return a;
|
||||
},
|
||||
|
||||
inlineCode(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.code;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.code));
|
||||
return el;
|
||||
},
|
||||
|
||||
mathInline(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.formula));
|
||||
return el;
|
||||
},
|
||||
|
||||
mathBlock(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.formula));
|
||||
return el;
|
||||
},
|
||||
|
||||
async link(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('href', node.props.url);
|
||||
await appendChildren(node.children, a);
|
||||
link(node) {
|
||||
const a = new Element('a', {
|
||||
rel: 'nofollow noopener noreferrer',
|
||||
target: '_blank',
|
||||
href: node.props.url,
|
||||
});
|
||||
appendChildren(node.children, a);
|
||||
return a;
|
||||
},
|
||||
|
||||
async mention(node) {
|
||||
mention(node) {
|
||||
const { username, host, acct } = node.props;
|
||||
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||
|
||||
const el = doc.createElement('span');
|
||||
const el = new Element('span', {});
|
||||
if (!resolved) {
|
||||
el.textContent = acct;
|
||||
el.childNodes.push(new Text(acct));
|
||||
} else {
|
||||
el.setAttribute('class', 'h-card');
|
||||
el.setAttribute('translate', 'no');
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
|
||||
a.className = 'u-url mention';
|
||||
const span = doc.createElement('span');
|
||||
span.textContent = resolved.username || username;
|
||||
a.textContent = '@';
|
||||
a.appendChild(span);
|
||||
el.appendChild(a);
|
||||
el.attribs.class = 'h-card';
|
||||
el.attribs.translate = 'no';
|
||||
const a = new Element('a', {
|
||||
href: resolved.url ? resolved.url : resolved.uri,
|
||||
class: 'u-url mention',
|
||||
});
|
||||
const span = new Element('span', {});
|
||||
span.childNodes.push(new Text(resolved.username || username));
|
||||
a.childNodes.push(new Text('@'));
|
||||
a.childNodes.push(span);
|
||||
el.childNodes.push(a);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
async quote(node) {
|
||||
const el = doc.createElement('blockquote');
|
||||
await appendChildren(node.children, el);
|
||||
quote(node) {
|
||||
const el = new Element('blockquote', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
text(node) {
|
||||
const el = doc.createElement('span');
|
||||
if (!node.props.text.match(/[\r\n]/)) {
|
||||
return new Text(node.props.text);
|
||||
}
|
||||
|
||||
const el = new Element('span', {});
|
||||
const nodes = node.props.text
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((x) => doc.createTextNode(x));
|
||||
.map((x) => new Text(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
url(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('href', node.props.url);
|
||||
a.textContent = node.props.url.replace(/^https?:\/\//, '');
|
||||
const a = new Element('a', {
|
||||
rel: 'nofollow noopener noreferrer',
|
||||
target: '_blank',
|
||||
href: node.props.url,
|
||||
});
|
||||
a.childNodes.push(new Text(node.props.url.replace(/^https?:\/\//, '')));
|
||||
return a;
|
||||
},
|
||||
|
||||
search: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
|
||||
a.textContent = node.props.content;
|
||||
const a = new Element('a', {
|
||||
href: `https://www.google.com/search?q=${node.props.query}`,
|
||||
});
|
||||
a.childNodes.push(new Text(node.props.content));
|
||||
return a;
|
||||
},
|
||||
|
||||
async plain(node) {
|
||||
const el = doc.createElement('span');
|
||||
await appendChildren(node.children, el);
|
||||
plain(node) {
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
};
|
||||
|
||||
await appendChildren(nodes, body);
|
||||
|
||||
if (quoteUri !== null) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', quoteUri);
|
||||
a.textContent = quoteUri.replace(/^https?:\/\//, '');
|
||||
|
||||
const quote = doc.createElement('span');
|
||||
quote.setAttribute('class', 'quote-inline');
|
||||
quote.appendChild(doc.createElement('br'));
|
||||
quote.appendChild(doc.createElement('br'));
|
||||
quote.innerHTML += 'RE: ';
|
||||
quote.appendChild(a);
|
||||
|
||||
body.appendChild(quote);
|
||||
// Utility function to make TypeScript behave
|
||||
function handle<T extends mfm.MfmNode>(node: T): ChildNode {
|
||||
const handler = handlers[node.type] as (node: T) => ChildNode;
|
||||
return handler(node);
|
||||
}
|
||||
|
||||
let result = new XMLSerializer().serializeToString(body);
|
||||
appendChildren(nodes, body);
|
||||
|
||||
if (quoteUri !== null) {
|
||||
const a = new Element('a', {
|
||||
href: quoteUri,
|
||||
});
|
||||
a.childNodes.push(new Text(quoteUri.replace(/^https?:\/\//, '')));
|
||||
|
||||
const quote = new Element('span', {
|
||||
class: 'quote-inline',
|
||||
});
|
||||
quote.childNodes.push(new Element('br', {}));
|
||||
quote.childNodes.push(new Element('br', {}));
|
||||
quote.childNodes.push(new Text('RE: '));
|
||||
quote.childNodes.push(a);
|
||||
|
||||
body.childNodes.push(quote);
|
||||
}
|
||||
|
||||
let result = domserializer.render(body, {
|
||||
encodeEntities: 'utf8'
|
||||
});
|
||||
|
||||
if (inline) {
|
||||
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
|
||||
}
|
||||
|
||||
happyDOM.close().catch(e => {});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { In, DataSource, IsNull, LessThan } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
|
@ -42,7 +42,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
|
@ -52,7 +51,7 @@ import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { trackTask } from '@/misc/promise-tracker.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { LatestNoteService } from '@/core/LatestNoteService.js';
|
||||
|
|
@ -203,7 +202,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
private globalEventService: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private noteReadService: NoteReadService,
|
||||
private notificationService: NotificationService,
|
||||
private relayService: RelayService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
|
|
@ -298,7 +296,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
case 'followers':
|
||||
// 他人のfollowers noteはreject
|
||||
if (data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
|
||||
}
|
||||
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
|
|
@ -306,7 +304,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
break;
|
||||
case 'specified':
|
||||
// specified / direct noteはreject
|
||||
throw new Error('Renote target is not public or home');
|
||||
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +317,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
if (data.renote.userId !== user.id) {
|
||||
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
|
||||
if (blocked) {
|
||||
throw new Error('blocked');
|
||||
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is blocked');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -491,10 +489,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
// should really not happen, but better safe than sorry
|
||||
if (data.reply?.id === insert.id) {
|
||||
throw new Error('A note can\'t reply to itself');
|
||||
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', 'A note can\'t reply to itself');
|
||||
}
|
||||
if (data.renote?.id === insert.id) {
|
||||
throw new Error('A note can\'t renote itself');
|
||||
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', 'A note can\'t renote itself');
|
||||
}
|
||||
|
||||
if (data.uri != null) insert.uri = data.uri;
|
||||
|
|
@ -541,7 +539,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
await this.notesRepository.insert(insert);
|
||||
}
|
||||
|
||||
return insert;
|
||||
// Re-fetch note to get the default values of null / unset fields.
|
||||
return await this.notesRepository.findOneByOrFail({ id: insert.id });
|
||||
} catch (e) {
|
||||
// duplicate key error
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
|
|
@ -550,8 +549,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
throw err;
|
||||
}
|
||||
|
||||
console.error(e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
@ -573,7 +570,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||
if (note.renote && note.text || !note.renote) {
|
||||
if (!this.isRenote(note) || this.isQuote(note)) {
|
||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
||||
}
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
|
|
@ -585,35 +582,35 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
// ハッシュタグ更新
|
||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
||||
if (user.isBot && this.meta.enableBotTrending) {
|
||||
this.hashtagService.updateHashtags(user, tags);
|
||||
} else if (!user.isBot) {
|
||||
if (!user.isBot || this.meta.enableBotTrending) {
|
||||
this.hashtagService.updateHashtags(user, tags);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.renote && data.text) {
|
||||
// Increment notes count (user)
|
||||
this.incNotesCountOfUser(user);
|
||||
} else if (!data.renote) {
|
||||
if (!this.isRenote(note) || this.isQuote(note)) {
|
||||
// Increment notes count (user)
|
||||
this.incNotesCountOfUser(user);
|
||||
} else {
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
}
|
||||
|
||||
this.pushToTl(note, user);
|
||||
|
||||
this.antennaService.addNoteToAntennas(note, user);
|
||||
this.antennaService.addNoteToAntennas({
|
||||
...note,
|
||||
channel: data.channel ?? null,
|
||||
}, user);
|
||||
|
||||
if (data.reply) {
|
||||
this.saveReply(data.reply, note);
|
||||
}
|
||||
|
||||
if (data.reply == null) {
|
||||
// TODO: キャッシュ
|
||||
this.followingsRepository.findBy({
|
||||
followeeId: user.id,
|
||||
notify: 'normal',
|
||||
}).then(async followings => {
|
||||
this.cacheService.userFollowersCache.fetch(user.id).then(async followingsMap => {
|
||||
const followings = Array
|
||||
.from(followingsMap.values())
|
||||
.filter(f => f.notify === 'normal');
|
||||
|
||||
if (note.visibility !== 'specified') {
|
||||
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
|
||||
for (const following of followings) {
|
||||
|
|
@ -633,8 +630,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
|
||||
if (data.renote && data.text == null && data.renote.userId !== user.id && !user.isBot) {
|
||||
this.incRenoteCount(data.renote);
|
||||
if (this.isRenote(data) && !this.isQuote(data) && data.renote.userId !== user.id && !user.isBot) {
|
||||
this.incRenoteCount(data.renote, user);
|
||||
}
|
||||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
|
|
@ -644,38 +641,20 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}, {
|
||||
jobId: `pollEnd:${note.id}`,
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
|
||||
|
||||
// 未読通知を作成
|
||||
if (data.visibility === 'specified') {
|
||||
if (data.visibleUsers == null) throw new Error('invalid param');
|
||||
|
||||
for (const u of data.visibleUsers) {
|
||||
// ローカルユーザーのみ
|
||||
if (!this.userEntityService.isLocalUser(u)) continue;
|
||||
|
||||
this.noteReadService.insertNoteUnread(u.id, note, {
|
||||
isSpecified: true,
|
||||
isMentioned: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const u of mentionedUsers) {
|
||||
// ローカルユーザーのみ
|
||||
if (!this.userEntityService.isLocalUser(u)) continue;
|
||||
|
||||
this.noteReadService.insertNoteUnread(u.id, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pack the note
|
||||
const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true });
|
||||
|
||||
|
|
@ -697,18 +676,15 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
// 通知
|
||||
if (data.reply.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: data.reply.userId,
|
||||
threadId: data.reply.threadId ?? data.reply.id,
|
||||
},
|
||||
});
|
||||
const threadId = data.reply.threadId ?? data.reply.id;
|
||||
|
||||
const [
|
||||
isThreadMuted,
|
||||
userIdsWhoMeMuting,
|
||||
] = data.reply.userId ? await Promise.all([
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
|
||||
this.cacheService.userMutingsCache.fetch(data.reply.userId),
|
||||
]) : [new Set<string>()];
|
||||
]);
|
||||
|
||||
const muted = isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
|
|
@ -726,20 +702,17 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
// Notify
|
||||
if (data.renote.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: data.renote.userId,
|
||||
threadId: data.renote.threadId ?? data.renote.id,
|
||||
},
|
||||
});
|
||||
const threadId = data.renote.threadId ?? data.renote.id;
|
||||
|
||||
const [
|
||||
isThreadMuted,
|
||||
userIdsWhoMeMuting,
|
||||
] = data.renote.userId ? await Promise.all([
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetch(data.renote.userId).then(ms => ms.has(threadId)),
|
||||
this.cacheService.userMutingsCache.fetch(data.renote.userId),
|
||||
]) : [new Set<string>()];
|
||||
]);
|
||||
|
||||
const muted = isUserRelated(note, userIdsWhoMeMuting);
|
||||
const muted = data.renote.userId && isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
if (!isThreadMuted && !muted) {
|
||||
nm.push(data.renote.userId, type);
|
||||
|
|
@ -757,8 +730,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
//#region AP deliver
|
||||
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
|
||||
(async () => {
|
||||
const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
|
||||
trackTask(async () => {
|
||||
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||
|
||||
// メンションされたリモートユーザーに配送
|
||||
|
|
@ -783,12 +756,12 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
dm.addFollowersRecipe();
|
||||
}
|
||||
|
||||
if (['public'].includes(note.visibility)) {
|
||||
this.relayService.deliverToRelays(user, noteActivity);
|
||||
}
|
||||
await dm.execute();
|
||||
|
||||
trackPromise(dm.execute());
|
||||
})();
|
||||
if (['public'].includes(note.visibility)) {
|
||||
await this.relayService.deliverToRelays(user, noteActivity);
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
|
@ -841,8 +814,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private incRenoteCount(renote: MiNote) {
|
||||
this.notesRepository.createQueryBuilder().update()
|
||||
private async incRenoteCount(renote: MiNote, user: MiUser) {
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
renoteCount: () => '"renoteCount" + 1',
|
||||
})
|
||||
|
|
@ -850,15 +823,18 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
.execute();
|
||||
|
||||
// 30%の確率、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||
if (renote.channelId != null) {
|
||||
if (renote.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote.id, 5);
|
||||
}
|
||||
} else {
|
||||
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(renote.id, 5);
|
||||
this.featuredService.updatePerUserNotesRanking(renote.userId, renote.id, 5);
|
||||
if (user.isExplorable && Math.random() < 0.3 && (Date.now() - this.idService.parse(renote.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3) {
|
||||
const policies = await this.roleService.getUserPolicies(user);
|
||||
if (policies.canTrend) {
|
||||
if (renote.channelId != null) {
|
||||
if (renote.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(renote.channelId, renote, 5);
|
||||
}
|
||||
} else {
|
||||
if (renote.visibility === 'public' && renote.userHost == null && renote.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(renote, 5);
|
||||
this.featuredService.updatePerUserNotesRanking(renote.userId, renote, 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -866,23 +842,23 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) {
|
||||
for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: u.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
},
|
||||
});
|
||||
const [
|
||||
threadMutings,
|
||||
userMutings,
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
|
||||
this.cacheService.userMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
|
||||
]);
|
||||
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
] = u.id ? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(u.id),
|
||||
]) : [new Set<string>()];
|
||||
// Only create mention events for local users, and users for whom the note is visible
|
||||
for (const u of mentionedUsers.filter(u => (note.visibility !== 'specified' || note.visibleUserIds.some(x => x === u.id)) && this.userEntityService.isLocalUser(u))) {
|
||||
const threadId = note.threadId ?? note.id;
|
||||
const isThreadMuted = threadMutings.get(u.id)?.has(threadId);
|
||||
|
||||
const muted = isUserRelated(note, userIdsWhoMeMuting);
|
||||
const mutings = userMutings.get(u.id);
|
||||
const isUserMuted = mutings != null && isUserRelated(note, mutings);
|
||||
|
||||
if (isThreadMuted || muted) {
|
||||
if (isThreadMuted || isUserMuted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -903,17 +879,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
|
||||
if (data.localOnly) return null;
|
||||
|
||||
const content = this.isRenote(data) && !this.isQuote(data)
|
||||
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
|
||||
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
|
||||
|
||||
return this.apRendererService.addContext(content);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private index(note: MiNote) {
|
||||
if (note.text == null && note.cw == null) return;
|
||||
|
|
@ -977,14 +942,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
// TODO: キャッシュ?
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [followings, userListMemberships] = await Promise.all([
|
||||
this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
}),
|
||||
this.cacheService.getNonHibernatedFollowers(user.id),
|
||||
this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
|
@ -1000,6 +958,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||
for (const following of followings) {
|
||||
if (following.followerHost !== null) continue;
|
||||
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||
|
||||
|
|
@ -1101,17 +1060,19 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
|
||||
if (hibernatedUsers.length > 0) {
|
||||
this.usersRepository.update({
|
||||
id: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isHibernated: true,
|
||||
});
|
||||
|
||||
this.followingsRepository.update({
|
||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isFollowerHibernated: true,
|
||||
});
|
||||
await Promise.all([
|
||||
this.usersRepository.update({
|
||||
id: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isHibernated: true,
|
||||
}),
|
||||
this.followingsRepository.update({
|
||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isFollowerHibernated: true,
|
||||
}),
|
||||
this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { Brackets, In, IsNull, Not } from 'typeorm';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
|
|
@ -124,9 +124,11 @@ export class NoteDeleteService {
|
|||
this.perUserNotesChart.update(user, note, false);
|
||||
}
|
||||
|
||||
if (note.renoteId && note.text || !note.renoteId) {
|
||||
if (!isRenote(note) || isQuote(note)) {
|
||||
// Decrement notes count (user)
|
||||
this.decNotesCountOfUser(user);
|
||||
} else {
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
}
|
||||
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
|
|
@ -165,8 +167,11 @@ export class NoteDeleteService {
|
|||
});
|
||||
}
|
||||
|
||||
if (note.uri) {
|
||||
this.apLogService.deleteObjectLogs(note.uri)
|
||||
const deletedUris = [note, ...cascadingNotes]
|
||||
.map(n => n.uri)
|
||||
.filter((u): u is string => u != null);
|
||||
if (deletedUris.length > 0) {
|
||||
this.apLogService.deleteObjectLogs(deletedUris)
|
||||
.catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`));
|
||||
}
|
||||
}
|
||||
|
|
@ -231,13 +236,27 @@ export class NoteDeleteService {
|
|||
}) as MiRemoteUser[];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getRenotedOrRepliedRemoteUsers(note: MiNote) {
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.leftJoinAndSelect('note.user', 'user')
|
||||
.where(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteId = :renoteId', { renoteId: note.id });
|
||||
qb.orWhere('note.replyId = :replyId', { replyId: note.id });
|
||||
}))
|
||||
.andWhere({ userHost: Not(IsNull()) });
|
||||
const notes = await query.getMany() as (MiNote & { user: MiRemoteUser })[];
|
||||
const remoteUsers = notes.map(({ user }) => user);
|
||||
return remoteUsers;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) {
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
const remoteUsers = await this.getMentionedRemoteUsers(note);
|
||||
for (const remoteUser of remoteUsers) {
|
||||
this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
|
||||
}
|
||||
await this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
await this.apDeliverManagerService.deliverToUsers(user, content, [
|
||||
...await this.getMentionedRemoteUsers(note),
|
||||
...await this.getRenotedOrRepliedRemoteUsers(note),
|
||||
]);
|
||||
await this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@
|
|||
*/
|
||||
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { DataSource, In, IsNull, LessThan } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
|
|
@ -36,7 +37,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
|
@ -46,7 +46,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { trackTask } from '@/misc/promise-tracker.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { LatestNoteService } from '@/core/LatestNoteService.js';
|
||||
|
|
@ -203,7 +203,6 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
private globalEventService: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private noteReadService: NoteReadService,
|
||||
private notificationService: NotificationService,
|
||||
private relayService: RelayService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
|
|
@ -233,7 +232,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
noindex: MiUser['noindex'];
|
||||
}, editid: MiNote['id'], data: Option, silent = false): Promise<MiNote> {
|
||||
if (!editid) {
|
||||
throw new Error('fail');
|
||||
throw new UnrecoverableError('edit failed: missing editid');
|
||||
}
|
||||
|
||||
const oldnote = await this.notesRepository.findOneBy({
|
||||
|
|
@ -241,11 +240,11 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
});
|
||||
|
||||
if (oldnote == null) {
|
||||
throw new Error('no such note');
|
||||
throw new UnrecoverableError(`edit failed for ${editid}: missing oldnote`);
|
||||
}
|
||||
|
||||
if (oldnote.userId !== user.id) {
|
||||
throw new Error('not the author');
|
||||
throw new UnrecoverableError(`edit failed for ${editid}: user is not the note author`);
|
||||
}
|
||||
|
||||
// we never want to change the replyId, so fetch the original "parent"
|
||||
|
|
@ -276,12 +275,12 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
|
||||
}
|
||||
|
||||
if (data.updatedAt == null) data.updatedAt = new Date();
|
||||
if (data.visibility == null) data.visibility = 'public';
|
||||
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 (data.updatedAt == null) data.updatedAt = new Date();
|
||||
|
||||
if (data.visibility === 'public' && data.channel == null) {
|
||||
const sensitiveWords = this.meta.sensitiveWords;
|
||||
|
|
@ -310,7 +309,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
|
||||
if (this.isRenote(data)) {
|
||||
if (data.renote.id === oldnote.id) {
|
||||
throw new Error('A note can\'t renote itself');
|
||||
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', `edit failed for ${oldnote.id}: cannot renote itself`);
|
||||
}
|
||||
|
||||
switch (data.renote.visibility) {
|
||||
|
|
@ -326,7 +325,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
case 'followers':
|
||||
// 他人のfollowers noteはreject
|
||||
if (data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
|
||||
}
|
||||
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
|
|
@ -334,7 +333,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
break;
|
||||
case 'specified':
|
||||
// specified / direct noteはreject
|
||||
throw new Error('Renote target is not public or home');
|
||||
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -406,10 +405,10 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
|
||||
// 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 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)!))
|
||||
? concat(data.poll.choices.map(choice => mfm.parse(choice)))
|
||||
: [];
|
||||
|
||||
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
|
||||
|
|
@ -424,7 +423,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
// if the host is media-silenced, custom emojis are not allowed
|
||||
if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = [];
|
||||
|
||||
tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32);
|
||||
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 this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
|
||||
|
|
@ -467,10 +466,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
update.hasPoll = !!data.poll;
|
||||
}
|
||||
|
||||
// technically we should check if the two sets of files are
|
||||
// different, or if their descriptions have changed. In practice
|
||||
// this is good enough.
|
||||
const filesChanged = oldnote.fileIds?.length || data.files?.length;
|
||||
// TODO deep-compare files
|
||||
const filesChanged = oldnote.fileIds.length || data.files?.length;
|
||||
|
||||
const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id });
|
||||
|
||||
|
|
@ -564,7 +561,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
noteVisibility: note.visibility,
|
||||
userId: user.id,
|
||||
userHost: user.host,
|
||||
channelId: data.channel ? data.channel.id : null,
|
||||
channelId: data.channel?.id ?? null,
|
||||
});
|
||||
|
||||
if (!oldnote.hasPoll) {
|
||||
|
|
@ -577,12 +574,15 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
await this.notesRepository.update(oldnote.id, note);
|
||||
}
|
||||
|
||||
// Re-fetch note to get the default values of null / unset fields.
|
||||
const edited = await this.notesRepository.findOneByOrFail({ id: note.id });
|
||||
|
||||
setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
|
||||
() => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!),
|
||||
() => this.postNoteEdited(edited, oldnote, user, data, silent, tags!, mentionedUsers!),
|
||||
() => { /* aborted, ignore this */ },
|
||||
);
|
||||
|
||||
return note;
|
||||
return edited;
|
||||
} else {
|
||||
return oldnote;
|
||||
}
|
||||
|
|
@ -610,6 +610,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
|
||||
// ハッシュタグ更新
|
||||
this.pushToTl(note, user);
|
||||
|
||||
|
|
@ -628,44 +630,12 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
if (!silent) {
|
||||
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
|
||||
|
||||
// 未読通知を作成
|
||||
if (data.visibility === 'specified') {
|
||||
if (data.visibleUsers == null) throw new Error('invalid param');
|
||||
|
||||
for (const u of data.visibleUsers) {
|
||||
// ローカルユーザーのみ
|
||||
if (!this.userEntityService.isLocalUser(u)) continue;
|
||||
|
||||
this.noteReadService.insertNoteUnread(u.id, note, {
|
||||
isSpecified: true,
|
||||
isMentioned: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const u of mentionedUsers) {
|
||||
// ローカルユーザーのみ
|
||||
if (!this.userEntityService.isLocalUser(u)) continue;
|
||||
|
||||
this.noteReadService.insertNoteUnread(u.id, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pack the note
|
||||
const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true });
|
||||
if (data.poll != null) {
|
||||
this.globalEventService.publishNoteStream(note.id, 'updated', {
|
||||
cw: note.cw,
|
||||
text: note.text!,
|
||||
});
|
||||
} else {
|
||||
this.globalEventService.publishNoteStream(note.id, 'updated', {
|
||||
cw: note.cw,
|
||||
text: note.text!,
|
||||
});
|
||||
}
|
||||
this.globalEventService.publishNoteStream(note.id, 'updated', {
|
||||
cw: note.cw,
|
||||
text: note.text ?? '',
|
||||
});
|
||||
|
||||
this.roleService.addNoteToRoleTimeline(noteObj);
|
||||
|
||||
|
|
@ -673,31 +643,25 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
|
||||
const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note);
|
||||
|
||||
//await this.createMentionedEvents(mentionedUsers, note, nm);
|
||||
|
||||
// If has in reply to note
|
||||
if (data.reply) {
|
||||
// 通知
|
||||
if (data.reply.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: data.reply.userId,
|
||||
threadId: data.reply.threadId ?? data.reply.id,
|
||||
},
|
||||
});
|
||||
const threadId = data.reply.threadId ?? data.reply.id;
|
||||
|
||||
const [
|
||||
isThreadMuted,
|
||||
userIdsWhoMeMuting,
|
||||
] = data.reply.userId ? await Promise.all([
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
|
||||
this.cacheService.userMutingsCache.fetch(data.reply.userId),
|
||||
]) : [new Set<string>()];
|
||||
]);
|
||||
|
||||
const muted = isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
if (!isThreadMuted && !muted) {
|
||||
nm.push(data.reply.userId, 'edited');
|
||||
this.globalEventService.publishMainStream(data.reply.userId, 'edited', noteObj);
|
||||
|
||||
this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj });
|
||||
}
|
||||
}
|
||||
|
|
@ -707,8 +671,8 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
|
||||
//#region AP deliver
|
||||
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
|
||||
(async () => {
|
||||
const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
|
||||
trackTask(async () => {
|
||||
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||
|
||||
// メンションされたリモートユーザーに配送
|
||||
|
|
@ -751,12 +715,12 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
if (['public'].includes(note.visibility)) {
|
||||
this.relayService.deliverToRelays(user, noteActivity);
|
||||
}
|
||||
await dm.execute();
|
||||
|
||||
trackPromise(dm.execute());
|
||||
})();
|
||||
if (['public'].includes(note.visibility)) {
|
||||
await this.relayService.deliverToRelays(user, noteActivity);
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
|
@ -803,52 +767,6 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
(note.files != null && note.files.length > 0);
|
||||
}
|
||||
|
||||
// TODO why is this unused?
|
||||
@bindThis
|
||||
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) {
|
||||
for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: u.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
] = u.id ? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(u.id),
|
||||
]) : [new Set<string>()];
|
||||
|
||||
const muted = isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
if (isThreadMuted || muted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const detailPackedNote = await this.noteEntityService.pack(note, u, {
|
||||
detail: true,
|
||||
});
|
||||
|
||||
this.globalEventService.publishMainStream(u.id, 'edited', detailPackedNote);
|
||||
this.webhookService.enqueueUserWebhook(u.id, 'edited', { note: detailPackedNote });
|
||||
|
||||
// Create notification
|
||||
nm.push(u.id, 'edited');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
|
||||
if (data.localOnly) return null;
|
||||
|
||||
const content = this.isRenote(data) && !this.isQuote(data)
|
||||
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
|
||||
: this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user);
|
||||
|
||||
return this.apRendererService.addContext(content);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private index(note: MiNote) {
|
||||
if (note.text == null && note.cw == null) return;
|
||||
|
|
@ -901,14 +819,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
// TODO: キャッシュ?
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [followings, userListMemberships] = await Promise.all([
|
||||
this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
}),
|
||||
this.cacheService.getNonHibernatedFollowers(user.id),
|
||||
this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
|
@ -924,6 +835,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
|
||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||
for (const following of followings) {
|
||||
if (following.followerHost !== null) continue;
|
||||
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||
|
||||
|
|
@ -1025,17 +937,19 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
});
|
||||
|
||||
if (hibernatedUsers.length > 0) {
|
||||
this.usersRepository.update({
|
||||
id: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isHibernated: true,
|
||||
});
|
||||
|
||||
this.followingsRepository.update({
|
||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isFollowerHibernated: true,
|
||||
});
|
||||
await Promise.all([
|
||||
this.usersRepository.update({
|
||||
id: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isHibernated: true,
|
||||
}),
|
||||
this.followingsRepository.update({
|
||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isFollowerHibernated: true,
|
||||
}),
|
||||
this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
|
|||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiUserNotePining } from '@/models/UserNotePining.js';
|
||||
import { MiUserNotePining } from '@/models/UserNotePining.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
|
|
@ -18,6 +18,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
|
|||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class NotePiningService {
|
||||
|
|
@ -34,6 +35,9 @@ export class NotePiningService {
|
|||
@Inject(DI.userNotePiningsRepository)
|
||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||
|
||||
@Inject(DI.db)
|
||||
private readonly db: DataSource,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private roleService: RoleService,
|
||||
|
|
@ -49,7 +53,7 @@ export class NotePiningService {
|
|||
* @param noteId
|
||||
*/
|
||||
@bindThis
|
||||
public async addPinned(user: { id: MiUser['id']; host: MiUser['host']; }, noteId: MiNote['id']) {
|
||||
public async addPinned(user: MiUser, noteId: MiNote['id']) {
|
||||
// Fetch pinee
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: noteId,
|
||||
|
|
@ -57,28 +61,30 @@ export class NotePiningService {
|
|||
});
|
||||
|
||||
if (note == null) {
|
||||
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
|
||||
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', `Note ${noteId} does not exist`);
|
||||
}
|
||||
|
||||
const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id });
|
||||
await this.db.transaction(async tem => {
|
||||
const pinings = await tem.findBy(MiUserNotePining, { userId: user.id });
|
||||
|
||||
if (pinings.length >= (await this.roleService.getUserPolicies(user.id)).pinLimit) {
|
||||
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
|
||||
}
|
||||
if (pinings.length >= (await this.roleService.getUserPolicies(user.id)).pinLimit) {
|
||||
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.');
|
||||
}
|
||||
if (pinings.some(pining => pining.noteId === note.id)) {
|
||||
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
|
||||
}
|
||||
|
||||
await this.userNotePiningsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
} as MiUserNotePining);
|
||||
await tem.insert(MiUserNotePining, {
|
||||
id: this.idService.gen(),
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
});
|
||||
});
|
||||
|
||||
// Deliver to remote followers
|
||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
||||
this.deliverPinnedChange(user.id, note.id, true);
|
||||
this.deliverPinnedChange(user, note.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +94,7 @@ export class NotePiningService {
|
|||
* @param noteId
|
||||
*/
|
||||
@bindThis
|
||||
public async removePinned(user: { id: MiUser['id']; host: MiUser['host']; }, noteId: MiNote['id']) {
|
||||
public async removePinned(user: MiUser, noteId: MiNote['id']) {
|
||||
// Fetch unpinee
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: noteId,
|
||||
|
|
@ -96,7 +102,7 @@ export class NotePiningService {
|
|||
});
|
||||
|
||||
if (note == null) {
|
||||
throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.');
|
||||
throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', `Note ${noteId} does not exist`);
|
||||
}
|
||||
|
||||
this.userNotePiningsRepository.delete({
|
||||
|
|
@ -106,22 +112,19 @@ export class NotePiningService {
|
|||
|
||||
// Deliver to remote followers
|
||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly && ['public', 'home'].includes(note.visibility)) {
|
||||
this.deliverPinnedChange(user.id, noteId, false);
|
||||
this.deliverPinnedChange(user, noteId, false);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async deliverPinnedChange(userId: MiUser['id'], noteId: MiNote['id'], isAddition: boolean) {
|
||||
const user = await this.usersRepository.findOneBy({ id: userId });
|
||||
if (user == null) throw new Error('user not found');
|
||||
|
||||
public async deliverPinnedChange(user: MiUser, noteId: MiNote['id'], isAddition: boolean) {
|
||||
if (!this.userEntityService.isLocalUser(user)) return;
|
||||
|
||||
const target = `${this.config.url}/users/${user.id}/collections/featured`;
|
||||
const item = `${this.config.url}/notes/${noteId}`;
|
||||
const content = this.apRendererService.addContext(isAddition ? this.apRendererService.renderAdd(user, target, item) : this.apRendererService.renderRemove(user, target, item));
|
||||
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
await this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
await this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
|
||||
@Injectable()
|
||||
export class NoteReadService implements OnApplicationShutdown {
|
||||
#shutdownController = new AbortController();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.noteUnreadsRepository)
|
||||
private noteUnreadsRepository: NoteUnreadsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async insertNoteUnread(userId: MiUser['id'], note: MiNote, params: {
|
||||
// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
|
||||
isSpecified: boolean;
|
||||
isMentioned: boolean;
|
||||
}): Promise<void> {
|
||||
//#region ミュートしているなら無視
|
||||
const mute = await this.mutingsRepository.findBy({
|
||||
muterId: userId,
|
||||
});
|
||||
if (mute.map(m => m.muteeId).includes(note.userId)) return;
|
||||
//#endregion
|
||||
|
||||
// スレッドミュート
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: userId,
|
||||
threadId: note.threadId ?? note.id,
|
||||
},
|
||||
});
|
||||
if (isThreadMuted) return;
|
||||
|
||||
const unread = {
|
||||
id: this.idService.gen(),
|
||||
noteId: note.id,
|
||||
userId: userId,
|
||||
isSpecified: params.isSpecified,
|
||||
isMentioned: params.isMentioned,
|
||||
noteUserId: note.userId,
|
||||
};
|
||||
|
||||
/* we may be called from NoteEditService, for a note that's
|
||||
already present in the `note_unread` table: `upsert` makes sure
|
||||
we don't throw a "duplicate key" error, while still updating
|
||||
the other columns if they've changed */
|
||||
await this.noteUnreadsRepository.upsert(unread, ['userId', 'noteId']);
|
||||
|
||||
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
|
||||
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
|
||||
const exist = await this.noteUnreadsRepository.exists({ where: { id: unread.id } });
|
||||
|
||||
if (!exist) return;
|
||||
|
||||
if (params.isMentioned) {
|
||||
this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);
|
||||
}
|
||||
if (params.isSpecified) {
|
||||
this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id);
|
||||
}
|
||||
}, () => { /* aborted, ignore it */ });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async read(
|
||||
userId: MiUser['id'],
|
||||
notes: (MiNote | Packed<'Note'>)[],
|
||||
): Promise<void> {
|
||||
if (notes.length === 0) return;
|
||||
|
||||
const noteIds = new Set<MiNote['id']>();
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.mentions && note.mentions.includes(userId)) {
|
||||
noteIds.add(note.id);
|
||||
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
|
||||
noteIds.add(note.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (noteIds.size === 0) return;
|
||||
|
||||
// Remove the record
|
||||
await this.noteUnreadsRepository.delete({
|
||||
userId: userId,
|
||||
noteId: In(Array.from(noteIds)),
|
||||
});
|
||||
|
||||
// TODO: ↓まとめてクエリしたい
|
||||
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isMentioned: true,
|
||||
}).then(mentionsCount => {
|
||||
if (mentionsCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
}));
|
||||
|
||||
trackPromise(this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isSpecified: true,
|
||||
}).then(specifiedCount => {
|
||||
if (specifiedCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { setTimeout } from 'node:timers/promises';
|
|||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { ReplyError } from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
|
|
@ -19,7 +20,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { UserListService } from '@/core/UserListService.js';
|
||||
import type { FilterUnionByProperty } from '@/types.js';
|
||||
import { FilterUnionByProperty, groupedNotificationTypes, obsoleteNotificationTypes } from '@/types.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -112,27 +113,27 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
if (recieveConfig?.type === 'following') {
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId));
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId));
|
||||
if (!isFollowing) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'follower') {
|
||||
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId));
|
||||
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId));
|
||||
if (!isFollower) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'mutualFollow') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)),
|
||||
]);
|
||||
if (!(isFollowing && isFollower)) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'followingOrFollower') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)),
|
||||
]);
|
||||
if (!isFollowing && !isFollower) {
|
||||
return null;
|
||||
|
|
@ -145,21 +146,36 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
const notification = {
|
||||
id: this.idService.gen(),
|
||||
createdAt: new Date(),
|
||||
type: type,
|
||||
...(notifierId ? {
|
||||
notifierId,
|
||||
} : {}),
|
||||
...data,
|
||||
} as any as FilterUnionByProperty<MiNotification, 'type', T>;
|
||||
const createdAt = new Date();
|
||||
let notification: FilterUnionByProperty<MiNotification, 'type', T>;
|
||||
let redisId: string;
|
||||
|
||||
const redisIdPromise = this.redisClient.xadd(
|
||||
`notificationTimeline:${notifieeId}`,
|
||||
'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(),
|
||||
'*',
|
||||
'data', JSON.stringify(notification));
|
||||
do {
|
||||
notification = {
|
||||
id: this.idService.gen(),
|
||||
createdAt,
|
||||
type: type,
|
||||
...(notifierId ? {
|
||||
notifierId,
|
||||
} : {}),
|
||||
...data,
|
||||
} as unknown as FilterUnionByProperty<MiNotification, 'type', T>;
|
||||
|
||||
try {
|
||||
redisId = (await this.redisClient.xadd(
|
||||
`notificationTimeline:${notifieeId}`,
|
||||
'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(),
|
||||
this.toXListId(notification.id, 0),
|
||||
'data', JSON.stringify(notification)))!;
|
||||
} catch (e) {
|
||||
// The ID specified in XADD is equal or smaller than the target stream top item で失敗することがあるのでリトライ
|
||||
if (e instanceof ReplyError) continue;
|
||||
throw e;
|
||||
}
|
||||
|
||||
break;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} while (true);
|
||||
|
||||
const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
|
||||
|
||||
|
|
@ -173,7 +189,7 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
const interval = notification.type === 'test' ? 0 : 2000;
|
||||
setTimeout(interval, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
|
||||
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
|
||||
if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return;
|
||||
if (latestReadNotificationId && (latestReadNotificationId >= redisId)) return;
|
||||
|
||||
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
|
||||
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
|
||||
|
|
@ -228,6 +244,79 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
this.#shutdownController.abort();
|
||||
}
|
||||
|
||||
private toXListId(id: string, offset: number): string {
|
||||
const { date, additional } = this.idService.parseFull(id);
|
||||
return (date + offset).toString() + '-' + additional.toString();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getNotifications(
|
||||
userId: MiUser['id'],
|
||||
{
|
||||
sinceId,
|
||||
untilId,
|
||||
limit = 20,
|
||||
includeTypes,
|
||||
excludeTypes,
|
||||
}: {
|
||||
sinceId?: string,
|
||||
untilId?: string,
|
||||
limit?: number,
|
||||
// any extra types are allowed, those are no-op
|
||||
includeTypes?: (MiNotification['type'] | string)[],
|
||||
excludeTypes?: (MiNotification['type'] | string)[],
|
||||
},
|
||||
): Promise<MiNotification[]> {
|
||||
let sinceTime = sinceId ? this.toXListId(sinceId, 1) : null;
|
||||
let untilTime = untilId ? this.toXListId(untilId, -1) : null;
|
||||
|
||||
let notifications: MiNotification[];
|
||||
for (;;) {
|
||||
let notificationsRes: [id: string, fields: string[]][];
|
||||
|
||||
// sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照
|
||||
if (sinceTime && !untilTime) {
|
||||
notificationsRes = await this.redisClient.xrange(
|
||||
`notificationTimeline:${userId}`,
|
||||
'(' + sinceTime,
|
||||
'+',
|
||||
'COUNT', limit);
|
||||
} else {
|
||||
notificationsRes = await this.redisClient.xrevrange(
|
||||
`notificationTimeline:${userId}`,
|
||||
untilTime ? '(' + untilTime : '+',
|
||||
sinceTime ? '(' + sinceTime : '-',
|
||||
'COUNT', limit);
|
||||
}
|
||||
|
||||
if (notificationsRes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[];
|
||||
|
||||
if (includeTypes && includeTypes.length > 0) {
|
||||
notifications = notifications.filter(notification => includeTypes.includes(notification.type));
|
||||
} else if (excludeTypes && excludeTypes.length > 0) {
|
||||
notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
|
||||
}
|
||||
|
||||
if (notifications.length !== 0) {
|
||||
// 通知が1件以上ある場合は返す
|
||||
break;
|
||||
}
|
||||
|
||||
// フィルタしたことで通知が0件になった場合、次のページを取得する
|
||||
if (sinceId && !untilId) {
|
||||
sinceTime = notificationsRes[notificationsRes.length - 1][0];
|
||||
} else {
|
||||
untilTime = notificationsRes[notificationsRes.length - 1][0];
|
||||
}
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
|
|
|
|||
|
|
@ -90,10 +90,7 @@ export class PollService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async deliverQuestionUpdate(noteId: MiNote['id']) {
|
||||
const note = await this.notesRepository.findOneBy({ id: noteId });
|
||||
if (note == null) throw new Error('note not found');
|
||||
|
||||
public async deliverQuestionUpdate(note: MiNote) {
|
||||
if (note.localOnly) return;
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
|
|
@ -101,8 +98,8 @@ export class PollService {
|
|||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, user, false), user));
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
await this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
await this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { MiMeta, UsersRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class ProxyAccountService {
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(): Promise<MiLocalUser | null> {
|
||||
if (this.meta.proxyAccountId == null) return null;
|
||||
return await this.usersRepository.findOneByOrFail({ id: this.meta.proxyAccountId }) as MiLocalUser;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||
import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||
|
||||
// Defined also packages/sw/types.ts#L13
|
||||
type PushNotificationsTypes = {
|
||||
|
|
@ -22,6 +23,7 @@ type PushNotificationsTypes = {
|
|||
note: Packed<'Note'>;
|
||||
};
|
||||
'readAllNotifications': undefined;
|
||||
newChatMessage: Packed<'ChatMessage'>;
|
||||
};
|
||||
|
||||
// Reduce length because push message servers have character limits
|
||||
|
|
@ -47,7 +49,7 @@ function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: Pus
|
|||
|
||||
@Injectable()
|
||||
export class PushNotificationService implements OnApplicationShutdown {
|
||||
private subscriptionsCache: RedisKVCache<MiSwSubscription[]>;
|
||||
private subscriptionsCache: QuantumKVCache<MiSwSubscription[]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
|
|
@ -61,13 +63,11 @@ export class PushNotificationService implements OnApplicationShutdown {
|
|||
|
||||
@Inject(DI.swSubscriptionsRepository)
|
||||
private swSubscriptionsRepository: SwSubscriptionsRepository,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
) {
|
||||
this.subscriptionsCache = new RedisKVCache<MiSwSubscription[]>(this.redisClient, 'userSwSubscriptions', {
|
||||
this.subscriptionsCache = new QuantumKVCache<MiSwSubscription[]>(this.internalEventService, 'userSwSubscriptions', {
|
||||
lifetime: 1000 * 60 * 60 * 1, // 1h
|
||||
memoryCacheLifetime: 1000 * 60 * 3, // 3m
|
||||
fetcher: (key) => this.swSubscriptionsRepository.findBy({ userId: key }),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -113,8 +113,8 @@ export class PushNotificationService implements OnApplicationShutdown {
|
|||
endpoint: subscription.endpoint,
|
||||
auth: subscription.auth,
|
||||
publickey: subscription.publickey,
|
||||
}).then(() => {
|
||||
this.refreshCache(userId);
|
||||
}).then(async () => {
|
||||
await this.refreshCache(userId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -122,8 +122,8 @@ export class PushNotificationService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public refreshCache(userId: string): void {
|
||||
this.subscriptionsCache.refresh(userId);
|
||||
public async refreshCache(userId: string): Promise<void> {
|
||||
await this.subscriptionsCache.refresh(userId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets, ObjectLiteral } from 'typeorm';
|
||||
import { Brackets, Not, WhereExpressionBuilder } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class QueryService {
|
||||
|
|
@ -36,219 +37,553 @@ export class QueryService {
|
|||
@Inject(DI.renoteMutingsRepository)
|
||||
private renoteMutingsRepository: RenoteMutingsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private readonly instancesRepository: InstancesRepository,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder<T> {
|
||||
public makePaginationQuery<T extends ObjectLiteral>(
|
||||
q: SelectQueryBuilder<T>,
|
||||
sinceId?: string | null,
|
||||
untilId?: string | null,
|
||||
sinceDate?: number | null,
|
||||
untilDate?: number | null,
|
||||
targetColumn = 'id',
|
||||
): SelectQueryBuilder<T> {
|
||||
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');
|
||||
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
|
||||
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
} else if (sinceId) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
|
||||
q.orderBy(`${q.alias}.id`, 'ASC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
|
||||
} else if (untilId) {
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
} else if (sinceDate && untilDate) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
|
||||
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
} else if (sinceDate) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
|
||||
q.orderBy(`${q.alias}.id`, 'ASC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
|
||||
} else if (untilDate) {
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
} else {
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
// ここでいうBlockedは被Blockedの意
|
||||
@bindThis
|
||||
public generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
|
||||
public generateBlockedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
// 投稿の作者にブロックされていない かつ
|
||||
// 投稿の返信先の作者にブロックされていない かつ
|
||||
// 投稿の引用元の作者にブロックされていない
|
||||
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());
|
||||
return this
|
||||
.andNotBlockingUser(q, 'note.userId', ':meId')
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotBlockingUser(qb, 'note.replyUserId', ':meId')
|
||||
.orWhere('note.replyUserId IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotBlockingUser(qb, 'note.renoteUserId', ':meId')
|
||||
.orWhere('note.renoteUserId IS NULL')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockeeId')
|
||||
.where('blocking.blockerId = :blockerId', { blockerId: me.id });
|
||||
|
||||
const blockedQuery = this.blockingsRepository.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());
|
||||
public generateBlockQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
this.andNotBlockingUser(q, ':meId', 'user.id');
|
||||
this.andNotBlockingUser(q, 'user.id', ':meId');
|
||||
return q.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutedQuery = this.noteThreadMutingsRepository.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());
|
||||
public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
return this
|
||||
.andNotMutingThread(q, ':meId', 'note.id')
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingThread(qb, ':meId', 'note.threadId')
|
||||
.orWhere('note.threadId IS NULL')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
if (exclude) {
|
||||
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
|
||||
}
|
||||
|
||||
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
||||
.select('user_profile.mutedInstances')
|
||||
.where('user_profile.userId = :muterId', { muterId: me.id });
|
||||
|
||||
public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
// 投稿の作者をミュートしていない かつ
|
||||
// 投稿の返信先の作者をミュートしていない かつ
|
||||
// 投稿の引用元の作者をミュートしていない
|
||||
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() })`);
|
||||
}))
|
||||
return this
|
||||
.andNotMutingUser(q, ':meId', 'note.userId', exclude)
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude)
|
||||
.orWhere('note.replyUserId IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude)
|
||||
.orWhere('note.renoteUserId IS NULL')))
|
||||
// TODO exclude should also pass a host to skip these instances
|
||||
// mute instances
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.renoteUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
q.setParameters(mutingInstanceQuery.getParameters());
|
||||
.andWhere(new Brackets(qb => this
|
||||
.andNotMutingInstance(qb, ':meId', 'note.userHost')
|
||||
.orWhere('note.userHost IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingInstance(qb, ':meId', 'note.replyUserHost')
|
||||
.orWhere('note.replyUserHost IS NULL')))
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingInstance(qb, ':meId', 'note.renoteUserHost')
|
||||
.orWhere('note.renoteUserHost IS NULL')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
public generateMutedUserQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
return this
|
||||
.andNotMutingUser(q, ':meId', 'user.id')
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
// This intentionally skips isSuspended, isDeleted, makeNotesFollowersOnlyBefore, makeNotesHiddenBefore, and requireSigninToViewContents.
|
||||
// NoteEntityService checks these automatically and calls hideNote() to hide them without breaking threads.
|
||||
// For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user.
|
||||
@bindThis
|
||||
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
|
||||
public generateVisibilityQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
|
||||
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}));
|
||||
} else {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :meId');
|
||||
return q.andWhere(new Brackets(qb => {
|
||||
// Public post
|
||||
qb.orWhere('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
|
||||
q.andWhere(new Brackets(qb => {
|
||||
if (me != null) {
|
||||
qb
|
||||
// 公開投稿である
|
||||
.where(new Brackets(qb => {
|
||||
qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
// または 自分自身
|
||||
.orWhere('note.userId = :meId')
|
||||
// または 自分宛て
|
||||
// My post
|
||||
.orWhere(':meId = note.userId')
|
||||
// Visible to me
|
||||
.orWhere(':meIdAsList <@ note.visibleUserIds')
|
||||
.orWhere(':meIdAsList <@ note.mentions')
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
.where('note.visibility = \'followers\'')
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
// 自分がフォロワーである
|
||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
||||
// または 自分の投稿へのリプライ
|
||||
.orWhere('note.replyUserId = :meId');
|
||||
}));
|
||||
}));
|
||||
// Followers-only post
|
||||
.orWhere(new Brackets(qb => qb
|
||||
.andWhere(new Brackets(qbb => this
|
||||
// Following author
|
||||
.orFollowingUser(qbb, ':meId', 'note.userId')
|
||||
// Mentions me
|
||||
.orWhere(':meIdAsList <@ note.mentions')
|
||||
// Reply to me
|
||||
.orWhere(':meId = note.replyUserId')))
|
||||
.andWhere('note.visibility = \'followers\'')));
|
||||
|
||||
q.setParameters({ meId: me.id, meIdAsList: [me.id] });
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||
return q
|
||||
.andWhere(new Brackets(qb => this
|
||||
.orNotMutingRenote(qb, ':meId', 'note.userId')
|
||||
.orWhere('note.renoteId IS NULL')
|
||||
.orWhere('note.text IS NOT NULL')
|
||||
.orWhere('note.cw IS NOT NULL')
|
||||
.orWhere('note.replyId IS NOT NULL')
|
||||
.orWhere('note.hasPoll = true')
|
||||
.orWhere('note.fileIds != \'{}\'')))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateExcludedRenotesQueryForNotes<Q extends WhereExpressionBuilder>(q: Q): Q {
|
||||
return this.andIsNotRenote(q, 'note');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateBlockedHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): SelectQueryBuilder<E> {
|
||||
const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this
|
||||
.leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.orWhere(`"${key}Instance" IS NULL`) // local
|
||||
.orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked
|
||||
|
||||
if (excludeAuthor) {
|
||||
qb.orWhere(`note.userId = note.${key}Id`); // author
|
||||
}
|
||||
}));
|
||||
|
||||
q.setParameters({ meId: me.id, meIdAsList: [me.id] });
|
||||
if (!excludeAuthor) {
|
||||
checkFor('user');
|
||||
}
|
||||
checkFor('replyUser');
|
||||
checkFor('renoteUser');
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
|
||||
if (!me) {
|
||||
return q.andWhere('user.isSilenced = false');
|
||||
}
|
||||
|
||||
return this
|
||||
.leftJoinInstance(q, 'note.userInstance', 'userInstance')
|
||||
.andWhere(new Brackets(qb => this
|
||||
// case 1: we are following the user
|
||||
.orFollowingUser(qb, ':meId', 'note.userId')
|
||||
// case 2: user not silenced AND instance not silenced
|
||||
.orWhere(new Brackets(qbb => qbb
|
||||
.andWhere(new Brackets(qbbb => qbbb
|
||||
.orWhere('"userInstance"."isSilenced" = false')
|
||||
.orWhere('"userInstance" IS NULL')))
|
||||
.andWhere('user.isSilenced = false')))))
|
||||
.setParameters({ meId: me.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-joins an instance in to the query with a given alias and optional condition.
|
||||
* These calls are de-duplicated - multiple uses of the same alias are skipped.
|
||||
*/
|
||||
@bindThis
|
||||
public leftJoinInstance<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder<E> {
|
||||
// Skip if it's already joined, otherwise we'll get an error
|
||||
if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) {
|
||||
q.leftJoin(relation, alias, condition);
|
||||
}
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that noteProp (note ID) refers to a quote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public orIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsQuote(q, noteProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that noteProp (note ID) refers to a quote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public andIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsQuote(q, noteProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
return q[join](new Brackets(qb => qb
|
||||
.andWhere(`${noteProp}.renoteId IS NOT NULL`)
|
||||
.andWhere(new Brackets(qbb => qbb
|
||||
.orWhere(`${noteProp}.text IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.cw IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.replyId IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.hasPoll = true`)
|
||||
.orWhere(`${noteProp}.fileIds != '{}'`)))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that noteProp (note ID) does not refer to a quote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public orIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsNotQuote(q, noteProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that noteProp (note ID) does not refer to a quote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public andIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsNotQuote(q, noteProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
return q[join](new Brackets(qb => qb
|
||||
.orWhere(`${noteProp}.renoteId IS NULL`)
|
||||
.orWhere(new Brackets(qb => qb
|
||||
.andWhere(`${noteProp}.text IS NULL`)
|
||||
.andWhere(`${noteProp}.cw IS NULL`)
|
||||
.andWhere(`${noteProp}.replyId IS NULL`)
|
||||
.andWhere(`${noteProp}.hasPoll = false`)
|
||||
.andWhere(`${noteProp}.fileIds = '{}'`)))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that noteProp (note ID) refers to a renote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public orIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsRenote(q, noteProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that noteProp (note ID) refers to a renote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public andIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsRenote(q, noteProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
return q[join](new Brackets(qb => qb
|
||||
.andWhere(`${noteProp}.renoteId IS NOT NULL`)
|
||||
.andWhere(`${noteProp}.text IS NULL`)
|
||||
.andWhere(`${noteProp}.cw IS NULL`)
|
||||
.andWhere(`${noteProp}.replyId IS NULL`)
|
||||
.andWhere(`${noteProp}.hasPoll = false`)
|
||||
.andWhere(`${noteProp}.fileIds = '{}'`)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that noteProp (note ID) does not refer to a renote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public orIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsNotRenote(q, noteProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that noteProp (note ID) does not refer to a renote.
|
||||
* The prop should be an expression, not a raw value.
|
||||
*/
|
||||
@bindThis
|
||||
public andIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||
return this.addIsNotRenote(q, noteProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
return q[join](new Brackets(qb => qb
|
||||
.orWhere(`${noteProp}.renoteId IS NULL`)
|
||||
.orWhere(`${noteProp}.text IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.cw IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.replyId IS NOT NULL`)
|
||||
.orWhere(`${noteProp}.hasPoll = true`)
|
||||
.orWhere(`${noteProp}.fileIds != '{}'`)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that followerProp (user ID) is following followeeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||
return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that followerProp (user ID) is following followeeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('1')
|
||||
.andWhere(`following.followerId = ${followerProp}`)
|
||||
.andWhere(`following.followeeId = ${followeeProp}`);
|
||||
|
||||
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds OR condition that followerProp (user ID) is following followeeProp (channel ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||
return this.addFollowingChannel(q, followerProp, followeeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that followerProp (user ID) is following followeeProp (channel ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||
return this.addFollowingChannel(q, followerProp, followeeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private addFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const followingQuery = this.channelFollowingsRepository.createQueryBuilder('following')
|
||||
.select('1')
|
||||
.andWhere(`following.followerId = ${followerProp}`)
|
||||
.andWhere(`following.followeeId = ${followeeProp}`);
|
||||
|
||||
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
|
||||
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
|
||||
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private excludeBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('1')
|
||||
.andWhere(`blocking.blockerId = ${blockerProp}`)
|
||||
.andWhere(`blocking.blockeeId = ${blockeeProp}`);
|
||||
|
||||
return q[join](`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters());
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds OR condition that muterProp (user ID) is not muting muteeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
|
||||
return this.excludeMutingUser(q, muterProp, muteeProp, 'orWhere', exclude);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that muterProp (user ID) is not muting muteeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
|
||||
return this.excludeMutingUser(q, muterProp, muteeProp, 'andWhere', exclude);
|
||||
}
|
||||
|
||||
private excludeMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere', exclude?: { id: MiUser['id'] }): Q {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('1')
|
||||
.andWhere(`muting.muterId = ${muterProp}`)
|
||||
.andWhere(`muting.muteeId = ${muteeProp}`);
|
||||
|
||||
if (exclude) {
|
||||
mutingQuery.andWhere({ muteeId: Not(exclude.id) });
|
||||
}
|
||||
|
||||
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingRenote(q, muterProp, muteeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingRenote(q, muterProp, muteeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private excludeMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
|
||||
.select('1')
|
||||
.andWhere(`renote_muting.muterId = ${muterProp}`)
|
||||
.andWhere(`renote_muting.muteeId = ${muteeProp}`);
|
||||
|
||||
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds OR condition that muterProp (user ID) is not muting muteeProp (instance host).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingInstance(q, muterProp, muteeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that muterProp (user ID) is not muting muteeProp (instance host).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingInstance(q, muterProp, muteeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private excludeMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
||||
.select('1')
|
||||
.andWhere(`user_profile.userId = ${muterProp}`)
|
||||
.andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`);
|
||||
|
||||
return q[join](`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public orNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingThread(q, muterProp, muteeProp, 'orWhere');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds AND condition that muterProp (user ID) is not muting muteeProp (note ID).
|
||||
* Both props should be expressions, not raw values.
|
||||
*/
|
||||
@bindThis
|
||||
public andNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||
return this.excludeMutingThread(q, muterProp, muteeProp, 'andWhere');
|
||||
}
|
||||
|
||||
private excludeMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||
const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
|
||||
.select('1')
|
||||
.andWhere(`threadMuted.userId = ${muterProp}`)
|
||||
.andWhere(`threadMuted.threadId = ${muteeProp}`);
|
||||
|
||||
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
|
||||
}
|
||||
|
||||
// Requirements: user replyUser renoteUser must be joined
|
||||
@bindThis
|
||||
public generateSuspendedUserQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
|
||||
if (excludeAuthor) {
|
||||
const brakets = (user: string) => new Brackets(qb => qb
|
||||
.where(`note.${user}Id IS NULL`)
|
||||
.orWhere(`user.id = ${user}.id`)
|
||||
.orWhere(`${user}.isSuspended = FALSE`));
|
||||
q
|
||||
.andWhere(brakets('replyUser'))
|
||||
.andWhere(brakets('renoteUser'));
|
||||
} else {
|
||||
const brakets = (user: string) => new Brackets(qb => qb
|
||||
.where(`note.${user}Id IS NULL`)
|
||||
.orWhere(`${user}.isSuspended = FALSE`));
|
||||
q
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere(brakets('replyUser'))
|
||||
.andWhere(brakets('renoteUser'));
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
|
||||
.select('renote_muting.muteeId')
|
||||
.where('renote_muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
q.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(new Brackets(qb => {
|
||||
qb.where('note.renoteId IS NOT NULL');
|
||||
qb.andWhere('note.text IS NULL');
|
||||
qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.orWhere('note.renoteId IS NULL')
|
||||
.orWhere('note.text IS NOT NULL');
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { Config } from '@/config.js';
|
||||
import { baseQueueOptions, QUEUE } from '@/queue/const.js';
|
||||
import { allSettled } from '@/misc/promise-tracker.js';
|
||||
import Logger from '@/logger.js';
|
||||
import {
|
||||
DeliverJobData,
|
||||
EndedPollNotificationJobData,
|
||||
|
|
@ -120,6 +121,8 @@ const $scheduleNotePost: Provider = {
|
|||
],
|
||||
})
|
||||
export class QueueModule implements OnApplicationShutdown {
|
||||
private readonly logger = new Logger('queue');
|
||||
|
||||
constructor(
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
|
|
@ -135,8 +138,10 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
|
||||
public async dispose(): Promise<void> {
|
||||
// Wait for all potential queue jobs
|
||||
this.logger.info('Finalizing active promises...');
|
||||
await allSettled();
|
||||
// And then close all queues
|
||||
this.logger.info('Closing BullMQ queues...');
|
||||
await Promise.all([
|
||||
this.systemQueue.close(),
|
||||
this.endedPollNotificationQueue.close(),
|
||||
|
|
@ -149,6 +154,7 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
this.systemWebhookDeliverQueue.close(),
|
||||
this.scheduleNotePostQueue.close(),
|
||||
]);
|
||||
this.logger.info('Queue module disposed.');
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MetricsTime, type JobType } from 'bullmq';
|
||||
import { parse as parseRedisInfo } from 'redis-info';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
|
||||
|
|
@ -15,6 +17,7 @@ import { bindThis } from '@/decorators.js';
|
|||
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
|
||||
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
|
||||
import { type SystemWebhookPayload } from '@/core/SystemWebhookService.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { type UserWebhookPayload } from './UserWebhookService.js';
|
||||
import type {
|
||||
DbJobData,
|
||||
|
|
@ -38,7 +41,19 @@ import type {
|
|||
} from './QueueModule.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import type * as Bull from 'bullmq';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
|
||||
export const QUEUE_TYPES = [
|
||||
'system',
|
||||
'endedPollNotification',
|
||||
'deliver',
|
||||
'inbox',
|
||||
'db',
|
||||
'relationship',
|
||||
'objectStorage',
|
||||
'userWebhookDeliver',
|
||||
'systemWebhookDeliver',
|
||||
'scheduleNotePost',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class QueueService {
|
||||
|
|
@ -60,50 +75,58 @@ export class QueueService {
|
|||
this.systemQueue.add('tickCharts', {
|
||||
}, {
|
||||
repeat: { pattern: '55 * * * *' },
|
||||
removeOnComplete: true,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('resyncCharts', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('cleanCharts', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('aggregateRetention', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('clean', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('checkExpiredMutings', {
|
||||
}, {
|
||||
repeat: { pattern: '*/5 * * * *' },
|
||||
removeOnComplete: true,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('bakeBufferedReactions', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('checkModeratorsActivity', {
|
||||
}, {
|
||||
// 毎時30分に起動
|
||||
repeat: { pattern: '30 * * * *' },
|
||||
removeOnComplete: true,
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -125,13 +148,21 @@ export class QueueService {
|
|||
isSharedInbox,
|
||||
};
|
||||
|
||||
return this.deliverQueue.add(to, data, {
|
||||
const label = to.replace('https://', '').replace('/inbox', '');
|
||||
|
||||
return this.deliverQueue.add(label, data, {
|
||||
attempts: this.config.deliverJobMaxAttempts ?? 12,
|
||||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -153,12 +184,18 @@ export class QueueService {
|
|||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
};
|
||||
|
||||
await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({
|
||||
name: d[0],
|
||||
name: d[0].replace('https://', '').replace('/inbox', ''),
|
||||
data: {
|
||||
user,
|
||||
content: contentBody,
|
||||
|
|
@ -179,13 +216,24 @@ export class QueueService {
|
|||
signature,
|
||||
};
|
||||
|
||||
return this.inboxQueue.add('', data, {
|
||||
const label = (activity.id ?? '').replace('https://', '').replace('/activity', '');
|
||||
|
||||
return this.inboxQueue.add(label, data, {
|
||||
attempts: this.config.inboxJobMaxAttempts ?? 8,
|
||||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: activity.id ? {
|
||||
id: activity.id,
|
||||
} : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -194,8 +242,17 @@ export class QueueService {
|
|||
return this.dbQueue.add('deleteDriveFiles', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -204,8 +261,17 @@ export class QueueService {
|
|||
return this.dbQueue.add('exportCustomEmojis', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +282,9 @@ export class QueueService {
|
|||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -224,8 +293,17 @@ export class QueueService {
|
|||
return this.dbQueue.add('exportNotes', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -234,8 +312,17 @@ export class QueueService {
|
|||
return this.dbQueue.add('exportClips', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -244,8 +331,17 @@ export class QueueService {
|
|||
return this.dbQueue.add('exportFavorites', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -256,8 +352,17 @@ export class QueueService {
|
|||
excludeMuting,
|
||||
excludeInactive,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -266,8 +371,17 @@ export class QueueService {
|
|||
return this.dbQueue.add('exportMuting', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -276,8 +390,17 @@ export class QueueService {
|
|||
return this.dbQueue.add('exportBlocking', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -286,8 +409,17 @@ export class QueueService {
|
|||
return this.dbQueue.add('exportUserLists', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -296,8 +428,17 @@ export class QueueService {
|
|||
return this.dbQueue.add('exportAntennas', {
|
||||
user: { id: user.id },
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -308,8 +449,17 @@ export class QueueService {
|
|||
fileId: fileId,
|
||||
withReplies,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}_${withReplies ?? false}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -322,6 +472,9 @@ export class QueueService {
|
|||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}_${type ?? null}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -373,8 +526,17 @@ export class QueueService {
|
|||
user: { id: user.id },
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -384,8 +546,17 @@ export class QueueService {
|
|||
user: { id: user.id },
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -405,8 +576,14 @@ export class QueueService {
|
|||
name,
|
||||
data,
|
||||
opts: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -417,8 +594,17 @@ export class QueueService {
|
|||
user: { id: user.id },
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -428,19 +614,38 @@ export class QueueService {
|
|||
user: { id: user.id },
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createImportAntennasJob(user: ThinUser, antenna: Antenna) {
|
||||
public createImportAntennasJob(user: ThinUser, antenna: Antenna, fileId: MiDriveFile['id']) {
|
||||
return this.dbQueue.add('importAntennas', {
|
||||
user: { id: user.id },
|
||||
antenna,
|
||||
fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${user.id}_${fileId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -450,8 +655,17 @@ export class QueueService {
|
|||
user: { id: user.id },
|
||||
soft: opts.soft,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -501,9 +715,18 @@ export class QueueService {
|
|||
withReplies: data.withReplies,
|
||||
},
|
||||
opts: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
...opts,
|
||||
deduplication: {
|
||||
id: `${data.from.id}_${data.to.id}_${data.requestId ?? ''}_${data.silent ?? false}_${data.withReplies ?? false}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -513,16 +736,37 @@ export class QueueService {
|
|||
return this.objectStorageQueue.add('deleteFile', {
|
||||
key: key,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createCleanRemoteFilesJob() {
|
||||
return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
public createCleanRemoteFilesJob(olderThanSeconds: number = 0, keepFilesInUse: boolean = false) {
|
||||
return this.objectStorageQueue.add('cleanRemoteFiles', {
|
||||
keepFilesInUse,
|
||||
olderThanSeconds,
|
||||
}, {
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
deduplication: {
|
||||
id: `${olderThanSeconds}_${keepFilesInUse}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -553,8 +797,14 @@ export class QueueService {
|
|||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -584,21 +834,204 @@ export class QueueService {
|
|||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public destroy() {
|
||||
this.deliverQueue.once('cleaned', (jobs, status) => {
|
||||
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
});
|
||||
this.deliverQueue.clean(0, 0, 'delayed');
|
||||
private getQueue(type: typeof QUEUE_TYPES[number]): Bull.Queue {
|
||||
switch (type) {
|
||||
case 'system': return this.systemQueue;
|
||||
case 'endedPollNotification': return this.endedPollNotificationQueue;
|
||||
case 'deliver': return this.deliverQueue;
|
||||
case 'inbox': return this.inboxQueue;
|
||||
case 'db': return this.dbQueue;
|
||||
case 'relationship': return this.relationshipQueue;
|
||||
case 'objectStorage': return this.objectStorageQueue;
|
||||
case 'userWebhookDeliver': return this.userWebhookDeliverQueue;
|
||||
case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue;
|
||||
case 'scheduleNotePost': return this.ScheduleNotePostQueue;
|
||||
default: throw new Error(`Unrecognized queue type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.inboxQueue.once('cleaned', (jobs, status) => {
|
||||
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
@bindThis
|
||||
public async queueClear(queueType: typeof QUEUE_TYPES[number], state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed') {
|
||||
const queue = this.getQueue(queueType);
|
||||
|
||||
if (state === '*') {
|
||||
await Promise.all([
|
||||
queue.clean(0, 0, 'completed'),
|
||||
queue.clean(0, 0, 'wait'),
|
||||
queue.clean(0, 0, 'active'),
|
||||
queue.clean(0, 0, 'paused'),
|
||||
queue.clean(0, 0, 'prioritized'),
|
||||
queue.clean(0, 0, 'delayed'),
|
||||
queue.clean(0, 0, 'failed'),
|
||||
]);
|
||||
} else {
|
||||
await queue.clean(0, 0, state);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async queuePromoteJobs(queueType: typeof QUEUE_TYPES[number]) {
|
||||
const queue = this.getQueue(queueType);
|
||||
await queue.promoteJobs();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async queueRetryJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
|
||||
const queue = this.getQueue(queueType);
|
||||
const job: Bull.Job | null = await queue.getJob(jobId);
|
||||
if (job) {
|
||||
if (job.finishedOn != null) {
|
||||
await job.retry();
|
||||
} else {
|
||||
await job.promote();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async queueRemoveJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
|
||||
const queue = this.getQueue(queueType);
|
||||
const job: Bull.Job | null = await queue.getJob(jobId);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private packJobData(job: Bull.Job) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : [];
|
||||
stacktrace.reverse();
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
data: job.data,
|
||||
opts: job.opts,
|
||||
timestamp: job.timestamp,
|
||||
processedOn: job.processedOn,
|
||||
processedBy: job.processedBy,
|
||||
finishedOn: job.finishedOn,
|
||||
progress: job.progress,
|
||||
attempts: job.attemptsMade,
|
||||
delay: job.delay,
|
||||
failedReason: job.failedReason,
|
||||
stacktrace: stacktrace,
|
||||
returnValue: job.returnvalue,
|
||||
isFailed: !!job.failedReason || (Array.isArray(stacktrace) && stacktrace.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async queueGetJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
|
||||
const queue = this.getQueue(queueType);
|
||||
const job: Bull.Job | null = await queue.getJob(jobId);
|
||||
if (job) {
|
||||
return this.packJobData(job);
|
||||
} else {
|
||||
throw new Error(`Job not found: ${jobId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) {
|
||||
const RETURN_LIMIT = 100;
|
||||
const queue = this.getQueue(queueType);
|
||||
let jobs: (Bull.Job | null)[];
|
||||
|
||||
if (search) {
|
||||
jobs = await queue.getJobs(jobTypes, 0, 1000);
|
||||
|
||||
jobs = jobs.filter(job => {
|
||||
const jobString = JSON.stringify(job).toLowerCase();
|
||||
return search.toLowerCase().split(' ').every(term => {
|
||||
return jobString.includes(term);
|
||||
});
|
||||
});
|
||||
|
||||
jobs = jobs.slice(0, RETURN_LIMIT);
|
||||
} else {
|
||||
jobs = await queue.getJobs(jobTypes, 0, RETURN_LIMIT);
|
||||
}
|
||||
|
||||
return jobs
|
||||
.filter(job => job != null) // not sure how this happens, but it does
|
||||
.map(job => this.packJobData(job));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async queueGetQueues() {
|
||||
const fetchings = QUEUE_TYPES.map(async type => {
|
||||
const queue = this.getQueue(type);
|
||||
|
||||
const counts = await queue.getJobCounts();
|
||||
const isPaused = await queue.isPaused();
|
||||
const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK);
|
||||
const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK);
|
||||
|
||||
return {
|
||||
name: type,
|
||||
counts: counts,
|
||||
isPaused,
|
||||
metrics: {
|
||||
completed: metrics_completed,
|
||||
failed: metrics_failed,
|
||||
},
|
||||
};
|
||||
});
|
||||
this.inboxQueue.clean(0, 0, 'delayed');
|
||||
|
||||
return await Promise.all(fetchings);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async queueGetQueue(queueType: typeof QUEUE_TYPES[number]) {
|
||||
const queue = this.getQueue(queueType);
|
||||
const counts = await queue.getJobCounts();
|
||||
const isPaused = await queue.isPaused();
|
||||
const metrics_completed = await queue.getMetrics('completed', 0, MetricsTime.ONE_WEEK);
|
||||
const metrics_failed = await queue.getMetrics('failed', 0, MetricsTime.ONE_WEEK);
|
||||
const db = parseRedisInfo(await (await queue.client).info());
|
||||
|
||||
return {
|
||||
name: queueType,
|
||||
qualifiedName: queue.qualifiedName,
|
||||
counts: counts,
|
||||
isPaused,
|
||||
metrics: {
|
||||
completed: metrics_completed,
|
||||
failed: metrics_failed,
|
||||
},
|
||||
db: {
|
||||
version: db.redis_version,
|
||||
mode: db.redis_mode,
|
||||
runId: db.run_id,
|
||||
processId: db.process_id,
|
||||
port: parseInt(db.tcp_port),
|
||||
os: db.os,
|
||||
uptime: parseInt(db.uptime_in_seconds),
|
||||
memory: {
|
||||
total: parseInt(db.total_system_memory) || parseInt(db.maxmemory),
|
||||
used: parseInt(db.used_memory),
|
||||
fragmentationRatio: parseInt(db.mem_fragmentation_ratio),
|
||||
peak: parseInt(db.used_memory_peak),
|
||||
},
|
||||
clients: {
|
||||
connected: parseInt(db.connected_clients),
|
||||
blocked: parseInt(db.blocked_clients),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
|
|||
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||
import { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
|
@ -30,6 +30,8 @@ import { trackPromise } from '@/misc/promise-tracker.js';
|
|||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
const FALLBACK = '\u2764';
|
||||
|
||||
|
|
@ -88,6 +90,9 @@ export class ReactionService {
|
|||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
@Inject(DI.db)
|
||||
private readonly db: DataSource,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private customEmojiService: CustomEmojiService,
|
||||
private roleService: RoleService,
|
||||
|
|
@ -102,6 +107,7 @@ export class ReactionService {
|
|||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private notificationService: NotificationService,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -111,12 +117,12 @@ export class ReactionService {
|
|||
if (note.userId !== user.id) {
|
||||
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
|
||||
if (blocked) {
|
||||
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7');
|
||||
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7', 'Note not accessible for you.');
|
||||
}
|
||||
}
|
||||
|
||||
// check visibility
|
||||
if (!await this.noteEntityService.isVisibleForMe(note, user.id)) {
|
||||
if (!await this.noteEntityService.isVisibleForMe(note, user.id, { me: user })) {
|
||||
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
|
||||
}
|
||||
|
||||
|
|
@ -174,26 +180,28 @@ export class ReactionService {
|
|||
reaction,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
} catch (e) {
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
const exists = await this.noteReactionsRepository.findOneByOrFail({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
const result = await this.db.transaction(async tem => {
|
||||
await tem.createQueryBuilder(MiNoteReaction, 'noteReaction')
|
||||
.insert()
|
||||
.values(record)
|
||||
.orIgnore()
|
||||
.execute();
|
||||
|
||||
if (exists.reaction !== reaction) {
|
||||
// 別のリアクションがすでにされていたら置き換える
|
||||
await this.delete(user, note);
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
} else {
|
||||
// 同じリアクションがすでにされていたらエラー
|
||||
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
return await tem.createQueryBuilder(MiNoteReaction, 'noteReaction')
|
||||
.select()
|
||||
.where({ noteId: note.id, userId: user.id })
|
||||
.getOneOrFail();
|
||||
});
|
||||
|
||||
if (result.id !== record.id) {
|
||||
// Conflict with the same ID => nothing to do.
|
||||
if (result.reaction === record.reaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 別のリアクションがすでにされていたら置き換える
|
||||
await this.delete(user, note);
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
}
|
||||
|
||||
// Increment reactions count
|
||||
|
|
@ -212,20 +220,28 @@ export class ReactionService {
|
|||
.execute();
|
||||
}
|
||||
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
|
||||
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (
|
||||
Math.random() < 0.3 &&
|
||||
note.userId !== user.id &&
|
||||
(Date.now() - this.idService.parse(note.id).date.getTime()) < 1000 * 60 * 60 * 24 * 3
|
||||
) {
|
||||
if (note.channelId != null) {
|
||||
if (note.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(note.channelId, note.id, 1);
|
||||
}
|
||||
} else {
|
||||
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(note.id, 1);
|
||||
this.featuredService.updatePerUserNotesRanking(note.userId, note.id, 1);
|
||||
const author = await this.cacheService.findUserById(note.userId);
|
||||
if (author.isExplorable) {
|
||||
const policies = await this.roleService.getUserPolicies(author);
|
||||
if (policies.canTrend) {
|
||||
if (note.channelId != null) {
|
||||
if (note.replyId == null) {
|
||||
this.featuredService.updateInChannelNotesRanking(note.channelId, note, 1);
|
||||
}
|
||||
} else {
|
||||
if (note.visibility === 'public' && note.userHost == null && note.replyId == null) {
|
||||
this.featuredService.updateGlobalNotesRanking(note, 1);
|
||||
this.featuredService.updatePerUserNotesRanking(note.userId, note, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -259,12 +275,8 @@ export class ReactionService {
|
|||
|
||||
// リアクションされたユーザーがローカルユーザーなら通知を作成
|
||||
if (note.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: note.userId,
|
||||
threadId: note.threadId ?? note.id,
|
||||
},
|
||||
});
|
||||
const threadId = note.threadId ?? note.id;
|
||||
const isThreadMuted = await this.cacheService.threadMutingsCache.fetch(note.userId).then(ms => ms.has(threadId));
|
||||
|
||||
if (!isThreadMuted) {
|
||||
this.notificationService.createNotification(note.userId, 'reaction', {
|
||||
|
|
@ -298,22 +310,22 @@ export class ReactionService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote) {
|
||||
public async delete(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, exist?: MiNoteReaction | null) {
|
||||
// if already unreacted
|
||||
const exist = await this.noteReactionsRepository.findOneBy({
|
||||
exist ??= await this.noteReactionsRepository.findOneBy({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (exist == null) {
|
||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
|
||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'reaction does not exist');
|
||||
}
|
||||
|
||||
// Delete reaction
|
||||
const result = await this.noteReactionsRepository.delete(exist.id);
|
||||
|
||||
if (result.affected !== 1) {
|
||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
|
||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'reaction does not exist');
|
||||
}
|
||||
|
||||
// Decrement reactions count
|
||||
|
|
@ -330,6 +342,8 @@ export class ReactionService {
|
|||
.execute();
|
||||
}
|
||||
|
||||
this.usersRepository.update({ id: user.id }, { updatedAt: new Date() });
|
||||
|
||||
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
|
||||
reaction: this.decodeReaction(exist.reaction).reaction,
|
||||
userId: user.id,
|
||||
|
|
|
|||
|
|
@ -27,25 +27,9 @@ export class RegistryApiService {
|
|||
public async set(userId: MiUser['id'], domain: string | null, scope: string[], key: string, value: any) {
|
||||
// TODO: 作成できるキーの数を制限する
|
||||
|
||||
const query = this.registryItemsRepository.createQueryBuilder('item');
|
||||
if (domain) {
|
||||
query.where('item.domain = :domain', { domain: domain });
|
||||
} else {
|
||||
query.where('item.domain IS NULL');
|
||||
}
|
||||
query.andWhere('item.userId = :userId', { userId: userId });
|
||||
query.andWhere('item.key = :key', { key: key });
|
||||
query.andWhere('item.scope = :scope', { scope: scope });
|
||||
|
||||
const existingItem = await query.getOne();
|
||||
|
||||
if (existingItem) {
|
||||
await this.registryItemsRepository.update(existingItem.id, {
|
||||
updatedAt: new Date(),
|
||||
value: value,
|
||||
});
|
||||
} else {
|
||||
await this.registryItemsRepository.insert({
|
||||
await this.registryItemsRepository.createQueryBuilder('item')
|
||||
.insert()
|
||||
.values({
|
||||
id: this.idService.gen(),
|
||||
updatedAt: new Date(),
|
||||
userId: userId,
|
||||
|
|
@ -53,8 +37,13 @@ export class RegistryApiService {
|
|||
scope: scope,
|
||||
key: key,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
})
|
||||
.orUpdate(
|
||||
['updatedAt', 'value'],
|
||||
['userId', 'key', 'scope', 'domain'],
|
||||
{ upsertType: 'on-conflict-do-update' }
|
||||
)
|
||||
.execute();
|
||||
|
||||
if (domain == null) {
|
||||
// TODO: サードパーティアプリが傍受出来てしまうのでどうにかする
|
||||
|
|
|
|||
|
|
@ -4,53 +4,34 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import type { RelaysRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { RelaysRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { MemorySingleCache } from '@/misc/cache.js';
|
||||
import type { MiRelay } from '@/models/Relay.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { deepClone } from '@/misc/clone.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
const ACTOR_USERNAME = 'relay.actor' as const;
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
|
||||
@Injectable()
|
||||
export class RelayService {
|
||||
private relaysCache: MemorySingleCache<MiRelay[]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.relaysRepository)
|
||||
private relaysRepository: RelaysRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); // 10m
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getRelayActor(): Promise<MiLocalUser> {
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
username: ACTOR_USERNAME,
|
||||
});
|
||||
|
||||
if (user) return user as MiLocalUser;
|
||||
|
||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME);
|
||||
return created as MiLocalUser;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async addRelay(inbox: string): Promise<MiRelay> {
|
||||
const relay = await this.relaysRepository.insertOne({
|
||||
|
|
@ -59,8 +40,8 @@ export class RelayService {
|
|||
status: 'requesting',
|
||||
});
|
||||
|
||||
const relayActor = await this.getRelayActor();
|
||||
const follow = await this.apRendererService.renderFollowRelay(relay, relayActor);
|
||||
const relayActor = await this.systemAccountService.fetch('relay');
|
||||
const follow = this.apRendererService.renderFollowRelay(relay, relayActor);
|
||||
const activity = this.apRendererService.addContext(follow);
|
||||
this.queueService.deliver(relayActor, activity, relay.inbox, false);
|
||||
|
||||
|
|
@ -77,7 +58,7 @@ export class RelayService {
|
|||
throw new Error('relay not found');
|
||||
}
|
||||
|
||||
const relayActor = await this.getRelayActor();
|
||||
const relayActor = await this.systemAccountService.fetch('relay');
|
||||
const follow = this.apRendererService.renderFollowRelay(relay, relayActor);
|
||||
const undo = this.apRendererService.renderUndo(follow, relayActor);
|
||||
const activity = this.apRendererService.addContext(undo);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import chalk from 'chalk';
|
||||
import { IsNull } from 'typeorm';
|
||||
|
|
@ -18,6 +17,7 @@ import { RemoteLoggerService } from '@/core/RemoteLoggerService.js';
|
|||
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
|
||||
@Injectable()
|
||||
export class RemoteUserResolveService {
|
||||
|
|
@ -44,27 +44,13 @@ export class RemoteUserResolveService {
|
|||
const usernameLower = username.toLowerCase();
|
||||
|
||||
if (host == null) {
|
||||
this.logger.info(`return local user: ${usernameLower}`);
|
||||
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
||||
if (u == null) {
|
||||
throw new Error('user not found');
|
||||
} else {
|
||||
return u;
|
||||
}
|
||||
}) as MiLocalUser;
|
||||
return await this.usersRepository.findOneByOrFail({ usernameLower, host: IsNull() }) as MiLocalUser;
|
||||
}
|
||||
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||
this.logger.info(`return local user: ${usernameLower}`);
|
||||
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
||||
if (u == null) {
|
||||
throw new Error('user not found');
|
||||
} else {
|
||||
return u;
|
||||
}
|
||||
}) as MiLocalUser;
|
||||
return await this.usersRepository.findOneByOrFail({ usernameLower, host: IsNull() }) as MiLocalUser;
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null;
|
||||
|
|
@ -74,7 +60,7 @@ export class RemoteUserResolveService {
|
|||
if (user == null) {
|
||||
const self = await this.resolveSelf(acctLower);
|
||||
|
||||
if (self.href.startsWith(this.config.url)) {
|
||||
if (this.utilityService.isUriLocal(self.href)) {
|
||||
const local = this.apDbResolverService.parseUri(self.href);
|
||||
if (local.local && local.type === 'users') {
|
||||
// the LR points to local
|
||||
|
|
@ -82,7 +68,7 @@ export class RemoteUserResolveService {
|
|||
.getUserFromApId(self.href)
|
||||
.then((u) => {
|
||||
if (u == null) {
|
||||
throw new Error('local user not found');
|
||||
throw new Error(`local user not found: ${self.href}`);
|
||||
} else {
|
||||
return u;
|
||||
}
|
||||
|
|
@ -90,7 +76,7 @@ export class RemoteUserResolveService {
|
|||
}
|
||||
}
|
||||
|
||||
this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
|
||||
this.logger.info(`Fetching new remote user ${chalk.magenta(acctLower)} from ${self.href}`);
|
||||
return await this.apPersonService.createPerson(self.href);
|
||||
}
|
||||
|
||||
|
|
@ -101,18 +87,16 @@ export class RemoteUserResolveService {
|
|||
lastFetchedAt: new Date(),
|
||||
});
|
||||
|
||||
this.logger.info(`try resync: ${acctLower}`);
|
||||
const self = await this.resolveSelf(acctLower);
|
||||
|
||||
if (user.uri !== self.href) {
|
||||
// if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping.
|
||||
this.logger.info(`uri missmatch: ${acctLower}`);
|
||||
this.logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`);
|
||||
this.logger.warn(`Detected URI mismatch for ${acctLower}`);
|
||||
|
||||
// validate uri
|
||||
const uri = new URL(self.href);
|
||||
if (uri.hostname !== host) {
|
||||
throw new Error('Invalid uri');
|
||||
const uriHost = this.utilityService.extractDbHost(self.href);
|
||||
if (uriHost !== host) {
|
||||
throw new Error(`Failed to correct URI for ${acctLower}: new URI ${self.href} has different host from previous URI ${user.uri}`);
|
||||
}
|
||||
|
||||
await this.usersRepository.update({
|
||||
|
|
@ -121,37 +105,28 @@ export class RemoteUserResolveService {
|
|||
}, {
|
||||
uri: self.href,
|
||||
});
|
||||
} else {
|
||||
this.logger.info(`uri is fine: ${acctLower}`);
|
||||
}
|
||||
|
||||
this.logger.info(`Corrected URI for ${acctLower} from ${user.uri} to ${self.href}`);
|
||||
|
||||
await this.apPersonService.updatePerson(self.href);
|
||||
|
||||
this.logger.info(`return resynced remote user: ${acctLower}`);
|
||||
return await this.usersRepository.findOneBy({ uri: self.href }).then(u => {
|
||||
if (u == null) {
|
||||
throw new Error('user not found');
|
||||
} else {
|
||||
return u as MiLocalUser | MiRemoteUser;
|
||||
}
|
||||
});
|
||||
return await this.usersRepository.findOneByOrFail({ uri: self.href }) as MiLocalUser | MiRemoteUser;
|
||||
}
|
||||
|
||||
this.logger.info(`return existing remote user: ${acctLower}`);
|
||||
return user;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async resolveSelf(acctLower: string): Promise<ILink> {
|
||||
this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
|
||||
const finger = await this.webfingerService.webfinger(acctLower).catch(err => {
|
||||
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`);
|
||||
throw new Error(`Failed to WebFinger for ${acctLower}: ${ err.statusCode ?? err.message }`);
|
||||
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${renderInlineError(err)}`);
|
||||
throw new Error(`Failed to WebFinger for ${acctLower}: error thrown`, { cause: err });
|
||||
});
|
||||
const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self');
|
||||
if (!self) {
|
||||
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`);
|
||||
throw new Error('self link not found');
|
||||
throw new Error(`Failed to WebFinger for ${acctLower}: self link not found`);
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -587,6 +587,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
|
||||
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
|
||||
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
|
||||
instance: null,
|
||||
userProfile: null,
|
||||
} : null,
|
||||
user2: parsed.user2 != null ? {
|
||||
...parsed.user2,
|
||||
|
|
@ -597,6 +599,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
|||
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
|
||||
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
|
||||
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
|
||||
instance: null,
|
||||
userProfile: null,
|
||||
} : null,
|
||||
};
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type { MiUser } from '@/models/User.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { FollowStats } from '@/core/CacheService.js';
|
||||
import type { RoleCondFormulaValue } from '@/models/Role.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
|
|
@ -48,6 +49,7 @@ export type RolePolicies = {
|
|||
canUseTranslator: boolean;
|
||||
canHideAds: boolean;
|
||||
driveCapacityMb: number;
|
||||
maxFileSizeMb: number;
|
||||
alwaysMarkNsfw: boolean;
|
||||
canUpdateBioMedia: boolean;
|
||||
pinLimit: number;
|
||||
|
|
@ -66,6 +68,8 @@ export type RolePolicies = {
|
|||
canImportFollowing: boolean;
|
||||
canImportMuting: boolean;
|
||||
canImportUserLists: boolean;
|
||||
chatAvailability: 'available' | 'readonly' | 'unavailable';
|
||||
canTrend: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_POLICIES: RolePolicies = {
|
||||
|
|
@ -82,14 +86,15 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
canManageCustomEmojis: false,
|
||||
canManageAvatarDecorations: false,
|
||||
canSearchNotes: false,
|
||||
canUseTranslator: true,
|
||||
canUseTranslator: false,
|
||||
canHideAds: false,
|
||||
driveCapacityMb: 100,
|
||||
maxFileSizeMb: 25,
|
||||
alwaysMarkNsfw: false,
|
||||
canUpdateBioMedia: true,
|
||||
pinLimit: 5,
|
||||
antennaLimit: 5,
|
||||
wordMuteLimit: 200,
|
||||
wordMuteLimit: 1000,
|
||||
webhookLimit: 3,
|
||||
clipLimit: 10,
|
||||
noteEachClipsLimit: 200,
|
||||
|
|
@ -103,11 +108,12 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
canImportFollowing: true,
|
||||
canImportMuting: true,
|
||||
canImportUserLists: true,
|
||||
chatAvailability: 'available',
|
||||
canTrend: true,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
private rootUserIdCache: MemorySingleCache<MiUser['id']>;
|
||||
private rolesCache: MemorySingleCache<MiRole[]>;
|
||||
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
|
||||
private notificationService: NotificationService;
|
||||
|
|
@ -143,9 +149,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
private moderationLogService: ModerationLogService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
) {
|
||||
this.rootUserIdCache = new MemorySingleCache<MiUser['id']>(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに
|
||||
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
|
||||
// TODO additional cache for final calculation?
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
|
@ -219,20 +225,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
|
||||
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue, followStats: FollowStats): boolean {
|
||||
try {
|
||||
switch (value.type) {
|
||||
// ~かつ~
|
||||
case 'and': {
|
||||
return value.values.every(v => this.evalCond(user, roles, v));
|
||||
return value.values.every(v => this.evalCond(user, roles, v, followStats));
|
||||
}
|
||||
// ~または~
|
||||
case 'or': {
|
||||
return value.values.some(v => this.evalCond(user, roles, v));
|
||||
return value.values.some(v => this.evalCond(user, roles, v, followStats));
|
||||
}
|
||||
// ~ではない
|
||||
case 'not': {
|
||||
return !this.evalCond(user, roles, value.value);
|
||||
return !this.evalCond(user, roles, value.value, followStats);
|
||||
}
|
||||
// マニュアルロールがアサインされている
|
||||
case 'roleAssignedTo': {
|
||||
|
|
@ -246,6 +252,23 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
case 'isRemote': {
|
||||
return this.userEntityService.isRemoteUser(user);
|
||||
}
|
||||
// User is from a specific instance
|
||||
case 'isFromInstance': {
|
||||
if (user.host == null) {
|
||||
return false;
|
||||
}
|
||||
if (value.subdomains) {
|
||||
const userHost = '.' + user.host.toLowerCase();
|
||||
const targetHost = '.' + value.host.toLowerCase();
|
||||
return userHost.endsWith(targetHost);
|
||||
} else {
|
||||
return user.host.toLowerCase() === value.host.toLowerCase();
|
||||
}
|
||||
}
|
||||
// Is the user from a local bubble instance
|
||||
case 'fromBubbleInstance': {
|
||||
return user.host != null && this.meta.bubbleInstances.includes(user.host);
|
||||
}
|
||||
// サスペンド済みユーザである
|
||||
case 'isSuspended': {
|
||||
return user.isSuspended;
|
||||
|
|
@ -290,6 +313,30 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
case 'followingMoreThanOrEq': {
|
||||
return user.followingCount >= value.value;
|
||||
}
|
||||
case 'localFollowersLessThanOrEq': {
|
||||
return followStats.localFollowers <= value.value;
|
||||
}
|
||||
case 'localFollowersMoreThanOrEq': {
|
||||
return followStats.localFollowers >= value.value;
|
||||
}
|
||||
case 'localFollowingLessThanOrEq': {
|
||||
return followStats.localFollowing <= value.value;
|
||||
}
|
||||
case 'localFollowingMoreThanOrEq': {
|
||||
return followStats.localFollowing >= value.value;
|
||||
}
|
||||
case 'remoteFollowersLessThanOrEq': {
|
||||
return followStats.remoteFollowers <= value.value;
|
||||
}
|
||||
case 'remoteFollowersMoreThanOrEq': {
|
||||
return followStats.remoteFollowers >= value.value;
|
||||
}
|
||||
case 'remoteFollowingLessThanOrEq': {
|
||||
return followStats.remoteFollowing <= value.value;
|
||||
}
|
||||
case 'remoteFollowingMoreThanOrEq': {
|
||||
return followStats.remoteFollowing >= value.value;
|
||||
}
|
||||
// ノート数が指定値以下
|
||||
case 'notesLessThanOrEq': {
|
||||
return user.notesCount <= value.value;
|
||||
|
|
@ -314,8 +361,9 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserAssigns(userId: MiUser['id']) {
|
||||
public async getUserAssigns(userOrId: MiUser | MiUser['id']) {
|
||||
const now = Date.now();
|
||||
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
||||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
|
|
@ -323,12 +371,14 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserRoles(userId: MiUser['id']) {
|
||||
public async getUserRoles(userOrId: MiUser | MiUser['id']) {
|
||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||
const assigns = await this.getUserAssigns(userId);
|
||||
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||
const followStats = await this.cacheService.getFollowStats(userId);
|
||||
const assigns = await this.getUserAssigns(userOrId);
|
||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
|
||||
const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null;
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula, followStats));
|
||||
return [...assignedRoles, ...matchedCondRoles];
|
||||
}
|
||||
|
||||
|
|
@ -336,18 +386,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
* 指定ユーザーのバッジロール一覧取得
|
||||
*/
|
||||
@bindThis
|
||||
public async getUserBadgeRoles(userId: MiUser['id']) {
|
||||
public async getUserBadgeRoles(userOrId: MiUser | MiUser['id']) {
|
||||
const now = Date.now();
|
||||
const userId = typeof(userOrId) === 'object' ? userOrId.id : userOrId;
|
||||
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
||||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||
const followStats = await this.cacheService.getFollowStats(userId);
|
||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
|
||||
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
||||
if (badgeCondRoles.length > 0) {
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
|
||||
const user = typeof(userOrId) === 'object' ? userOrId : roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userOrId) : null;
|
||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula, followStats));
|
||||
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
||||
} else {
|
||||
return assignedBadgeRoles;
|
||||
|
|
@ -355,12 +407,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserPolicies(userId: MiUser['id'] | null): Promise<RolePolicies> {
|
||||
public async getUserPolicies(userOrId: MiUser | MiUser['id'] | null): Promise<RolePolicies> {
|
||||
const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies };
|
||||
|
||||
if (userId == null) return basePolicies;
|
||||
if (userOrId == null) return basePolicies;
|
||||
|
||||
const roles = await this.getUserRoles(userId);
|
||||
const roles = await this.getUserRoles(userOrId);
|
||||
|
||||
function calc<T extends keyof RolePolicies>(name: T, aggregate: (values: RolePolicies[T][]) => RolePolicies[T]) {
|
||||
if (roles.length === 0) return basePolicies[name];
|
||||
|
|
@ -376,6 +428,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
return aggregate(policies.map(policy => policy.useDefault ? basePolicies[name] : policy.value));
|
||||
}
|
||||
|
||||
function aggregateChatAvailability(vs: RolePolicies['chatAvailability'][]) {
|
||||
if (vs.some(v => v === 'available')) return 'available';
|
||||
if (vs.some(v => v === 'readonly')) return 'readonly';
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
return {
|
||||
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
|
||||
btlAvailable: calc('btlAvailable', vs => vs.some(v => v === true)),
|
||||
|
|
@ -393,6 +451,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
|
||||
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
|
||||
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
|
||||
maxFileSizeMb: calc('maxFileSizeMb', vs => Math.max(...vs)),
|
||||
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
|
||||
canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)),
|
||||
pinLimit: calc('pinLimit', vs => Math.max(...vs)),
|
||||
|
|
@ -411,19 +470,21 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
|
||||
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
|
||||
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
|
||||
chatAvailability: calc('chatAvailability', aggregateChatAvailability),
|
||||
canTrend: calc('canTrend', vs => vs.some(v => v === true)),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isModerator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise<boolean> {
|
||||
public async isModerator(user: { id: MiUser['id'] } | null): Promise<boolean> {
|
||||
if (user == null) return false;
|
||||
return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator);
|
||||
return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isAdministrator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise<boolean> {
|
||||
public async isAdministrator(user: { id: MiUser['id'] } | null): Promise<boolean> {
|
||||
if (user == null) return false;
|
||||
return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isAdministrator);
|
||||
return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isAdministrator);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -472,16 +533,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
.map(a => a.userId),
|
||||
);
|
||||
|
||||
if (includeRoot) {
|
||||
const rootUserId = await this.rootUserIdCache.fetch(async () => {
|
||||
const it = await this.usersRepository.createQueryBuilder('users')
|
||||
.select('id')
|
||||
.where({ isRoot: true })
|
||||
.getRawOne<{ id: string }>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return it!.id;
|
||||
});
|
||||
resultSet.add(rootUserId);
|
||||
if (includeRoot && this.meta.rootUserId) {
|
||||
resultSet.add(this.meta.rootUserId);
|
||||
}
|
||||
|
||||
return [...resultSet].sort((x, y) => x.localeCompare(y));
|
||||
|
|
@ -646,6 +699,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
isModerator: values.isModerator,
|
||||
isExplorable: values.isExplorable,
|
||||
asBadge: values.asBadge,
|
||||
preserveAssignmentOnMoveAccount: values.preserveAssignmentOnMoveAccount,
|
||||
canEditMembersByModerator: values.canEditMembersByModerator,
|
||||
displayOrder: values.displayOrder,
|
||||
policies: values.policies,
|
||||
|
|
@ -683,6 +737,17 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async clone(role: MiRole, moderator?: MiUser): Promise<MiRole> {
|
||||
const suffix = ' (cloned)';
|
||||
const newName = role.name.slice(0, 256 - suffix.length) + suffix;
|
||||
|
||||
return this.create({
|
||||
...role,
|
||||
name: newName,
|
||||
}, moderator);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(role: MiRole, moderator?: MiUser): Promise<void> {
|
||||
await this.rolesRepository.delete({ id: role.id });
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export class S3Service {
|
|||
tls: meta.objectStorageUseSSL,
|
||||
forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted
|
||||
requestHandler: new NodeHttpHandler(handlerOption),
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -300,8 +300,10 @@ export class SearchService {
|
|||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
return await query.limit(pagination.limit).getMany();
|
||||
}
|
||||
|
|
@ -366,9 +368,20 @@ export class SearchService {
|
|||
this.cacheService.userBlockedCache.fetch(me.id),
|
||||
])
|
||||
: [new Set<string>(), new Set<string>()];
|
||||
const notes = (await this.notesRepository.findBy({
|
||||
id: In(res.hits.map(x => x.id)),
|
||||
})).filter(note => {
|
||||
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
query.where('note.id IN (:...noteIds)', { noteIds: res.hits.map(x => x.id) });
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
|
||||
const notes = (await query.getMany()).filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ import { MiUserProfile } from '@/models/UserProfile.js';
|
|||
import { IdService } from '@/core/IdService.js';
|
||||
import { MiUserKeypair } from '@/models/UserKeypair.js';
|
||||
import { MiUsedUsername } from '@/models/UsedUsername.js';
|
||||
import generateUserToken from '@/misc/generate-native-user-token.js';
|
||||
import { generateNativeUserToken } from '@/misc/token.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import UsersChart from '@/core/chart/charts/users.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { UserService } from '@/core/UserService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
|
||||
@Injectable()
|
||||
export class SignupService {
|
||||
|
|
@ -42,7 +43,8 @@ export class SignupService {
|
|||
private userService: UserService,
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private metaService: MetaService,
|
||||
private usersChart: UsersChart,
|
||||
) {
|
||||
}
|
||||
|
|
@ -77,7 +79,7 @@ export class SignupService {
|
|||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = generateUserToken();
|
||||
const secret = generateNativeUserToken();
|
||||
|
||||
// Check username duplication
|
||||
if (await this.usersRepository.exists({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) {
|
||||
|
|
@ -89,9 +91,7 @@ export class SignupService {
|
|||
throw new Error('USED_USERNAME');
|
||||
}
|
||||
|
||||
const isTheFirstUser = !await this.instanceActorService.realLocalUsersPresent();
|
||||
|
||||
if (!opts.ignorePreservedUsernames && !isTheFirstUser) {
|
||||
if (!opts.ignorePreservedUsernames && this.meta.rootUserId != null) {
|
||||
const isPreserved = this.meta.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
|
||||
if (isPreserved) {
|
||||
throw new Error('USED_USERNAME');
|
||||
|
|
@ -132,8 +132,7 @@ export class SignupService {
|
|||
usernameLower: username.toLowerCase(),
|
||||
host: this.utilityService.toPunyNullable(host),
|
||||
token: secret,
|
||||
isRoot: isTheFirstUser,
|
||||
approved: isTheFirstUser || (opts.approved ?? !this.meta.approvalRequiredForSignup),
|
||||
approved: opts.approved ?? !this.meta.approvalRequiredForSignup,
|
||||
signupReason: reason,
|
||||
enableRss: false,
|
||||
}));
|
||||
|
|
@ -159,6 +158,10 @@ export class SignupService {
|
|||
this.usersChart.update(account, true);
|
||||
this.userService.notifySystemWebhook(account, 'userCreated');
|
||||
|
||||
if (this.meta.rootUserId == null) {
|
||||
await this.metaService.update({ rootUserId: account.id });
|
||||
}
|
||||
|
||||
return { account, secret };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,24 +4,47 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import type { MiMeta } from '@/models/_.js';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { MiMeta } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
export interface Sponsor {
|
||||
MemberId: number;
|
||||
createdAt: string;
|
||||
type: string;
|
||||
role: string;
|
||||
tier: string;
|
||||
isActive: boolean;
|
||||
totalAmountDonated: number;
|
||||
currency: string;
|
||||
lastTransactionAt: string;
|
||||
lastTransactionAmount: number;
|
||||
profile: string;
|
||||
name: string;
|
||||
company: string | null;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
email: string | null;
|
||||
newsletterOptIn: unknown | null;
|
||||
twitter: string | null;
|
||||
github: string | null;
|
||||
website: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SponsorsService implements OnApplicationShutdown {
|
||||
private cache: RedisKVCache<void[]>;
|
||||
private readonly cache: RedisKVCache<Sponsor[]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
private readonly meta: MiMeta,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
redisClient: Redis.Redis,
|
||||
) {
|
||||
this.cache = new RedisKVCache<void[]>(this.redisClient, 'sponsors', {
|
||||
this.cache = new RedisKVCache<Sponsor[]>(redisClient, 'sponsors', {
|
||||
lifetime: 1000 * 60 * 60,
|
||||
memoryCacheLifetime: 1000 * 60,
|
||||
fetcher: (key) => {
|
||||
|
|
@ -34,54 +57,54 @@ export class SponsorsService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchInstanceSponsors() {
|
||||
private async fetchInstanceSponsors(): Promise<Sponsor[]> {
|
||||
if (!(this.meta.donationUrl && this.meta.donationUrl.includes('opencollective.com'))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const backers = await fetch(`${this.meta.donationUrl}/members/users.json`).then((response) => response.json());
|
||||
const backers = await fetch(`${this.meta.donationUrl}/members/users.json`).then((response) => response.json() as Promise<Sponsor[]>);
|
||||
|
||||
// Merge both together into one array and make sure it only has Active subscriptions
|
||||
const allSponsors = [...backers].filter(sponsor => sponsor.isActive === true && sponsor.role === 'BACKER' && sponsor.tier);
|
||||
const allSponsors = [...backers].filter(sponsor => sponsor.isActive && sponsor.role === 'BACKER' && sponsor.tier);
|
||||
|
||||
// Remove possible duplicates
|
||||
return [...new Map(allSponsors.map(v => [v.profile, v])).values()];
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchSharkeySponsors() {
|
||||
private async fetchSharkeySponsors(): Promise<Sponsor[]> {
|
||||
try {
|
||||
const backers = await fetch('https://opencollective.com/sharkey/tiers/backer/all.json').then((response) => response.json());
|
||||
const sponsorsOC = await fetch('https://opencollective.com/sharkey/tiers/sponsor/all.json').then((response) => response.json());
|
||||
const backers = await fetch('https://opencollective.com/sharkey/tiers/backer/all.json').then((response) => response.json() as Promise<Sponsor[]>);
|
||||
const sponsorsOC = await fetch('https://opencollective.com/sharkey/tiers/sponsor/all.json').then((response) => response.json() as Promise<Sponsor[]>);
|
||||
|
||||
// Merge both together into one array and make sure it only has Active subscriptions
|
||||
const allSponsors = [...sponsorsOC, ...backers].filter(sponsor => sponsor.isActive === true);
|
||||
const allSponsors = [...sponsorsOC, ...backers].filter(sponsor => sponsor.isActive);
|
||||
|
||||
// Remove possible duplicates
|
||||
return [...new Map(allSponsors.map(v => [v.profile, v])).values()];
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async instanceSponsors(forceUpdate: boolean) {
|
||||
if (forceUpdate) this.cache.refresh('instance');
|
||||
if (forceUpdate) await this.cache.refresh('instance');
|
||||
return this.cache.fetch('instance');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async sharkeySponsors(forceUpdate: boolean) {
|
||||
if (forceUpdate) this.cache.refresh('sharkey');
|
||||
if (forceUpdate) await this.cache.refresh('sharkey');
|
||||
return this.cache.fetch('sharkey');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
public onApplicationShutdown(): void {
|
||||
this.cache.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
232
packages/backend/src/core/SystemAccountService.ts
Normal file
232
packages/backend/src/core/SystemAccountService.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js';
|
||||
import type { MiMeta, UserProfilesRepository } from '@/models/_.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { generateNativeUserToken } from '@/misc/token.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
|
||||
|
||||
export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SystemAccountService implements OnApplicationShutdown {
|
||||
private cache: MemoryKVCache<MiLocalUser>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.systemAccountsRepository)
|
||||
private systemAccountsRepository: SystemAccountsRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.cache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 10); // 10m
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'metaUpdated': {
|
||||
if (body.before != null && body.before.name !== body.after.name) {
|
||||
for (const account of SYSTEM_ACCOUNT_TYPES) {
|
||||
await this.updateCorrespondingUserProfile(account, {
|
||||
name: body.after.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async list(): Promise<MiSystemAccount[]> {
|
||||
const accounts = await this.systemAccountsRepository.findBy({});
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(type: typeof SYSTEM_ACCOUNT_TYPES[number]): Promise<MiLocalUser> {
|
||||
const cached = this.cache.get(type);
|
||||
if (cached) return cached;
|
||||
|
||||
const systemAccount = await this.systemAccountsRepository.findOne({
|
||||
where: { type: type },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (systemAccount) {
|
||||
this.cache.set(type, systemAccount.user as MiLocalUser);
|
||||
return systemAccount.user as MiLocalUser;
|
||||
} else {
|
||||
const created = await this.createCorrespondingUser(type, {
|
||||
username: `system.${type}`, // NOTE: (できれば避けたいが) . が含まれるかどうかでシステムアカウントかどうかを判定している処理もあるので変えないように
|
||||
name: this.meta.name,
|
||||
});
|
||||
this.cache.set(type, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async createCorrespondingUser(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: {
|
||||
username: MiUser['username'];
|
||||
name?: MiUser['name'];
|
||||
}): Promise<MiLocalUser> {
|
||||
const password = randomUUID();
|
||||
|
||||
// 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();
|
||||
|
||||
let account!: MiUser;
|
||||
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
const exist = await transactionalEntityManager.findOneBy(MiUser, {
|
||||
usernameLower: extra.username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
});
|
||||
|
||||
if (exist) {
|
||||
account = exist;
|
||||
return;
|
||||
}
|
||||
|
||||
account = await transactionalEntityManager.insert(MiUser, {
|
||||
id: this.idService.gen(),
|
||||
username: extra.username,
|
||||
usernameLower: extra.username.toLowerCase(),
|
||||
host: null,
|
||||
token: secret,
|
||||
isLocked: true,
|
||||
isExplorable: false,
|
||||
isBot: true,
|
||||
name: extra.name,
|
||||
// System accounts are automatically approved.
|
||||
approved: true,
|
||||
// We always allow requests to system accounts to avoid federation infinite loop.
|
||||
// When a remote instance needs to check our signature on a request we sent, it will need to fetch information about the user that signed it (which is our instance actor).
|
||||
// If we try to check their signature on *that* request, we'll fetch *their* instance actor... leading to an infinite recursion
|
||||
allowUnsignedFetch: 'always',
|
||||
}).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
|
||||
|
||||
await transactionalEntityManager.insert(MiUserKeypair, {
|
||||
publicKey: keyPair.publicKey,
|
||||
privateKey: keyPair.privateKey,
|
||||
userId: account.id,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(MiUserProfile, {
|
||||
userId: account.id,
|
||||
autoAcceptFollowed: false,
|
||||
password: hash,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(MiUsedUsername, {
|
||||
createdAt: new Date(),
|
||||
username: extra.username.toLowerCase(),
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(MiSystemAccount, {
|
||||
id: this.idService.gen(),
|
||||
userId: account.id,
|
||||
type: type,
|
||||
});
|
||||
});
|
||||
|
||||
return account as MiLocalUser;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: {
|
||||
name?: string | null;
|
||||
description?: MiUserProfile['description'];
|
||||
}): Promise<MiLocalUser> {
|
||||
const user = await this.fetch(type);
|
||||
|
||||
const updates = {} as Partial<MiUser>;
|
||||
if (extra.name !== undefined) updates.name = extra.name;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.usersRepository.update(user.id, updates);
|
||||
}
|
||||
|
||||
const profileUpdates = {} as Partial<MiUserProfile>;
|
||||
if (extra.description !== undefined) profileUpdates.description = extra.description;
|
||||
|
||||
if (Object.keys(profileUpdates).length > 0) {
|
||||
await this.userProfilesRepository.update(user.id, profileUpdates);
|
||||
}
|
||||
|
||||
const updated = await this.usersRepository.findOneByOrFail({ id: user.id }) as MiLocalUser;
|
||||
this.cache.set(type, updated);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async getInstanceActor() {
|
||||
return await this.fetch('actor');
|
||||
}
|
||||
|
||||
public async getRelayActor() {
|
||||
return await this.fetch('relay');
|
||||
}
|
||||
|
||||
public async getProxyActor() {
|
||||
return await this.fetch('proxy');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
this.cache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
52
packages/backend/src/core/UpdateInstanceQueue.ts
Normal file
52
packages/backend/src/core/UpdateInstanceQueue.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
|
||||
type UpdateInstanceJob = {
|
||||
latestRequestReceivedAt: Date,
|
||||
shouldUnsuspend: boolean,
|
||||
};
|
||||
|
||||
// Moved from InboxProcessorService to allow access from ApInboxService
|
||||
@Injectable()
|
||||
export class UpdateInstanceQueue extends CollapsedQueue<MiNote['id'], UpdateInstanceJob> implements OnApplicationShutdown {
|
||||
constructor(
|
||||
private readonly federatedInstanceService: FederatedInstanceService,
|
||||
) {
|
||||
super(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, (id, job) => this.collapseUpdateInstanceJobs(id, job), (id, job) => this.performUpdateInstance(id, job));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private collapseUpdateInstanceJobs(oldJob: UpdateInstanceJob, newJob: UpdateInstanceJob) {
|
||||
const latestRequestReceivedAt = oldJob.latestRequestReceivedAt < newJob.latestRequestReceivedAt
|
||||
? newJob.latestRequestReceivedAt
|
||||
: oldJob.latestRequestReceivedAt;
|
||||
const shouldUnsuspend = oldJob.shouldUnsuspend || newJob.shouldUnsuspend;
|
||||
return {
|
||||
latestRequestReceivedAt,
|
||||
shouldUnsuspend,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async performUpdateInstance(id: string, job: UpdateInstanceJob) {
|
||||
await this.federatedInstanceService.update(id, {
|
||||
latestRequestReceivedAt: new Date(),
|
||||
isNotResponding: false,
|
||||
// もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる
|
||||
suspensionState: job.shouldUnsuspend ? 'none' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
async onApplicationShutdown() {
|
||||
await this.performAllNow();
|
||||
}
|
||||
}
|
||||
|
|
@ -77,8 +77,10 @@ export class UserBlockingService implements OnModuleInit {
|
|||
|
||||
await this.blockingsRepository.insert(blocking);
|
||||
|
||||
this.cacheService.userBlockingCache.refresh(blocker.id);
|
||||
this.cacheService.userBlockedCache.refresh(blockee.id);
|
||||
await Promise.all([
|
||||
this.cacheService.userBlockingCache.delete(blocker.id),
|
||||
this.cacheService.userBlockedCache.delete(blockee.id),
|
||||
]);
|
||||
|
||||
this.globalEventService.publishInternalEvent('blockingCreated', {
|
||||
blockerId: blocker.id,
|
||||
|
|
@ -168,8 +170,10 @@ export class UserBlockingService implements OnModuleInit {
|
|||
|
||||
await this.blockingsRepository.delete(blocking.id);
|
||||
|
||||
this.cacheService.userBlockingCache.refresh(blocker.id);
|
||||
this.cacheService.userBlockedCache.refresh(blockee.id);
|
||||
await Promise.all([
|
||||
this.cacheService.userBlockingCache.delete(blocker.id),
|
||||
this.cacheService.userBlockedCache.delete(blockee.id),
|
||||
]);
|
||||
|
||||
this.globalEventService.publishInternalEvent('blockingDeleted', {
|
||||
blockerId: blocker.id,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { Brackets, IsNull } from 'typeorm';
|
||||
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
|
@ -28,9 +28,9 @@ import type { Config } from '@/config.js';
|
|||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { ThinUser } from '@/queue/types.js';
|
||||
import Logger from '../logger.js';
|
||||
|
||||
const logger = new Logger('following/create');
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||
import type Logger from '../logger.js';
|
||||
|
||||
type Local = MiLocalUser | {
|
||||
id: MiLocalUser['id'];
|
||||
|
|
@ -48,6 +48,7 @@ type Both = Local | Remote;
|
|||
@Injectable()
|
||||
export class UserFollowingService implements OnModuleInit {
|
||||
private userBlockingService: UserBlockingService;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
|
@ -86,7 +87,11 @@ export class UserFollowingService implements OnModuleInit {
|
|||
private accountMoveService: AccountMoveService,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private instanceChart: InstanceChart,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('following/create');
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
|
|
@ -142,12 +147,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
|
||||
}
|
||||
|
||||
if (await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
})) {
|
||||
if (await this.cacheService.isFollowing(follower, followee)) {
|
||||
// すでにフォロー関係が存在している場合
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
// リモート → ローカル: acceptを送り返しておしまい
|
||||
|
|
@ -175,24 +175,14 @@ export class UserFollowingService implements OnModuleInit {
|
|||
let autoAccept = false;
|
||||
|
||||
// 鍵アカウントであっても、既にフォローされていた場合はスルー
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
});
|
||||
const isFollowing = await this.cacheService.isFollowing(follower, followee);
|
||||
if (isFollowing) {
|
||||
autoAccept = true;
|
||||
}
|
||||
|
||||
// フォローしているユーザーは自動承認オプション
|
||||
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
|
||||
const isFollowed = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: followee.id,
|
||||
followeeId: follower.id,
|
||||
},
|
||||
});
|
||||
const isFollowed = await this.cacheService.isFollowing(followee, follower); // intentionally reversed parameters
|
||||
|
||||
if (isFollowed) autoAccept = true;
|
||||
}
|
||||
|
|
@ -201,12 +191,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
if (followee.isLocked && !autoAccept) {
|
||||
autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs(
|
||||
follower,
|
||||
(oldSrc, newSrc) => this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: followee.id,
|
||||
followerId: newSrc.id,
|
||||
},
|
||||
}),
|
||||
(oldSrc, newSrc) => this.cacheService.isFollowing(newSrc, followee),
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
|
@ -254,14 +239,15 @@ export class UserFollowingService implements OnModuleInit {
|
|||
followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null,
|
||||
}).catch(err => {
|
||||
if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
|
||||
this.logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
|
||||
alreadyFollowed = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
this.cacheService.userFollowingsCache.refresh(follower.id);
|
||||
// Handled by CacheService
|
||||
//this.cacheService.userFollowingsCache.refresh(follower.id);
|
||||
|
||||
const requestExist = await this.followRequestsRepository.exists({
|
||||
where: {
|
||||
|
|
@ -288,7 +274,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
}, followee.id);
|
||||
}
|
||||
|
||||
this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id });
|
||||
await this.internalEventService.emit('follow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
const [followeeUser, followerUser] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: followee.id }),
|
||||
|
|
@ -360,31 +346,29 @@ export class UserFollowingService implements OnModuleInit {
|
|||
},
|
||||
silent = false,
|
||||
): Promise<void> {
|
||||
const following = await this.followingsRepository.findOne({
|
||||
relations: {
|
||||
follower: true,
|
||||
followee: true,
|
||||
},
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
});
|
||||
const [
|
||||
followerUser,
|
||||
followeeUser,
|
||||
following,
|
||||
] = await Promise.all([
|
||||
this.cacheService.findUserById(follower.id),
|
||||
this.cacheService.findUserById(followee.id),
|
||||
this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)),
|
||||
]);
|
||||
|
||||
if (following === null || !following.follower || !following.followee) {
|
||||
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
|
||||
if (following == null) {
|
||||
this.logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.followingsRepository.delete(following.id);
|
||||
await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
this.cacheService.userFollowingsCache.refresh(follower.id);
|
||||
|
||||
this.decrementFollowing(following.follower, following.followee);
|
||||
this.decrementFollowing(followerUser, followeeUser);
|
||||
|
||||
if (!silent && this.userEntityService.isLocalUser(follower)) {
|
||||
// Publish unfollow event
|
||||
this.userEntityService.pack(followee.id, follower, {
|
||||
this.userEntityService.pack(followeeUser, follower, {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}).then(async packed => {
|
||||
this.globalEventService.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
|
@ -409,8 +393,6 @@ export class UserFollowingService implements OnModuleInit {
|
|||
follower: MiUser,
|
||||
followee: MiUser,
|
||||
): Promise<void> {
|
||||
this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
// Neither followee nor follower has moved.
|
||||
if (!follower.movedToUri && !followee.movedToUri) {
|
||||
//#region Decrement following / followers counts
|
||||
|
|
@ -684,22 +666,22 @@ export class UserFollowingService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
private async removeFollow(followee: Both, follower: Both): Promise<void> {
|
||||
const following = await this.followingsRepository.findOne({
|
||||
relations: {
|
||||
followee: true,
|
||||
follower: true,
|
||||
},
|
||||
where: {
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
},
|
||||
});
|
||||
const [
|
||||
followerUser,
|
||||
followeeUser,
|
||||
following,
|
||||
] = await Promise.all([
|
||||
this.cacheService.findUserById(follower.id),
|
||||
this.cacheService.findUserById(followee.id),
|
||||
this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)),
|
||||
]);
|
||||
|
||||
if (!following || !following.followee || !following.follower) return;
|
||||
if (!following) return;
|
||||
|
||||
await this.followingsRepository.delete(following.id);
|
||||
await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
this.decrementFollowing(following.follower, following.followee);
|
||||
this.decrementFollowing(followerUser, followeeUser);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -730,10 +712,26 @@ export class UserFollowingService implements OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public getFollowees(userId: MiUser['id']) {
|
||||
return this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: userId })
|
||||
.getMany();
|
||||
public async getFollowees(userId: MiUser['id']) {
|
||||
const followings = await this.cacheService.userFollowingsCache.fetch(userId);
|
||||
return Array.from(followings.values());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
|
||||
return this.cacheService.isFollowing(followerId, followeeId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) {
|
||||
const [
|
||||
isFollowing,
|
||||
isFollowed,
|
||||
] = await Promise.all([
|
||||
this.isFollowing(aUserId, bUserId),
|
||||
this.isFollowing(bUserId, aUserId),
|
||||
]);
|
||||
|
||||
return isFollowing && isFollowed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
|||
import * as Redis from 'ioredis';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { UserKeypairsRepository } from '@/models/_.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import type { MiUserKeypair } from '@/models/UserKeypair.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserKeypairService implements OnApplicationShutdown {
|
||||
private cache: RedisKVCache<MiUserKeypair>;
|
||||
private cache: MemoryKVCache<MiUserKeypair>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
|
|
@ -23,18 +23,12 @@ export class UserKeypairService implements OnApplicationShutdown {
|
|||
@Inject(DI.userKeypairsRepository)
|
||||
private userKeypairsRepository: UserKeypairsRepository,
|
||||
) {
|
||||
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
|
||||
lifetime: 1000 * 60 * 60 * 24, // 24h
|
||||
memoryCacheLifetime: 1000 * 60 * 60, // 1h
|
||||
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
});
|
||||
this.cache = new MemoryKVCache<MiUserKeypair>(1000 * 60 * 60 * 24); // 24h
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserKeypair(userId: MiUser['id']): Promise<MiUserKeypair> {
|
||||
return await this.cache.fetch(userId);
|
||||
return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId }));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -6,26 +6,27 @@
|
|||
import { Inject, Injectable, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import type { UserListMembershipsRepository } from '@/models/_.js';
|
||||
import type { MiMeta, UserListMembershipsRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiUserList } from '@/models/UserList.js';
|
||||
import type { MiUserListMembership } from '@/models/UserListMembership.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { GlobalEvents, InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ProxyAccountService } from '@/core/ProxyAccountService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
||||
public static TooManyUsersError = class extends Error {};
|
||||
|
||||
public membersCache: RedisKVCache<Set<string>>;
|
||||
public membersCache: QuantumKVCache<Set<string>>;
|
||||
private roleService: RoleService;
|
||||
|
||||
constructor(
|
||||
|
|
@ -40,21 +41,23 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
|||
@Inject(DI.userListMembershipsRepository)
|
||||
private userListMembershipsRepository: UserListMembershipsRepository,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private readonly meta: MiMeta,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private proxyAccountService: ProxyAccountService,
|
||||
private queueService: QueueService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
) {
|
||||
this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', {
|
||||
this.membersCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userListMembers', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
memoryCacheLifetime: 1000 * 60, // 1m
|
||||
fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))),
|
||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
});
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
this.internalEventService.on('userListMemberAdded', this.onMessage);
|
||||
this.internalEventService.on('userListMemberRemoved', this.onMessage);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
|
|
@ -62,15 +65,12 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
private async onMessage<E extends 'userListMemberAdded' | 'userListMemberRemoved'>(body: InternalEventTypes[E], type: E): Promise<void> {
|
||||
{
|
||||
switch (type) {
|
||||
case 'userListMemberAdded': {
|
||||
const { userListId, memberId } = body;
|
||||
const members = await this.membersCache.get(userListId);
|
||||
const members = this.membersCache.get(userListId);
|
||||
if (members) {
|
||||
members.add(memberId);
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
case 'userListMemberRemoved': {
|
||||
const { userListId, memberId } = body;
|
||||
const members = await this.membersCache.get(userListId);
|
||||
const members = this.membersCache.get(userListId);
|
||||
if (members) {
|
||||
members.delete(memberId);
|
||||
}
|
||||
|
|
@ -110,11 +110,9 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
|||
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
|
||||
|
||||
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
|
||||
if (this.userEntityService.isRemoteUser(target)) {
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
if (proxy) {
|
||||
this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
|
||||
}
|
||||
if (this.userEntityService.isRemoteUser(target) && this.meta.enableProxyAccount) {
|
||||
const proxy = await this.systemAccountService.fetch('proxy');
|
||||
this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +147,8 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
this.internalEventService.off('userListMemberAdded', this.onMessage);
|
||||
this.internalEventService.off('userListMemberRemoved', this.onMessage);
|
||||
this.membersCache.dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class UserMutingService {
|
|||
muteeId: target.id,
|
||||
});
|
||||
|
||||
this.cacheService.userMutingsCache.refresh(user.id);
|
||||
await this.cacheService.userMutingsCache.delete(user.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -43,9 +43,6 @@ export class UserMutingService {
|
|||
id: In(mutings.map(m => m.id)),
|
||||
});
|
||||
|
||||
const muterIds = [...new Set(mutings.map(m => m.muterId))];
|
||||
for (const muterId of muterIds) {
|
||||
this.cacheService.userMutingsCache.refresh(muterId);
|
||||
}
|
||||
await this.cacheService.userMutingsCache.deleteMany(mutings.map(m => m.muterId));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export class UserRenoteMutingService {
|
|||
muteeId: target.id,
|
||||
});
|
||||
|
||||
await this.cacheService.renoteMutingsCache.refresh(user.id);
|
||||
await this.cacheService.renoteMutingsCache.delete(user.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -44,9 +44,6 @@ export class UserRenoteMutingService {
|
|||
id: In(mutings.map(m => m.id)),
|
||||
});
|
||||
|
||||
const muterIds = [...new Set(mutings.map(m => m.muterId))];
|
||||
for (const muterId of muterIds) {
|
||||
await this.cacheService.renoteMutingsCache.refresh(muterId);
|
||||
}
|
||||
await this.cacheService.renoteMutingsCache.deleteMany(mutings.map(m => m.muterId));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets, SelectQueryBuilder } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { type FollowingsRepository, MiUser, type UsersRepository } from '@/models/_.js';
|
||||
import { type FollowingsRepository, MiUser, type MutingsRepository, type UserProfilesRepository, type UsersRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
|
@ -22,10 +22,19 @@ export class UserSearchService {
|
|||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
}
|
||||
|
|
@ -58,7 +67,7 @@ export class UserSearchService {
|
|||
* @see {@link UserSearchService#buildSearchUserNoLoginQueries}
|
||||
*/
|
||||
@bindThis
|
||||
public async search(
|
||||
public async searchByUsernameAndHost(
|
||||
params: {
|
||||
username?: string | null,
|
||||
host?: string | null,
|
||||
|
|
@ -202,4 +211,91 @@ export class UserSearchService {
|
|||
|
||||
return userQuery;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async search(query: string, meId: MiUser['id'] | null, options: Partial<{
|
||||
limit: number;
|
||||
offset: number;
|
||||
origin: 'local' | 'remote' | 'combined';
|
||||
}> = {}) {
|
||||
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
|
||||
|
||||
const isUsername = query.startsWith('@') && !query.includes(' ') && query.indexOf('@', 1) === -1;
|
||||
|
||||
let users: MiUser[] = [];
|
||||
|
||||
const mutingQuery = meId == null ? null : this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: meId });
|
||||
|
||||
const nameQuery = this.usersRepository.createQueryBuilder('user')
|
||||
.where(new Brackets(qb => {
|
||||
qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' });
|
||||
|
||||
if (isUsername) {
|
||||
qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(query.replace('@', '').toLowerCase()) + '%' });
|
||||
} else if (this.userEntityService.validateLocalUsername(query)) { // Also search username if it qualifies as username
|
||||
qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(query.toLowerCase()) + '%' });
|
||||
}
|
||||
}))
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where('user.updatedAt IS NULL')
|
||||
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
|
||||
}))
|
||||
.andWhere('user.isSuspended = FALSE');
|
||||
|
||||
if (mutingQuery) {
|
||||
nameQuery.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`);
|
||||
nameQuery.setParameters(mutingQuery.getParameters());
|
||||
}
|
||||
|
||||
if (options.origin === 'local') {
|
||||
nameQuery.andWhere('user.host IS NULL');
|
||||
} else if (options.origin === 'remote') {
|
||||
nameQuery.andWhere('user.host IS NOT NULL');
|
||||
}
|
||||
|
||||
users = await nameQuery
|
||||
.orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
|
||||
.limit(options.limit)
|
||||
.offset(options.offset)
|
||||
.getMany();
|
||||
|
||||
if (users.length < (options.limit ?? 30)) {
|
||||
const profQuery = this.userProfilesRepository.createQueryBuilder('prof')
|
||||
.select('prof.userId')
|
||||
.where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' });
|
||||
|
||||
if (mutingQuery) {
|
||||
profQuery.andWhere(`prof.userId NOT IN (${mutingQuery.getQuery()})`);
|
||||
profQuery.setParameters(mutingQuery.getParameters());
|
||||
}
|
||||
|
||||
if (options.origin === 'local') {
|
||||
profQuery.andWhere('prof.userHost IS NULL');
|
||||
} else if (options.origin === 'remote') {
|
||||
profQuery.andWhere('prof.userHost IS NOT NULL');
|
||||
}
|
||||
|
||||
const userQuery = this.usersRepository.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 userQuery
|
||||
.orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
|
||||
.limit(options.limit)
|
||||
.offset(options.offset)
|
||||
.getMany(),
|
||||
);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
|
|
@ -20,6 +21,7 @@ export class UserService {
|
|||
private followingsRepository: FollowingsRepository,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private userEntityService: UserEntityService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -38,14 +40,17 @@ export class UserService {
|
|||
});
|
||||
const wokeUp = result.isHibernated;
|
||||
if (wokeUp) {
|
||||
this.usersRepository.update(user.id, {
|
||||
isHibernated: false,
|
||||
});
|
||||
this.followingsRepository.update({
|
||||
followerId: user.id,
|
||||
}, {
|
||||
isFollowerHibernated: false,
|
||||
});
|
||||
await Promise.all([
|
||||
this.usersRepository.update(user.id, {
|
||||
isHibernated: false,
|
||||
}),
|
||||
this.followingsRepository.update({
|
||||
followerId: user.id,
|
||||
}, {
|
||||
isFollowerHibernated: false,
|
||||
}),
|
||||
this.cacheService.hibernatedUserCache.set(user.id, false),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
this.usersRepository.update(user.id, {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import { Not, IsNull, DataSource } from 'typeorm';
|
||||
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
|
@ -16,9 +16,17 @@ import { bindThis } from '@/decorators.js';
|
|||
import { RelationshipJobData } from '@/queue/types.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserSuspendService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
|
@ -29,12 +37,20 @@ export class UserSuspendService {
|
|||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('user-suspend');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -45,16 +61,18 @@ export class UserSuspendService {
|
|||
isSuspended: true,
|
||||
});
|
||||
|
||||
this.moderationLogService.log(moderator, 'suspend', {
|
||||
await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id });
|
||||
|
||||
await this.moderationLogService.log(moderator, 'suspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await this.postSuspend(user).catch(e => {});
|
||||
await this.unFollowAll(user).catch(e => {});
|
||||
})();
|
||||
trackPromise((async () => {
|
||||
await this.postSuspend(user);
|
||||
await this.freezeAll(user);
|
||||
})().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -63,33 +81,38 @@ export class UserSuspendService {
|
|||
isSuspended: false,
|
||||
});
|
||||
|
||||
this.moderationLogService.log(moderator, 'unsuspend', {
|
||||
await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id });
|
||||
|
||||
await this.moderationLogService.log(moderator, 'unsuspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await this.postUnsuspend(user).catch(e => {});
|
||||
})();
|
||||
trackPromise((async () => {
|
||||
await this.postUnsuspend(user);
|
||||
await this.unFreezeAll(user);
|
||||
})().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
|
||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
||||
|
||||
/*
|
||||
this.followRequestsRepository.delete({
|
||||
followeeId: user.id,
|
||||
});
|
||||
this.followRequestsRepository.delete({
|
||||
followerId: user.id,
|
||||
});
|
||||
*/
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
const queue = new Map<string, boolean>();
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
|
|
@ -102,12 +125,12 @@ export class UserSuspendService {
|
|||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
if (inbox != null) {
|
||||
queue.set(inbox, true);
|
||||
}
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user, content, inbox, true);
|
||||
}
|
||||
await this.queueService.deliverMany(user, content, queue);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +142,7 @@ export class UserSuspendService {
|
|||
// 知り得る全SharedInboxにUndo Delete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
const queue = new Map<string, boolean>();
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
|
|
@ -132,23 +155,19 @@ export class UserSuspendService {
|
|||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
if (inbox != null) {
|
||||
queue.set(inbox, true);
|
||||
}
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user as any, content, inbox, true);
|
||||
}
|
||||
await this.queueService.deliverMany(user, content, queue);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async unFollowAll(follower: MiUser) {
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
const followings = await this.cacheService.userFollowingsCache.fetch(follower.id)
|
||||
.then(fs => Array.from(fs.values()).filter(f => f.followeeHost != null));
|
||||
|
||||
const jobs: RelationshipJobData[] = [];
|
||||
for (const following of followings) {
|
||||
|
|
@ -162,4 +181,35 @@ export class UserSuspendService {
|
|||
}
|
||||
this.queueService.createUnfollowJob(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async freezeAll(user: MiUser): Promise<void> {
|
||||
// Freeze follow relations with all remote users
|
||||
await this.followingsRepository
|
||||
.createQueryBuilder('following')
|
||||
.update({
|
||||
isFollowerHibernated: true,
|
||||
})
|
||||
.where({
|
||||
followeeId: user.id,
|
||||
followerHost: Not(IsNull()),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async unFreezeAll(user: MiUser): Promise<void> {
|
||||
// Restore follow relations with all remote users
|
||||
|
||||
// TypeORM does not support UPDATE with JOIN: https://github.com/typeorm/typeorm/issues/564#issuecomment-310331468
|
||||
await this.db.query(`
|
||||
UPDATE "following"
|
||||
SET "isFollowerHibernated" = false
|
||||
FROM "user"
|
||||
WHERE "user"."id" = "following"."followerId"
|
||||
AND "user"."isHibernated" = false -- Don't unfreeze if the follower is *actually* frozen
|
||||
AND "followeeId" = $1
|
||||
AND "followeeHost" IS NOT NULL
|
||||
`, [user.id]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { QueueService } from '@/core/QueueService.js';
|
|||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
export type UserWebhookPayload<T extends WebhookEventTypes> =
|
||||
T extends 'note' | 'reply' | 'renote' |'mention' | 'edited' ? {
|
||||
T extends 'note' | 'reply' | 'renote' | 'mention' | 'edited' ? {
|
||||
note: Packed<'Note'>,
|
||||
} :
|
||||
T extends 'follow' | 'unfollow' ? {
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@ import { URL, domainToASCII } from 'node:url';
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
import psl from 'psl';
|
||||
import semver from 'semver';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
import type { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
|
||||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { EnvService } from '@/core/EnvService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UtilityService {
|
||||
|
|
@ -20,6 +24,8 @@ export class UtilityService {
|
|||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
private readonly envService: EnvService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -39,22 +45,59 @@ export class UtilityService {
|
|||
return this.punyHost(uri) === this.toPuny(this.config.host);
|
||||
}
|
||||
|
||||
// メールアドレスのバリデーションを行う
|
||||
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
|
||||
@bindThis
|
||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
||||
public validateEmailFormat(email: string): boolean {
|
||||
// Note: replaced MK's complicated regex with a simpler one that is more efficient and reliable.
|
||||
const regexp = /^.+@.+$/;
|
||||
//const regexp = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
return regexp.test(email);
|
||||
}
|
||||
|
||||
public isBlockedHost(host: string | null): boolean;
|
||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean;
|
||||
@bindThis
|
||||
public isBlockedHost(blockedHostsOrHost: string[] | string | null, host?: string | null): boolean {
|
||||
const blockedHosts = Array.isArray(blockedHostsOrHost) ? blockedHostsOrHost : this.meta.blockedHosts;
|
||||
host = Array.isArray(blockedHostsOrHost) ? host : blockedHostsOrHost;
|
||||
|
||||
if (host == null) return false;
|
||||
return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
public isSilencedHost(host: string | null): boolean;
|
||||
public isSilencedHost(silencedHosts: string[], host: string | null): boolean;
|
||||
@bindThis
|
||||
public isSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean {
|
||||
if (!silencedHosts || host == null) return false;
|
||||
public isSilencedHost(silencedHostsOrHost: string[] | string | null, host?: string | null): boolean {
|
||||
const silencedHosts = Array.isArray(silencedHostsOrHost) ? silencedHostsOrHost : this.meta.silencedHosts;
|
||||
host = Array.isArray(silencedHostsOrHost) ? host : silencedHostsOrHost;
|
||||
|
||||
if (host == null) return false;
|
||||
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
public isMediaSilencedHost(host: string | null): boolean;
|
||||
public isMediaSilencedHost(silencedHosts: string[], host: string | null): boolean;
|
||||
@bindThis
|
||||
public isMediaSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean {
|
||||
if (!silencedHosts || host == null) return false;
|
||||
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
public isMediaSilencedHost(mediaSilencedHostsOrHost: string[] | string | null, host?: string | null): boolean {
|
||||
const mediaSilencedHosts = Array.isArray(mediaSilencedHostsOrHost) ? mediaSilencedHostsOrHost : this.meta.mediaSilencedHosts;
|
||||
host = Array.isArray(mediaSilencedHostsOrHost) ? host : mediaSilencedHostsOrHost;
|
||||
|
||||
if (host == null) return false;
|
||||
return mediaSilencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isAllowListedHost(host: string | null): boolean {
|
||||
if (host == null) return false;
|
||||
return this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isBubbledHost(host: string | null): boolean {
|
||||
if (host == null) return false;
|
||||
return this.meta.bubbleInstances.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -106,13 +149,22 @@ export class UtilityService {
|
|||
|
||||
@bindThis
|
||||
public toPuny(host: string): string {
|
||||
return domainToASCII(host.toLowerCase());
|
||||
// domainToASCII will return an empty string if we give it a
|
||||
// string like `name:123`, but `host` may well be in that form
|
||||
// (e.g. when testing locally, you'll get `localhost:3000`); split
|
||||
// the port off, and add it back later
|
||||
const hostParts = host.toLowerCase().match(/^(.+?)(:.+)?$/);
|
||||
if (!hostParts) return '';
|
||||
const hostname = hostParts[1];
|
||||
const port = hostParts[2] ?? '';
|
||||
|
||||
return domainToASCII(hostname) + port;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public toPunyNullable(host: string | null | undefined): string | null {
|
||||
if (host == null) return null;
|
||||
return domainToASCII(host.toLowerCase());
|
||||
return this.toPuny(host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -135,8 +187,8 @@ export class UtilityService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public punyHostPSLDomain(url: string): string {
|
||||
const urlObj = new URL(url);
|
||||
public punyHostPSLDomain(url: string | URL): string {
|
||||
const urlObj = typeof(url) === 'object' ? url : new URL(url);
|
||||
const hostname = urlObj.hostname;
|
||||
const domain = this.specialSuffix(hostname) ?? psl.get(hostname) ?? hostname;
|
||||
const host = `${this.toPuny(domain)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||
|
|
@ -157,4 +209,97 @@ export class UtilityService {
|
|||
const host = this.extractDbHost(uri);
|
||||
return this.isFederationAllowedHost(host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getUrlScheme(url: string): string {
|
||||
try {
|
||||
// Returns in the format "https:" or an empty string
|
||||
return new URL(url).protocol;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isDeliverSuspendedSoftware(software: Pick<MiInstance, 'softwareName' | 'softwareVersion'>): SoftwareSuspension | undefined {
|
||||
// a missing name or version is treated as the empty string
|
||||
const softwareName = software.softwareName ?? '';
|
||||
const softwareVersion = software.softwareVersion ?? '';
|
||||
|
||||
function maybeRegexpMatch(test: string, target: string): boolean {
|
||||
const regexpStrPair = test.trim().match(/^\/(.+)\/(.*)$/);
|
||||
if (!regexpStrPair) return false; // not a regexp, can't match
|
||||
|
||||
try {
|
||||
return new RE2(regexpStrPair[1], regexpStrPair[2]).test(target);
|
||||
} catch (err) {
|
||||
return false; // not a well-formed regexp, can't match
|
||||
}
|
||||
}
|
||||
|
||||
// each element of `meta.deliverSuspendedSoftware` can have a
|
||||
// normal string, a `*`, or a `/regexp/` for software or
|
||||
// versionRange
|
||||
return this.meta.deliverSuspendedSoftware.find(
|
||||
x => (
|
||||
(
|
||||
x.software.trim() === '*' ||
|
||||
x.software === softwareName ||
|
||||
maybeRegexpMatch(x.software, softwareName)
|
||||
) && (
|
||||
x.versionRange.trim() === '*' ||
|
||||
semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }) ||
|
||||
maybeRegexpMatch(x.versionRange, softwareVersion)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a provided URL is in a format acceptable for federation.
|
||||
* @throws {IdentifiableError} If URL cannot be parsed
|
||||
* @throws {IdentifiableError} If URL is not HTTPS
|
||||
* @throws {IdentifiableError} If URL contains credentials
|
||||
*/
|
||||
@bindThis
|
||||
public assertUrl(url: string | URL, allowHttp?: boolean): URL | never {
|
||||
// If string, parse and validate
|
||||
if (typeof(url) === 'string') {
|
||||
try {
|
||||
url = new URL(url);
|
||||
} catch {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: not a valid URL`);
|
||||
}
|
||||
}
|
||||
|
||||
// Must be HTTPS
|
||||
if (!this.checkHttps(url, allowHttp)) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: unsupported protocol ${url.protocol}`);
|
||||
}
|
||||
|
||||
// Must not have credentials
|
||||
if (url.username || url.password) {
|
||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid url ${url}: contains embedded credentials`);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the URL contains HTTPS.
|
||||
* Additionally, allows HTTP in non-production environments.
|
||||
* Based on check-https.ts.
|
||||
*/
|
||||
@bindThis
|
||||
public checkHttps(url: string | URL, allowHttp = false): boolean {
|
||||
const isNonProd = this.envService.env.NODE_ENV !== 'production';
|
||||
|
||||
try {
|
||||
const proto = new URL(url).protocol;
|
||||
return proto === 'https:' || (proto === 'http:' && (isNonProd || allowHttp));
|
||||
} catch {
|
||||
// Invalid URLs don't "count" as HTTPS
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,24 +3,41 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import FFmpeg from 'fluent-ffmpeg';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
import { createTemp, createTempDir } from '@/misc/create-temp.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { appendQuery, query } from '@/misc/prelude/url.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
||||
// faststart is only supported for MP4, M4A, M4W and MOV files (the MOV family).
|
||||
// WebM (and Matroska) files always support faststart-like behavior.
|
||||
const supportedMimeTypes = new Map([
|
||||
['video/mp4', 'mp4'],
|
||||
['video/m4a', 'mp4'],
|
||||
['video/m4v', 'mp4'],
|
||||
['video/quicktime', 'mov'],
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class VideoProcessingService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private imageProcessingService: ImageProcessingService,
|
||||
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('video-processing');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -60,5 +77,51 @@ export class VideoProcessingService {
|
|||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize video for web playback by adding faststart flag.
|
||||
* This allows the video to start playing before it is fully downloaded.
|
||||
* The original file is modified in-place.
|
||||
* @param source Path to the video file
|
||||
* @param mimeType The MIME type of the video
|
||||
* @returns Promise that resolves when optimization is complete
|
||||
*/
|
||||
@bindThis
|
||||
public async webOptimizeVideo(source: string, mimeType: string): Promise<void> {
|
||||
const outputFormat = supportedMimeTypes.get(mimeType);
|
||||
if (!outputFormat) {
|
||||
this.logger.debug(`Skipping web optimization for unsupported MIME type: ${mimeType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [tempPath, cleanup] = await createTemp();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
FFmpeg(source)
|
||||
.format(outputFormat) // Specify output format
|
||||
.addOutputOptions('-c copy') // Copy streams without re-encoding
|
||||
.addOutputOptions('-movflags +faststart')
|
||||
.addOutputOptions('-map 0')
|
||||
.on('error', reject)
|
||||
.on('end', async () => {
|
||||
try {
|
||||
// Replace original file with optimized version
|
||||
await fs.copyFile(tempPath, source);
|
||||
this.logger.info(`Web-optimized video: ${source}`);
|
||||
resolve();
|
||||
} catch (copyError) {
|
||||
reject(copyError);
|
||||
}
|
||||
})
|
||||
.save(tempPath);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to web-optimize video: ${source}`, { error });
|
||||
throw error;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import type { Config } from '@/config.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { MiUser } from '@/models/_.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import Logger from '@/logger.js';
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
|
|
@ -28,6 +30,8 @@ import type {
|
|||
|
||||
@Injectable()
|
||||
export class WebAuthnService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
|
@ -40,7 +44,9 @@ export class WebAuthnService {
|
|||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('web-authn');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -114,8 +120,8 @@ export class WebAuthnService {
|
|||
requireUserVerification: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed');
|
||||
this.logger.error(error as Error, 'Error authenticating webauthn');
|
||||
throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed', true, error);
|
||||
}
|
||||
|
||||
const { verified } = verification;
|
||||
|
|
@ -127,11 +133,11 @@ export class WebAuthnService {
|
|||
const { registrationInfo } = verification;
|
||||
|
||||
return {
|
||||
credentialID: registrationInfo.credentialID,
|
||||
credentialPublicKey: registrationInfo.credentialPublicKey,
|
||||
credentialID: registrationInfo.credential.id,
|
||||
credentialPublicKey: registrationInfo.credential.publicKey,
|
||||
attestationObject: registrationInfo.attestationObject,
|
||||
fmt: registrationInfo.fmt,
|
||||
counter: registrationInfo.counter,
|
||||
counter: registrationInfo.credential.counter,
|
||||
userVerified: registrationInfo.userVerified,
|
||||
credentialDeviceType: registrationInfo.credentialDeviceType,
|
||||
credentialBackedUp: registrationInfo.credentialBackedUp,
|
||||
|
|
@ -212,16 +218,16 @@ export class WebAuthnService {
|
|||
expectedChallenge: challenge,
|
||||
expectedOrigin: relyingParty.origin,
|
||||
expectedRPID: relyingParty.rpId,
|
||||
authenticator: {
|
||||
credentialID: key.id,
|
||||
credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
|
||||
credential: {
|
||||
id: key.id,
|
||||
publicKey: Buffer.from(key.publicKey, 'base64url'),
|
||||
counter: key.counter,
|
||||
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
|
||||
},
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed: ${error}`);
|
||||
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed`, true, error);
|
||||
}
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
|
@ -292,17 +298,17 @@ export class WebAuthnService {
|
|||
expectedChallenge: challenge,
|
||||
expectedOrigin: relyingParty.origin,
|
||||
expectedRPID: relyingParty.rpId,
|
||||
authenticator: {
|
||||
credentialID: key.id,
|
||||
credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
|
||||
credential: {
|
||||
id: key.id,
|
||||
publicKey: Buffer.from(key.publicKey, 'base64url'),
|
||||
counter: key.counter,
|
||||
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
|
||||
},
|
||||
requireUserVerification: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed');
|
||||
this.logger.error(error as Error, 'Error authenticating webauthn');
|
||||
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed', true, error);
|
||||
}
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@
|
|||
|
||||
import { URL } from 'node:url';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { load as cheerio } from 'cheerio/slim';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { RemoteLoggerService } from './RemoteLoggerService.js';
|
||||
|
||||
export type ILink = {
|
||||
|
|
@ -100,16 +101,14 @@ export class WebfingerService {
|
|||
private async fetchWebFingerTemplateFromHostMeta(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await this.httpRequestService.getHtml(url, 'application/xrd+xml');
|
||||
const options = {
|
||||
ignoreAttributes: false,
|
||||
isArray: (_name: string, jpath: string) => jpath === 'XRD.Link',
|
||||
};
|
||||
const parser = new XMLParser(options);
|
||||
const hostMeta = parser.parse(res);
|
||||
const template = (hostMeta['XRD']['Link'] as Array<any>).filter(p => p['@_rel'] === 'lrdd')[0]['@_template'];
|
||||
return template.indexOf('{uri}') < 0 ? null : template;
|
||||
const hostMeta = cheerio(res, {
|
||||
xml: true,
|
||||
});
|
||||
|
||||
const template = hostMeta('XRD > Link[rel="lrdd"][template*="{uri}"]').attr('template');
|
||||
return template ?? null;
|
||||
} catch (err) {
|
||||
this.logger.error(`error while request host-meta for ${url}: ${err}`);
|
||||
this.logger.error(`error while request host-meta for ${url}: ${renderInlineError(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,42 +7,17 @@ import { Injectable } from '@nestjs/common';
|
|||
import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
|
||||
import { AbuseReportPayload, SystemWebhookPayload, SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { type AbuseReportPayload, SystemWebhookPayload, SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { type Packed } from '@/misc/json-schema.js';
|
||||
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
|
||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||
|
||||
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): AbuseReportPayload {
|
||||
const result: MiAbuseUserReport = {
|
||||
id: 'dummy-abuse-report1',
|
||||
targetUserId: 'dummy-target-user',
|
||||
targetUser: null,
|
||||
reporterId: 'dummy-reporter-user',
|
||||
reporter: null,
|
||||
assigneeId: null,
|
||||
assignee: null,
|
||||
resolved: false,
|
||||
forwarded: false,
|
||||
comment: 'This is a dummy report for testing purposes.',
|
||||
targetUserHost: null,
|
||||
reporterHost: null,
|
||||
resolvedAs: null,
|
||||
moderationNote: 'foo',
|
||||
...override,
|
||||
};
|
||||
|
||||
return {
|
||||
...result,
|
||||
targetUser: result.targetUser ? toPackedUserLite(result.targetUser) : null,
|
||||
reporter: result.reporter ? toPackedUserLite(result.reporter) : null,
|
||||
assignee: result.assignee ? toPackedUserLite(result.assignee) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||
return {
|
||||
id: 'dummy-user-1',
|
||||
|
|
@ -79,16 +54,17 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
|||
isBot: false,
|
||||
isCat: true,
|
||||
speakAsCat: true,
|
||||
isRoot: false,
|
||||
isExplorable: true,
|
||||
isHibernated: false,
|
||||
isDeleted: false,
|
||||
requireSigninToViewContents: false,
|
||||
makeNotesFollowersOnlyBefore: null,
|
||||
makeNotesHiddenBefore: null,
|
||||
chatScope: 'mutual',
|
||||
emojis: [],
|
||||
score: 0,
|
||||
host: null,
|
||||
instance: null,
|
||||
inbox: null,
|
||||
sharedInbox: null,
|
||||
featured: null,
|
||||
|
|
@ -101,6 +77,9 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
|||
enableRss: true,
|
||||
mandatoryCW: null,
|
||||
rejectQuotes: false,
|
||||
allowUnsignedFetch: 'staff',
|
||||
userProfile: null,
|
||||
attributionDomains: [],
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
|
@ -139,145 +118,19 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
|||
channelId: null,
|
||||
channel: null,
|
||||
userHost: null,
|
||||
userInstance: null,
|
||||
replyUserId: null,
|
||||
replyUserHost: null,
|
||||
replyUserInstance: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
renoteUserInstance: null,
|
||||
updatedAt: null,
|
||||
processErrors: [],
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> {
|
||||
return {
|
||||
id: note.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
text: note.text,
|
||||
cw: note.cw,
|
||||
userId: note.userId,
|
||||
user: toPackedUserLite(note.user ?? generateDummyUser()),
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
isHidden: false,
|
||||
visibility: note.visibility,
|
||||
mentions: note.mentions,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
fileIds: note.fileIds,
|
||||
files: [],
|
||||
tags: note.tags,
|
||||
poll: null,
|
||||
emojis: note.emojis,
|
||||
channelId: note.channelId,
|
||||
channel: note.channel,
|
||||
localOnly: note.localOnly,
|
||||
reactionAcceptance: note.reactionAcceptance,
|
||||
reactionEmojis: {},
|
||||
reactions: {},
|
||||
reactionCount: 0,
|
||||
renoteCount: note.renoteCount,
|
||||
repliesCount: note.repliesCount,
|
||||
uri: note.uri ?? undefined,
|
||||
url: note.url ?? undefined,
|
||||
reactionAndUserPairCache: note.reactionAndUserPairCache,
|
||||
...(detail ? {
|
||||
clippedCount: note.clippedCount,
|
||||
reply: note.reply ? toPackedNote(note.reply, false) : null,
|
||||
renote: note.renote ? toPackedNote(note.renote, true) : null,
|
||||
myReaction: null,
|
||||
} : {}),
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
host: user.host,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarBlurhash: user.avatarBlurhash,
|
||||
avatarDecorations: user.avatarDecorations.map(it => ({
|
||||
id: it.id,
|
||||
angle: it.angle,
|
||||
flipH: it.flipH,
|
||||
url: 'https://example.com/dummy-image001.png',
|
||||
offsetX: it.offsetX,
|
||||
offsetY: it.offsetY,
|
||||
})),
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
speakAsCat: user.speakAsCat,
|
||||
emojis: user.emojis,
|
||||
onlineStatus: 'active',
|
||||
badgeRoles: [],
|
||||
noindex: user.noindex,
|
||||
isModerator: false,
|
||||
isAdmin: false,
|
||||
isSystem: false,
|
||||
isSilenced: user.isSilenced,
|
||||
enableRss: true,
|
||||
mandatoryCW: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> {
|
||||
return {
|
||||
...toPackedUserLite(user),
|
||||
url: null,
|
||||
uri: null,
|
||||
movedTo: null,
|
||||
alsoKnownAs: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
|
||||
bannerUrl: user.bannerUrl,
|
||||
bannerBlurhash: user.bannerBlurhash,
|
||||
backgroundUrl: user.backgroundUrl,
|
||||
backgroundBlurhash: user.backgroundBlurhash,
|
||||
isLocked: user.isLocked,
|
||||
isSilenced: false,
|
||||
isSuspended: user.isSuspended,
|
||||
description: null,
|
||||
location: null,
|
||||
birthday: null,
|
||||
lang: null,
|
||||
fields: [],
|
||||
verifiedLinks: [],
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
notesCount: user.notesCount,
|
||||
pinnedNoteIds: [],
|
||||
pinnedNotes: [],
|
||||
pinnedPageId: null,
|
||||
pinnedPage: null,
|
||||
publicReactions: true,
|
||||
followersVisibility: 'public',
|
||||
followingVisibility: 'public',
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
securityKeys: false,
|
||||
roles: [],
|
||||
memo: null,
|
||||
moderationNote: undefined,
|
||||
isFollowing: false,
|
||||
isFollowed: false,
|
||||
hasPendingFollowRequestFromYou: false,
|
||||
hasPendingFollowRequestToYou: false,
|
||||
isBlocking: false,
|
||||
isBlocked: false,
|
||||
isMuted: false,
|
||||
isRenoteMuted: false,
|
||||
notify: 'none',
|
||||
withReplies: true,
|
||||
listenbrainz: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
const dummyUser1 = generateDummyUser();
|
||||
const dummyUser2 = generateDummyUser({
|
||||
id: 'dummy-user-2',
|
||||
|
|
@ -310,9 +163,11 @@ export class WebhookTestService {
|
|||
};
|
||||
|
||||
constructor(
|
||||
private customEmojiService: CustomEmojiService,
|
||||
private userWebhookService: UserWebhookService,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private queueService: QueueService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -380,35 +235,35 @@ export class WebhookTestService {
|
|||
|
||||
switch (params.type) {
|
||||
case 'note': {
|
||||
send('note', { note: toPackedNote(dummyNote1) });
|
||||
send('note', { note: await this.toPackedNote(dummyNote1) });
|
||||
break;
|
||||
}
|
||||
case 'reply': {
|
||||
send('reply', { note: toPackedNote(dummyReply1) });
|
||||
send('reply', { note: await this.toPackedNote(dummyReply1) });
|
||||
break;
|
||||
}
|
||||
case 'renote': {
|
||||
send('renote', { note: toPackedNote(dummyRenote1) });
|
||||
send('renote', { note: await this.toPackedNote(dummyRenote1) });
|
||||
break;
|
||||
}
|
||||
case 'mention': {
|
||||
send('mention', { note: toPackedNote(dummyMention1) });
|
||||
send('mention', { note: await this.toPackedNote(dummyMention1) });
|
||||
break;
|
||||
}
|
||||
case 'edited': {
|
||||
send('edited', { note: toPackedNote(dummyNote1) });
|
||||
send('edited', { note: await this.toPackedNote(dummyNote1) });
|
||||
break;
|
||||
}
|
||||
case 'follow': {
|
||||
send('follow', { user: toPackedUserDetailedNotMe(dummyUser1) });
|
||||
send('follow', { user: await this.toPackedUserDetailedNotMe(dummyUser1) });
|
||||
break;
|
||||
}
|
||||
case 'followed': {
|
||||
send('followed', { user: toPackedUserLite(dummyUser2) });
|
||||
send('followed', { user: await this.toPackedUserLite(dummyUser2) });
|
||||
break;
|
||||
}
|
||||
case 'unfollow': {
|
||||
send('unfollow', { user: toPackedUserDetailedNotMe(dummyUser3) });
|
||||
send('unfollow', { user: await this.toPackedUserDetailedNotMe(dummyUser3) });
|
||||
break;
|
||||
}
|
||||
// まだ実装されていない (#9485)
|
||||
|
|
@ -457,7 +312,7 @@ export class WebhookTestService {
|
|||
|
||||
switch (params.type) {
|
||||
case 'abuseReport': {
|
||||
send('abuseReport', generateAbuseReport({
|
||||
send('abuseReport', await this.generateAbuseReport({
|
||||
targetUserId: dummyUser1.id,
|
||||
targetUser: dummyUser1,
|
||||
reporterId: dummyUser2.id,
|
||||
|
|
@ -466,7 +321,7 @@ export class WebhookTestService {
|
|||
break;
|
||||
}
|
||||
case 'abuseReportResolved': {
|
||||
send('abuseReportResolved', generateAbuseReport({
|
||||
send('abuseReportResolved', await this.generateAbuseReport({
|
||||
targetUserId: dummyUser1.id,
|
||||
targetUser: dummyUser1,
|
||||
reporterId: dummyUser2.id,
|
||||
|
|
@ -478,7 +333,7 @@ export class WebhookTestService {
|
|||
break;
|
||||
}
|
||||
case 'userCreated': {
|
||||
send('userCreated', toPackedUserLite(dummyUser1));
|
||||
send('userCreated', await this.toPackedUserLite(dummyUser1));
|
||||
break;
|
||||
}
|
||||
case 'inactiveModeratorsWarning': {
|
||||
|
|
@ -504,4 +359,174 @@ export class WebhookTestService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async generateAbuseReport(override?: Partial<MiAbuseUserReport>): Promise<AbuseReportPayload> {
|
||||
const result: MiAbuseUserReport = {
|
||||
id: 'dummy-abuse-report1',
|
||||
targetUserId: 'dummy-target-user',
|
||||
targetUser: null,
|
||||
targetUserInstance: null,
|
||||
reporterId: 'dummy-reporter-user',
|
||||
reporter: null,
|
||||
reporterInstance: null,
|
||||
assigneeId: null,
|
||||
assignee: null,
|
||||
resolved: false,
|
||||
forwarded: false,
|
||||
comment: 'This is a dummy report for testing purposes.',
|
||||
targetUserHost: null,
|
||||
reporterHost: null,
|
||||
resolvedAs: null,
|
||||
moderationNote: 'foo',
|
||||
...override,
|
||||
};
|
||||
|
||||
return {
|
||||
...result,
|
||||
targetUser: result.targetUser ? await this.toPackedUserLite(result.targetUser) : null,
|
||||
reporter: result.reporter ? await this.toPackedUserLite(result.reporter) : null,
|
||||
assignee: result.assignee ? await this.toPackedUserLite(result.assignee) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise<Packed<'Note'>> {
|
||||
return {
|
||||
id: note.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
text: note.text,
|
||||
cw: note.cw,
|
||||
userId: note.userId,
|
||||
user: await this.toPackedUserLite(note.user ?? generateDummyUser()),
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
isHidden: false,
|
||||
isMutingThread: false,
|
||||
isMutingNote: false,
|
||||
isFavorited: false,
|
||||
isRenoted: false,
|
||||
visibility: note.visibility,
|
||||
mentions: note.mentions,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
fileIds: note.fileIds,
|
||||
files: [],
|
||||
tags: note.tags,
|
||||
poll: null,
|
||||
emojis: await this.customEmojiService.populateEmojis(note.emojis, note.userHost),
|
||||
channelId: note.channelId,
|
||||
channel: note.channel,
|
||||
localOnly: note.localOnly,
|
||||
reactionAcceptance: note.reactionAcceptance,
|
||||
reactionEmojis: {},
|
||||
reactions: {},
|
||||
reactionCount: 0,
|
||||
renoteCount: note.renoteCount,
|
||||
repliesCount: note.repliesCount,
|
||||
uri: note.uri ?? undefined,
|
||||
url: note.url ?? undefined,
|
||||
reactionAndUserPairCache: note.reactionAndUserPairCache,
|
||||
...(detail ? {
|
||||
clippedCount: note.clippedCount,
|
||||
reply: note.reply ? await this.toPackedNote(note.reply, false) : null,
|
||||
renote: note.renote ? await this.toPackedNote(note.renote, true) : null,
|
||||
myReaction: null,
|
||||
} : {}),
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Promise<Packed<'UserLite'>> {
|
||||
return {
|
||||
...user,
|
||||
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||
updatedAt: null,
|
||||
lastFetchedAt: null,
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
host: user.host,
|
||||
description: 'dummy user',
|
||||
avatarUrl: user.avatarId == null ? null : user.avatarUrl,
|
||||
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
|
||||
avatarDecorations: user.avatarDecorations.map(it => ({
|
||||
id: it.id,
|
||||
angle: it.angle,
|
||||
flipH: it.flipH,
|
||||
url: 'https://example.com/dummy-image001.png',
|
||||
offsetX: it.offsetX,
|
||||
offsetY: it.offsetY,
|
||||
})),
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host),
|
||||
onlineStatus: 'active',
|
||||
badgeRoles: [],
|
||||
isAdmin: false,
|
||||
isModerator: false,
|
||||
isSystem: false,
|
||||
instance: undefined,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Promise<Packed<'UserDetailedNotMe'>> {
|
||||
return {
|
||||
...await this.toPackedUserLite(user),
|
||||
url: null,
|
||||
uri: null,
|
||||
movedTo: null,
|
||||
alsoKnownAs: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: user.updatedAt?.toISOString() ?? null,
|
||||
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
|
||||
bannerUrl: user.bannerId == null ? null : user.bannerUrl,
|
||||
bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
|
||||
backgroundUrl: user.backgroundId == null ? null : user.backgroundUrl,
|
||||
backgroundBlurhash: user.backgroundId == null ? null : user.backgroundBlurhash,
|
||||
listenbrainz: null,
|
||||
isLocked: user.isLocked,
|
||||
isSilenced: false,
|
||||
isSuspended: user.isSuspended,
|
||||
description: null,
|
||||
location: null,
|
||||
birthday: null,
|
||||
lang: null,
|
||||
fields: [],
|
||||
verifiedLinks: [],
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
notesCount: user.notesCount,
|
||||
pinnedNoteIds: [],
|
||||
pinnedNotes: [],
|
||||
pinnedPageId: null,
|
||||
pinnedPage: null,
|
||||
publicReactions: true,
|
||||
followersVisibility: 'public',
|
||||
followingVisibility: 'public',
|
||||
chatScope: 'mutual',
|
||||
canChat: true,
|
||||
twoFactorEnabled: false,
|
||||
usePasswordLessLogin: false,
|
||||
securityKeys: false,
|
||||
roles: [],
|
||||
memo: null,
|
||||
moderationNote: undefined,
|
||||
isFollowing: false,
|
||||
isFollowed: false,
|
||||
hasPendingFollowRequestFromYou: false,
|
||||
hasPendingFollowRequestToYou: false,
|
||||
isBlocking: false,
|
||||
isBlocked: false,
|
||||
isMuted: false,
|
||||
isRenoteMuted: false,
|
||||
notify: 'none',
|
||||
withReplies: true,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||
import type { MiNote } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
|
||||
import { getApId } from './type.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import type { IObject } from './type.js';
|
||||
import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
|
||||
|
||||
export type UriParseResult = {
|
||||
/** wether the URI was generated by us */
|
||||
|
|
@ -37,9 +37,6 @@ export type UriParseResult = {
|
|||
|
||||
@Injectable()
|
||||
export class ApDbResolverService implements OnApplicationShutdown {
|
||||
private publicKeyCache: MemoryKVCache<MiUserPublickey | null>;
|
||||
private publicKeyByUserIdCache: MemoryKVCache<MiUserPublickey | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
|
@ -58,8 +55,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
private apLoggerService: ApLoggerService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
// Caches moved to ApPersonService to avoid circular dependency
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -131,15 +127,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
user: MiRemoteUser;
|
||||
key: MiUserPublickey;
|
||||
} | null> {
|
||||
const key = await this.publicKeyCache.fetch(keyId, async () => {
|
||||
const key = await this.userPublickeysRepository.findOneBy({
|
||||
keyId,
|
||||
});
|
||||
|
||||
if (key == null) return null;
|
||||
|
||||
return key;
|
||||
}, key => key != null);
|
||||
const key = await this.apPersonService.findPublicKeyByKeyId(keyId);
|
||||
|
||||
if (key == null) return null;
|
||||
|
||||
|
|
@ -164,11 +152,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
const user = await this.apPersonService.resolvePerson(uri) as MiRemoteUser;
|
||||
if (user.isDeleted) return null;
|
||||
|
||||
const key = await this.publicKeyByUserIdCache.fetch(
|
||||
user.id,
|
||||
() => this.userPublickeysRepository.findOneBy({ userId: user.id }),
|
||||
v => v != null,
|
||||
);
|
||||
const key = await this.apPersonService.findPublicKeyByUserId(user.id);
|
||||
|
||||
return {
|
||||
user,
|
||||
|
|
@ -181,24 +165,27 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
*/
|
||||
@bindThis
|
||||
public async refetchPublicKeyForApId(user: MiRemoteUser): Promise<MiUserPublickey | null> {
|
||||
this.apLoggerService.logger.debug('Re-fetching public key for user', { userId: user.id, uri: user.uri });
|
||||
this.apLoggerService.logger.debug(`Updating public key for user ${user.id} (${user.uri})`);
|
||||
|
||||
const oldKey = await this.apPersonService.findPublicKeyByUserId(user.id);
|
||||
await this.apPersonService.updatePerson(user.uri);
|
||||
const newKey = await this.apPersonService.findPublicKeyByUserId(user.id);
|
||||
|
||||
const key = await this.userPublickeysRepository.findOneBy({ userId: user.id });
|
||||
this.publicKeyByUserIdCache.set(user.id, key);
|
||||
|
||||
if (key) {
|
||||
this.apLoggerService.logger.info('Re-fetched public key for user', { userId: user.id, uri: user.uri });
|
||||
if (newKey) {
|
||||
if (oldKey && newKey.keyPem === oldKey.keyPem) {
|
||||
this.apLoggerService.logger.debug(`Public key is up-to-date for user ${user.id} (${user.uri})`);
|
||||
} else {
|
||||
this.apLoggerService.logger.info(`Updated public key for user ${user.id} (${user.uri})`);
|
||||
}
|
||||
} else {
|
||||
this.apLoggerService.logger.warn('Failed to re-fetch key for user', { userId: user.id, uri: user.uri });
|
||||
this.apLoggerService.logger.warn(`Failed to update public key for user ${user.id} (${user.uri})`);
|
||||
}
|
||||
return key;
|
||||
|
||||
return newKey ?? oldKey;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.publicKeyCache.dispose();
|
||||
this.publicKeyByUserIdCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
|
|
@ -14,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import { ThinUser } from '@/queue/types.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
interface IRecipe {
|
||||
type: string;
|
||||
|
|
@ -41,23 +41,21 @@ class DeliverManager {
|
|||
|
||||
/**
|
||||
* Constructor
|
||||
* @param userEntityService
|
||||
* @param followingsRepository
|
||||
* @param queueService
|
||||
* @param cacheService
|
||||
* @param actor Actor
|
||||
* @param activity Activity to deliver
|
||||
*/
|
||||
constructor(
|
||||
private userEntityService: UserEntityService,
|
||||
private followingsRepository: FollowingsRepository,
|
||||
private queueService: QueueService,
|
||||
private readonly cacheService: CacheService,
|
||||
|
||||
actor: { id: MiUser['id']; host: null; },
|
||||
activity: IActivity | null,
|
||||
) {
|
||||
// 型で弾いてはいるが一応ローカルユーザーかチェック
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (actor.host != null) throw new Error('actor.host must be null');
|
||||
if (actor.host != null) throw new Error(`deliver failed for ${actor.id}: host is not null`);
|
||||
|
||||
// パフォーマンス向上のためキューに突っ込むのはidのみに絞る
|
||||
this.actor = {
|
||||
|
|
@ -114,23 +112,23 @@ class DeliverManager {
|
|||
// Process follower recipes first to avoid duplication when processing direct recipes later.
|
||||
if (this.recipes.some(r => isFollowers(r))) {
|
||||
// followers deliver
|
||||
// TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
|
||||
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう?
|
||||
const followers = await this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: this.actor.id,
|
||||
followerHost: Not(IsNull()),
|
||||
},
|
||||
select: {
|
||||
followerSharedInbox: true,
|
||||
followerInbox: true,
|
||||
},
|
||||
});
|
||||
const followers = await this.cacheService.userFollowersCache
|
||||
.fetch(this.actor.id)
|
||||
.then(f => Array
|
||||
.from(f.values())
|
||||
.filter(f => f.followerHost != null)
|
||||
.map(f => ({
|
||||
followerInbox: f.followerInbox,
|
||||
followerSharedInbox: f.followerSharedInbox,
|
||||
})));
|
||||
|
||||
for (const following of followers) {
|
||||
const inbox = following.followerSharedInbox ?? following.followerInbox;
|
||||
if (inbox === null) throw new UnrecoverableError(`inbox is null: following ${following.id}`);
|
||||
inboxes.set(inbox, following.followerSharedInbox != null);
|
||||
if (following.followerSharedInbox) {
|
||||
inboxes.set(following.followerSharedInbox, true);
|
||||
} else if (following.followerInbox) {
|
||||
inboxes.set(following.followerInbox, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,11 +150,8 @@ class DeliverManager {
|
|||
@Injectable()
|
||||
export class ApDeliverManagerService {
|
||||
constructor(
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -168,9 +163,8 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.cacheService,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
|
@ -187,9 +181,8 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.cacheService,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
|
@ -197,12 +190,29 @@ export class ApDeliverManagerService {
|
|||
await manager.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver activity to users
|
||||
* @param actor
|
||||
* @param activity Activity
|
||||
* @param targets Target users
|
||||
*/
|
||||
@bindThis
|
||||
public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.queueService,
|
||||
this.cacheService,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
for (const to of targets) manager.addDirectRecipe(to);
|
||||
await manager.execute();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
|
||||
return new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.cacheService,
|
||||
|
||||
actor,
|
||||
activity,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,13 @@ import { AbuseReportService } from '@/core/AbuseReportService.js';
|
|||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { fromTuple } from '@/misc/from-tuple.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import FederationChart from '@/core/chart/charts/federation.js';
|
||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
|
|
@ -41,7 +47,7 @@ import { ApAudienceService } from './ApAudienceService.js';
|
|||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import { ApQuestionService } from './models/ApQuestionService.js';
|
||||
import type { Resolver } from './ApResolverService.js';
|
||||
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IDislike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
|
||||
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IDislike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost, IActivity } from './type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApInboxService {
|
||||
|
|
@ -88,7 +94,12 @@ export class ApInboxService {
|
|||
private apQuestionService: ApQuestionService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private readonly federatedInstanceService: FederatedInstanceService,
|
||||
private readonly fetchInstanceMetadataService: FetchInstanceMetadataService,
|
||||
private readonly instanceChart: InstanceChart,
|
||||
private readonly federationChart: FederationChart,
|
||||
private readonly updateInstanceQueue: UpdateInstanceQueue,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
|
@ -98,25 +109,29 @@ export class ApInboxService {
|
|||
let result = undefined as string | void;
|
||||
if (isCollectionOrOrderedCollection(activity)) {
|
||||
const results = [] as [string, string | void][];
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
|
||||
if (items.length >= resolver.getRecursionLimit()) {
|
||||
throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const act = await resolver.resolve(item);
|
||||
if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
|
||||
this.logger.debug('skipping activity: activity id is null or mismatching');
|
||||
continue;
|
||||
const items = await resolver.resolveCollectionItems(activity);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const act = items[i];
|
||||
if (act.id != null) {
|
||||
if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
|
||||
this.logger.warn('skipping activity: activity id mismatch');
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Activity ID should only be string or undefined.
|
||||
act.id = undefined;
|
||||
}
|
||||
|
||||
const id = getNullableApId(act) ?? `${getNullableApId(activity)}#${i}`;
|
||||
|
||||
try {
|
||||
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
|
||||
const result = await this.performOneActivity(actor, act, resolver);
|
||||
results.push([id, result]);
|
||||
} catch (err) {
|
||||
if (err instanceof Error || typeof err === 'string') {
|
||||
this.logger.error(err);
|
||||
this.logger.error(`Unhandled error in activity ${id}:`, err);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -136,7 +151,8 @@ export class ApInboxService {
|
|||
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||
setImmediate(() => {
|
||||
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
||||
this.apPersonService.updatePerson(actor.uri);
|
||||
this.apPersonService.updatePerson(actor.uri)
|
||||
.catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -209,6 +225,10 @@ export class ApInboxService {
|
|||
const note = await this.apNoteService.resolveNote(object, { resolver });
|
||||
if (!note) return `skip: target note not found ${targetUri}`;
|
||||
|
||||
if (note.userHost == null && note.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot react to local-only note');
|
||||
}
|
||||
|
||||
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
|
||||
|
||||
try {
|
||||
|
|
@ -238,7 +258,7 @@ export class ApInboxService {
|
|||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(err => {
|
||||
this.logger.error(`Resolution failed: ${err}`);
|
||||
this.logger.error(`Resolution failed: ${renderInlineError(err)}`);
|
||||
throw err;
|
||||
});
|
||||
|
||||
|
|
@ -310,18 +330,19 @@ export class ApInboxService {
|
|||
const targetUri = getApId(activityObject);
|
||||
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
|
||||
|
||||
const target = await resolver.resolve(activityObject).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
const target = await resolver.secureResolve(activityObject, uri).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (isPost(target)) return await this.announceNote(actor, activity, target);
|
||||
if (isActivity(target)) return await this.announceActivity(activity, target, resolver);
|
||||
|
||||
return `skip: unknown object type ${getApType(target)}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string | void> {
|
||||
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost & IObjectWithId, resolver?: Resolver): Promise<string | void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
if (actor.isSuspended) {
|
||||
|
|
@ -341,23 +362,17 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
// Announce対象をresolve
|
||||
let renote;
|
||||
try {
|
||||
renote = await this.apNoteService.resolveNote(target, { resolver });
|
||||
if (renote == null) return 'announce target is null';
|
||||
} catch (err) {
|
||||
// 対象が4xxならスキップ
|
||||
if (err instanceof StatusError) {
|
||||
if (!err.isRetryable) {
|
||||
return `skip: ignored announce target ${target.id} - ${err.statusCode}`;
|
||||
}
|
||||
return `Error in announce target ${target.id} - ${err.statusCode}`;
|
||||
}
|
||||
throw err;
|
||||
// The target ID is verified by secureResolve, so we know it shares host authority with the actor who sent it.
|
||||
// This means we can pass that ID to resolveNote and avoid an extra fetch, which will fail if the note is private.
|
||||
const renote = await this.apNoteService.resolveNote(target, { resolver, sentFrom: getApId(target) });
|
||||
if (renote == null) return 'announce target is null';
|
||||
|
||||
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id, { me: actor })) {
|
||||
return 'skip: invalid actor for this activity';
|
||||
}
|
||||
|
||||
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) {
|
||||
return 'skip: invalid actor for this activity';
|
||||
if (renote.userHost == null && renote.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot renote a local-only note');
|
||||
}
|
||||
|
||||
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
||||
|
|
@ -383,6 +398,65 @@ export class ApInboxService {
|
|||
}
|
||||
}
|
||||
|
||||
private async announceActivity(announce: IAnnounce, activity: IActivity & IObjectWithId, resolver: Resolver): Promise<string | void> {
|
||||
// Since this is a new activity, we need to get a new actor.
|
||||
const actorId = getApId(activity.actor);
|
||||
const actor = await this.apPersonService.resolvePerson(actorId, resolver);
|
||||
|
||||
// Ignore announce of our own activities
|
||||
// 1. No URI/host on an MiUser == local user
|
||||
// 2. Local URI on activity == local activity
|
||||
if (!actor.uri || !actor.host || this.utilityService.isUriLocal(activity.id)) {
|
||||
throw new Bull.UnrecoverableError(`Cannot announce a local activity: ${activity.id} (from ${announce.id})`);
|
||||
}
|
||||
|
||||
// Make sure that actor matches activity host.
|
||||
// Activity host is already verified by resolver when fetching the activity, so that is the source of truth.
|
||||
const actorHost = this.utilityService.punyHostPSLDomain(actor.uri);
|
||||
const activityHost = this.utilityService.punyHostPSLDomain(activity.id);
|
||||
if (actorHost !== activityHost) {
|
||||
throw new Bull.UnrecoverableError(`Actor host ${actorHost} does not activity host ${activityHost} in activity ${activity.id} (from ${announce.id})`);
|
||||
}
|
||||
|
||||
// Update stats (adapted from InboxProcessorService)
|
||||
this.federationChart.inbox(actor.host).then();
|
||||
process.nextTick(async () => {
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(actor.host)
|
||||
: this.federatedInstanceService.fetch(actor.host));
|
||||
|
||||
if (i == null) return;
|
||||
|
||||
this.updateInstanceQueue.enqueue(i.id, {
|
||||
latestRequestReceivedAt: new Date(),
|
||||
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
|
||||
});
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestReceived(i.host).then();
|
||||
}
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i).then();
|
||||
});
|
||||
|
||||
// Process it!
|
||||
return await this.performOneActivity(actor, activity, resolver)
|
||||
.finally(() => {
|
||||
// Update user (adapted from performActivity)
|
||||
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||
setImmediate(() => {
|
||||
// Don't re-use the resolver, or it may throw recursion errors.
|
||||
// Instead, create a new resolver with an appropriately-reduced recursion limit.
|
||||
const subResolver = this.apResolverService.createResolver({
|
||||
recursionLimit: resolver.getRecursionLimit() - resolver.getHistory().length,
|
||||
});
|
||||
this.apPersonService.updatePerson(actor.uri, subResolver)
|
||||
.catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async block(actor: MiRemoteUser, activity: IBlock): Promise<string> {
|
||||
// ※ activity.objectにブロック対象があり、それは存在するローカルユーザーのはず
|
||||
|
|
@ -402,7 +476,7 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver): Promise<string | void> {
|
||||
private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver, silent = false): Promise<string | void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
this.logger.info(`Create: ${uri}`);
|
||||
|
|
@ -432,12 +506,12 @@ export class ApInboxService {
|
|||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activityObject).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (isPost(object)) {
|
||||
await this.createNote(resolver, actor, object, false);
|
||||
await this.createNote(resolver, actor, object, silent);
|
||||
} else {
|
||||
return `skip: Unsupported type for Create: ${getApType(object)} ${getNullableApId(object)}`;
|
||||
}
|
||||
|
|
@ -469,12 +543,6 @@ export class ApInboxService {
|
|||
|
||||
await this.apNoteService.createNote(note, actor, resolver, silent);
|
||||
return 'ok';
|
||||
} catch (err) {
|
||||
if (err instanceof StatusError && !err.isRetryable) {
|
||||
return `skip: ${err.statusCode}`;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
|
|
@ -530,19 +598,12 @@ export class ApInboxService {
|
|||
return `skip: delete actor ${actor.uri} !== ${uri}`;
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: actor.id });
|
||||
if (user == null) {
|
||||
return 'skip: actor not found';
|
||||
} else if (user.isDeleted) {
|
||||
return 'skip: already deleted';
|
||||
if (!(await this.usersRepository.update({ id: actor.id, isDeleted: false }, { isDeleted: true })).affected) {
|
||||
return 'skip: already deleted or actor not found';
|
||||
}
|
||||
|
||||
const job = await this.queueService.createDeleteAccountJob(actor);
|
||||
|
||||
await this.usersRepository.update(actor.id, {
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id });
|
||||
|
||||
return `ok: queued ${job.name} ${job.id}`;
|
||||
|
|
@ -614,7 +675,7 @@ export class ApInboxService {
|
|||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
|
|
@ -686,7 +747,7 @@ export class ApInboxService {
|
|||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
|
|
@ -707,12 +768,7 @@ export class ApInboxService {
|
|||
return 'skip: follower not found';
|
||||
}
|
||||
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: actor.id,
|
||||
},
|
||||
});
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(actor.id));
|
||||
|
||||
if (isFollowing) {
|
||||
await this.userFollowingService.unfollow(follower, actor);
|
||||
|
|
@ -771,12 +827,7 @@ export class ApInboxService {
|
|||
},
|
||||
});
|
||||
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: actor.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
});
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(actor.id).then(f => f.has(followee.id));
|
||||
|
||||
if (requestExist) {
|
||||
await this.userFollowingService.cancelFollowRequest(followee, actor);
|
||||
|
|
@ -818,7 +869,7 @@ export class ApInboxService {
|
|||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
|
|
@ -828,7 +879,7 @@ export class ApInboxService {
|
|||
} else if (getApType(object) === 'Question') {
|
||||
// If we get an Update(Question) for a note that doesn't exist, then create it instead
|
||||
if (!await this.apNoteService.hasNote(object)) {
|
||||
return await this.create(actor, activity, resolver);
|
||||
return await this.create(actor, activity, resolver, true);
|
||||
}
|
||||
|
||||
await this.apQuestionService.updateQuestion(object, actor, resolver);
|
||||
|
|
@ -836,7 +887,7 @@ export class ApInboxService {
|
|||
} else if (isPost(object)) {
|
||||
// If we get an Update(Note) for a note that doesn't exist, then create it instead
|
||||
if (!await this.apNoteService.hasNote(object)) {
|
||||
return await this.create(actor, activity, resolver);
|
||||
return await this.create(actor, activity, resolver, true);
|
||||
}
|
||||
|
||||
await this.apNoteService.updateNote(object, actor, resolver);
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import { MfmService } from '@/core/MfmService.js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { MfmService, Appender } from '@/core/MfmService.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { extractApHashtagObjects } from './models/tag.js';
|
||||
|
|
@ -25,17 +25,17 @@ export class ApMfmService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, apAppend?: string) {
|
||||
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, additionalAppender: Appender[] = []) {
|
||||
let noMisskeyContent = false;
|
||||
const srcMfm = (note.text ?? '') + (apAppend ?? '');
|
||||
const srcMfm = (note.text ?? '');
|
||||
|
||||
const parsed = mfm.parse(srcMfm);
|
||||
|
||||
if (!apAppend && parsed?.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
|
||||
if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
|
||||
noMisskeyContent = true;
|
||||
}
|
||||
|
||||
const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : []);
|
||||
const content = this.mfmService.toHtml(parsed, note.mentionedRemoteUsers ? JSON.parse(note.mentionedRemoteUsers) : [], additionalAppender);
|
||||
|
||||
return {
|
||||
content,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
import { createPublicKey, randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { Element, Text } from 'domhandler';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
|
|
@ -20,7 +21,7 @@ import type { MiEmoji } from '@/models/Emoji.js';
|
|||
import type { MiPoll } from '@/models/Poll.js';
|
||||
import type { MiPollVote } from '@/models/PollVote.js';
|
||||
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
||||
import { MfmService } from '@/core/MfmService.js';
|
||||
import { MfmService, type Appender } from '@/core/MfmService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { MiUserKeypair } from '@/models/UserKeypair.js';
|
||||
|
|
@ -29,10 +30,14 @@ import { bindThis } from '@/decorators.js';
|
|||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { appendContentWarning } from '@/misc/append-content-warning.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { JsonLdService } from './JsonLdService.js';
|
||||
import { ApMfmService } from './ApMfmService.js';
|
||||
import { CONTEXT } from './misc/contexts.js';
|
||||
import { getApId } from './type.js';
|
||||
import { getApId, ILink, IOrderedCollection, IOrderedCollectionPage } from './type.js';
|
||||
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -70,6 +75,9 @@ export class ApRendererService {
|
|||
private apMfmService: ApMfmService,
|
||||
private mfmService: MfmService,
|
||||
private idService: IdService,
|
||||
private readonly queryService: QueryService,
|
||||
private utilityService: UtilityService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -227,7 +235,7 @@ export class ApRendererService {
|
|||
*/
|
||||
@bindThis
|
||||
public async renderFollowUser(id: MiUser['id']): Promise<string> {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: id }) as MiPartialLocalUser | MiPartialRemoteUser;
|
||||
const user = await this.cacheService.findUserById(id) as MiPartialLocalUser | MiPartialRemoteUser;
|
||||
return this.userEntityService.getUserUri(user);
|
||||
}
|
||||
|
||||
|
|
@ -264,6 +272,49 @@ export class ApRendererService {
|
|||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderIdenticon(user: MiLocalUser): IApImage {
|
||||
return {
|
||||
type: 'Image',
|
||||
url: this.userEntityService.getIdenticonUrl(user),
|
||||
sensitive: false,
|
||||
name: null,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderSystemAvatar(user: MiLocalUser): IApImage {
|
||||
if (this.meta.iconUrl == null) return this.renderIdenticon(user);
|
||||
return {
|
||||
type: 'Image',
|
||||
url: this.meta.iconUrl,
|
||||
sensitive: false,
|
||||
name: null,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderSystemBanner(): IApImage | null {
|
||||
if (this.meta.bannerUrl == null) return null;
|
||||
return {
|
||||
type: 'Image',
|
||||
url: this.meta.bannerUrl,
|
||||
sensitive: false,
|
||||
name: null,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderSystemBackground(): IApImage | null {
|
||||
if (this.meta.backgroundImageUrl == null) return null;
|
||||
return {
|
||||
type: 'Image',
|
||||
url: this.meta.backgroundImageUrl,
|
||||
sensitive: false,
|
||||
name: null,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey {
|
||||
return {
|
||||
|
|
@ -354,7 +405,7 @@ export class ApRendererService {
|
|||
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
|
||||
|
||||
if (inReplyToNote != null) {
|
||||
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
|
||||
const inReplyToUser = await this.cacheService.findUserById(inReplyToNote.userId);
|
||||
|
||||
if (inReplyToUser) {
|
||||
if (inReplyToNote.uri) {
|
||||
|
|
@ -372,9 +423,9 @@ export class ApRendererService {
|
|||
inReplyTo = null;
|
||||
}
|
||||
|
||||
let quote;
|
||||
let quote: string | undefined = undefined;
|
||||
|
||||
if (note.renoteId) {
|
||||
if (isRenote(note) && isQuote(note)) {
|
||||
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
|
||||
|
||||
if (renote) {
|
||||
|
|
@ -388,13 +439,16 @@ export class ApRendererService {
|
|||
|
||||
let to: string[] = [];
|
||||
let cc: string[] = [];
|
||||
let isPublic = false;
|
||||
|
||||
if (note.visibility === 'public') {
|
||||
to = ['https://www.w3.org/ns/activitystreams#Public'];
|
||||
cc = [`${attributedTo}/followers`].concat(mentions);
|
||||
isPublic = true;
|
||||
} else if (note.visibility === 'home') {
|
||||
to = [`${attributedTo}/followers`];
|
||||
cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
|
||||
isPublic = true;
|
||||
} else if (note.visibility === 'followers') {
|
||||
to = [`${attributedTo}/followers`];
|
||||
cc = mentions;
|
||||
|
|
@ -418,10 +472,26 @@ export class ApRendererService {
|
|||
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
}
|
||||
|
||||
let apAppend = '';
|
||||
const apAppend: Appender[] = [];
|
||||
|
||||
if (quote) {
|
||||
apAppend += `\n\nRE: ${quote}`;
|
||||
// Append quote link as `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
|
||||
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
|
||||
// For compatibility, the span part should be kept as possible.
|
||||
apAppend.push((doc, body) => {
|
||||
body.childNodes.push(new Element('br', {}));
|
||||
body.childNodes.push(new Element('br', {}));
|
||||
const span = new Element('span', {
|
||||
class: 'quote-inline',
|
||||
});
|
||||
span.childNodes.push(new Text('RE: '));
|
||||
const link = new Element('a', {
|
||||
href: quote,
|
||||
});
|
||||
link.childNodes.push(new Text(quote));
|
||||
span.childNodes.push(link);
|
||||
body.childNodes.push(span);
|
||||
});
|
||||
}
|
||||
|
||||
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
|
||||
|
|
@ -436,12 +506,22 @@ export class ApRendererService {
|
|||
const emojis = await this.getEmojis(note.emojis);
|
||||
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
||||
|
||||
const tag = [
|
||||
const tag: IObject[] = [
|
||||
...hashtagTags,
|
||||
...mentionTags,
|
||||
...apemojis,
|
||||
];
|
||||
|
||||
// https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md
|
||||
if (quote) {
|
||||
tag.push({
|
||||
type: 'Link',
|
||||
mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
rel: 'https://misskey-hub.net/ns#_misskey_quote',
|
||||
href: quote,
|
||||
} satisfies ILink);
|
||||
}
|
||||
|
||||
const asPoll = poll ? {
|
||||
type: 'Question',
|
||||
[poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
|
||||
|
|
@ -455,12 +535,17 @@ export class ApRendererService {
|
|||
})),
|
||||
} as const : {};
|
||||
|
||||
// Render the outer replies collection wrapper, which contains the count but not the actual URLs.
|
||||
// This saves one hop (request) when de-referencing the replies.
|
||||
const replies = isPublic ? await this.renderRepliesCollection(note.id) : undefined;
|
||||
|
||||
return {
|
||||
id: `${this.config.url}/notes/${note.id}`,
|
||||
type: 'Note',
|
||||
attributedTo,
|
||||
summary: summary ?? undefined,
|
||||
content: content ?? undefined,
|
||||
updated: note.updatedAt?.toISOString() ?? undefined,
|
||||
_misskey_content: text,
|
||||
source: {
|
||||
content: text,
|
||||
|
|
@ -469,10 +554,14 @@ export class ApRendererService {
|
|||
_misskey_quote: quote,
|
||||
quoteUrl: quote,
|
||||
quoteUri: quote,
|
||||
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
||||
// Disabled since Mastodon hides the fallback link when this is set
|
||||
// quote: quote,
|
||||
published: this.idService.parse(note.id).date.toISOString(),
|
||||
to,
|
||||
cc,
|
||||
inReplyTo,
|
||||
replies,
|
||||
attachment: files.map(x => this.renderDocument(x)),
|
||||
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
||||
tag,
|
||||
|
|
@ -496,9 +585,7 @@ export class ApRendererService {
|
|||
const attachment = profile.fields.map(field => ({
|
||||
type: 'PropertyValue',
|
||||
name: field.name,
|
||||
value: (field.value.startsWith('http://') || field.value.startsWith('https://'))
|
||||
? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>`
|
||||
: field.value,
|
||||
value: this.mfmService.toHtml(mfm.parse(field.value), [], [], true),
|
||||
}));
|
||||
|
||||
const emojis = await this.getEmojis(user.emojis);
|
||||
|
|
@ -532,9 +619,9 @@ export class ApRendererService {
|
|||
_misskey_requireSigninToViewContents: user.requireSigninToViewContents,
|
||||
_misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore,
|
||||
_misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore,
|
||||
icon: avatar ? this.renderImage(avatar) : null,
|
||||
image: banner ? this.renderImage(banner) : null,
|
||||
backgroundUrl: background ? this.renderImage(background) : null,
|
||||
icon: avatar ? this.renderImage(avatar) : isSystem ? this.renderSystemAvatar(user) : this.renderIdenticon(user),
|
||||
image: banner ? this.renderImage(banner) : isSystem ? this.renderSystemBanner() : null,
|
||||
backgroundUrl: background ? this.renderImage(background) : isSystem ? this.renderSystemBackground() : null,
|
||||
tag,
|
||||
manuallyApprovesFollowers: user.isLocked,
|
||||
discoverable: user.isExplorable,
|
||||
|
|
@ -546,6 +633,7 @@ export class ApRendererService {
|
|||
enableRss: user.enableRss,
|
||||
speakAsCat: user.speakAsCat,
|
||||
attachment: attachment.length ? attachment : undefined,
|
||||
attributionDomains: user.attributionDomains,
|
||||
};
|
||||
|
||||
if (user.movedToUri) {
|
||||
|
|
@ -571,6 +659,38 @@ export class ApRendererService {
|
|||
return person;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async renderPersonRedacted(user: MiLocalUser) {
|
||||
const id = this.userEntityService.genLocalUserUri(user.id);
|
||||
const isSystem = user.username.includes('.');
|
||||
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
return {
|
||||
// Basic federation metadata
|
||||
type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person',
|
||||
id,
|
||||
inbox: `${id}/inbox`,
|
||||
outbox: `${id}/outbox`,
|
||||
sharedInbox: `${this.config.url}/inbox`,
|
||||
endpoints: { sharedInbox: `${this.config.url}/inbox` },
|
||||
url: `${this.config.url}/@${user.username}`,
|
||||
preferredUsername: user.username,
|
||||
publicKey: this.renderKey(user, keypair, '#main-key'),
|
||||
|
||||
// Privacy settings
|
||||
_misskey_requireSigninToViewContents: user.requireSigninToViewContents,
|
||||
_misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore,
|
||||
_misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore,
|
||||
manuallyApprovesFollowers: user.isLocked,
|
||||
discoverable: user.isExplorable,
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
noindex: user.noindex,
|
||||
indexable: !user.noindex,
|
||||
enableRss: user.enableRss,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion {
|
||||
return {
|
||||
|
|
@ -618,7 +738,7 @@ export class ApRendererService {
|
|||
|
||||
@bindThis
|
||||
public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo {
|
||||
const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined;
|
||||
const id = typeof object !== 'string' && typeof object.id === 'string' && this.utilityService.isUriLocal(object.id) ? `${object.id}/undo` : undefined;
|
||||
|
||||
return {
|
||||
type: 'Undo',
|
||||
|
|
@ -630,9 +750,11 @@ export class ApRendererService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public renderUpdate(object: string | IObject, user: { id: MiUser['id'] }): IUpdate {
|
||||
public renderUpdate(object: IObject, user: { id: MiUser['id'] }): IUpdate {
|
||||
// Deterministic activity IDs to allow de-duplication by remote instances
|
||||
const updatedAt = object.updated ? new Date(object.updated).getTime() : Date.now();
|
||||
return {
|
||||
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
|
||||
id: `${this.config.url}/users/${user.id}#updates/${updatedAt}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
type: 'Update',
|
||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
|
|
@ -641,148 +763,6 @@ export class ApRendererService {
|
|||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> {
|
||||
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
|
||||
if (ids.length === 0) return [];
|
||||
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
|
||||
return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null);
|
||||
};
|
||||
|
||||
let inReplyTo;
|
||||
let inReplyToNote: MiNote | null;
|
||||
|
||||
if (note.replyId) {
|
||||
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
|
||||
|
||||
if (inReplyToNote != null) {
|
||||
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
|
||||
|
||||
if (inReplyToUser) {
|
||||
if (inReplyToNote.uri) {
|
||||
inReplyTo = inReplyToNote.uri;
|
||||
} else {
|
||||
if (dive) {
|
||||
inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false);
|
||||
} else {
|
||||
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inReplyTo = null;
|
||||
}
|
||||
|
||||
let quote;
|
||||
|
||||
if (note.renoteId) {
|
||||
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
|
||||
|
||||
if (renote) {
|
||||
quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
|
||||
|
||||
const mentions = note.mentionedRemoteUsers ? (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 && note.mentions.length > 0 ? await this.usersRepository.findBy({
|
||||
id: In(note.mentions),
|
||||
}) : [];
|
||||
|
||||
const hashtagTags = note.tags.map(tag => this.renderHashtag(tag));
|
||||
const mentionTags = mentionedUsers.map(u => this.renderMention(u as MiLocalUser | MiRemoteUser));
|
||||
|
||||
const files = await getPromisedFiles(note.fileIds);
|
||||
|
||||
const text = note.text ?? '';
|
||||
let poll: MiPoll | null = null;
|
||||
|
||||
if (note.hasPoll) {
|
||||
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
}
|
||||
|
||||
let apAppend = '';
|
||||
|
||||
if (quote) {
|
||||
apAppend += `\n\nRE: ${quote}`;
|
||||
}
|
||||
|
||||
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
|
||||
|
||||
// Apply mandatory CW, if applicable
|
||||
if (author.mandatoryCW) {
|
||||
summary = appendContentWarning(summary, author.mandatoryCW);
|
||||
}
|
||||
|
||||
const { content } = this.apMfmService.getNoteHtml(note, apAppend);
|
||||
|
||||
const emojis = await this.getEmojis(note.emojis);
|
||||
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
||||
|
||||
const tag = [
|
||||
...hashtagTags,
|
||||
...mentionTags,
|
||||
...apemojis,
|
||||
];
|
||||
|
||||
const asPoll = poll ? {
|
||||
type: 'Question',
|
||||
[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],
|
||||
},
|
||||
})),
|
||||
} as const : {};
|
||||
|
||||
return {
|
||||
id: `${this.config.url}/notes/${note.id}`,
|
||||
type: 'Note',
|
||||
attributedTo,
|
||||
summary: summary ?? undefined,
|
||||
content: content ?? undefined,
|
||||
updated: note.updatedAt?.toISOString(),
|
||||
_misskey_content: text,
|
||||
source: {
|
||||
content: text,
|
||||
mediaType: 'text/x.misskeymarkdown',
|
||||
},
|
||||
_misskey_quote: quote,
|
||||
quoteUrl: quote,
|
||||
quoteUri: quote,
|
||||
published: this.idService.parse(note.id).date.toISOString(),
|
||||
to,
|
||||
cc,
|
||||
inReplyTo,
|
||||
attachment: files.map(x => this.renderDocument(x)),
|
||||
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
||||
tag,
|
||||
...asPoll,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
|
||||
return {
|
||||
|
|
@ -822,9 +802,7 @@ export class ApRendererService {
|
|||
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
const jsonLd = this.jsonLdService.use();
|
||||
jsonLd.debug = false;
|
||||
activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`);
|
||||
activity = await this.jsonLdService.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
|
@ -877,6 +855,88 @@ export class ApRendererService {
|
|||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the reply collection wrapper object for a note
|
||||
* @param noteId Note whose reply collection to render.
|
||||
*/
|
||||
@bindThis
|
||||
public async renderRepliesCollection(noteId: string): Promise<IOrderedCollection> {
|
||||
const replyCount = await this.notesRepository.countBy({
|
||||
replyId: noteId,
|
||||
visibility: In(['public', 'home']),
|
||||
localOnly: false,
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'OrderedCollection',
|
||||
id: `${this.config.url}/notes/${noteId}/replies`,
|
||||
first: `${this.config.url}/notes/${noteId}/replies?page=true`,
|
||||
totalItems: replyCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a page of the replies collection for a note
|
||||
* @param noteId Return notes that are inReplyTo this value.
|
||||
* @param untilId If set, return only notes that are *older* than this value.
|
||||
*/
|
||||
@bindThis
|
||||
public async renderRepliesCollectionPage(noteId: string, untilId: string | undefined): Promise<IOrderedCollectionPage> {
|
||||
const replyCount = await this.notesRepository.countBy({
|
||||
replyId: noteId,
|
||||
visibility: In(['public', 'home']),
|
||||
localOnly: false,
|
||||
});
|
||||
|
||||
const limit = 50;
|
||||
const results = await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), undefined, untilId)
|
||||
.andWhere({
|
||||
replyId: noteId,
|
||||
visibility: In(['public', 'home']),
|
||||
localOnly: false,
|
||||
})
|
||||
.select(['note.id', 'note.uri'])
|
||||
.limit(limit)
|
||||
.getRawMany<{ note_id: string, note_uri: string | null }>();
|
||||
|
||||
const hasNextPage = results.length >= limit;
|
||||
const baseId = `${this.config.url}/notes/${noteId}/replies?page=true`;
|
||||
|
||||
return {
|
||||
type: 'OrderedCollectionPage',
|
||||
id: untilId == null ? baseId : `${baseId}&until_id=${untilId}`,
|
||||
partOf: `${this.config.url}/notes/${noteId}/replies`,
|
||||
first: baseId,
|
||||
next: hasNextPage ? `${baseId}&until_id=${results.at(-1)?.note_id}` : undefined,
|
||||
totalItems: replyCount,
|
||||
orderedItems: results.map(r => {
|
||||
// Remote notes have a URI, local have just an ID.
|
||||
return r.note_uri ?? `${this.config.url}/notes/${r.note_id}`;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async renderNoteOrRenoteActivity(note: MiNote, user: MiUser, hint?: { renote?: MiNote | null }) {
|
||||
if (note.localOnly) return null;
|
||||
|
||||
if (isPureRenote(note)) {
|
||||
const renote = hint?.renote ?? note.renote ?? await this.notesRepository.findOneByOrFail({ id: note.renoteId });
|
||||
const apAnnounce = this.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note);
|
||||
return this.addContext(apAnnounce);
|
||||
}
|
||||
|
||||
const apNote = await this.renderNote(note, user, false);
|
||||
|
||||
if (note.updatedAt != null) {
|
||||
const apUpdate = this.renderUpdate(apNote, user);
|
||||
return this.addContext(apUpdate);
|
||||
} else {
|
||||
const apCreate = this.renderCreate(apNote, note);
|
||||
return this.addContext(apCreate);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getEmojis(names: string[]): Promise<MiEmoji[]> {
|
||||
if (names.length === 0) return [];
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import * as crypto from 'node:crypto';
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Window } from 'happy-dom';
|
||||
import { load as cheerio } from 'cheerio/slim';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
|
|
@ -17,7 +17,9 @@ import { LoggerService } from '@/core/LoggerService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import type { IObject } from './type.js';
|
||||
import type { IObject, IObjectWithId } from './type.js';
|
||||
import type { Cheerio, CheerioAPI } from 'cheerio/slim';
|
||||
import type { AnyNode } from 'domhandler';
|
||||
|
||||
type Request = {
|
||||
url: string;
|
||||
|
|
@ -182,10 +184,11 @@ export class ApRequestService {
|
|||
* Get AP object with http-signature
|
||||
* @param user http-signature user
|
||||
* @param url URL to fetch
|
||||
* @param followAlternate
|
||||
* @param allowAnonymous If a fetched object lacks an ID, then it will be auto-generated from the final URL. (default: false)
|
||||
* @param followAlternate Whether to resolve HTML responses to their referenced canonical AP endpoint. (default: true)
|
||||
*/
|
||||
@bindThis
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<IObject> {
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise<IObjectWithId> {
|
||||
const _followAlternate = followAlternate ?? true;
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
|
|
@ -214,53 +217,33 @@ export class ApRequestService {
|
|||
(contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' &&
|
||||
_followAlternate === true
|
||||
) {
|
||||
const html = await res.text();
|
||||
const { window, happyDOM } = new Window({
|
||||
settings: {
|
||||
disableJavaScriptEvaluation: true,
|
||||
disableJavaScriptFileLoading: true,
|
||||
disableCSSFileLoading: true,
|
||||
disableComputedStyleRendering: true,
|
||||
handleDisabledFileLoadingAsSuccess: true,
|
||||
navigation: {
|
||||
disableMainFrameNavigation: true,
|
||||
disableChildFrameNavigation: true,
|
||||
disableChildPageNavigation: true,
|
||||
disableFallbackToSetURL: true,
|
||||
},
|
||||
timer: {
|
||||
maxTimeout: 0,
|
||||
maxIntervalTime: 0,
|
||||
maxIntervalIterations: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const document = window.document;
|
||||
let alternate: Cheerio<AnyNode> | null;
|
||||
try {
|
||||
document.documentElement.innerHTML = html;
|
||||
const html = await res.text();
|
||||
const document = cheerio(html);
|
||||
|
||||
// Search for any matching value in priority order:
|
||||
// 1. Type=AP > Type=none > Type=anything
|
||||
// 2. Alternate > Canonical
|
||||
// 3. Page order (fallback)
|
||||
const alternate =
|
||||
document.querySelector('head > link[href][rel="alternate"][type="application/activity+json"]') ??
|
||||
document.querySelector('head > link[href][rel="canonical"][type="application/activity+json"]') ??
|
||||
document.querySelector('head > link[href][rel="alternate"]:not([type])') ??
|
||||
document.querySelector('head > link[href][rel="canonical"]:not([type])') ??
|
||||
document.querySelector('head > link[href][rel="alternate"]') ??
|
||||
document.querySelector('head > link[href][rel="canonical"]');
|
||||
|
||||
if (alternate) {
|
||||
const href = alternate.getAttribute('href');
|
||||
if (href && this.apUtilityService.haveSameAuthority(url, href)) {
|
||||
return await this.signedGet(href, user, false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alternate = selectFirst(document, [
|
||||
'head > link[href][rel="alternate"][type="application/activity+json"]',
|
||||
'head > link[href][rel="canonical"][type="application/activity+json"]',
|
||||
'head > link[href][rel="alternate"]:not([type])',
|
||||
'head > link[href][rel="canonical"]:not([type])',
|
||||
'head > link[href][rel="alternate"]',
|
||||
'head > link[href][rel="canonical"]',
|
||||
]);
|
||||
} catch {
|
||||
// something went wrong parsing the HTML, ignore the whole thing
|
||||
} finally {
|
||||
happyDOM.close().catch(err => {});
|
||||
alternate = null;
|
||||
}
|
||||
|
||||
if (alternate) {
|
||||
const href = alternate.attr('href');
|
||||
if (href && this.apUtilityService.haveSameAuthority(url, href)) {
|
||||
return await this.signedGet(href, user, allowAnonymous, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
|
@ -271,8 +254,23 @@ export class ApRequestService {
|
|||
|
||||
// Make sure the object ID matches the final URL (which is where it actually exists).
|
||||
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
||||
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
||||
if (allowAnonymous && activity.id == null) {
|
||||
activity.id = res.url;
|
||||
} else {
|
||||
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
||||
}
|
||||
|
||||
return activity;
|
||||
return activity as IObjectWithId;
|
||||
}
|
||||
}
|
||||
|
||||
function selectFirst($: CheerioAPI, selectors: string[]): Cheerio<AnyNode> | null {
|
||||
for (const selector of selectors) {
|
||||
const selection = $(selector);
|
||||
if (selection.length > 0) {
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
|
|
@ -16,14 +16,18 @@ import { bindThis } from '@/decorators.js';
|
|||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { fromTuple } from '@/misc/from-tuple.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ApLogService, calculateDurationSince, extractObjectContext } from '@/core/ApLogService.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import { getApId, getNullableApId, isCollectionOrOrderedCollection } from './type.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { isPureRenote } from '@/misc/is-renote.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
import { ApRequestService } from './ApRequestService.js';
|
||||
import type { IObject, ICollection, IOrderedCollection } from './type.js';
|
||||
import type { IObject, ApObject, IAnonymousObject } from './type.js';
|
||||
|
||||
export class Resolver {
|
||||
private history: Set<string>;
|
||||
|
|
@ -39,7 +43,7 @@ export class Resolver {
|
|||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
private utilityService: UtilityService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private apRequestService: ApRequestService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private apRendererService: ApRendererService,
|
||||
|
|
@ -47,6 +51,7 @@ export class Resolver {
|
|||
private loggerService: LoggerService,
|
||||
private readonly apLogService: ApLogService,
|
||||
private readonly apUtilityService: ApUtilityService,
|
||||
private readonly cacheService: CacheService,
|
||||
private recursionLimit = 256,
|
||||
) {
|
||||
this.history = new Set();
|
||||
|
|
@ -63,37 +68,184 @@ export class Resolver {
|
|||
return this.recursionLimit;
|
||||
}
|
||||
|
||||
public async resolveCollection(value: string | IObjectWithId, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection & IObjectWithId>;
|
||||
public async resolveCollection(value: string | IObject, allowAnonymous: boolean | undefined, sentFromUri: string): Promise<AnyCollection & IObjectWithId>;
|
||||
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection>;
|
||||
@bindThis
|
||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
||||
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection> {
|
||||
const collection = typeof value === 'string'
|
||||
? await this.resolve(value)
|
||||
: value;
|
||||
? sentFromUri
|
||||
? await this.secureResolve(value, sentFromUri, allowAnonymous)
|
||||
: await this.resolve(value, allowAnonymous)
|
||||
: value; // TODO try and remove this eventually, as it's a major security foot-gun
|
||||
|
||||
if (isCollectionOrOrderedCollection(collection)) {
|
||||
return collection;
|
||||
} else {
|
||||
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `unrecognized collection type: ${collection.type}`);
|
||||
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `collection ${getApId(value)} has unsupported type: ${collection.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async resolveCollectionItems(collection: IAnonymousObject, limit?: number | null, allowAnonymousItems?: true, concurrency?: number): Promise<IAnonymousObject[]>;
|
||||
public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObjectWithId[]>;
|
||||
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObject[]>;
|
||||
/**
|
||||
* Recursively resolves items from a collection.
|
||||
* Stops when reaching the resolution limit or an optional item limit - whichever is lower.
|
||||
* This method supports Collection, OrderedCollection, and individual pages of either type.
|
||||
* Malformed collections (mixing Ordered and un-Ordered types) are also supported.
|
||||
* @param collection Collection to resolve from - can be a URL or object of any supported collection type.
|
||||
* @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit.
|
||||
* @param allowAnonymousItems If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID.
|
||||
* @param concurrency Maximum number of items to resolve at once. (default: 4)
|
||||
*/
|
||||
@bindThis
|
||||
public async resolve(value: string | IObject | [string | IObject]): Promise<IObject> {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise<IObject[]> {
|
||||
const resolvedItems: IObject[] = [];
|
||||
|
||||
// This is pulled up to avoid code duplication below
|
||||
const iterate = async(items: ApObject, current: AnyCollection) => {
|
||||
const sentFrom = current.id;
|
||||
const itemArr = toArray(items);
|
||||
const itemLimit = limit ?? Number.MAX_SAFE_INTEGER;
|
||||
const allowAnonymous = allowAnonymousItems ?? false;
|
||||
await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems);
|
||||
};
|
||||
|
||||
let current: AnyCollection | null = await this.resolveCollection(collection);
|
||||
do {
|
||||
// Iterate all items in the current page
|
||||
if (current.items) {
|
||||
await iterate(current.items, current);
|
||||
}
|
||||
if (current.orderedItems) {
|
||||
await iterate(current.orderedItems, current);
|
||||
}
|
||||
|
||||
if (this.history.size >= this.recursionLimit) {
|
||||
// Stop when we reach the fetch limit
|
||||
current = null;
|
||||
} else if (limit != null && resolvedItems.length >= limit) {
|
||||
// Stop when we reach the item limit
|
||||
current = null;
|
||||
} else if (isCollection(current) || isOrderedCollection(current)) {
|
||||
// Continue to first page
|
||||
current = current.first ? await this.resolveCollection(current.first, true, current.id) : null;
|
||||
} else if (isCollectionPage(current) || isOrderedCollectionPage(current)) {
|
||||
// Continue to next page
|
||||
current = current.next ? await this.resolveCollection(current.next, true, current.id) : null;
|
||||
} else {
|
||||
// Stop in all other conditions
|
||||
current = null;
|
||||
}
|
||||
} while (current != null);
|
||||
|
||||
return resolvedItems;
|
||||
}
|
||||
|
||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: undefined, itemLimit: number, concurrency: number, allowAnonymousItems: true, destination: IAnonymousObject[]): Promise<void>;
|
||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise<void>;
|
||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void>;
|
||||
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void> {
|
||||
const recursionLimit = this.recursionLimit - this.history.size;
|
||||
const batchLimit = Math.min(source.length, recursionLimit, itemLimit);
|
||||
|
||||
const limiter = promiseLimit<IObject>(concurrency);
|
||||
const batch = await Promise.all(source
|
||||
.slice(0, batchLimit)
|
||||
.map(item => limiter(async () => {
|
||||
if (sentFrom) {
|
||||
// Use secureResolve to avoid re-fetching items that were included inline.
|
||||
return await this.secureResolve(item, sentFrom, allowAnonymousItems);
|
||||
} else if (allowAnonymousItems) {
|
||||
return await this.resolveAnonymous(item);
|
||||
} else {
|
||||
// ID is required if we have neither sentFrom not allowAnonymousItems
|
||||
const id = getApId(item);
|
||||
return await this.resolve(id);
|
||||
}
|
||||
})));
|
||||
|
||||
destination.push(...batch);
|
||||
};
|
||||
|
||||
/**
|
||||
* Securely resolves an AP object or URL that has been sent from another instance.
|
||||
* An input object is trusted if and only if its ID matches the authority of sentFromUri.
|
||||
* In all other cases, the object is re-fetched from remote by input string or object ID.
|
||||
* @param input The input object or URL to resolve
|
||||
* @param sentFromUri The URL where this object originated. This MUST be accurate - all security checks depend on this value!
|
||||
* @param allowAnonymous If true, anonymous objects are allowed and will have their ID set to sentFromUri. If false (default) then anonymous objects will be rejected with an error.
|
||||
*/
|
||||
@bindThis
|
||||
public async secureResolve(input: string | IObject | [string | IObject], sentFromUri: string, allowAnonymous?: boolean): Promise<IObjectWithId> {
|
||||
// Unpack arrays to get the value element.
|
||||
const value = fromTuple(input);
|
||||
|
||||
// If anonymous input is allowed, then any object is automatically valid if we set the ID.
|
||||
// We can short-circuit here and avoid un-necessary checks.
|
||||
if (allowAnonymous && typeof(value) === 'object' && value.id == null) {
|
||||
value.id = sentFromUri;
|
||||
return value as IObjectWithId;
|
||||
}
|
||||
|
||||
// This ensures the input has a string ID, protecting against type confusion and rejecting anonymous objects.
|
||||
const id = getApId(value, sentFromUri);
|
||||
|
||||
// Check if we can use the provided object as-is.
|
||||
// Our security requires that the object ID matches the host authority that sent it, otherwise it can't be trusted.
|
||||
// A mismatch isn't necessarily malicious, it just means we can't use the object we were given.
|
||||
if (typeof(value) === 'object' && this.apUtilityService.haveSameAuthority(id, sentFromUri)) {
|
||||
return value as IObjectWithId;
|
||||
}
|
||||
|
||||
// If the checks didn't pass, then we must fetch the object and use that.
|
||||
return await this.resolve(id, allowAnonymous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an anonymous object.
|
||||
* The returned value will not have any ID present.
|
||||
* If one is provided in the response, it will be removed automatically.
|
||||
*/
|
||||
@bindThis
|
||||
public async resolveAnonymous(value: string | IObject | [string | IObject]): Promise<IAnonymousObject> {
|
||||
value = fromTuple(value);
|
||||
|
||||
const object = await this.resolve(value);
|
||||
object.id = undefined;
|
||||
|
||||
return object as IAnonymousObject;
|
||||
}
|
||||
|
||||
public async resolve(value: string | [string], allowAnonymous?: boolean): Promise<IObjectWithId>;
|
||||
public async resolve(value: string | IObjectWithId | [string | IObjectWithId], allowAnonymous?: boolean): Promise<IObjectWithId>;
|
||||
public async resolve(value: string | IObject | [string | IObject], allowAnonymous?: boolean): Promise<IObject>;
|
||||
/**
|
||||
* Resolves a URL or object to an AP object.
|
||||
* Tuples are expanded to their first element before anything else, and non-string inputs are returned as-is.
|
||||
* Otherwise, the string URL is fetched and validated to represent a valid ActivityPub object.
|
||||
* @param value The input value to resolve
|
||||
* @param allowAnonymous Determines what to do if a response object lacks an ID field. If false (default), then an exception is thrown. If true, then the ID is populated from the final response URL.
|
||||
*/
|
||||
@bindThis
|
||||
public async resolve(value: string | IObject | [string | IObject], allowAnonymous = false): Promise<IObject> {
|
||||
value = fromTuple(value);
|
||||
|
||||
// TODO try and remove this eventually, as it's a major security foot-gun
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const host = this.utilityService.extractDbHost(value);
|
||||
if (this.config.activityLogging.enabled && !this.utilityService.isSelfHost(host)) {
|
||||
return await this._resolveLogged(value, host);
|
||||
return await this._resolveLogged(value, host, allowAnonymous);
|
||||
} else {
|
||||
return await this._resolve(value, host);
|
||||
return await this._resolve(value, host, allowAnonymous);
|
||||
}
|
||||
}
|
||||
|
||||
private async _resolveLogged(requestUri: string, host: string): Promise<IObject> {
|
||||
private async _resolveLogged(requestUri: string, host: string, allowAnonymous: boolean): Promise<IObjectWithId> {
|
||||
const startTime = process.hrtime.bigint();
|
||||
|
||||
const log = await this.apLogService.createFetchLog({
|
||||
|
|
@ -102,7 +254,7 @@ export class Resolver {
|
|||
});
|
||||
|
||||
try {
|
||||
const result = await this._resolve(requestUri, host, log);
|
||||
const result = await this._resolve(requestUri, host, allowAnonymous, log);
|
||||
|
||||
log.accepted = true;
|
||||
log.result = 'ok';
|
||||
|
|
@ -122,39 +274,39 @@ export class Resolver {
|
|||
}
|
||||
}
|
||||
|
||||
private async _resolve(value: string, host: string, log?: SkApFetchLog): Promise<IObject> {
|
||||
private async _resolve(value: string, host: string, allowAnonymous: boolean, log?: SkApFetchLog): Promise<IObjectWithId> {
|
||||
if (value.includes('#')) {
|
||||
// URLs with fragment parts cannot be resolved correctly because
|
||||
// the fragment part does not get transmitted over HTTP(S).
|
||||
// Avoid strange behaviour by not trying to resolve these at all.
|
||||
throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `cannot resolve URL with fragment: ${value}`);
|
||||
throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `failed to resolve ${value}: URL contains fragment`);
|
||||
}
|
||||
|
||||
if (this.history.has(value)) {
|
||||
throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', `cannot resolve already resolved URL: ${value}`);
|
||||
throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', `failed to resolve ${value}: recursive resolution blocked`);
|
||||
}
|
||||
|
||||
if (this.history.size > this.recursionLimit) {
|
||||
throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `hit recursion limit: ${value}`);
|
||||
throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `failed to resolve ${value}: hit recursion limit`);
|
||||
}
|
||||
|
||||
this.history.add(value);
|
||||
|
||||
if (this.utilityService.isSelfHost(host)) {
|
||||
return await this.resolveLocal(value);
|
||||
return await this.resolveLocal(value) as IObjectWithId;
|
||||
}
|
||||
|
||||
if (!this.utilityService.isFederationAllowedHost(host)) {
|
||||
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', `cannot fetch AP object ${value}: blocked instance ${host}`);
|
||||
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', `failed to resolve ${value}: instance ${host} is blocked`);
|
||||
}
|
||||
|
||||
if (this.config.signToActivityPubGet && !this.user) {
|
||||
this.user = await this.instanceActorService.getInstanceActor();
|
||||
this.user = await this.systemAccountService.fetch('actor');
|
||||
}
|
||||
|
||||
const object = (this.user
|
||||
? await this.apRequestService.signedGet(value, this.user) as IObject
|
||||
: await this.httpRequestService.getActivityJson(value)) as IObject;
|
||||
? await this.apRequestService.signedGet(value, this.user, allowAnonymous)
|
||||
: await this.httpRequestService.getActivityJson(value, false, allowAnonymous));
|
||||
|
||||
if (log) {
|
||||
const { object: objectOnly, context, contextHash } = extractObjectContext(object);
|
||||
|
|
@ -175,12 +327,12 @@ export class Resolver {
|
|||
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
|
||||
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
|
||||
) {
|
||||
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `invalid AP object ${value}: does not have ActivityStreams context`);
|
||||
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `failed to resolve ${value}: response does not have ActivityStreams context`);
|
||||
}
|
||||
|
||||
// The object ID is already validated to match the final URL's authority by signedGet / getActivityJson.
|
||||
// We only need to validate that it also matches the original URL's authority, in case of redirects.
|
||||
const objectId = getApId(object);
|
||||
const objectId = getApId(object, value);
|
||||
|
||||
// We allow some limited cross-domain redirects, which means the host may have changed during fetch.
|
||||
// Additional checks are needed to validate the scope of cross-domain redirects.
|
||||
|
|
@ -191,64 +343,65 @@ export class Resolver {
|
|||
|
||||
// Check if the redirect bounce from [allowed domain] to [blocked domain].
|
||||
if (!this.utilityService.isFederationAllowedHost(finalHost)) {
|
||||
throw new IdentifiableError('0a72bf24-2d9b-4f1d-886b-15aaa31adeda', `cannot fetch AP object ${value}: redirected to blocked instance ${finalHost}`);
|
||||
throw new IdentifiableError('0a72bf24-2d9b-4f1d-886b-15aaa31adeda', `failed to resolve ${value}: redirected to blocked instance ${finalHost}`);
|
||||
}
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
// TODO try to remove this, as it creates a large attack surface
|
||||
@bindThis
|
||||
private resolveLocal(url: string): Promise<IObject> {
|
||||
private resolveLocal(url: string): Promise<IObjectWithId> {
|
||||
const parsed = this.apDbResolverService.parseUri(url);
|
||||
if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `resolveLocal - not a local URL: ${url}`);
|
||||
if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `failed to resolve local ${url}: not a local URL`);
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'notes':
|
||||
return this.notesRepository.findOneByOrFail({ id: parsed.id })
|
||||
return this.notesRepository.findOneOrFail({ where: { id: parsed.id, userHost: IsNull() }, relations: { user: true, renote: true } })
|
||||
.then(async note => {
|
||||
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
|
||||
const author = note.user ?? await this.cacheService.findUserById(note.userId);
|
||||
if (parsed.rest === 'activity') {
|
||||
// this refers to the create activity and not the note itself
|
||||
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note));
|
||||
return await this.apRendererService.renderNoteOrRenoteActivity(note, author);
|
||||
} else if (!isPureRenote(note)) {
|
||||
const apNote = await this.apRendererService.renderNote(note, author);
|
||||
return this.apRendererService.addContext(apNote);
|
||||
} else {
|
||||
return this.apRendererService.renderNote(note, author);
|
||||
throw new IdentifiableError('732c2633-3395-4d51-a9b7-c7084774e3e7', `Failed to resolve local ${url}: cannot resolve a boost as note`);
|
||||
}
|
||||
});
|
||||
}) as Promise<IObjectWithId>;
|
||||
case 'users':
|
||||
return this.usersRepository.findOneByOrFail({ id: parsed.id })
|
||||
return this.cacheService.findLocalUserById(parsed.id)
|
||||
.then(user => this.apRendererService.renderPerson(user as MiLocalUser));
|
||||
case 'questions':
|
||||
// Polls are indexed by the note they are attached to.
|
||||
return Promise.all([
|
||||
this.notesRepository.findOneByOrFail({ id: parsed.id }),
|
||||
this.pollsRepository.findOneByOrFail({ noteId: parsed.id }),
|
||||
this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() }),
|
||||
this.pollsRepository.findOneByOrFail({ noteId: parsed.id, userHost: IsNull() }),
|
||||
])
|
||||
.then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll));
|
||||
.then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)) as Promise<IObjectWithId>;
|
||||
case 'likes':
|
||||
return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(async reaction =>
|
||||
this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, { uri: null })));
|
||||
return this.noteReactionsRepository.findOneOrFail({ where: { id: parsed.id }, relations: { user: true } }).then(async reaction => {
|
||||
if (reaction.user?.host != null) {
|
||||
throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `failed to resolve local ${url}: not a local reaction`);
|
||||
}
|
||||
return this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, { uri: null }));
|
||||
});
|
||||
case 'follows':
|
||||
return this.followRequestsRepository.findOneBy({ id: parsed.id })
|
||||
.then(async followRequest => {
|
||||
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `resolveLocal - invalid follow request ID ${parsed.id}: ${url}`);
|
||||
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `failed to resolve local ${url}: invalid follow request ID`);
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneBy({
|
||||
id: followRequest.followerId,
|
||||
host: IsNull(),
|
||||
}),
|
||||
this.usersRepository.findOneBy({
|
||||
id: followRequest.followeeId,
|
||||
host: Not(IsNull()),
|
||||
}),
|
||||
this.cacheService.findLocalUserById(followRequest.followerId),
|
||||
this.cacheService.findLocalUserById(followRequest.followeeId),
|
||||
]);
|
||||
if (follower == null || followee == null) {
|
||||
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `resolveLocal - follower or followee does not exist: ${url}`);
|
||||
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `failed to resolve local ${url}: follower or followee does not exist`);
|
||||
}
|
||||
return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url));
|
||||
});
|
||||
default:
|
||||
throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `resolveLocal: type ${parsed.type} unhandled: ${url}`);
|
||||
throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `failed to resolve local ${url}: unsupported type ${parsed.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -278,7 +431,7 @@ export class ApResolverService {
|
|||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private apRequestService: ApRequestService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private apRendererService: ApRendererService,
|
||||
|
|
@ -286,11 +439,15 @@ export class ApResolverService {
|
|||
private loggerService: LoggerService,
|
||||
private readonly apLogService: ApLogService,
|
||||
private readonly apUtilityService: ApUtilityService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createResolver(): Resolver {
|
||||
public createResolver(opts?: {
|
||||
// Override the recursion limit
|
||||
recursionLimit?: number,
|
||||
}): Resolver {
|
||||
return new Resolver(
|
||||
this.config,
|
||||
this.meta,
|
||||
|
|
@ -300,7 +457,7 @@ export class ApResolverService {
|
|||
this.noteReactionsRepository,
|
||||
this.followRequestsRepository,
|
||||
this.utilityService,
|
||||
this.instanceActorService,
|
||||
this.systemAccountService,
|
||||
this.apRequestService,
|
||||
this.httpRequestService,
|
||||
this.apRendererService,
|
||||
|
|
@ -308,6 +465,8 @@ export class ApResolverService {
|
|||
this.loggerService,
|
||||
this.apLogService,
|
||||
this.apUtilityService,
|
||||
this.cacheService,
|
||||
opts?.recursionLimit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,24 +7,33 @@ import { Injectable } from '@nestjs/common';
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { EnvService } from '@/core/EnvService.js';
|
||||
import { getApId, getOneApHrefNullable, IObject } from './type.js';
|
||||
import { getApId, getNullableApId, getOneApHrefNullable } from '@/core/activitypub/type.js';
|
||||
import type { IObject, IObjectWithId } from '@/core/activitypub/type.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApUtilityService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
private readonly utilityService: UtilityService,
|
||||
private readonly envService: EnvService,
|
||||
) {}
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('ap-utility');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the object's ID has the same authority as the provided URL.
|
||||
* Returns on success, throws on any validation error.
|
||||
*/
|
||||
@bindThis
|
||||
public assertIdMatchesUrlAuthority(object: IObject, url: string): void {
|
||||
// This throws if the ID is missing or invalid, but that's ok.
|
||||
// Anonymous objects are impossible to verify, so we don't allow fetching them.
|
||||
const id = getApId(object);
|
||||
const id = getApId(object, url);
|
||||
|
||||
// Make sure the object ID matches the final URL (which is where it actually exists).
|
||||
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
||||
|
|
@ -36,11 +45,15 @@ export class ApUtilityService {
|
|||
/**
|
||||
* Checks if two URLs have the same host authority
|
||||
*/
|
||||
@bindThis
|
||||
public haveSameAuthority(url1: string, url2: string): boolean {
|
||||
if (url1 === url2) return true;
|
||||
|
||||
const authority1 = this.utilityService.punyHostPSLDomain(url1);
|
||||
const authority2 = this.utilityService.punyHostPSLDomain(url2);
|
||||
const parsed1 = this.utilityService.assertUrl(url1);
|
||||
const parsed2 = this.utilityService.assertUrl(url2);
|
||||
|
||||
const authority1 = this.utilityService.punyHostPSLDomain(parsed1);
|
||||
const authority2 = this.utilityService.punyHostPSLDomain(parsed2);
|
||||
return authority1 === authority2;
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +63,7 @@ export class ApUtilityService {
|
|||
* @throws {IdentifiableError} if object does not have an ID
|
||||
* @returns the best URL, or null if none were found
|
||||
*/
|
||||
@bindThis
|
||||
public findBestObjectUrl(object: IObject): string | null {
|
||||
const targetUrl = getApId(object);
|
||||
const targetAuthority = this.utilityService.punyHostPSLDomain(targetUrl);
|
||||
|
|
@ -63,12 +77,16 @@ export class ApUtilityService {
|
|||
: undefined,
|
||||
}))
|
||||
.filter(({ url, type }) => {
|
||||
if (!url) return false;
|
||||
if (!this.checkHttps(url)) return false;
|
||||
if (!isAcceptableUrlType(type)) return false;
|
||||
try {
|
||||
if (!url) return false;
|
||||
if (!isAcceptableUrlType(type)) return false;
|
||||
const parsed = this.utilityService.assertUrl(url);
|
||||
|
||||
const urlAuthority = this.utilityService.punyHostPSLDomain(url);
|
||||
return urlAuthority === targetAuthority;
|
||||
const urlAuthority = this.utilityService.punyHostPSLDomain(parsed);
|
||||
return urlAuthority === targetAuthority;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return rankUrlType(a.type) - rankUrlType(b.type);
|
||||
|
|
@ -78,15 +96,72 @@ export class ApUtilityService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Checks if the URL contains HTTPS.
|
||||
* Additionally, allows HTTP in non-production environments.
|
||||
* Based on check-https.ts.
|
||||
* Sanitizes an inline / nested Object property within an AP object.
|
||||
*
|
||||
* Returns true if the property contains a valid string URL, object w/ valid ID, or an array containing one of those.
|
||||
* Returns false and erases the property if it doesn't contain a valid value.
|
||||
*
|
||||
* Arrays are automatically flattened.
|
||||
* Falsy values (including null) are collapsed to undefined.
|
||||
* @param obj Object containing the property to validate
|
||||
* @param key Key of the property in obj
|
||||
* @param parentUri URI of the object that contains this inline object.
|
||||
* @param parentHost PSL host of parentUri
|
||||
* @param keyPath If obj is *itself* a nested object, set this to the property path from root to obj (including the trailing '.'). This does not affect the logic, but improves the clarity of logs.
|
||||
*/
|
||||
private checkHttps(url: string): boolean {
|
||||
const isNonProd = this.envService.env.NODE_ENV !== 'production';
|
||||
@bindThis
|
||||
public sanitizeInlineObject<Key extends string>(obj: Partial<Record<Key, string | { id?: string } | (string | { id?: string })[]>>, key: Key, parentUri: string | URL, parentHost: string, keyPath = ''): obj is Partial<Record<Key, string | { id: string }>> {
|
||||
let value: unknown = obj[key];
|
||||
|
||||
// noinspection HttpUrlsUsage
|
||||
return url.startsWith('https://') || (url.startsWith('http://') && isNonProd);
|
||||
// Unpack arrays
|
||||
if (Array.isArray(value)) {
|
||||
value = value[0];
|
||||
}
|
||||
|
||||
// Clear the value - we'll add it back once we have a confirmed ID
|
||||
obj[key] = undefined;
|
||||
|
||||
// Collapse falsy values to undefined
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude nested arrays
|
||||
if (Array.isArray(value)) {
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: nested arrays are prohibited`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude incorrect types
|
||||
if (typeof(value) !== 'string' && typeof(value) !== 'object') {
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: incorrect type ${typeof(value)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const valueId = getNullableApId(value);
|
||||
if (!valueId) {
|
||||
// Exclude missing ID
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: missing or invalid ID`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = this.utilityService.assertUrl(valueId);
|
||||
const parsedHost = this.utilityService.punyHostPSLDomain(parsed);
|
||||
if (parsedHost !== parentHost) {
|
||||
// Exclude wrong host
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: wrong host in ${valueId} (got ${parsedHost}, expected ${parentHost})`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
// Exclude invalid URLs
|
||||
this.logger.warn(`Excluding ${keyPath}${key} from object ${parentUri}: invalid URL ${valueId}: ${renderInlineError(err)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Success - store the sanitized value and return
|
||||
obj[key] = value as string | IObjectWithId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,25 +8,61 @@ import { Injectable } from '@nestjs/common';
|
|||
import { UnrecoverableError } from 'bullmq';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js';
|
||||
import { validateContentTypeSetAsJsonLD } from './misc/validator.js';
|
||||
import type { JsonLdDocument } from 'jsonld';
|
||||
import type { ContextDefinition, JsonLdDocument } from 'jsonld';
|
||||
import type { JsonLd as JsonLdObject, RemoteDocument } from 'jsonld/jsonld-spec.js';
|
||||
|
||||
// https://stackoverflow.com/a/66252656
|
||||
type RemoveIndex<T> = {
|
||||
[ K in keyof T as string extends K
|
||||
? never
|
||||
: number extends K
|
||||
? never
|
||||
: symbol extends K
|
||||
? never
|
||||
: K
|
||||
] : T[K];
|
||||
};
|
||||
|
||||
export type Document = RemoveIndex<JsonLdDocument>;
|
||||
|
||||
export type Signature = {
|
||||
id?: string;
|
||||
type: string;
|
||||
creator: string;
|
||||
domain?: string;
|
||||
nonce: string;
|
||||
created: string;
|
||||
signatureValue: string;
|
||||
};
|
||||
|
||||
export type Signed<T extends Document> = T & {
|
||||
signature: Signature;
|
||||
};
|
||||
|
||||
export function isSigned<T extends Document>(doc: T): doc is Signed<T> {
|
||||
return 'signature' in doc && typeof(doc.signature) === 'object';
|
||||
}
|
||||
|
||||
// RsaSignature2017 implementation is based on https://github.com/transmute-industries/RsaSignature2017
|
||||
|
||||
class JsonLd {
|
||||
public debug = false;
|
||||
public preLoad = true;
|
||||
public loderTimeout = 5000;
|
||||
@Injectable()
|
||||
export class JsonLdService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
private httpRequestService: HttpRequestService,
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('json-ld');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise<any> {
|
||||
public async signRsaSignature2017<T extends Document>(data: T, privateKey: string, creator: string, domain?: string, created?: Date): Promise<Signed<T>> {
|
||||
const options: {
|
||||
type: string;
|
||||
creator: string;
|
||||
|
|
@ -62,7 +98,7 @@ class JsonLd {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyRsaSignature2017(data: any, publicKey: string): Promise<boolean> {
|
||||
public async verifyRsaSignature2017(data: Signed<Document>, publicKey: string): Promise<boolean> {
|
||||
const toBeSigned = await this.createVerifyData(data, data.signature);
|
||||
const verifier = crypto.createVerify('sha256');
|
||||
verifier.update(toBeSigned);
|
||||
|
|
@ -70,7 +106,7 @@ class JsonLd {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async createVerifyData(data: any, options: any): Promise<string> {
|
||||
public async createVerifyData<T extends Document>(data: T, options: Partial<Signature>): Promise<string> {
|
||||
const transformedOptions = {
|
||||
...options,
|
||||
'@context': 'https://w3id.org/identity/v1',
|
||||
|
|
@ -80,17 +116,18 @@ class JsonLd {
|
|||
delete transformedOptions['signatureValue'];
|
||||
const canonizedOptions = await this.normalize(transformedOptions);
|
||||
const optionsHash = this.sha256(canonizedOptions.toString());
|
||||
const transformedData = { ...data };
|
||||
const transformedData = { ...data } as T & { signature?: unknown };
|
||||
delete transformedData['signature'];
|
||||
const cannonidedData = await this.normalize(transformedData);
|
||||
if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`);
|
||||
this.logger.debug('cannonidedData', cannonidedData);
|
||||
const documentHash = this.sha256(cannonidedData.toString());
|
||||
const verifyData = `${optionsHash}${documentHash}`;
|
||||
return verifyData;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async compact(data: any, context: any = CONTEXT): Promise<JsonLdDocument> {
|
||||
// TODO our default CONTEXT isn't valid for the library, is this a bug?
|
||||
public async compact(data: Document, context: ContextDefinition = CONTEXT as unknown as ContextDefinition): Promise<Document> {
|
||||
const customLoader = this.getLoader();
|
||||
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
|
||||
// https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595
|
||||
|
|
@ -100,7 +137,7 @@ class JsonLd {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async normalize(data: JsonLdDocument): Promise<string> {
|
||||
public async normalize(data: Document): Promise<string> {
|
||||
const customLoader = this.getLoader();
|
||||
return (await import('jsonld')).default.normalize(data, {
|
||||
documentLoader: customLoader,
|
||||
|
|
@ -112,9 +149,9 @@ class JsonLd {
|
|||
return async (url: string): Promise<RemoteDocument> => {
|
||||
if (!/^https?:\/\//.test(url)) throw new UnrecoverableError(`Invalid URL: ${url}`);
|
||||
|
||||
if (this.preLoad) {
|
||||
{
|
||||
if (url in PRELOADED_CONTEXTS) {
|
||||
if (this.debug) console.debug(`HIT: ${url}`);
|
||||
this.logger.debug(`Preload HIT: ${url}`);
|
||||
return {
|
||||
contextUrl: undefined,
|
||||
document: PRELOADED_CONTEXTS[url],
|
||||
|
|
@ -123,7 +160,7 @@ class JsonLd {
|
|||
}
|
||||
}
|
||||
|
||||
if (this.debug) console.debug(`MISS: ${url}`);
|
||||
this.logger.debug(`Preload MISS: ${url}`);
|
||||
const document = await this.fetchDocument(url);
|
||||
return {
|
||||
contextUrl: undefined,
|
||||
|
|
@ -141,7 +178,6 @@ class JsonLd {
|
|||
headers: {
|
||||
Accept: 'application/ld+json, application/json',
|
||||
},
|
||||
timeout: this.loderTimeout,
|
||||
},
|
||||
{
|
||||
throwErrorWhenResponseNotOk: false,
|
||||
|
|
@ -149,7 +185,7 @@ class JsonLd {
|
|||
},
|
||||
).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`JSON-LD fetch failed with ${res.status} ${res.statusText}: ${url}`);
|
||||
throw new StatusError(`failed to fetch JSON-LD from ${url}`, res.status, res.statusText);
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
|
|
@ -165,16 +201,3 @@ class JsonLd {
|
|||
return hash.digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JsonLdService {
|
||||
constructor(
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public use(): JsonLd {
|
||||
return new JsonLd(this.httpRequestService);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -540,12 +540,20 @@ const extension_context_definition = {
|
|||
quoteUrl: 'as:quoteUrl',
|
||||
fedibird: 'http://fedibird.com/ns#',
|
||||
quoteUri: 'fedibird:quoteUri',
|
||||
quote: {
|
||||
'@id': 'https://w3id.org/fep/044f#quote',
|
||||
'@type': '@id',
|
||||
},
|
||||
// Mastodon
|
||||
toot: 'http://joinmastodon.org/ns#',
|
||||
Emoji: 'toot:Emoji',
|
||||
featured: 'toot:featured',
|
||||
discoverable: 'toot:discoverable',
|
||||
indexable: 'toot:indexable',
|
||||
attributionDomains: {
|
||||
'@id': 'toot:attributionDomains',
|
||||
'@type': '@id',
|
||||
},
|
||||
// schema
|
||||
schema: 'http://schema.org#',
|
||||
PropertyValue: 'schema:PropertyValue',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { load as cheerio } from 'cheerio/slim';
|
||||
import type { IApDocument } from '@/core/activitypub/type.js';
|
||||
import type { CheerioAPI } from 'cheerio/slim';
|
||||
|
||||
/**
|
||||
* Finds HTML elements representing inline media and returns them as simulated AP documents.
|
||||
* Returns an empty array if the input cannot be parsed, or no media was found.
|
||||
* @param html Input HTML to analyze.
|
||||
*/
|
||||
export function extractMediaFromHtml(html: string): IApDocument[] {
|
||||
const $ = parseHtml(html);
|
||||
if (!$) return [];
|
||||
|
||||
const attachments = new Map<string, IApDocument>();
|
||||
|
||||
// <img> tags, including <picture> and <object> fallback elements
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img
|
||||
$('img[src]')
|
||||
.toArray()
|
||||
.forEach(img => attachments.set(img.attribs.src, {
|
||||
type: 'Image',
|
||||
url: img.attribs.src,
|
||||
name: img.attribs.alt || img.attribs.title || null,
|
||||
}));
|
||||
|
||||
// <object> tags
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/object
|
||||
$('object[data]')
|
||||
.toArray()
|
||||
.forEach(object => attachments.set(object.attribs.data, {
|
||||
type: 'Document',
|
||||
url: object.attribs.data,
|
||||
name: object.attribs.alt || object.attribs.title || null,
|
||||
}));
|
||||
|
||||
// <embed> tags
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/embed
|
||||
$('embed[src]')
|
||||
.toArray()
|
||||
.forEach(embed => attachments.set(embed.attribs.src, {
|
||||
type: 'Document',
|
||||
url: embed.attribs.src,
|
||||
name: embed.attribs.alt || embed.attribs.title || null,
|
||||
}));
|
||||
|
||||
// <audio> tags
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/audio
|
||||
$('audio[src]')
|
||||
.toArray()
|
||||
.forEach(audio => attachments.set(audio.attribs.src, {
|
||||
type: 'Audio',
|
||||
url: audio.attribs.src,
|
||||
name: audio.attribs.alt || audio.attribs.title || null,
|
||||
}));
|
||||
|
||||
// <video> tags
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video
|
||||
$('video[src]')
|
||||
.toArray()
|
||||
.forEach(audio => attachments.set(audio.attribs.src, {
|
||||
type: 'Video',
|
||||
url: audio.attribs.src,
|
||||
name: audio.attribs.alt || audio.attribs.title || null,
|
||||
}));
|
||||
|
||||
// TODO support <svg>? We would need to extract it directly from the HTML and save to a temp file.
|
||||
|
||||
return Array.from(attachments.values());
|
||||
}
|
||||
|
||||
function parseHtml(html: string): CheerioAPI | null {
|
||||
try {
|
||||
return cheerio(html);
|
||||
} catch {
|
||||
// Don't worry about invalid HTML
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { parse, inspect, extract } from 'mfm-js';
|
||||
import type { IApDocument } from '@/core/activitypub/type.js';
|
||||
import type { MfmNode, MfmText } from 'mfm-js';
|
||||
|
||||
/**
|
||||
* Finds MFM notes representing inline media and returns them as simulated AP documents.
|
||||
* Returns an empty array if the input cannot be parsed, or no media was found.
|
||||
* @param mfm Input MFM to analyze.
|
||||
*/
|
||||
export function extractMediaFromMfm(mfm: string): IApDocument[] {
|
||||
const nodes = parseMfm(mfm);
|
||||
if (nodes == null) return [];
|
||||
|
||||
const attachments = new Map<string, IApDocument>();
|
||||
|
||||
inspect(nodes, node => {
|
||||
if (node.type === 'link' && node.props.image) {
|
||||
const alt: string[] = [];
|
||||
|
||||
inspect(node.children, node => {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
alt.push(node.props.text);
|
||||
break;
|
||||
case 'unicodeEmoji':
|
||||
alt.push(node.props.emoji);
|
||||
break;
|
||||
case 'emojiCode':
|
||||
alt.push(':');
|
||||
alt.push(node.props.name);
|
||||
alt.push(':');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
attachments.set(node.props.url, {
|
||||
type: 'Image',
|
||||
url: node.props.url,
|
||||
name: alt.length > 0
|
||||
? alt.join('')
|
||||
: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(attachments.values());
|
||||
}
|
||||
|
||||
function parseMfm(mfm: string): MfmNode[] | null {
|
||||
try {
|
||||
return parse(mfm);
|
||||
} catch {
|
||||
// Don't worry about invalid MFM
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { IPost } from '@/core/activitypub/type.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
|
||||
/**
|
||||
* Gets content of a specified media type from a provided object.
|
||||
*
|
||||
* Optionally supports a "permissive" mode which enables the following changes:
|
||||
* 1. MIME types are checked in a case-insensitive manner.
|
||||
* 2. MIME types are matched based on inclusion, not strict equality.
|
||||
* 3. A candidate content is considered to match if it has no specified MIME type.
|
||||
*
|
||||
* Note: this method is written defensively to protect against malform remote objects.
|
||||
* When extending or modifying it, please be sure to work with "unknown" type and validate everything.
|
||||
*
|
||||
* Note: the logic in this method is carefully ordered to match the selection priority of existing code in ApNoteService.
|
||||
* Please do not re-arrange it without testing!
|
||||
* New checks can be added to the end of the method to safely extend the existing logic.
|
||||
*
|
||||
* @param object AP object to extract content from.
|
||||
* @param mimeType MIME type to look for.
|
||||
* @param permissive Enables permissive mode, as described above. Defaults to false (disabled).
|
||||
*/
|
||||
export function getContentByType(object: IPost | Record<string, unknown>, mimeType: string, permissive = false): string | null {
|
||||
// Case 1: Extended "source" property
|
||||
if (object.source && typeof(object.source) === 'object') {
|
||||
// "source" is permitted to be an array, though no implementations are known to do this yet.
|
||||
const sources = toArray(object.source) as Record<string, unknown>[];
|
||||
for (const source of sources) {
|
||||
if (typeof (source.content) === 'string' && checkMediaType(source.mediaType)) {
|
||||
return source.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Special case for MFM
|
||||
if (typeof(object._misskey_content) === 'string' && mimeType === 'text/x.misskeymarkdown') {
|
||||
return object._misskey_content;
|
||||
}
|
||||
|
||||
// Case 3: AP native "content" property
|
||||
if (typeof(object.content) === 'string' && checkMediaType(object.mediaType)) {
|
||||
return object.content;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
// Checks if the provided media type matches the input parameters.
|
||||
function checkMediaType(mediaType: unknown): boolean {
|
||||
if (typeof(mediaType) === 'string') {
|
||||
// Strict match
|
||||
if (mediaType === mimeType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Permissive match
|
||||
if (permissive && mediaType.toLowerCase().includes(mimeType.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Permissive fallback match
|
||||
if (permissive && mediaType == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No match
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,14 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
|
||||
export function validateContentTypeSetAsActivityPub(response: Response): void {
|
||||
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
||||
|
||||
if (contentType === '') {
|
||||
throw new Error(`invalid content type of AP response - no content-type header: ${response.url}`);
|
||||
throw new IdentifiableError('d09dc850-b76c-4f45-875a-7389339d78b8', `invalid AP response from ${response.url}: no content-type header`, true);
|
||||
}
|
||||
if (
|
||||
contentType.startsWith('application/activity+json') ||
|
||||
|
|
@ -17,7 +18,7 @@ export function validateContentTypeSetAsActivityPub(response: Response): void {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`invalid content type of AP response - content type is not application/activity+json or application/ld+json: ${response.url}`);
|
||||
throw new IdentifiableError('dc110060-a5f2-461d-808b-39c62702ca64', `invalid AP response from ${response.url}: content type "${contentType}" is not application/activity+json or application/ld+json`);
|
||||
}
|
||||
|
||||
const plusJsonSuffixRegex = /^\s*(application|text)\/[a-zA-Z0-9\.\-\+]+\+json\s*(;|$)/;
|
||||
|
|
@ -26,7 +27,7 @@ export function validateContentTypeSetAsJsonLD(response: Response): void {
|
|||
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
||||
|
||||
if (contentType === '') {
|
||||
throw new Error(`invalid content type of JSON LD - no content-type header: ${response.url}`);
|
||||
throw new IdentifiableError('45793ab7-7648-4886-b503-429f8a0d0f73', `invalid AP response from ${response.url}: no content-type header`, true);
|
||||
}
|
||||
if (
|
||||
contentType.startsWith('application/ld+json') ||
|
||||
|
|
@ -35,5 +36,5 @@ export function validateContentTypeSetAsJsonLD(response: Response): void {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`invalid content type of JSON LD - content type is not application/ld+json or application/json: ${response.url}`);
|
||||
throw new IdentifiableError('4bf8f36b-4d33-4ac9-ad76-63fa11f354e9', `invalid AP response from ${response.url}: content type "${contentType}" is not application/ld+json or application/json`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type { Config } from '@/config.js';
|
|||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { isDocument, type IObject } from '../type.js';
|
||||
import { getNullableApId, isDocument, type IObject } from '../type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApImageService {
|
||||
|
|
@ -48,7 +48,7 @@ export class ApImageService {
|
|||
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
|
||||
// 投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${actor.uri}`);
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create image ${getNullableApId(value)}: actor ${actor.id} has been suspended`);
|
||||
}
|
||||
|
||||
const image = await this.apResolverService.createResolver().resolve(value);
|
||||
|
|
@ -86,7 +86,7 @@ export class ApImageService {
|
|||
uri: image.url,
|
||||
sensitive: !!(image.sensitive),
|
||||
isLink: !shouldBeCached,
|
||||
comment: truncate(image.name ?? undefined, this.config.maxRemoteAltTextLength),
|
||||
comment: truncate(image.summary || image.name || undefined, this.config.maxRemoteAltTextLength),
|
||||
});
|
||||
if (!file.isLink || file.url === image.url) return file;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
|
@ -26,7 +27,11 @@ import { bindThis } from '@/decorators.js';
|
|||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||
import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument } from '../type.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
|
||||
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
|
||||
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
|
||||
import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument, isLink } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApMfmService } from '../ApMfmService.js';
|
||||
import { ApDbResolverService } from '../ApDbResolverService.js';
|
||||
|
|
@ -95,33 +100,34 @@ export class ApNoteService {
|
|||
actor?: MiRemoteUser,
|
||||
user?: MiRemoteUser,
|
||||
): Error | null {
|
||||
this.utilityService.assertUrl(uri);
|
||||
const expectHost = this.utilityService.extractDbHost(uri);
|
||||
const apType = getApType(object);
|
||||
|
||||
if (apType == null || !validPost.includes(apType)) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${apType ?? 'undefined'}`);
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: invalid object type ${apType ?? 'undefined'}`);
|
||||
}
|
||||
|
||||
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
|
||||
}
|
||||
|
||||
const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo));
|
||||
if (object.attributedTo && actualHost !== expectHost) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
|
||||
}
|
||||
|
||||
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note from ${uri}: published timestamp is malformed');
|
||||
}
|
||||
|
||||
if (actor) {
|
||||
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
|
||||
if (attribution !== actor.uri) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
|
||||
}
|
||||
if (user && attribution !== user.uri) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated attribution does not match original attribution. updated attribution: ${user.uri}, original attribution: ${attribution}`);
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: updated attribution does not match original attribution. updated attribution: ${user.uri}, original attribution: ${attribution}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +166,7 @@ export class ApNoteService {
|
|||
const entryUri = getApId(value);
|
||||
const err = this.validateNote(object, entryUri, actor);
|
||||
if (err) {
|
||||
this.logger.error(err.message, {
|
||||
this.logger.error(`Error creating note: ${renderInlineError(err)}`, {
|
||||
resolver: { history: resolver.getHistory() },
|
||||
value,
|
||||
object,
|
||||
|
|
@ -173,11 +179,11 @@ export class ApNoteService {
|
|||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||
|
||||
if (note.id == null) {
|
||||
throw new UnrecoverableError(`Refusing to create note without id: ${entryUri}`);
|
||||
throw new UnrecoverableError(`failed to create note ${entryUri}: missing ID`);
|
||||
}
|
||||
|
||||
if (!checkHttps(note.id)) {
|
||||
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`);
|
||||
throw new UnrecoverableError(`failed to create note ${entryUri}: unexpected schema`);
|
||||
}
|
||||
|
||||
const url = this.apUtilityService.findBestObjectUrl(note);
|
||||
|
|
@ -186,7 +192,7 @@ export class ApNoteService {
|
|||
|
||||
// 投稿者をフェッチ
|
||||
if (note.attributedTo == null) {
|
||||
throw new UnrecoverableError(`invalid note.attributedTo ${note.attributedTo} in ${entryUri}`);
|
||||
throw new UnrecoverableError(`failed to create note: ${entryUri}: missing attributedTo`);
|
||||
}
|
||||
|
||||
const uri = getOneApId(note.attributedTo);
|
||||
|
|
@ -195,7 +201,7 @@ export class ApNoteService {
|
|||
// eslint-disable-next-line no-param-reassign
|
||||
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
|
||||
if (actor && actor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${uri} has been suspended: ${entryUri}`);
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create note ${entryUri}: actor ${uri} has been suspended`);
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
|
|
@ -204,12 +210,10 @@ export class ApNoteService {
|
|||
const cw = note.summary === '' ? null : note.summary;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
let text =
|
||||
getContentByType(note, 'text/x.misskeymarkdown') ??
|
||||
getContentByType(note, 'text/markdown');
|
||||
if (text == null && typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +226,7 @@ export class ApNoteService {
|
|||
*/
|
||||
const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
||||
if (hasProhibitedWords) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${entryUri}`);
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `failed to create note ${entryUri}: contains prohibited words`);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
|
@ -231,7 +235,7 @@ export class ApNoteService {
|
|||
|
||||
// 解決した投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${entryUri}`);
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create note ${entryUri}: actor ${actor.id} has been suspended`);
|
||||
}
|
||||
|
||||
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
|
||||
|
|
@ -246,21 +250,14 @@ export class ApNoteService {
|
|||
}
|
||||
}
|
||||
|
||||
const processErrors: string[] = [];
|
||||
|
||||
// 添付ファイル
|
||||
const files: MiDriveFile[] = [];
|
||||
|
||||
for (const attach of toArray(note.attachment)) {
|
||||
attach.sensitive ??= note.sensitive;
|
||||
const file = await this.apImageService.resolveImage(actor, attach);
|
||||
if (file) files.push(file);
|
||||
}
|
||||
|
||||
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
|
||||
const icon = getBestIcon(note);
|
||||
if (icon) {
|
||||
icon.sensitive ??= note.sensitive;
|
||||
const file = await this.apImageService.resolveImage(actor, icon);
|
||||
if (file) files.push(file);
|
||||
// Note: implementation moved to getAttachment function to avoid duplication.
|
||||
// Please copy any upstream changes to that method! (It's in the bottom of this class)
|
||||
const { files, hasFileError } = await this.getAttachments(note, actor);
|
||||
if (hasFileError) {
|
||||
processErrors.push('attachmentFailed');
|
||||
}
|
||||
|
||||
// リプライ
|
||||
|
|
@ -268,21 +265,30 @@ export class ApNoteService {
|
|||
? await this.resolveNote(note.inReplyTo, { resolver })
|
||||
.then(x => {
|
||||
if (x == null) {
|
||||
this.logger.warn('Specified inReplyTo, but not found');
|
||||
throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||
this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`);
|
||||
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true);
|
||||
}
|
||||
|
||||
return x;
|
||||
})
|
||||
.catch(async err => {
|
||||
this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||
throw err;
|
||||
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
|
||||
})
|
||||
: null;
|
||||
|
||||
// 引用
|
||||
const quote = await this.getQuote(note, entryUri, resolver);
|
||||
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
||||
if (quote === null) {
|
||||
processErrors.push('quoteUnavailable');
|
||||
}
|
||||
|
||||
if (reply && reply.userHost == null && reply.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note');
|
||||
}
|
||||
if (quote && quote.userHost == null && quote.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
|
||||
}
|
||||
|
||||
// vote
|
||||
if (reply && reply.hasPoll) {
|
||||
|
|
@ -296,7 +302,7 @@ export class ApNoteService {
|
|||
await this.pollService.vote(actor, reply, index);
|
||||
|
||||
// リモートフォロワーにUpdate配信
|
||||
this.pollService.deliverQuestionUpdate(reply.id);
|
||||
this.pollService.deliverQuestionUpdate(reply);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
@ -319,7 +325,7 @@ export class ApNoteService {
|
|||
files,
|
||||
reply,
|
||||
renote: quote ?? null,
|
||||
processErrors,
|
||||
processErrors: processErrors.length > 0 ? processErrors : null,
|
||||
name: note.name,
|
||||
cw,
|
||||
text,
|
||||
|
|
@ -340,7 +346,7 @@ export class ApNoteService {
|
|||
this.logger.info('The note is already inserted while creating itself, reading again');
|
||||
const duplicate = await this.fetchNote(value);
|
||||
if (!duplicate) {
|
||||
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${entryUri}`);
|
||||
throw new IdentifiableError('39c328e1-e829-458b-bfc9-65dcd513d1f8', `failed to create note ${entryUri}: the note creation failed with duplication error even when there is no duplication. This is likely a bug.`);
|
||||
}
|
||||
return duplicate;
|
||||
}
|
||||
|
|
@ -354,45 +360,39 @@ export class ApNoteService {
|
|||
const noteUri = getApId(value);
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (noteUri.startsWith(this.config.url + '/')) throw new UnrecoverableError(`uri points local: ${noteUri}`);
|
||||
if (this.utilityService.isUriLocal(noteUri)) {
|
||||
throw new UnrecoverableError(`failed to update note ${noteUri}: uri is local`);
|
||||
}
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const updatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
|
||||
if (updatedNote == null) throw new Error(`Note is not registered (no note): ${noteUri}`);
|
||||
if (updatedNote == null) throw new UnrecoverableError(`failed to update note ${noteUri}: note does not exist`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: updatedNote.userId }) as MiRemoteUser | null;
|
||||
if (user == null) throw new Error(`Note is not registered (no user): ${noteUri}`);
|
||||
if (user == null) throw new UnrecoverableError(`failed to update note ${noteUri}: user does not exist`);
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(value);
|
||||
|
||||
const entryUri = getApId(value);
|
||||
const err = this.validateNote(object, entryUri, actor, user);
|
||||
if (err) {
|
||||
this.logger.error(err.message, {
|
||||
resolver: { history: resolver.getHistory() },
|
||||
value,
|
||||
object,
|
||||
});
|
||||
this.logger.error(`Failed to update note ${noteUri}: ${renderInlineError(err)}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// `validateNote` checks that the actor and user are one and the same
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
actor ??= user;
|
||||
|
||||
const note = object as IPost;
|
||||
|
||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||
|
||||
if (note.id == null) {
|
||||
throw new UnrecoverableError(`Refusing to update note without id: ${noteUri}`);
|
||||
throw new UnrecoverableError(`failed to update note ${entryUri}: missing ID`);
|
||||
}
|
||||
|
||||
if (!checkHttps(note.id)) {
|
||||
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`);
|
||||
throw new UnrecoverableError(`failed to update note ${entryUri}: unexpected schema`);
|
||||
}
|
||||
|
||||
const url = this.apUtilityService.findBestObjectUrl(note);
|
||||
|
|
@ -400,7 +400,7 @@ export class ApNoteService {
|
|||
this.logger.info(`Creating the Note: ${note.id}`);
|
||||
|
||||
if (actor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${actor.id} has been suspended: ${noteUri}`);
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to update note ${entryUri}: actor ${actor.id} has been suspended`);
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
|
|
@ -409,12 +409,10 @@ export class ApNoteService {
|
|||
const cw = note.summary === '' ? null : note.summary;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
let text =
|
||||
getContentByType(note, 'text/x.misskeymarkdown') ??
|
||||
getContentByType(note, 'text/markdown');
|
||||
if (text == null && typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
|
|
@ -425,9 +423,9 @@ export class ApNoteService {
|
|||
/**
|
||||
* 禁止ワードチェック
|
||||
*/
|
||||
const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
||||
const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
||||
if (hasProhibitedWords) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${noteUri}`);
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `failed to update note ${noteUri}: contains prohibited words`);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
|
@ -443,21 +441,12 @@ export class ApNoteService {
|
|||
}
|
||||
}
|
||||
|
||||
const processErrors: string[] = [];
|
||||
|
||||
// 添付ファイル
|
||||
const files: MiDriveFile[] = [];
|
||||
|
||||
for (const attach of toArray(note.attachment)) {
|
||||
attach.sensitive ??= note.sensitive;
|
||||
const file = await this.apImageService.resolveImage(actor, attach);
|
||||
if (file) files.push(file);
|
||||
}
|
||||
|
||||
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
|
||||
const icon = getBestIcon(note);
|
||||
if (icon) {
|
||||
icon.sensitive ??= note.sensitive;
|
||||
const file = await this.apImageService.resolveImage(actor, icon);
|
||||
if (file) files.push(file);
|
||||
const { files, hasFileError } = await this.getAttachments(note, actor);
|
||||
if (hasFileError) {
|
||||
processErrors.push('attachmentFailed');
|
||||
}
|
||||
|
||||
// リプライ
|
||||
|
|
@ -465,21 +454,27 @@ export class ApNoteService {
|
|||
? await this.resolveNote(note.inReplyTo, { resolver })
|
||||
.then(x => {
|
||||
if (x == null) {
|
||||
this.logger.warn('Specified inReplyTo, but not found');
|
||||
throw new Error(`could not fetch inReplyTo ${note.inReplyTo}: ${entryUri}`);
|
||||
this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`);
|
||||
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true);
|
||||
}
|
||||
|
||||
return x;
|
||||
})
|
||||
.catch(async err => {
|
||||
this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo}: ${entryUri}`);
|
||||
throw err;
|
||||
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
|
||||
})
|
||||
: null;
|
||||
|
||||
// 引用
|
||||
const quote = await this.getQuote(note, entryUri, resolver);
|
||||
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
||||
if (quote === null) {
|
||||
processErrors.push('quoteUnavailable');
|
||||
}
|
||||
|
||||
if (quote && quote.userHost == null && quote.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
|
||||
}
|
||||
|
||||
// vote
|
||||
if (reply && reply.hasPoll) {
|
||||
|
|
@ -493,7 +488,7 @@ export class ApNoteService {
|
|||
await this.pollService.vote(actor, reply, index);
|
||||
|
||||
// リモートフォロワーにUpdate配信
|
||||
this.pollService.deliverQuestionUpdate(reply.id);
|
||||
this.pollService.deliverQuestionUpdate(reply);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
@ -516,7 +511,7 @@ export class ApNoteService {
|
|||
files,
|
||||
reply,
|
||||
renote: quote ?? null,
|
||||
processErrors,
|
||||
processErrors: processErrors.length > 0 ? processErrors : null,
|
||||
name: note.name,
|
||||
cw,
|
||||
text,
|
||||
|
|
@ -537,7 +532,7 @@ export class ApNoteService {
|
|||
this.logger.info('The note is already inserted while creating itself, reading again');
|
||||
const duplicate = await this.fetchNote(value);
|
||||
if (!duplicate) {
|
||||
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${noteUri}`);
|
||||
throw new IdentifiableError('39c328e1-e829-458b-bfc9-65dcd513d1f8', `failed to update note ${entryUri}: the note update failed with duplication error even when there is no duplication. This is likely a bug.`);
|
||||
}
|
||||
return duplicate;
|
||||
}
|
||||
|
|
@ -550,29 +545,30 @@ export class ApNoteService {
|
|||
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
|
||||
*/
|
||||
@bindThis
|
||||
public async resolveNote(value: string | IObject, options: { sentFrom?: URL, resolver?: Resolver } = {}): Promise<MiNote | null> {
|
||||
public async resolveNote(value: string | IObject, options: { sentFrom?: string, resolver?: Resolver } = {}): Promise<MiNote | null> {
|
||||
const uri = getApId(value);
|
||||
|
||||
if (!this.utilityService.isFederationAllowedUri(uri)) {
|
||||
throw new StatusError(`blocked host: ${uri}`, 451, 'blocked host');
|
||||
throw new IdentifiableError('04620a7e-044e-45ce-b72c-10e1bdc22e69', `failed to resolve note ${uri}: host is blocked`);
|
||||
}
|
||||
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
const exist = await this.fetchNote(uri);
|
||||
if (exist) return exist;
|
||||
//#endregion
|
||||
|
||||
// Bail if local URI doesn't exist
|
||||
if (this.utilityService.isUriLocal(uri)) {
|
||||
throw new IdentifiableError('cbac7358-23f2-4c70-833e-cffb4bf77913', `failed to resolve note ${uri}: URL is local and does not exist`);
|
||||
}
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
try {
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
const exist = await this.fetchNote(uri);
|
||||
if (exist) return exist;
|
||||
//#endregion
|
||||
|
||||
if (this.utilityService.isUriLocal(uri)) {
|
||||
throw new StatusError(`cannot resolve local note: ${uri}`, 400, 'cannot resolve local note');
|
||||
}
|
||||
|
||||
// リモートサーバーからフェッチしてきて登録
|
||||
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
||||
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
||||
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
|
||||
// Optimization: we can avoid re-fetching the value *if and only if* it matches the host authority that it was sent from.
|
||||
// Instances can create any object within their host authority, but anything outside of that MUST be untrusted.
|
||||
const haveSameAuthority = options.sentFrom && this.apUtilityService.haveSameAuthority(options.sentFrom, uri);
|
||||
const createFrom = haveSameAuthority ? value : uri;
|
||||
return await this.createNote(createFrom, undefined, options.resolver, true);
|
||||
} finally {
|
||||
unlock();
|
||||
|
|
@ -649,9 +645,29 @@ export class ApNoteService {
|
|||
*/
|
||||
private async getQuote(note: IPost, entryUri: string, resolver: Resolver): Promise<MiNote | null | undefined> {
|
||||
const quoteUris = new Set<string>();
|
||||
if (note._misskey_quote) quoteUris.add(note._misskey_quote);
|
||||
if (note.quoteUrl) quoteUris.add(note.quoteUrl);
|
||||
if (note.quoteUri) quoteUris.add(note.quoteUri);
|
||||
if (note._misskey_quote && typeof(note._misskey_quote as unknown) === 'string') quoteUris.add(note._misskey_quote);
|
||||
if (note.quoteUrl && typeof(note.quoteUrl as unknown) === 'string') quoteUris.add(note.quoteUrl);
|
||||
if (note.quoteUri && typeof(note.quoteUri as unknown) === 'string') quoteUris.add(note.quoteUri);
|
||||
|
||||
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
||||
if (note.quote && typeof(note.quote as unknown) === 'string') quoteUris.add(note.quote);
|
||||
|
||||
// https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md
|
||||
const tags = toArray(note.tag).filter(tag => typeof(tag) === 'object' && isLink(tag));
|
||||
for (const tag of tags) {
|
||||
if (!tag.href || typeof (tag.href as unknown) !== 'string') continue;
|
||||
|
||||
const mediaTypes = toArray(tag.mediaType);
|
||||
if (
|
||||
!mediaTypes.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"') &&
|
||||
!mediaTypes.includes('application/activity+json')
|
||||
) continue;
|
||||
|
||||
const rels = toArray(tag.rel);
|
||||
if (!rels.includes('https://misskey-hub.net/ns#_misskey_quote')) continue;
|
||||
|
||||
quoteUris.add(tag.href);
|
||||
}
|
||||
|
||||
// No quote, return undefined
|
||||
if (quoteUris.size < 1) return undefined;
|
||||
|
|
@ -670,18 +686,13 @@ export class ApNoteService {
|
|||
const quote = await this.resolveNote(uri, { resolver });
|
||||
|
||||
if (quote == null) {
|
||||
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": request error`);
|
||||
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": fetch failed`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return quote;
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}":`, e);
|
||||
} else {
|
||||
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${e}`);
|
||||
}
|
||||
|
||||
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${renderInlineError(e)}`);
|
||||
return isRetryableError(e);
|
||||
}
|
||||
};
|
||||
|
|
@ -699,10 +710,95 @@ export class ApNoteService {
|
|||
// Permanent error - return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and saves all media attachments from the provided note.
|
||||
* Returns an array of all the created files.
|
||||
*/
|
||||
private async getAttachments(note: IPost, actor: MiRemoteUser): Promise<{ files: MiDriveFile[], hasFileError: boolean }> {
|
||||
const attachments = new Map<string, IApDocument & { url: string }>();
|
||||
|
||||
// Extract inline media from HTML content.
|
||||
// Don't use source.content, _misskey_content, or anything else because those aren't HTML.
|
||||
const htmlContent = getContentByType(note, 'text/html', true);
|
||||
if (htmlContent) {
|
||||
for (const attach of extractMediaFromHtml(htmlContent)) {
|
||||
if (hasUrl(attach)) {
|
||||
attachments.set(attach.url, attach);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract inline media from MFM / markdown content.
|
||||
const mfmContent =
|
||||
getContentByType(note, 'text/x.misskeymarkdown') ??
|
||||
getContentByType(note, 'text/markdown');
|
||||
if (mfmContent) {
|
||||
for (const attach of extractMediaFromMfm(mfmContent)) {
|
||||
if (hasUrl(attach)) {
|
||||
attachments.set(attach.url, attach);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
|
||||
const icon = getBestIcon(note);
|
||||
if (icon) {
|
||||
if (hasUrl(icon)) {
|
||||
attachments.set(icon.url, icon);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate AP attachments last, to overwrite any "fallback" elements that may have been inlined in HTML.
|
||||
// AP attachments should be considered canonical.
|
||||
for (const attach of toArray(note.attachment)) {
|
||||
if (hasUrl(attach)) {
|
||||
attachments.set(attach.url, attach);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve all files w/ concurrency 2.
|
||||
// This prevents one big file from blocking the others.
|
||||
const limiter = promiseLimit<MiDriveFile | null>(2);
|
||||
const results = await Promise
|
||||
.all(Array
|
||||
.from(attachments.values())
|
||||
.map(attach => limiter(async () => {
|
||||
attach.sensitive ??= note.sensitive;
|
||||
return await this.resolveImage(actor, attach);
|
||||
})));
|
||||
|
||||
// Process results
|
||||
let hasFileError = false;
|
||||
const files: MiDriveFile[] = [];
|
||||
for (const result of results) {
|
||||
if (result != null) {
|
||||
files.push(result);
|
||||
} else {
|
||||
hasFileError = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { files, hasFileError };
|
||||
}
|
||||
|
||||
private async resolveImage(actor: MiRemoteUser, attachment: IApDocument & { url: string }): Promise<MiDriveFile | null> {
|
||||
try {
|
||||
return await this.apImageService.resolveImage(actor, attachment);
|
||||
} catch (err) {
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.warn(`Temporary failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
|
||||
throw err;
|
||||
} else {
|
||||
this.logger.warn(`Permanent failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getBestIcon(note: IObject): IObject | null {
|
||||
const icons: IObject[] = toArray(note.icon);
|
||||
function getBestIcon(note: IObject): IApDocument | null {
|
||||
const icons: IApDocument[] = toArray(note.icon);
|
||||
if (icons.length < 2) {
|
||||
return icons[0] ?? null;
|
||||
}
|
||||
|
|
@ -718,3 +814,8 @@ function getBestIcon(note: IObject): IObject | null {
|
|||
return best;
|
||||
}, null as IApDocument | null) ?? null;
|
||||
}
|
||||
|
||||
// Need this to make TypeScript happy...
|
||||
function hasUrl<T extends IObject>(object: T): object is T & { url: string } {
|
||||
return typeof(object.url) === 'string';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { AbortError } from 'node-fetch';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
|
||||
|
|
@ -32,7 +31,6 @@ import type UsersChart from '@/core/chart/charts/users.js';
|
|||
import type InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import type { HashtagService } from '@/core/HashtagService.js';
|
||||
import { MiUserNotePining } from '@/models/UserNotePining.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import type { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
|
@ -40,6 +38,13 @@ import { RoleService } from '@/core/RoleService.js';
|
|||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { verifyFieldLinks } from '@/misc/verify-field-link.js';
|
||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
|
|
@ -52,12 +57,15 @@ import type { ApImageService } from './ApImageService.js';
|
|||
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
|
||||
|
||||
const nameLength = 128;
|
||||
const summaryLength = 2048;
|
||||
|
||||
type Field = Record<'name' | 'value', string>;
|
||||
|
||||
@Injectable()
|
||||
export class ApPersonService implements OnModuleInit {
|
||||
export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||
// Moved from ApDbResolverService
|
||||
private readonly publicKeyByKeyIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
private readonly publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
|
||||
private utilityService: UtilityService;
|
||||
private userEntityService: UserEntityService;
|
||||
private driveFileEntityService: DriveFileEntityService;
|
||||
|
|
@ -107,6 +115,8 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
private roleService: RoleService,
|
||||
private readonly apUtilityService: ApUtilityService,
|
||||
private readonly httpRequestService: HttpRequestService,
|
||||
private readonly appLockService: AppLockService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +142,10 @@ export class ApPersonService implements OnModuleInit {
|
|||
this.logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
||||
onApplicationShutdown(): void {
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and convert to actor object
|
||||
* @param x Fetched object
|
||||
|
|
@ -139,85 +153,88 @@ export class ApPersonService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
private validateActor(x: IObject, uri: string): IActor {
|
||||
const expectHost = this.utilityService.punyHostPSLDomain(uri);
|
||||
const parsedUri = this.utilityService.assertUrl(uri);
|
||||
const expectHost = this.utilityService.punyHostPSLDomain(parsedUri);
|
||||
|
||||
// Validate type
|
||||
if (!isActor(x)) {
|
||||
throw new UnrecoverableError(`invalid Actor type '${x.type}' in ${uri}`);
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: unknown type '${x.type}'`);
|
||||
}
|
||||
|
||||
if (!(typeof x.id === 'string' && x.id.length > 0)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id type`);
|
||||
// Validate id
|
||||
if (!x.id) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: missing id`);
|
||||
}
|
||||
if (typeof(x.id) !== 'string') {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type ${typeof(x.id)}`);
|
||||
}
|
||||
const parsedId = this.utilityService.assertUrl(x.id);
|
||||
const idHost = this.utilityService.punyHostPSLDomain(parsedId);
|
||||
if (idHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong host in id ${x.id} (got ${parsedId}, expected ${expectHost})`);
|
||||
}
|
||||
|
||||
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox type`);
|
||||
// Validate inbox
|
||||
this.apUtilityService.sanitizeInlineObject(x, 'inbox', parsedUri, expectHost);
|
||||
if (!x.inbox || typeof(x.inbox) !== 'string') {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: missing or invalid inbox ${x.inbox}`);
|
||||
}
|
||||
|
||||
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
|
||||
if (inboxHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox ${inboxHost}`);
|
||||
// Sanitize sharedInbox
|
||||
this.apUtilityService.sanitizeInlineObject(x, 'sharedInbox', parsedUri, expectHost);
|
||||
|
||||
// Sanitize endpoints object
|
||||
if (typeof(x.endpoints) === 'object') {
|
||||
x.endpoints = {
|
||||
sharedInbox: x.endpoints.sharedInbox,
|
||||
};
|
||||
} else {
|
||||
x.endpoints = undefined;
|
||||
}
|
||||
|
||||
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
||||
if (sharedInboxObject != null) {
|
||||
const sharedInbox = getApId(sharedInboxObject);
|
||||
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong shared inbox ${sharedInbox}`);
|
||||
// Sanitize endpoints.sharedInbox
|
||||
if (x.endpoints) {
|
||||
this.apUtilityService.sanitizeInlineObject(x.endpoints, 'sharedInbox', parsedUri, expectHost, 'endpoints.');
|
||||
|
||||
if (!x.endpoints.sharedInbox) {
|
||||
x.endpoints = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
|
||||
const xCollection = (x as IActor)[collection];
|
||||
if (xCollection != null) {
|
||||
const collectionUri = getApId(xCollection);
|
||||
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
||||
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong ${collection} ${collectionUri}`);
|
||||
}
|
||||
} else if (collectionUri != null) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`);
|
||||
}
|
||||
}
|
||||
// Sanitize collections
|
||||
for (const collection of ['outbox', 'followers', 'following', 'featured'] as const) {
|
||||
this.apUtilityService.sanitizeInlineObject(x, collection, parsedUri, expectHost);
|
||||
}
|
||||
|
||||
// Validate username
|
||||
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong username`);
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong username`);
|
||||
}
|
||||
|
||||
// Sanitize name
|
||||
// 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.
|
||||
if (x.name) {
|
||||
if (!(typeof x.name === 'string' && x.name.length > 0)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong name`);
|
||||
}
|
||||
x.name = truncate(x.name, nameLength);
|
||||
} else if (x.name === '') {
|
||||
// Mastodon emits empty string when the name is not set.
|
||||
if (!x.name) {
|
||||
x.name = undefined;
|
||||
}
|
||||
if (x.summary) {
|
||||
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong summary`);
|
||||
}
|
||||
x.summary = truncate(x.summary, summaryLength);
|
||||
} else if (typeof(x.name) !== 'string') {
|
||||
this.logger.warn(`Excluding name from object ${uri}: incorrect type ${typeof(x)}`);
|
||||
x.name = undefined;
|
||||
} else {
|
||||
x.name = truncate(x.name, nameLength);
|
||||
}
|
||||
|
||||
const idHost = this.utilityService.punyHostPSLDomain(x.id);
|
||||
if (idHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id ${x.id}`);
|
||||
// Sanitize summary
|
||||
if (!x.summary) {
|
||||
x.summary = undefined;
|
||||
} else if (typeof(x.summary) !== 'string') {
|
||||
this.logger.warn(`Excluding summary from object ${uri}: incorrect type ${typeof(x)}`);
|
||||
} else {
|
||||
x.summary = truncate(x.summary, this.config.maxRemoteBioLength);
|
||||
}
|
||||
|
||||
if (x.publicKey) {
|
||||
if (typeof x.publicKey.id !== 'string') {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id type`);
|
||||
}
|
||||
|
||||
const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id);
|
||||
if (publicKeyIdHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id ${x.publicKey.id}`);
|
||||
}
|
||||
}
|
||||
// Sanitize publicKey
|
||||
this.apUtilityService.sanitizeInlineObject(x, 'publicKey', parsedUri, expectHost);
|
||||
|
||||
return x;
|
||||
}
|
||||
|
|
@ -253,8 +270,6 @@ export class ApPersonService implements OnModuleInit {
|
|||
}
|
||||
|
||||
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> {
|
||||
if (user == null) throw new Error('failed to create user: user is null');
|
||||
|
||||
const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => {
|
||||
// icon and image may be arrays
|
||||
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
|
||||
|
|
@ -307,19 +322,23 @@ export class ApPersonService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||
if (typeof uri !== 'string') throw new UnrecoverableError(`uri is not string: ${uri}`);
|
||||
if (typeof uri !== 'string') throw new UnrecoverableError(`failed to create user ${uri}: input is not string`);
|
||||
|
||||
const host = this.utilityService.punyHost(uri);
|
||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||
throw new StatusError(`cannot resolve local user: ${uri}`, 400, 'cannot resolve local user');
|
||||
throw new UnrecoverableError(`failed to create user ${uri}: URI is local`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
return await this._createPerson(uri, resolver);
|
||||
}
|
||||
|
||||
const object = await resolver.resolve(uri);
|
||||
if (object.id == null) throw new UnrecoverableError(`null object.id in ${uri}`);
|
||||
private async _createPerson(value: string | IObject, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||
const uri = getApId(value);
|
||||
const host = this.utilityService.punyHost(uri);
|
||||
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(value);
|
||||
const person = this.validateActor(object, uri);
|
||||
|
||||
this.logger.info(`Creating the Person: ${person.id}`);
|
||||
|
|
@ -332,14 +351,16 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||
[
|
||||
this.isPublicCollection(person.following, resolver),
|
||||
this.isPublicCollection(person.followers, resolver),
|
||||
this.isPublicCollection(person.following, resolver, uri),
|
||||
this.isPublicCollection(person.followers, resolver, uri),
|
||||
].map((p): Promise<'public' | 'private'> => p
|
||||
.then(isPublic => isPublic ? 'public' : 'private')
|
||||
.catch(err => {
|
||||
if (!(err instanceof StatusError) || err.isRetryable) {
|
||||
this.logger.error('error occurred while fetching following/followers collection', { stack: err });
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.error(`error occurred while fetching following/followers collection: ${renderInlineError(err)}`);
|
||||
}
|
||||
|
||||
return 'private';
|
||||
}),
|
||||
),
|
||||
|
|
@ -348,28 +369,43 @@ export class ApPersonService implements OnModuleInit {
|
|||
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
|
||||
|
||||
if (person.id == null) {
|
||||
throw new UnrecoverableError(`Refusing to create person without id: ${uri}`);
|
||||
throw new UnrecoverableError(`failed to create user ${uri}: missing ID`);
|
||||
}
|
||||
|
||||
const url = this.apUtilityService.findBestObjectUrl(person);
|
||||
|
||||
const profileUrls = url ? [url, person.id] : [person.id];
|
||||
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
|
||||
|
||||
// Create user
|
||||
let user: MiRemoteUser | null = null;
|
||||
let publicKey: MiUserPublickey | null = null;
|
||||
|
||||
//#region カスタム絵文字取得
|
||||
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host)
|
||||
.then(_emojis => _emojis.map(emoji => emoji.name))
|
||||
.catch(err => {
|
||||
this.logger.error('error occurred while fetching user emojis', { stack: err });
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.error(`error occurred while fetching user emojis: ${renderInlineError(err)}`);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region resolve counts
|
||||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
||||
const outboxcollection = await _resolver.resolveCollection(person.outbox).catch(() => { return null; });
|
||||
const followerscollection = await _resolver.resolveCollection(person.followers!).catch(() => { return null; });
|
||||
const followingcollection = await _resolver.resolveCollection(person.following!).catch(() => { return null; });
|
||||
const outboxCollection = person.outbox
|
||||
? await resolver.resolveCollection(person.outbox, true, uri).catch(() => { return null; })
|
||||
: null;
|
||||
const followersCollection = person.followers
|
||||
? await resolver.resolveCollection(person.followers, true, uri).catch(() => { return null; })
|
||||
: null;
|
||||
const followingCollection = person.following
|
||||
? await resolver.resolveCollection(person.following, true, uri).catch(() => { return null; })
|
||||
: null;
|
||||
|
||||
// Register the instance first, to avoid FK errors
|
||||
await this.federatedInstanceService.fetchOrRegister(host);
|
||||
|
||||
try {
|
||||
// Start transaction
|
||||
|
|
@ -396,9 +432,9 @@ export class ApPersonService implements OnModuleInit {
|
|||
host,
|
||||
inbox: person.inbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||
notesCount: outboxcollection?.totalItems ?? 0,
|
||||
followersCount: followerscollection?.totalItems ?? 0,
|
||||
followingCount: followingcollection?.totalItems ?? 0,
|
||||
notesCount: outboxCollection?.totalItems ?? 0,
|
||||
followersCount: followersCollection?.totalItems ?? 0,
|
||||
followingCount: followingCollection?.totalItems ?? 0,
|
||||
followersUri: person.followers ? getApId(person.followers) : undefined,
|
||||
featured: person.featured ? getApId(person.featured) : undefined,
|
||||
uri: person.id,
|
||||
|
|
@ -410,14 +446,19 @@ export class ApPersonService implements OnModuleInit {
|
|||
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
||||
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
||||
emojis,
|
||||
attributionDomains: Array.isArray(person.attributionDomains)
|
||||
? person.attributionDomains
|
||||
.filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128)
|
||||
.slice(0, 32)
|
||||
: [],
|
||||
})) as MiRemoteUser;
|
||||
|
||||
let _description: string | null = null;
|
||||
|
||||
if (person._misskey_summary) {
|
||||
_description = truncate(person._misskey_summary, summaryLength);
|
||||
_description = truncate(person._misskey_summary, this.config.maxRemoteBioLength);
|
||||
} else if (person.summary) {
|
||||
_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag);
|
||||
_description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag);
|
||||
}
|
||||
|
||||
await transactionalEntityManager.save(new MiUserProfile({
|
||||
|
|
@ -426,6 +467,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null,
|
||||
url,
|
||||
fields,
|
||||
verifiedLinks,
|
||||
followingVisibility,
|
||||
followersVisibility,
|
||||
birthday: bday?.[0] ?? null,
|
||||
|
|
@ -435,7 +477,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
}));
|
||||
|
||||
if (person.publicKey) {
|
||||
await transactionalEntityManager.save(new MiUserPublickey({
|
||||
publicKey = await transactionalEntityManager.save(new MiUserPublickey({
|
||||
userId: user.id,
|
||||
keyId: person.publicKey.id,
|
||||
keyPem: person.publicKey.publicKeyPem.trim(),
|
||||
|
|
@ -450,8 +492,9 @@ export class ApPersonService implements OnModuleInit {
|
|||
if (u == null) throw new UnrecoverableError(`already registered a user with conflicting data: ${uri}`);
|
||||
|
||||
user = u as MiRemoteUser;
|
||||
publicKey = await this.userPublickeysRepository.findOneBy({ userId: user.id });
|
||||
} else {
|
||||
this.logger.error(e instanceof Error ? e : new Error(e as string));
|
||||
this.logger.error(`Error creating Person ${uri}: ${renderInlineError(e)}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
@ -461,6 +504,11 @@ export class ApPersonService implements OnModuleInit {
|
|||
// Register to the cache
|
||||
this.cacheService.uriPersonCache.set(user.uri, user);
|
||||
|
||||
// Register public key to the cache.
|
||||
// Value may be null, which indicates that the user has no defined key. (optimization)
|
||||
this.publicKeyByUserIdCache.set(user.id, publicKey);
|
||||
if (publicKey) this.publicKeyByKeyIdCache.set(publicKey.keyId, publicKey);
|
||||
|
||||
// Register host
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
|
|
@ -486,11 +534,19 @@ export class ApPersonService implements OnModuleInit {
|
|||
// Register to the cache
|
||||
this.cacheService.uriPersonCache.set(user.uri, user);
|
||||
} catch (err) {
|
||||
this.logger.error('error occurred while fetching user avatar/banner', { stack: err });
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.error(`error occurred while fetching user avatar/banner: ${renderInlineError(err)}`);
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err));
|
||||
await this.updateFeatured(user.id, resolver).catch(err => {
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
|
@ -507,7 +563,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
|
||||
if (typeof uri !== 'string') throw new UnrecoverableError('uri is not string');
|
||||
if (typeof uri !== 'string') throw new UnrecoverableError(`failed to update user ${uri}: input is not string`);
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (this.utilityService.isUriLocal(uri)) return;
|
||||
|
|
@ -517,7 +573,6 @@ export class ApPersonService implements OnModuleInit {
|
|||
if (exist === null) return;
|
||||
//#endregion
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = hint ?? await resolver.resolve(uri);
|
||||
|
|
@ -527,8 +582,11 @@ export class ApPersonService implements OnModuleInit {
|
|||
this.logger.info(`Updating the Person: ${person.id}`);
|
||||
|
||||
// カスタム絵文字取得
|
||||
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => {
|
||||
this.logger.info(`extractEmojis: ${e}`);
|
||||
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(err => {
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.error(`error occurred while fetching user emojis: ${renderInlineError(err)}`);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
|
|
@ -540,16 +598,18 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||
[
|
||||
this.isPublicCollection(person.following, resolver),
|
||||
this.isPublicCollection(person.followers, resolver),
|
||||
this.isPublicCollection(person.following, resolver, exist.uri),
|
||||
this.isPublicCollection(person.followers, resolver, exist.uri),
|
||||
].map((p): Promise<'public' | 'private' | undefined> => p
|
||||
.then(isPublic => isPublic ? 'public' : 'private')
|
||||
.catch(err => {
|
||||
if (!(err instanceof StatusError) || err.isRetryable) {
|
||||
this.logger.error('error occurred while fetching following/followers collection', { stack: err });
|
||||
// Do not update the visibiility on transient errors.
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.error(`error occurred while fetching following/followers collection: ${renderInlineError(err)}`);
|
||||
// Do not update the visibility on transient errors.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return 'private';
|
||||
}),
|
||||
),
|
||||
|
|
@ -558,17 +618,20 @@ export class ApPersonService implements OnModuleInit {
|
|||
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
|
||||
|
||||
if (person.id == null) {
|
||||
throw new UnrecoverableError(`Refusing to update person without id: ${uri}`);
|
||||
throw new UnrecoverableError(`failed to update user ${uri}: missing ID`);
|
||||
}
|
||||
|
||||
const url = this.apUtilityService.findBestObjectUrl(person);
|
||||
|
||||
const profileUrls = url ? [url, person.id] : [person.id];
|
||||
const verifiedLinks = await verifyFieldLinks(fields, profileUrls, this.httpRequestService);
|
||||
|
||||
const updates = {
|
||||
lastFetchedAt: new Date(),
|
||||
inbox: person.inbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||
followersUri: person.followers ? getApId(person.followers) : undefined,
|
||||
featured: person.featured,
|
||||
featured: person.featured ? getApId(person.featured) : undefined,
|
||||
emojis: emojiNames,
|
||||
name: truncate(person.name, nameLength),
|
||||
tags,
|
||||
|
|
@ -584,7 +647,20 @@ export class ApPersonService implements OnModuleInit {
|
|||
// We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic.
|
||||
hideOnlineStatus: person.hideOnlineStatus !== false,
|
||||
isExplorable: person.discoverable !== false,
|
||||
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))),
|
||||
attributionDomains: Array.isArray(person.attributionDomains)
|
||||
? person.attributionDomains
|
||||
.filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128)
|
||||
.slice(0, 32)
|
||||
: [],
|
||||
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(err => {
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.error(`error occurred while fetching user avatar/banner: ${renderInlineError(err)}`);
|
||||
}
|
||||
|
||||
// Can't return null or destructuring operator will break
|
||||
return {};
|
||||
})),
|
||||
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||
|
||||
const moving = ((): boolean => {
|
||||
|
|
@ -608,26 +684,46 @@ export class ApPersonService implements OnModuleInit {
|
|||
if (moving) updates.movedAt = new Date();
|
||||
|
||||
// Update user
|
||||
await this.usersRepository.update(exist.id, updates);
|
||||
if (!(await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates)).affected) {
|
||||
return `skip: user ${exist.id} is deleted`;
|
||||
}
|
||||
|
||||
if (person.publicKey) {
|
||||
await this.userPublickeysRepository.update({ userId: exist.id }, {
|
||||
const publicKey = new MiUserPublickey({
|
||||
userId: exist.id,
|
||||
keyId: person.publicKey.id,
|
||||
keyPem: person.publicKey.publicKeyPem,
|
||||
});
|
||||
|
||||
// Create or update key
|
||||
await this.userPublickeysRepository.save(publicKey);
|
||||
|
||||
this.publicKeyByKeyIdCache.set(person.publicKey.id, publicKey);
|
||||
this.publicKeyByUserIdCache.set(exist.id, publicKey);
|
||||
} else {
|
||||
const existingPublicKey = await this.userPublickeysRepository.findOneBy({ userId: exist.id });
|
||||
if (existingPublicKey) {
|
||||
// Delete key
|
||||
await this.userPublickeysRepository.delete({ userId: exist.id });
|
||||
this.publicKeyByKeyIdCache.delete(existingPublicKey.keyId);
|
||||
}
|
||||
|
||||
// Null indicates that the user has no key. (optimization)
|
||||
this.publicKeyByUserIdCache.set(exist.id, null);
|
||||
}
|
||||
|
||||
let _description: string | null = null;
|
||||
|
||||
if (person._misskey_summary) {
|
||||
_description = truncate(person._misskey_summary, summaryLength);
|
||||
_description = truncate(person._misskey_summary, this.config.maxRemoteBioLength);
|
||||
} else if (person.summary) {
|
||||
_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag);
|
||||
_description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag);
|
||||
}
|
||||
|
||||
await this.userProfilesRepository.update({ userId: exist.id }, {
|
||||
url,
|
||||
fields,
|
||||
verifiedLinks,
|
||||
description: _description,
|
||||
followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null,
|
||||
followingVisibility,
|
||||
|
|
@ -643,12 +739,24 @@ export class ApPersonService implements OnModuleInit {
|
|||
this.hashtagService.updateUsertags(exist, tags);
|
||||
|
||||
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
|
||||
await this.followingsRepository.update(
|
||||
{ followerId: exist.id },
|
||||
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null },
|
||||
);
|
||||
if (exist.inbox !== person.inbox || exist.sharedInbox !== (person.sharedInbox ?? person.endpoints?.sharedInbox)) {
|
||||
await this.followingsRepository.update(
|
||||
{ followerId: exist.id },
|
||||
{
|
||||
followerInbox: person.inbox,
|
||||
followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
|
||||
await this.cacheService.refreshFollowRelationsFor(exist.id);
|
||||
}
|
||||
|
||||
await this.updateFeatured(exist.id, resolver).catch(err => {
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const updated = { ...exist, ...updates };
|
||||
|
||||
|
|
@ -669,7 +777,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
return result;
|
||||
})
|
||||
.catch(e => {
|
||||
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e });
|
||||
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri}): ${renderInlineError(e)}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -683,16 +791,34 @@ export class ApPersonService implements OnModuleInit {
|
|||
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
|
||||
*/
|
||||
@bindThis
|
||||
public async resolvePerson(uri: string, resolver?: Resolver): Promise<MiLocalUser | MiRemoteUser> {
|
||||
public async resolvePerson(value: string | IObject, resolver?: Resolver, sentFrom?: string): Promise<MiLocalUser | MiRemoteUser> {
|
||||
const uri = getApId(value);
|
||||
|
||||
if (!this.utilityService.isFederationAllowedUri(uri)) {
|
||||
throw new IdentifiableError('590719b3-f51f-48a9-8e7d-6f559ad00e5d', `failed to resolve person ${uri}: host is blocked`);
|
||||
}
|
||||
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
const exist = await this.fetchPerson(uri);
|
||||
if (exist) return exist;
|
||||
//#endregion
|
||||
|
||||
// リモートサーバーからフェッチしてきて登録
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
return await this.createPerson(uri, resolver);
|
||||
// Bail if local URI doesn't exist
|
||||
if (this.utilityService.isUriLocal(uri)) {
|
||||
throw new IdentifiableError('efb573fd-6b9e-4912-9348-a02f5603df4f', `failed to resolve person ${uri}: URL is local and does not exist`);
|
||||
}
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
try {
|
||||
// Optimization: we can avoid re-fetching the value *if and only if* it matches the host authority that it was sent from.
|
||||
// Instances can create any object within their host authority, but anything outside of that MUST be untrusted.
|
||||
const haveSameAuthority = sentFrom && this.apUtilityService.haveSameAuthority(sentFrom, uri);
|
||||
const createFrom = haveSameAuthority ? value : uri;
|
||||
return await this._createPerson(createFrom, resolver);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -714,7 +840,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise<void> {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: userId });
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: userId, isDeleted: false });
|
||||
if (!this.userEntityService.isRemoteUser(user)) return;
|
||||
if (!user.featured) return;
|
||||
|
||||
|
|
@ -723,16 +849,17 @@ export class ApPersonService implements OnModuleInit {
|
|||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
||||
|
||||
// Resolve to (Ordered)Collection Object
|
||||
const collection = await _resolver.resolveCollection(user.featured).catch(err => {
|
||||
if (err instanceof AbortError || err instanceof StatusError) {
|
||||
this.logger.warn(`Failed to update featured notes: ${err.name}: ${err.message}`);
|
||||
} else {
|
||||
this.logger.error('Failed to update featured notes:', err);
|
||||
const collection = user.featured ? await _resolver.resolveCollection(user.featured, true, user.uri).catch(err => {
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.warn(`Failed to update featured notes: ${renderInlineError(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}) : null;
|
||||
if (!collection) return;
|
||||
|
||||
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`featured ${user.featured} is not Collection or OrderedCollection in ${user.uri}`);
|
||||
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`failed to update user ${user.uri}: featured ${user.featured} is not Collection or OrderedCollection`);
|
||||
|
||||
// Resolve to Object(may be Note) arrays
|
||||
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
|
||||
|
|
@ -746,7 +873,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
.slice(0, maxPinned)
|
||||
.map(item => limit(() => this.apNoteService.resolveNote(item, {
|
||||
resolver: _resolver,
|
||||
sentFrom: new URL(user.uri),
|
||||
sentFrom: user.uri,
|
||||
}))));
|
||||
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
|
|
@ -815,14 +942,50 @@ export class ApPersonService implements OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise<boolean> {
|
||||
private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver, sentFrom: string): Promise<boolean> {
|
||||
if (collection) {
|
||||
const resolved = await resolver.resolveCollection(collection);
|
||||
if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) {
|
||||
return true;
|
||||
const resolved = await resolver.resolveCollection(collection, true, sentFrom).catch(() => null);
|
||||
if (resolved) {
|
||||
if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findPublicKeyByUserId(userId: string): Promise<MiUserPublickey | null> {
|
||||
const publicKey = this.publicKeyByUserIdCache.get(userId) ?? await this.userPublickeysRepository.findOneBy({ userId });
|
||||
|
||||
// This can technically keep a key cached "forever" if it's used enough, but that's ok.
|
||||
// We can never have stale data because the publicKey caches are coherent. (cache updates whenever data changes)
|
||||
if (publicKey) {
|
||||
this.publicKeyByUserIdCache.set(publicKey.userId, publicKey);
|
||||
this.publicKeyByKeyIdCache.set(publicKey.keyId, publicKey);
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findPublicKeyByKeyId(keyId: string): Promise<MiUserPublickey | null> {
|
||||
const publicKey = this.publicKeyByKeyIdCache.get(keyId) ?? await this.userPublickeysRepository.findOneBy({ keyId });
|
||||
|
||||
// This can technically keep a key cached "forever" if it's used enough, but that's ok.
|
||||
// We can never have stale data because the publicKey caches are coherent. (cache updates whenever data changes)
|
||||
if (publicKey) {
|
||||
this.publicKeyByUserIdCache.set(publicKey.userId, publicKey);
|
||||
this.publicKeyByKeyIdCache.set(publicKey.keyId, publicKey);
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.publicKeyByUserIdCache.dispose();
|
||||
this.publicKeyByKeyIdCache.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@ export class ApQuestionService {
|
|||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
const question = await resolver.resolve(value);
|
||||
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
||||
|
||||
if (!isQuestion(question)) throw new UnrecoverableError(`object ${getApType(question)} is not a Question: ${uri}`);
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue