mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-23 09:54:06 +02:00
Merge branch 'develop' into mahjong
This commit is contained in:
@@ -7,11 +7,13 @@ import { Global, Inject, Module } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MeiliSearch } from 'meilisearch';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
import { DI } from './di-symbols.js';
|
||||
import { Config, loadConfig } from './config.js';
|
||||
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 type { Provider, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const $config: Provider = {
|
||||
@@ -31,7 +33,11 @@ const $db: Provider = {
|
||||
const $meilisearch: Provider = {
|
||||
provide: DI.meilisearch,
|
||||
useFactory: (config: Config) => {
|
||||
if (config.meilisearch) {
|
||||
if (config.fulltextSearch?.provider === 'meilisearch') {
|
||||
if (!config.meilisearch) {
|
||||
throw new Error('MeiliSearch is enabled but no configuration is provided');
|
||||
}
|
||||
|
||||
return new MeiliSearch({
|
||||
host: `${config.meilisearch.ssl ? 'https' : 'http'}://${config.meilisearch.host}:${config.meilisearch.port}`,
|
||||
apiKey: config.meilisearch.apiKey,
|
||||
@@ -78,11 +84,76 @@ const $redisForTimelines: Provider = {
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $redisForReactions: Provider = {
|
||||
provide: DI.redisForReactions,
|
||||
useFactory: (config: Config) => {
|
||||
return new Redis.Redis(config.redisForReactions);
|
||||
},
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $meta: Provider = {
|
||||
provide: DI.meta,
|
||||
useFactory: async (db: DataSource, redisForSub: Redis.Redis) => {
|
||||
const meta = await db.transaction(async transactionalEntityManager => {
|
||||
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
|
||||
const metas = await transactionalEntityManager.find(MiMeta, {
|
||||
order: {
|
||||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
const meta = metas[0];
|
||||
|
||||
if (meta) {
|
||||
return meta;
|
||||
} else {
|
||||
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
|
||||
const saved = await transactionalEntityManager
|
||||
.upsert(
|
||||
MiMeta,
|
||||
{
|
||||
id: 'x',
|
||||
},
|
||||
['id'],
|
||||
)
|
||||
.then((x) => transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0]));
|
||||
|
||||
return saved;
|
||||
}
|
||||
});
|
||||
|
||||
async function 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': {
|
||||
for (const key in body.after) {
|
||||
(meta as any)[key] = (body.after as any)[key];
|
||||
}
|
||||
meta.rootUser = null; // joinなカラムは通常取ってこないので
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redisForSub.on('message', onMessage);
|
||||
|
||||
return meta;
|
||||
},
|
||||
inject: [DI.db, DI.redisForSub],
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [RepositoryModule],
|
||||
providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines],
|
||||
exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, RepositoryModule],
|
||||
providers: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions],
|
||||
exports: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, RepositoryModule],
|
||||
})
|
||||
export class GlobalModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@@ -91,6 +162,7 @@ export class GlobalModule implements OnApplicationShutdown {
|
||||
@Inject(DI.redisForPub) private redisForPub: Redis.Redis,
|
||||
@Inject(DI.redisForSub) private redisForSub: Redis.Redis,
|
||||
@Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis,
|
||||
@Inject(DI.redisForReactions) private redisForReactions: Redis.Redis,
|
||||
) { }
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
@@ -103,6 +175,7 @@ export class GlobalModule implements OnApplicationShutdown {
|
||||
this.redisForPub.disconnect(),
|
||||
this.redisForSub.disconnect(),
|
||||
this.redisForTimelines.disconnect(),
|
||||
this.redisForReactions.disconnect(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,16 +68,22 @@ process.on('exit', code => {
|
||||
|
||||
//#endregion
|
||||
|
||||
if (cluster.isPrimary || envOption.disableClustering) {
|
||||
await masterMain();
|
||||
|
||||
if (!envOption.disableClustering) {
|
||||
if (cluster.isPrimary) {
|
||||
logger.info(`Start main process... pid: ${process.pid}`);
|
||||
await masterMain();
|
||||
ev.mount();
|
||||
} else if (cluster.isWorker) {
|
||||
logger.info(`Start worker process... pid: ${process.pid}`);
|
||||
await workerMain();
|
||||
} else {
|
||||
throw new Error('Unknown process type');
|
||||
}
|
||||
}
|
||||
|
||||
if (cluster.isWorker || envOption.disableClustering) {
|
||||
await workerMain();
|
||||
} else {
|
||||
// 非clusterの場合はMasterのみが起動するため、Workerの処理は行わない(cluster.isWorker === trueの状態でこのブロックに来ることはない)
|
||||
logger.info(`Start main process... pid: ${process.pid}`);
|
||||
await masterMain();
|
||||
ev.mount();
|
||||
}
|
||||
|
||||
readyRef.value = true;
|
||||
|
||||
@@ -91,25 +91,36 @@ export async function masterMain() {
|
||||
});
|
||||
}
|
||||
|
||||
if (envOption.disableClustering) {
|
||||
bootLogger.info(
|
||||
`mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`,
|
||||
);
|
||||
|
||||
if (!envOption.disableClustering) {
|
||||
// clusterモジュール有効時
|
||||
|
||||
if (envOption.onlyServer) {
|
||||
await server();
|
||||
// onlyServer かつ enableCluster な場合、メインプロセスはforkのみに制限する(listenしない)。
|
||||
// ワーカープロセス側でlistenすると、メインプロセスでポートへの着信を受け入れてワーカープロセスへの分配を行う動作をする。
|
||||
// そのため、メインプロセスでも直接listenするとポートの競合が発生して起動に失敗してしまう。
|
||||
// see: https://nodejs.org/api/cluster.html#cluster
|
||||
} else if (envOption.onlyQueue) {
|
||||
await jobQueue();
|
||||
} else {
|
||||
await server();
|
||||
await jobQueue();
|
||||
}
|
||||
} else {
|
||||
if (envOption.onlyServer) {
|
||||
// nop
|
||||
} else if (envOption.onlyQueue) {
|
||||
// nop
|
||||
} else {
|
||||
await server();
|
||||
}
|
||||
|
||||
await spawnWorkers(config.clusterLimit);
|
||||
} else {
|
||||
// clusterモジュール無効時
|
||||
|
||||
if (envOption.onlyServer) {
|
||||
await server();
|
||||
} else if (envOption.onlyQueue) {
|
||||
await jobQueue();
|
||||
} else {
|
||||
await server();
|
||||
await jobQueue();
|
||||
}
|
||||
}
|
||||
|
||||
if (envOption.onlyQueue) {
|
||||
|
||||
@@ -7,7 +7,8 @@ import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import type * as Sentry from '@sentry/node';
|
||||
import type * as SentryVue from '@sentry/vue';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
|
||||
type RedisOptionsSource = Partial<RedisOptions> & {
|
||||
@@ -49,6 +50,10 @@ type Source = {
|
||||
redisForPubsub?: RedisOptionsSource;
|
||||
redisForJobQueue?: RedisOptionsSource;
|
||||
redisForTimelines?: RedisOptionsSource;
|
||||
redisForReactions?: RedisOptionsSource;
|
||||
fulltextSearch?: {
|
||||
provider?: FulltextSearchProvider;
|
||||
};
|
||||
meilisearch?: {
|
||||
host: string;
|
||||
port: string;
|
||||
@@ -58,15 +63,23 @@ 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;
|
||||
|
||||
setupPassword?: string;
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
disallowExternalApRedirect?: boolean;
|
||||
|
||||
maxFileSize?: number;
|
||||
|
||||
@@ -96,6 +109,13 @@ type Source = {
|
||||
perUserNotificationsMaxCount?: number;
|
||||
deactivateAntennaThreshold?: number;
|
||||
pidFile: string;
|
||||
|
||||
logging?: {
|
||||
sql?: {
|
||||
disableQueryTruncation?: boolean,
|
||||
enableQueryParamLogging?: boolean,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
@@ -121,6 +141,9 @@ export type Config = {
|
||||
user: string;
|
||||
pass: string;
|
||||
}[] | undefined;
|
||||
fulltextSearch?: {
|
||||
provider?: FulltextSearchProvider;
|
||||
};
|
||||
meilisearch: {
|
||||
host: string;
|
||||
port: string;
|
||||
@@ -133,7 +156,8 @@ export type Config = {
|
||||
proxySmtp: string | undefined;
|
||||
proxyBypassHosts: string[] | undefined;
|
||||
allowedPrivateNetworks: string[] | undefined;
|
||||
maxFileSize: number | undefined;
|
||||
disallowExternalApRedirect: boolean;
|
||||
maxFileSize: number;
|
||||
clusterLimit: number | undefined;
|
||||
id: string;
|
||||
outgoingAddress: string | undefined;
|
||||
@@ -148,9 +172,16 @@ export type Config = {
|
||||
inboxJobMaxAttempts: number | undefined;
|
||||
proxyRemoteFiles: boolean | undefined;
|
||||
signToActivityPubGet: boolean | undefined;
|
||||
logging?: {
|
||||
sql?: {
|
||||
disableQueryTruncation?: boolean,
|
||||
enableQueryParamLogging?: boolean,
|
||||
}
|
||||
}
|
||||
|
||||
version: string;
|
||||
publishTarballInsteadOfProvideRepositoryUrl: boolean;
|
||||
setupPassword: string | undefined;
|
||||
host: string;
|
||||
hostname: string;
|
||||
scheme: string;
|
||||
@@ -160,8 +191,10 @@ export type Config = {
|
||||
authUrl: string;
|
||||
driveUrl: string;
|
||||
userAgent: string;
|
||||
clientEntry: string;
|
||||
clientManifestExists: boolean;
|
||||
frontendEntry: string;
|
||||
frontendManifestExists: boolean;
|
||||
frontendEmbedEntry: string;
|
||||
frontendEmbedManifestExists: boolean;
|
||||
mediaProxy: string;
|
||||
externalMediaProxyEnabled: boolean;
|
||||
videoThumbnailGenerator: string | null;
|
||||
@@ -169,14 +202,22 @@ export type Config = {
|
||||
redisForPubsub: RedisOptions & RedisOptionsSource;
|
||||
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
||||
redisForTimelines: RedisOptions & RedisOptionsSource;
|
||||
redisForReactions: 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;
|
||||
pidFile: string;
|
||||
};
|
||||
|
||||
export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
@@ -196,10 +237,16 @@ const path = process.env.MISSKEY_CONFIG_YML
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
|
||||
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
|
||||
const clientManifest = clientManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8'))
|
||||
|
||||
const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json');
|
||||
const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json');
|
||||
const frontendManifest = frontendManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8'))
|
||||
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
|
||||
const frontendEmbedManifest = frontendEmbedManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
|
||||
: { 'src/boot.ts': { file: 'src/boot.ts' } };
|
||||
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
|
||||
@@ -222,6 +269,7 @@ export function loadConfig(): Config {
|
||||
return {
|
||||
version,
|
||||
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||
setupPassword: config.setupPassword,
|
||||
url: url.origin,
|
||||
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
|
||||
socket: config.socket,
|
||||
@@ -238,11 +286,13 @@ export function loadConfig(): Config {
|
||||
db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass },
|
||||
dbReplications: config.dbReplications,
|
||||
dbSlaves: config.dbSlaves,
|
||||
fulltextSearch: config.fulltextSearch,
|
||||
meilisearch: config.meilisearch,
|
||||
redis,
|
||||
redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis,
|
||||
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
|
||||
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
|
||||
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
|
||||
sentryForBackend: config.sentryForBackend,
|
||||
sentryForFrontend: config.sentryForFrontend,
|
||||
id: config.id,
|
||||
@@ -250,7 +300,8 @@ export function loadConfig(): Config {
|
||||
proxySmtp: config.proxySmtp,
|
||||
proxyBypassHosts: config.proxyBypassHosts,
|
||||
allowedPrivateNetworks: config.allowedPrivateNetworks,
|
||||
maxFileSize: config.maxFileSize,
|
||||
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
|
||||
maxFileSize: config.maxFileSize ?? 262144000,
|
||||
clusterLimit: config.clusterLimit,
|
||||
outgoingAddress: config.outgoingAddress,
|
||||
outgoingAddressFamily: config.outgoingAddressFamily,
|
||||
@@ -270,12 +321,15 @@ export function loadConfig(): Config {
|
||||
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
|
||||
: null,
|
||||
userAgent: `Misskey/${version} (${config.url})`,
|
||||
clientEntry: clientManifest['src/_boot_.ts'],
|
||||
clientManifestExists: clientManifestExists,
|
||||
frontendEntry: frontendManifest['src/_boot_.ts'],
|
||||
frontendManifestExists: frontendManifestExists,
|
||||
frontendEmbedEntry: frontendEmbedManifest['src/boot.ts'],
|
||||
frontendEmbedManifestExists: frontendEmbedManifestExists,
|
||||
perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000,
|
||||
perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500,
|
||||
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
|
||||
pidFile: config.pidFile,
|
||||
logging: config.logging,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// dummy
|
||||
export const MAX_NOTE_TEXT_LENGTH = 3000;
|
||||
|
||||
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
|
||||
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days
|
||||
|
||||
export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
|
||||
|
||||
//#region hard limits
|
||||
// If you change DB_* values, you must also change the DB schema.
|
||||
|
||||
@@ -25,6 +26,18 @@ export const DB_MAX_NOTE_TEXT_LENGTH = 8192;
|
||||
export const DB_MAX_IMAGE_COMMENT_LENGTH = 512;
|
||||
//#endregion
|
||||
|
||||
export const FILE_TYPE_IMAGE = [
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/apng',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/x-icon',
|
||||
];
|
||||
|
||||
// ブラウザで直接表示することを許可するファイルの種類のリスト
|
||||
// ここに含まれないものは application/octet-stream としてレスポンスされる
|
||||
// SVGはXSSを生むので許可しない
|
||||
|
||||
@@ -14,37 +14,43 @@ import type {
|
||||
AbuseReportNotificationRecipientRepository,
|
||||
MiAbuseReportNotificationRecipient,
|
||||
MiAbuseUserReport,
|
||||
MiMeta,
|
||||
MiUser,
|
||||
} from '@/models/_.js';
|
||||
import { EmailService } from '@/core/EmailService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { IdService } from './IdService.js';
|
||||
|
||||
@Injectable()
|
||||
export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.abuseReportNotificationRecipientRepository)
|
||||
private abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository,
|
||||
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
private idService: IdService,
|
||||
private roleService: RoleService,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private emailService: EmailService,
|
||||
private metaService: MetaService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理者用Redisイベントを用いて{@link abuseReports}の内容を管理者各位に通知する.
|
||||
* 通知先ユーザは{@link RoleService.getModeratorIds}の取得結果に依る.
|
||||
* 通知先ユーザは{@link getModeratorIds}の取得結果に依る.
|
||||
*
|
||||
* @see RoleService.getModeratorIds
|
||||
* @see GlobalEventService.publishAdminStream
|
||||
@@ -55,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||
return;
|
||||
}
|
||||
|
||||
const moderatorIds = await this.roleService.getModeratorIds(true, true);
|
||||
const moderatorIds = await this.roleService.getModeratorIds({
|
||||
includeAdmins: true,
|
||||
excludeExpire: true,
|
||||
});
|
||||
|
||||
for (const moderatorId of moderatorIds) {
|
||||
for (const abuseReport of abuseReports) {
|
||||
@@ -93,10 +102,8 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||
.filter(x => x != null),
|
||||
);
|
||||
|
||||
// 送信先の鮮度を保つため、毎回取得する
|
||||
const meta = await this.metaService.fetch(true);
|
||||
recipientEMailAddresses.push(
|
||||
...(meta.email ? [meta.email] : []),
|
||||
...(this.meta.email ? [this.meta.email] : []),
|
||||
);
|
||||
|
||||
if (recipientEMailAddresses.length <= 0) {
|
||||
@@ -133,22 +140,42 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||
return;
|
||||
}
|
||||
|
||||
const recipientWebhookIds = await this.fetchWebhookRecipients()
|
||||
.then(it => it
|
||||
.filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook')
|
||||
.map(it => it.systemWebhookId)
|
||||
.filter(x => x != null));
|
||||
for (const webhookId of recipientWebhookIds) {
|
||||
await Promise.all(
|
||||
abuseReports.map(it => {
|
||||
return this.systemWebhookService.enqueueSystemWebhook(
|
||||
webhookId,
|
||||
type,
|
||||
it,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const usersMap = await this.userEntityService.packMany(
|
||||
[
|
||||
...new Set([
|
||||
...abuseReports.map(it => it.reporter ?? it.reporterId),
|
||||
...abuseReports.map(it => it.targetUser ?? it.targetUserId),
|
||||
...abuseReports.map(it => it.assignee ?? it.assigneeId),
|
||||
].filter(x => x != null)),
|
||||
],
|
||||
null,
|
||||
{ schema: 'UserLite' },
|
||||
).then(it => new Map(it.map(it => [it.id, it])));
|
||||
const convertedReports = abuseReports.map(it => {
|
||||
return {
|
||||
...it,
|
||||
reporter: usersMap.get(it.reporterId) ?? null,
|
||||
targetUser: usersMap.get(it.targetUserId) ?? null,
|
||||
assignee: it.assigneeId ? (usersMap.get(it.assigneeId) ?? null) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const inactiveRecipients = await this.fetchWebhookRecipients()
|
||||
.then(it => it.filter(it => !it.isActive));
|
||||
const withoutWebhookIds = inactiveRecipients
|
||||
.map(it => it.systemWebhookId)
|
||||
.filter(x => x != null);
|
||||
return Promise.all(
|
||||
convertedReports.map(it => {
|
||||
return this.systemWebhookService.enqueueSystemWebhook(
|
||||
type,
|
||||
it,
|
||||
{
|
||||
excludes: withoutWebhookIds,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||
.log(updater, 'createAbuseReportNotificationRecipient', {
|
||||
recipientId: id,
|
||||
recipient: created,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
@@ -300,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||
recipientId: params.id,
|
||||
before: beforeEntity,
|
||||
after: afterEntity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return afterEntity;
|
||||
}
|
||||
@@ -322,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||
.log(updater, 'deleteAbuseReportNotificationRecipient', {
|
||||
recipientId: id,
|
||||
recipient: entity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,7 +370,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
// モデレータ権限の有無で通知先設定を振り分ける
|
||||
const authorizedUserIds = await this.roleService.getModeratorIds(true, true);
|
||||
const authorizedUserIds = await this.roleService.getModeratorIds({
|
||||
includeAdmins: true,
|
||||
excludeExpire: true,
|
||||
});
|
||||
const authorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
|
||||
const unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
|
||||
for (const recipient of userRecipients) {
|
||||
|
||||
@@ -10,9 +10,9 @@ 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 { IdService } from './IdService.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -20,12 +20,14 @@ export class AbuseReportService {
|
||||
constructor(
|
||||
@Inject(DI.abuseUserReportsRepository)
|
||||
private abuseUserReportsRepository: AbuseUserReportsRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private abuseReportNotificationService: AbuseReportNotificationService,
|
||||
private queueService: QueueService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private apRendererService: ApRendererService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
@@ -77,16 +79,16 @@ export class AbuseReportService {
|
||||
* - SystemWebhook
|
||||
*
|
||||
* @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える
|
||||
* @param operator 通報を処理したユーザ
|
||||
* @param moderator 通報を処理したユーザ
|
||||
* @see AbuseReportNotificationService.notify
|
||||
*/
|
||||
@bindThis
|
||||
public async resolve(
|
||||
params: {
|
||||
reportId: string;
|
||||
forward: boolean;
|
||||
resolvedAs: MiAbuseUserReport['resolvedAs'];
|
||||
}[],
|
||||
operator: MiUser,
|
||||
moderator: MiUser,
|
||||
) {
|
||||
const paramsMap = new Map(params.map(it => [it.reportId, it]));
|
||||
const reports = await this.abuseUserReportsRepository.findBy({
|
||||
@@ -99,30 +101,76 @@ export class AbuseReportService {
|
||||
|
||||
await this.abuseUserReportsRepository.update(report.id, {
|
||||
resolved: true,
|
||||
assigneeId: operator.id,
|
||||
forwarded: ps.forward && report.targetUserHost !== null,
|
||||
assigneeId: moderator.id,
|
||||
resolvedAs: ps.resolvedAs,
|
||||
});
|
||||
|
||||
if (ps.forward && report.targetUserHost != null) {
|
||||
const actor = await this.instanceActorService.getInstanceActor();
|
||||
const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId });
|
||||
|
||||
// eslint-disable-next-line
|
||||
const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment);
|
||||
const contextAssignedFlag = this.apRendererService.addContext(flag);
|
||||
this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false);
|
||||
}
|
||||
|
||||
this.moderationLogService
|
||||
.log(operator, 'resolveAbuseReport', {
|
||||
.log(moderator, 'resolveAbuseReport', {
|
||||
reportId: report.id,
|
||||
report: report,
|
||||
forwarded: ps.forward && report.targetUserHost !== null,
|
||||
})
|
||||
.then();
|
||||
resolvedAs: ps.resolvedAs,
|
||||
});
|
||||
}
|
||||
|
||||
return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
|
||||
.then(reports => this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReportResolved'));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async forward(
|
||||
reportId: MiAbuseUserReport['id'],
|
||||
moderator: MiUser,
|
||||
) {
|
||||
const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
|
||||
|
||||
if (report.targetUserHost == null) {
|
||||
throw new Error('The target user host is null.');
|
||||
}
|
||||
|
||||
if (report.forwarded) {
|
||||
throw new Error('The report has already been forwarded.');
|
||||
}
|
||||
|
||||
await this.abuseUserReportsRepository.update(report.id, {
|
||||
forwarded: true,
|
||||
});
|
||||
|
||||
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);
|
||||
const contextAssignedFlag = this.apRendererService.addContext(flag);
|
||||
this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false);
|
||||
|
||||
this.moderationLogService
|
||||
.log(moderator, 'forwardAbuseReport', {
|
||||
reportId: report.id,
|
||||
report: report,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(
|
||||
reportId: MiAbuseUserReport['id'],
|
||||
params: {
|
||||
moderationNote?: MiAbuseUserReport['moderationNote'];
|
||||
},
|
||||
moderator: MiUser,
|
||||
) {
|
||||
const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
|
||||
|
||||
await this.abuseUserReportsRepository.update(report.id, {
|
||||
moderationNote: params.moderationNote,
|
||||
});
|
||||
|
||||
if (params.moderationNote != null && report.moderationNote !== params.moderationNote) {
|
||||
this.moderationLogService.log(moderator, 'updateAbuseReportNote', {
|
||||
reportId: report.id,
|
||||
report: report,
|
||||
before: report.moderationNote,
|
||||
after: params.moderationNote,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MiMeta, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
|
||||
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
@@ -20,15 +20,18 @@ 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 { MetaService } from '@/core/MetaService.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';
|
||||
|
||||
@Injectable()
|
||||
export class AccountMoveService {
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -53,13 +56,13 @@ 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 metaService: MetaService,
|
||||
private relayService: RelayService,
|
||||
private queueService: QueueService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -118,6 +121,7 @@ export class AccountMoveService {
|
||||
await Promise.all([
|
||||
this.copyBlocking(src, dst),
|
||||
this.copyMutings(src, dst),
|
||||
this.copyRoles(src, dst),
|
||||
this.updateLists(src, dst),
|
||||
]);
|
||||
} catch {
|
||||
@@ -125,11 +129,11 @@ export class AccountMoveService {
|
||||
}
|
||||
|
||||
// follow the new account
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
const proxy = await this.systemAccountService.fetch('proxy');
|
||||
const followings = await this.followingsRepository.findBy({
|
||||
followeeId: src.id,
|
||||
followerHost: IsNull(), // follower is local
|
||||
followerId: proxy ? Not(proxy.id) : undefined,
|
||||
followerId: Not(proxy.id),
|
||||
});
|
||||
const followJobs = followings.map(following => ({
|
||||
from: { id: following.followerId },
|
||||
@@ -200,6 +204,32 @@ export class AccountMoveService {
|
||||
await this.mutingsRepository.insert(arrayToInsert);
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -249,10 +279,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 } }]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,13 +301,15 @@ export class AccountMoveService {
|
||||
}
|
||||
|
||||
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
|
||||
if (this.userEntityService.isRemoteUser(oldAccount)) {
|
||||
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(oldAccount)) {
|
||||
this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: expensive?
|
||||
|
||||
@@ -10,12 +10,13 @@ import { Injectable } from '@nestjs/common';
|
||||
import * as nsfw from 'nsfwjs';
|
||||
import si from 'systeminformation';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import fetch from 'node-fetch';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const REQUIRED_CPU_FLAGS = ['avx2', 'fma'];
|
||||
const REQUIRED_CPU_FLAGS_X64 = ['avx2', 'fma'];
|
||||
let isSupportedCpu: undefined | boolean = undefined;
|
||||
|
||||
@Injectable()
|
||||
@@ -28,11 +29,10 @@ export class AiService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async detectSensitive(path: string): Promise<nsfw.predictionType[] | null> {
|
||||
public async detectSensitive(path: string): Promise<nsfw.PredictionType[] | null> {
|
||||
try {
|
||||
if (isSupportedCpu === undefined) {
|
||||
const cpuFlags = await this.getCpuFlags();
|
||||
isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required));
|
||||
isSupportedCpu = await this.computeIsSupportedCpu();
|
||||
}
|
||||
|
||||
if (!isSupportedCpu) {
|
||||
@@ -41,6 +41,7 @@ export class AiService {
|
||||
}
|
||||
|
||||
const tf = await import('@tensorflow/tfjs-node');
|
||||
tf.env().global.fetch = fetch;
|
||||
|
||||
if (this.model == null) {
|
||||
await this.modelLoadMutex.runExclusive(async () => {
|
||||
@@ -64,6 +65,22 @@ export class AiService {
|
||||
}
|
||||
}
|
||||
|
||||
private async computeIsSupportedCpu(): Promise<boolean> {
|
||||
switch (process.arch) {
|
||||
case 'x64': {
|
||||
const cpuFlags = await this.getCpuFlags();
|
||||
return REQUIRED_CPU_FLAGS_X64.every(required => cpuFlags.includes(required));
|
||||
}
|
||||
case 'arm64': {
|
||||
// As far as I know, no required CPU flags for ARM64.
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getCpuFlags(): Promise<string[]> {
|
||||
const str = await si.cpuFlags();
|
||||
|
||||
@@ -72,7 +72,7 @@ export class AnnouncementService {
|
||||
updatedAt: null,
|
||||
title: values.title,
|
||||
text: values.text,
|
||||
imageUrl: values.imageUrl,
|
||||
imageUrl: values.imageUrl || null,
|
||||
icon: values.icon,
|
||||
display: values.display,
|
||||
forExistingUsers: values.forExistingUsers,
|
||||
@@ -209,6 +209,13 @@ export class AnnouncementService {
|
||||
return;
|
||||
}
|
||||
|
||||
const announcement = await this.announcementsRepository.findOneBy({ id: announcementId });
|
||||
if (announcement != null && announcement.userId === user.id) {
|
||||
await this.announcementsRepository.update(announcementId, {
|
||||
isActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
if ((await this.getUnreadAnnouncements(user)).length === 0) {
|
||||
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
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;
|
||||
|
||||
if (antenna.localOnly && noteUser.host != null) return false;
|
||||
@@ -123,11 +125,14 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
if (antenna.src === 'home') {
|
||||
// TODO
|
||||
} else if (antenna.src === 'list') {
|
||||
const listUsers = (await this.userListMembershipsRepository.findBy({
|
||||
userListId: antenna.userListId!,
|
||||
})).map(x => x.userId);
|
||||
|
||||
if (!listUsers.includes(note.userId)) return false;
|
||||
if (antenna.userListId == null) return false;
|
||||
const exists = await this.userListMembershipsRepository.exists({
|
||||
where: {
|
||||
userListId: antenna.userListId,
|
||||
userId: note.userId,
|
||||
},
|
||||
});
|
||||
if (!exists) return false;
|
||||
} else if (antenna.src === 'users') {
|
||||
const accts = antenna.users.map(x => {
|
||||
const { username, host } = Acct.parse(x);
|
||||
|
||||
@@ -29,7 +29,7 @@ export class AvatarDecorationService implements OnApplicationShutdown {
|
||||
private moderationLogService: ModerationLogService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30);
|
||||
this.cache = new MemorySingleCache<MiAvatarDecoration[]>(1000 * 60 * 30); // 30s
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@@ -56,10 +56,10 @@ export class CacheService implements OnApplicationShutdown {
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.userByIdCache = new MemoryKVCache<MiUser>(Infinity);
|
||||
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(Infinity);
|
||||
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(Infinity);
|
||||
this.uriPersonCache = new MemoryKVCache<MiUser | null>(Infinity);
|
||||
this.userByIdCache = new MemoryKVCache<MiUser>(1000 * 60 * 5); // 5m
|
||||
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(1000 * 60 * 5); // 5m
|
||||
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', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
@@ -135,14 +135,14 @@ export class CacheService implements OnApplicationShutdown {
|
||||
if (user == null) {
|
||||
this.userByIdCache.delete(body.id);
|
||||
this.localUserByIdCache.delete(body.id);
|
||||
for (const [k, v] of this.uriPersonCache.cache.entries()) {
|
||||
for (const [k, v] of this.uriPersonCache.entries) {
|
||||
if (v.value?.id === body.id) {
|
||||
this.uriPersonCache.delete(k);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.userByIdCache.set(user.id, user);
|
||||
for (const [k, v] of this.uriPersonCache.cache.entries()) {
|
||||
for (const [k, v] of this.uriPersonCache.entries) {
|
||||
if (v.value?.id === user.id) {
|
||||
this.uriPersonCache.set(k, user);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,65 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
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';
|
||||
|
||||
export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'testcaptcha'] as const;
|
||||
export type CaptchaProvider = typeof supportedCaptchaProviders[number];
|
||||
|
||||
export const captchaErrorCodes = {
|
||||
invalidProvider: Symbol('invalidProvider'),
|
||||
invalidParameters: Symbol('invalidParameters'),
|
||||
noResponseProvided: Symbol('noResponseProvided'),
|
||||
requestFailed: Symbol('requestFailed'),
|
||||
verificationFailed: Symbol('verificationFailed'),
|
||||
unknown: Symbol('unknown'),
|
||||
} as const;
|
||||
export type CaptchaErrorCode = typeof captchaErrorCodes[keyof typeof captchaErrorCodes];
|
||||
|
||||
export type CaptchaSetting = {
|
||||
provider: CaptchaProvider;
|
||||
hcaptcha: {
|
||||
siteKey: string | null;
|
||||
secretKey: string | null;
|
||||
}
|
||||
mcaptcha: {
|
||||
siteKey: string | null;
|
||||
secretKey: string | null;
|
||||
instanceUrl: string | null;
|
||||
}
|
||||
recaptcha: {
|
||||
siteKey: string | null;
|
||||
secretKey: string | null;
|
||||
}
|
||||
turnstile: {
|
||||
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 = {
|
||||
success: boolean;
|
||||
@@ -14,9 +73,14 @@ type CaptchaResponse = {
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
private httpRequestService: HttpRequestService,
|
||||
private metaService: MetaService,
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('captcha');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -44,32 +108,32 @@ export class CaptchaService {
|
||||
@bindThis
|
||||
public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw new Error('recaptcha-failed: no response provided');
|
||||
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'recaptcha-failed: no response provided');
|
||||
}
|
||||
|
||||
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
|
||||
throw new Error(`recaptcha-request-failed: ${err}`);
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`);
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw new Error(`recaptcha-failed: ${errorCodes}`);
|
||||
throw new CaptchaError(captchaErrorCodes.verificationFailed, `recaptcha-failed: ${errorCodes}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw new Error('hcaptcha-failed: no response provided');
|
||||
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'hcaptcha-failed: no response provided');
|
||||
}
|
||||
|
||||
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
|
||||
throw new Error(`hcaptcha-request-failed: ${err}`);
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`);
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw new Error(`hcaptcha-failed: ${errorCodes}`);
|
||||
throw new CaptchaError(captchaErrorCodes.verificationFailed, `hcaptcha-failed: ${errorCodes}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +141,7 @@ export class CaptchaService {
|
||||
@bindThis
|
||||
public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw new Error('mcaptcha-failed: no response provided');
|
||||
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'mcaptcha-failed: no response provided');
|
||||
}
|
||||
|
||||
const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost);
|
||||
@@ -91,33 +155,251 @@ export class CaptchaService {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}, { throwErrorWhenResponseNotOk: false });
|
||||
|
||||
if (result.status !== 200) {
|
||||
throw new Error('mcaptcha-failed: mcaptcha didn\'t return 200 OK');
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, 'mcaptcha-failed: mcaptcha didn\'t return 200 OK');
|
||||
}
|
||||
|
||||
const resp = (await result.json()) as { valid: boolean };
|
||||
|
||||
if (!resp.valid) {
|
||||
throw new Error('mcaptcha-request-failed');
|
||||
throw new CaptchaError(captchaErrorCodes.verificationFailed, 'mcaptcha-request-failed');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw new Error('turnstile-failed: no response provided');
|
||||
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'turnstile-failed: no response provided');
|
||||
}
|
||||
|
||||
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
|
||||
throw new Error(`turnstile-request-failed: ${err}`);
|
||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`);
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw new Error(`turnstile-failed: ${errorCodes}`);
|
||||
throw new CaptchaError(captchaErrorCodes.verificationFailed, `turnstile-failed: ${errorCodes}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'testcaptcha-failed: no response provided');
|
||||
}
|
||||
|
||||
const success = response === 'testcaptcha-passed';
|
||||
|
||||
if (!success) {
|
||||
throw new CaptchaError(captchaErrorCodes.verificationFailed, 'testcaptcha-failed');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async get(): Promise<CaptchaSetting> {
|
||||
const meta = await this.metaService.fetch(true);
|
||||
|
||||
let provider: CaptchaProvider;
|
||||
switch (true) {
|
||||
case meta.enableHcaptcha: {
|
||||
provider = 'hcaptcha';
|
||||
break;
|
||||
}
|
||||
case meta.enableMcaptcha: {
|
||||
provider = 'mcaptcha';
|
||||
break;
|
||||
}
|
||||
case meta.enableRecaptcha: {
|
||||
provider = 'recaptcha';
|
||||
break;
|
||||
}
|
||||
case meta.enableTurnstile: {
|
||||
provider = 'turnstile';
|
||||
break;
|
||||
}
|
||||
case meta.enableTestcaptcha: {
|
||||
provider = 'testcaptcha';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
provider = 'none';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider: provider,
|
||||
hcaptcha: {
|
||||
siteKey: meta.hcaptchaSiteKey,
|
||||
secretKey: meta.hcaptchaSecretKey,
|
||||
},
|
||||
mcaptcha: {
|
||||
siteKey: meta.mcaptchaSitekey,
|
||||
secretKey: meta.mcaptchaSecretKey,
|
||||
instanceUrl: meta.mcaptchaInstanceUrl,
|
||||
},
|
||||
recaptcha: {
|
||||
siteKey: meta.recaptchaSiteKey,
|
||||
secretKey: meta.recaptchaSecretKey,
|
||||
},
|
||||
turnstile: {
|
||||
siteKey: meta.turnstileSiteKey,
|
||||
secretKey: meta.turnstileSecretKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* captchaの設定を更新します. その際、フロントエンド側で受け取ったcaptchaからの戻り値を検証し、passした場合のみ設定を更新します.
|
||||
* 実際の検証処理はサービス内で定義されている各captchaプロバイダの検証関数に委譲します.
|
||||
*
|
||||
* @param provider 検証するcaptchaのプロバイダ
|
||||
* @param params
|
||||
* @param params.sitekey hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsitekey. それ以外のプロバイダでは無視されます
|
||||
* @param params.secret hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsecret. それ以外のプロバイダでは無視されます
|
||||
* @param params.instanceUrl mcaptchaの場合に指定するインスタンスのURL. それ以外のプロバイダでは無視されます
|
||||
* @param params.captchaResult フロントエンド側で受け取ったcaptchaプロバイダからの戻り値. この値を使ってサーバサイドでの検証を行います
|
||||
* @see verifyHcaptcha
|
||||
* @see verifyMcaptcha
|
||||
* @see verifyRecaptcha
|
||||
* @see verifyTurnstile
|
||||
* @see verifyTestcaptcha
|
||||
*/
|
||||
@bindThis
|
||||
public async save(
|
||||
provider: CaptchaProvider,
|
||||
params?: {
|
||||
sitekey?: string | null;
|
||||
secret?: string | null;
|
||||
instanceUrl?: string | null;
|
||||
captchaResult?: string | null;
|
||||
},
|
||||
): Promise<CaptchaSaveResult> {
|
||||
if (!supportedCaptchaProviders.includes(provider)) {
|
||||
return {
|
||||
success: false,
|
||||
error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${provider}`),
|
||||
};
|
||||
}
|
||||
|
||||
const operation = {
|
||||
none: async () => {
|
||||
await this.updateMeta(provider, params);
|
||||
},
|
||||
hcaptcha: async () => {
|
||||
if (!params?.secret || !params.captchaResult) {
|
||||
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and captureResult are required');
|
||||
}
|
||||
|
||||
await this.verifyHcaptcha(params.secret, params.captchaResult);
|
||||
await this.updateMeta(provider, params);
|
||||
},
|
||||
mcaptcha: async () => {
|
||||
if (!params?.secret || !params.sitekey || !params.instanceUrl || !params.captchaResult) {
|
||||
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and captureResult are required');
|
||||
}
|
||||
|
||||
await this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult);
|
||||
await this.updateMeta(provider, params);
|
||||
},
|
||||
recaptcha: async () => {
|
||||
if (!params?.secret || !params.captchaResult) {
|
||||
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and captureResult are required');
|
||||
}
|
||||
|
||||
await this.verifyRecaptcha(params.secret, params.captchaResult);
|
||||
await this.updateMeta(provider, params);
|
||||
},
|
||||
turnstile: async () => {
|
||||
if (!params?.secret || !params.captchaResult) {
|
||||
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and captureResult are required');
|
||||
}
|
||||
|
||||
await this.verifyTurnstile(params.secret, params.captchaResult);
|
||||
await this.updateMeta(provider, params);
|
||||
},
|
||||
testcaptcha: async () => {
|
||||
if (!params?.captchaResult) {
|
||||
throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: captureResult are required');
|
||||
}
|
||||
|
||||
await this.verifyTestcaptcha(params.captchaResult);
|
||||
await this.updateMeta(provider, params);
|
||||
},
|
||||
}[provider];
|
||||
|
||||
return operation()
|
||||
.then(() => ({ success: true }) as CaptchaSaveSuccess)
|
||||
.catch(err => {
|
||||
this.logger.info(err);
|
||||
const error = err instanceof CaptchaError
|
||||
? err
|
||||
: new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`);
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async updateMeta(
|
||||
provider: CaptchaProvider,
|
||||
params?: {
|
||||
sitekey?: string | null;
|
||||
secret?: string | null;
|
||||
instanceUrl?: string | null;
|
||||
},
|
||||
) {
|
||||
const metaPartial: Partial<
|
||||
Pick<
|
||||
MiMeta,
|
||||
('enableHcaptcha' | 'hcaptchaSiteKey' | 'hcaptchaSecretKey') |
|
||||
('enableMcaptcha' | 'mcaptchaSitekey' | 'mcaptchaSecretKey' | 'mcaptchaInstanceUrl') |
|
||||
('enableRecaptcha' | 'recaptchaSiteKey' | 'recaptchaSecretKey') |
|
||||
('enableTurnstile' | 'turnstileSiteKey' | 'turnstileSecretKey') |
|
||||
('enableTestcaptcha')
|
||||
>
|
||||
> = {
|
||||
enableHcaptcha: provider === 'hcaptcha',
|
||||
enableMcaptcha: provider === 'mcaptcha',
|
||||
enableRecaptcha: provider === 'recaptcha',
|
||||
enableTurnstile: provider === 'turnstile',
|
||||
enableTestcaptcha: provider === 'testcaptcha',
|
||||
};
|
||||
|
||||
const updateIfNotUndefined = <K extends keyof typeof metaPartial>(key: K, value: typeof metaPartial[K]) => {
|
||||
if (value !== undefined) {
|
||||
metaPartial[key] = value;
|
||||
}
|
||||
};
|
||||
switch (provider) {
|
||||
case 'hcaptcha': {
|
||||
updateIfNotUndefined('hcaptchaSiteKey', params?.sitekey);
|
||||
updateIfNotUndefined('hcaptchaSecretKey', params?.secret);
|
||||
break;
|
||||
}
|
||||
case 'mcaptcha': {
|
||||
updateIfNotUndefined('mcaptchaSitekey', params?.sitekey);
|
||||
updateIfNotUndefined('mcaptchaSecretKey', params?.secret);
|
||||
updateIfNotUndefined('mcaptchaInstanceUrl', params?.instanceUrl);
|
||||
break;
|
||||
}
|
||||
case 'recaptcha': {
|
||||
updateIfNotUndefined('recaptchaSiteKey', params?.sitekey);
|
||||
updateIfNotUndefined('recaptchaSecretKey', params?.secret);
|
||||
break;
|
||||
}
|
||||
case 'turnstile': {
|
||||
updateIfNotUndefined('turnstileSiteKey', params?.sitekey);
|
||||
updateIfNotUndefined('turnstileSecretKey', params?.secret);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.metaService.update(metaPartial);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserSearchService } from '@/core/UserSearchService.js';
|
||||
import { WebhookTestService } from '@/core/WebhookTestService.js';
|
||||
import { FlashService } from '@/core/FlashService.js';
|
||||
import { AccountMoveService } from './AccountMoveService.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AiService } from './AiService.js';
|
||||
@@ -22,7 +24,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';
|
||||
@@ -35,7 +36,7 @@ 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 { InternalStorageService } from './InternalStorageService.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
import { MfmService } from './MfmService.js';
|
||||
@@ -43,12 +44,12 @@ import { ModerationLogService } from './ModerationLogService.js';
|
||||
import { NoteCreateService } from './NoteCreateService.js';
|
||||
import { NoteDeleteService } from './NoteDeleteService.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';
|
||||
import { QueryService } from './QueryService.js';
|
||||
import { ReactionService } from './ReactionService.js';
|
||||
import { ReactionsBufferingService } from './ReactionsBufferingService.js';
|
||||
import { RelayService } from './RelayService.js';
|
||||
import { RoleService } from './RoleService.js';
|
||||
import { S3Service } from './S3Service.js';
|
||||
@@ -66,7 +67,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';
|
||||
@@ -74,6 +74,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';
|
||||
import { MahjongService } from './MahjongService.js';
|
||||
@@ -100,6 +101,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';
|
||||
@@ -165,7 +167,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 };
|
||||
@@ -178,7 +179,6 @@ 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 $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
|
||||
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
|
||||
const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService };
|
||||
@@ -186,13 +186,13 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
|
||||
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
|
||||
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
|
||||
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 };
|
||||
const $ReactionsBufferingService: Provider = { provide: 'ReactionsBufferingService', useExisting: ReactionsBufferingService };
|
||||
const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService };
|
||||
const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
|
||||
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
|
||||
@@ -212,14 +212,17 @@ const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: Us
|
||||
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
|
||||
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
|
||||
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
|
||||
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
|
||||
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
|
||||
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
|
||||
const $FlashService: Provider = { provide: 'FlashService', useExisting: FlashService };
|
||||
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
|
||||
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
|
||||
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
|
||||
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 $MahjongService: Provider = { provide: 'MahjongService', useExisting: MahjongService };
|
||||
@@ -247,6 +250,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 };
|
||||
@@ -314,7 +318,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
AchievementService,
|
||||
AvatarDecorationService,
|
||||
CaptchaService,
|
||||
CreateSystemUserService,
|
||||
CustomEmojiService,
|
||||
DeleteAccountService,
|
||||
DownloadService,
|
||||
@@ -327,7 +330,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
HttpRequestService,
|
||||
IdService,
|
||||
ImageProcessingService,
|
||||
InstanceActorService,
|
||||
InternalStorageService,
|
||||
MetaService,
|
||||
MfmService,
|
||||
@@ -335,13 +337,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
NoteCreateService,
|
||||
NoteDeleteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
SystemAccountService,
|
||||
PushNotificationService,
|
||||
QueryService,
|
||||
ReactionService,
|
||||
ReactionsBufferingService,
|
||||
RelayService,
|
||||
RoleService,
|
||||
S3Service,
|
||||
@@ -361,14 +363,17 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
VideoProcessingService,
|
||||
UserWebhookService,
|
||||
SystemWebhookService,
|
||||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FlashService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
MahjongService,
|
||||
@@ -396,6 +401,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
AuthSessionEntityService,
|
||||
BlockingEntityService,
|
||||
ChannelEntityService,
|
||||
ChatEntityService,
|
||||
ClipEntityService,
|
||||
DriveFileEntityService,
|
||||
DriveFolderEntityService,
|
||||
@@ -459,7 +465,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$AchievementService,
|
||||
$AvatarDecorationService,
|
||||
$CaptchaService,
|
||||
$CreateSystemUserService,
|
||||
$CustomEmojiService,
|
||||
$DeleteAccountService,
|
||||
$DownloadService,
|
||||
@@ -472,7 +477,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$HttpRequestService,
|
||||
$IdService,
|
||||
$ImageProcessingService,
|
||||
$InstanceActorService,
|
||||
$InternalStorageService,
|
||||
$MetaService,
|
||||
$MfmService,
|
||||
@@ -480,13 +484,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$NoteCreateService,
|
||||
$NoteDeleteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$SystemAccountService,
|
||||
$PushNotificationService,
|
||||
$QueryService,
|
||||
$ReactionService,
|
||||
$ReactionsBufferingService,
|
||||
$RelayService,
|
||||
$RoleService,
|
||||
$S3Service,
|
||||
@@ -506,14 +510,17 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$VideoProcessingService,
|
||||
$UserWebhookService,
|
||||
$SystemWebhookService,
|
||||
$WebhookTestService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$FlashService,
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$MahjongService,
|
||||
@@ -541,6 +548,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$AuthSessionEntityService,
|
||||
$BlockingEntityService,
|
||||
$ChannelEntityService,
|
||||
$ChatEntityService,
|
||||
$ClipEntityService,
|
||||
$DriveFileEntityService,
|
||||
$DriveFolderEntityService,
|
||||
@@ -605,7 +613,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
AchievementService,
|
||||
AvatarDecorationService,
|
||||
CaptchaService,
|
||||
CreateSystemUserService,
|
||||
CustomEmojiService,
|
||||
DeleteAccountService,
|
||||
DownloadService,
|
||||
@@ -618,7 +625,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
HttpRequestService,
|
||||
IdService,
|
||||
ImageProcessingService,
|
||||
InstanceActorService,
|
||||
InternalStorageService,
|
||||
MetaService,
|
||||
MfmService,
|
||||
@@ -626,13 +632,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
NoteCreateService,
|
||||
NoteDeleteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
SystemAccountService,
|
||||
PushNotificationService,
|
||||
QueryService,
|
||||
ReactionService,
|
||||
ReactionsBufferingService,
|
||||
RelayService,
|
||||
RoleService,
|
||||
S3Service,
|
||||
@@ -652,14 +658,17 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
VideoProcessingService,
|
||||
UserWebhookService,
|
||||
SystemWebhookService,
|
||||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FlashService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
FanoutTimelineService,
|
||||
FanoutTimelineEndpointService,
|
||||
ChannelFollowingService,
|
||||
ChatService,
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
MahjongService,
|
||||
@@ -686,6 +695,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
AuthSessionEntityService,
|
||||
BlockingEntityService,
|
||||
ChannelEntityService,
|
||||
ChatEntityService,
|
||||
ClipEntityService,
|
||||
DriveFileEntityService,
|
||||
DriveFolderEntityService,
|
||||
@@ -749,7 +759,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$AchievementService,
|
||||
$AvatarDecorationService,
|
||||
$CaptchaService,
|
||||
$CreateSystemUserService,
|
||||
$CustomEmojiService,
|
||||
$DeleteAccountService,
|
||||
$DownloadService,
|
||||
@@ -762,7 +771,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$HttpRequestService,
|
||||
$IdService,
|
||||
$ImageProcessingService,
|
||||
$InstanceActorService,
|
||||
$InternalStorageService,
|
||||
$MetaService,
|
||||
$MfmService,
|
||||
@@ -770,13 +778,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$NoteCreateService,
|
||||
$NoteDeleteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$SystemAccountService,
|
||||
$PushNotificationService,
|
||||
$QueryService,
|
||||
$ReactionService,
|
||||
$ReactionsBufferingService,
|
||||
$RelayService,
|
||||
$RoleService,
|
||||
$S3Service,
|
||||
@@ -796,6 +804,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$VideoProcessingService,
|
||||
$UserWebhookService,
|
||||
$SystemWebhookService,
|
||||
$WebhookTestService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$SearchService,
|
||||
@@ -804,6 +813,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$FanoutTimelineService,
|
||||
$FanoutTimelineEndpointService,
|
||||
$ChannelFollowingService,
|
||||
$ChatService,
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$MahjongService,
|
||||
@@ -830,6 +840,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$AuthSessionEntityService,
|
||||
$BlockingEntityService,
|
||||
$ChannelEntityService,
|
||||
$ChatEntityService,
|
||||
$ClipEntityService,
|
||||
$DriveFileEntityService,
|
||||
$DriveFolderEntityService,
|
||||
|
||||
@@ -1,86 +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 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 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: 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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4,43 +4,76 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiEmoji } from '@/models/Emoji.js';
|
||||
import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
import type { Serialized } from '@/types.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
|
||||
import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
|
||||
import type { MiEmoji } from '@/models/Emoji.js';
|
||||
import type { Serialized } from '@/types.js';
|
||||
|
||||
const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/;
|
||||
|
||||
export const fetchEmojisHostTypes = [
|
||||
'local',
|
||||
'remote',
|
||||
'all',
|
||||
] as const;
|
||||
export type FetchEmojisHostTypes = typeof fetchEmojisHostTypes[number];
|
||||
export const fetchEmojisSortKeys = [
|
||||
'+id',
|
||||
'-id',
|
||||
'+updatedAt',
|
||||
'-updatedAt',
|
||||
'+name',
|
||||
'-name',
|
||||
'+host',
|
||||
'-host',
|
||||
'+uri',
|
||||
'-uri',
|
||||
'+publicUrl',
|
||||
'-publicUrl',
|
||||
'+type',
|
||||
'-type',
|
||||
'+aliases',
|
||||
'-aliases',
|
||||
'+category',
|
||||
'-category',
|
||||
'+license',
|
||||
'-license',
|
||||
'+isSensitive',
|
||||
'-isSensitive',
|
||||
'+localOnly',
|
||||
'-localOnly',
|
||||
'+roleIdsThatCanBeUsedThisEmojiAsReaction',
|
||||
'-roleIdsThatCanBeUsedThisEmojiAsReaction',
|
||||
] as const;
|
||||
export type FetchEmojisSortKeys = typeof fetchEmojisSortKeys[number];
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService implements OnApplicationShutdown {
|
||||
private cache: MemoryKVCache<MiEmoji | null>;
|
||||
private emojisCache: MemoryKVCache<MiEmoji | null>;
|
||||
public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
this.cache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12);
|
||||
this.emojisCache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12); // 12h
|
||||
|
||||
this.localEmojisCache = new RedisSingleCache<Map<string, MiEmoji>>(this.redisClient, 'localEmojis', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
@@ -58,7 +91,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
public async add(data: {
|
||||
driveFile: MiDriveFile;
|
||||
originalUrl: string;
|
||||
publicUrl: string;
|
||||
fileType: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
aliases: string[];
|
||||
@@ -75,9 +110,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
category: data.category,
|
||||
host: data.host,
|
||||
aliases: data.aliases,
|
||||
originalUrl: data.driveFile.url,
|
||||
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
|
||||
type: data.driveFile.webpublicType ?? data.driveFile.type,
|
||||
originalUrl: data.originalUrl,
|
||||
publicUrl: data.publicUrl,
|
||||
type: data.fileType,
|
||||
license: data.license,
|
||||
isSensitive: data.isSensitive,
|
||||
localOnly: data.localOnly,
|
||||
@@ -103,19 +138,35 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(id: MiEmoji['id'], data: {
|
||||
driveFile?: MiDriveFile;
|
||||
name?: string;
|
||||
public async update(data: (
|
||||
{ id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], }
|
||||
) & {
|
||||
originalUrl?: string;
|
||||
publicUrl?: string;
|
||||
fileType?: string;
|
||||
category?: string | null;
|
||||
aliases?: string[];
|
||||
license?: string | null;
|
||||
isSensitive?: boolean;
|
||||
localOnly?: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
|
||||
}, moderator?: MiUser): Promise<void> {
|
||||
const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
|
||||
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
|
||||
if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists');
|
||||
}, moderator?: MiUser): Promise<
|
||||
null
|
||||
| 'NO_SUCH_EMOJI'
|
||||
| 'SAME_NAME_EMOJI_EXISTS'
|
||||
> {
|
||||
const emoji = data.id
|
||||
? await this.getEmojiById(data.id)
|
||||
: await this.getEmojiByName(data.name!);
|
||||
if (emoji === null) return 'NO_SUCH_EMOJI';
|
||||
const id = emoji.id;
|
||||
|
||||
// IDと絵文字名が両方指定されている場合は絵文字名の変更を行うため重複チェックが必要
|
||||
const doNameUpdate = data.id && data.name && (data.name !== emoji.name);
|
||||
if (doNameUpdate) {
|
||||
const isDuplicate = await this.checkDuplicate(data.name!);
|
||||
if (isDuplicate) return 'SAME_NAME_EMOJI_EXISTS';
|
||||
}
|
||||
|
||||
await this.emojisRepository.update(emoji.id, {
|
||||
updatedAt: new Date(),
|
||||
@@ -125,9 +176,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
license: data.license,
|
||||
isSensitive: data.isSensitive,
|
||||
localOnly: data.localOnly,
|
||||
originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
|
||||
publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined,
|
||||
type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,
|
||||
originalUrl: data.originalUrl,
|
||||
publicUrl: data.publicUrl,
|
||||
type: data.fileType,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
|
||||
});
|
||||
|
||||
@@ -135,7 +186,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
|
||||
const packed = await this.emojiEntityService.packDetailed(emoji.id);
|
||||
|
||||
if (emoji.name === data.name) {
|
||||
if (!doNameUpdate) {
|
||||
this.globalEventService.publishBroadcastStream('emojiUpdated', {
|
||||
emojis: [packed],
|
||||
});
|
||||
@@ -157,6 +208,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
after: updated,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -293,7 +345,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
|
||||
// クエリに使うホスト
|
||||
// クエリに使うホスト
|
||||
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
|
||||
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
|
||||
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定
|
||||
@@ -334,7 +386,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
host,
|
||||
})) ?? null;
|
||||
|
||||
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
|
||||
const emoji = await this.emojisCache.fetch(`${name} ${host}`, queryOrNull);
|
||||
|
||||
if (emoji == null) return null;
|
||||
return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||
@@ -361,7 +413,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
*/
|
||||
@bindThis
|
||||
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
|
||||
const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null);
|
||||
const notCachedEmojis = emojis.filter(emoji => this.emojisCache.get(`${emoji.name} ${emoji.host}`) == null);
|
||||
const emojisQuery: any[] = [];
|
||||
const hosts = new Set(notCachedEmojis.map(e => e.host));
|
||||
for (const host of hosts) {
|
||||
@@ -376,7 +428,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
select: ['name', 'host', 'originalUrl', 'publicUrl'],
|
||||
}) : [];
|
||||
for (const emoji of _emojis) {
|
||||
this.cache.set(`${emoji.name} ${emoji.host}`, emoji);
|
||||
this.emojisCache.set(`${emoji.name} ${emoji.host}`, emoji);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,9 +451,154 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
return this.emojisRepository.findOneBy({ name, host: IsNull() });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetchEmojis(
|
||||
params?: {
|
||||
query?: {
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
uri?: string;
|
||||
publicUrl?: string;
|
||||
type?: string;
|
||||
aliases?: string;
|
||||
category?: string;
|
||||
license?: string;
|
||||
isSensitive?: boolean;
|
||||
localOnly?: boolean;
|
||||
hostType?: FetchEmojisHostTypes;
|
||||
roleIds?: string[];
|
||||
},
|
||||
sinceId?: string;
|
||||
untilId?: string;
|
||||
},
|
||||
opts?: {
|
||||
limit?: number;
|
||||
page?: number;
|
||||
sortKeys?: FetchEmojisSortKeys[]
|
||||
},
|
||||
) {
|
||||
function multipleWordsToQuery(words: string) {
|
||||
return words.split(/\s/).filter(x => x.length > 0).map(x => `%${sqlLikeEscape(x)}%`);
|
||||
}
|
||||
|
||||
const builder = this.emojisRepository.createQueryBuilder('emoji');
|
||||
if (params?.query) {
|
||||
const q = params.query;
|
||||
if (q.updatedAtFrom) {
|
||||
// noIndexScan
|
||||
builder.andWhere('CAST(emoji.updatedAt AS DATE) >= :updateAtFrom', { updateAtFrom: q.updatedAtFrom });
|
||||
}
|
||||
if (q.updatedAtTo) {
|
||||
// noIndexScan
|
||||
builder.andWhere('CAST(emoji.updatedAt AS DATE) <= :updateAtTo', { updateAtTo: q.updatedAtTo });
|
||||
}
|
||||
if (q.name) {
|
||||
builder.andWhere('emoji.name ~~ ANY(ARRAY[:...name])', { name: multipleWordsToQuery(q.name) });
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case q.hostType === 'local': {
|
||||
builder.andWhere('emoji.host IS NULL');
|
||||
break;
|
||||
}
|
||||
case q.hostType === 'remote': {
|
||||
if (q.host) {
|
||||
// noIndexScan
|
||||
builder.andWhere('emoji.host ~~ ANY(ARRAY[:...host])', { host: multipleWordsToQuery(q.host) });
|
||||
} else {
|
||||
builder.andWhere('emoji.host IS NOT NULL');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (q.uri) {
|
||||
// noIndexScan
|
||||
builder.andWhere('emoji.uri ~~ ANY(ARRAY[:...uri])', { uri: multipleWordsToQuery(q.uri) });
|
||||
}
|
||||
if (q.publicUrl) {
|
||||
// noIndexScan
|
||||
builder.andWhere('emoji.publicUrl ~~ ANY(ARRAY[:...publicUrl])', { publicUrl: multipleWordsToQuery(q.publicUrl) });
|
||||
}
|
||||
if (q.type) {
|
||||
// noIndexScan
|
||||
builder.andWhere('emoji.type ~~ ANY(ARRAY[:...type])', { type: multipleWordsToQuery(q.type) });
|
||||
}
|
||||
if (q.aliases) {
|
||||
// noIndexScan
|
||||
const subQueryBuilder = builder.subQuery()
|
||||
.select('COUNT(0)', 'count')
|
||||
.from(
|
||||
sq2 => sq2
|
||||
.select('unnest(subEmoji.aliases)', 'alias')
|
||||
.addSelect('subEmoji.id', 'id')
|
||||
.from('emoji', 'subEmoji'),
|
||||
'aliasTable',
|
||||
)
|
||||
.where('"emoji"."id" = "aliasTable"."id"')
|
||||
.andWhere('"aliasTable"."alias" ~~ ANY(ARRAY[:...aliases])', { aliases: multipleWordsToQuery(q.aliases) });
|
||||
|
||||
builder.andWhere(`(${subQueryBuilder.getQuery()}) > 0`);
|
||||
}
|
||||
if (q.category) {
|
||||
builder.andWhere('emoji.category ~~ ANY(ARRAY[:...category])', { category: multipleWordsToQuery(q.category) });
|
||||
}
|
||||
if (q.license) {
|
||||
// noIndexScan
|
||||
builder.andWhere('emoji.license ~~ ANY(ARRAY[:...license])', { license: multipleWordsToQuery(q.license) });
|
||||
}
|
||||
if (q.isSensitive != null) {
|
||||
// noIndexScan
|
||||
builder.andWhere('emoji.isSensitive = :isSensitive', { isSensitive: q.isSensitive });
|
||||
}
|
||||
if (q.localOnly != null) {
|
||||
// noIndexScan
|
||||
builder.andWhere('emoji.localOnly = :localOnly', { localOnly: q.localOnly });
|
||||
}
|
||||
if (q.roleIds && q.roleIds.length > 0) {
|
||||
builder.andWhere('emoji.roleIdsThatCanBeUsedThisEmojiAsReaction && ARRAY[:...roleIds]::VARCHAR[]', { roleIds: q.roleIds });
|
||||
}
|
||||
}
|
||||
|
||||
if (params?.sinceId) {
|
||||
builder.andWhere('emoji.id > :sinceId', { sinceId: params.sinceId });
|
||||
}
|
||||
if (params?.untilId) {
|
||||
builder.andWhere('emoji.id < :untilId', { untilId: params.untilId });
|
||||
}
|
||||
|
||||
if (opts?.sortKeys && opts.sortKeys.length > 0) {
|
||||
for (const sortKey of opts.sortKeys) {
|
||||
const direction = sortKey.startsWith('-') ? 'DESC' : 'ASC';
|
||||
const key = sortKey.replace(/^[+-]/, '');
|
||||
builder.addOrderBy(`emoji.${key}`, direction);
|
||||
}
|
||||
} else {
|
||||
builder.addOrderBy('emoji.id', 'DESC');
|
||||
}
|
||||
|
||||
const limit = opts?.limit ?? 10;
|
||||
if (opts?.page) {
|
||||
builder.skip((opts.page - 1) * limit);
|
||||
}
|
||||
|
||||
builder.take(limit);
|
||||
|
||||
const [emojis, count] = await builder.getManyAndCount();
|
||||
|
||||
return {
|
||||
emojis,
|
||||
count: (count > limit ? emojis.length : count),
|
||||
allCount: count,
|
||||
allPages: Math.ceil(count / limit),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.cache.dispose();
|
||||
this.emojisCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -4,22 +4,35 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import type { FollowingsRepository, MiMeta, MiUser, UsersRepository } from '@/models/_.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { UserSuspendService } from '@/core/UserSuspendService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteAccountService {
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private userSuspendService: UserSuspendService,
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private apRendererService: ApRendererService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -27,16 +40,57 @@ export class DeleteAccountService {
|
||||
public async deleteAccount(user: {
|
||||
id: string;
|
||||
host: string | null;
|
||||
}): Promise<void> {
|
||||
}, 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 (user.host === null && _user.username.includes('.')) {
|
||||
throw new Error('cannot delete a system account');
|
||||
}
|
||||
|
||||
if (moderator != null) {
|
||||
this.moderationLogService.log(moderator, 'deleteAccount', {
|
||||
userId: user.id,
|
||||
userUsername: _user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
}
|
||||
|
||||
// 物理削除する前にDelete activityを送信する
|
||||
await this.userSuspendService.doPostSuspend(user).catch(e => {});
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
soft: false,
|
||||
});
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
{ followeeSharedInbox: Not(IsNull()) },
|
||||
],
|
||||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user, content, inbox, true);
|
||||
}
|
||||
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
soft: false,
|
||||
});
|
||||
} else {
|
||||
// リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
soft: true,
|
||||
});
|
||||
}
|
||||
|
||||
await this.usersRepository.update(user.id, {
|
||||
isDeleted: true,
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as stream from 'node:stream/promises';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import chalk from 'chalk';
|
||||
import got, * as Got from 'got';
|
||||
import { parse } from 'content-disposition';
|
||||
@@ -42,7 +41,7 @@ export class DownloadService {
|
||||
|
||||
const timeout = 30 * 1000;
|
||||
const operationTimeout = 60 * 1000;
|
||||
const maxSize = this.config.maxFileSize ?? 262144000;
|
||||
const maxSize = this.config.maxFileSize;
|
||||
|
||||
const urlObj = new URL(url);
|
||||
let filename = urlObj.pathname.split('/').pop() ?? 'untitled';
|
||||
@@ -61,8 +60,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: {
|
||||
@@ -70,13 +69,6 @@ export class DownloadService {
|
||||
},
|
||||
enableUnixSockets: false,
|
||||
}).on('response', (res: Got.Response) => {
|
||||
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
|
||||
if (this.isPrivateIp(res.ip)) {
|
||||
this.logger.warn(`Blocked address: ${res.ip}`);
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = res.headers['content-length'];
|
||||
if (contentLength != null) {
|
||||
const size = Number(contentLength);
|
||||
@@ -139,18 +131,4 @@ export class DownloadService {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,10 @@ import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/_.js';
|
||||
import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import Logger from '@/logger.js';
|
||||
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
@@ -43,6 +42,7 @@ import { RoleService } from '@/core/RoleService.js';
|
||||
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';
|
||||
|
||||
type AddFileArgs = {
|
||||
/** User who wish to add file */
|
||||
@@ -98,6 +98,9 @@ export class DriveService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -114,7 +117,6 @@ export class DriveService {
|
||||
private userEntityService: UserEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private idService: IdService,
|
||||
private metaService: MetaService,
|
||||
private downloadService: DownloadService,
|
||||
private internalStorageService: InternalStorageService,
|
||||
private s3Service: S3Service,
|
||||
@@ -127,6 +129,7 @@ export class DriveService {
|
||||
private driveChart: DriveChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private instanceChart: InstanceChart,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
const logger = new Logger('drive', 'blue');
|
||||
this.registerLogger = logger.createSubLogger('register', 'yellow');
|
||||
@@ -147,9 +150,7 @@ export class DriveService {
|
||||
// thunbnail, webpublic を必要なら生成
|
||||
const alts = await this.generateAlts(path, type, !file.uri);
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (meta.useObjectStorage) {
|
||||
if (this.meta.useObjectStorage) {
|
||||
//#region ObjectStorage params
|
||||
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
|
||||
|
||||
@@ -168,11 +169,12 @@ export class DriveService {
|
||||
ext = '';
|
||||
}
|
||||
|
||||
const baseUrl = meta.objectStorageBaseUrl
|
||||
?? `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`;
|
||||
const baseUrl = this.meta.objectStorageBaseUrl
|
||||
?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`;
|
||||
|
||||
// for original
|
||||
const key = `${meta.objectStoragePrefix}/${randomUUID()}${ext}`;
|
||||
const prefix = this.meta.objectStoragePrefix ? `${this.meta.objectStoragePrefix}/` : '';
|
||||
const key = `${prefix}${randomUUID()}${ext}`;
|
||||
const url = `${ baseUrl }/${ key }`;
|
||||
|
||||
// for alts
|
||||
@@ -189,7 +191,7 @@ export class DriveService {
|
||||
];
|
||||
|
||||
if (alts.webpublic) {
|
||||
webpublicKey = `${meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
||||
webpublicKey = `${prefix}webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
||||
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
|
||||
|
||||
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
|
||||
@@ -197,7 +199,7 @@ export class DriveService {
|
||||
}
|
||||
|
||||
if (alts.thumbnail) {
|
||||
thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
||||
thumbnailKey = `${prefix}thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
||||
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
|
||||
|
||||
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
|
||||
@@ -374,10 +376,8 @@ export class DriveService {
|
||||
if (type === 'image/apng') type = 'image/png';
|
||||
if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream';
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const params = {
|
||||
Bucket: meta.objectStorageBucket,
|
||||
Bucket: this.meta.objectStorageBucket,
|
||||
Key: key,
|
||||
Body: stream,
|
||||
ContentType: type,
|
||||
@@ -390,9 +390,9 @@ export class DriveService {
|
||||
// 許可されているファイル形式でしか拡張子をつけない
|
||||
ext ? correctFilename(filename, ext) : filename,
|
||||
);
|
||||
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
|
||||
if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read';
|
||||
|
||||
await this.s3Service.upload(meta, params)
|
||||
await this.s3Service.upload(this.meta, params)
|
||||
.then(
|
||||
result => {
|
||||
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
|
||||
@@ -458,32 +458,31 @@ export class DriveService {
|
||||
ext = null,
|
||||
}: AddFileArgs): Promise<MiDriveFile> {
|
||||
let skipNsfwCheck = false;
|
||||
const instance = await this.metaService.fetch();
|
||||
const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw;
|
||||
if (user == null) {
|
||||
skipNsfwCheck = true;
|
||||
} else if (userRoleNSFW) {
|
||||
skipNsfwCheck = true;
|
||||
}
|
||||
if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true;
|
||||
if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true;
|
||||
if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
|
||||
if (this.meta.sensitiveMediaDetection === 'none') skipNsfwCheck = true;
|
||||
if (user && this.meta.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true;
|
||||
if (user && this.meta.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
|
||||
|
||||
const info = await this.fileInfoService.getFileInfo(path, {
|
||||
skipSensitiveDetection: skipNsfwCheck,
|
||||
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
|
||||
instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
|
||||
instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
|
||||
instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
|
||||
instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
|
||||
this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
|
||||
0.5,
|
||||
sensitiveThresholdForPorn: 0.75,
|
||||
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
|
||||
enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos,
|
||||
});
|
||||
this.registerLogger.info(`${JSON.stringify(info)}`);
|
||||
|
||||
// 現状 false positive が多すぎて実用に耐えない
|
||||
//if (info.porn && instance.disallowUploadWhenPredictedAsPorn) {
|
||||
//if (info.porn && this.meta.disallowUploadWhenPredictedAsPorn) {
|
||||
// throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.');
|
||||
//}
|
||||
|
||||
@@ -587,8 +586,9 @@ export class DriveService {
|
||||
sensitive ?? false
|
||||
: false;
|
||||
|
||||
if (user && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) file.isSensitive = true;
|
||||
if (info.sensitive && profile!.autoSensitive) file.isSensitive = true;
|
||||
if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true;
|
||||
if (info.sensitive && this.meta.setSensitiveFlagAutomatically) file.isSensitive = true;
|
||||
if (userRoleNSFW) file.isSensitive = true;
|
||||
|
||||
if (url !== null) {
|
||||
@@ -649,7 +649,7 @@ export class DriveService {
|
||||
// ローカルユーザーのみ
|
||||
this.perUserDriveChart.update(file, true);
|
||||
} else {
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateDrive(file, true);
|
||||
}
|
||||
}
|
||||
@@ -795,7 +795,7 @@ export class DriveService {
|
||||
// ローカルユーザーのみ
|
||||
this.perUserDriveChart.update(file, false);
|
||||
} else {
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateDrive(file, false);
|
||||
}
|
||||
}
|
||||
@@ -817,14 +817,13 @@ export class DriveService {
|
||||
|
||||
@bindThis
|
||||
public async deleteObjectStorageFile(key: string) {
|
||||
const meta = await this.metaService.fetch();
|
||||
try {
|
||||
const param = {
|
||||
Bucket: meta.objectStorageBucket,
|
||||
Bucket: this.meta.objectStorageBucket,
|
||||
Key: key,
|
||||
} as DeleteObjectCommandInput;
|
||||
|
||||
await this.s3Service.delete(meta, param);
|
||||
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);
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
|
||||
import { URLSearchParams } from 'node:url';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import juice from 'juice';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { validate as validateEmail } from 'deep-email-validator';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import type { UserProfilesRepository } from '@/models/_.js';
|
||||
import type { MiMeta, UserProfilesRepository } from '@/models/_.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
@@ -26,49 +25,41 @@ export class EmailService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private loggerService: LoggerService,
|
||||
private utilityService: UtilityService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('email');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async sendEmail(to: string, subject: string, html: string, text: string) {
|
||||
const meta = await this.metaService.fetch(true);
|
||||
|
||||
if (!meta.enableEmail) return;
|
||||
if (!this.meta.enableEmail) return;
|
||||
|
||||
const iconUrl = `${this.config.url}/static-assets/mi-white.png`;
|
||||
const emailSettingUrl = `${this.config.url}/settings/email`;
|
||||
|
||||
const enableAuth = meta.smtpUser != null && meta.smtpUser !== '';
|
||||
const enableAuth = this.meta.smtpUser != null && this.meta.smtpUser !== '';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: meta.smtpHost,
|
||||
port: meta.smtpPort,
|
||||
secure: meta.smtpSecure,
|
||||
host: this.meta.smtpHost,
|
||||
port: this.meta.smtpPort,
|
||||
secure: this.meta.smtpSecure,
|
||||
ignoreTLS: !enableAuth,
|
||||
proxy: this.config.proxySmtp,
|
||||
auth: enableAuth ? {
|
||||
user: meta.smtpUser,
|
||||
pass: meta.smtpPass,
|
||||
user: this.meta.smtpUser,
|
||||
pass: this.meta.smtpPass,
|
||||
} : undefined,
|
||||
} as any);
|
||||
|
||||
try {
|
||||
// TODO: htmlサニタイズ
|
||||
const info = await transporter.sendMail({
|
||||
from: meta.email!,
|
||||
to: to,
|
||||
subject: subject,
|
||||
text: text,
|
||||
html: `<!doctype html>
|
||||
const htmlContent = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -133,7 +124,7 @@ export class EmailService {
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<img src="${ meta.logoImageUrl ?? meta.iconUrl ?? iconUrl }"/>
|
||||
<img src="${ this.meta.logoImageUrl ?? this.meta.iconUrl ?? iconUrl }"/>
|
||||
</header>
|
||||
<article>
|
||||
<h1>${ subject }</h1>
|
||||
@@ -147,7 +138,18 @@ export class EmailService {
|
||||
<a href="${ this.config.url }">${ this.config.host }</a>
|
||||
</nav>
|
||||
</body>
|
||||
</html>`,
|
||||
</html>`;
|
||||
|
||||
const inlinedHtml = juice(htmlContent);
|
||||
|
||||
try {
|
||||
// TODO: htmlサニタイズ
|
||||
const info = await transporter.sendMail({
|
||||
from: this.meta.email!,
|
||||
to: to,
|
||||
subject: subject,
|
||||
text: text,
|
||||
html: inlinedHtml,
|
||||
});
|
||||
|
||||
this.logger.info(`Message sent: ${info.messageId}`);
|
||||
@@ -162,7 +164,12 @@ export class EmailService {
|
||||
available: boolean;
|
||||
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | 'banned' | 'network' | 'blacklist';
|
||||
}> {
|
||||
const meta = await this.metaService.fetch();
|
||||
if (!this.utilityService.validateEmailFormat(emailAddress)) {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'format',
|
||||
};
|
||||
}
|
||||
|
||||
const exist = await this.userProfilesRepository.countBy({
|
||||
emailVerified: true,
|
||||
@@ -181,11 +188,11 @@ export class EmailService {
|
||||
reason?: string | null,
|
||||
} = { valid: true, reason: null };
|
||||
|
||||
if (meta.enableActiveEmailValidation) {
|
||||
if (meta.enableVerifymailApi && meta.verifymailAuthKey != null) {
|
||||
validated = await this.verifyMail(emailAddress, meta.verifymailAuthKey);
|
||||
} else if (meta.enableTruemailApi && meta.truemailInstance && meta.truemailAuthKey != null) {
|
||||
validated = await this.trueMail(meta.truemailInstance, emailAddress, meta.truemailAuthKey);
|
||||
if (this.meta.enableActiveEmailValidation) {
|
||||
if (this.meta.enableVerifymailApi && this.meta.verifymailAuthKey != null) {
|
||||
validated = await this.verifyMail(emailAddress, this.meta.verifymailAuthKey);
|
||||
} else if (this.meta.enableTruemailApi && this.meta.truemailInstance && this.meta.truemailAuthKey != null) {
|
||||
validated = await this.trueMail(this.meta.truemailInstance, emailAddress, this.meta.truemailAuthKey);
|
||||
} else {
|
||||
validated = await validateEmail({
|
||||
email: emailAddress,
|
||||
@@ -215,7 +222,7 @@ export class EmailService {
|
||||
}
|
||||
|
||||
const emailDomain: string = emailAddress.split('@')[1];
|
||||
const isBanned = this.utilityService.isBlockedHost(meta.bannedEmailDomains, emailDomain);
|
||||
const isBanned = this.utilityService.isBlockedHost(this.meta.bannedEmailDomains, emailDomain);
|
||||
|
||||
if (isBanned) {
|
||||
return {
|
||||
@@ -312,6 +319,7 @@ export class EmailService {
|
||||
Accept: 'application/json',
|
||||
Authorization: truemailAuthKey,
|
||||
},
|
||||
isLocalAddressAllowed: true,
|
||||
});
|
||||
|
||||
const json = (await res.json()) as {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance> {
|
||||
public async fetchOrRegister(host: string): Promise<MiInstance> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
@@ -70,6 +70,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance | null> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
||||
if (index == null) {
|
||||
this.federatedInstanceCache.set(host, null);
|
||||
return null;
|
||||
} else {
|
||||
this.federatedInstanceCache.set(host, index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
|
||||
const result = await this.instancesRepository.createQueryBuilder().update()
|
||||
|
||||
@@ -82,7 +82,7 @@ export class FetchInstanceMetadataService {
|
||||
|
||||
try {
|
||||
if (!force) {
|
||||
const _instance = await this.federatedInstanceService.fetch(host);
|
||||
const _instance = await this.federatedInstanceService.fetchOrRegister(host);
|
||||
const now = Date.now();
|
||||
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
|
||||
// unlock at the finally caluse
|
||||
@@ -181,7 +181,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchDom(instance: MiInstance): Promise<DOMWindow['document']> {
|
||||
private async fetchDom(instance: MiInstance): Promise<Document> {
|
||||
this.logger.info(`Fetching HTML of ${instance.host} ...`);
|
||||
|
||||
const url = 'https://' + instance.host;
|
||||
@@ -206,7 +206,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise<string | null> {
|
||||
private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise<string | null> {
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
if (doc) {
|
||||
@@ -232,7 +232,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
|
||||
const url = 'https://' + instance.host;
|
||||
return (new URL(manifest.icons[0].src, url)).href;
|
||||
@@ -261,7 +261,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
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;
|
||||
|
||||
if (themeColor) {
|
||||
@@ -273,7 +273,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (typeof info.metadata.nodeName === 'string') {
|
||||
return info.metadata.nodeName;
|
||||
@@ -298,7 +298,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (typeof info.metadata.nodeDescription === 'string') {
|
||||
return info.metadata.nodeDescription;
|
||||
|
||||
@@ -13,14 +13,14 @@ import * as fileType from 'file-type';
|
||||
import FFmpeg from 'fluent-ffmpeg';
|
||||
import isSvg from 'is-svg';
|
||||
import probeImageSize from 'probe-image-size';
|
||||
import { type predictionType } from 'nsfwjs';
|
||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import { encode } from 'blurhash';
|
||||
import * as blurhash from 'blurhash';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
import { AiService } from '@/core/AiService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { PredictionType } from 'nsfwjs';
|
||||
|
||||
export type FileInfo = {
|
||||
size: number;
|
||||
@@ -170,7 +170,7 @@ export class FileInfoService {
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] {
|
||||
function judgePrediction(result: readonly PredictionType[]): [sensitive: boolean, porn: boolean] {
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
@@ -268,7 +268,6 @@ export class FileInfoService {
|
||||
private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> {
|
||||
const watcher = new FSWatcher({
|
||||
cwd,
|
||||
disableGlobbing: true,
|
||||
});
|
||||
let finished = false;
|
||||
command.once('end', () => {
|
||||
@@ -452,7 +451,7 @@ export class FileInfoService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average color of image
|
||||
* Calculate blurhash string of image
|
||||
*/
|
||||
@bindThis
|
||||
private getBlurhash(path: string, type: string): Promise<string> {
|
||||
@@ -467,7 +466,7 @@ export class FileInfoService {
|
||||
let hash;
|
||||
|
||||
try {
|
||||
hash = encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5);
|
||||
hash = blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5);
|
||||
} catch (e) {
|
||||
return reject(e);
|
||||
}
|
||||
|
||||
40
packages/backend/src/core/FlashService.ts
Normal file
40
packages/backend/src/core/FlashService.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { type FlashsRepository } from '@/models/_.js';
|
||||
|
||||
/**
|
||||
* MisskeyPlay関係のService
|
||||
*/
|
||||
@Injectable()
|
||||
export class FlashService {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashRepository: FlashsRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 人気のあるPlay一覧を取得する.
|
||||
*/
|
||||
public async featured(opts?: { offset?: number, limit: number }) {
|
||||
const builder = this.flashRepository.createQueryBuilder('flash')
|
||||
.andWhere('flash.likedCount > 0')
|
||||
.andWhere('flash.visibility = :visibility', { visibility: 'public' })
|
||||
.addOrderBy('flash.likedCount', 'DESC')
|
||||
.addOrderBy('flash.updatedAt', 'DESC')
|
||||
.addOrderBy('flash.id', 'DESC');
|
||||
|
||||
if (opts?.offset) {
|
||||
builder.skip(opts.offset);
|
||||
}
|
||||
|
||||
builder.take(opts?.limit ?? 10);
|
||||
|
||||
return await builder.getMany();
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,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';
|
||||
@@ -73,12 +73,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: {
|
||||
@@ -164,6 +160,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'>;
|
||||
@@ -249,7 +260,7 @@ export interface MahjongRoomEventTypes {
|
||||
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> = {
|
||||
@@ -258,7 +269,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']; };
|
||||
@@ -288,7 +299,7 @@ export interface InternalEventTypes {
|
||||
avatarDecorationCreated: MiAvatarDecoration;
|
||||
avatarDecorationDeleted: MiAvatarDecoration;
|
||||
avatarDecorationUpdated: MiAvatarDecoration;
|
||||
metaUpdated: MiMeta;
|
||||
metaUpdated: { before?: MiMeta; after: MiMeta; };
|
||||
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
|
||||
updateUserProfile: MiUserProfile;
|
||||
@@ -342,6 +353,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>;
|
||||
@@ -444,6 +463,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);
|
||||
|
||||
@@ -10,16 +10,18 @@ import type { MiUser } from '@/models/User.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiHashtag } from '@/models/Hashtag.js';
|
||||
import type { HashtagsRepository } from '@/models/_.js';
|
||||
import type { HashtagsRepository, MiMeta } from '@/models/_.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class HashtagService {
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||
|
||||
@@ -29,7 +31,6 @@ export class HashtagService {
|
||||
private userEntityService: UserEntityService,
|
||||
private featuredService: FeaturedService,
|
||||
private idService: IdService,
|
||||
private metaService: MetaService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
}
|
||||
@@ -160,10 +161,9 @@ export class HashtagService {
|
||||
|
||||
@bindThis
|
||||
public async updateHashtagsRanking(hashtag: string, userId: MiUser['id']): Promise<void> {
|
||||
const instance = await this.metaService.fetch();
|
||||
const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t));
|
||||
const hiddenTags = this.meta.hiddenTags.map(t => normalizeForSearch(t));
|
||||
if (hiddenTags.includes(hashtag)) return;
|
||||
if (this.utilityService.isKeyWordIncluded(hashtag, instance.sensitiveWords)) return;
|
||||
if (this.utilityService.isKeyWordIncluded(hashtag, this.meta.sensitiveWords)) return;
|
||||
|
||||
// YYYYMMDDHHmm (10分間隔)
|
||||
const now = new Date();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import * as net from 'node:net';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import CacheableLookup from 'cacheable-lookup';
|
||||
import fetch from 'node-fetch';
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
@@ -15,6 +16,7 @@ import type { Config } from '@/config.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import type { IObject } from '@/core/activitypub/type.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
@@ -24,27 +26,121 @@ export type HttpRequestSendOptions = {
|
||||
validators?: ((res: Response) => void)[];
|
||||
};
|
||||
|
||||
declare module 'node:http' {
|
||||
interface Agent {
|
||||
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
|
||||
}
|
||||
}
|
||||
|
||||
class HttpRequestServiceAgent extends http.Agent {
|
||||
constructor(
|
||||
private config: Config,
|
||||
options?: http.AgentOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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 {
|
||||
constructor(
|
||||
private config: Config,
|
||||
options?: https.AgentOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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()
|
||||
export class HttpRequestService {
|
||||
/**
|
||||
* Get http non-proxy agent (without local address filtering)
|
||||
*/
|
||||
private readonly httpNative: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https non-proxy agent (without local address filtering)
|
||||
*/
|
||||
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;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
@@ -56,19 +152,20 @@ export class HttpRequestService {
|
||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||
});
|
||||
|
||||
this.http = new http.Agent({
|
||||
const agentOption = {
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
||||
localAddress: config.outgoingAddress,
|
||||
});
|
||||
};
|
||||
|
||||
this.https = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
||||
localAddress: config.outgoingAddress,
|
||||
});
|
||||
this.httpNative = new http.Agent(agentOption);
|
||||
|
||||
this.httpsNative = new https.Agent(agentOption);
|
||||
|
||||
this.http = new HttpRequestServiceAgent(config, agentOption);
|
||||
|
||||
this.https = new HttpsRequestServiceAgent(config, agentOption);
|
||||
|
||||
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
||||
|
||||
@@ -100,19 +197,58 @@ export class HttpRequestService {
|
||||
/**
|
||||
* Get agent by URL
|
||||
* @param url URL
|
||||
* @param bypassProxy Allways bypass proxy
|
||||
* @param bypassProxy Always bypass proxy
|
||||
* @param isLocalAddressAllowed
|
||||
*/
|
||||
@bindThis
|
||||
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
|
||||
public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent {
|
||||
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
||||
if (isLocalAddressAllowed) {
|
||||
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||
}
|
||||
return url.protocol === 'http:' ? this.http : this.https;
|
||||
} else {
|
||||
if (isLocalAddressAllowed && (!this.config.proxy)) {
|
||||
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||
}
|
||||
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent for http by URL
|
||||
* @param url URL
|
||||
* @param isLocalAddressAllowed
|
||||
*/
|
||||
@bindThis
|
||||
public async getActivityJson(url: string): 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, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise<IObject> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -120,16 +256,22 @@ export class HttpRequestService {
|
||||
},
|
||||
timeout: 5000,
|
||||
size: 1024 * 256,
|
||||
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||
}, {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
validators: [validateContentTypeSetAsActivityPub],
|
||||
});
|
||||
|
||||
return await res.json() as IObject;
|
||||
const finalUrl = res.url; // redirects may have been involved
|
||||
const activity = await res.json() as IObject;
|
||||
|
||||
assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
|
||||
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<T> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
@@ -137,19 +279,21 @@ export class HttpRequestService {
|
||||
}, headers ?? {}),
|
||||
timeout: 5000,
|
||||
size: 1024 * 256,
|
||||
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||
});
|
||||
|
||||
return await res.json() as T;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> {
|
||||
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<string> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
timeout: 5000,
|
||||
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||
});
|
||||
|
||||
return await res.text();
|
||||
@@ -164,6 +308,7 @@ export class HttpRequestService {
|
||||
headers?: Record<string, string>,
|
||||
timeout?: number,
|
||||
size?: number,
|
||||
isLocalAddressAllowed?: boolean,
|
||||
} = {},
|
||||
extra: HttpRequestSendOptions = {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
@@ -177,6 +322,8 @@ export class HttpRequestService {
|
||||
controller.abort();
|
||||
}, timeout);
|
||||
|
||||
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: args.method ?? 'GET',
|
||||
headers: {
|
||||
@@ -185,7 +332,7 @@ export class HttpRequestService {
|
||||
},
|
||||
body: args.body,
|
||||
size: args.size ?? 10 * 1024 * 1024,
|
||||
agent: (url) => this.getAgentByUrl(url),
|
||||
agent: (url) => this.getAgentByUrl(url, false, isLocalAddressAllowed),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,8 @@ export class MetaService implements OnApplicationShutdown {
|
||||
switch (type) {
|
||||
case 'metaUpdated': {
|
||||
this.cache = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
|
||||
...body,
|
||||
proxyAccount: null, // joinなカラムは通常取ってこないので
|
||||
...(body.after),
|
||||
rootUser: null, // joinなカラムは通常取ってこないので
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -113,17 +113,20 @@ 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',
|
||||
},
|
||||
});
|
||||
|
||||
return afters[0];
|
||||
});
|
||||
|
||||
if (data.hiddenTags) {
|
||||
@@ -141,7 +144,7 @@ export class MetaService implements OnApplicationShutdown {
|
||||
});
|
||||
}
|
||||
|
||||
this.globalEventService.publishInternalEvent('metaUpdated', updated);
|
||||
this.globalEventService.publishInternalEvent('metaUpdated', { before, after: updated });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,39 @@ export class MfmService {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ruby': {
|
||||
let ruby: [string, string][] = [];
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeName === 'rp') {
|
||||
continue;
|
||||
}
|
||||
if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
|
||||
ruby.push([child.value, '']);
|
||||
continue;
|
||||
}
|
||||
if (child.nodeName === '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
|
||||
ruby = [];
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
} else {
|
||||
ruby.at(-1)![1] = rt;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// If any other element is included in ruby, it is treated as a normal text
|
||||
ruby = [];
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
for (const [base, rt] of ruby) {
|
||||
text += `$[ruby ${base} ${rt}]`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// block code (<pre><code>)
|
||||
case 'pre': {
|
||||
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
|
||||
@@ -239,7 +272,7 @@ export class MfmService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { window } = new Window();
|
||||
const { happyDOM, window } = new Window();
|
||||
|
||||
const doc = window.document;
|
||||
|
||||
@@ -406,8 +439,10 @@ export class MfmService {
|
||||
mention: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
const { username, host, acct } = node.props;
|
||||
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||
a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`);
|
||||
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;
|
||||
return a;
|
||||
@@ -457,6 +492,11 @@ export class MfmService {
|
||||
|
||||
appendChildren(nodes, body);
|
||||
|
||||
return new XMLSerializer().serializeToString(body);
|
||||
// Remove the unnecessary namespace
|
||||
const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*<p xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">/, '<p>');
|
||||
|
||||
happyDOM.close().catch(err => {});
|
||||
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import type { ModerationLogsRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ModerationLogPayloads, moderationLogTypes } from '@/types.js';
|
||||
import type { ModerationLogPayloads } from '@/types.js';
|
||||
import { moderationLogTypes } from '@/types.js';
|
||||
|
||||
@Injectable()
|
||||
export class ModerationLogService {
|
||||
|
||||
@@ -8,13 +8,12 @@ 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';
|
||||
import RE2 from 're2';
|
||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiApp } from '@/models/App.js';
|
||||
import { concat } from '@/misc/prelude/array.js';
|
||||
@@ -23,11 +22,8 @@ import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import type { IPoll } from '@/models/Poll.js';
|
||||
import { MiPoll } from '@/models/Poll.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { checkWordMute } from '@/misc/check-word-mute.js';
|
||||
import type { MiChannel } from '@/models/Channel.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { MemorySingleCache } from '@/misc/cache.js';
|
||||
import type { MiUserProfile } from '@/models/UserProfile.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
@@ -46,12 +42,10 @@ 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 { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
@@ -60,6 +54,8 @@ import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
@@ -151,11 +147,15 @@ type Option = {
|
||||
@Injectable()
|
||||
export class NoteCreateService implements OnApplicationShutdown {
|
||||
#shutdownController = new AbortController();
|
||||
private updateNotesCountQueue: CollapsedQueue<MiNote['id'], number>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@@ -198,7 +198,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,
|
||||
@@ -210,7 +209,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private apRendererService: ApRendererService,
|
||||
private roleService: RoleService,
|
||||
private metaService: MetaService,
|
||||
private searchService: SearchService,
|
||||
private notesChart: NotesChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
@@ -218,7 +216,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
private instanceChart: InstanceChart,
|
||||
private utilityService: UtilityService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
) { }
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async create(user: {
|
||||
@@ -251,10 +252,8 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
if (data.channel != null) data.visibleUsers = [];
|
||||
if (data.channel != null) data.localOnly = true;
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (data.visibility === 'public' && data.channel == null) {
|
||||
const sensitiveWords = meta.sensitiveWords;
|
||||
const sensitiveWords = this.meta.sensitiveWords;
|
||||
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) {
|
||||
data.visibility = 'home';
|
||||
} else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) {
|
||||
@@ -262,17 +261,17 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
const hasProhibitedWords = await this.checkProhibitedWordsContain({
|
||||
const hasProhibitedWords = this.checkProhibitedWordsContain({
|
||||
cw: data.cw,
|
||||
text: data.text,
|
||||
pollChoices: data.poll?.choices,
|
||||
}, meta.prohibitedWords);
|
||||
}, this.meta.prohibitedWords);
|
||||
|
||||
if (hasProhibitedWords) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
|
||||
}
|
||||
|
||||
const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host);
|
||||
const inSilencedInstance = this.utilityService.isSilencedHost(this.meta.silencedHosts, user.host);
|
||||
|
||||
if (data.visibility === 'public' && inSilencedInstance && user.host !== null) {
|
||||
data.visibility = 'home';
|
||||
@@ -364,6 +363,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {
|
||||
@@ -503,21 +505,21 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
host: MiUser['host'];
|
||||
isBot: MiUser['isBot'];
|
||||
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
this.notesChart.update(note, true);
|
||||
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) {
|
||||
this.perUserNotesChart.update(user, note, true);
|
||||
}
|
||||
|
||||
// Register host
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ハッシュタグ更新
|
||||
@@ -530,7 +532,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
|
||||
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);
|
||||
@@ -541,13 +546,21 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
this.followingsRepository.findBy({
|
||||
followeeId: user.id,
|
||||
notify: 'normal',
|
||||
}).then(followings => {
|
||||
}).then(async followings => {
|
||||
if (note.visibility !== 'specified') {
|
||||
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
|
||||
for (const following of followings) {
|
||||
// TODO: ワードミュート考慮
|
||||
this.notificationService.createNotification(following.followerId, 'note', {
|
||||
noteId: note.id,
|
||||
}, user.id);
|
||||
let isRenoteMuted = false;
|
||||
if (isPureRenote) {
|
||||
const userIdsWhoMeMutingRenotes = await this.cacheService.renoteMutingsCache.fetch(following.followerId);
|
||||
isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id);
|
||||
}
|
||||
if (!isRenoteMuted) {
|
||||
this.notificationService.createNotification(following.followerId, 'note', {
|
||||
noteId: note.id,
|
||||
}, user.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -570,31 +583,6 @@ export class NoteCreateService 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 });
|
||||
|
||||
@@ -602,14 +590,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
|
||||
this.roleService.addNoteToRoleTimeline(noteObj);
|
||||
|
||||
this.webhookService.getActiveWebhooks().then(webhooks => {
|
||||
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.userWebhookDeliver(webhook, 'note', {
|
||||
note: noteObj,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj });
|
||||
|
||||
const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note);
|
||||
|
||||
@@ -629,13 +610,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
if (!isThreadMuted) {
|
||||
nm.push(data.reply.userId, 'reply');
|
||||
this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.userWebhookDeliver(webhook, 'reply', {
|
||||
note: noteObj,
|
||||
});
|
||||
}
|
||||
this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -652,20 +627,14 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
// Publish event
|
||||
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
|
||||
this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.userWebhookDeliver(webhook, 'renote', {
|
||||
note: noteObj,
|
||||
});
|
||||
}
|
||||
this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj });
|
||||
}
|
||||
}
|
||||
|
||||
nm.notify();
|
||||
|
||||
//#region AP deliver
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
|
||||
(async () => {
|
||||
const noteActivity = await this.renderNoteOrRenoteActivity(data, note);
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||
@@ -784,13 +753,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
});
|
||||
|
||||
this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.userWebhookDeliver(webhook, 'mention', {
|
||||
note: detailPackedNote,
|
||||
});
|
||||
}
|
||||
this.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote });
|
||||
|
||||
// Create notification
|
||||
nm.push(u.id, 'mention');
|
||||
@@ -850,15 +813,14 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
||||
const meta = await this.metaService.fetch();
|
||||
if (!meta.enableFanoutTimeline) return;
|
||||
if (!this.meta.enableFanoutTimeline) return;
|
||||
|
||||
const r = this.redisForTimelines.pipeline();
|
||||
|
||||
if (note.channelId) {
|
||||
this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r);
|
||||
|
||||
this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
|
||||
const channelFollowings = await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
@@ -868,9 +830,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
});
|
||||
|
||||
for (const channelFollowing of channelFollowings) {
|
||||
this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -908,9 +870,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
if (!following.withReplies) continue;
|
||||
}
|
||||
|
||||
this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,25 +889,25 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
if (!userListMembership.withReplies) continue;
|
||||
}
|
||||
|
||||
this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r);
|
||||
this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r);
|
||||
this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
|
||||
// 自分自身のHTL
|
||||
if (note.userHost == null) {
|
||||
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) {
|
||||
this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r);
|
||||
this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自分自身以外への返信
|
||||
if (isReply(note)) {
|
||||
this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r);
|
||||
@@ -954,9 +916,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r);
|
||||
if (note.fileIds.length > 0) {
|
||||
this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r);
|
||||
this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax / 2 : this.meta.perRemoteUserUserTimelineCacheMax / 2, r);
|
||||
}
|
||||
|
||||
if (note.visibility === 'public' && note.userHost == null) {
|
||||
@@ -1015,9 +977,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
public async checkProhibitedWordsContain(content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0], prohibitedWords?: string[]) {
|
||||
public checkProhibitedWordsContain(content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0], prohibitedWords?: string[]) {
|
||||
if (prohibitedWords == null) {
|
||||
prohibitedWords = (await this.metaService.fetch()).prohibitedWords;
|
||||
prohibitedWords = this.meta.prohibitedWords;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1033,12 +995,23 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
private collapseNotesCount(oldValue: number, newValue: number) {
|
||||
return oldValue + newValue;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) {
|
||||
await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async dispose(): Promise<void> {
|
||||
this.#shutdownController.abort();
|
||||
await this.updateNotesCountQueue.performAllNow();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
|
||||
await this.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* 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 type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
@@ -19,9 +19,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
@@ -32,6 +30,9 @@ export class NoteDeleteService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -42,13 +43,11 @@ export class NoteDeleteService {
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private relayService: RelayService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private metaService: MetaService,
|
||||
private searchService: SearchService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private notesChart: NotesChart,
|
||||
@@ -92,7 +91,7 @@ export class NoteDeleteService {
|
||||
this.deliverToConcerned(user, note, content);
|
||||
}
|
||||
|
||||
// also deliever delete activity to cascaded notes
|
||||
// also deliver delete activity to cascaded notes
|
||||
const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes
|
||||
for (const cascadingNote of federatedLocalCascadingNotes) {
|
||||
if (!cascadingNote.user) continue;
|
||||
@@ -102,20 +101,20 @@ export class NoteDeleteService {
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
this.notesChart.update(note, false);
|
||||
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
if (this.meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
this.perUserNotesChart.update(user, note, false);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,13 +189,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);
|
||||
}
|
||||
this.apDeliverManagerService.deliverToUsers(user, content, [
|
||||
...await this.getMentionedRemoteUsers(note),
|
||||
...await this.getRenotedOrRepliedRemoteUsers(note),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,143 +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,
|
||||
};
|
||||
|
||||
await this.noteUnreadsRepository.insert(unread);
|
||||
|
||||
// 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()
|
||||
@@ -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),
|
||||
'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): string {
|
||||
const { date, additional } = this.idService.parseFull(id);
|
||||
return date.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) : null;
|
||||
let untilTime = untilId ? this.toXListId(untilId) : 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();
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class ProxyAccountService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(): Promise<MiLocalUser | null> {
|
||||
const meta = await this.metaService.fetch();
|
||||
if (meta.proxyAccountId == null) return null;
|
||||
return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as MiLocalUser;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||
import type { MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
|
||||
@@ -54,13 +53,14 @@ export class PushNotificationService implements OnApplicationShutdown {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.swSubscriptionsRepository)
|
||||
private swSubscriptionsRepository: SwSubscriptionsRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
this.subscriptionsCache = new RedisKVCache<MiSwSubscription[]>(this.redisClient, 'userSwSubscriptions', {
|
||||
lifetime: 1000 * 60 * 60 * 1, // 1h
|
||||
@@ -73,14 +73,12 @@ export class PushNotificationService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
public async pushNotification<T extends keyof PushNotificationsTypes>(userId: string, type: T, body: PushNotificationsTypes[T]) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
|
||||
if (!this.meta.enableServiceWorker || this.meta.swPublicKey == null || this.meta.swPrivateKey == null) return;
|
||||
|
||||
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
|
||||
push.setVapidDetails(this.config.url,
|
||||
meta.swPublicKey,
|
||||
meta.swPrivateKey);
|
||||
this.meta.swPublicKey,
|
||||
this.meta.swPrivateKey);
|
||||
|
||||
const subscriptions = await this.subscriptionsCache.fetch(userId);
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export class QueryService {
|
||||
|
||||
// ここでいうBlockedは被Blockedの意
|
||||
@bindThis
|
||||
public generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
@@ -127,7 +127,7 @@ export class QueryService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
|
||||
public generateMutedUserQueryForNotes(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 });
|
||||
|
||||
@@ -7,13 +7,15 @@ import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js';
|
||||
import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
|
||||
import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
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 { type UserWebhookPayload } from './UserWebhookService.js';
|
||||
import type {
|
||||
DbJobData,
|
||||
DeliverJobData,
|
||||
@@ -30,8 +32,8 @@ import type {
|
||||
ObjectStorageQueue,
|
||||
RelationshipQueue,
|
||||
SystemQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
} from './QueueModule.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import type * as Bull from 'bullmq';
|
||||
@@ -87,6 +89,19 @@ export class QueueService {
|
||||
repeat: { pattern: '*/5 * * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('bakeBufferedReactions', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('checkModeratorsActivity', {
|
||||
}, {
|
||||
// 毎時30分に起動
|
||||
repeat: { pattern: '30 * * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -452,10 +467,15 @@ export class QueueService {
|
||||
|
||||
/**
|
||||
* @see UserWebhookDeliverJobData
|
||||
* @see WebhookDeliverProcessorService
|
||||
* @see UserWebhookDeliverProcessorService
|
||||
*/
|
||||
@bindThis
|
||||
public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) {
|
||||
public userWebhookDeliver<T extends WebhookEventTypes>(
|
||||
webhook: MiWebhook,
|
||||
type: T,
|
||||
content: UserWebhookPayload<T>,
|
||||
opts?: { attempts?: number },
|
||||
) {
|
||||
const data: UserWebhookDeliverJobData = {
|
||||
type,
|
||||
content,
|
||||
@@ -468,7 +488,7 @@ export class QueueService {
|
||||
};
|
||||
|
||||
return this.userWebhookDeliverQueue.add(webhook.id, data, {
|
||||
attempts: 4,
|
||||
attempts: opts?.attempts ?? 4,
|
||||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
@@ -479,10 +499,15 @@ export class QueueService {
|
||||
|
||||
/**
|
||||
* @see SystemWebhookDeliverJobData
|
||||
* @see WebhookDeliverProcessorService
|
||||
* @see SystemWebhookDeliverProcessorService
|
||||
*/
|
||||
@bindThis
|
||||
public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) {
|
||||
public systemWebhookDeliver<T extends SystemWebhookEventType>(
|
||||
webhook: MiSystemWebhook,
|
||||
type: T,
|
||||
content: SystemWebhookPayload<T>,
|
||||
opts?: { attempts?: number },
|
||||
) {
|
||||
const data: SystemWebhookDeliverJobData = {
|
||||
type,
|
||||
content,
|
||||
@@ -494,7 +519,7 @@ export class QueueService {
|
||||
};
|
||||
|
||||
return this.systemWebhookDeliverQueue.add(webhook.id, data, {
|
||||
attempts: 4,
|
||||
attempts: opts?.attempts ?? 4,
|
||||
backoff: {
|
||||
type: 'custom',
|
||||
},
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js';
|
||||
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta } from '@/models/_.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
@@ -21,7 +20,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
@@ -30,9 +28,10 @@ import { RoleService } from '@/core/RoleService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
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';
|
||||
|
||||
const FALLBACK = '\u2764';
|
||||
const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
|
||||
|
||||
const legacies: Record<string, string> = {
|
||||
'like': '👍',
|
||||
@@ -71,8 +70,8 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/;
|
||||
@Injectable()
|
||||
export class ReactionService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@@ -87,12 +86,12 @@ export class ReactionService {
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private metaService: MetaService,
|
||||
private customEmojiService: CustomEmojiService,
|
||||
private roleService: RoleService,
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
private reactionsBufferingService: ReactionsBufferingService,
|
||||
private idService: IdService,
|
||||
private featuredService: FeaturedService,
|
||||
private globalEventService: GlobalEventService,
|
||||
@@ -148,6 +147,11 @@ export class ReactionService {
|
||||
if ((note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && emoji.isSensitive) {
|
||||
reaction = FALLBACK;
|
||||
}
|
||||
|
||||
// for media silenced host, custom emoji reactions are not allowed
|
||||
if (reacterHost != null && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, reacterHost)) {
|
||||
reaction = FALLBACK;
|
||||
}
|
||||
} else {
|
||||
// リアクションとして使う権限がない
|
||||
reaction = FALLBACK;
|
||||
@@ -167,7 +171,6 @@ export class ReactionService {
|
||||
reaction,
|
||||
};
|
||||
|
||||
// Create reaction
|
||||
try {
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
} catch (e) {
|
||||
@@ -191,16 +194,20 @@ export class ReactionService {
|
||||
}
|
||||
|
||||
// Increment reactions count
|
||||
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? {
|
||||
reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`,
|
||||
} : {}),
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
if (this.meta.enableReactionsBuffering) {
|
||||
await this.reactionsBufferingService.create(note.id, user.id, reaction, note.reactionAndUserPairCache);
|
||||
} else {
|
||||
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
...(note.reactionAndUserPairCache.length < PER_NOTE_REACTION_USER_PAIR_CACHE_MAX ? {
|
||||
reactionAndUserPairCache: () => `array_append("reactionAndUserPairCache", '${user.id}/${reaction}')`,
|
||||
} : {}),
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
}
|
||||
|
||||
// 30%の確率、セルフではない、3日以内に投稿されたノートの場合ハイライト用ランキング更新
|
||||
if (
|
||||
@@ -220,9 +227,7 @@ export class ReactionService {
|
||||
}
|
||||
}
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
if (this.meta.enableChartsForRemoteUser || (user.host == null)) {
|
||||
this.perUserReactionsChart.update(user, note);
|
||||
}
|
||||
|
||||
@@ -300,14 +305,18 @@ export class ReactionService {
|
||||
}
|
||||
|
||||
// Decrement reactions count
|
||||
const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`,
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
if (this.meta.enableReactionsBuffering) {
|
||||
await this.reactionsBufferingService.delete(note.id, user.id, exist.reaction);
|
||||
} else {
|
||||
const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
reactionAndUserPairCache: () => `array_remove("reactionAndUserPairCache", '${user.id}/${exist.reaction}')`,
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
}
|
||||
|
||||
this.globalEventService.publishNoteStream(note.id, 'unreacted', {
|
||||
reaction: this.decodeReaction(exist.reaction).reaction,
|
||||
@@ -329,8 +338,21 @@ export class ReactionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字列タイプのレガシーな形式のリアクションを現在の形式に変換しつつ、
|
||||
* データベース上には存在する「0個のリアクションがついている」という情報を削除する。
|
||||
* - 文字列タイプのレガシーな形式のリアクションを現在の形式に変換する
|
||||
* - ローカルのリアクションのホストを `@.` にする(`decodeReaction()`の効果)
|
||||
*/
|
||||
@bindThis
|
||||
public convertLegacyReaction(reaction: string): string {
|
||||
reaction = this.decodeReaction(reaction).reaction;
|
||||
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
|
||||
return reaction;
|
||||
}
|
||||
|
||||
// TODO: 廃止
|
||||
/**
|
||||
* - 文字列タイプのレガシーな形式のリアクションを現在の形式に変換する
|
||||
* - ローカルのリアクションのホストを `@.` にする(`decodeReaction()`の効果)
|
||||
* - データベース上には存在する「0個のリアクションがついている」という情報を削除する
|
||||
*/
|
||||
@bindThis
|
||||
public convertLegacyReactions(reactions: MiNote['reactions']): MiNote['reactions'] {
|
||||
@@ -343,10 +365,7 @@ export class ReactionService {
|
||||
return count > 0;
|
||||
})
|
||||
.map(([reaction, count]) => {
|
||||
// unchecked indexed access
|
||||
const convertedReaction = legacies[reaction] as string | undefined;
|
||||
|
||||
const key = this.decodeReaction(convertedReaction ?? reaction).reaction;
|
||||
const key = this.convertLegacyReaction(reaction);
|
||||
|
||||
return [key, count] as const;
|
||||
})
|
||||
@@ -401,11 +420,4 @@ export class ReactionService {
|
||||
host: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public convertLegacyReaction(reaction: string): string {
|
||||
reaction = this.decodeReaction(reaction).reaction;
|
||||
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
|
||||
return reaction;
|
||||
}
|
||||
}
|
||||
|
||||
211
packages/backend/src/core/ReactionsBufferingService.ts
Normal file
211
packages/backend/src/core/ReactionsBufferingService.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* 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 { DI } from '@/di-symbols.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiUser, NotesRepository } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
|
||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const REDIS_DELTA_PREFIX = 'reactionsBufferDeltas';
|
||||
const REDIS_PAIR_PREFIX = 'reactionsBufferPairs';
|
||||
|
||||
@Injectable()
|
||||
export class ReactionsBufferingService implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redisForSub)
|
||||
private redisForSub: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisForReactions)
|
||||
private redisForReactions: Redis.Redis, // TODO: 専用のRedisインスタンスにする
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
) {
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string) {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||
switch (type) {
|
||||
case 'metaUpdated': {
|
||||
// リアクションバッファリングが有効→無効になったら即bake
|
||||
if (body.before != null && body.before.enableReactionsBuffering && !body.after.enableReactionsBuffering) {
|
||||
this.bake();
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async create(noteId: MiNote['id'], userId: MiUser['id'], reaction: string, currentPairs: string[]): Promise<void> {
|
||||
const pipeline = this.redisForReactions.pipeline();
|
||||
pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, 1);
|
||||
for (let i = 0; i < currentPairs.length; i++) {
|
||||
pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, i, currentPairs[i]);
|
||||
}
|
||||
pipeline.zadd(`${REDIS_PAIR_PREFIX}:${noteId}`, Date.now(), `${userId}/${reaction}`);
|
||||
pipeline.zremrangebyrank(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -(PER_NOTE_REACTION_USER_PAIR_CACHE_MAX + 1));
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(noteId: MiNote['id'], userId: MiUser['id'], reaction: string): Promise<void> {
|
||||
const pipeline = this.redisForReactions.pipeline();
|
||||
pipeline.hincrby(`${REDIS_DELTA_PREFIX}:${noteId}`, reaction, -1);
|
||||
pipeline.zrem(`${REDIS_PAIR_PREFIX}:${noteId}`, `${userId}/${reaction}`);
|
||||
// TODO: 「消した要素一覧」も持っておかないとcreateされた時に上書きされて復活する
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async get(noteId: MiNote['id']): Promise<{
|
||||
deltas: Record<string, number>;
|
||||
pairs: ([MiUser['id'], string])[];
|
||||
}> {
|
||||
const pipeline = this.redisForReactions.pipeline();
|
||||
pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`);
|
||||
pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1);
|
||||
const results = await pipeline.exec();
|
||||
|
||||
const resultDeltas = results![0][1] as Record<string, string>;
|
||||
const resultPairs = results![1][1] as string[];
|
||||
|
||||
const deltas = {} as Record<string, number>;
|
||||
for (const [name, count] of Object.entries(resultDeltas)) {
|
||||
deltas[name] = parseInt(count);
|
||||
}
|
||||
|
||||
const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]);
|
||||
|
||||
return {
|
||||
deltas,
|
||||
pairs,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getMany(noteIds: MiNote['id'][]): Promise<Map<MiNote['id'], {
|
||||
deltas: Record<string, number>;
|
||||
pairs: ([MiUser['id'], string])[];
|
||||
}>> {
|
||||
const map = new Map<MiNote['id'], {
|
||||
deltas: Record<string, number>;
|
||||
pairs: ([MiUser['id'], string])[];
|
||||
}>();
|
||||
|
||||
const pipeline = this.redisForReactions.pipeline();
|
||||
for (const noteId of noteIds) {
|
||||
pipeline.hgetall(`${REDIS_DELTA_PREFIX}:${noteId}`);
|
||||
pipeline.zrange(`${REDIS_PAIR_PREFIX}:${noteId}`, 0, -1);
|
||||
}
|
||||
const results = await pipeline.exec();
|
||||
|
||||
const opsForEachNotes = 2;
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
const noteId = noteIds[i];
|
||||
const resultDeltas = results![i * opsForEachNotes][1] as Record<string, string>;
|
||||
const resultPairs = results![i * opsForEachNotes + 1][1] as string[];
|
||||
|
||||
const deltas = {} as Record<string, number>;
|
||||
for (const [name, count] of Object.entries(resultDeltas)) {
|
||||
deltas[name] = parseInt(count);
|
||||
}
|
||||
|
||||
const pairs = resultPairs.map(x => x.split('/') as [MiUser['id'], string]);
|
||||
|
||||
map.set(noteId, {
|
||||
deltas,
|
||||
pairs,
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// TODO: scanは重い可能性があるので、別途 bufferedNoteIds を直接Redis上に持っておいてもいいかもしれない
|
||||
@bindThis
|
||||
public async bake(): Promise<void> {
|
||||
const bufferedNoteIds = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
// https://github.com/redis/ioredis#transparent-key-prefixing
|
||||
const result = await this.redisForReactions.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:*`,
|
||||
'COUNT',
|
||||
'1000');
|
||||
|
||||
cursor = result[0];
|
||||
bufferedNoteIds.push(...result[1].map(x => x.replace(`${this.config.redis.prefix}:${REDIS_DELTA_PREFIX}:`, '')));
|
||||
} while (cursor !== '0');
|
||||
|
||||
const bufferedMap = await this.getMany(bufferedNoteIds);
|
||||
|
||||
// clear
|
||||
const pipeline = this.redisForReactions.pipeline();
|
||||
for (const noteId of bufferedNoteIds) {
|
||||
pipeline.del(`${REDIS_DELTA_PREFIX}:${noteId}`);
|
||||
pipeline.del(`${REDIS_PAIR_PREFIX}:${noteId}`);
|
||||
}
|
||||
await pipeline.exec();
|
||||
|
||||
// TODO: SQL一個にまとめたい
|
||||
for (const [noteId, buffered] of bufferedMap) {
|
||||
const sql = Object.entries(buffered.deltas)
|
||||
.map(([reaction, count]) =>
|
||||
`jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + ${count})::text::jsonb)`)
|
||||
.join(' || ');
|
||||
|
||||
this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
reactionAndUserPairCache: buffered.pairs.map(x => x.join('/')),
|
||||
})
|
||||
.where('id = :id', { id: noteId })
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public mergeReactions(src: MiNote['reactions'], delta: Record<string, number>): MiNote['reactions'] {
|
||||
const reactions = { ...src };
|
||||
for (const [name, count] of Object.entries(delta)) {
|
||||
if (reactions[name] != null) {
|
||||
reactions[name] += count;
|
||||
} else {
|
||||
reactions[name] = count;
|
||||
}
|
||||
}
|
||||
return reactions;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
@@ -4,51 +4,32 @@
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@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;
|
||||
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); // 10m
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -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);
|
||||
|
||||
@@ -56,7 +56,7 @@ export class RemoteUserResolveService {
|
||||
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
if (this.config.host === 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) {
|
||||
@@ -74,7 +74,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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { reversiUpdateKeys } from 'misskey-js';
|
||||
import * as Reversi from 'misskey-reversi';
|
||||
import { IsNull, LessThan, MoreThan } from 'typeorm';
|
||||
import type {
|
||||
@@ -399,7 +400,33 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateSettings(gameId: MiReversiGame['id'], user: MiUser, key: string, value: any) {
|
||||
public isValidReversiUpdateKey(key: unknown): key is typeof reversiUpdateKeys[number] {
|
||||
if (typeof key !== 'string') return false;
|
||||
return (reversiUpdateKeys as string[]).includes(key);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isValidReversiUpdateValue<K extends typeof reversiUpdateKeys[number]>(key: K, value: unknown): value is MiReversiGame[K] {
|
||||
switch (key) {
|
||||
case 'map':
|
||||
return Array.isArray(value) && value.every(row => typeof row === 'string');
|
||||
case 'bw':
|
||||
return typeof value === 'string' && ['random', '1', '2'].includes(value);
|
||||
case 'isLlotheo':
|
||||
return typeof value === 'boolean';
|
||||
case 'canPutEverywhere':
|
||||
return typeof value === 'boolean';
|
||||
case 'loopedBoard':
|
||||
return typeof value === 'boolean';
|
||||
case 'timeLimitForEachTurn':
|
||||
return typeof value === 'number' && value >= 0;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateSettings<K extends typeof reversiUpdateKeys[number]>(gameId: MiReversiGame['id'], user: MiUser, key: K, value: MiReversiGame[K]) {
|
||||
const game = await this.get(gameId);
|
||||
if (game == null) throw new Error('game not found');
|
||||
if (game.isStarted) return;
|
||||
@@ -407,10 +434,6 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
||||
if ((game.user1Id === user.id) && game.user1Ready) return;
|
||||
if ((game.user2Id === user.id) && game.user2Ready) return;
|
||||
|
||||
if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard', 'timeLimitForEachTurn'].includes(key)) return;
|
||||
|
||||
// TODO: より厳格なバリデーション
|
||||
|
||||
const updatedGame = {
|
||||
...game,
|
||||
[key]: value,
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as Redis from 'ioredis';
|
||||
import { In } from 'typeorm';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import type {
|
||||
MiMeta,
|
||||
MiRole,
|
||||
MiRoleAssignment,
|
||||
RoleAssignmentsRepository,
|
||||
@@ -18,7 +19,6 @@ import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { RoleCondFormulaValue } from '@/models/Role.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
@@ -58,6 +58,12 @@ export type RolePolicies = {
|
||||
userEachUserListsLimit: number;
|
||||
rateLimitFactor: number;
|
||||
avatarDecorationLimit: number;
|
||||
canImportAntennas: boolean;
|
||||
canImportBlocking: boolean;
|
||||
canImportFollowing: boolean;
|
||||
canImportMuting: boolean;
|
||||
canImportUserLists: boolean;
|
||||
chatAvailability: 'available' | 'readonly' | 'unavailable';
|
||||
};
|
||||
|
||||
export const DEFAULT_POLICIES: RolePolicies = {
|
||||
@@ -87,6 +93,12 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||
userEachUserListsLimit: 50,
|
||||
rateLimitFactor: 1,
|
||||
avatarDecorationLimit: 1,
|
||||
canImportAntennas: true,
|
||||
canImportBlocking: true,
|
||||
canImportFollowing: true,
|
||||
canImportMuting: true,
|
||||
canImportUserLists: true,
|
||||
chatAvailability: 'available',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -101,8 +113,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.redisForTimelines)
|
||||
private redisForTimelines: Redis.Redis,
|
||||
@@ -119,7 +131,6 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
@Inject(DI.roleAssignmentsRepository)
|
||||
private roleAssignmentsRepository: RoleAssignmentsRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private cacheService: CacheService,
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
@@ -127,10 +138,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
private moderationLogService: ModerationLogService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1);
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1);
|
||||
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
@@ -341,8 +350,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
|
||||
@bindThis
|
||||
public async getUserPolicies(userId: MiUser['id'] | null): Promise<RolePolicies> {
|
||||
const meta = await this.metaService.fetch();
|
||||
const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies };
|
||||
const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies };
|
||||
|
||||
if (userId == null) return basePolicies;
|
||||
|
||||
@@ -362,6 +370,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)),
|
||||
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
|
||||
@@ -389,65 +403,92 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)),
|
||||
rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)),
|
||||
avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)),
|
||||
canImportAntennas: calc('canImportAntennas', vs => vs.some(v => v === true)),
|
||||
canImportBlocking: calc('canImportBlocking', vs => vs.some(v => v === true)),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@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
|
||||
public async isExplorable(role: { id: MiRole['id']} | null): Promise<boolean> {
|
||||
public async isExplorable(role: { id: MiRole['id'] } | null): Promise<boolean> {
|
||||
if (role == null) return false;
|
||||
const check = await this.rolesRepository.findOneBy({ id: role.id });
|
||||
if (check == null) return false;
|
||||
return check.isExplorable;
|
||||
}
|
||||
|
||||
/**
|
||||
* モデレーター権限のロールが割り当てられているユーザID一覧を取得する.
|
||||
*
|
||||
* @param opts.includeAdmins 管理者権限も含めるか(デフォルト: true)
|
||||
* @param opts.includeRoot rootユーザも含めるか(デフォルト: false)
|
||||
* @param opts.excludeExpire 期限切れのロールを除外するか(デフォルト: false)
|
||||
*/
|
||||
@bindThis
|
||||
public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise<MiUser['id'][]> {
|
||||
public async getModeratorIds(opts?: {
|
||||
includeAdmins?: boolean,
|
||||
includeRoot?: boolean,
|
||||
excludeExpire?: boolean,
|
||||
}): Promise<MiUser['id'][]> {
|
||||
const includeAdmins = opts?.includeAdmins ?? true;
|
||||
const includeRoot = opts?.includeRoot ?? false;
|
||||
const excludeExpire = opts?.excludeExpire ?? false;
|
||||
|
||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||
const moderatorRoles = includeAdmins
|
||||
? roles.filter(r => r.isModerator || r.isAdministrator)
|
||||
: roles.filter(r => r.isModerator);
|
||||
|
||||
// TODO: isRootなアカウントも含める
|
||||
const assigns = moderatorRoles.length > 0
|
||||
? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
|
||||
: [];
|
||||
|
||||
// Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
|
||||
const now = Date.now();
|
||||
const result = [
|
||||
// Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
|
||||
...new Set(
|
||||
assigns
|
||||
.filter(it =>
|
||||
(excludeExpire)
|
||||
? (it.expiresAt == null || it.expiresAt.getTime() > now)
|
||||
: true,
|
||||
)
|
||||
.map(a => a.userId),
|
||||
),
|
||||
];
|
||||
const resultSet = new Set(
|
||||
assigns
|
||||
.filter(it =>
|
||||
(excludeExpire)
|
||||
? (it.expiresAt == null || it.expiresAt.getTime() > now)
|
||||
: true,
|
||||
)
|
||||
.map(a => a.userId),
|
||||
);
|
||||
|
||||
return result.sort((x, y) => x.localeCompare(y));
|
||||
if (includeRoot && this.meta.rootUserId) {
|
||||
resultSet.add(this.meta.rootUserId);
|
||||
}
|
||||
|
||||
return [...resultSet].sort((x, y) => x.localeCompare(y));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getModerators(includeAdmins = true): Promise<MiUser[]> {
|
||||
const ids = await this.getModeratorIds(includeAdmins);
|
||||
const users = ids.length > 0 ? await this.usersRepository.findBy({
|
||||
id: In(ids),
|
||||
}) : [];
|
||||
return users;
|
||||
public async getModerators(opts?: {
|
||||
includeAdmins?: boolean,
|
||||
includeRoot?: boolean,
|
||||
excludeExpire?: boolean,
|
||||
}): Promise<MiUser[]> {
|
||||
const ids = await this.getModeratorIds(opts);
|
||||
return ids.length > 0
|
||||
? await this.usersRepository.findBy({
|
||||
id: In(ids),
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -595,6 +636,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,
|
||||
|
||||
@@ -28,7 +28,7 @@ export class S3Service {
|
||||
? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}`
|
||||
: `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent
|
||||
|
||||
const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy);
|
||||
const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy, true);
|
||||
const handlerOption: NodeHttpHandlerOptions = {};
|
||||
if (meta.objectStorageUseSSL) {
|
||||
handlerOption.httpsAgent = agent as https.Agent;
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,17 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { type Config, FulltextSearchProvider } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { MiUser } from '@/models/_.js';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import { MiUser } from '@/models/_.js';
|
||||
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type { Index, MeiliSearch } from 'meilisearch';
|
||||
|
||||
type K = string;
|
||||
@@ -27,12 +28,24 @@ type Q =
|
||||
{ op: '<', k: K, v: number } |
|
||||
{ op: '>=', k: K, v: number } |
|
||||
{ op: '<=', k: K, v: number } |
|
||||
{ op: 'is null', k: K} |
|
||||
{ op: 'is not null', k: K} |
|
||||
{ op: 'is null', k: K } |
|
||||
{ op: 'is not null', k: K } |
|
||||
{ op: 'and', qs: Q[] } |
|
||||
{ op: 'or', qs: Q[] } |
|
||||
{ op: 'not', q: Q };
|
||||
|
||||
export type SearchOpts = {
|
||||
userId?: MiNote['userId'] | null;
|
||||
channelId?: MiNote['channelId'] | null;
|
||||
host?: string | null;
|
||||
};
|
||||
|
||||
export type SearchPagination = {
|
||||
untilId?: MiNote['id'];
|
||||
sinceId?: MiNote['id'];
|
||||
limit: number;
|
||||
};
|
||||
|
||||
function compileValue(value: V): string {
|
||||
if (typeof value === 'string') {
|
||||
return `'${value}'`; // TODO: escape
|
||||
@@ -64,7 +77,8 @@ function compileQuery(q: Q): string {
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local';
|
||||
private meilisearchNoteIndex: Index | null = null;
|
||||
private readonly meilisearchNoteIndex: Index | null = null;
|
||||
private readonly provider: FulltextSearchProvider;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
@@ -79,6 +93,7 @@ export class SearchService {
|
||||
private cacheService: CacheService,
|
||||
private queryService: QueryService,
|
||||
private idService: IdService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
if (meilisearch) {
|
||||
this.meilisearchNoteIndex = meilisearch.index(`${config.meilisearch!.index}---notes`);
|
||||
@@ -109,132 +124,185 @@ export class SearchService {
|
||||
if (config.meilisearch?.scope) {
|
||||
this.meilisearchIndexScope = config.meilisearch.scope;
|
||||
}
|
||||
|
||||
this.provider = config.fulltextSearch?.provider ?? 'sqlLike';
|
||||
this.loggerService.getLogger('SearchService').info(`-- Provider: ${this.provider}`);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async indexNote(note: MiNote): Promise<void> {
|
||||
if (!this.meilisearch) return;
|
||||
if (note.text == null && note.cw == null) return;
|
||||
if (!['home', 'public'].includes(note.visibility)) return;
|
||||
|
||||
if (this.meilisearch) {
|
||||
switch (this.meilisearchIndexScope) {
|
||||
case 'global':
|
||||
break;
|
||||
switch (this.meilisearchIndexScope) {
|
||||
case 'global':
|
||||
break;
|
||||
|
||||
case 'local':
|
||||
if (note.userHost == null) break;
|
||||
return;
|
||||
case 'local':
|
||||
if (note.userHost == null) break;
|
||||
return;
|
||||
|
||||
default: {
|
||||
if (note.userHost == null) break;
|
||||
if (this.meilisearchIndexScope.includes(note.userHost)) break;
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
if (note.userHost == null) break;
|
||||
if (this.meilisearchIndexScope.includes(note.userHost)) break;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.meilisearchNoteIndex?.addDocuments([{
|
||||
id: note.id,
|
||||
createdAt: this.idService.parse(note.id).date.getTime(),
|
||||
userId: note.userId,
|
||||
userHost: note.userHost,
|
||||
channelId: note.channelId,
|
||||
cw: note.cw,
|
||||
text: note.text,
|
||||
tags: note.tags,
|
||||
}], {
|
||||
primaryKey: 'id',
|
||||
});
|
||||
}
|
||||
|
||||
await this.meilisearchNoteIndex?.addDocuments([{
|
||||
id: note.id,
|
||||
createdAt: this.idService.parse(note.id).date.getTime(),
|
||||
userId: note.userId,
|
||||
userHost: note.userHost,
|
||||
channelId: note.channelId,
|
||||
cw: note.cw,
|
||||
text: note.text,
|
||||
tags: note.tags,
|
||||
}], {
|
||||
primaryKey: 'id',
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async unindexNote(note: MiNote): Promise<void> {
|
||||
if (!this.meilisearch) return;
|
||||
if (!['home', 'public'].includes(note.visibility)) return;
|
||||
|
||||
if (this.meilisearch) {
|
||||
this.meilisearchNoteIndex!.deleteDocument(note.id);
|
||||
await this.meilisearchNoteIndex?.deleteDocument(note.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async searchNote(
|
||||
q: string,
|
||||
me: MiUser | null,
|
||||
opts: SearchOpts,
|
||||
pagination: SearchPagination,
|
||||
): Promise<MiNote[]> {
|
||||
switch (this.provider) {
|
||||
case 'sqlLike':
|
||||
case 'sqlPgroonga': {
|
||||
// ほとんど内容に差がないのでsqlLikeとsqlPgroongaを同じ処理にしている.
|
||||
// 今後の拡張で差が出る用であれば関数を分ける.
|
||||
return this.searchNoteByLike(q, me, opts, pagination);
|
||||
}
|
||||
case 'meilisearch': {
|
||||
return this.searchNoteByMeiliSearch(q, me, opts, pagination);
|
||||
}
|
||||
default: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const typeCheck: never = this.provider;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async searchNote(q: string, me: MiUser | null, opts: {
|
||||
userId?: MiNote['userId'] | null;
|
||||
channelId?: MiNote['channelId'] | null;
|
||||
host?: string | null;
|
||||
}, pagination: {
|
||||
untilId?: MiNote['id'];
|
||||
sinceId?: MiNote['id'];
|
||||
limit?: number;
|
||||
}): Promise<MiNote[]> {
|
||||
if (this.meilisearch) {
|
||||
const filter: Q = {
|
||||
op: 'and',
|
||||
qs: [],
|
||||
};
|
||||
if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() });
|
||||
if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() });
|
||||
if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId });
|
||||
if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId });
|
||||
if (opts.host) {
|
||||
if (opts.host === '.') {
|
||||
filter.qs.push({ op: 'is null', k: 'userHost' });
|
||||
} else {
|
||||
filter.qs.push({ op: '=', k: 'userHost', v: opts.host });
|
||||
}
|
||||
private async searchNoteByLike(
|
||||
q: string,
|
||||
me: MiUser | null,
|
||||
opts: SearchOpts,
|
||||
pagination: SearchPagination,
|
||||
): Promise<MiNote[]> {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId);
|
||||
|
||||
if (opts.userId) {
|
||||
query.andWhere('note.userId = :userId', { userId: opts.userId });
|
||||
} else if (opts.channelId) {
|
||||
query.andWhere('note.channelId = :channelId', { channelId: opts.channelId });
|
||||
}
|
||||
|
||||
query
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (this.config.fulltextSearch?.provider === 'sqlPgroonga') {
|
||||
query.andWhere('note.text &@~ :q', { q });
|
||||
} else {
|
||||
query.andWhere('LOWER(note.text) LIKE :q', { q: `%${ sqlLikeEscape(q.toLowerCase()) }%` });
|
||||
}
|
||||
|
||||
if (opts.host) {
|
||||
if (opts.host === '.') {
|
||||
query.andWhere('user.host IS NULL');
|
||||
} else {
|
||||
query.andWhere('user.host = :host', { host: opts.host });
|
||||
}
|
||||
const res = await this.meilisearchNoteIndex!.search(q, {
|
||||
sort: ['createdAt:desc'],
|
||||
matchingStrategy: 'all',
|
||||
attributesToRetrieve: ['id', 'createdAt'],
|
||||
filter: compileQuery(filter),
|
||||
limit: pagination.limit,
|
||||
});
|
||||
if (res.hits.length === 0) return [];
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
userIdsWhoBlockingMe,
|
||||
] = me ? await Promise.all([
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
return query.limit(pagination.limit).getMany();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async searchNoteByMeiliSearch(
|
||||
q: string,
|
||||
me: MiUser | null,
|
||||
opts: SearchOpts,
|
||||
pagination: SearchPagination,
|
||||
): Promise<MiNote[]> {
|
||||
if (!this.meilisearch || !this.meilisearchNoteIndex) {
|
||||
throw new Error('MeiliSearch is not available');
|
||||
}
|
||||
|
||||
const filter: Q = {
|
||||
op: 'and',
|
||||
qs: [],
|
||||
};
|
||||
if (pagination.untilId) filter.qs.push({
|
||||
op: '<',
|
||||
k: 'createdAt',
|
||||
v: this.idService.parse(pagination.untilId).date.getTime(),
|
||||
});
|
||||
if (pagination.sinceId) filter.qs.push({
|
||||
op: '>',
|
||||
k: 'createdAt',
|
||||
v: this.idService.parse(pagination.sinceId).date.getTime(),
|
||||
});
|
||||
if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId });
|
||||
if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId });
|
||||
if (opts.host) {
|
||||
if (opts.host === '.') {
|
||||
filter.qs.push({ op: 'is null', k: 'userHost' });
|
||||
} else {
|
||||
filter.qs.push({ op: '=', k: 'userHost', v: opts.host });
|
||||
}
|
||||
}
|
||||
|
||||
const res = await this.meilisearchNoteIndex.search(q, {
|
||||
sort: ['createdAt:desc'],
|
||||
matchingStrategy: 'all',
|
||||
attributesToRetrieve: ['id', 'createdAt'],
|
||||
filter: compileQuery(filter),
|
||||
limit: pagination.limit,
|
||||
});
|
||||
if (res.hits.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
userIdsWhoBlockingMe,
|
||||
] = me
|
||||
? await Promise.all([
|
||||
this.cacheService.userMutingsCache.fetch(me.id),
|
||||
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 => {
|
||||
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
|
||||
return true;
|
||||
});
|
||||
return notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
} else {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId);
|
||||
])
|
||||
: [new Set<string>(), new Set<string>()];
|
||||
const notes = (await this.notesRepository.findBy({
|
||||
id: In(res.hits.map(x => x.id)),
|
||||
})).filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (opts.userId) {
|
||||
query.andWhere('note.userId = :userId', { userId: opts.userId });
|
||||
} else if (opts.channelId) {
|
||||
query.andWhere('note.channelId = :channelId', { channelId: opts.channelId });
|
||||
}
|
||||
|
||||
query
|
||||
.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (opts.host) {
|
||||
if (opts.host === '.') {
|
||||
query.andWhere('user.host IS NULL');
|
||||
} else {
|
||||
query.andWhere('user.host = :host', { host: opts.host });
|
||||
}
|
||||
}
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
||||
|
||||
return await query.limit(pagination.limit).getMany();
|
||||
}
|
||||
return notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,19 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsedUsernamesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiMeta, UsedUsernamesRepository, UsersRepository } from '@/models/_.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 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()
|
||||
@@ -28,6 +29,9 @@ export class SignupService {
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -35,10 +39,11 @@ export class SignupService {
|
||||
private usedUsernamesRepository: UsedUsernamesRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private userService: UserService,
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private metaService: MetaService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private usersChart: UsersChart,
|
||||
) {
|
||||
}
|
||||
@@ -71,7 +76,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() } })) {
|
||||
@@ -83,11 +88,8 @@ export class SignupService {
|
||||
throw new Error('USED_USERNAME');
|
||||
}
|
||||
|
||||
const isTheFirstUser = !await this.instanceActorService.realLocalUsersPresent();
|
||||
|
||||
if (!opts.ignorePreservedUsernames && !isTheFirstUser) {
|
||||
const instance = await this.metaService.fetch(true);
|
||||
const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
|
||||
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');
|
||||
}
|
||||
@@ -127,7 +129,6 @@ export class SignupService {
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: this.utilityService.toPunyNullable(host),
|
||||
token: secret,
|
||||
isRoot: isTheFirstUser,
|
||||
}));
|
||||
|
||||
await transactionalEntityManager.save(new MiUserKeypair({
|
||||
@@ -149,6 +150,11 @@ 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 };
|
||||
}
|
||||
|
||||
172
packages/backend/src/core/SystemAccountService.ts
Normal file
172
packages/backend/src/core/SystemAccountService.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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 { DataSource, IsNull } from 'typeorm';
|
||||
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 { 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 {
|
||||
private cache: MemoryKVCache<MiLocalUser>;
|
||||
|
||||
constructor(
|
||||
@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
|
||||
}
|
||||
|
||||
@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,
|
||||
}).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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,41 @@ import { QueueService } from '@/core/QueueService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { AbuseReportResolveType } from '@/models/AbuseUserReport.js';
|
||||
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
export type AbuseReportPayload = {
|
||||
id: string;
|
||||
targetUserId: string;
|
||||
targetUser: Packed<'UserLite'> | null;
|
||||
targetUserHost: string | null;
|
||||
reporterId: string;
|
||||
reporter: Packed<'UserLite'> | null;
|
||||
reporterHost: string | null;
|
||||
assigneeId: string | null;
|
||||
assignee: Packed<'UserLite'> | null;
|
||||
resolved: boolean;
|
||||
forwarded: boolean;
|
||||
comment: string;
|
||||
moderationNote: string;
|
||||
resolvedAs: AbuseReportResolveType | null;
|
||||
};
|
||||
|
||||
export type InactiveModeratorsWarningPayload = {
|
||||
remainingTime: ModeratorInactivityRemainingTime;
|
||||
};
|
||||
|
||||
export type SystemWebhookPayload<T extends SystemWebhookEventType> =
|
||||
T extends 'abuseReport' | 'abuseReportResolved' ? AbuseReportPayload :
|
||||
T extends 'userCreated' ? Packed<'UserLite'> :
|
||||
T extends 'inactiveModeratorsWarning' ? InactiveModeratorsWarningPayload :
|
||||
T extends 'inactiveModeratorsInvitationOnlyChanged' ? Record<string, never> :
|
||||
never;
|
||||
|
||||
@Injectable()
|
||||
export class SystemWebhookService implements OnApplicationShutdown {
|
||||
private logger: Logger;
|
||||
private activeSystemWebhooksFetched = false;
|
||||
private activeSystemWebhooks: MiSystemWebhook[] = [];
|
||||
|
||||
@@ -31,11 +61,9 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private loggerService: LoggerService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
this.logger = this.loggerService.getLogger('webhook');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -54,7 +82,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
||||
* SystemWebhook の一覧を取得する.
|
||||
*/
|
||||
@bindThis
|
||||
public async fetchSystemWebhooks(params?: {
|
||||
public fetchSystemWebhooks(params?: {
|
||||
ids?: MiSystemWebhook['id'][];
|
||||
isActive?: MiSystemWebhook['isActive'];
|
||||
on?: MiSystemWebhook['on'];
|
||||
@@ -101,8 +129,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
||||
.log(updater, 'createSystemWebhook', {
|
||||
systemWebhookId: webhook.id,
|
||||
webhook: webhook,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return webhook;
|
||||
}
|
||||
@@ -139,8 +166,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
||||
systemWebhookId: beforeEntity.id,
|
||||
before: beforeEntity,
|
||||
after: afterEntity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return afterEntity;
|
||||
}
|
||||
@@ -158,8 +184,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
||||
.log(updater, 'deleteSystemWebhook', {
|
||||
systemWebhookId: webhook.id,
|
||||
webhook,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,21 +192,22 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
||||
* @see QueueService.systemWebhookDeliver
|
||||
*/
|
||||
@bindThis
|
||||
public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) {
|
||||
const webhookEntity = typeof webhook === 'string'
|
||||
? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)
|
||||
: webhook;
|
||||
if (!webhookEntity || !webhookEntity.isActive) {
|
||||
this.logger.info(`Webhook is not active or not found : ${webhook}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!webhookEntity.on.includes(type)) {
|
||||
this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
return this.queueService.systemWebhookDeliver(webhookEntity, type, content);
|
||||
public async enqueueSystemWebhook<T extends SystemWebhookEventType>(
|
||||
type: T,
|
||||
content: SystemWebhookPayload<T>,
|
||||
opts?: {
|
||||
excludes?: MiSystemWebhook['id'][];
|
||||
},
|
||||
) {
|
||||
const webhooks = await this.fetchActiveSystemWebhooks()
|
||||
.then(webhooks => {
|
||||
return webhooks.filter(webhook => !opts?.excludes?.includes(webhook.id) && webhook.on.includes(type));
|
||||
});
|
||||
return Promise.all(
|
||||
webhooks.map(webhook => {
|
||||
return this.queueService.systemWebhookDeliver(webhook, type, content);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
||||
@@ -118,13 +118,7 @@ export class UserBlockingService implements OnModuleInit {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}).then(async packed => {
|
||||
this.globalEventService.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.userWebhookDeliver(webhook, 'unfollow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
this.webhookService.enqueueUserWebhook(follower.id, 'unfollow', { user: packed });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -13,23 +13,20 @@ import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
import type { ThinUser } from '@/queue/types.js';
|
||||
import Logger from '../logger.js';
|
||||
|
||||
@@ -58,6 +55,9 @@ export class UserFollowingService implements OnModuleInit {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -79,13 +79,11 @@ export class UserFollowingService implements OnModuleInit {
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private metaService: MetaService,
|
||||
private notificationService: NotificationService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private webhookService: UserWebhookService,
|
||||
private apRendererService: ApRendererService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {
|
||||
@@ -172,7 +170,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
followee.isLocked ||
|
||||
(followeeProfile.carefulBot && follower.isBot) ||
|
||||
(this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') ||
|
||||
(this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && this.utilityService.isSilencedHost((await this.metaService.fetch()).silencedHosts, follower.host))
|
||||
(this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && this.utilityService.isSilencedHost(this.meta.silencedHosts, follower.host))
|
||||
) {
|
||||
let autoAccept = false;
|
||||
|
||||
@@ -277,16 +275,19 @@ export class UserFollowingService implements OnModuleInit {
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
// 通知を作成
|
||||
if (follower.host === null) {
|
||||
this.notificationService.createNotification(follower.id, 'followRequestAccepted', {
|
||||
}, followee.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (alreadyFollowed) return;
|
||||
|
||||
// 通知を作成
|
||||
if (follower.host === null) {
|
||||
const profile = await this.cacheService.userProfileCache.fetch(followee.id);
|
||||
|
||||
this.notificationService.createNotification(follower.id, 'followRequestAccepted', {
|
||||
message: profile.followedMessage,
|
||||
}, followee.id);
|
||||
}
|
||||
|
||||
this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
const [followeeUser, followerUser] = await Promise.all([
|
||||
@@ -304,20 +305,22 @@ export class UserFollowingService implements OnModuleInit {
|
||||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
@@ -330,13 +333,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}).then(async packed => {
|
||||
this.globalEventService.publishMainStream(follower.id, 'follow', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.userWebhookDeliver(webhook, 'follow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
this.webhookService.enqueueUserWebhook(follower.id, 'follow', { user: packed });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -344,13 +341,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
if (this.userEntityService.isLocalUser(followee)) {
|
||||
this.userEntityService.pack(follower.id, followee).then(async packed => {
|
||||
this.globalEventService.publishMainStream(followee.id, 'followed', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.userWebhookDeliver(webhook, 'followed', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
this.webhookService.enqueueUserWebhook(followee.id, 'followed', { user: packed });
|
||||
});
|
||||
|
||||
// 通知を作成
|
||||
@@ -397,13 +388,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}).then(async packed => {
|
||||
this.globalEventService.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.userWebhookDeliver(webhook, 'unfollow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
this.webhookService.enqueueUserWebhook(follower.id, 'unfollow', { user: packed });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -436,20 +421,22 @@ export class UserFollowingService implements OnModuleInit {
|
||||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
@@ -739,13 +726,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||
});
|
||||
|
||||
this.globalEventService.publishMainStream(follower.id, 'unfollow', packedFollowee);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.userWebhookDeliver(webhook, 'unfollow', {
|
||||
user: packedFollowee,
|
||||
});
|
||||
}
|
||||
this.webhookService.enqueueUserWebhook(follower.id, 'unfollow', { user: packedFollowee });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -755,4 +736,30 @@ export class UserFollowingService implements OnModuleInit {
|
||||
.where('following.followerId = :followerId', { followerId: userId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
|
||||
return this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId,
|
||||
followeeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) {
|
||||
const count = await this.followingsRepository.createQueryBuilder('following')
|
||||
.where(new Brackets(qb => {
|
||||
qb.where('following.followerId = :aUserId', { aUserId })
|
||||
.andWhere('following.followeeId = :bUserId', { bUserId });
|
||||
}))
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb.where('following.followerId = :bUserId', { bUserId })
|
||||
.andWhere('following.followeeId = :aUserId', { aUserId });
|
||||
}))
|
||||
.getCount();
|
||||
|
||||
return count === 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export class UserKeypairService implements OnApplicationShutdown {
|
||||
) {
|
||||
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
|
||||
lifetime: 1000 * 60 * 60 * 24, // 24h
|
||||
memoryCacheLifetime: Infinity,
|
||||
memoryCacheLifetime: 1000 * 60 * 60, // 1h
|
||||
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
|
||||
@@ -15,11 +15,11 @@ import type { GlobalEvents } 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 { RoleService } from '@/core/RoleService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
||||
@@ -43,8 +43,8 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private proxyAccountService: ProxyAccountService,
|
||||
private queueService: QueueService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
) {
|
||||
this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
@@ -111,10 +111,8 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
||||
|
||||
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
|
||||
if (this.userEntityService.isRemoteUser(target)) {
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
if (proxy) {
|
||||
this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
|
||||
}
|
||||
const proxy = await this.systemAccountService.fetch('proxy');
|
||||
this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,18 @@ import type { FollowingsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -50,4 +53,16 @@ export class UserService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SystemWebhookを用いてユーザに関する操作内容を管理者各位に通知する.
|
||||
* ここではJobQueueへのエンキューのみを行うため、即時実行されない.
|
||||
*
|
||||
* @see SystemWebhookService.enqueueSystemWebhook
|
||||
*/
|
||||
@bindThis
|
||||
public async notifySystemWebhook(user: MiUser, type: 'userCreated') {
|
||||
const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' });
|
||||
return this.systemWebhookService.enqueueSystemWebhook(type, packedUser);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import type { FollowingsRepository } from '@/models/_.js';
|
||||
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
@@ -13,24 +13,75 @@ import { DI } from '@/di-symbols.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RelationshipJobData } from '@/queue/types.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserSuspendService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async doPostSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
|
||||
public async suspend(user: MiUser, moderator: MiUser): Promise<void> {
|
||||
await this.usersRepository.update(user.id, {
|
||||
isSuspended: true,
|
||||
});
|
||||
|
||||
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 => {});
|
||||
})();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async unsuspend(user: MiUser, moderator: MiUser): Promise<void> {
|
||||
await this.usersRepository.update(user.id, {
|
||||
isSuspended: false,
|
||||
});
|
||||
|
||||
this.moderationLogService.log(moderator, 'unsuspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await this.postUnsuspend(user).catch(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));
|
||||
@@ -58,7 +109,7 @@ export class UserSuspendService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async doPostUnsuspend(user: MiUser): Promise<void> {
|
||||
private async postUnsuspend(user: MiUser): Promise<void> {
|
||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
@@ -86,4 +137,26 @@ export class UserSuspendService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async unFollowAll(follower: MiUser) {
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
const jobs: RelationshipJobData[] = [];
|
||||
for (const following of followings) {
|
||||
if (following.followeeId && following.followerId) {
|
||||
jobs.push({
|
||||
from: { id: following.followerId },
|
||||
to: { id: following.followeeId },
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.queueService.createUnfollowJob(jobs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,26 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import type { WebhooksRepository } from '@/models/_.js';
|
||||
import type { MiWebhook } from '@/models/Webhook.js';
|
||||
import { MiUser, type WebhooksRepository } from '@/models/_.js';
|
||||
import { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
export type UserWebhookPayload<T extends WebhookEventTypes> =
|
||||
T extends 'note' | 'reply' | 'renote' | 'mention' ? {
|
||||
note: Packed<'Note'>,
|
||||
} :
|
||||
T extends 'follow' | 'unfollow' ? {
|
||||
user: Packed<'UserDetailedNotMe'>,
|
||||
} :
|
||||
T extends 'followed' ? {
|
||||
user: Packed<'UserLite'>,
|
||||
} : never;
|
||||
|
||||
@Injectable()
|
||||
export class UserWebhookService implements OnApplicationShutdown {
|
||||
private activeWebhooksFetched = false;
|
||||
@@ -22,6 +35,7 @@ export class UserWebhookService implements OnApplicationShutdown {
|
||||
private redisForSub: Redis.Redis,
|
||||
@Inject(DI.webhooksRepository)
|
||||
private webhooksRepository: WebhooksRepository,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
@@ -38,6 +52,50 @@ export class UserWebhookService implements OnApplicationShutdown {
|
||||
return this.activeWebhooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserWebhook の一覧を取得する.
|
||||
*/
|
||||
@bindThis
|
||||
public fetchWebhooks(params?: {
|
||||
ids?: MiWebhook['id'][];
|
||||
isActive?: MiWebhook['active'];
|
||||
on?: MiWebhook['on'];
|
||||
}): Promise<MiWebhook[]> {
|
||||
const query = this.webhooksRepository.createQueryBuilder('webhook');
|
||||
if (params) {
|
||||
if (params.ids && params.ids.length > 0) {
|
||||
query.andWhere('webhook.id IN (:...ids)', { ids: params.ids });
|
||||
}
|
||||
if (params.isActive !== undefined) {
|
||||
query.andWhere('webhook.active = :isActive', { isActive: params.isActive });
|
||||
}
|
||||
if (params.on && params.on.length > 0) {
|
||||
query.andWhere(':on <@ webhook.on', { on: params.on });
|
||||
}
|
||||
}
|
||||
|
||||
return query.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* UserWebhook をWebhook配送キューに追加する
|
||||
* @see QueueService.userWebhookDeliver
|
||||
*/
|
||||
@bindThis
|
||||
public async enqueueUserWebhook<T extends WebhookEventTypes>(
|
||||
userId: MiUser['id'],
|
||||
type: T,
|
||||
content: UserWebhookPayload<T>,
|
||||
) {
|
||||
const webhooks = await this.getActiveWebhooks()
|
||||
.then(webhooks => webhooks.filter(webhook => webhook.userId === userId && webhook.on.includes(type)));
|
||||
return Promise.all(
|
||||
webhooks.map(webhook => {
|
||||
return this.queueService.userWebhookDeliver(webhook, type, content);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onMessage(_: string, data: string): Promise<void> {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
@@ -3,19 +3,22 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { URL } from 'node:url';
|
||||
import { toASCII } from 'punycode';
|
||||
import { URL, domainToASCII } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
|
||||
@Injectable()
|
||||
export class UtilityService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -30,6 +33,19 @@ export class UtilityService {
|
||||
return this.toPuny(this.config.host) === this.toPuny(host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isUriLocal(uri: string): boolean {
|
||||
return this.punyHost(uri) === this.toPuny(this.config.host);
|
||||
}
|
||||
|
||||
// メールアドレスのバリデーションを行う
|
||||
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
|
||||
@bindThis
|
||||
public validateEmailFormat(email: string): boolean {
|
||||
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);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
||||
if (host == null) return false;
|
||||
@@ -42,6 +58,12 @@ export class UtilityService {
|
||||
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isMediaSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean {
|
||||
if (!silencedHosts || host == null) return false;
|
||||
return silencedHosts.some(x => host.toLowerCase() === x);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public concatNoteContentsForKeyWordCheck(content: {
|
||||
cw?: string | null;
|
||||
@@ -86,17 +108,39 @@ export class UtilityService {
|
||||
@bindThis
|
||||
public extractDbHost(uri: string): string {
|
||||
const url = new URL(uri);
|
||||
return this.toPuny(url.hostname);
|
||||
return this.toPuny(url.host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public toPuny(host: string): string {
|
||||
return toASCII(host.toLowerCase());
|
||||
return domainToASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public toPunyNullable(host: string | null | undefined): string | null {
|
||||
if (host == null) return null;
|
||||
return toASCII(host.toLowerCase());
|
||||
return domainToASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public punyHost(url: string): string {
|
||||
const urlObj = new URL(url);
|
||||
const host = `${this.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||
return host;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isFederationAllowedHost(host: string): boolean {
|
||||
if (this.meta.federation === 'none') return false;
|
||||
if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false;
|
||||
if (this.isBlockedHost(this.meta.blockedHosts, host)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isFederationAllowedUri(uri: string): boolean {
|
||||
const host = this.extractDbHost(uri);
|
||||
return this.isFederationAllowedHost(host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,9 @@ import {
|
||||
} from '@simplewebauthn/server';
|
||||
import { AttestationFormat, isoCBOR, isoUint8Array } from '@simplewebauthn/server/helpers';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UserSecurityKeysRepository } from '@/models/_.js';
|
||||
import type { MiMeta, UserSecurityKeysRepository } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { MiUser } from '@/models/_.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type {
|
||||
@@ -23,7 +22,6 @@ import type {
|
||||
AuthenticatorTransportFuture,
|
||||
CredentialDeviceType,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialDescriptorFuture,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/types';
|
||||
@@ -31,33 +29,33 @@ import type {
|
||||
@Injectable()
|
||||
export class WebAuthnService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getRelyingParty(): Promise<{ origin: string; rpId: string; rpName: string; rpIcon?: string; }> {
|
||||
const instance = await this.metaService.fetch();
|
||||
public getRelyingParty(): { origin: string; rpId: string; rpName: string; rpIcon?: string; } {
|
||||
return {
|
||||
origin: this.config.url,
|
||||
rpId: this.config.hostname,
|
||||
rpName: instance.name ?? this.config.host,
|
||||
rpIcon: instance.iconUrl ?? undefined,
|
||||
rpName: this.meta.name ?? this.config.host,
|
||||
rpIcon: this.meta.iconUrl ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async initiateRegistration(userId: MiUser['id'], userName: string, userDisplayName?: string): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const relyingParty = await this.getRelyingParty();
|
||||
const relyingParty = this.getRelyingParty();
|
||||
const keys = await this.userSecurityKeysRepository.findBy({
|
||||
userId: userId,
|
||||
});
|
||||
@@ -104,7 +102,7 @@ export class WebAuthnService {
|
||||
|
||||
await this.redisClient.del(`webauthn:challenge:${userId}`);
|
||||
|
||||
const relyingParty = await this.getRelyingParty();
|
||||
const relyingParty = this.getRelyingParty();
|
||||
|
||||
let verification;
|
||||
try {
|
||||
@@ -129,11 +127,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,
|
||||
@@ -143,7 +141,7 @@ export class WebAuthnService {
|
||||
|
||||
@bindThis
|
||||
public async initiateAuthentication(userId: MiUser['id']): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const relyingParty = await this.getRelyingParty();
|
||||
const relyingParty = this.getRelyingParty();
|
||||
const keys = await this.userSecurityKeysRepository.findBy({
|
||||
userId: userId,
|
||||
});
|
||||
@@ -166,16 +164,92 @@ export class WebAuthnService {
|
||||
return authenticationOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate Passkey Auth (Without specifying user)
|
||||
* @returns authenticationOptions
|
||||
*/
|
||||
@bindThis
|
||||
public async initiateSignInWithPasskeyAuthentication(context: string): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
const relyingParty = await this.getRelyingParty();
|
||||
|
||||
const authenticationOptions = await generateAuthenticationOptions({
|
||||
rpID: relyingParty.rpId,
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
await this.redisClient.setex(`webauthn:challenge:${context}`, 90, authenticationOptions.challenge);
|
||||
|
||||
return authenticationOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Webauthn AuthenticationCredential
|
||||
* @throws IdentifiableError
|
||||
* @returns If the challenge is successful, return the user ID. Otherwise, return null.
|
||||
*/
|
||||
@bindThis
|
||||
public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise<MiUser['id'] | null> {
|
||||
const challenge = await this.redisClient.getdel(`webauthn:challenge:${context}`);
|
||||
|
||||
if (!challenge) {
|
||||
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`);
|
||||
}
|
||||
|
||||
const key = await this.userSecurityKeysRepository.findOneBy({
|
||||
id: response.id,
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'Unknown Webauthn key');
|
||||
}
|
||||
|
||||
const relyingParty = await this.getRelyingParty();
|
||||
|
||||
let verification;
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response: response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: relyingParty.origin,
|
||||
expectedRPID: relyingParty.rpId,
|
||||
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}`);
|
||||
}
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (!verified) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.userSecurityKeysRepository.update({
|
||||
id: response.id,
|
||||
}, {
|
||||
lastUsed: new Date(),
|
||||
counter: authenticationInfo.newCounter,
|
||||
credentialDeviceType: authenticationInfo.credentialDeviceType,
|
||||
credentialBackedUp: authenticationInfo.credentialBackedUp,
|
||||
});
|
||||
|
||||
return key.userId;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
|
||||
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
|
||||
const challenge = await this.redisClient.getdel(`webauthn:challenge:${userId}`);
|
||||
|
||||
if (!challenge) {
|
||||
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
|
||||
}
|
||||
|
||||
await this.redisClient.del(`webauthn:challenge:${userId}`);
|
||||
|
||||
const key = await this.userSecurityKeysRepository.findOneBy({
|
||||
id: response.id,
|
||||
userId: userId,
|
||||
@@ -209,7 +283,7 @@ export class WebAuthnService {
|
||||
}
|
||||
}
|
||||
|
||||
const relyingParty = await this.getRelyingParty();
|
||||
const relyingParty = this.getRelyingParty();
|
||||
|
||||
let verification;
|
||||
try {
|
||||
@@ -218,9 +292,9 @@ 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,
|
||||
},
|
||||
|
||||
486
packages/backend/src/core/WebhookTestService.ts
Normal file
486
packages/backend/src/core/WebhookTestService.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
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 { 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 { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
|
||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||
|
||||
function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||
return {
|
||||
id: 'dummy-user-1',
|
||||
updatedAt: new Date(Date.now() - oneDayMillis * 7),
|
||||
lastFetchedAt: new Date(Date.now() - oneDayMillis * 5),
|
||||
lastActiveDate: new Date(Date.now() - oneDayMillis * 3),
|
||||
hideOnlineStatus: false,
|
||||
username: 'dummy1',
|
||||
usernameLower: 'dummy1',
|
||||
name: 'DummyUser1',
|
||||
followersCount: 10,
|
||||
followingCount: 5,
|
||||
movedToUri: null,
|
||||
movedAt: null,
|
||||
alsoKnownAs: null,
|
||||
notesCount: 30,
|
||||
avatarId: null,
|
||||
avatar: null,
|
||||
bannerId: null,
|
||||
banner: null,
|
||||
avatarUrl: null,
|
||||
bannerUrl: null,
|
||||
avatarBlurhash: null,
|
||||
bannerBlurhash: null,
|
||||
avatarDecorations: [],
|
||||
tags: [],
|
||||
isSuspended: false,
|
||||
isLocked: false,
|
||||
isBot: false,
|
||||
isCat: true,
|
||||
isExplorable: true,
|
||||
isHibernated: false,
|
||||
isDeleted: false,
|
||||
requireSigninToViewContents: false,
|
||||
makeNotesFollowersOnlyBefore: null,
|
||||
makeNotesHiddenBefore: null,
|
||||
chatScope: 'mutual',
|
||||
emojis: [],
|
||||
score: 0,
|
||||
host: null,
|
||||
inbox: null,
|
||||
sharedInbox: null,
|
||||
featured: null,
|
||||
uri: null,
|
||||
followersUri: null,
|
||||
token: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
||||
return {
|
||||
id: 'dummy-note-1',
|
||||
replyId: null,
|
||||
reply: null,
|
||||
renoteId: null,
|
||||
renote: null,
|
||||
threadId: null,
|
||||
text: 'This is a dummy note for testing purposes.',
|
||||
name: null,
|
||||
cw: null,
|
||||
userId: 'dummy-user-1',
|
||||
user: null,
|
||||
localOnly: true,
|
||||
reactionAcceptance: 'likeOnly',
|
||||
renoteCount: 10,
|
||||
repliesCount: 5,
|
||||
clippedCount: 0,
|
||||
reactions: {},
|
||||
visibility: 'public',
|
||||
uri: null,
|
||||
url: null,
|
||||
fileIds: [],
|
||||
attachedFileTypes: [],
|
||||
visibleUserIds: [],
|
||||
mentions: [],
|
||||
mentionedRemoteUsers: '[]',
|
||||
reactionAndUserPairCache: [],
|
||||
emojis: [],
|
||||
tags: [],
|
||||
hasPoll: false,
|
||||
channelId: null,
|
||||
channel: null,
|
||||
userHost: null,
|
||||
replyUserId: null,
|
||||
replyUserHost: null,
|
||||
renoteUserId: null,
|
||||
renoteUserHost: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
const dummyUser1 = generateDummyUser();
|
||||
const dummyUser2 = generateDummyUser({
|
||||
id: 'dummy-user-2',
|
||||
updatedAt: new Date(Date.now() - oneDayMillis * 30),
|
||||
lastFetchedAt: new Date(Date.now() - oneDayMillis),
|
||||
lastActiveDate: new Date(Date.now() - oneDayMillis),
|
||||
username: 'dummy2',
|
||||
usernameLower: 'dummy2',
|
||||
name: 'DummyUser2',
|
||||
followersCount: 40,
|
||||
followingCount: 50,
|
||||
notesCount: 900,
|
||||
});
|
||||
const dummyUser3 = generateDummyUser({
|
||||
id: 'dummy-user-3',
|
||||
updatedAt: new Date(Date.now() - oneDayMillis * 15),
|
||||
lastFetchedAt: new Date(Date.now() - oneDayMillis * 2),
|
||||
lastActiveDate: new Date(Date.now() - oneDayMillis * 2),
|
||||
username: 'dummy3',
|
||||
usernameLower: 'dummy3',
|
||||
name: 'DummyUser3',
|
||||
followersCount: 60,
|
||||
followingCount: 70,
|
||||
notesCount: 15900,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class WebhookTestService {
|
||||
public static NoSuchWebhookError = class extends Error {
|
||||
};
|
||||
|
||||
constructor(
|
||||
private customEmojiService: CustomEmojiService,
|
||||
private userWebhookService: UserWebhookService,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* UserWebhookのテスト送信を行う.
|
||||
* 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
|
||||
*
|
||||
* また、この関数経由で送信されるWebhookは以下の設定を無視する.
|
||||
* - Webhookそのものの有効・無効設定(active)
|
||||
* - 送信対象イベント(on)に関する設定
|
||||
*/
|
||||
@bindThis
|
||||
public async testUserWebhook<T extends WebhookEventTypes>(
|
||||
params: {
|
||||
webhookId: MiWebhook['id'],
|
||||
type: T,
|
||||
override?: Partial<Omit<MiWebhook, 'id'>>,
|
||||
},
|
||||
sender: MiUser | null,
|
||||
) {
|
||||
const webhooks = await this.userWebhookService.fetchWebhooks({ ids: [params.webhookId] })
|
||||
.then(it => it.filter(it => it.userId === sender?.id));
|
||||
if (webhooks.length === 0) {
|
||||
throw new WebhookTestService.NoSuchWebhookError();
|
||||
}
|
||||
|
||||
const webhook = webhooks[0];
|
||||
const send = <U extends WebhookEventTypes>(type: U, contents: UserWebhookPayload<U>) => {
|
||||
const merged = {
|
||||
...webhook,
|
||||
...params.override,
|
||||
};
|
||||
|
||||
// テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
|
||||
// また、Jobの試行回数も1回だけ.
|
||||
this.queueService.userWebhookDeliver(merged, type, contents, { attempts: 1 });
|
||||
};
|
||||
|
||||
const dummyNote1 = generateDummyNote({
|
||||
userId: dummyUser1.id,
|
||||
user: dummyUser1,
|
||||
});
|
||||
const dummyReply1 = generateDummyNote({
|
||||
id: 'dummy-reply-1',
|
||||
replyId: dummyNote1.id,
|
||||
reply: dummyNote1,
|
||||
userId: dummyUser1.id,
|
||||
user: dummyUser1,
|
||||
});
|
||||
const dummyRenote1 = generateDummyNote({
|
||||
id: 'dummy-renote-1',
|
||||
renoteId: dummyNote1.id,
|
||||
renote: dummyNote1,
|
||||
userId: dummyUser2.id,
|
||||
user: dummyUser2,
|
||||
text: null,
|
||||
});
|
||||
const dummyMention1 = generateDummyNote({
|
||||
id: 'dummy-mention-1',
|
||||
userId: dummyUser1.id,
|
||||
user: dummyUser1,
|
||||
text: `@${dummyUser2.username} This is a mention to you.`,
|
||||
mentions: [dummyUser2.id],
|
||||
});
|
||||
|
||||
switch (params.type) {
|
||||
case 'note': {
|
||||
send('note', { note: await this.toPackedNote(dummyNote1) });
|
||||
break;
|
||||
}
|
||||
case 'reply': {
|
||||
send('reply', { note: await this.toPackedNote(dummyReply1) });
|
||||
break;
|
||||
}
|
||||
case 'renote': {
|
||||
send('renote', { note: await this.toPackedNote(dummyRenote1) });
|
||||
break;
|
||||
}
|
||||
case 'mention': {
|
||||
send('mention', { note: await this.toPackedNote(dummyMention1) });
|
||||
break;
|
||||
}
|
||||
case 'follow': {
|
||||
send('follow', { user: await this.toPackedUserDetailedNotMe(dummyUser1) });
|
||||
break;
|
||||
}
|
||||
case 'followed': {
|
||||
send('followed', { user: await this.toPackedUserLite(dummyUser2) });
|
||||
break;
|
||||
}
|
||||
case 'unfollow': {
|
||||
send('unfollow', { user: await this.toPackedUserDetailedNotMe(dummyUser3) });
|
||||
break;
|
||||
}
|
||||
// まだ実装されていない (#9485)
|
||||
case 'reaction':
|
||||
return;
|
||||
default: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _exhaustiveAssertion: never = params.type;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SystemWebhookのテスト送信を行う.
|
||||
* 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
|
||||
*
|
||||
* また、この関数経由で送信されるWebhookは以下の設定を無視する.
|
||||
* - Webhookそのものの有効・無効設定(isActive)
|
||||
* - 送信対象イベント(on)に関する設定
|
||||
*/
|
||||
@bindThis
|
||||
public async testSystemWebhook<T extends SystemWebhookEventType>(
|
||||
params: {
|
||||
webhookId: MiSystemWebhook['id'],
|
||||
type: T,
|
||||
override?: Partial<Omit<MiSystemWebhook, 'id'>>,
|
||||
},
|
||||
) {
|
||||
const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [params.webhookId] });
|
||||
if (webhooks.length === 0) {
|
||||
throw new WebhookTestService.NoSuchWebhookError();
|
||||
}
|
||||
|
||||
const webhook = webhooks[0];
|
||||
const send = <U extends SystemWebhookEventType>(type: U, contents: SystemWebhookPayload<U>) => {
|
||||
const merged = {
|
||||
...webhook,
|
||||
...params.override,
|
||||
};
|
||||
|
||||
// テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
|
||||
// また、Jobの試行回数も1回だけ.
|
||||
this.queueService.systemWebhookDeliver(merged, type, contents, { attempts: 1 });
|
||||
};
|
||||
|
||||
switch (params.type) {
|
||||
case 'abuseReport': {
|
||||
send('abuseReport', await this.generateAbuseReport({
|
||||
targetUserId: dummyUser1.id,
|
||||
targetUser: dummyUser1,
|
||||
reporterId: dummyUser2.id,
|
||||
reporter: dummyUser2,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'abuseReportResolved': {
|
||||
send('abuseReportResolved', await this.generateAbuseReport({
|
||||
targetUserId: dummyUser1.id,
|
||||
targetUser: dummyUser1,
|
||||
reporterId: dummyUser2.id,
|
||||
reporter: dummyUser2,
|
||||
assigneeId: dummyUser3.id,
|
||||
assignee: dummyUser3,
|
||||
resolved: true,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'userCreated': {
|
||||
send('userCreated', await this.toPackedUserLite(dummyUser1));
|
||||
break;
|
||||
}
|
||||
case 'inactiveModeratorsWarning': {
|
||||
const dummyTime: ModeratorInactivityRemainingTime = {
|
||||
time: 100000,
|
||||
asDays: 1,
|
||||
asHours: 24,
|
||||
};
|
||||
|
||||
send('inactiveModeratorsWarning', {
|
||||
remainingTime: dummyTime,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'inactiveModeratorsInvitationOnlyChanged': {
|
||||
send('inactiveModeratorsInvitationOnlyChanged', {});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _exhaustiveAssertion: never = params.type;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async generateAbuseReport(override?: Partial<MiAbuseUserReport>): Promise<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 ? 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,
|
||||
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,
|
||||
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 {
|
||||
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,
|
||||
emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host),
|
||||
onlineStatus: 'active',
|
||||
badgeRoles: [],
|
||||
...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.bannerUrl,
|
||||
bannerBlurhash: user.bannerBlurhash,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
|
||||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import type { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
@@ -53,9 +54,10 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
||||
|
||||
private cacheService: CacheService,
|
||||
private apPersonService: ApPersonService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity);
|
||||
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(Infinity);
|
||||
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -63,7 +65,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
||||
const separator = '/';
|
||||
|
||||
const uri = new URL(getApId(value));
|
||||
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
|
||||
if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) {
|
||||
return { local: false, uri: uri.href };
|
||||
}
|
||||
|
||||
const [, type, id, ...rest] = uri.pathname.split(separator);
|
||||
return {
|
||||
|
||||
@@ -196,6 +196,25 @@ 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.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
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(
|
||||
|
||||
@@ -17,18 +17,18 @@ import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
@@ -48,6 +48,9 @@ export class ApInboxService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -64,7 +67,6 @@ export class ApInboxService {
|
||||
private noteEntityService: NoteEntityService,
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
private metaService: MetaService,
|
||||
private abuseReportService: AbuseReportService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private apAudienceService: ApAudienceService,
|
||||
@@ -88,15 +90,26 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
||||
public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
|
||||
let result = undefined as string | void;
|
||||
if (isCollectionOrOrderedCollection(activity)) {
|
||||
const results = [] as [string, string | void][];
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
|
||||
// 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;
|
||||
}
|
||||
try {
|
||||
results.push([getApId(item), await this.performOneActivity(actor, act)]);
|
||||
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
|
||||
} catch (err) {
|
||||
if (err instanceof Error || typeof err === 'string') {
|
||||
this.logger.error(err);
|
||||
@@ -111,13 +124,14 @@ export class ApInboxService {
|
||||
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
|
||||
}
|
||||
} else {
|
||||
result = await this.performOneActivity(actor, activity);
|
||||
result = await this.performOneActivity(actor, activity, resolver);
|
||||
}
|
||||
|
||||
// ついでにリモートユーザーの情報が古かったら更新しておく
|
||||
if (actor.uri) {
|
||||
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||
setImmediate(() => {
|
||||
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
||||
this.apPersonService.updatePerson(actor.uri);
|
||||
});
|
||||
}
|
||||
@@ -126,37 +140,37 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
||||
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
|
||||
if (actor.isSuspended) return;
|
||||
|
||||
if (isCreate(activity)) {
|
||||
return await this.create(actor, activity);
|
||||
return await this.create(actor, activity, resolver);
|
||||
} else if (isDelete(activity)) {
|
||||
return await this.delete(actor, activity);
|
||||
} else if (isUpdate(activity)) {
|
||||
return await this.update(actor, activity);
|
||||
return await this.update(actor, activity, resolver);
|
||||
} else if (isFollow(activity)) {
|
||||
return await this.follow(actor, activity);
|
||||
} else if (isAccept(activity)) {
|
||||
return await this.accept(actor, activity);
|
||||
return await this.accept(actor, activity, resolver);
|
||||
} else if (isReject(activity)) {
|
||||
return await this.reject(actor, activity);
|
||||
return await this.reject(actor, activity, resolver);
|
||||
} else if (isAdd(activity)) {
|
||||
return await this.add(actor, activity);
|
||||
return await this.add(actor, activity, resolver);
|
||||
} else if (isRemove(activity)) {
|
||||
return await this.remove(actor, activity);
|
||||
return await this.remove(actor, activity, resolver);
|
||||
} else if (isAnnounce(activity)) {
|
||||
return await this.announce(actor, activity);
|
||||
return await this.announce(actor, activity, resolver);
|
||||
} else if (isLike(activity)) {
|
||||
return await this.like(actor, activity);
|
||||
} else if (isUndo(activity)) {
|
||||
return await this.undo(actor, activity);
|
||||
return await this.undo(actor, activity, resolver);
|
||||
} else if (isBlock(activity)) {
|
||||
return await this.block(actor, activity);
|
||||
} else if (isFlag(activity)) {
|
||||
return await this.flag(actor, activity);
|
||||
} else if (isMove(activity)) {
|
||||
return await this.move(actor, activity);
|
||||
return await this.move(actor, activity, resolver);
|
||||
} else {
|
||||
return `unrecognized activity type: ${activity.type}`;
|
||||
}
|
||||
@@ -188,22 +202,26 @@ export class ApInboxService {
|
||||
|
||||
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
|
||||
|
||||
return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => {
|
||||
if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
|
||||
try {
|
||||
await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name);
|
||||
return 'ok';
|
||||
} catch (err) {
|
||||
if (err instanceof IdentifiableError && err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
|
||||
return 'skip: already reacted';
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}).then(() => 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async accept(actor: MiRemoteUser, activity: IAccept): Promise<string> {
|
||||
private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise<string> {
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
this.logger.info(`Accept: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(err => {
|
||||
this.logger.error(`Resolution failed: ${err}`);
|
||||
@@ -240,7 +258,7 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> {
|
||||
private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise<string | void> {
|
||||
if (actor.uri !== activity.actor) {
|
||||
return 'invalid actor';
|
||||
}
|
||||
@@ -250,7 +268,7 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
if (activity.target === actor.featured) {
|
||||
const note = await this.apNoteService.resolveNote(activity.object);
|
||||
const note = await this.apNoteService.resolveNote(activity.object, { resolver });
|
||||
if (note == null) return 'note not found';
|
||||
await this.notePiningService.addPinned(actor, note.id);
|
||||
return;
|
||||
@@ -260,12 +278,13 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> {
|
||||
private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise<string | void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
this.logger.info(`Announce: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
if (!activity.object) return 'skip: activity has no object property';
|
||||
const targetUri = getApId(activity.object);
|
||||
@@ -273,7 +292,7 @@ export class ApInboxService {
|
||||
|
||||
const target = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
return e;
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (isPost(target)) return await this.announceNote(actor, activity, target);
|
||||
@@ -282,16 +301,15 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> {
|
||||
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string | void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
if (actor.isSuspended) {
|
||||
return;
|
||||
}
|
||||
|
||||
// アナウンス先をブロックしてたら中断
|
||||
const meta = await this.metaService.fetch();
|
||||
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) return;
|
||||
// アナウンス先が許可されているかチェック
|
||||
if (!this.utilityService.isFederationAllowedUri(uri)) return;
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
@@ -305,7 +323,7 @@ export class ApInboxService {
|
||||
// Announce対象をresolve
|
||||
let renote;
|
||||
try {
|
||||
renote = await this.apNoteService.resolveNote(target);
|
||||
renote = await this.apNoteService.resolveNote(target, { resolver });
|
||||
if (renote == null) return 'announce target is null';
|
||||
} catch (err) {
|
||||
// 対象が4xxならスキップ
|
||||
@@ -324,7 +342,7 @@ export class ApInboxService {
|
||||
|
||||
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
||||
|
||||
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc);
|
||||
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver);
|
||||
const createdAt = activity.published ? new Date(activity.published) : null;
|
||||
|
||||
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
|
||||
@@ -362,7 +380,7 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> {
|
||||
private async create(actor: MiRemoteUser, activity: ICreate, resolver?: Resolver): Promise<string | void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
this.logger.info(`Create: ${uri}`);
|
||||
@@ -387,7 +405,8 @@ export class ApInboxService {
|
||||
activity.object.attributedTo = activity.actor;
|
||||
}
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
@@ -414,6 +433,8 @@ export class ApInboxService {
|
||||
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
|
||||
return 'skip: host in actor.uri !== note.id';
|
||||
}
|
||||
} else {
|
||||
return 'skip: note.id is not a string';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +444,7 @@ export class ApInboxService {
|
||||
const exist = await this.apNoteService.fetchNote(note);
|
||||
if (exist) return 'skip: note exists';
|
||||
|
||||
await this.apNoteService.createNote(note, resolver, silent);
|
||||
await this.apNoteService.createNote(note, actor, resolver, silent);
|
||||
return 'ok';
|
||||
} catch (err) {
|
||||
if (err instanceof StatusError && !err.isRetryable) {
|
||||
@@ -486,19 +507,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}`;
|
||||
@@ -555,12 +569,13 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async reject(actor: MiRemoteUser, activity: IReject): Promise<string> {
|
||||
private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise<string> {
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
this.logger.info(`Reject: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
@@ -597,7 +612,7 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> {
|
||||
private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise<string | void> {
|
||||
if (actor.uri !== activity.actor) {
|
||||
return 'invalid actor';
|
||||
}
|
||||
@@ -607,7 +622,7 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
if (activity.target === actor.featured) {
|
||||
const note = await this.apNoteService.resolveNote(activity.object);
|
||||
const note = await this.apNoteService.resolveNote(activity.object, { resolver });
|
||||
if (note == null) return 'note not found';
|
||||
await this.notePiningService.removePinned(actor, note.id);
|
||||
return;
|
||||
@@ -617,7 +632,7 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> {
|
||||
private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise<string> {
|
||||
if (actor.uri !== activity.actor) {
|
||||
return 'invalid actor';
|
||||
}
|
||||
@@ -626,11 +641,12 @@ export class ApInboxService {
|
||||
|
||||
this.logger.info(`Undo: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
return e;
|
||||
throw e;
|
||||
});
|
||||
|
||||
// don't queue because the sender may attempt again when timeout
|
||||
@@ -750,14 +766,15 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async update(actor: MiRemoteUser, activity: IUpdate): Promise<string> {
|
||||
private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise<string> {
|
||||
if (actor.uri !== activity.actor) {
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
this.logger.debug('Update');
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
@@ -768,7 +785,7 @@ export class ApInboxService {
|
||||
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
||||
return 'ok: Person updated';
|
||||
} else if (getApType(object) === 'Question') {
|
||||
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
|
||||
await this.apQuestionService.updateQuestion(object, actor, resolver).catch(err => console.error(err));
|
||||
return 'ok: Question updated';
|
||||
} else {
|
||||
return `skip: Unknown type: ${getApType(object)}`;
|
||||
@@ -776,11 +793,11 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async move(actor: MiRemoteUser, activity: IMove): Promise<string> {
|
||||
private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise<string> {
|
||||
// fetch the new and old accounts
|
||||
const targetUri = getApHrefNullable(activity.target);
|
||||
if (!targetUri) return 'skip: invalid activity target';
|
||||
|
||||
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
|
||||
return await this.apPersonService.updatePerson(actor.uri, resolver) ?? 'skip: nothing to do';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ import { MfmService } 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';
|
||||
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js';
|
||||
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository, MiMeta } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { JsonLdService } from './JsonLdService.js';
|
||||
import { ApMfmService } from './ApMfmService.js';
|
||||
import { CONTEXT } from './misc/contexts.js';
|
||||
@@ -38,6 +39,9 @@ export class ApRendererService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -61,6 +65,7 @@ export class ApRendererService {
|
||||
private apMfmService: ApMfmService,
|
||||
private mfmService: MfmService,
|
||||
private idService: IdService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -183,6 +188,9 @@ export class ApRendererService {
|
||||
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||
url: emoji.publicUrl || emoji.originalUrl,
|
||||
},
|
||||
_misskey_license: {
|
||||
freeText: emoji.license,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,6 +258,38 @@ 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 renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey {
|
||||
return {
|
||||
@@ -459,11 +499,28 @@ export class ApRendererService {
|
||||
this.userProfilesRepository.findOneByOrFail({ userId: user.id }),
|
||||
]);
|
||||
|
||||
const tryRewriteUrl = (maybeUrl: string) => {
|
||||
const urlSafeRegex = /^(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/;
|
||||
try {
|
||||
const match = maybeUrl.match(urlSafeRegex);
|
||||
if (!match) {
|
||||
return maybeUrl;
|
||||
}
|
||||
const urlPart = match[0];
|
||||
const urlPartParsed = new URL(urlPart);
|
||||
const restPart = maybeUrl.slice(match[0].length);
|
||||
|
||||
return `<a href="${urlPartParsed.href}" rel="me nofollow noopener" target="_blank">${urlPart}</a>${restPart}`;
|
||||
} catch (e) {
|
||||
return maybeUrl;
|
||||
}
|
||||
};
|
||||
|
||||
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>`
|
||||
? tryRewriteUrl(field.value)
|
||||
: field.value,
|
||||
}));
|
||||
|
||||
@@ -494,8 +551,12 @@ export class ApRendererService {
|
||||
name: user.name,
|
||||
summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null,
|
||||
_misskey_summary: profile.description,
|
||||
icon: avatar ? this.renderImage(avatar) : null,
|
||||
image: banner ? this.renderImage(banner) : null,
|
||||
_misskey_followedMessage: profile.followedMessage,
|
||||
_misskey_requireSigninToViewContents: user.requireSigninToViewContents,
|
||||
_misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore,
|
||||
_misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore,
|
||||
icon: avatar ? this.renderImage(avatar) : isSystem ? this.renderSystemAvatar(user) : this.renderIdenticon(user),
|
||||
image: banner ? this.renderImage(banner) : isSystem ? this.renderSystemBanner() : null,
|
||||
tag,
|
||||
manuallyApprovesFollowers: user.isLocked,
|
||||
discoverable: user.isExplorable,
|
||||
@@ -570,7 +631,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',
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Window } from 'happy-dom';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
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 { assertActivityMatchesUrl, FetchAllowSoftFailMask as FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import type { IObject } from './type.js';
|
||||
|
||||
type Request = {
|
||||
url: string;
|
||||
@@ -144,6 +148,7 @@ export class ApRequestService {
|
||||
private userKeypairService: UserKeypairService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
||||
@@ -180,7 +185,8 @@ export class ApRequestService {
|
||||
* @param url URL to fetch
|
||||
*/
|
||||
@bindThis
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }): Promise<unknown> {
|
||||
public async signedGet(url: string, user: { id: MiUser['id'] }, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict, followAlternate?: boolean): Promise<unknown> {
|
||||
const _followAlternate = followAlternate ?? true;
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
const req = ApRequestCreator.createSignedGet({
|
||||
@@ -198,9 +204,62 @@ export class ApRequestService {
|
||||
headers: req.request.headers,
|
||||
}, {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
validators: [validateContentTypeSetAsActivityPub],
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
//#region リクエスト先がhtmlかつactivity+jsonへのalternate linkタグがあるとき
|
||||
const contentType = res.headers.get('content-type');
|
||||
|
||||
if (
|
||||
res.ok &&
|
||||
(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;
|
||||
try {
|
||||
document.documentElement.innerHTML = html;
|
||||
|
||||
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
|
||||
if (alternate) {
|
||||
const href = alternate.getAttribute('href');
|
||||
if (href && this.utilityService.punyHost(url) === this.utilityService.punyHost(href)) {
|
||||
return await this.signedGet(href, user, allowSoftfail, false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// something went wrong parsing the HTML, ignore the whole thing
|
||||
} finally {
|
||||
happyDOM.close().catch(err => {});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
validateContentTypeSetAsActivityPub(res);
|
||||
const finalUrl = res.url; // redirects may have been involved
|
||||
const activity = await res.json() as IObject;
|
||||
|
||||
assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail);
|
||||
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,20 +6,21 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js';
|
||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isCollectionOrOrderedCollection } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
import { ApRequestService } from './ApRequestService.js';
|
||||
import { FetchAllowSoftFailMask } from './misc/check-against-url.js';
|
||||
import type { IObject, ICollection, IOrderedCollection } from './type.js';
|
||||
|
||||
export class Resolver {
|
||||
@@ -29,20 +30,20 @@ export class Resolver {
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
private meta: MiMeta,
|
||||
private usersRepository: UsersRepository,
|
||||
private notesRepository: NotesRepository,
|
||||
private pollsRepository: PollsRepository,
|
||||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
private utilityService: UtilityService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private metaService: MetaService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private apRequestService: ApRequestService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private loggerService: LoggerService,
|
||||
private recursionLimit = 100,
|
||||
private recursionLimit = 256,
|
||||
) {
|
||||
this.history = new Set();
|
||||
this.logger = this.loggerService.getLogger('ap-resolve');
|
||||
@@ -53,6 +54,11 @@ export class Resolver {
|
||||
return Array.from(this.history);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getRecursionLimit(): number {
|
||||
return this.recursionLimit;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
||||
const collection = typeof value === 'string'
|
||||
@@ -62,12 +68,12 @@ export class Resolver {
|
||||
if (isCollectionOrOrderedCollection(collection)) {
|
||||
return collection;
|
||||
} else {
|
||||
throw new Error(`unrecognized collection type: ${collection.type}`);
|
||||
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `unrecognized collection type: ${collection.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async resolve(value: string | IObject): Promise<IObject> {
|
||||
public async resolve(value: string | IObject, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise<IObject> {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
@@ -76,15 +82,15 @@ export class Resolver {
|
||||
// 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 Error(`cannot resolve URL with fragment: ${value}`);
|
||||
throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `cannot resolve URL with fragment: ${value}`);
|
||||
}
|
||||
|
||||
if (this.history.has(value)) {
|
||||
throw new Error('cannot resolve already resolved one');
|
||||
throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', 'cannot resolve already resolved one');
|
||||
}
|
||||
|
||||
if (this.history.size > this.recursionLimit) {
|
||||
throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`);
|
||||
throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `hit recursion limit: ${this.utilityService.extractDbHost(value)}`);
|
||||
}
|
||||
|
||||
this.history.add(value);
|
||||
@@ -94,25 +100,24 @@ export class Resolver {
|
||||
return await this.resolveLocal(value);
|
||||
}
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
if (this.utilityService.isBlockedHost(meta.blockedHosts, host)) {
|
||||
throw new Error('Instance is blocked');
|
||||
if (!this.utilityService.isFederationAllowedHost(host)) {
|
||||
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', 'Instance 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, allowSoftfail) as IObject
|
||||
: await this.httpRequestService.getActivityJson(value, undefined, allowSoftfail)) as IObject;
|
||||
|
||||
if (
|
||||
Array.isArray(object['@context']) ?
|
||||
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
|
||||
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
|
||||
) {
|
||||
throw new Error('invalid response');
|
||||
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', 'invalid response');
|
||||
}
|
||||
|
||||
return object;
|
||||
@@ -121,7 +126,7 @@ export class Resolver {
|
||||
@bindThis
|
||||
private resolveLocal(url: string): Promise<IObject> {
|
||||
const parsed = this.apDbResolverService.parseUri(url);
|
||||
if (!parsed.local) throw new Error('resolveLocal: not local');
|
||||
if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', 'resolveLocal: not local');
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'notes':
|
||||
@@ -150,7 +155,7 @@ export class Resolver {
|
||||
case 'follows':
|
||||
return this.followRequestsRepository.findOneBy({ id: parsed.id })
|
||||
.then(async followRequest => {
|
||||
if (followRequest == null) throw new Error('resolveLocal: invalid follow request ID');
|
||||
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', 'resolveLocal: invalid follow request ID');
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneBy({
|
||||
id: followRequest.followerId,
|
||||
@@ -162,12 +167,12 @@ export class Resolver {
|
||||
}),
|
||||
]);
|
||||
if (follower == null || followee == null) {
|
||||
throw new Error('resolveLocal: follower or followee does not exist');
|
||||
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', 'resolveLocal: 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 Error(`resolveLocal: type ${parsed.type} unhandled`);
|
||||
throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `resolveLocal: type ${parsed.type} unhandled`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +183,9 @@ export class ApResolverService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -194,8 +202,7 @@ export class ApResolverService {
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private metaService: MetaService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
private apRequestService: ApRequestService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private apRendererService: ApRendererService,
|
||||
@@ -208,14 +215,14 @@ export class ApResolverService {
|
||||
public createResolver(): Resolver {
|
||||
return new Resolver(
|
||||
this.config,
|
||||
this.meta,
|
||||
this.usersRepository,
|
||||
this.notesRepository,
|
||||
this.pollsRepository,
|
||||
this.noteReactionsRepository,
|
||||
this.followRequestsRepository,
|
||||
this.utilityService,
|
||||
this.instanceActorService,
|
||||
this.metaService,
|
||||
this.systemAccountService,
|
||||
this.apRequestService,
|
||||
this.httpRequestService,
|
||||
this.apRendererService,
|
||||
|
||||
133
packages/backend/src/core/activitypub/misc/check-against-url.ts
Normal file
133
packages/backend/src/core/activitypub/misc/check-against-url.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: dakkar and sharkey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { IObject } from '../type.js';
|
||||
|
||||
export enum FetchAllowSoftFailMask {
|
||||
// Allow no softfail flags
|
||||
Strict = 0,
|
||||
// The values in tuple (requestUrl, finalUrl, objectId) are not all identical
|
||||
//
|
||||
// This condition is common for user-initiated lookups but should not be allowed in federation loop
|
||||
//
|
||||
// Allow variations:
|
||||
// good example: https://alice.example.com/@user -> https://alice.example.com/user/:userId
|
||||
// problematic example: https://alice.example.com/redirect?url=https://bad.example.com/ -> https://bad.example.com/ -> https://alice.example.com/somethingElse
|
||||
NonCanonicalId = 1 << 0,
|
||||
// Allow the final object to be at most one subdomain deeper than the request URL, similar to SPF relaxed alignment
|
||||
//
|
||||
// Currently no code path allows this flag to be set, but is kept in case of future use as some niche deployments do this, and we provide a pre-reviewed mechanism to opt-in.
|
||||
//
|
||||
// Allow variations:
|
||||
// good example: https://example.com/@user -> https://activitypub.example.com/@user { id: 'https://activitypub.example.com/@user' }
|
||||
// problematic example: https://example.com/@user -> https://untrusted.example.com/@user { id: 'https://untrusted.example.com/@user' }
|
||||
MisalignedOrigin = 1 << 1,
|
||||
// The requested URL has a different host than the returned object ID, although the final URL is still consistent with the object ID
|
||||
//
|
||||
// This condition is common for user-initiated lookups using an intermediate host but should not be allowed in federation loops
|
||||
//
|
||||
// Allow variations:
|
||||
// good example: https://alice.example.com/@user@bob.example.com -> https://bob.example.com/@user { id: 'https://bob.example.com/@user' }
|
||||
// problematic example: https://alice.example.com/definitelyAlice -> https://bob.example.com/@somebodyElse { id: 'https://bob.example.com/@somebodyElse' }
|
||||
CrossOrigin = 1 << 2 | MisalignedOrigin,
|
||||
// Allow all softfail flags
|
||||
//
|
||||
// do not use this flag on released code
|
||||
Any = ~0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzz match on whether the candidate host has authority over the request host
|
||||
*
|
||||
* @param requestHost The host of the requested resources
|
||||
* @param candidateHost The host of final response
|
||||
* @returns Whether the candidate host has authority over the request host, or if a soft fail is required for a match
|
||||
*/
|
||||
function hostFuzzyMatch(requestHost: string, candidateHost: string): FetchAllowSoftFailMask {
|
||||
const requestFqdn = requestHost.endsWith('.') ? requestHost : `${requestHost}.`;
|
||||
const candidateFqdn = candidateHost.endsWith('.') ? candidateHost : `${candidateHost}.`;
|
||||
|
||||
if (requestFqdn === candidateFqdn) {
|
||||
return FetchAllowSoftFailMask.Strict;
|
||||
}
|
||||
|
||||
// allow only one case where candidateHost is a first-level subdomain of requestHost
|
||||
const requestDnsDepth = requestFqdn.split('.').length;
|
||||
const candidateDnsDepth = candidateFqdn.split('.').length;
|
||||
|
||||
if ((candidateDnsDepth - requestDnsDepth) !== 1) {
|
||||
return FetchAllowSoftFailMask.CrossOrigin;
|
||||
}
|
||||
|
||||
if (`.${candidateHost}`.endsWith(`.${requestHost}`)) {
|
||||
return FetchAllowSoftFailMask.MisalignedOrigin;
|
||||
}
|
||||
|
||||
return FetchAllowSoftFailMask.CrossOrigin;
|
||||
}
|
||||
|
||||
// normalize host names by removing www. prefix
|
||||
function normalizeSynonymousSubdomain(url: URL | string): URL {
|
||||
const urlParsed = url instanceof URL ? url : new URL(url);
|
||||
const host = urlParsed.host;
|
||||
const normalizedHost = host.replace(/^www\./, '');
|
||||
return new URL(urlParsed.toString().replace(host, normalizedHost));
|
||||
}
|
||||
|
||||
export function assertActivityMatchesUrl(requestUrl: string | URL, activity: IObject, finalUrl: string | URL, allowSoftfail: FetchAllowSoftFailMask): FetchAllowSoftFailMask {
|
||||
// must have a unique identifier to verify authority
|
||||
if (!activity.id) {
|
||||
throw new Error('bad Activity: missing id field');
|
||||
}
|
||||
|
||||
let softfail = 0;
|
||||
|
||||
// if the flag is allowed, set the flag on return otherwise throw
|
||||
const requireSoftfail = (needed: FetchAllowSoftFailMask, message: string) => {
|
||||
if ((allowSoftfail & needed) !== needed) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
softfail |= needed;
|
||||
};
|
||||
|
||||
const requestUrlParsed = normalizeSynonymousSubdomain(requestUrl);
|
||||
const idParsed = normalizeSynonymousSubdomain(activity.id);
|
||||
|
||||
const finalUrlParsed = normalizeSynonymousSubdomain(finalUrl);
|
||||
|
||||
// mastodon sends activities with hash in the URL
|
||||
// currently it only happens with likes, deletes etc.
|
||||
// but object ID never has hash
|
||||
requestUrlParsed.hash = '';
|
||||
finalUrlParsed.hash = '';
|
||||
|
||||
const requestUrlSecure = requestUrlParsed.protocol === 'https:';
|
||||
const finalUrlSecure = finalUrlParsed.protocol === 'https:';
|
||||
if (requestUrlSecure && !finalUrlSecure) {
|
||||
throw new Error(`bad Activity: id(${activity.id}) is not allowed to have http:// in the url`);
|
||||
}
|
||||
|
||||
// Compare final URL to the ID
|
||||
if (finalUrlParsed.href !== idParsed.href) {
|
||||
requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${finalUrlParsed.toString()})`);
|
||||
|
||||
// at lease host need to match exactly (ActivityPub requirement)
|
||||
if (idParsed.host !== finalUrlParsed.host) {
|
||||
throw new Error(`bad Activity: id(${activity.id}) does not match response host(${finalUrlParsed.host})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare request URL to the ID
|
||||
if (requestUrlParsed.href !== idParsed.href) {
|
||||
requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match request url(${requestUrlParsed.toString()})`);
|
||||
|
||||
// if cross-origin lookup is allowed, we can accept some variation between the original request URL to the final object ID (but not between the final URL and the object ID)
|
||||
const hostResult = hostFuzzyMatch(requestUrlParsed.host, idParsed.host);
|
||||
|
||||
requireSoftfail(hostResult, `bad Activity: id(${activity.id}) is valid but is not the same origin as request url(${requestUrlParsed.toString()})`);
|
||||
}
|
||||
|
||||
return softfail;
|
||||
}
|
||||
@@ -554,6 +554,15 @@ const extension_context_definition = {
|
||||
'_misskey_reaction': 'misskey:_misskey_reaction',
|
||||
'_misskey_votes': 'misskey:_misskey_votes',
|
||||
'_misskey_summary': 'misskey:_misskey_summary',
|
||||
'_misskey_followedMessage': 'misskey:_misskey_followedMessage',
|
||||
'_misskey_requireSigninToViewContents': 'misskey:_misskey_requireSigninToViewContents',
|
||||
'_misskey_makeNotesFollowersOnlyBefore': 'misskey:_misskey_makeNotesFollowersOnlyBefore',
|
||||
'_misskey_makeNotesHiddenBefore': 'misskey:_misskey_makeNotesHiddenBefore',
|
||||
'_misskey_license': 'misskey:_misskey_license',
|
||||
'freeText': {
|
||||
'@id': 'misskey:freeText',
|
||||
'@type': 'schema:text',
|
||||
},
|
||||
'isCat': 'misskey:isCat',
|
||||
// vcard
|
||||
vcard: 'http://www.w3.org/2006/vcard/ns#',
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository } from '@/models/_.js';
|
||||
import type { DriveFilesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { truncate } from '@/misc/truncate.js';
|
||||
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
@@ -24,10 +23,12 @@ export class ApImageService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private apResolverService: ApResolverService,
|
||||
private driveService: DriveService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
@@ -63,12 +64,10 @@ export class ApImageService {
|
||||
|
||||
this.logger.info(`Creating the Image: ${image.url}`);
|
||||
|
||||
const instance = await this.metaService.fetch();
|
||||
|
||||
// Cache if remote file cache is on AND either
|
||||
// 1. remote sensitive file is also on
|
||||
// 2. or the image is not sensitive
|
||||
const shouldBeCached = instance.cacheRemoteFiles && (instance.cacheRemoteSensitiveFiles || !image.sensitive);
|
||||
const shouldBeCached = this.meta.cacheRemoteFiles && (this.meta.cacheRemoteSensitiveFiles || !image.sensitive);
|
||||
|
||||
const file = await this.driveService.uploadFromUrl({
|
||||
url: image.url,
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { PollsRepository, EmojisRepository } from '@/models/_.js';
|
||||
import type { PollsRepository, EmojisRepository, MiMeta } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { toArray, toSingle, unique } from '@/misc/prelude/array.js';
|
||||
import type { MiEmoji } from '@/models/Emoji.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
@@ -46,6 +45,9 @@ export class ApNoteService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
@@ -65,7 +67,6 @@ export class ApNoteService {
|
||||
private apMentionService: ApMentionService,
|
||||
private apImageService: ApImageService,
|
||||
private apQuestionService: ApQuestionService,
|
||||
private metaService: MetaService,
|
||||
private appLockService: AppLockService,
|
||||
private pollService: PollService,
|
||||
private noteCreateService: NoteCreateService,
|
||||
@@ -76,11 +77,12 @@ export class ApNoteService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public validateNote(object: IObject, uri: string): Error | null {
|
||||
public validateNote(object: IObject, uri: string, actor?: MiRemoteUser): Error | null {
|
||||
const expectHost = this.utilityService.extractDbHost(uri);
|
||||
const apType = getApType(object);
|
||||
|
||||
if (!validPost.includes(getApType(object))) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${getApType(object)}`);
|
||||
if (apType == null || !validPost.includes(apType)) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${apType ?? 'undefined'}`);
|
||||
}
|
||||
|
||||
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) {
|
||||
@@ -96,6 +98,14 @@ export class ApNoteService {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: 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 null;
|
||||
}
|
||||
|
||||
@@ -113,14 +123,14 @@ export class ApNoteService {
|
||||
* Noteを作成します。
|
||||
*/
|
||||
@bindThis
|
||||
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(value);
|
||||
|
||||
const entryUri = getApId(value);
|
||||
const err = this.validateNote(object, entryUri);
|
||||
const err = this.validateNote(object, entryUri, actor);
|
||||
if (err) {
|
||||
this.logger.error(err.message, {
|
||||
resolver: { history: resolver.getHistory() },
|
||||
@@ -134,7 +144,11 @@ export class ApNoteService {
|
||||
|
||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||
|
||||
if (note.id && !checkHttps(note.id)) {
|
||||
if (note.id == null) {
|
||||
throw new Error('Refusing to create note without id');
|
||||
}
|
||||
|
||||
if (!checkHttps(note.id)) {
|
||||
throw new Error('unexpected schema of note.id: ' + note.id);
|
||||
}
|
||||
|
||||
@@ -154,8 +168,9 @@ export class ApNoteService {
|
||||
const uri = getOneApId(note.attributedTo);
|
||||
|
||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
||||
if (cachedActor && cachedActor.isSuspended) {
|
||||
// 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 has been suspended');
|
||||
}
|
||||
|
||||
@@ -181,13 +196,14 @@ 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');
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||
|
||||
// 解決した投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
@@ -334,9 +350,7 @@ export class ApNoteService {
|
||||
public async resolveNote(value: string | IObject, options: { sentFrom?: URL, resolver?: Resolver } = {}): Promise<MiNote | null> {
|
||||
const uri = getApId(value);
|
||||
|
||||
// ブロックしていたら中断
|
||||
const meta = await this.metaService.fetch();
|
||||
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) {
|
||||
if (!this.utilityService.isFederationAllowedUri(uri)) {
|
||||
throw new StatusError('blocked host', 451);
|
||||
}
|
||||
|
||||
@@ -348,7 +362,7 @@ export class ApNoteService {
|
||||
if (exist) return exist;
|
||||
//#endregion
|
||||
|
||||
if (uri.startsWith(this.config.url)) {
|
||||
if (this.utilityService.isUriLocal(uri)) {
|
||||
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
|
||||
}
|
||||
|
||||
@@ -356,7 +370,7 @@ export class ApNoteService {
|
||||
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
||||
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
||||
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
|
||||
return await this.createNote(createFrom, options.resolver, true);
|
||||
return await this.createNote(createFrom, undefined, options.resolver, true);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
@@ -394,6 +408,8 @@ export class ApNoteService {
|
||||
originalUrl: tag.icon.url,
|
||||
publicUrl: tag.icon.url,
|
||||
updatedAt: new Date(),
|
||||
// _misskey_license が存在しなければ `null`
|
||||
license: (tag._misskey_license?.freeText ?? null)
|
||||
});
|
||||
|
||||
const emoji = await this.emojisRepository.findOneBy({ host, name });
|
||||
@@ -415,6 +431,8 @@ export class ApNoteService {
|
||||
publicUrl: tag.icon.url,
|
||||
updatedAt: new Date(),
|
||||
aliases: [],
|
||||
// _misskey_license が存在しなければ `null`
|
||||
license: (tag._misskey_license?.freeText ?? null)
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import promiseLimit from 'promise-limit';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
@@ -35,7 +35,6 @@ import type { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
@@ -46,9 +45,9 @@ import type { ApNoteService } from './ApNoteService.js';
|
||||
import type { ApMfmService } from '../ApMfmService.js';
|
||||
import type { ApResolverService, Resolver } from '../ApResolverService.js';
|
||||
import type { ApLoggerService } from '../ApLoggerService.js';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
|
||||
import type { ApImageService } from './ApImageService.js';
|
||||
import type { IActor, IObject } from '../type.js';
|
||||
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
|
||||
|
||||
const nameLength = 128;
|
||||
const summaryLength = 2048;
|
||||
@@ -62,7 +61,6 @@ export class ApPersonService implements OnModuleInit {
|
||||
private driveFileEntityService: DriveFileEntityService;
|
||||
private idService: IdService;
|
||||
private globalEventService: GlobalEventService;
|
||||
private metaService: MetaService;
|
||||
private federatedInstanceService: FederatedInstanceService;
|
||||
private fetchInstanceMetadataService: FetchInstanceMetadataService;
|
||||
private cacheService: CacheService;
|
||||
@@ -84,6 +82,9 @@ export class ApPersonService implements OnModuleInit {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@@ -112,7 +113,6 @@ export class ApPersonService implements OnModuleInit {
|
||||
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
|
||||
this.idService = this.moduleRef.get('IdService');
|
||||
this.globalEventService = this.moduleRef.get('GlobalEventService');
|
||||
this.metaService = this.moduleRef.get('MetaService');
|
||||
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
||||
this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService');
|
||||
this.cacheService = this.moduleRef.get('CacheService');
|
||||
@@ -129,12 +129,6 @@ export class ApPersonService implements OnModuleInit {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
||||
private punyHost(url: string): string {
|
||||
const urlObj = new URL(url);
|
||||
const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and convert to actor object
|
||||
* @param x Fetched object
|
||||
@@ -142,7 +136,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
*/
|
||||
@bindThis
|
||||
private validateActor(x: IObject, uri: string): IActor {
|
||||
const expectHost = this.punyHost(uri);
|
||||
const expectHost = this.utilityService.punyHost(uri);
|
||||
|
||||
if (!isActor(x)) {
|
||||
throw new Error(`invalid Actor type '${x.type}'`);
|
||||
@@ -156,6 +150,36 @@ export class ApPersonService implements OnModuleInit {
|
||||
throw new Error('invalid Actor: wrong inbox');
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHost(x.inbox) !== expectHost) {
|
||||
throw new Error('invalid Actor: inbox has different host');
|
||||
}
|
||||
|
||||
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
||||
if (sharedInboxObject != null) {
|
||||
const sharedInbox = getApId(sharedInboxObject);
|
||||
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && new URL(sharedInbox).host === expectHost)) {
|
||||
this.logger.warn(`invalid Actor: skipping wrong shared inbox, expected host: ${expectHost}, actual URL: ${sharedInbox}`);
|
||||
x.sharedInbox = undefined;
|
||||
if (x.endpoints?.sharedInbox) {
|
||||
x.endpoints.sharedInbox = 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.punyHost(collectionUri) !== expectHost) {
|
||||
throw new Error(`invalid Actor: ${collection} has different host`);
|
||||
}
|
||||
} else if (collectionUri != null) {
|
||||
throw new Error(`invalid Actor: wrong ${collection}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
||||
throw new Error('invalid Actor: wrong username');
|
||||
}
|
||||
@@ -179,7 +203,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
x.summary = truncate(x.summary, summaryLength);
|
||||
}
|
||||
|
||||
const idHost = this.punyHost(x.id);
|
||||
const idHost = this.utilityService.punyHost(x.id);
|
||||
if (idHost !== expectHost) {
|
||||
throw new Error('invalid Actor: id has different host');
|
||||
}
|
||||
@@ -189,7 +213,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
throw new Error('invalid Actor: publicKey.id is not a string');
|
||||
}
|
||||
|
||||
const publicKeyIdHost = this.punyHost(x.publicKey.id);
|
||||
const publicKeyIdHost = this.utilityService.punyHost(x.publicKey.id);
|
||||
if (publicKeyIdHost !== expectHost) {
|
||||
throw new Error('invalid Actor: publicKey.id has different host');
|
||||
}
|
||||
@@ -232,6 +256,12 @@ export class ApPersonService implements OnModuleInit {
|
||||
if (user == null) throw new Error('failed to create user: user is null');
|
||||
|
||||
const [avatar, banner] = await Promise.all([icon, image].map(img => {
|
||||
// icon and image may be arrays
|
||||
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
|
||||
if (Array.isArray(img)) {
|
||||
img = img.find(item => item && item.url) ?? null;
|
||||
}
|
||||
|
||||
// if we have an explicitly missing image, return an
|
||||
// explicitly-null set of values
|
||||
if ((img == null) || (typeof img === 'object' && img.url == null)) {
|
||||
@@ -274,7 +304,8 @@ export class ApPersonService implements OnModuleInit {
|
||||
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
if (uri.startsWith(this.config.url)) {
|
||||
const host = this.utilityService.punyHost(uri);
|
||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
||||
}
|
||||
|
||||
@@ -288,18 +319,35 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
this.logger.info(`Creating the Person: ${person.id}`);
|
||||
|
||||
const host = this.punyHost(object.id);
|
||||
|
||||
const fields = this.analyzeAttachments(person.attachment ?? []);
|
||||
|
||||
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
|
||||
|
||||
const isBot = getApType(object) === 'Service' || getApType(object) === 'Application';
|
||||
|
||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||
[
|
||||
this.isPublicCollection(person.following, resolver),
|
||||
this.isPublicCollection(person.followers, resolver),
|
||||
].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 });
|
||||
}
|
||||
return 'private';
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
|
||||
|
||||
const url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (person.id == null) {
|
||||
throw new Error('Refusing to create person without id');
|
||||
}
|
||||
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of person url: ' + url);
|
||||
}
|
||||
@@ -334,13 +382,16 @@ export class ApPersonService implements OnModuleInit {
|
||||
usernameLower: person.preferredUsername?.toLowerCase(),
|
||||
host,
|
||||
inbox: person.inbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||
followersUri: person.followers ? getApId(person.followers) : undefined,
|
||||
featured: person.featured ? getApId(person.featured) : undefined,
|
||||
uri: person.id,
|
||||
tags,
|
||||
isBot,
|
||||
isCat: (person as any).isCat === true,
|
||||
requireSigninToViewContents: (person as any).requireSigninToViewContents === true,
|
||||
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
||||
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
||||
emojis,
|
||||
})) as MiRemoteUser;
|
||||
|
||||
@@ -355,8 +406,11 @@ export class ApPersonService implements OnModuleInit {
|
||||
await transactionalEntityManager.save(new MiUserProfile({
|
||||
userId: user.id,
|
||||
description: _description,
|
||||
followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null,
|
||||
url,
|
||||
fields,
|
||||
followingVisibility,
|
||||
followersVisibility,
|
||||
birthday: bday?.[0] ?? null,
|
||||
location: person['vcard:Address'] ?? null,
|
||||
userHost: host,
|
||||
@@ -390,13 +444,15 @@ export class ApPersonService implements OnModuleInit {
|
||||
this.cacheService.uriPersonCache.set(user.uri, user);
|
||||
|
||||
// Register host
|
||||
this.federatedInstanceService.fetch(host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.newUser(i.host);
|
||||
}
|
||||
});
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.newUser(i.host);
|
||||
}
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
});
|
||||
}
|
||||
|
||||
this.usersChart.update(user, true);
|
||||
|
||||
@@ -436,7 +492,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (uri.startsWith(`${this.config.url}/`)) return;
|
||||
if (this.utilityService.isUriLocal(uri)) return;
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
|
||||
@@ -464,20 +520,47 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
|
||||
|
||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||
[
|
||||
this.isPublicCollection(person.following, resolver),
|
||||
this.isPublicCollection(person.followers, resolver),
|
||||
].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.
|
||||
return undefined;
|
||||
}
|
||||
return 'private';
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
|
||||
|
||||
const url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of person url: ' + url);
|
||||
if (person.id == null) {
|
||||
throw new Error('Refusing to update person without id');
|
||||
}
|
||||
|
||||
if (url != null) {
|
||||
if (!checkHttps(url)) {
|
||||
throw new Error('unexpected schema of person url: ' + url);
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(person.id)) {
|
||||
throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
lastFetchedAt: new Date(),
|
||||
inbox: person.inbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
|
||||
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,
|
||||
@@ -511,7 +594,9 @@ 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';
|
||||
}
|
||||
|
||||
if (person.publicKey) {
|
||||
await this.userPublickeysRepository.update({ userId: exist.id }, {
|
||||
@@ -532,6 +617,9 @@ export class ApPersonService implements OnModuleInit {
|
||||
url,
|
||||
fields,
|
||||
description: _description,
|
||||
followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null,
|
||||
followingVisibility,
|
||||
followersVisibility,
|
||||
birthday: bday?.[0] ?? null,
|
||||
location: person['vcard:Address'] ?? null,
|
||||
});
|
||||
@@ -544,7 +632,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
|
||||
await this.followingsRepository.update(
|
||||
{ followerId: exist.id },
|
||||
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox },
|
||||
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null },
|
||||
);
|
||||
|
||||
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
|
||||
@@ -613,7 +701,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;
|
||||
|
||||
@@ -679,7 +767,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
|
||||
dst = await this.fetchPerson(src.movedToUri) ?? dst;
|
||||
} else {
|
||||
if (src.movedToUri.startsWith(`${this.config.url}/`)) {
|
||||
if (this.utilityService.isUriLocal(src.movedToUri)) {
|
||||
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
|
||||
return 'failed: movedTo is local but not found';
|
||||
}
|
||||
@@ -703,4 +791,16 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise<boolean> {
|
||||
if (collection) {
|
||||
const resolved = await resolver.resolveCollection(collection);
|
||||
if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { NotesRepository, PollsRepository } from '@/models/_.js';
|
||||
import type { UsersRepository, NotesRepository, PollsRepository } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { IPoll } from '@/models/Poll.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isQuestion } from '../type.js';
|
||||
import { getOneApId, isQuestion } from '../type.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IQuestion } from '../type.js';
|
||||
import type { IObject } from '../type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApQuestionService {
|
||||
@@ -24,6 +26,9 @@ export class ApQuestionService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@@ -32,6 +37,7 @@ export class ApQuestionService {
|
||||
|
||||
private apResolverService: ApResolverService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
}
|
||||
@@ -65,12 +71,12 @@ export class ApQuestionService {
|
||||
* @returns true if updated
|
||||
*/
|
||||
@bindThis
|
||||
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
|
||||
public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise<boolean> {
|
||||
const uri = typeof value === 'string' ? value : value.id;
|
||||
if (uri == null) throw new Error('uri is null');
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
|
||||
if (this.utilityService.isUriLocal(uri)) throw new Error('uri points local');
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const note = await this.notesRepository.findOneBy({ uri });
|
||||
@@ -78,15 +84,26 @@ export class ApQuestionService {
|
||||
|
||||
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
if (poll == null) throw new Error('Question is not registered');
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: poll.userId });
|
||||
if (user == null) throw new Error('Question is not registered');
|
||||
//#endregion
|
||||
|
||||
// resolve new Question object
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
const question = await resolver.resolve(value) as IQuestion;
|
||||
const question = await resolver.resolve(value);
|
||||
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
||||
|
||||
if (question.type !== 'Question') throw new Error('object is not a Question');
|
||||
if (!isQuestion(question)) throw new Error('object is not a Question');
|
||||
|
||||
const attribution = (question.attributedTo) ? getOneApId(question.attributedTo) : user.uri;
|
||||
const attributionMatchesExisting = attribution === user.uri;
|
||||
const actorMatchesAttribution = (actor) ? attribution === actor.uri : true;
|
||||
|
||||
if (!attributionMatchesExisting || !actorMatchesAttribution) {
|
||||
throw new Error('Refusing to ingest update for poll by different user');
|
||||
}
|
||||
|
||||
const apChoices = question.oneOf ?? question.anyOf;
|
||||
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
|
||||
@@ -96,7 +113,7 @@ export class ApQuestionService {
|
||||
for (const choice of poll.choices) {
|
||||
const oldCount = poll.votes[poll.choices.indexOf(choice)];
|
||||
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
|
||||
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
|
||||
if (newCount == null || !(Number.isInteger(newCount) && newCount >= 0)) throw new Error('invalid newCount: ' + newCount);
|
||||
|
||||
if (oldCount !== newCount) {
|
||||
changed = true;
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface IObject {
|
||||
name?: string | null;
|
||||
summary?: string;
|
||||
_misskey_summary?: string;
|
||||
_misskey_followedMessage?: string | null;
|
||||
_misskey_requireSigninToViewContents?: boolean;
|
||||
_misskey_makeNotesFollowersOnlyBefore?: number | null;
|
||||
_misskey_makeNotesHiddenBefore?: number | null;
|
||||
published?: string;
|
||||
cc?: ApObject;
|
||||
to?: ApObject;
|
||||
@@ -60,11 +64,14 @@ export function getApId(value: string | IObject): string {
|
||||
|
||||
/**
|
||||
* Get ActivityStreams Object type
|
||||
*
|
||||
* タイプ判定ができなかった場合に、あえてエラーではなくnullを返すようにしている。
|
||||
* 詳細: https://github.com/misskey-dev/misskey/issues/14239
|
||||
*/
|
||||
export function getApType(value: IObject): string {
|
||||
export function getApType(value: IObject): string | null {
|
||||
if (typeof value.type === 'string') return value.type;
|
||||
if (Array.isArray(value.type) && typeof value.type[0] === 'string') return value.type[0];
|
||||
throw new Error('cannot detect type');
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getOneApHrefNullable(value: ApObject | undefined): string | undefined {
|
||||
@@ -97,19 +104,23 @@ export interface IActivity extends IObject {
|
||||
export interface ICollection extends IObject {
|
||||
type: 'Collection';
|
||||
totalItems: number;
|
||||
items: ApObject;
|
||||
first?: IObject | string;
|
||||
items?: ApObject;
|
||||
}
|
||||
|
||||
export interface IOrderedCollection extends IObject {
|
||||
type: 'OrderedCollection';
|
||||
totalItems: number;
|
||||
orderedItems: ApObject;
|
||||
first?: IObject | string;
|
||||
orderedItems?: ApObject;
|
||||
}
|
||||
|
||||
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
|
||||
|
||||
export const isPost = (object: IObject): object is IPost =>
|
||||
validPost.includes(getApType(object));
|
||||
export const isPost = (object: IObject): object is IPost => {
|
||||
const type = getApType(object);
|
||||
return type != null && validPost.includes(type);
|
||||
};
|
||||
|
||||
export interface IPost extends IObject {
|
||||
type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event';
|
||||
@@ -156,8 +167,10 @@ export const isTombstone = (object: IObject): object is ITombstone =>
|
||||
|
||||
export const validActor = ['Person', 'Service', 'Group', 'Organization', 'Application'];
|
||||
|
||||
export const isActor = (object: IObject): object is IActor =>
|
||||
validActor.includes(getApType(object));
|
||||
export const isActor = (object: IObject): object is IActor => {
|
||||
const type = getApType(object);
|
||||
return type != null && validActor.includes(type);
|
||||
};
|
||||
|
||||
export interface IActor extends IObject {
|
||||
type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application';
|
||||
@@ -229,6 +242,11 @@ export interface IApEmoji extends IObject {
|
||||
type: 'Emoji';
|
||||
name: string;
|
||||
updated: string;
|
||||
// Misskey拡張。後方互換性のためにoptional。
|
||||
// 将来の拡張性を考慮してobjectにしている
|
||||
_misskey_license?: {
|
||||
freeText: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const isEmoji = (object: IObject): object is IApEmoji =>
|
||||
@@ -240,12 +258,16 @@ export interface IKey extends IObject {
|
||||
publicKeyPem: string | Buffer;
|
||||
}
|
||||
|
||||
export const validDocumentTypes = ['Audio', 'Document', 'Image', 'Page', 'Video'];
|
||||
|
||||
export interface IApDocument extends IObject {
|
||||
type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video';
|
||||
}
|
||||
|
||||
export const isDocument = (object: IObject): object is IApDocument =>
|
||||
['Audio', 'Document', 'Image', 'Page', 'Video'].includes(getApType(object));
|
||||
export const isDocument = (object: IObject): object is IApDocument => {
|
||||
const type = getApType(object);
|
||||
return type != null && validDocumentTypes.includes(type);
|
||||
};
|
||||
|
||||
export interface IApImage extends IApDocument {
|
||||
type: 'Image';
|
||||
@@ -323,7 +345,10 @@ export const isAccept = (object: IObject): object is IAccept => getApType(object
|
||||
export const isReject = (object: IObject): object is IReject => getApType(object) === 'Reject';
|
||||
export const isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add';
|
||||
export const isRemove = (object: IObject): object is IRemove => getApType(object) === 'Remove';
|
||||
export const isLike = (object: IObject): object is ILike => getApType(object) === 'Like' || getApType(object) === 'EmojiReaction' || getApType(object) === 'EmojiReact';
|
||||
export const isLike = (object: IObject): object is ILike => {
|
||||
const type = getApType(object);
|
||||
return type != null && ['Like', 'EmojiReaction', 'EmojiReact'].includes(type);
|
||||
};
|
||||
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
|
||||
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
|
||||
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
|
||||
|
||||
@@ -58,9 +58,9 @@ export class ChartManagementService implements OnApplicationShutdown {
|
||||
@bindThis
|
||||
public async start() {
|
||||
// 20分おきにメモリ情報をDBに書き込み
|
||||
this.saveIntervalId = setInterval(() => {
|
||||
this.saveIntervalId = setInterval(async () => {
|
||||
for (const chart of this.charts) {
|
||||
chart.save();
|
||||
await chart.save();
|
||||
}
|
||||
}, 1000 * 60 * 20);
|
||||
}
|
||||
@@ -69,9 +69,9 @@ export class ChartManagementService implements OnApplicationShutdown {
|
||||
public async dispose(): Promise<void> {
|
||||
clearInterval(this.saveIntervalId);
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
await Promise.all(
|
||||
this.charts.map(chart => chart.save()),
|
||||
);
|
||||
for (const chart of this.charts) {
|
||||
await chart.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@
|
||||
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { FollowingsRepository, InstancesRepository } from '@/models/_.js';
|
||||
import type { FollowingsRepository, InstancesRepository, MiMeta } from '@/models/_.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import Chart from '../core.js';
|
||||
import { ChartLoggerService } from '../ChartLoggerService.js';
|
||||
@@ -24,13 +23,15 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private appLockService: AppLockService,
|
||||
private chartLoggerService: ChartLoggerService,
|
||||
) {
|
||||
@@ -43,8 +44,6 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||
}
|
||||
|
||||
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance')
|
||||
.select('instance.host')
|
||||
.where('instance.suspensionState != \'none\'');
|
||||
@@ -65,21 +64,21 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||
this.followingsRepository.createQueryBuilder('following')
|
||||
.select('COUNT(DISTINCT following.followeeHost)')
|
||||
.where('following.followeeHost IS NOT NULL')
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
this.followingsRepository.createQueryBuilder('following')
|
||||
.select('COUNT(DISTINCT following.followerHost)')
|
||||
.where('following.followerHost IS NOT NULL')
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
this.followingsRepository.createQueryBuilder('following')
|
||||
.select('COUNT(DISTINCT following.followeeHost)')
|
||||
.where('following.followeeHost IS NOT NULL')
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
||||
.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`)
|
||||
.setParameters(pubsubSubQuery.getParameters())
|
||||
@@ -88,7 +87,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||
this.instancesRepository.createQueryBuilder('instance')
|
||||
.select('COUNT(instance.id)')
|
||||
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere('instance.suspensionState = \'none\'')
|
||||
.andWhere('instance.isNotResponding = false')
|
||||
.getRawOne()
|
||||
@@ -96,7 +95,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||
this.instancesRepository.createQueryBuilder('instance')
|
||||
.select('COUNT(instance.id)')
|
||||
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
|
||||
.andWhere('instance.suspensionState = \'none\'')
|
||||
.andWhere('instance.isNotResponding = false')
|
||||
.getRawOne()
|
||||
|
||||
@@ -53,6 +53,8 @@ export class AbuseUserReportEntityService {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}) : null,
|
||||
forwarded: report.forwarded,
|
||||
resolvedAs: report.resolvedAs,
|
||||
moderationNote: report.moderationNote,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export class AntennaEntityService {
|
||||
excludeBots: antenna.excludeBots,
|
||||
withReplies: antenna.withReplies,
|
||||
withFile: antenna.withFile,
|
||||
excludeNotesInSensitiveChannel: antenna.excludeNotesInSensitiveChannel,
|
||||
isActive: antenna.isActive,
|
||||
hasUnreadNote: false, // TODO
|
||||
notify: false, // 後方互換性のため
|
||||
|
||||
376
packages/backend/src/core/entities/ChatEntityService.ts
Normal file
376
packages/backend/src/core/entities/ChatEntityService.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser, ChatMessagesRepository, MiChatMessage, ChatRoomsRepository, MiChatRoom, MiChatRoomInvitation, ChatRoomInvitationsRepository, MiChatRoomMembership, ChatRoomMembershipsRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class ChatEntityService {
|
||||
constructor(
|
||||
@Inject(DI.chatMessagesRepository)
|
||||
private chatMessagesRepository: ChatMessagesRepository,
|
||||
|
||||
@Inject(DI.chatRoomsRepository)
|
||||
private chatRoomsRepository: ChatRoomsRepository,
|
||||
|
||||
@Inject(DI.chatRoomInvitationsRepository)
|
||||
private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
|
||||
|
||||
@Inject(DI.chatRoomMembershipsRepository)
|
||||
private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMessageDetailed(
|
||||
src: MiChatMessage['id'] | MiChatMessage,
|
||||
me?: { id: MiUser['id'] },
|
||||
options?: {
|
||||
_hint_?: {
|
||||
packedFiles?: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
|
||||
packedUsers?: Map<MiChatMessage['id'], Packed<'UserLite'>>;
|
||||
packedRooms?: Map<MiChatMessage['toRoomId'], Packed<'ChatRoom'> | null>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'ChatMessage'>> {
|
||||
const packedUsers = options?._hint_?.packedUsers;
|
||||
const packedFiles = options?._hint_?.packedFiles;
|
||||
const packedRooms = options?._hint_?.packedRooms;
|
||||
|
||||
const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
|
||||
|
||||
for (const record of message.reactions) {
|
||||
const [userId, reaction] = record.split('/');
|
||||
reactions.push({
|
||||
user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
|
||||
reaction,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
createdAt: this.idService.parse(message.id).date.toISOString(),
|
||||
text: message.text,
|
||||
fromUserId: message.fromUserId,
|
||||
fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me),
|
||||
toUserId: message.toUserId,
|
||||
toUser: message.toUserId ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined,
|
||||
toRoomId: message.toRoomId,
|
||||
toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined,
|
||||
fileId: message.fileId,
|
||||
file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
|
||||
reactions,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMessagesDetailed(
|
||||
messages: MiChatMessage[],
|
||||
me: { id: MiUser['id'] },
|
||||
) {
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const excludeMe = (x: MiUser | string) => {
|
||||
if (typeof x === 'string') {
|
||||
return x !== me.id;
|
||||
} else {
|
||||
return x.id !== me.id;
|
||||
}
|
||||
};
|
||||
|
||||
const users = [
|
||||
...messages.map((m) => m.fromUser ?? m.fromUserId).filter(excludeMe),
|
||||
...messages.map((m) => m.toUser ?? m.toUserId).filter(x => x != null).filter(excludeMe),
|
||||
];
|
||||
|
||||
const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
|
||||
|
||||
for (const reactedUserId of reactedUserIds) {
|
||||
if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
|
||||
users.push(reactedUserId);
|
||||
}
|
||||
}
|
||||
|
||||
const [packedUsers, packedFiles, packedRooms] = await Promise.all([
|
||||
this.userEntityService.packMany(users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u]))),
|
||||
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
|
||||
.then(files => new Map(files.map(f => [f.id, f]))),
|
||||
this.packRooms(messages.map(m => m.toRoom ?? m.toRoomId).filter(x => x != null), me)
|
||||
.then(rooms => new Map(rooms.map(r => [r.id, r]))),
|
||||
]);
|
||||
|
||||
return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } })));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMessageLiteFor1on1(
|
||||
src: MiChatMessage['id'] | MiChatMessage,
|
||||
options?: {
|
||||
_hint_?: {
|
||||
packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'ChatMessageLiteFor1on1'>> {
|
||||
const packedFiles = options?._hint_?.packedFiles;
|
||||
|
||||
const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const reactions: { reaction: string; }[] = [];
|
||||
|
||||
for (const record of message.reactions) {
|
||||
const [userId, reaction] = record.split('/');
|
||||
reactions.push({
|
||||
reaction,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
createdAt: this.idService.parse(message.id).date.toISOString(),
|
||||
text: message.text,
|
||||
fromUserId: message.fromUserId,
|
||||
toUserId: message.toUserId!,
|
||||
fileId: message.fileId,
|
||||
file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
|
||||
reactions,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMessagesLiteFor1on1(
|
||||
messages: MiChatMessage[],
|
||||
) {
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const [packedFiles] = await Promise.all([
|
||||
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
|
||||
.then(files => new Map(files.map(f => [f.id, f]))),
|
||||
]);
|
||||
|
||||
return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } })));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMessageLiteForRoom(
|
||||
src: MiChatMessage['id'] | MiChatMessage,
|
||||
options?: {
|
||||
_hint_?: {
|
||||
packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'ChatMessageLiteForRoom'>> {
|
||||
const packedFiles = options?._hint_?.packedFiles;
|
||||
const packedUsers = options?._hint_?.packedUsers;
|
||||
|
||||
const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
|
||||
|
||||
for (const record of message.reactions) {
|
||||
const [userId, reaction] = record.split('/');
|
||||
reactions.push({
|
||||
user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
|
||||
reaction,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
createdAt: this.idService.parse(message.id).date.toISOString(),
|
||||
text: message.text,
|
||||
fromUserId: message.fromUserId,
|
||||
fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId),
|
||||
toRoomId: message.toRoomId!,
|
||||
fileId: message.fileId,
|
||||
file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
|
||||
reactions,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMessagesLiteForRoom(
|
||||
messages: MiChatMessage[],
|
||||
) {
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const users = messages.map(x => x.fromUser ?? x.fromUserId);
|
||||
const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
|
||||
|
||||
for (const reactedUserId of reactedUserIds) {
|
||||
if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
|
||||
users.push(reactedUserId);
|
||||
}
|
||||
}
|
||||
|
||||
const [packedUsers, packedFiles] = await Promise.all([
|
||||
this.userEntityService.packMany(users)
|
||||
.then(users => new Map(users.map(u => [u.id, u]))),
|
||||
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
|
||||
.then(files => new Map(files.map(f => [f.id, f]))),
|
||||
]);
|
||||
|
||||
return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } })));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packRoom(
|
||||
src: MiChatRoom['id'] | MiChatRoom,
|
||||
me?: { id: MiUser['id'] },
|
||||
options?: {
|
||||
_hint_?: {
|
||||
packedOwners: Map<MiChatRoom['id'], Packed<'UserLite'>>;
|
||||
memberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'ChatRoom'>> {
|
||||
const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
createdAt: this.idService.parse(room.id).date.toISOString(),
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
ownerId: room.ownerId,
|
||||
owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me),
|
||||
isMuted: membership != null ? membership.isMuted : false,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packRooms(
|
||||
rooms: (MiChatRoom | MiChatRoom['id'])[],
|
||||
me: { id: MiUser['id'] },
|
||||
) {
|
||||
if (rooms.length === 0) return [];
|
||||
|
||||
const _rooms = rooms.filter((room): room is MiChatRoom => typeof room !== 'string');
|
||||
if (_rooms.length !== rooms.length) {
|
||||
_rooms.push(
|
||||
...await this.chatRoomsRepository.find({
|
||||
where: {
|
||||
id: In(rooms.filter((room): room is string => typeof room === 'string')),
|
||||
},
|
||||
relations: ['owner'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const owners = _rooms.map(x => x.owner ?? x.ownerId);
|
||||
|
||||
const [packedOwners, memberships] = await Promise.all([
|
||||
this.userEntityService.packMany(owners, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u]))),
|
||||
this.chatRoomMembershipsRepository.find({
|
||||
where: {
|
||||
roomId: In(_rooms.map(x => x.id)),
|
||||
userId: me.id,
|
||||
},
|
||||
}).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
|
||||
]);
|
||||
|
||||
return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } })));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packRoomInvitation(
|
||||
src: MiChatRoomInvitation['id'] | MiChatRoomInvitation,
|
||||
me: { id: MiUser['id'] },
|
||||
options?: {
|
||||
_hint_?: {
|
||||
packedRooms: Map<MiChatRoomInvitation['roomId'], Packed<'ChatRoom'>>;
|
||||
packedUsers: Map<MiChatRoomInvitation['id'], Packed<'UserLite'>>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'ChatRoomInvitation'>> {
|
||||
const invitation = typeof src === 'object' ? src : await this.chatRoomInvitationsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return {
|
||||
id: invitation.id,
|
||||
createdAt: this.idService.parse(invitation.id).date.toISOString(),
|
||||
roomId: invitation.roomId,
|
||||
room: options?._hint_?.packedRooms.get(invitation.roomId) ?? await this.packRoom(invitation.room ?? invitation.roomId, me),
|
||||
userId: invitation.userId,
|
||||
user: options?._hint_?.packedUsers.get(invitation.userId) ?? await this.userEntityService.pack(invitation.user ?? invitation.userId, me),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packRoomInvitations(
|
||||
invitations: MiChatRoomInvitation[],
|
||||
me: { id: MiUser['id'] },
|
||||
) {
|
||||
if (invitations.length === 0) return [];
|
||||
|
||||
return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packRoomMembership(
|
||||
src: MiChatRoomMembership['id'] | MiChatRoomMembership,
|
||||
me: { id: MiUser['id'] },
|
||||
options?: {
|
||||
populateUser?: boolean;
|
||||
populateRoom?: boolean;
|
||||
_hint_?: {
|
||||
packedRooms: Map<MiChatRoomMembership['roomId'], Packed<'ChatRoom'>>;
|
||||
packedUsers: Map<MiChatRoomMembership['id'], Packed<'UserLite'>>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'ChatRoomMembership'>> {
|
||||
const membership = typeof src === 'object' ? src : await this.chatRoomMembershipsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return {
|
||||
id: membership.id,
|
||||
createdAt: this.idService.parse(membership.id).date.toISOString(),
|
||||
userId: membership.userId,
|
||||
user: options?.populateUser ? (options._hint_?.packedUsers.get(membership.userId) ?? await this.userEntityService.pack(membership.user ?? membership.userId, me)) : undefined,
|
||||
roomId: membership.roomId,
|
||||
room: options?.populateRoom ? (options._hint_?.packedRooms.get(membership.roomId) ?? await this.packRoom(membership.room ?? membership.roomId, me)) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packRoomMemberships(
|
||||
memberships: MiChatRoomMembership[],
|
||||
me: { id: MiUser['id'] },
|
||||
options: {
|
||||
populateUser?: boolean;
|
||||
populateRoom?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (memberships.length === 0) return [];
|
||||
|
||||
const users = memberships.map(x => x.user ?? x.userId);
|
||||
const rooms = memberships.map(x => x.room ?? x.roomId);
|
||||
|
||||
const [packedUsers, packedRooms] = await Promise.all([
|
||||
this.userEntityService.packMany(users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u]))),
|
||||
this.packRooms(rooms, me)
|
||||
.then(rooms => new Map(rooms.map(r => [r.id, r]))),
|
||||
]);
|
||||
|
||||
return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } })));
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { EmojisRepository } from '@/models/_.js';
|
||||
import type { EmojisRepository, MiRole, RolesRepository } from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiEmoji } from '@/models/Emoji.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
@@ -16,6 +16,8 @@ export class EmojiEntityService {
|
||||
constructor(
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
@Inject(DI.rolesRepository)
|
||||
private rolesRepository: RolesRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -68,8 +70,90 @@ export class EmojiEntityService {
|
||||
@bindThis
|
||||
public packDetailedMany(
|
||||
emojis: any[],
|
||||
) {
|
||||
): Promise<Packed<'EmojiDetailed'>[]> {
|
||||
return Promise.all(emojis.map(x => this.packDetailed(x)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailedAdmin(
|
||||
src: MiEmoji['id'] | MiEmoji,
|
||||
hint?: {
|
||||
roles?: Map<MiRole['id'], MiRole>
|
||||
},
|
||||
): Promise<Packed<'EmojiDetailedAdmin'>> {
|
||||
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const roles = Array.of<MiRole>();
|
||||
if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0) {
|
||||
if (hint?.roles) {
|
||||
const hintRoles = hint.roles;
|
||||
roles.push(
|
||||
...emoji.roleIdsThatCanBeUsedThisEmojiAsReaction
|
||||
.filter(x => hintRoles.has(x))
|
||||
.map(x => hintRoles.get(x)!),
|
||||
);
|
||||
} else {
|
||||
roles.push(
|
||||
...await this.rolesRepository.findBy({ id: In(emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) }),
|
||||
);
|
||||
}
|
||||
|
||||
roles.sort((a, b) => {
|
||||
if (a.displayOrder !== b.displayOrder) {
|
||||
return b.displayOrder - a.displayOrder;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: emoji.id,
|
||||
updatedAt: emoji.updatedAt?.toISOString() ?? null,
|
||||
name: emoji.name,
|
||||
host: emoji.host,
|
||||
uri: emoji.uri,
|
||||
type: emoji.type,
|
||||
aliases: emoji.aliases,
|
||||
category: emoji.category,
|
||||
publicUrl: emoji.publicUrl,
|
||||
originalUrl: emoji.originalUrl,
|
||||
license: emoji.license,
|
||||
localOnly: emoji.localOnly,
|
||||
isSensitive: emoji.isSensitive,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: roles.map(it => ({ id: it.id, name: it.name })),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailedAdminMany(
|
||||
emojis: MiEmoji['id'][] | MiEmoji[],
|
||||
hint?: {
|
||||
roles?: Map<MiRole['id'], MiRole>
|
||||
},
|
||||
): Promise<Packed<'EmojiDetailedAdmin'>[]> {
|
||||
// IDのみの要素をピックアップし、DBからレコードを取り出して他の値を補完する
|
||||
const emojiEntities = emojis.filter(x => typeof x === 'object') as MiEmoji[];
|
||||
const emojiIdOnlyList = emojis.filter(x => typeof x === 'string') as string[];
|
||||
if (emojiIdOnlyList.length > 0) {
|
||||
emojiEntities.push(...await this.emojisRepository.findBy({ id: In(emojiIdOnlyList) }));
|
||||
}
|
||||
|
||||
// 特定ロール専用の絵文字である場合、そのロール情報をあらかじめまとめて取得しておく(pack側で都度取得も出来るが負荷が高いので)
|
||||
let hintRoles: Map<MiRole['id'], MiRole>;
|
||||
if (hint?.roles) {
|
||||
hintRoles = hint.roles;
|
||||
} else {
|
||||
const roles = Array.of<MiRole>();
|
||||
const roleIds = [...new Set(emojiEntities.flatMap(x => x.roleIdsThatCanBeUsedThisEmojiAsReaction))];
|
||||
if (roleIds.length > 0) {
|
||||
roles.push(...await this.rolesRepository.findBy({ id: In(roleIds) }));
|
||||
}
|
||||
|
||||
hintRoles = new Map(roles.map(x => [x.id, x]));
|
||||
}
|
||||
|
||||
return Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles })));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { FlashLikesRepository, FlashsRepository } from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiFlash } from '@/models/Flash.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
@@ -20,10 +18,8 @@ export class FlashEntityService {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
@Inject(DI.flashLikesRepository)
|
||||
private flashLikesRepository: FlashLikesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
@@ -34,24 +30,36 @@ export class FlashEntityService {
|
||||
src: MiFlash['id'] | MiFlash,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
packedUser?: Packed<'UserLite'>,
|
||||
likedFlashIds?: MiFlash['id'][],
|
||||
},
|
||||
): Promise<Packed<'Flash'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return await awaitAll({
|
||||
// { schema: 'UserDetailed' } すると無限ループするので注意
|
||||
const user = hint?.packedUser ?? await this.userEntityService.pack(flash.user ?? flash.userId, me);
|
||||
|
||||
let isLiked = undefined;
|
||||
if (meId) {
|
||||
isLiked = hint?.likedFlashIds
|
||||
? hint.likedFlashIds.includes(flash.id)
|
||||
: await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } });
|
||||
}
|
||||
|
||||
return {
|
||||
id: flash.id,
|
||||
createdAt: this.idService.parse(flash.id).date.toISOString(),
|
||||
updatedAt: flash.updatedAt.toISOString(),
|
||||
userId: flash.userId,
|
||||
user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意
|
||||
user: user,
|
||||
title: flash.title,
|
||||
summary: flash.summary,
|
||||
script: flash.script,
|
||||
visibility: flash.visibility,
|
||||
likedCount: flash.likedCount,
|
||||
isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined,
|
||||
});
|
||||
isLiked: isLiked,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -62,7 +70,19 @@ export class FlashEntityService {
|
||||
const _users = flashes.map(({ user, userId }) => user ?? userId);
|
||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u])));
|
||||
return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) })));
|
||||
const _likedFlashIds = me
|
||||
? await this.flashLikesRepository.createQueryBuilder('flashLike')
|
||||
.select('flashLike.flashId')
|
||||
.where('flashLike.userId = :userId', { userId: me.id })
|
||||
.getRawMany<{ flashLike_flashId: string }>()
|
||||
.then(likes => [...new Set(likes.map(like => like.flashLike_flashId))])
|
||||
: [];
|
||||
return Promise.all(
|
||||
flashes.map(flash => this.pack(flash, me, {
|
||||
packedUser: _userMap.get(flash.userId),
|
||||
likedFlashIds: _likedFlashIds,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,19 +3,22 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MiMeta } from '@/models/_.js';
|
||||
|
||||
@Injectable()
|
||||
export class InstanceEntityService {
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
private roleService: RoleService,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
@@ -27,7 +30,6 @@ export class InstanceEntityService {
|
||||
instance: MiInstance,
|
||||
me?: { id: MiUser['id']; } | null | undefined,
|
||||
): Promise<Packed<'FederationInstance'>> {
|
||||
const meta = await this.metaService.fetch();
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
|
||||
return {
|
||||
@@ -41,7 +43,7 @@ export class InstanceEntityService {
|
||||
isNotResponding: instance.isNotResponding,
|
||||
isSuspended: instance.suspensionState !== 'none',
|
||||
suspensionState: instance.suspensionState,
|
||||
isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host),
|
||||
isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host),
|
||||
softwareName: instance.softwareName,
|
||||
softwareVersion: instance.softwareVersion,
|
||||
openRegistrations: instance.openRegistrations,
|
||||
@@ -49,7 +51,8 @@ export class InstanceEntityService {
|
||||
description: instance.description,
|
||||
maintainerName: instance.maintainerName,
|
||||
maintainerEmail: instance.maintainerEmail,
|
||||
isSilenced: this.utilityService.isSilencedHost(meta.silencedHosts, instance.host),
|
||||
isSilenced: this.utilityService.isSilencedHost(this.meta.silencedHosts, instance.host),
|
||||
isMediaSilenced: this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, instance.host),
|
||||
iconUrl: instance.iconUrl,
|
||||
faviconUrl: instance.faviconUrl,
|
||||
themeColor: instance.themeColor,
|
||||
@@ -62,8 +65,9 @@ export class InstanceEntityService {
|
||||
@bindThis
|
||||
public packMany(
|
||||
instances: MiInstance[],
|
||||
me?: { id: MiUser['id']; } | null | undefined,
|
||||
) {
|
||||
return Promise.all(instances.map(x => this.pack(x)));
|
||||
return Promise.all(instances.map(x => this.pack(x, me)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,8 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { MiMeta } from '@/models/Meta.js';
|
||||
import type { AdsRepository } from '@/models/_.js';
|
||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
@@ -24,12 +22,13 @@ export class MetaEntityService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.adsRepository)
|
||||
private adsRepository: AdsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private metaService: MetaService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private systemAccountService: SystemAccountService,
|
||||
) { }
|
||||
|
||||
@bindThis
|
||||
@@ -37,7 +36,7 @@ export class MetaEntityService {
|
||||
let instance = meta;
|
||||
|
||||
if (!instance) {
|
||||
instance = await this.metaService.fetch();
|
||||
instance = this.meta;
|
||||
}
|
||||
|
||||
const ads = await this.adsRepository.createQueryBuilder('ads')
|
||||
@@ -95,6 +94,8 @@ export class MetaEntityService {
|
||||
recaptchaSiteKey: instance.recaptchaSiteKey,
|
||||
enableTurnstile: instance.enableTurnstile,
|
||||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
enableTestcaptcha: instance.enableTestcaptcha,
|
||||
googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
|
||||
@@ -126,8 +127,12 @@ export class MetaEntityService {
|
||||
|
||||
policies: { ...DEFAULT_POLICIES, ...instance.policies },
|
||||
|
||||
sentryForFrontend: this.config.sentryForFrontend ?? null,
|
||||
mediaProxy: this.config.mediaProxy,
|
||||
enableUrlPreview: instance.urlPreviewEnabled,
|
||||
noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local',
|
||||
maxFileSize: this.config.maxFileSize,
|
||||
federation: this.meta.federation,
|
||||
};
|
||||
|
||||
return packed;
|
||||
@@ -138,19 +143,19 @@ export class MetaEntityService {
|
||||
let instance = meta;
|
||||
|
||||
if (!instance) {
|
||||
instance = await this.metaService.fetch();
|
||||
instance = this.meta;
|
||||
}
|
||||
|
||||
const packed = await this.pack(instance);
|
||||
|
||||
const proxyAccount = instance.proxyAccountId ? await this.userEntityService.pack(instance.proxyAccountId).catch(() => null) : null;
|
||||
const proxyAccount = await this.systemAccountService.fetch('proxy');
|
||||
|
||||
const packDetailed: Packed<'MetaDetailed'> = {
|
||||
...packed,
|
||||
cacheRemoteFiles: instance.cacheRemoteFiles,
|
||||
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
|
||||
requireSetup: !await this.instanceActorService.realLocalUsersPresent(),
|
||||
proxyAccountName: proxyAccount ? proxyAccount.username : null,
|
||||
requireSetup: this.meta.rootUserId == null,
|
||||
proxyAccountName: proxyAccount.username,
|
||||
features: {
|
||||
localTimeline: instance.policies.ltlAvailable,
|
||||
globalTimeline: instance.policies.gtlAvailable,
|
||||
|
||||
@@ -11,29 +11,57 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DebounceLoader } from '@/misc/loader.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
||||
import type { ReactionService } from '../ReactionService.js';
|
||||
import type { UserEntityService } from './UserEntityService.js';
|
||||
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
|
||||
// is-renote.tsとよしなにリンク
|
||||
function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
|
||||
return (
|
||||
note.renote != null &&
|
||||
note.reply == null &&
|
||||
note.text == null &&
|
||||
note.cw == null &&
|
||||
(note.fileIds == null || note.fileIds.length === 0) &&
|
||||
!note.hasPoll
|
||||
);
|
||||
}
|
||||
|
||||
function getAppearNoteIds(notes: MiNote[]): Set<string> {
|
||||
const appearNoteIds = new Set<string>();
|
||||
for (const note of notes) {
|
||||
if (isPureRenote(note)) {
|
||||
appearNoteIds.add(note.renoteId);
|
||||
} else {
|
||||
appearNoteIds.add(note.id);
|
||||
}
|
||||
}
|
||||
return appearNoteIds;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NoteEntityService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
private driveFileEntityService: DriveFileEntityService;
|
||||
private customEmojiService: CustomEmojiService;
|
||||
private reactionService: ReactionService;
|
||||
private reactionsBufferingService: ReactionsBufferingService;
|
||||
private idService: IdService;
|
||||
private noteLoader = new DebounceLoader(this.findNoteOrFail);
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@@ -59,6 +87,8 @@ export class NoteEntityService implements OnModuleInit {
|
||||
//private driveFileEntityService: DriveFileEntityService,
|
||||
//private customEmojiService: CustomEmojiService,
|
||||
//private reactionService: ReactionService,
|
||||
//private reactionsBufferingService: ReactionsBufferingService,
|
||||
//private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -67,54 +97,88 @@ export class NoteEntityService implements OnModuleInit {
|
||||
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
|
||||
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
|
||||
this.reactionService = this.moduleRef.get('ReactionService');
|
||||
this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService');
|
||||
this.idService = this.moduleRef.get('IdService');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null) {
|
||||
private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] {
|
||||
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
|
||||
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
|
||||
if ((followersOnlyBefore != null)
|
||||
&& (
|
||||
(followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000)))
|
||||
|| (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000))
|
||||
)
|
||||
) {
|
||||
packedNote.visibility = 'followers';
|
||||
}
|
||||
}
|
||||
return packedNote.visibility;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> {
|
||||
if (meId === packedNote.userId) return;
|
||||
|
||||
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
|
||||
let hide = false;
|
||||
|
||||
// visibility が specified かつ自分が指定されていなかったら非表示
|
||||
if (packedNote.visibility === 'specified') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else if (meId === packedNote.userId) {
|
||||
hide = false;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
const specified = packedNote.visibleUserIds!.some((id: any) => meId === id);
|
||||
if (packedNote.user.requireSigninToViewContents && meId == null) {
|
||||
hide = true;
|
||||
}
|
||||
|
||||
if (specified) {
|
||||
hide = false;
|
||||
} else {
|
||||
if (!hide) {
|
||||
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
|
||||
if ((hiddenBefore != null)
|
||||
&& (
|
||||
(hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000)))
|
||||
|| (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000))
|
||||
)
|
||||
) {
|
||||
hide = true;
|
||||
}
|
||||
}
|
||||
|
||||
// visibility が specified かつ自分が指定されていなかったら非表示
|
||||
if (!hide) {
|
||||
if (packedNote.visibility === 'specified') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
const specified = packedNote.visibleUserIds!.some(id => meId === id);
|
||||
|
||||
if (!specified) {
|
||||
hide = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
|
||||
if (packedNote.visibility === 'followers') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else if (meId === packedNote.userId) {
|
||||
hide = false;
|
||||
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
|
||||
// 自分の投稿に対するリプライ
|
||||
hide = false;
|
||||
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
|
||||
// 自分へのメンション
|
||||
hide = false;
|
||||
} else {
|
||||
// フォロワーかどうか
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: packedNote.userId,
|
||||
followerId: meId,
|
||||
},
|
||||
});
|
||||
if (!hide) {
|
||||
if (packedNote.visibility === 'followers') {
|
||||
if (meId == null) {
|
||||
hide = true;
|
||||
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
|
||||
// 自分の投稿に対するリプライ
|
||||
hide = false;
|
||||
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
|
||||
// 自分へのメンション
|
||||
hide = false;
|
||||
} else {
|
||||
// フォロワーかどうか
|
||||
// TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: packedNote.userId,
|
||||
followerId: meId,
|
||||
},
|
||||
});
|
||||
|
||||
hide = !isFollowing;
|
||||
hide = !isFollowing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +190,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
packedNote.poll = undefined;
|
||||
packedNote.cw = null;
|
||||
packedNote.isHidden = true;
|
||||
// TODO: hiddenReason みたいなのを提供しても良さそう
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +285,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
return true;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
return note.visibleUserIds.some((id: any) => meId === id);
|
||||
return note.visibleUserIds.some(id => meId === id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +352,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
skipHide?: boolean;
|
||||
withReactionAndUserPairCache?: boolean;
|
||||
_hint_?: {
|
||||
bufferedReactions: Map<MiNote['id'], { deltas: Record<string, number>; pairs: ([MiUser['id'], string])[] }> | null;
|
||||
myReactions: Map<MiNote['id'], string | null>;
|
||||
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>
|
||||
@@ -303,6 +369,15 @@ export class NoteEntityService implements OnModuleInit {
|
||||
const note = typeof src === 'object' ? src : await this.noteLoader.load(src);
|
||||
const host = note.userHost;
|
||||
|
||||
const bufferedReactions = opts._hint_?.bufferedReactions != null
|
||||
? (opts._hint_.bufferedReactions.get(note.id) ?? { deltas: {}, pairs: [] })
|
||||
: this.meta.enableReactionsBuffering
|
||||
? await this.reactionsBufferingService.get(note.id)
|
||||
: { deltas: {}, pairs: [] };
|
||||
const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions.deltas ?? {}));
|
||||
|
||||
const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/')));
|
||||
|
||||
let text = note.text;
|
||||
|
||||
if (note.name && (note.url ?? note.uri)) {
|
||||
@@ -315,7 +390,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
: await this.channelsRepository.findOneBy({ id: note.channelId })
|
||||
: null;
|
||||
|
||||
const reactionEmojiNames = Object.keys(note.reactions)
|
||||
const reactionEmojiNames = Object.keys(reactions)
|
||||
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
|
||||
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
|
||||
const packedFiles = options?._hint_?.packedFiles;
|
||||
@@ -334,10 +409,10 @@ export class NoteEntityService implements OnModuleInit {
|
||||
visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined,
|
||||
renoteCount: note.renoteCount,
|
||||
repliesCount: note.repliesCount,
|
||||
reactionCount: Object.values(note.reactions).reduce((a, b) => a + b, 0),
|
||||
reactions: this.reactionService.convertLegacyReactions(note.reactions),
|
||||
reactionCount: Object.values(reactions).reduce((a, b) => a + b, 0),
|
||||
reactions: reactions,
|
||||
reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host),
|
||||
reactionAndUserPairCache: opts.withReactionAndUserPairCache ? note.reactionAndUserPairCache : undefined,
|
||||
reactionAndUserPairCache: opts.withReactionAndUserPairCache ? reactionAndUserPairCache : undefined,
|
||||
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
|
||||
tags: note.tags.length > 0 ? note.tags : undefined,
|
||||
fileIds: note.fileIds,
|
||||
@@ -376,12 +451,18 @@ export class NoteEntityService implements OnModuleInit {
|
||||
|
||||
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
|
||||
|
||||
...(meId && Object.keys(note.reactions).length > 0 ? {
|
||||
myReaction: this.populateMyReaction(note, meId, options?._hint_),
|
||||
...(meId && Object.keys(reactions).length > 0 ? {
|
||||
myReaction: this.populateMyReaction({
|
||||
id: note.id,
|
||||
reactions: reactions,
|
||||
reactionAndUserPairCache: reactionAndUserPairCache,
|
||||
}, meId, options?._hint_),
|
||||
} : {}),
|
||||
} : {}),
|
||||
});
|
||||
|
||||
this.treatVisibility(packed);
|
||||
|
||||
if (!opts.skipHide) {
|
||||
await this.hideNote(packed, meId);
|
||||
}
|
||||
@@ -400,6 +481,8 @@ export class NoteEntityService implements OnModuleInit {
|
||||
) {
|
||||
if (notes.length === 0) return [];
|
||||
|
||||
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
|
||||
|
||||
const meId = me ? me.id : null;
|
||||
const myReactionsMap = new Map<MiNote['id'], string | null>();
|
||||
if (meId) {
|
||||
@@ -409,24 +492,34 @@ export class NoteEntityService implements OnModuleInit {
|
||||
const oldId = this.idService.gen(Date.now() - 2000);
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote
|
||||
const reactionsCount = Object.values(note.renote.reactions).reduce((a, b) => a + b, 0);
|
||||
if (isPureRenote(note)) {
|
||||
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
|
||||
if (reactionsCount === 0) {
|
||||
myReactionsMap.set(note.renote.id, null);
|
||||
} else if (reactionsCount <= note.renote.reactionAndUserPairCache.length) {
|
||||
const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId));
|
||||
myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null);
|
||||
} else if (reactionsCount <= note.renote.reactionAndUserPairCache.length + (bufferedReactions?.get(note.renote.id)?.pairs.length ?? 0)) {
|
||||
const pairInBuffer = bufferedReactions?.get(note.renote.id)?.pairs.find(p => p[0] === meId);
|
||||
if (pairInBuffer) {
|
||||
myReactionsMap.set(note.renote.id, pairInBuffer[1]);
|
||||
} else {
|
||||
const pair = note.renote.reactionAndUserPairCache.find(p => p.startsWith(meId));
|
||||
myReactionsMap.set(note.renote.id, pair ? pair.split('/')[1] : null);
|
||||
}
|
||||
} else {
|
||||
idsNeedFetchMyReaction.add(note.renote.id);
|
||||
}
|
||||
} else {
|
||||
if (note.id < oldId) {
|
||||
const reactionsCount = Object.values(note.reactions).reduce((a, b) => a + b, 0);
|
||||
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
|
||||
if (reactionsCount === 0) {
|
||||
myReactionsMap.set(note.id, null);
|
||||
} else if (reactionsCount <= note.reactionAndUserPairCache.length) {
|
||||
const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId));
|
||||
myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null);
|
||||
} else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) {
|
||||
const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId);
|
||||
if (pairInBuffer) {
|
||||
myReactionsMap.set(note.id, pairInBuffer[1]);
|
||||
} else {
|
||||
const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId));
|
||||
myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null);
|
||||
}
|
||||
} else {
|
||||
idsNeedFetchMyReaction.add(note.id);
|
||||
}
|
||||
@@ -461,6 +554,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
return await Promise.all(notes.map(n => this.pack(n, me, {
|
||||
...options,
|
||||
_hint_: {
|
||||
bufferedReactions,
|
||||
myReactions: myReactionsMap,
|
||||
packedFiles,
|
||||
packedUsers,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { bindThis } from '@/decorators.js';
|
||||
import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { RoleEntityService } from './RoleEntityService.js';
|
||||
import { ChatEntityService } from './ChatEntityService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { UserEntityService } from './UserEntityService.js';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
@@ -27,6 +28,7 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
private noteEntityService: NoteEntityService;
|
||||
private roleEntityService: RoleEntityService;
|
||||
private chatEntityService: ChatEntityService;
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
@@ -41,9 +43,6 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
|
||||
//private userEntityService: UserEntityService,
|
||||
//private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -51,6 +50,7 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
this.userEntityService = this.moduleRef.get('UserEntityService');
|
||||
this.noteEntityService = this.moduleRef.get('NoteEntityService');
|
||||
this.roleEntityService = this.moduleRef.get('RoleEntityService');
|
||||
this.chatEntityService = this.moduleRef.get('ChatEntityService');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +59,6 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
async #packInternal <T extends MiNotification | MiGroupedNotification> (
|
||||
src: T,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
checkValidNotifier?: boolean;
|
||||
},
|
||||
@@ -92,7 +91,7 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
// if the user has been deleted, don't show this notification
|
||||
if (needsUser && !userIfNeed) return null;
|
||||
|
||||
// #region Grouped notifications
|
||||
//#region Grouped notifications
|
||||
if (notification.type === 'reaction:grouped') {
|
||||
const reactions = (await Promise.all(notification.reactions.map(async reaction => {
|
||||
const user = hint?.packedUsers != null
|
||||
@@ -137,7 +136,7 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
users,
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
//#endregion
|
||||
|
||||
const needsRole = notification.type === 'roleAssigned';
|
||||
const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined;
|
||||
@@ -146,6 +145,13 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
return null;
|
||||
}
|
||||
|
||||
const needsChatRoomInvitation = notification.type === 'chatRoomInvitationReceived';
|
||||
const chatRoomInvitation = needsChatRoomInvitation ? await this.chatEntityService.packRoomInvitation(notification.invitationId, { id: meId }).catch(() => null) : undefined;
|
||||
// if the invitation has been deleted, don't show this notification
|
||||
if (needsChatRoomInvitation && !chatRoomInvitation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
@@ -159,9 +165,19 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
...(notification.type === 'roleAssigned' ? {
|
||||
role: role,
|
||||
} : {}),
|
||||
...(notification.type === 'chatRoomInvitationReceived' ? {
|
||||
invitation: chatRoomInvitation,
|
||||
} : {}),
|
||||
...(notification.type === 'followRequestAccepted' ? {
|
||||
message: notification.message,
|
||||
} : {}),
|
||||
...(notification.type === 'achievementEarned' ? {
|
||||
achievement: notification.achievement,
|
||||
} : {}),
|
||||
...(notification.type === 'exportCompleted' ? {
|
||||
exportedEntity: notification.exportedEntity,
|
||||
fileId: notification.fileId,
|
||||
} : {}),
|
||||
...(notification.type === 'app' ? {
|
||||
body: notification.customBody,
|
||||
header: notification.customHeader,
|
||||
@@ -229,7 +245,7 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
public async pack(
|
||||
src: MiNotification | MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
|
||||
options: {
|
||||
checkValidNotifier?: boolean;
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { MiRole } from '@/models/Role.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
@Injectable()
|
||||
export class RoleEntityService {
|
||||
@@ -31,7 +32,7 @@ export class RoleEntityService {
|
||||
public async pack(
|
||||
src: MiRole['id'] | MiRole,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
) {
|
||||
): Promise<Packed<'Role'>> {
|
||||
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const assignedCount = await this.roleAssignmentsRepository.createQueryBuilder('assign')
|
||||
@@ -67,6 +68,7 @@ export class RoleEntityService {
|
||||
isModerator: role.isModerator,
|
||||
isExplorable: role.isExplorable,
|
||||
asBadge: role.asBadge,
|
||||
preserveAssignmentOnMoveAccount: role.preserveAssignmentOnMoveAccount,
|
||||
canEditMembersByModerator: role.canEditMembersByModerator,
|
||||
displayOrder: role.displayOrder,
|
||||
policies: policies,
|
||||
|
||||
@@ -28,10 +28,10 @@ import type {
|
||||
FollowingsRepository,
|
||||
FollowRequestsRepository,
|
||||
MiFollowing,
|
||||
MiMeta,
|
||||
MiUserNotePining,
|
||||
MiUserProfile,
|
||||
MutingsRepository,
|
||||
NoteUnreadsRepository,
|
||||
RenoteMutingsRepository,
|
||||
UserMemoRepository,
|
||||
UserNotePiningsRepository,
|
||||
@@ -47,9 +47,9 @@ import { IdService } from '@/core/IdService.js';
|
||||
import type { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||
import type { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
|
||||
import { ChatService } from '@/core/ChatService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import type { PageEntityService } from './PageEntityService.js';
|
||||
|
||||
const Ajv = _Ajv.default;
|
||||
@@ -57,12 +57,14 @@ const ajv = new Ajv();
|
||||
|
||||
function isLocalUser(user: MiUser): user is MiLocalUser;
|
||||
function isLocalUser<T extends { host: MiUser['host'] }>(user: T): user is (T & { host: null; });
|
||||
|
||||
function isLocalUser(user: MiUser | { host: MiUser['host'] }): boolean {
|
||||
return user.host == null;
|
||||
}
|
||||
|
||||
function isRemoteUser(user: MiUser): user is MiRemoteUser;
|
||||
function isRemoteUser<T extends { host: MiUser['host'] }>(user: T): user is (T & { host: string; });
|
||||
|
||||
function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean {
|
||||
return !isLocalUser(user);
|
||||
}
|
||||
@@ -78,7 +80,7 @@ export type UserRelation = {
|
||||
isBlocked: boolean
|
||||
isMuted: boolean
|
||||
isRenoteMuted: boolean
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UserEntityService implements OnModuleInit {
|
||||
@@ -91,6 +93,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
private federatedInstanceService: FederatedInstanceService;
|
||||
private idService: IdService;
|
||||
private avatarDecorationService: AvatarDecorationService;
|
||||
private chatService: ChatService;
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
@@ -98,6 +101,9 @@ export class UserEntityService implements OnModuleInit {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@@ -122,9 +128,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
@Inject(DI.renoteMutingsRepository)
|
||||
private renoteMutingsRepository: RenoteMutingsRepository,
|
||||
|
||||
@Inject(DI.noteUnreadsRepository)
|
||||
private noteUnreadsRepository: NoteUnreadsRepository,
|
||||
|
||||
@Inject(DI.userNotePiningsRepository)
|
||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||
|
||||
@@ -146,6 +149,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
||||
this.idService = this.moduleRef.get('IdService');
|
||||
this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService');
|
||||
this.chatService = this.moduleRef.get('ChatService');
|
||||
}
|
||||
|
||||
//#region Validators
|
||||
@@ -379,7 +383,11 @@ export class UserEntityService implements OnModuleInit {
|
||||
|
||||
@bindThis
|
||||
public getIdenticonUrl(user: MiUser): string {
|
||||
return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
|
||||
if ((user.host == null || user.host === this.config.host) && user.username.includes('.') && this.meta.iconUrl) { // ローカルのシステムアカウントの場合
|
||||
return this.meta.iconUrl;
|
||||
} else {
|
||||
return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -454,12 +462,12 @@ export class UserEntityService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const followingCount = profile == null ? null :
|
||||
(profile.followingVisibility === 'public') || isMe ? user.followingCount :
|
||||
(profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
|
||||
(profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount :
|
||||
null;
|
||||
|
||||
const followersCount = profile == null ? null :
|
||||
(profile.followersVisibility === 'public') || isMe ? user.followersCount :
|
||||
(profile.followersVisibility === 'public') || isMe || iAmModerator ? user.followersCount :
|
||||
(profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount :
|
||||
null;
|
||||
|
||||
@@ -490,6 +498,9 @@ export class UserEntityService implements OnModuleInit {
|
||||
}))) : [],
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
|
||||
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
|
||||
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
|
||||
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
|
||||
name: instance.name,
|
||||
softwareName: instance.softwareName,
|
||||
@@ -508,7 +519,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
name: r.name,
|
||||
iconUrl: r.iconUrl,
|
||||
displayOrder: r.displayOrder,
|
||||
}))
|
||||
})),
|
||||
) : undefined,
|
||||
|
||||
...(isDetailed ? {
|
||||
@@ -545,11 +556,8 @@ export class UserEntityService implements OnModuleInit {
|
||||
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
|
||||
followersVisibility: profile!.followersVisibility,
|
||||
followingVisibility: profile!.followingVisibility,
|
||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||
usePasswordLessLogin: profile!.usePasswordLessLogin,
|
||||
securityKeys: profile!.twoFactorEnabled
|
||||
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
|
||||
: false,
|
||||
chatScope: user.chatScope,
|
||||
canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'),
|
||||
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
@@ -564,9 +572,18 @@ export class UserEntityService implements OnModuleInit {
|
||||
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
|
||||
} : {}),
|
||||
|
||||
...(isDetailed && (isMe || iAmModerator) ? {
|
||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||
usePasswordLessLogin: profile!.usePasswordLessLogin,
|
||||
securityKeys: profile!.twoFactorEnabled
|
||||
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
|
||||
: false,
|
||||
} : {}),
|
||||
|
||||
...(isDetailed && isMe ? {
|
||||
avatarId: user.avatarId,
|
||||
bannerId: user.bannerId,
|
||||
followedMessage: profile!.followedMessage,
|
||||
isModerator: isModerator,
|
||||
isAdmin: isAdmin,
|
||||
injectFeaturedNote: profile!.injectFeaturedNote,
|
||||
@@ -581,14 +598,9 @@ export class UserEntityService implements OnModuleInit {
|
||||
isDeleted: user.isDeleted,
|
||||
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
|
||||
where: { userId: user.id, isSpecified: true },
|
||||
take: 1,
|
||||
}).then(count => count > 0),
|
||||
hasUnreadMentions: this.noteUnreadsRepository.count({
|
||||
where: { userId: user.id, isMentioned: true },
|
||||
take: 1,
|
||||
}).then(count => count > 0),
|
||||
hasUnreadSpecifiedNotes: false, // 後方互換性のため
|
||||
hasUnreadMentions: false, // 後方互換性のため
|
||||
hasUnreadChatMessages: this.chatService.hasUnreadMessages(user.id),
|
||||
hasUnreadAnnouncement: unreadAnnouncements!.length > 0,
|
||||
unreadAnnouncements,
|
||||
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
|
||||
@@ -635,6 +647,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
isRenoteMuted: relation.isRenoteMuted,
|
||||
notify: relation.following?.notify ?? 'none',
|
||||
withReplies: relation.following?.withReplies ?? false,
|
||||
followedMessage: relation.isFollowing ? profile!.followedMessage : undefined,
|
||||
} : {}),
|
||||
} as Promiseable<Packed<S>>;
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import si from 'systeminformation';
|
||||
import Xev from 'xev';
|
||||
import * as osUtils from 'os-utils';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
import { MiMeta } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
const ev = new Xev();
|
||||
|
||||
@@ -23,7 +24,8 @@ export class ServerStatsService implements OnApplicationShutdown {
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private metaService: MetaService,
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -32,7 +34,7 @@ export class ServerStatsService implements OnApplicationShutdown {
|
||||
*/
|
||||
@bindThis
|
||||
public async start(): Promise<void> {
|
||||
if (!(await this.metaService.fetch(true)).enableServerMachineStats) return;
|
||||
if (!this.meta.enableServerMachineStats) return;
|
||||
|
||||
const log = [] as any[];
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
* The getter will return a .bind version of the function
|
||||
* and memoize the result against a symbol on the instance
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function bindThis(target: any, key: string, descriptor: any) {
|
||||
let fn = descriptor.value;
|
||||
const fn = descriptor.value;
|
||||
|
||||
if (typeof fn !== 'function') {
|
||||
throw new TypeError(`@bindThis decorator can only be applied to methods not: ${typeof fn}`);
|
||||
@@ -21,26 +22,18 @@ export function bindThis(target: any, key: string, descriptor: any) {
|
||||
configurable: true,
|
||||
get() {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (this === target.prototype || this.hasOwnProperty(key) ||
|
||||
typeof fn !== 'function') {
|
||||
if (this === target.prototype || this.hasOwnProperty(key)) {
|
||||
return fn;
|
||||
}
|
||||
|
||||
const boundFn = fn.bind(this);
|
||||
Object.defineProperty(this, key, {
|
||||
Reflect.defineProperty(this, key, {
|
||||
value: boundFn,
|
||||
configurable: true,
|
||||
get() {
|
||||
return boundFn;
|
||||
},
|
||||
set(value) {
|
||||
fn = value;
|
||||
delete this[key];
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
return boundFn;
|
||||
},
|
||||
set(value: any) {
|
||||
fn = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
export const DI = {
|
||||
config: Symbol('config'),
|
||||
db: Symbol('db'),
|
||||
meta: Symbol('meta'),
|
||||
meilisearch: Symbol('meilisearch'),
|
||||
redis: Symbol('redis'),
|
||||
redisForPub: Symbol('redisForPub'),
|
||||
redisForSub: Symbol('redisForSub'),
|
||||
redisForTimelines: Symbol('redisForTimelines'),
|
||||
redisForReactions: Symbol('redisForReactions'),
|
||||
|
||||
//#region Repositories
|
||||
usersRepository: Symbol('usersRepository'),
|
||||
@@ -22,7 +24,6 @@ export const DI = {
|
||||
noteFavoritesRepository: Symbol('noteFavoritesRepository'),
|
||||
noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'),
|
||||
noteReactionsRepository: Symbol('noteReactionsRepository'),
|
||||
noteUnreadsRepository: Symbol('noteUnreadsRepository'),
|
||||
pollsRepository: Symbol('pollsRepository'),
|
||||
pollVotesRepository: Symbol('pollVotesRepository'),
|
||||
userProfilesRepository: Symbol('userProfilesRepository'),
|
||||
@@ -72,6 +73,7 @@ export const DI = {
|
||||
registryItemsRepository: Symbol('registryItemsRepository'),
|
||||
webhooksRepository: Symbol('webhooksRepository'),
|
||||
systemWebhooksRepository: Symbol('systemWebhooksRepository'),
|
||||
systemAccountsRepository: Symbol('systemAccountsRepository'),
|
||||
adsRepository: Symbol('adsRepository'),
|
||||
passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'),
|
||||
retentionAggregationsRepository: Symbol('retentionAggregationsRepository'),
|
||||
@@ -80,6 +82,11 @@ export const DI = {
|
||||
flashsRepository: Symbol('flashsRepository'),
|
||||
flashLikesRepository: Symbol('flashLikesRepository'),
|
||||
userMemosRepository: Symbol('userMemosRepository'),
|
||||
chatMessagesRepository: Symbol('chatMessagesRepository'),
|
||||
chatApprovalsRepository: Symbol('chatApprovalsRepository'),
|
||||
chatRoomsRepository: Symbol('chatRoomsRepository'),
|
||||
chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'),
|
||||
chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'),
|
||||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||
mahjongGamesRepository: Symbol('mahjongGamesRepository'),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { WritableStream } from 'node:stream/web';
|
||||
import type { PathLike } from 'node:fs';
|
||||
|
||||
/**
|
||||
|
||||
40
packages/backend/src/misc/bigint.ts
Normal file
40
packages/backend/src/misc/bigint.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
function parseBigIntChunked(str: string, base: number, chunkSize: number, powerOfChunkSize: bigint): bigint {
|
||||
const chunks = [];
|
||||
while (str.length > 0) {
|
||||
chunks.unshift(str.slice(-chunkSize));
|
||||
str = str.slice(0, -chunkSize);
|
||||
}
|
||||
let result = 0n;
|
||||
for (const chunk of chunks) {
|
||||
result *= powerOfChunkSize;
|
||||
const int = parseInt(chunk, base);
|
||||
if (Number.isNaN(int)) {
|
||||
throw new Error('Invalid base36 string');
|
||||
}
|
||||
result += BigInt(int);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseBigInt36(str: string): bigint {
|
||||
// log_36(Number.MAX_SAFE_INTEGER) => 10.251599391715352
|
||||
// so we process 10 chars at once
|
||||
return parseBigIntChunked(str, 36, 10, 36n ** 10n);
|
||||
}
|
||||
|
||||
export function parseBigInt16(str: string): bigint {
|
||||
// log_16(Number.MAX_SAFE_INTEGER) => 13.25
|
||||
// so we process 13 chars at once
|
||||
return parseBigIntChunked(str, 16, 13, 16n ** 13n);
|
||||
}
|
||||
|
||||
export function parseBigInt32(str: string): bigint {
|
||||
// log_32(Number.MAX_SAFE_INTEGER) => 10.6
|
||||
// so we process 10 chars at once
|
||||
return parseBigIntChunked(str, 32, 10, 32n ** 10n);
|
||||
}
|
||||
@@ -7,23 +7,23 @@ import * as Redis from 'ioredis';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
export class RedisKVCache<T> {
|
||||
private redisClient: Redis.Redis;
|
||||
private name: string;
|
||||
private lifetime: number;
|
||||
private memoryCache: MemoryKVCache<T>;
|
||||
private fetcher: (key: string) => Promise<T>;
|
||||
private toRedisConverter: (value: T) => string;
|
||||
private fromRedisConverter: (value: string) => T | undefined;
|
||||
private readonly lifetime: number;
|
||||
private readonly memoryCache: MemoryKVCache<T>;
|
||||
private readonly fetcher: (key: string) => Promise<T>;
|
||||
private readonly toRedisConverter: (value: T) => string;
|
||||
private readonly fromRedisConverter: (value: string) => T | undefined;
|
||||
|
||||
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
|
||||
lifetime: RedisKVCache<T>['lifetime'];
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisKVCache<T>['fetcher'];
|
||||
toRedisConverter: RedisKVCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
|
||||
}) {
|
||||
this.redisClient = redisClient;
|
||||
this.name = name;
|
||||
constructor(
|
||||
private redisClient: Redis.Redis,
|
||||
private name: string,
|
||||
opts: {
|
||||
lifetime: RedisKVCache<T>['lifetime'];
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisKVCache<T>['fetcher'];
|
||||
toRedisConverter: RedisKVCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
|
||||
},
|
||||
) {
|
||||
this.lifetime = opts.lifetime;
|
||||
this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime);
|
||||
this.fetcher = opts.fetcher;
|
||||
@@ -55,7 +55,13 @@ export class RedisKVCache<T> {
|
||||
|
||||
const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`);
|
||||
if (cached == null) return undefined;
|
||||
return this.fromRedisConverter(cached);
|
||||
|
||||
const value = this.fromRedisConverter(cached);
|
||||
if (value !== undefined) {
|
||||
this.memoryCache.set(key, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -66,6 +72,10 @@ export class RedisKVCache<T> {
|
||||
|
||||
/**
|
||||
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
|
||||
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
|
||||
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
|
||||
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
|
||||
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
|
||||
*/
|
||||
@bindThis
|
||||
public async fetch(key: string): Promise<T> {
|
||||
@@ -77,14 +87,14 @@ export class RedisKVCache<T> {
|
||||
|
||||
// Cache MISS
|
||||
const value = await this.fetcher(key);
|
||||
this.set(key, value);
|
||||
await this.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async refresh(key: string) {
|
||||
const value = await this.fetcher(key);
|
||||
this.set(key, value);
|
||||
await this.set(key, value);
|
||||
|
||||
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
|
||||
}
|
||||
@@ -101,23 +111,23 @@ export class RedisKVCache<T> {
|
||||
}
|
||||
|
||||
export class RedisSingleCache<T> {
|
||||
private redisClient: Redis.Redis;
|
||||
private name: string;
|
||||
private lifetime: number;
|
||||
private memoryCache: MemorySingleCache<T>;
|
||||
private fetcher: () => Promise<T>;
|
||||
private toRedisConverter: (value: T) => string;
|
||||
private fromRedisConverter: (value: string) => T | undefined;
|
||||
private readonly lifetime: number;
|
||||
private readonly memoryCache: MemorySingleCache<T>;
|
||||
private readonly fetcher: () => Promise<T>;
|
||||
private readonly toRedisConverter: (value: T) => string;
|
||||
private readonly fromRedisConverter: (value: string) => T | undefined;
|
||||
|
||||
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
|
||||
lifetime: RedisSingleCache<T>['lifetime'];
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisSingleCache<T>['fetcher'];
|
||||
toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
|
||||
}) {
|
||||
this.redisClient = redisClient;
|
||||
this.name = name;
|
||||
constructor(
|
||||
private redisClient: Redis.Redis,
|
||||
private name: string,
|
||||
opts: {
|
||||
lifetime: number;
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisSingleCache<T>['fetcher'];
|
||||
toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
|
||||
},
|
||||
) {
|
||||
this.lifetime = opts.lifetime;
|
||||
this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime);
|
||||
this.fetcher = opts.fetcher;
|
||||
@@ -149,7 +159,13 @@ export class RedisSingleCache<T> {
|
||||
|
||||
const cached = await this.redisClient.get(`singlecache:${this.name}`);
|
||||
if (cached == null) return undefined;
|
||||
return this.fromRedisConverter(cached);
|
||||
|
||||
const value = this.fromRedisConverter(cached);
|
||||
if (value !== undefined) {
|
||||
this.memoryCache.set(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -160,6 +176,10 @@ export class RedisSingleCache<T> {
|
||||
|
||||
/**
|
||||
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
|
||||
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
|
||||
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
|
||||
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
|
||||
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
|
||||
*/
|
||||
@bindThis
|
||||
public async fetch(): Promise<T> {
|
||||
@@ -171,14 +191,14 @@ export class RedisSingleCache<T> {
|
||||
|
||||
// Cache MISS
|
||||
const value = await this.fetcher();
|
||||
this.set(value);
|
||||
await this.set(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async refresh() {
|
||||
const value = await this.fetcher();
|
||||
this.set(value);
|
||||
await this.set(value);
|
||||
|
||||
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
|
||||
}
|
||||
@@ -187,22 +207,12 @@ export class RedisSingleCache<T> {
|
||||
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
|
||||
|
||||
export class MemoryKVCache<T> {
|
||||
/**
|
||||
* データを持つマップ
|
||||
* @deprecated これを直接操作するべきではない
|
||||
*/
|
||||
public cache: Map<string, { date: number; value: T; }>;
|
||||
private lifetime: number;
|
||||
private gcIntervalHandle: NodeJS.Timeout;
|
||||
private readonly cache = new Map<string, { date: number; value: T; }>();
|
||||
private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m
|
||||
|
||||
constructor(lifetime: MemoryKVCache<never>['lifetime']) {
|
||||
this.cache = new Map();
|
||||
this.lifetime = lifetime;
|
||||
|
||||
this.gcIntervalHandle = setInterval(() => {
|
||||
this.gc();
|
||||
}, 1000 * 60 * 3);
|
||||
}
|
||||
constructor(
|
||||
private readonly lifetime: number,
|
||||
) {}
|
||||
|
||||
@bindThis
|
||||
/**
|
||||
@@ -287,10 +297,14 @@ export class MemoryKVCache<T> {
|
||||
@bindThis
|
||||
public gc(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, { date }] of this.cache.entries()) {
|
||||
if ((now - date) > this.lifetime) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
// The map is ordered from oldest to youngest.
|
||||
// We can stop once we find an entry that's still active, because all following entries must *also* be active.
|
||||
const age = now - date;
|
||||
if (age < this.lifetime) break;
|
||||
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,16 +312,19 @@ export class MemoryKVCache<T> {
|
||||
public dispose(): void {
|
||||
clearInterval(this.gcIntervalHandle);
|
||||
}
|
||||
|
||||
public get entries() {
|
||||
return this.cache.entries();
|
||||
}
|
||||
}
|
||||
|
||||
export class MemorySingleCache<T> {
|
||||
private cachedAt: number | null = null;
|
||||
private value: T | undefined;
|
||||
private lifetime: number;
|
||||
|
||||
constructor(lifetime: MemorySingleCache<never>['lifetime']) {
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
constructor(
|
||||
private lifetime: number,
|
||||
) {}
|
||||
|
||||
@bindThis
|
||||
public set(value: T): void {
|
||||
|
||||
44
packages/backend/src/misc/collapsed-queue.ts
Normal file
44
packages/backend/src/misc/collapsed-queue.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
type Job<V> = {
|
||||
value: V;
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
// TODO: redis使えるようにする
|
||||
export class CollapsedQueue<K, V> {
|
||||
private jobs: Map<K, Job<V>> = new Map();
|
||||
|
||||
constructor(
|
||||
private timeout: number,
|
||||
private collapse: (oldValue: V, newValue: V) => V,
|
||||
private perform: (key: K, value: V) => Promise<void>,
|
||||
) {}
|
||||
|
||||
enqueue(key: K, value: V) {
|
||||
if (this.jobs.has(key)) {
|
||||
const old = this.jobs.get(key)!;
|
||||
const merged = this.collapse(old.value, value);
|
||||
this.jobs.set(key, { ...old, value: merged });
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
const job = this.jobs.get(key)!;
|
||||
this.jobs.delete(key);
|
||||
this.perform(key, job.value);
|
||||
}, this.timeout);
|
||||
this.jobs.set(key, { value, timer });
|
||||
}
|
||||
}
|
||||
|
||||
async performAllNow() {
|
||||
const entries = [...this.jobs.entries()];
|
||||
this.jobs.clear();
|
||||
for (const [_key, job] of entries) {
|
||||
clearTimeout(job.timer);
|
||||
}
|
||||
await Promise.allSettled(entries.map(([key, job]) => this.perform(key, job.value)));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { onRequestHookHandler } from 'fastify';
|
||||
export const handleRequestRedirectToOmitSearch: onRequestHookHandler = (request, reply, done) => {
|
||||
const index = request.url.indexOf('?');
|
||||
if (~index) {
|
||||
reply.redirect(301, request.url.slice(0, index));
|
||||
reply.redirect(request.url.slice(0, index), 301);
|
||||
}
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ2の[ノイズ文字列]
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { parseBigInt36 } from '@/misc/bigint.js';
|
||||
|
||||
export const aidRegExp = /^[0-9a-z]{10}$/;
|
||||
|
||||
@@ -35,6 +36,12 @@ export function parseAid(id: string): { date: Date; } {
|
||||
return { date: new Date(time) };
|
||||
}
|
||||
|
||||
export function parseAidFull(id: string): { date: number; additional: bigint; } {
|
||||
const date = parseInt(id.slice(0, 8), 36) + TIME2000;
|
||||
const additional = parseBigInt36(id.slice(8, 10));
|
||||
return { date, additional };
|
||||
}
|
||||
|
||||
export function isSafeAidT(t: number): boolean {
|
||||
return t > TIME2000;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user