mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-31 20:35:52 +00:00
fix: sensitive-detectorへのリクエスト時もHttpRequestServiceを使うように (#17828)
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import fetch from 'node-fetch';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
@@ -131,9 +130,6 @@ export class AiService {
|
||||
|
||||
@bindThis
|
||||
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++) {
|
||||
@@ -148,14 +144,13 @@ export class AiService {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
const res = await this.httpRequestService.send(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
// 外部サービスとして通常の proxy / private address 制限を適用する。
|
||||
// サイドカーへの private network 接続は allowedPrivateNetworks 等で明示的に許可する。
|
||||
agent: (u) => this.httpRequestService.getAgentByUrl(u),
|
||||
signal: controller.signal,
|
||||
timeout,
|
||||
}, {
|
||||
throwErrorWhenResponseNotOk: false,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -181,8 +176,6 @@ export class AiService {
|
||||
} catch (err) {
|
||||
this.logger.warn(`sensitive detection error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return chunk.map(() => null);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { bindThis } from '@/decorators.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import type { IObject } from '@/core/activitypub/type.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { BodyInit, Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
|
||||
export type HttpRequestSendOptions = {
|
||||
@@ -311,7 +311,7 @@ export class HttpRequestService {
|
||||
url: string,
|
||||
args: {
|
||||
method?: string,
|
||||
body?: string,
|
||||
body?: BodyInit,
|
||||
headers?: Record<string, string>,
|
||||
timeout?: number,
|
||||
size?: number,
|
||||
|
||||
@@ -4,19 +4,12 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
import { AiService, type Prediction } from '@/core/AiService.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 sendMock = vi.fn();
|
||||
|
||||
const DEFAULT_META = {
|
||||
sensitiveMediaDetectionApiUrl: 'http://localhost:3009' as string | null,
|
||||
@@ -25,10 +18,9 @@ const DEFAULT_META = {
|
||||
sensitiveMediaDetectionMaxImagesPerRequest: 4,
|
||||
};
|
||||
|
||||
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiServiceType {
|
||||
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiService {
|
||||
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
|
||||
getAgentByUrlMock = vi.fn(() => undefined);
|
||||
const httpRequestService = { getAgentByUrl: getAgentByUrlMock } as unknown as HttpRequestService;
|
||||
const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
|
||||
const loggerService = {
|
||||
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
|
||||
} as unknown as LoggerService;
|
||||
@@ -52,11 +44,11 @@ const buf = (s: string) => Buffer.from(s);
|
||||
|
||||
describe('AiService', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
sendMock.mockReset();
|
||||
});
|
||||
|
||||
test('正常: 送信順を保った予測値配列を返す', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([
|
||||
sendMock.mockResolvedValue(okResponse([
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: [{ className: 'Porn', probability: 0.8 }] },
|
||||
]));
|
||||
@@ -66,30 +58,35 @@ describe('AiService', () => {
|
||||
[{ 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');
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][0]).toBe('http://localhost:3009/v1/detect-images');
|
||||
});
|
||||
|
||||
test('外部サービス: 通常の outbound agent を使用する', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
test('外部サービス: HttpRequestService を使用する', async () => {
|
||||
sendMock.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));
|
||||
expect(sendMock).toHaveBeenCalledWith('https://detector.example.com/v1/detect-images', {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: expect.any(FormData),
|
||||
timeout: 5000,
|
||||
}, {
|
||||
throwErrorWhenResponseNotOk: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('detectSensitive: 単一画像はバッチの先頭を返す', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
sendMock.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([
|
||||
sendMock.mockResolvedValue(okResponse([
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: false, error: { code: 'IMAGE_DECODE_FAILED', message: 'x' } },
|
||||
]));
|
||||
@@ -100,14 +97,14 @@ describe('AiService', () => {
|
||||
});
|
||||
|
||||
test('非200: チャンク全件 null(例外を投げない)', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}) });
|
||||
sendMock.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'));
|
||||
sendMock.mockRejectedValue(new Error('network down'));
|
||||
const svc = makeService();
|
||||
const res = await svc.detectSensitiveMany([buf('a')]);
|
||||
expect(res).toEqual([null]);
|
||||
@@ -117,11 +114,11 @@ describe('AiService', () => {
|
||||
const svc = makeService({ sensitiveMediaDetectionApiUrl: null });
|
||||
const res = await svc.detectSensitiveMany([buf('a'), buf('b')]);
|
||||
expect(res).toEqual([null, null]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('チャンク分割: maxImagesPerRequest ごとに順次送信する', async () => {
|
||||
fetchMock.mockResolvedValue(okResponse([
|
||||
sendMock.mockResolvedValue(okResponse([
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: neutral() },
|
||||
{ success: true, predictions: neutral() },
|
||||
@@ -130,22 +127,24 @@ describe('AiService', () => {
|
||||
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(sendMock).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() }]));
|
||||
sendMock.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');
|
||||
const withKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
||||
expect(withKeyHeaders.Authorization).toBe('Bearer secret');
|
||||
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockResolvedValue(okResponse([{ success: true, predictions: neutral() }]));
|
||||
sendMock.mockClear();
|
||||
sendMock.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();
|
||||
const withoutKeyHeaders = (sendMock.mock.calls[0][1] as { headers: Record<string, string> }).headers;
|
||||
expect(withoutKeyHeaders.Authorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user