merge upstream 2025-02-03
This commit is contained in:
commit
a4e86758c1
264 changed files with 15775 additions and 4919 deletions
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
0
packages/backend/src/core/AiService.ts
Normal file
0
packages/backend/src/core/AiService.ts
Normal file
|
|
@ -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;
|
||||
|
|
@ -15,9 +74,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
|
||||
|
|
@ -45,32 +109,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +171,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);
|
||||
|
|
@ -121,46 +185,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,19 +4,18 @@
|
|||
*/
|
||||
|
||||
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 { DriveFilesRepository, EmojisRepository, MiRole, MiUser } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { query } from '@/misc/prelude/url.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 { DriveFilesRepository, 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';
|
||||
import type { Config } from '@/config.js';
|
||||
|
|
@ -24,6 +23,42 @@ import { DriveService } from './DriveService.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>;
|
||||
|
|
@ -32,16 +67,12 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
|
|
@ -67,7 +98,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[];
|
||||
|
|
@ -84,9 +117,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,
|
||||
|
|
@ -114,8 +147,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;
|
||||
|
|
@ -140,6 +175,17 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
if (isDuplicate) return 'SAME_NAME_EMOJI_EXISTS';
|
||||
}
|
||||
|
||||
// If we're changing the file, then we need to delete the old one
|
||||
if (data.originalUrl != null && data.originalUrl !== emoji.originalUrl) {
|
||||
const oldFile = await this.driveFilesRepository.findOneBy({ url: emoji.originalUrl, userHost: emoji.host ? emoji.host : IsNull() });
|
||||
const newFile = await this.driveFilesRepository.findOneBy({ url: data.originalUrl, userHost: emoji.host ? emoji.host : IsNull() });
|
||||
|
||||
// But DON'T delete if this is the same file reference, otherwise we'll break the emoji!
|
||||
if (oldFile && newFile && oldFile.id !== newFile.id) {
|
||||
await this.driveService.deleteFile(oldFile, false, moderator ? moderator : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await this.emojisRepository.update(emoji.id, {
|
||||
updatedAt: new Date(),
|
||||
name: data.name,
|
||||
|
|
@ -148,21 +194,14 @@ 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,
|
||||
});
|
||||
|
||||
this.localEmojisCache.refresh();
|
||||
|
||||
if (data.driveFile != null) {
|
||||
const file = await this.driveFilesRepository.findOneBy({ url: emoji.originalUrl, userHost: emoji.host ? emoji.host : IsNull() });
|
||||
if (file && file.id !== data.driveFile.id) {
|
||||
await this.driveService.deleteFile(file, false, moderator ? moderator : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const packed = await this.emojiEntityService.packDetailed(emoji.id);
|
||||
|
||||
if (!doNameUpdate) {
|
||||
|
|
@ -336,7 +375,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 // 自ホスト指定
|
||||
|
|
@ -444,6 +483,151 @@ 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.emojisCache.dispose();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import * as blurhash from 'blurhash';
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -179,6 +179,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') {
|
||||
|
|
|
|||
|
|
@ -678,14 +678,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);
|
||||
|
||||
|
|
@ -717,13 +710,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
if (!isThreadMuted && !muted) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -757,20 +744,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);
|
||||
|
|
@ -905,13 +886,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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,27 @@ 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;
|
||||
filetype?: string | null;
|
||||
order?: string | null;
|
||||
disableMeili?: boolean | 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 +80,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 +96,7 @@ export class SearchService {
|
|||
private cacheService: CacheService,
|
||||
private queryService: QueryService,
|
||||
private idService: IdService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
if (meilisearch) {
|
||||
this.meilisearchNoteIndex = meilisearch.index(`${this.config.meilisearch?.index}---notes`);
|
||||
|
|
@ -110,189 +128,202 @@ export class SearchService {
|
|||
if (this.config.meilisearch?.scope) {
|
||||
this.meilisearchIndexScope = this.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,
|
||||
attachedFileTypes: note.attachedFileTypes,
|
||||
}], {
|
||||
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,
|
||||
attachedFileTypes: note.attachedFileTypes,
|
||||
}], {
|
||||
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);
|
||||
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;
|
||||
filetype?: string | null;
|
||||
order?: string | null;
|
||||
disableMeili?: boolean | null;
|
||||
}, pagination: {
|
||||
untilId?: MiNote['id'];
|
||||
sinceId?: MiNote['id'];
|
||||
limit?: number;
|
||||
}): Promise<MiNote[]> {
|
||||
if (this.meilisearch && !opts.disableMeili) {
|
||||
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 });
|
||||
}
|
||||
if (opts.filetype) {
|
||||
if (opts.filetype === 'image') {
|
||||
filter.qs.push({ op: 'or', qs: [
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'image/webp' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'image/png' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'image/jpeg' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'image/avif' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'image/apng' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'image/gif' },
|
||||
] });
|
||||
} else if (opts.filetype === 'video') {
|
||||
filter.qs.push({ op: 'or', qs: [
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'video/mp4' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'video/webm' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'video/mpeg' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'video/x-m4v' },
|
||||
] });
|
||||
} else if (opts.filetype === 'audio') {
|
||||
filter.qs.push({ op: 'or', qs: [
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/mpeg' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/flac' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/wav' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/aac' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/webm' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/opus' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/ogg' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/x-m4a' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/mod' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/s3m' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/xm' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/it' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/x-mod' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/x-s3m' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/x-xm' },
|
||||
{ op: '=', k: 'attachedFileTypes', v: 'audio/x-it' },
|
||||
] });
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.filetype) {
|
||||
/* this is very ugly, but the "correct" solution would
|
||||
be `and exists (select 1 from
|
||||
unnest(note."attachedFileTypes") x(t) where t like
|
||||
:type)` and I can't find a way to get TypeORM to
|
||||
generate that; this hack works because `~*` is
|
||||
"regexp match, ignoring case" and the stringified
|
||||
version of an array of varchars (which is what
|
||||
`attachedFileTypes` is) looks like `{foo,bar}`, so
|
||||
we're looking for opts.filetype as the first half of
|
||||
a MIME type, either at start of the array (after the
|
||||
`{`) or later (after a `,`) */
|
||||
query.andWhere('note."attachedFileTypes"::varchar ~* :type', { type: `[{,]${opts.filetype}/` });
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@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:${opts.order ? opts.order : '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([
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.filetype) {
|
||||
/* this is very ugly, but the "correct" solution would
|
||||
be `and exists (select 1 from
|
||||
unnest(note."attachedFileTypes") x(t) where t like
|
||||
:type)` and I can't find a way to get TypeORM to
|
||||
generate that; this hack works because `~*` is
|
||||
"regexp match, ignoring case" and the stringified
|
||||
version of an array of varchars (which is what
|
||||
`attachedFileTypes` is) looks like `{foo,bar}`, so
|
||||
we're looking for opts.filetype as the first half of
|
||||
a MIME type, either at start of the array (after the
|
||||
`{`) or later (after a `,`) */
|
||||
query.andWhere(`note."attachedFileTypes"::varchar ~* :type`, { type: `[{,]${opts.filetype}/` });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' | 'edited' ? {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { URL } from 'node:url';
|
||||
import punycode from 'punycode/punycode.js';
|
||||
import { URL, domainToASCII } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
import psl from 'psl';
|
||||
|
|
@ -107,13 +106,13 @@ export class UtilityService {
|
|||
|
||||
@bindThis
|
||||
public toPuny(host: string): string {
|
||||
return punycode.toASCII(host.toLowerCase());
|
||||
return domainToASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public toPunyNullable(host: string | null | undefined): string | null {
|
||||
if (host == null) return null;
|
||||
return punycode.toASCII(host.toLowerCase());
|
||||
return domainToASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -192,6 +192,9 @@ export class ApRendererService {
|
|||
// || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||
url: emoji.publicUrl || emoji.originalUrl,
|
||||
},
|
||||
_misskey_license: {
|
||||
freeText: emoji.license
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
|
||||
|
|
@ -17,6 +16,7 @@ import { bindThis } from '@/decorators.js';
|
|||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { fromTuple } from '@/misc/from-tuple.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isCollectionOrOrderedCollection } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
|
|
@ -68,7 +68,7 @@ export class Resolver {
|
|||
if (isCollectionOrOrderedCollection(collection)) {
|
||||
return collection;
|
||||
} else {
|
||||
throw new UnrecoverableError(`unrecognized collection type: ${collection.type}`);
|
||||
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `unrecognized collection type: ${collection.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,15 +85,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 UnrecoverableError(`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 URL: ${value}`);
|
||||
throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', `cannot resolve already resolved URL: ${value}`);
|
||||
}
|
||||
|
||||
if (this.history.size > this.recursionLimit) {
|
||||
throw new Error(`hit recursion limit: ${value}`);
|
||||
throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `hit recursion limit: ${value}`);
|
||||
}
|
||||
|
||||
this.history.add(value);
|
||||
|
|
@ -104,7 +104,7 @@ export class Resolver {
|
|||
}
|
||||
|
||||
if (!this.utilityService.isFederationAllowedHost(host)) {
|
||||
throw new UnrecoverableError(`cannot fetch AP object ${value}: blocked instance ${host}`);
|
||||
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', `cannot fetch AP object ${value}: blocked instance ${host}`);
|
||||
}
|
||||
|
||||
if (this.config.signToActivityPubGet && !this.user) {
|
||||
|
|
@ -120,13 +120,13 @@ export class Resolver {
|
|||
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
|
||||
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
|
||||
) {
|
||||
throw new UnrecoverableError(`invalid AP object ${value}: does not have ActivityStreams context`);
|
||||
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `invalid AP object ${value}: does not have ActivityStreams context`);
|
||||
}
|
||||
|
||||
// Since redirects are allowed, we cannot safely validate an anonymous object.
|
||||
// Reject any responses without an ID, as all other checks depend on that value.
|
||||
if (object.id == null) {
|
||||
throw new UnrecoverableError(`invalid AP object ${value}: missing id`);
|
||||
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing id`);
|
||||
}
|
||||
|
||||
// We allow some limited cross-domain redirects, which means the host may have changed during fetch.
|
||||
|
|
@ -135,12 +135,12 @@ export class Resolver {
|
|||
if (finalHost !== host) {
|
||||
// Make sure the redirect stayed within the same authority.
|
||||
if (this.utilityService.punyHostPSLDomain(object.id) !== this.utilityService.punyHostPSLDomain(value)) {
|
||||
throw new UnrecoverableError(`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`);
|
||||
}
|
||||
|
||||
// Check if the redirect bounce from [allowed domain] to [blocked domain].
|
||||
if (!this.utilityService.isFederationAllowedHost(finalHost)) {
|
||||
throw new UnrecoverableError(`cannot fetch AP object ${value}: redirected to blocked instance ${finalHost}`);
|
||||
throw new IdentifiableError('0a72bf24-2d9b-4f1d-886b-15aaa31adeda', `cannot fetch AP object ${value}: redirected to blocked instance ${finalHost}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ export class Resolver {
|
|||
@bindThis
|
||||
private resolveLocal(url: string): Promise<IObject> {
|
||||
const parsed = this.apDbResolverService.parseUri(url);
|
||||
if (!parsed.local) throw new UnrecoverableError(`resolveLocal - not a local URL: ${url}`);
|
||||
if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `resolveLocal - not a local URL: ${url}`);
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'notes':
|
||||
|
|
@ -179,7 +179,7 @@ export class Resolver {
|
|||
case 'follows':
|
||||
return this.followRequestsRepository.findOneBy({ id: parsed.id })
|
||||
.then(async followRequest => {
|
||||
if (followRequest == null) throw new UnrecoverableError(`resolveLocal - invalid follow request ID ${parsed.id}: ${url}`);
|
||||
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `resolveLocal - invalid follow request ID ${parsed.id}: ${url}`);
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneBy({
|
||||
id: followRequest.followerId,
|
||||
|
|
@ -191,12 +191,12 @@ export class Resolver {
|
|||
}),
|
||||
]);
|
||||
if (follower == null || followee == null) {
|
||||
throw new Error(`resolveLocal - follower or followee does not exist: ${url}`);
|
||||
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `resolveLocal - follower or followee does not exist: ${url}`);
|
||||
}
|
||||
return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url));
|
||||
});
|
||||
default:
|
||||
throw new UnrecoverableError(`resolveLocal: type ${parsed.type} unhandled: ${url}`);
|
||||
throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `resolveLocal: type ${parsed.type} unhandled: ${url}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -561,6 +561,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',
|
||||
// Firefish
|
||||
firefish: 'https://joinfirefish.org/ns#',
|
||||
|
|
|
|||
|
|
@ -690,6 +690,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 });
|
||||
|
|
@ -711,6 +713,8 @@ export class ApNoteService {
|
|||
publicUrl: tag.icon.url,
|
||||
updatedAt: new Date(),
|
||||
aliases: [],
|
||||
// _misskey_license が存在しなければ `null`
|
||||
license: (tag._misskey_license?.freeText ?? null)
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,6 +270,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 =>
|
||||
|
|
|
|||
|
|
@ -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 })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,6 +145,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;
|
||||
|
|
|
|||
|
|
@ -110,8 +110,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)
|
||||
|
|
@ -123,7 +122,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 を使うようにしても良さそう(型違うけど)
|
||||
|
|
@ -500,6 +503,8 @@ export class NoteEntityService implements OnModuleInit {
|
|||
} : {}),
|
||||
});
|
||||
|
||||
this.treatVisibility(packed);
|
||||
|
||||
if (!opts.skipHide) {
|
||||
await this.hideNote(packed, meId);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue