diff --git a/locales/en-US.yml b/locales/en-US.yml index c73c9aa605..28e2a6a011 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -2123,6 +2123,8 @@ _role: not: "NOT-Condition" _sensitiveMediaDetection: description: "Reduces the effort of server moderation through automatically recognizing sensitive media via Machine Learning. This will slightly increase the load on the server." + proxyUrl: "Sensitive media detection proxy URL" + proxyUrlDescription: "URL of the external sensitive media detection service. Leave empty to use the built-in detection (nsfwjs). Set to an external service URL to offload detection and reduce server load." sensitivity: "Detection sensitivity" sensitivityDescription: "Reducing the sensitivity will lead to fewer misdetections (false positives) whereas increasing it will lead to fewer missed detections (false negatives)." setSensitiveFlagAutomatically: "Mark as sensitive" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a1f9af8edc..30e39acd6f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2150,6 +2150,8 @@ _role: _sensitiveMediaDetection: description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" + proxyUrl: "センシティブメディア検出プロキシURL" + proxyUrlDescription: "外部のセンシティブメディア検出サービスのURL。空欄にすると内蔵の検出機能(nsfwjs)を使用します。外部サービスのURLを設定すると、検出処理を外部化してサーバーの負荷を軽減できます。" sensitivity: "検出感度" sensitivityDescription: "感度を低くすると、誤検知(偽陽性)が減ります。感度を高くすると、検知漏れ(偽陰性)が減ります。" setSensitiveFlagAutomatically: "センシティブフラグを設定する" diff --git a/packages/backend/migration/1731916961000-SensitiveMediaDetectionProxy.js b/packages/backend/migration/1731916961000-SensitiveMediaDetectionProxy.js new file mode 100644 index 0000000000..3f5f65d4cd --- /dev/null +++ b/packages/backend/migration/1731916961000-SensitiveMediaDetectionProxy.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SensitiveMediaDetectionProxy1731916961000 { + name = 'SensitiveMediaDetectionProxy1731916961000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionProxyUrl" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionProxyUrl"`); + } +} diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index 23ab8082ed..66bd50e0ff 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -6,12 +6,15 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import * as nsfw from 'nsfwjs'; import si from 'systeminformation'; import { Mutex } from 'async-mutex'; import fetch from 'node-fetch'; import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { MiMeta } from '@/models/Meta.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -25,11 +28,21 @@ export class AiService { private modelLoadMutex: Mutex = new Mutex(); constructor( + @Inject(DI.meta) + private meta: MiMeta, + + private httpRequestService: HttpRequestService, ) { } @bindThis public async detectSensitive(source: string | Buffer): Promise { + // If external service is configured, use it + if (this.meta.sensitiveMediaDetectionProxyUrl) { + return await this.detectSensitiveWithProxy(source); + } + + // Otherwise, use the local nsfwjs model try { if (isSupportedCpu === undefined) { isSupportedCpu = await this.computeIsSupportedCpu(); @@ -65,6 +78,29 @@ export class AiService { } } + @bindThis + private async detectSensitiveWithProxy(source: string | Buffer): Promise { + try { + const buffer = source instanceof Buffer ? source : await fs.promises.readFile(source); + const base64 = buffer.toString('base64'); + + const response = await this.httpRequestService.send(this.meta.sensitiveMediaDetectionProxyUrl!, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ image: base64 }), + timeout: 10000, + }); + + const json = await response.json() as { predictions: nsfw.PredictionType[] }; + return json.predictions; + } catch (err) { + console.error('Failed to detect sensitive media with proxy:', err); + return null; + } + } + private async computeIsSupportedCpu(): Promise { switch (process.arch) { case 'x64': { diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 205c9eeb89..7ef146bb9f 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -292,6 +292,13 @@ export class MiMeta { }) public enableSensitiveMediaDetectionForVideos: boolean; + @Column('varchar', { + length: 1024, + nullable: true, + default: null, + }) + public sensitiveMediaDetectionProxyUrl: string | null; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 2c7f793584..e66feaf5e6 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -238,6 +238,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + sensitiveMediaDetectionProxyUrl: { + type: 'string', + optional: false, nullable: true, + }, proxyAccountId: { type: 'string', optional: false, nullable: false, @@ -687,6 +691,7 @@ export default class extends Endpoint { // eslint- sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, + sensitiveMediaDetectionProxyUrl: instance.sensitiveMediaDetectionProxyUrl, proxyAccountId: proxy.id, email: instance.email, smtpSecure: instance.smtpSecure, diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index b3c2cecc67..a312fdb808 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -90,6 +90,7 @@ export const paramDef = { sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, enableSensitiveMediaDetectionForVideos: { type: 'boolean' }, + sensitiveMediaDetectionProxyUrl: { type: 'string', nullable: true }, maintainerName: { type: 'string', nullable: true }, maintainerEmail: { type: 'string', nullable: true }, langs: { @@ -422,6 +423,11 @@ export default class extends Endpoint { // eslint- set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos; } + if (ps.sensitiveMediaDetectionProxyUrl !== undefined) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + set.sensitiveMediaDetectionProxyUrl = ps.sensitiveMediaDetectionProxyUrl || null; + } + if (ps.maintainerName !== undefined) { set.maintainerName = ps.maintainerName; } diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index fa93124daa..f8a4a60d45 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -25,6 +25,13 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._sensitiveMediaDetection.description }}
+ + + + + + + @@ -185,6 +192,7 @@ const sensitiveMediaDetectionForm = useForm({ meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0, setSensitiveFlagAutomatically: meta.setSensitiveFlagAutomatically, enableSensitiveMediaDetectionForVideos: meta.enableSensitiveMediaDetectionForVideos, + sensitiveMediaDetectionProxyUrl: meta.sensitiveMediaDetectionProxyUrl, }, async (state) => { await os.apiWithDialog('admin/update-meta', { sensitiveMediaDetection: state.sensitiveMediaDetection, @@ -197,6 +205,7 @@ const sensitiveMediaDetectionForm = useForm({ null as never, setSensitiveFlagAutomatically: state.setSensitiveFlagAutomatically, enableSensitiveMediaDetectionForVideos: state.enableSensitiveMediaDetectionForVideos, + sensitiveMediaDetectionProxyUrl: state.sensitiveMediaDetectionProxyUrl, }); fetchInstance(true); });