summaryrefslogtreecommitdiff
path: root/packages/backend/src/core
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2025-01-28 12:29:14 +0000
committerGitHub <noreply@github.com>2025-01-28 12:29:14 +0000
commit36880493cb16e87feb83484498fcd3cc871eb68d (patch)
tree277127cf0e6c6990577935c5bb5dff9269c605b0 /packages/backend/src/core
parentMerge pull request #14924 from misskey-dev/develop (diff)
parentRelease: 2025.1.0 (diff)
downloadmisskey-36880493cb16e87feb83484498fcd3cc871eb68d.tar.gz
misskey-36880493cb16e87feb83484498fcd3cc871eb68d.tar.bz2
misskey-36880493cb16e87feb83484498fcd3cc871eb68d.zip
Merge pull request #15279 from misskey-dev/develop
Release: 2025.1.0
Diffstat (limited to 'packages/backend/src/core')
-rw-r--r--packages/backend/src/core/AbuseReportNotificationService.ts32
-rw-r--r--packages/backend/src/core/AiService.ts25
-rw-r--r--packages/backend/src/core/CaptchaService.ts299
-rw-r--r--packages/backend/src/core/CustomEmojiService.ts224
-rw-r--r--packages/backend/src/core/FetchInstanceMetadataService.ts12
-rw-r--r--packages/backend/src/core/FileInfoService.ts4
-rw-r--r--packages/backend/src/core/MfmService.ts33
-rw-r--r--packages/backend/src/core/NoteCreateService.ts35
-rw-r--r--packages/backend/src/core/S3Service.ts2
-rw-r--r--packages/backend/src/core/SearchService.ts274
-rw-r--r--packages/backend/src/core/SystemWebhookService.ts31
-rw-r--r--packages/backend/src/core/UserBlockingService.ts8
-rw-r--r--packages/backend/src/core/UserFollowingService.ts32
-rw-r--r--packages/backend/src/core/UserService.ts9
-rw-r--r--packages/backend/src/core/UserWebhookService.ts25
-rw-r--r--packages/backend/src/core/UtilityService.ts7
-rw-r--r--packages/backend/src/core/WebAuthnService.ts4
-rw-r--r--packages/backend/src/core/activitypub/ApRendererService.ts3
-rw-r--r--packages/backend/src/core/activitypub/ApResolverService.ts25
-rw-r--r--packages/backend/src/core/activitypub/misc/check-against-url.ts6
-rw-r--r--packages/backend/src/core/activitypub/misc/contexts.ts5
-rw-r--r--packages/backend/src/core/activitypub/models/ApNoteService.ts14
-rw-r--r--packages/backend/src/core/activitypub/models/ApPersonService.ts20
-rw-r--r--packages/backend/src/core/activitypub/type.ts5
-rw-r--r--packages/backend/src/core/chart/ChartManagementService.ts10
-rw-r--r--packages/backend/src/core/entities/EmojiEntityService.ts90
-rw-r--r--packages/backend/src/core/entities/MetaEntityService.ts1
-rw-r--r--packages/backend/src/core/entities/NoteEntityService.ts9
28 files changed, 932 insertions, 312 deletions
diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts
index 742e2621fd..9bca795479 100644
--- a/packages/backend/src/core/AbuseReportNotificationService.ts
+++ b/packages/backend/src/core/AbuseReportNotificationService.ts
@@ -160,22 +160,22 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
};
});
- 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(
- convertedReports.map(it => {
- return this.systemWebhookService.enqueueSystemWebhook(
- webhookId,
- type,
- it,
- );
- }),
- );
- }
+ 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,
+ },
+ );
+ }),
+ );
}
/**
diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts
index ad852fdd6e..248a9b8979 100644
--- a/packages/backend/src/core/AiService.ts
+++ b/packages/backend/src/core/AiService.ts
@@ -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();
diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts
index 206d0dbe0a..8c7f66236e 100644
--- a/packages/backend/src/core/CaptchaService.ts
+++ b/packages/backend/src/core/CaptchaService.ts
@@ -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,46 +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 Error('testcaptcha-failed: no response provided');
+ throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'testcaptcha-failed: no response provided');
}
const success = response === 'testcaptcha-passed';
if (!success) {
- throw new Error('testcaptcha-failed');
+ 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);
}
}
diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts
index 4566113449..da71a5de6f 100644
--- a/packages/backend/src/core/CustomEmojiService.ts
+++ b/packages/backend/src/core/CustomEmojiService.ts
@@ -4,24 +4,59 @@
*/
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 { 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 { UtilityService } from '@/core/UtilityService.js';
-import { query } from '@/misc/prelude/url.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';
-import { ModerationLogService } from '@/core/ModerationLogService.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 emojisCache: MemoryKVCache<MiEmoji | null>;
@@ -30,10 +65,8 @@ export class CustomEmojiService implements OnApplicationShutdown {
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
-
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
-
private utilityService: UtilityService,
private idService: IdService,
private emojiEntityService: EmojiEntityService,
@@ -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,
@@ -105,8 +140,10 @@ export class CustomEmojiService implements OnApplicationShutdown {
@bindThis
public async update(data: (
{ id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], }
- ) & {
- driveFile?: MiDriveFile;
+ ) & {
+ originalUrl?: string;
+ publicUrl?: string;
+ fileType?: string;
category?: string | null;
aliases?: string[];
license?: string | null;
@@ -139,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,
});
@@ -308,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 // 自ホスト指定
@@ -415,6 +452,151 @@ export class CustomEmojiService implements OnApplicationShutdown {
}
@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.emojisCache.dispose();
}
diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts
index 987999bce7..ce3af7c774 100644
--- a/packages/backend/src/core/FetchInstanceMetadataService.ts
+++ b/packages/backend/src/core/FetchInstanceMetadataService.ts
@@ -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;
diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts
index 6bd6cb8d9b..fc68eb4836 100644
--- a/packages/backend/src/core/FileInfoService.ts
+++ b/packages/backend/src/core/FileInfoService.ts
@@ -13,7 +13,6 @@ 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 * as blurhash from 'blurhash';
import { createTempDir } from '@/misc/create-temp.js';
@@ -21,6 +20,7 @@ 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;
diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts
index 8061622340..bf06d4457e 100644
--- a/packages/backend/src/core/MfmService.ts
+++ b/packages/backend/src/core/MfmService.ts
@@ -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') {
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 56ddcefd7c..8a79908e82 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -614,14 +614,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);
@@ -641,13 +634,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 });
}
}
}
@@ -664,20 +651,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);
@@ -796,13 +777,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');
diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts
index bb2a463354..37721d2bf1 100644
--- a/packages/backend/src/core/S3Service.ts
+++ b/packages/backend/src/core/S3Service.ts
@@ -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;
diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts
index edfc470375..64e3f2f56a 100644
--- a/packages/backend/src/core/SearchService.ts
+++ b/packages/backend/src/core/SearchService.ts
@@ -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: {
- 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 });
- }
+ 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);
}
- 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);
+ 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
+ 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 {
- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId);
+ query.andWhere('LOWER(note.text) LIKE :q', { q: `%${ sqlLikeEscape(q.toLowerCase()) }%` });
+ }
- if (opts.userId) {
- query.andWhere('note.userId = :userId', { userId: opts.userId });
- } else if (opts.channelId) {
- query.andWhere('note.channelId = :channelId', { channelId: opts.channelId });
+ if (opts.host) {
+ if (opts.host === '.') {
+ query.andWhere('user.host IS NULL');
+ } else {
+ query.andWhere('user.host = :host', { host: opts.host });
}
+ }
- 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');
+ this.queryService.generateVisibilityQuery(query, me);
+ if (me) this.queryService.generateMutedUserQuery(query, me);
+ if (me) this.queryService.generateBlockedUserQuery(query, me);
- if (opts.host) {
- if (opts.host === '.') {
- query.andWhere('user.host IS NULL');
- } else {
- query.andWhere('user.host = :host', { host: opts.host });
- }
- }
+ return query.limit(pagination.limit).getMany();
+ }
- this.queryService.generateVisibilityQuery(query, me);
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
+ @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 });
+ }
+ }
- return await query.limit(pagination.limit).getMany();
+ 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);
}
}
diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts
index de00169612..8239490adc 100644
--- a/packages/backend/src/core/SystemWebhookService.ts
+++ b/packages/backend/src/core/SystemWebhookService.ts
@@ -50,7 +50,6 @@ export type SystemWebhookPayload<T extends SystemWebhookEventType> =
@Injectable()
export class SystemWebhookService implements OnApplicationShutdown {
- private logger: Logger;
private activeSystemWebhooksFetched = false;
private activeSystemWebhooks: MiSystemWebhook[] = [];
@@ -62,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
@@ -193,28 +190,24 @@ export class SystemWebhookService implements OnApplicationShutdown {
/**
* SystemWebhook をWebhook配送キューに追加する
* @see QueueService.systemWebhookDeliver
- * // TODO: contentの型を厳格化する
*/
@bindThis
public async enqueueSystemWebhook<T extends SystemWebhookEventType>(
- webhook: MiSystemWebhook | MiSystemWebhook['id'],
type: T,
content: SystemWebhookPayload<T>,
+ opts?: {
+ excludes?: MiSystemWebhook['id'][];
+ },
) {
- const webhookEntity = typeof webhook === 'string'
- ? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)
- : webhook;
- if (!webhookEntity || !webhookEntity.isActive) {
- this.logger.info(`SystemWebhook is not active or not found : ${webhook}`);
- return;
- }
-
- if (!webhookEntity.on.includes(type)) {
- this.logger.info(`SystemWebhook ${webhookEntity.id} is not listening to ${type}`);
- return;
- }
-
- return this.queueService.systemWebhookDeliver(webhookEntity, type, content);
+ 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
diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts
index 2f1310b8ef..8da1bb2092 100644
--- a/packages/backend/src/core/UserBlockingService.ts
+++ b/packages/backend/src/core/UserBlockingService.ts
@@ -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 });
});
}
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
index 8963003057..b98ca97ec9 100644
--- a/packages/backend/src/core/UserFollowingService.ts
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -333,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 });
});
}
@@ -347,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 });
});
// 通知を作成
@@ -400,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 });
});
}
@@ -744,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
diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts
index 9b1961c631..1f471513f3 100644
--- a/packages/backend/src/core/UserService.ts
+++ b/packages/backend/src/core/UserService.ts
@@ -63,13 +63,6 @@ export class UserService {
@bindThis
public async notifySystemWebhook(user: MiUser, type: 'userCreated') {
const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' });
- const recipientWebhookIds = await this.systemWebhookService.fetchSystemWebhooks({ isActive: true, on: [type] });
- for (const webhookId of recipientWebhookIds) {
- await this.systemWebhookService.enqueueSystemWebhook(
- webhookId,
- type,
- packedUser,
- );
- }
+ return this.systemWebhookService.enqueueSystemWebhook(type, packedUser);
}
}
diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts
index 7117a3d7fa..b1728671ae 100644
--- a/packages/backend/src/core/UserWebhookService.ts
+++ b/packages/backend/src/core/UserWebhookService.ts
@@ -5,13 +5,14 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
-import { type WebhooksRepository } from '@/models/_.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 { OnApplicationShutdown } from '@nestjs/common';
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' ? {
@@ -34,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);
}
@@ -75,6 +77,25 @@ export class UserWebhookService implements OnApplicationShutdown {
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);
diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts
index 9a2ba72ed3..fcb750d3bf 100644
--- a/packages/backend/src/core/UtilityService.ts
+++ b/packages/backend/src/core/UtilityService.ts
@@ -3,8 +3,7 @@
* 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';
@@ -106,13 +105,13 @@ export class UtilityService {
@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
diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts
index ad53192f18..ed75e4f467 100644
--- a/packages/backend/src/core/WebAuthnService.ts
+++ b/packages/backend/src/core/WebAuthnService.ts
@@ -189,14 +189,12 @@ export class WebAuthnService {
*/
@bindThis
public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise<MiUser['id'] | null> {
- const challenge = await this.redisClient.get(`webauthn:challenge:${context}`);
+ const challenge = await this.redisClient.getdel(`webauthn:challenge:${context}`);
if (!challenge) {
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`);
}
- await this.redisClient.del(`webauthn:challenge:${context}`);
-
const key = await this.userSecurityKeysRepository.findOneBy({
id: response.id,
});
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 5617a29bab..9148095067 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -183,6 +183,9 @@ export class ApRendererService {
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl,
},
+ _misskey_license: {
+ freeText: emoji.license
+ },
};
}
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index b0b35274ea..52cc569140 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -20,6 +20,7 @@ import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
export class Resolver {
private history: Set<string>;
@@ -66,7 +67,7 @@ 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}`);
}
}
@@ -80,15 +81,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);
@@ -99,7 +100,7 @@ export class Resolver {
}
if (!this.utilityService.isFederationAllowedHost(host)) {
- throw new Error('Instance is blocked');
+ throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', 'Instance is blocked');
}
if (this.config.signToActivityPubGet && !this.user) {
@@ -115,7 +116,7 @@ export class Resolver {
!(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');
}
// HttpRequestService / ApRequestService have already checked that
@@ -123,11 +124,11 @@ export class Resolver {
// object after redirects; here we double-check that no redirects
// bounced between hosts
if (object.id == null) {
- throw new Error('invalid AP object: missing id');
+ throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', 'invalid AP object: missing id');
}
if (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value)) {
- throw new Error(`invalid AP object ${value}: id ${object.id} has different host`);
+ throw new IdentifiableError('fd93c2fa-69a8-440f-880b-bf178e0ec877', `invalid AP object ${value}: id ${object.id} has different host`);
}
return object;
@@ -136,7 +137,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':
@@ -165,7 +166,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,
@@ -177,12 +178,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`);
}
}
}
diff --git a/packages/backend/src/core/activitypub/misc/check-against-url.ts b/packages/backend/src/core/activitypub/misc/check-against-url.ts
index 78ba891a2e..d679bd8180 100644
--- a/packages/backend/src/core/activitypub/misc/check-against-url.ts
+++ b/packages/backend/src/core/activitypub/misc/check-against-url.ts
@@ -5,13 +5,15 @@
import type { IObject } from '../type.js';
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
- const idOk = activity.id !== undefined && urls.includes(activity.id);
+ const hosts = urls.map(it => new URL(it).host);
+
+ const idOk = activity.id !== undefined && hosts.includes(new URL(activity.id).host);
// technically `activity.url` could be an `ApObject = IObject |
// string | (IObject | string)[]`, but if it's a complicated thing
// and the `activity.id` doesn't match, I think we're fine
// rejecting the activity
- const urlOk = typeof(activity.url) === 'string' && urls.includes(activity.url);
+ const urlOk = typeof(activity.url) === 'string' && hosts.includes(new URL(activity.url).host);
if (!idOk && !urlOk) {
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`);
diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts
index 94cb0785cb..6611e4b7f9 100644
--- a/packages/backend/src/core/activitypub/misc/contexts.ts
+++ b/packages/backend/src/core/activitypub/misc/contexts.ts
@@ -558,6 +558,11 @@ const extension_context_definition = {
'_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#',
diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts
index eb2e771a38..8abacd293f 100644
--- a/packages/backend/src/core/activitypub/models/ApNoteService.ts
+++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts
@@ -154,14 +154,8 @@ export class ApNoteService {
const url = getOneApHrefNullable(note.url);
- if (url != null) {
- if (!checkHttps(url)) {
- throw new Error('unexpected schema of note url: ' + url);
- }
-
- if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(note.id)) {
- throw new Error(`note url & uri host mismatch: note url: ${url}, note uri: ${note.id}`);
- }
+ if (url && !checkHttps(url)) {
+ throw new Error('unexpected schema of note url: ' + url);
}
this.logger.info(`Creating the Note: ${note.id}`);
@@ -414,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 });
@@ -435,6 +431,8 @@ export class ApNoteService {
publicUrl: tag.icon.url,
updatedAt: new Date(),
aliases: [],
+ // _misskey_license が存在しなければ `null`
+ license: (tag._misskey_license?.freeText ?? null)
});
}));
}
diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts
index 8590861ca0..6019906add 100644
--- a/packages/backend/src/core/activitypub/models/ApPersonService.ts
+++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts
@@ -157,8 +157,12 @@ export class ApPersonService implements OnModuleInit {
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
if (sharedInboxObject != null) {
const sharedInbox = getApId(sharedInboxObject);
- if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHost(sharedInbox) === expectHost)) {
- throw new Error('invalid Actor: wrong shared inbox');
+ 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;
+ }
}
}
@@ -257,7 +261,7 @@ export class ApPersonService implements OnModuleInit {
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)) {
@@ -344,14 +348,8 @@ export class ApPersonService implements OnModuleInit {
throw new Error('Refusing to create 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}`);
- }
+ if (url && !checkHttps(url)) {
+ throw new Error('unexpected schema of person url: ' + url);
}
// Create user
diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts
index 7496315f09..72732b01df 100644
--- a/packages/backend/src/core/activitypub/type.ts
+++ b/packages/backend/src/core/activitypub/type.ts
@@ -242,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 =>
diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts
index 79681370a1..f04c561063 100644
--- a/packages/backend/src/core/chart/ChartManagementService.ts
+++ b/packages/backend/src/core/chart/ChartManagementService.ts
@@ -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();
+ }
}
}
diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts
index 841bd731c0..490d3f2511 100644
--- a/packages/backend/src/core/entities/EmojiEntityService.ts
+++ b/packages/backend/src/core/entities/EmojiEntityService.ts
@@ -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 })));
+ }
}
diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts
index 409dca3426..ec0b5360f4 100644
--- a/packages/backend/src/core/entities/MetaEntityService.ts
+++ b/packages/backend/src/core/entities/MetaEntityService.ts
@@ -132,6 +132,7 @@ export class MetaEntityService {
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;
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index 96cc6b028e..97f1c3d739 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -102,8 +102,7 @@ export class NoteEntityService implements OnModuleInit {
}
@bindThis
- private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> {
- // FIXME: このvisibility変更処理が当関数にあるのは若干不自然かもしれない(関数名を treatVisibility とかに変える手もある)
+ private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] {
if (packedNote.visibility === 'public' || packedNote.visibility === 'home') {
const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore;
if ((followersOnlyBefore != null)
@@ -115,7 +114,11 @@ export class NoteEntityService implements OnModuleInit {
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 を使うようにしても良さそう(型違うけど)
@@ -458,6 +461,8 @@ export class NoteEntityService implements OnModuleInit {
} : {}),
});
+ this.treatVisibility(packed);
+
if (!opts.skipHide) {
await this.hideNote(packed, meId);
}