diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-07 21:23:03 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-07 21:23:03 +0900 |
| commit | 84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b (patch) | |
| tree | a182502a5192992d873e7a7fcbf01662bb0dfca2 /packages/backend/src/misc | |
| parent | Merge pull request #8821 from misskey-dev/develop (diff) | |
| parent | 12.112.1 (diff) | |
| download | misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.gz misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.bz2 misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/backend/src/misc')
| -rw-r--r-- | packages/backend/src/misc/check-word-mute.ts | 8 | ||||
| -rw-r--r-- | packages/backend/src/misc/create-temp.ts | 13 | ||||
| -rw-r--r-- | packages/backend/src/misc/get-file-info.ts | 172 | ||||
| -rw-r--r-- | packages/backend/src/misc/is-blocker-user-related.ts | 15 | ||||
| -rw-r--r-- | packages/backend/src/misc/is-mime-image.ts | 8 | ||||
| -rw-r--r-- | packages/backend/src/misc/is-muted-user-related.ts | 15 | ||||
| -rw-r--r-- | packages/backend/src/misc/is-user-related.ts | 15 |
7 files changed, 206 insertions, 40 deletions
diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index 588dc79e55..d7662820af 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -16,11 +16,13 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi if (me && (note.userId === me.id)) return false; if (mutedWords.length > 0) { - if (note.text == null) return false; + const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); + + if (text === '') return false; const matched = mutedWords.some(filter => { if (Array.isArray(filter)) { - return filter.every(keyword => note.text!.includes(keyword)); + return filter.every(keyword => text.includes(keyword)); } else { // represents RegExp const regexp = filter.match(/^\/(.+)\/(.*)$/); @@ -29,7 +31,7 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi if (!regexp) return false; try { - return new RE2(regexp[1], regexp[2]).test(note.text!); + return new RE2(regexp[1], regexp[2]).test(text); } catch (err) { // This should never happen due to input sanitisation. return false; diff --git a/packages/backend/src/misc/create-temp.ts b/packages/backend/src/misc/create-temp.ts index f07be634fb..fa88769de0 100644 --- a/packages/backend/src/misc/create-temp.ts +++ b/packages/backend/src/misc/create-temp.ts @@ -11,9 +11,14 @@ export function createTemp(): Promise<[string, () => void]> { export function createTempDir(): Promise<[string, () => void]> { return new Promise<[string, () => void]>((res, rej) => { - tmp.dir((e, path, cleanup) => { - if (e) return rej(e); - res([path, cleanup]); - }); + tmp.dir( + { + unsafeCleanup: true, + }, + (e, path, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + } + ); }); } diff --git a/packages/backend/src/misc/get-file-info.ts b/packages/backend/src/misc/get-file-info.ts index d70dc3d70c..42061fcf83 100644 --- a/packages/backend/src/misc/get-file-info.ts +++ b/packages/backend/src/misc/get-file-info.ts @@ -1,12 +1,18 @@ import * as fs from 'node:fs'; import * as crypto from 'node:crypto'; +import { join } from 'node:path'; import * as stream from 'node:stream'; import * as util from 'node:util'; +import { FSWatcher } from 'chokidar'; import { fileTypeFromFile } from 'file-type'; +import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; +import { type predictionType } from 'nsfwjs'; import sharp from 'sharp'; import { encode } from 'blurhash'; +import { detectSensitive } from '@/services/detect-sensitive.js'; +import { createTempDir } from './create-temp.js'; const pipeline = util.promisify(stream.pipeline); @@ -21,6 +27,8 @@ export type FileInfo = { height?: number; orientation?: number; blurhash?: string; + sensitive: boolean; + porn: boolean; warnings: string[]; }; @@ -37,7 +45,12 @@ const TYPE_SVG = { /** * Get file information */ -export async function getFileInfo(path: string): Promise<FileInfo> { +export async function getFileInfo(path: string, opts: { + skipSensitiveDetection: boolean; + sensitiveThreshold?: number; + sensitiveThresholdForPorn?: number; + enableSensitiveMediaDetectionForVideos?: boolean; +}): Promise<FileInfo> { const warnings = [] as string[]; const size = await getFileSize(path); @@ -58,7 +71,7 @@ export async function getFileInfo(path: string): Promise<FileInfo> { // うまく判定できない画像は octet-stream にする if (!imageSize) { - warnings.push(`cannot detect image dimensions`); + warnings.push('cannot detect image dimensions'); type = TYPE_OCTET_STREAM; } else if (imageSize.wUnits === 'px') { width = imageSize.width; @@ -67,7 +80,7 @@ export async function getFileInfo(path: string): Promise<FileInfo> { // 制限を超えている画像は octet-stream にする if (imageSize.width > 16383 || imageSize.height > 16383) { - warnings.push(`image dimensions exceeds limits`); + warnings.push('image dimensions exceeds limits'); type = TYPE_OCTET_STREAM; } } else { @@ -84,6 +97,19 @@ export async function getFileInfo(path: string): Promise<FileInfo> { }); } + let sensitive = false; + let porn = false; + + if (!opts.skipSensitiveDetection) { + [sensitive, porn] = await detectSensitivity( + path, + type.mime, + opts.sensitiveThreshold ?? 0.5, + opts.sensitiveThresholdForPorn ?? 0.75, + opts.enableSensitiveMediaDetectionForVideos ?? false, + ); + } + return { size, md5, @@ -92,10 +118,150 @@ export async function getFileInfo(path: string): Promise<FileInfo> { height, orientation, blurhash, + sensitive, + porn, warnings, }; } +async function detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> { + let sensitive = false; + let porn = false; + + function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] { + let sensitive = false; + let porn = false; + + if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true; + + if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true; + + return [sensitive, porn]; + } + + if (['image/jpeg', 'image/png', 'image/webp'].includes(mime)) { + const result = await detectSensitive(source); + if (result) { + [sensitive, porn] = judgePrediction(result); + } + } else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) { + const [outDir, disposeOutDir] = await createTempDir(); + try { + const command = FFmpeg() + .input(source) + .inputOptions([ + '-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない) + '-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない) + ]) + .noAudio() + .videoFilters([ + { + filter: 'select', // フレームのフィルタリング + options: { + e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタする(VP9 とかはデコードしてみないとわからないっぽい) + }, + }, + { + filter: 'blackframe', // 暗いフレームの検出 + options: { + amount: '0', // 暗さに関わらず全てのフレームで測定値を取る + }, + }, + { + filter: 'metadata', + options: { + mode: 'select', // フレーム選択モード + key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する) + value: '50', + function: 'less', // 50% 未満のフレームを選択する(50% 以上暗部があるフレームだと誤検知を招くかもしれないので) + }, + }, + { + filter: 'scale', + options: { + w: 299, + h: 299, + }, + }, + ]) + .format('image2') + .output(join(outDir, '%d.png')) + .outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない + const results: ReturnType<typeof judgePrediction>[] = []; + let frameIndex = 0; + let targetIndex = 0; + let nextIndex = 1; + for await (const path of asyncIterateFrames(outDir, command)) { + try { + const index = frameIndex++; + if (index !== targetIndex) { + continue; + } + targetIndex = nextIndex; + nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける + const result = await detectSensitive(path); + if (result) { + results.push(judgePrediction(result)); + } + } 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); + } finally { + disposeOutDir(); + } + } + + return [sensitive, porn]; +} + +async function* asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> { + const watcher = new FSWatcher({ + cwd, + disableGlobbing: true, + }); + let finished = false; + command.once('end', () => { + finished = true; + watcher.close(); + }); + command.run(); + for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + const current = `${i}.png`; + const next = `${i + 1}.png`; + const framePath = join(cwd, current); + if (await exists(join(cwd, next))) { + yield framePath; + } else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + watcher.add(next); + await new Promise<void>((resolve, reject) => { + watcher.on('add', function onAdd(path) { + if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている + watcher.unwatch(current); + watcher.off('add', onAdd); + resolve(); + } + }); + command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている + command.once('error', reject); + }); + yield framePath; + } else if (await exists(framePath)) { + yield framePath; + } else { + return; + } + } +} + +function exists(path: string): Promise<boolean> { + return fs.promises.access(path).then(() => true, () => false); +} + /** * Detect MIME Type and extension */ diff --git a/packages/backend/src/misc/is-blocker-user-related.ts b/packages/backend/src/misc/is-blocker-user-related.ts deleted file mode 100644 index 8c0ebfad9b..0000000000 --- a/packages/backend/src/misc/is-blocker-user-related.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function isBlockerUserRelated(note: any, blockerUserIds: Set<string>): boolean { - if (blockerUserIds.has(note.userId)) { - return true; - } - - if (note.reply != null && blockerUserIds.has(note.reply.userId)) { - return true; - } - - if (note.renote != null && blockerUserIds.has(note.renote.userId)) { - return true; - } - - return false; -} diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts new file mode 100644 index 0000000000..8993ede33a --- /dev/null +++ b/packages/backend/src/misc/is-mime-image.ts @@ -0,0 +1,8 @@ +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; + +const dictionary = { + 'safe-file': FILE_TYPE_BROWSERSAFE, + 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/svg+xml'], +}; + +export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); diff --git a/packages/backend/src/misc/is-muted-user-related.ts b/packages/backend/src/misc/is-muted-user-related.ts deleted file mode 100644 index 2caa743f95..0000000000 --- a/packages/backend/src/misc/is-muted-user-related.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function isMutedUserRelated(note: any, mutedUserIds: Set<string>): boolean { - if (mutedUserIds.has(note.userId)) { - return true; - } - - if (note.reply != null && mutedUserIds.has(note.reply.userId)) { - return true; - } - - if (note.renote != null && mutedUserIds.has(note.renote.userId)) { - return true; - } - - return false; -} diff --git a/packages/backend/src/misc/is-user-related.ts b/packages/backend/src/misc/is-user-related.ts new file mode 100644 index 0000000000..e6bbdb5d35 --- /dev/null +++ b/packages/backend/src/misc/is-user-related.ts @@ -0,0 +1,15 @@ +export function isUserRelated(note: any, userIds: Set<string>): boolean { + if (userIds.has(note.userId)) { + return true; + } + + if (note.reply != null && userIds.has(note.reply.userId)) { + return true; + } + + if (note.renote != null && userIds.has(note.renote.userId)) { + return true; + } + + return false; +} |