mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-25 17:10:43 +00:00
Merge 71a45f5324 into 9785962539
This commit is contained in:
commit
6cc20e0ce8
26 changed files with 1094 additions and 1102 deletions
3
.github/dependabot.yml
vendored
3
.github/dependabot.yml
vendored
|
|
@ -37,6 +37,3 @@ updates:
|
|||
typescript-eslint:
|
||||
patterns:
|
||||
- "@typescript-eslint/*"
|
||||
tensorflow:
|
||||
patterns:
|
||||
- "@tensorflow/*"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
## Unreleased
|
||||
|
||||
### Note
|
||||
- センシティブメディアの判定 (NSFW検出) が、本体に内蔵された nsfwjs による推論から、外部サービス [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) への HTTP 呼び出し方式に変更されました。
|
||||
- これに伴い、本体から `nsfwjs` / `@tensorflow/tfjs` / `@tensorflow/tfjs-node` および同梱の NSFW 判定モデルが削除され、インストール要件 (ネイティブ ML スタック) が緩和されました。
|
||||
- **センシティブ判定機能を利用しているサーバーは対応が必要です。** 別途 [sensitive-detector](https://github.com/misskey-dev/sensitive-detector) サービスを立ち上げ、コントロールパネルの「モデレーション > センシティブなメディアの検出」で接続先 URL を設定してください。接続先が未設定の場合、センシティブ判定は行われません (すべて非センシティブ扱い)。
|
||||
- 画像の正規化・動画フレームの抽出・しきい値判定・集約は引き続き本体側で行われ、外部サービスには正規化済み画像の推論のみを委譲します。
|
||||
|
||||
### General
|
||||
-
|
||||
|
||||
|
|
@ -7,7 +13,7 @@
|
|||
-
|
||||
|
||||
### Server
|
||||
-
|
||||
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
|
||||
|
||||
|
||||
## 2026.6.0
|
||||
|
|
|
|||
|
|
@ -2177,6 +2177,15 @@ _sensitiveMediaDetection:
|
|||
setSensitiveFlagAutomaticallyDescription: "この設定をオフにしても内部的に判定結果は保持されます。"
|
||||
analyzeVideos: "動画の解析を有効化"
|
||||
analyzeVideosDescription: "静止画に加えて動画も解析するようにします。サーバーの負荷が少し増えます。"
|
||||
externalServiceInfo: "センシティブメディアの判定は外部サービス (sensitive-detector) に分離されました。この機能を利用するには、別途サイドカーサービスをセットアップし、下記の接続先を設定する必要があります。接続先が未設定の場合、判定は行われません (非センシティブ扱い)。"
|
||||
apiUrl: "判定サービスの接続先URL"
|
||||
apiUrlDescription: "sensitive-detector サービスのベースURL (例: http://localhost:3009)。プライベートネットワーク上のサービスに接続する場合は、設定ファイルの allowedPrivateNetworks で接続先ネットワークを許可してください。プロキシを使用している場合は、proxyBypassHosts も設定してください。空欄の場合、センシティブ判定は行われません。"
|
||||
apiKey: "APIキー"
|
||||
apiKeyDescription: "判定サービス側で認証 (Bearerトークン) を設定している場合に入力します。設定していない場合は空欄のままにしてください。"
|
||||
timeout: "タイムアウト (ミリ秒)"
|
||||
timeoutDescription: "判定リクエスト1回あたりのタイムアウト時間です。"
|
||||
maxImagesPerRequest: "1リクエストあたりの最大画像数"
|
||||
maxImagesPerRequestDescription: "動画など複数フレームを判定する際、1回のリクエストにまとめて送る画像の最大枚数です。これを超える分は分割して順次送信されます。sensitive-detector 側の maxParts 設定(デフォルト: 10)を超えないように設定してください。超えた場合、そのチャンクは全件非センシティブ扱いとなります。"
|
||||
|
||||
_emailUnavailable:
|
||||
used: "既に使用されています"
|
||||
|
|
|
|||
|
|
@ -78,8 +78,5 @@
|
|||
"pnpm": "11.5.2",
|
||||
"start-server-and-test": "3.0.9",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs-core": "4.22.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class SensitiveMediaDetectionExternalService1780488454126 {
|
||||
name = 'SensitiveMediaDetectionExternalService1780488454126'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionApiUrl" character varying(1024)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionApiKey" character varying(1024)`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionTimeout" integer NOT NULL DEFAULT '60000'`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionMaxImagesPerRequest" integer NOT NULL DEFAULT '4'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionMaxImagesPerRequest"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionTimeout"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionApiKey"`);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionApiUrl"`);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -34,8 +34,6 @@
|
|||
"generate-api-json": "pnpm compile-config && node ./scripts/generate_api_json.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"@tensorflow/tfjs-node": "4.22.0",
|
||||
"bufferutil": "4.1.0",
|
||||
"slacc-android-arm-eabi": "0.1.5",
|
||||
"slacc-android-arm64": "0.1.5",
|
||||
|
|
@ -79,7 +77,6 @@
|
|||
"accepts": "1.3.8",
|
||||
"ajv": "8.20.0",
|
||||
"archiver": "8.0.0",
|
||||
"async-mutex": "0.5.0",
|
||||
"bcryptjs": "3.0.3",
|
||||
"blurhash": "2.0.5",
|
||||
"bullmq": "5.78.0",
|
||||
|
|
@ -96,7 +93,6 @@
|
|||
"feed": "5.2.1",
|
||||
"file-type": "22.0.1",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"form-data": "4.0.6",
|
||||
"got": "15.0.5",
|
||||
"hpagent": "1.2.0",
|
||||
"http-link-header": "1.1.3",
|
||||
|
|
@ -119,7 +115,6 @@
|
|||
"node-fetch": "3.3.2",
|
||||
"node-html-parser": "7.1.0",
|
||||
"nodemailer": "8.0.10",
|
||||
"nsfwjs": "4.3.0",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.5.1",
|
||||
"pg": "8.21.0",
|
||||
|
|
|
|||
|
|
@ -67,14 +67,6 @@ export default defineConfig((args) => {
|
|||
'@nestjs/microservices/microservices-module',
|
||||
'@nestjs/microservices',
|
||||
/^@napi-rs\/.*/,
|
||||
// @tensorflow/tfjs-node はネイティブバインディングを持つため external 必須 (#17501)。
|
||||
// あわせて nsfwjs と @tensorflow/* 全体を external にする。bundle 内の nsfwjs が
|
||||
// 抱える @tensorflow/tfjs-core と、external な tfjs-node が使う tfjs-core が
|
||||
// 別インスタンスに分裂すると、tfjs-node が登録する file:// IOHandler を nsfwjs 側が
|
||||
// 共有できず、モデル読み込みが HTTP handler(node-fetch) にフォールバックして
|
||||
// 「URL scheme "file" is not supported」で失敗するため。
|
||||
/^@tensorflow\/.*/,
|
||||
'nsfwjs',
|
||||
'mock-aws-s3',
|
||||
'aws-sdk',
|
||||
'nock',
|
||||
|
|
|
|||
|
|
@ -3,92 +3,185 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { resolve } from 'node:path';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import fetch from 'node-fetch';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { NSFWJS, PredictionType } from 'nsfwjs/core';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type { MiMeta } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
||||
const REQUIRED_CPU_FLAGS_X64 = ['avx2', 'fma'];
|
||||
let isSupportedCpu: undefined | boolean = undefined;
|
||||
/**
|
||||
* 正規化済み画像に対する nsfwjs 互換の予測値。
|
||||
* 推論自体は外部サービス (sensitive-detector) が行い、本体はその生の値を受け取って判定する。
|
||||
*/
|
||||
export type Prediction = {
|
||||
className: string;
|
||||
probability: number;
|
||||
};
|
||||
|
||||
type BatchItemResult =
|
||||
| { success: true; predictions: Prediction[] }
|
||||
| { success: false; error: { code: string; message: string } };
|
||||
|
||||
type DetectImagesResponse =
|
||||
| { success: true; result: { results: BatchItemResult[] } }
|
||||
| { success: false; error: { code: string; message: string } };
|
||||
|
||||
// #region type guards
|
||||
function isPrediction(v: unknown): v is Prediction {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const obj = v as Record<string, unknown>;
|
||||
return typeof obj['className'] === 'string' && typeof obj['probability'] === 'number';
|
||||
}
|
||||
|
||||
function isBatchItemResult(v: unknown): v is BatchItemResult {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const obj = v as Record<string, unknown>;
|
||||
if (obj['success'] === true) {
|
||||
return Array.isArray(obj['predictions']) && (obj['predictions'] as unknown[]).every(isPrediction);
|
||||
}
|
||||
if (obj['success'] === false) {
|
||||
const error = obj['error'];
|
||||
return typeof error === 'object' && error !== null && typeof (error as Record<string, unknown>)['code'] === 'string';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDetectImagesResponse(v: unknown): v is DetectImagesResponse {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const obj = v as Record<string, unknown>;
|
||||
if (obj['success'] === true) {
|
||||
const result = obj['result'];
|
||||
if (typeof result !== 'object' || result === null) return false;
|
||||
const results = (result as Record<string, unknown>)['results'];
|
||||
return Array.isArray(results) && (results as unknown[]).every(isBatchItemResult);
|
||||
}
|
||||
if (obj['success'] === false) {
|
||||
const error = obj['error'];
|
||||
return typeof error === 'object' && error !== null && typeof (error as Record<string, unknown>)['code'] === 'string';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// サイドカーの判定エンドポイント。baseUrl にパスプレフィックスがあっても連結できるよう先頭スラッシュは付けない。
|
||||
const DETECT_IMAGES_PATH = 'v1/detect-images';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly modelDir: string;
|
||||
private model: NSFWJS;
|
||||
private modelLoadMutex: Mutex = new Mutex();
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
const md = resolve(this.config.rootDir, 'packages/backend/nsfw-model');
|
||||
this.modelDir = md.endsWith('/') ? md : md + '/';
|
||||
this.logger = this.loggerService.getLogger('ai');
|
||||
}
|
||||
|
||||
/**
|
||||
* 正規化済み画像 1 枚を外部サービスに送り、生の予測値を得る。
|
||||
* 接続先未設定・通信失敗・タイムアウト時は null(= 非センシティブ扱い)を返す。
|
||||
*/
|
||||
@bindThis
|
||||
public async detectSensitive(source: string | Buffer): Promise<PredictionType[] | null> {
|
||||
public async detectSensitive(source: Buffer): Promise<Prediction[] | null> {
|
||||
return (await this.detectSensitiveMany([source]))[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 複数の正規化済み画像をまとめて外部サービスに送る。
|
||||
* maxImagesPerRequest 枚ごとにチャンク分割して順次送信し、送信順を保った結果配列を返す。
|
||||
* 接続先未設定・通信失敗・タイムアウト時は該当分を null(= 非センシティブ扱い)にしてフォールバックする
|
||||
* (API 呼び出し失敗時はセンシティブではない判定とする方針: misskey-dev/misskey#16804)。
|
||||
*/
|
||||
@bindThis
|
||||
public async detectSensitiveMany(sources: Buffer[]): Promise<(Prediction[] | null)[]> {
|
||||
if (sources.length === 0) return [];
|
||||
|
||||
const baseUrl = this.meta.sensitiveMediaDetectionApiUrl;
|
||||
if (baseUrl == null || baseUrl.trim() === '') {
|
||||
// 接続先が未設定なら検出不能。全件 null(非センシティブ扱い)を返す。
|
||||
return sources.map(() => null);
|
||||
}
|
||||
|
||||
const apiKey = this.meta.sensitiveMediaDetectionApiKey;
|
||||
const timeout = this.meta.sensitiveMediaDetectionTimeout;
|
||||
const chunkSize = Math.max(1, this.meta.sensitiveMediaDetectionMaxImagesPerRequest);
|
||||
|
||||
const base = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
||||
let url: string;
|
||||
try {
|
||||
if (isSupportedCpu === undefined) {
|
||||
isSupportedCpu = await this.computeIsSupportedCpu();
|
||||
}
|
||||
|
||||
if (!isSupportedCpu) {
|
||||
console.error('This CPU cannot use TensorFlow.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tf = await import('@tensorflow/tfjs-node');
|
||||
tf.env().global.fetch = fetch;
|
||||
|
||||
if (this.model == null) {
|
||||
const nsfw = await import('nsfwjs/core');
|
||||
await this.modelLoadMutex.runExclusive(async () => {
|
||||
if (this.model == null) {
|
||||
this.model = await nsfw.load(pathToFileURL(this.modelDir).toString(), { size: 299 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = source instanceof Buffer ? source : await fs.promises.readFile(source);
|
||||
const image = await tf.node.decodeImage(buffer, 3) as any;
|
||||
try {
|
||||
const predictions = await this.model.classify(image);
|
||||
return predictions;
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
url = new URL(DETECT_IMAGES_PATH, base).href;
|
||||
} catch {
|
||||
this.logger.warn(`invalid sensitiveMediaDetectionApiUrl: ${baseUrl}`);
|
||||
return sources.map(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const results: (Prediction[] | null)[] = [];
|
||||
for (let i = 0; i < sources.length; i += chunkSize) {
|
||||
const chunk = sources.slice(i, i + chunkSize);
|
||||
results.push(...await this.detectChunk(url, apiKey, timeout, chunk));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getCpuFlags(): Promise<string[]> {
|
||||
const si = await import('systeminformation');
|
||||
const str = await si.cpuFlags();
|
||||
return str.split(/\s+/);
|
||||
private async detectChunk(url: string, apiKey: string | null, timeout: number, chunk: Buffer[]): Promise<(Prediction[] | null)[]> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
form.append(`image${i}`, new Blob([chunk[i]], { type: 'image/png' }), `${i}.png`);
|
||||
}
|
||||
|
||||
// Content-Type は FormData から boundary 付きで自動設定させるため、手動設定はしない。
|
||||
// 手動指定すると boundary の欠落・不一致で multipart として読めなくなる。
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey != null && apiKey !== '') {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
// 外部サービスとして通常の proxy / private address 制限を適用する。
|
||||
// サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。
|
||||
agent: (u) => this.httpRequestService.getAgentByUrl(u),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`sensitive detection request failed: ${res.status} ${res.statusText}`);
|
||||
return chunk.map(() => null);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
if (!isDetectImagesResponse(body)) {
|
||||
this.logger.warn(`sensitive detection responded with unexpected shape: ${JSON.stringify(body)}`);
|
||||
return chunk.map(() => null);
|
||||
}
|
||||
if (!body.success) {
|
||||
this.logger.warn(`sensitive detection responded with failure: ${body.error.code}`);
|
||||
return chunk.map(() => null);
|
||||
}
|
||||
|
||||
const items = body.result.results;
|
||||
return chunk.map((_, i) => {
|
||||
const item = items[i];
|
||||
return (item.success) ? item.predictions : null;
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return chunk.map(() => null);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { LoggerService } from '@/core/LoggerService.js';
|
|||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import type { PredictionType } from 'nsfwjs';
|
||||
import type { Prediction } from '@/core/AiService.js';
|
||||
|
||||
export type FileInfo = {
|
||||
size: number;
|
||||
|
|
@ -192,7 +192,7 @@ export class FileInfoService {
|
|||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
function judgePrediction(result: readonly PredictionType[]): [sensitive: boolean, porn: boolean] {
|
||||
function judgePrediction(result: readonly Prediction[]): [sensitive: boolean, porn: boolean] {
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
|
|
@ -248,7 +248,8 @@ export class FileInfoService {
|
|||
.format('image2')
|
||||
.output(join(outDir, '%d.png'))
|
||||
.outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない
|
||||
const results: ReturnType<typeof judgePrediction>[] = [];
|
||||
// 判定対象フレームを選定して正規化済みバッファとして集め、外部サービスへまとめて送る。
|
||||
const frameBuffers: Buffer[] = [];
|
||||
let frameIndex = 0;
|
||||
let targetIndex = 0;
|
||||
let nextIndex = 1;
|
||||
|
|
@ -260,22 +261,26 @@ export class FileInfoService {
|
|||
}
|
||||
targetIndex = nextIndex;
|
||||
nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける
|
||||
const result = await this.aiService.detectSensitive(path);
|
||||
if (result) {
|
||||
results.push(judgePrediction(result));
|
||||
}
|
||||
frameBuffers.push(await fs.promises.readFile(path));
|
||||
} finally {
|
||||
fs.promises.unlink(path);
|
||||
}
|
||||
}
|
||||
sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold);
|
||||
porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn);
|
||||
const predictions = await this.aiService.detectSensitiveMany(frameBuffers);
|
||||
const results = predictions.filter((x): x is Prediction[] => x != null).map(x => judgePrediction(x));
|
||||
// 判定に成功したフレームが 0 件のとき(接続先未設定・通信失敗等)は、
|
||||
// Math.ceil(0) との比較が 0 >= 0 で真になり全動画がセンシティブ扱いになってしまうため、
|
||||
// 1 件以上判定できたときのみ集約する(失敗時は非センシティブ扱い: misskey-dev/misskey#16804)。
|
||||
if (results.length > 0) {
|
||||
sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold);
|
||||
porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn);
|
||||
}
|
||||
} finally {
|
||||
disposeOutDir();
|
||||
}
|
||||
} else if (isMimeImage(mime, 'sharp-convertible-image-with-bmp')) {
|
||||
/*
|
||||
* tfjs-node は限られた画像形式しか受け付けないため、sharp で PNG に変換する
|
||||
* 判定サービス側のデコーダは限られた画像形式しか受け付けないため、sharp で PNG に変換する
|
||||
* せっかくなので内部処理で使われる最大サイズの299x299に事前にリサイズする
|
||||
*/
|
||||
const png = await (await sharpBmp(source, mime))
|
||||
|
|
|
|||
|
|
@ -292,6 +292,26 @@ export class MiMeta {
|
|||
})
|
||||
public enableSensitiveMediaDetectionForVideos: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, nullable: true,
|
||||
})
|
||||
public sensitiveMediaDetectionApiUrl: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, nullable: true,
|
||||
})
|
||||
public sensitiveMediaDetectionApiKey: string | null;
|
||||
|
||||
@Column('integer', {
|
||||
default: 60000,
|
||||
})
|
||||
public sensitiveMediaDetectionTimeout: number;
|
||||
|
||||
@Column('integer', {
|
||||
default: 4,
|
||||
})
|
||||
public sensitiveMediaDetectionMaxImagesPerRequest: number;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -238,6 +238,22 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
sensitiveMediaDetectionApiUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
sensitiveMediaDetectionApiKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
sensitiveMediaDetectionTimeout: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
sensitiveMediaDetectionMaxImagesPerRequest: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
proxyAccountId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
|
@ -686,6 +702,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity,
|
||||
setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically,
|
||||
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
|
||||
sensitiveMediaDetectionApiUrl: instance.sensitiveMediaDetectionApiUrl,
|
||||
sensitiveMediaDetectionApiKey: instance.sensitiveMediaDetectionApiKey,
|
||||
sensitiveMediaDetectionTimeout: instance.sensitiveMediaDetectionTimeout,
|
||||
sensitiveMediaDetectionMaxImagesPerRequest: instance.sensitiveMediaDetectionMaxImagesPerRequest,
|
||||
proxyAccountId: proxy.id,
|
||||
email: instance.email,
|
||||
smtpSecure: instance.smtpSecure,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ export const paramDef = {
|
|||
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
|
||||
setSensitiveFlagAutomatically: { type: 'boolean' },
|
||||
enableSensitiveMediaDetectionForVideos: { type: 'boolean' },
|
||||
sensitiveMediaDetectionApiUrl: { type: 'string', nullable: true },
|
||||
sensitiveMediaDetectionApiKey: { type: 'string', nullable: true },
|
||||
sensitiveMediaDetectionTimeout: { type: 'integer', minimum: 1 },
|
||||
sensitiveMediaDetectionMaxImagesPerRequest: { type: 'integer', minimum: 1 },
|
||||
maintainerName: { type: 'string', nullable: true },
|
||||
maintainerEmail: { type: 'string', nullable: true },
|
||||
langs: {
|
||||
|
|
@ -436,6 +440,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos;
|
||||
}
|
||||
|
||||
if (ps.sensitiveMediaDetectionApiUrl !== undefined) {
|
||||
set.sensitiveMediaDetectionApiUrl = ps.sensitiveMediaDetectionApiUrl === '' ? null : ps.sensitiveMediaDetectionApiUrl;
|
||||
}
|
||||
|
||||
if (ps.sensitiveMediaDetectionApiKey !== undefined) {
|
||||
set.sensitiveMediaDetectionApiKey = ps.sensitiveMediaDetectionApiKey === '' ? null : ps.sensitiveMediaDetectionApiKey;
|
||||
}
|
||||
|
||||
if (ps.sensitiveMediaDetectionTimeout !== undefined) {
|
||||
set.sensitiveMediaDetectionTimeout = ps.sensitiveMediaDetectionTimeout;
|
||||
}
|
||||
|
||||
if (ps.sensitiveMediaDetectionMaxImagesPerRequest !== undefined) {
|
||||
set.sensitiveMediaDetectionMaxImagesPerRequest = ps.sensitiveMediaDetectionMaxImagesPerRequest;
|
||||
}
|
||||
|
||||
if (ps.maintainerName !== undefined) {
|
||||
set.maintainerName = ps.maintainerName;
|
||||
}
|
||||
|
|
|
|||
151
packages/backend/test/unit/AiService.ts
Normal file
151
packages/backend/test/unit/AiService.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import type { AiService as AiServiceType, Prediction } from '@/core/AiService.js';
|
||||
import type { MiMeta } from '@/models/_.js';
|
||||
import type { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import type { LoggerService } from '@/core/LoggerService.js';
|
||||
|
||||
// AiService が直接 import している node-fetch をモックして、外部サービスへの送信内容と
|
||||
// レスポンス解釈を検証する。
|
||||
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
|
||||
vi.mock('node-fetch', () => ({ default: fetchMock }));
|
||||
|
||||
const { AiService } = await import('@/core/AiService.js');
|
||||
|
||||
let getAgentByUrlMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
const DEFAULT_META = {
|
||||
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
|
||||
sensitiveMediaDetectionApiKey: null as string | null,
|
||||
sensitiveMediaDetectionTimeout: 5000,
|
||||
sensitiveMediaDetectionMaxImagesPerRequest: 4,
|
||||
};
|
||||
|
||||
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiServiceType {
|
||||
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
|
||||
getAgentByUrlMock = vi.fn(() => undefined);
|
||||
const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService;
|
||||
const loggerService = {
|
||||
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
|
||||
} as unknown as LoggerService;
|
||||
return new AiService(meta, httpRequestService, loggerService);
|
||||
}
|
||||
|
||||
function neutral(): Prediction[] {
|
||||
return [{ className: 'Neutral', probability: 0.99 }];
|
||||
}
|
||||
|
||||
function okResponse(results: unknown[]) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ success: true, result: { results } }),
|
||||
};
|
||||
}
|
||||
|
||||
const buf = (s: string) => Buffer.from(s);
|
||||
|
||||
describe('AiService', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
test('正常: 送信順を保った予測値配列を返す', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
|
||||
]));
|
||||
const svc = makeService();
|
||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
||||
expect(res).toEqual([
|
||||
[{ className: 'Neutral', probability: 0.99 }],
|
||||
[{ className: 'Porn', probability: 0.8 }],
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
|
||||
});
|
||||
|
||||
test('外部サービス: 通常の outbound agent を使用する', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
const svc = makeService({ sensitiveMediaDetectionApiUrl: 'https://detector.example.com' });
|
||||
|
||||
await svc.detectSensitiveMany([buf('a')]);
|
||||
|
||||
const requestOptions = fetchMock.mock.calls[0][1] as { agent: (url: URL) => unknown };
|
||||
requestOptions.agent(new URL('https://detector.example.com/v1/detect-images'));
|
||||
expect(getAgentByUrlMock).toHaveBeenCalledWith(expect.any(URL));
|
||||
});
|
||||
|
||||
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
const svc = makeService();
|
||||
const res = await svc.detectSensitive(buf('a'));
|
||||
expect(res).toEqual(neutral());
|
||||
});
|
||||
|
||||
test('部分失敗: 失敗パーツのみ null になる', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
|
||||
]));
|
||||
const svc = makeService();
|
||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
||||
expect(res[0]).toEqual(neutral());
|
||||
expect(res[1]).toBeNull();
|
||||
});
|
||||
|
||||
test('非200: チャンク全件 null(例外を投げない)', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) });
|
||||
const svc = makeService();
|
||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
||||
expect(res).toEqual([null, null]);
|
||||
});
|
||||
|
||||
test('通信エラー: チャンク全件 null(例外を投げない)', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('network down'));
|
||||
const svc = makeService();
|
||||
const res = await svc.detectSensitiveMany([buf('a')]);
|
||||
expect(res).toEqual([null]);
|
||||
});
|
||||
|
||||
test('接続先未設定: HTTP を叩かず全件 null', async () => {
|
||||
const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
|
||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
||||
expect(res).toEqual([null, null]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: neutral() },
|
||||
]));
|
||||
const svc = makeService({ sensitiveMediaDetectionMaxImagesPerRequest: 2 });
|
||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b'), buf('c'), buf('d'), buf('e')]);
|
||||
// 5 枚を 2 枚ずつ → 3 リクエスト、結果は順序を保って 5 件。
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(res).toHaveLength(5);
|
||||
expect(res.every(x => x != null)).toBe(true);
|
||||
});
|
||||
|
||||
test('APIキー設定時のみ Authorization: Bearer を付与する', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
|
||||
const withKey = makeService({ sensitiveMediaDetectionApiKey: 'secret' });
|
||||
await withKey.detectSensitiveMany([buf('a')]);
|
||||
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBe('Bearer secret');
|
||||
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
const withoutKey = makeService();
|
||||
await withoutKey.detectSensitiveMany([buf('a')]);
|
||||
expect((fetchMock.mock.calls[0][1] as any).headers.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -149,6 +149,7 @@ describe('FileServerService', () => {
|
|||
const loggerService = new LoggerService();
|
||||
const aiService = {
|
||||
detectSensitive: async () => null,
|
||||
detectSensitiveMany: async (sources: Buffer[]) => sources.map(() => null),
|
||||
} as unknown as AiService;
|
||||
const fileInfoService = new FileInfoService(aiService, loggerService);
|
||||
const httpRequestService = new HttpRequestService(config);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div class="_gaps_m">
|
||||
<div><SearchText>{{ i18n.ts._sensitiveMediaDetection.description }}</SearchText></div>
|
||||
|
||||
<MkInfo warn><SearchText>{{ i18n.ts._sensitiveMediaDetection.externalServiceInfo }}</SearchText></MkInfo>
|
||||
|
||||
<MkRadios
|
||||
v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetection"
|
||||
:options="[
|
||||
|
|
@ -36,6 +38,34 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
</MkRadios>
|
||||
|
||||
<SearchMarker :keywords="['api', 'url', 'endpoint', 'sensitive']">
|
||||
<MkInput v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionApiUrl" type="url">
|
||||
<template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.apiUrl }}</SearchLabel></template>
|
||||
<template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.apiUrlDescription }}</SearchText></template>
|
||||
</MkInput>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['api', 'key', 'token', 'sensitive']">
|
||||
<MkInput v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionApiKey" type="password">
|
||||
<template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.apiKey }}</SearchLabel></template>
|
||||
<template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.apiKeyDescription }}</SearchText></template>
|
||||
</MkInput>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['timeout', 'sensitive']">
|
||||
<MkInput v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionTimeout" type="number" :min="1">
|
||||
<template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.timeout }}</SearchLabel></template>
|
||||
<template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.timeoutDescription }}</SearchText></template>
|
||||
</MkInput>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['max', 'images', 'chunk', 'sensitive']">
|
||||
<MkInput v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionMaxImagesPerRequest" type="number" :min="1">
|
||||
<template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.maxImagesPerRequest }}</SearchLabel></template>
|
||||
<template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.maxImagesPerRequestDescription }}</SearchText></template>
|
||||
</MkInput>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['sensitivity']">
|
||||
<MkRange v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`">
|
||||
<template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</SearchLabel></template>
|
||||
|
|
@ -170,6 +200,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
|
|||
import MkRange from '@/components/MkRange.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { fetchInstance } from '@/instance.js';
|
||||
|
|
@ -189,6 +220,10 @@ const sensitiveMediaDetectionForm = useForm({
|
|||
meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0,
|
||||
setSensitiveFlagAutomatically: meta.setSensitiveFlagAutomatically,
|
||||
enableSensitiveMediaDetectionForVideos: meta.enableSensitiveMediaDetectionForVideos,
|
||||
sensitiveMediaDetectionApiUrl: meta.sensitiveMediaDetectionApiUrl,
|
||||
sensitiveMediaDetectionApiKey: meta.sensitiveMediaDetectionApiKey,
|
||||
sensitiveMediaDetectionTimeout: meta.sensitiveMediaDetectionTimeout,
|
||||
sensitiveMediaDetectionMaxImagesPerRequest: meta.sensitiveMediaDetectionMaxImagesPerRequest,
|
||||
}, async (state) => {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
sensitiveMediaDetection: state.sensitiveMediaDetection,
|
||||
|
|
@ -201,6 +236,10 @@ const sensitiveMediaDetectionForm = useForm({
|
|||
null as never,
|
||||
setSensitiveFlagAutomatically: state.setSensitiveFlagAutomatically,
|
||||
enableSensitiveMediaDetectionForVideos: state.enableSensitiveMediaDetectionForVideos,
|
||||
sensitiveMediaDetectionApiUrl: state.sensitiveMediaDetectionApiUrl,
|
||||
sensitiveMediaDetectionApiKey: state.sensitiveMediaDetectionApiKey,
|
||||
sensitiveMediaDetectionTimeout: state.sensitiveMediaDetectionTimeout,
|
||||
sensitiveMediaDetectionMaxImagesPerRequest: state.sensitiveMediaDetectionMaxImagesPerRequest,
|
||||
});
|
||||
fetchInstance(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8390,6 +8390,42 @@ export interface Locale extends ILocale {
|
|||
* 静止画に加えて動画も解析するようにします。サーバーの負荷が少し増えます。
|
||||
*/
|
||||
"analyzeVideosDescription": string;
|
||||
/**
|
||||
* センシティブメディアの判定は外部サービス (sensitive-detector) に分離されました。この機能を利用するには、別途サイドカーサービスをセットアップし、下記の接続先を設定する必要があります。接続先が未設定の場合、判定は行われません (非センシティブ扱い)。
|
||||
*/
|
||||
"externalServiceInfo": string;
|
||||
/**
|
||||
* 判定サービスの接続先URL
|
||||
*/
|
||||
"apiUrl": string;
|
||||
/**
|
||||
* sensitive-detector サービスのベースURL (例: http://localhost:3009)。プライベートネットワーク上のサービスに接続する場合は、設定ファイルの allowedPrivateNetworks で接続先ネットワークを許可してください。プロキシを使用している場合は、proxyBypassHosts も設定してください。空欄の場合、センシティブ判定は行われません。
|
||||
*/
|
||||
"apiUrlDescription": string;
|
||||
/**
|
||||
* APIキー
|
||||
*/
|
||||
"apiKey": string;
|
||||
/**
|
||||
* 判定サービス側で認証 (Bearerトークン) を設定している場合に入力します。設定していない場合は空欄のままにしてください。
|
||||
*/
|
||||
"apiKeyDescription": string;
|
||||
/**
|
||||
* タイムアウト (ミリ秒)
|
||||
*/
|
||||
"timeout": string;
|
||||
/**
|
||||
* 判定リクエスト1回あたりのタイムアウト時間です。
|
||||
*/
|
||||
"timeoutDescription": string;
|
||||
/**
|
||||
* 1リクエストあたりの最大画像数
|
||||
*/
|
||||
"maxImagesPerRequest": string;
|
||||
/**
|
||||
* 動画など複数フレームを判定する際、1回のリクエストにまとめて送る画像の最大枚数です。これを超える分は分割して順次送信されます。sensitive-detector 側の maxParts 設定(デフォルト: 10)を超えないように設定してください。超えた場合、そのチャンクは全件非センシティブ扱いとなります。
|
||||
*/
|
||||
"maxImagesPerRequestDescription": string;
|
||||
};
|
||||
"_emailUnavailable": {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9467,6 +9467,10 @@ export interface operations {
|
|||
sensitiveMediaDetectionSensitivity: 'medium' | 'low' | 'high' | 'veryLow' | 'veryHigh';
|
||||
setSensitiveFlagAutomatically: boolean;
|
||||
enableSensitiveMediaDetectionForVideos: boolean;
|
||||
sensitiveMediaDetectionApiUrl: string | null;
|
||||
sensitiveMediaDetectionApiKey: string | null;
|
||||
sensitiveMediaDetectionTimeout: number;
|
||||
sensitiveMediaDetectionMaxImagesPerRequest: number;
|
||||
/** Format: id */
|
||||
proxyAccountId: string;
|
||||
email: string | null;
|
||||
|
|
@ -12927,6 +12931,10 @@ export interface operations {
|
|||
sensitiveMediaDetectionSensitivity?: 'medium' | 'low' | 'high' | 'veryLow' | 'veryHigh';
|
||||
setSensitiveFlagAutomatically?: boolean;
|
||||
enableSensitiveMediaDetectionForVideos?: boolean;
|
||||
sensitiveMediaDetectionApiUrl?: string | null;
|
||||
sensitiveMediaDetectionApiKey?: string | null;
|
||||
sensitiveMediaDetectionTimeout?: number;
|
||||
sensitiveMediaDetectionMaxImagesPerRequest?: number;
|
||||
maintainerName?: string | null;
|
||||
maintainerEmail?: string | null;
|
||||
langs?: string[];
|
||||
|
|
|
|||
1592
pnpm-lock.yaml
generated
1592
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,7 +16,6 @@ allowBuilds:
|
|||
'@parcel/watcher': true
|
||||
'@sentry/profiling-node': true
|
||||
'@sentry-internal/node-cpu-profiler': true
|
||||
'@tensorflow/tfjs-node': true
|
||||
bufferutil: true
|
||||
canvas: true
|
||||
core-js: true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue