feat: auto nsfw detection (#8840)
* feat: auto nsfw detection
* ✌️
* Update ja-JP.yml
* Update ja-JP.yml
* ポルノ判定のしきい値を高めに
* エラーハンドリングちゃんとした
* Update ja-JP.yml
* 感度設定を強化
* refactor
* feat: add video support for auto nsfw detection
* rename: image -> media
* .js
* fix: add missing error handling
* fix: use valid pathname instead of using filename due to invalid usage
* perf(nsfw-detection): decode frames
* disable detection of video for some reasons
* perf(nsfw-detection): streamify detection process for video
* disable disallowUploadWhenPredictedAsPorn option
* fix(nsfw-detection): improve reliability
* fix(nsfw-detection): use Math.ceil instead of Math.round
* perf(nsfw-detection): delete tmp frames after used
* fix(nsfw-detection): FSWatcher does not emit ready event
* perf(nsfw-detection): skip black frames
* refactor: strip exists check
* Update package.json
* めっちゃ変えた
* lint
* Update COPYING
* オプションで動画解析できるように
* Update yarn.lock
* Update CHANGELOG.md
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
			
			
This commit is contained in:
		
							parent
							
								
									010db2515c
								
							
						
					
					
						commit
						e560601815
					
				
					 39 changed files with 1275 additions and 78 deletions
				
			
		|  | @ -11,6 +11,9 @@ You should also include the user name that made the change. | ||||||
| 
 | 
 | ||||||
| ## 12.x.x (unreleased) | ## 12.x.x (unreleased) | ||||||
| 
 | 
 | ||||||
|  | ### Known issues | ||||||
|  | - 現在arm64環境ではインストールに失敗します。これは次のバージョンで修正される予定です。 | ||||||
|  | 
 | ||||||
| ### Changes | ### Changes | ||||||
| - ハイライトがみつけるに統合されました | - ハイライトがみつけるに統合されました | ||||||
| - カスタム絵文字ページはインスタンス情報ページに統合されました | - カスタム絵文字ページはインスタンス情報ページに統合されました | ||||||
|  | @ -18,6 +21,7 @@ You should also include the user name that made the change. | ||||||
| 
 | 
 | ||||||
| ### Improvements | ### Improvements | ||||||
| - Server: Allow GET method for some endpoints @syuilo | - Server: Allow GET method for some endpoints @syuilo | ||||||
|  | - Server: Auto NSFW detection @syuilo | ||||||
| - Server: Add rate limit to i/notifications @tamaina | - Server: Add rate limit to i/notifications @tamaina | ||||||
| - Client: Improve control panel @syuilo | - Client: Improve control panel @syuilo | ||||||
| - Client: Show warning in control panel when there is an unresolved abuse report @syuilo | - Client: Show warning in control panel when there is an unresolved abuse report @syuilo | ||||||
|  |  | ||||||
							
								
								
									
										6
									
								
								COPYING
									
										
									
									
									
								
							
							
						
						
									
										6
									
								
								COPYING
									
										
									
									
									
								
							|  | @ -1,5 +1,5 @@ | ||||||
| Unless otherwise stated this repository is | Unless otherwise stated this repository is | ||||||
| Copyright © 2014-2020 syuilo and contributers | Copyright © 2014-2022 syuilo and contributers | ||||||
| 
 | 
 | ||||||
| And is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as LICENSE. | And is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as LICENSE. | ||||||
| 
 | 
 | ||||||
|  | @ -13,3 +13,7 @@ https://github.com/muan/emojilib/blob/master/LICENSE | ||||||
| RsaSignature2017 implementation by Transmute Industries Inc | RsaSignature2017 implementation by Transmute Industries Inc | ||||||
| License: MIT | License: MIT | ||||||
| https://github.com/transmute-industries/RsaSignature2017/blob/master/LICENSE | https://github.com/transmute-industries/RsaSignature2017/blob/master/LICENSE | ||||||
|  | 
 | ||||||
|  | Machine learning model for sensitive images by Infinite Red, Inc. | ||||||
|  | License: MIT | ||||||
|  | https://github.com/infinitered/nsfwjs/blob/master/LICENSE | ||||||
|  |  | ||||||
|  | @ -877,6 +877,24 @@ type: "タイプ" | ||||||
| speed: "速度" | speed: "速度" | ||||||
| slow: "遅い" | slow: "遅い" | ||||||
| fast: "速い" | fast: "速い" | ||||||
|  | sensitiveMediaDetection: "センシティブなメディアの検出" | ||||||
|  | localOnly: "ローカルのみ" | ||||||
|  | remoteOnly: "リモートのみ" | ||||||
|  | failedToUpload: "アップロード失敗" | ||||||
|  | cannotUploadBecauseInappropriate: "不適切な内容を含む可能性があると判定されたためアップロードできません。" | ||||||
|  | cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" | ||||||
|  | beta: "ベータ" | ||||||
|  | enableAutoSensitive: "自動NSFW判定" | ||||||
|  | enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、インスタンスによっては自動で設定されることがあります。" | ||||||
|  | 
 | ||||||
|  | _sensitiveMediaDetection: | ||||||
|  |   description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" | ||||||
|  |   sensitivity: "検出感度" | ||||||
|  |   sensitivityDescription: "感度を低くすると、誤検知(偽陽性)が減ります。感度を高くすると、検知漏れ(偽陰性)が減ります。" | ||||||
|  |   setSensitiveFlagAutomatically: "NSFWフラグを設定する" | ||||||
|  |   setSensitiveFlagAutomaticallyDescription: "この設定をオフにしても内部的に判定結果は保持されます。" | ||||||
|  |   analyzeVideos: "動画の解析を有効化" | ||||||
|  |   analyzeVideosDescription: "静止画に加えて動画も解析するようにします。サーバーの負荷が少し増えます。" | ||||||
| 
 | 
 | ||||||
| _emailUnavailable: | _emailUnavailable: | ||||||
|   used: "既に使用されています" |   used: "既に使用されています" | ||||||
|  |  | ||||||
							
								
								
									
										23
									
								
								packages/backend/migration/1655368940105-nsfw-detection.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/backend/migration/1655368940105-nsfw-detection.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | ||||||
|  | export class nsfwDetection1655368940105 { | ||||||
|  |     name = 'nsfwDetection1655368940105' | ||||||
|  | 
 | ||||||
|  |     async up(queryRunner) { | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" ADD "forceIsSensitive" boolean NOT NULL DEFAULT false`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" ADD "predictedIsSensitive" boolean NOT NULL DEFAULT false`); | ||||||
|  |         await queryRunner.query(`COMMENT ON COLUMN "drive_file"."predictedIsSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`); | ||||||
|  |         await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetection_enum" AS ENUM('none', 'all', 'local', 'remote')`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveImageDetection" "public"."meta_sensitiveimagedetection_enum" NOT NULL DEFAULT 'none'`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "forceIsSensitiveWhenPredicted" boolean NOT NULL DEFAULT true`); | ||||||
|  |         await queryRunner.query(`CREATE INDEX "IDX_fc2d74a6d7d8b11292a851d8f8" ON "drive_file" ("predictedIsSensitive") `); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async down(queryRunner) { | ||||||
|  |         await queryRunner.query(`DROP INDEX "public"."IDX_fc2d74a6d7d8b11292a851d8f8"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "forceIsSensitiveWhenPredicted"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetection"`); | ||||||
|  |         await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetection_enum"`); | ||||||
|  |         await queryRunner.query(`COMMENT ON COLUMN "drive_file"."predictedIsSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "predictedIsSensitive"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "forceIsSensitive"`); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								packages/backend/migration/1655371960534-nsfw-detection-2.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/backend/migration/1655371960534-nsfw-detection-2.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | ||||||
|  | export class nsfwDetection21655371960534 { | ||||||
|  |     name = 'nsfwDetection21655371960534' | ||||||
|  | 
 | ||||||
|  |     async up(queryRunner) { | ||||||
|  |         await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" AS ENUM('medium', 'low', 'high')`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveImageDetectionSensitivity" "public"."meta_sensitiveimagedetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "disallowUploadWhenPredictedAsPorn" boolean NOT NULL DEFAULT false`); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async down(queryRunner) { | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "disallowUploadWhenPredictedAsPorn"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetectionSensitivity"`); | ||||||
|  |         await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum"`); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										21
									
								
								packages/backend/migration/1655388169582-nsfw-detection-3.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								packages/backend/migration/1655388169582-nsfw-detection-3.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | ||||||
|  | export class nsfwDetection31655388169582 { | ||||||
|  |     name = 'nsfwDetection31655388169582' | ||||||
|  | 
 | ||||||
|  |     async up(queryRunner) { | ||||||
|  |         await queryRunner.query(`ALTER TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" RENAME TO "meta_sensitiveimagedetectionsensitivity_enum_old"`); | ||||||
|  |         await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" DROP DEFAULT`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" USING "sensitiveImageDetectionSensitivity"::"text"::"public"."meta_sensitiveimagedetectionsensitivity_enum"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" SET DEFAULT 'medium'`); | ||||||
|  |         await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old"`); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async down(queryRunner) { | ||||||
|  |         await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old" AS ENUM('medium', 'low', 'high')`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" DROP DEFAULT`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old" USING "sensitiveImageDetectionSensitivity"::"text"::"public"."meta_sensitiveimagedetectionsensitivity_enum_old"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" SET DEFAULT 'medium'`); | ||||||
|  |         await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum"`); | ||||||
|  |         await queryRunner.query(`ALTER TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old" RENAME TO "meta_sensitiveimagedetectionsensitivity_enum"`); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										25
									
								
								packages/backend/migration/1655393015659-nsfw-detection-4.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								packages/backend/migration/1655393015659-nsfw-detection-4.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | ||||||
|  | export class nsfwDetection41655393015659 { | ||||||
|  |     name = 'nsfwDetection41655393015659' | ||||||
|  | 
 | ||||||
|  |     async up(queryRunner) { | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetection"`); | ||||||
|  |         await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetection_enum"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetectionSensitivity"`); | ||||||
|  |         await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum"`); | ||||||
|  |         await queryRunner.query(`CREATE TYPE "public"."meta_sensitivemediadetection_enum" AS ENUM('none', 'all', 'local', 'remote')`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetection" "public"."meta_sensitivemediadetection_enum" NOT NULL DEFAULT 'none'`); | ||||||
|  |         await queryRunner.query(`CREATE TYPE "public"."meta_sensitivemediadetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionSensitivity" "public"."meta_sensitivemediadetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async down(queryRunner) { | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionSensitivity"`); | ||||||
|  |         await queryRunner.query(`DROP TYPE "public"."meta_sensitivemediadetectionsensitivity_enum"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetection"`); | ||||||
|  |         await queryRunner.query(`DROP TYPE "public"."meta_sensitivemediadetection_enum"`); | ||||||
|  |         await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveImageDetectionSensitivity" "public"."meta_sensitiveimagedetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`); | ||||||
|  |         await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetection_enum" AS ENUM('none', 'all', 'local', 'remote')`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveImageDetection" "public"."meta_sensitiveimagedetection_enum" NOT NULL DEFAULT 'none'`); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										33
									
								
								packages/backend/migration/1656251734807-nsfw-detection-5.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								packages/backend/migration/1656251734807-nsfw-detection-5.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,33 @@ | ||||||
|  | export class nsfwDetection51656251734807 { | ||||||
|  |     name = 'nsfwDetection51656251734807' | ||||||
|  | 
 | ||||||
|  |     async up(queryRunner) { | ||||||
|  |         await queryRunner.query(`DROP INDEX "public"."IDX_fc2d74a6d7d8b11292a851d8f8"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "forceIsSensitive"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "predictedIsSensitive"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "forceIsSensitiveWhenPredicted"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "disallowUploadWhenPredictedAsPorn"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" ADD "maybeSensitive" boolean NOT NULL DEFAULT false`); | ||||||
|  |         await queryRunner.query(`COMMENT ON COLUMN "drive_file"."maybeSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" ADD "maybePorn" boolean NOT NULL DEFAULT false`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "setSensitiveFlagAutomatically" boolean NOT NULL DEFAULT false`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "user_profile" ADD "autoSensitive" boolean NOT NULL DEFAULT false`); | ||||||
|  |         await queryRunner.query(`CREATE INDEX "IDX_3b33dff77bb64b23c88151d23e" ON "drive_file" ("maybeSensitive") `); | ||||||
|  |         await queryRunner.query(`CREATE INDEX "IDX_8bdcd3dd2bddb78014999a16ce" ON "drive_file" ("maybePorn") `); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async down(queryRunner) { | ||||||
|  |         await queryRunner.query(`DROP INDEX "public"."IDX_8bdcd3dd2bddb78014999a16ce"`); | ||||||
|  |         await queryRunner.query(`DROP INDEX "public"."IDX_3b33dff77bb64b23c88151d23e"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "autoSensitive"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "setSensitiveFlagAutomatically"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "maybePorn"`); | ||||||
|  |         await queryRunner.query(`COMMENT ON COLUMN "drive_file"."maybeSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "maybeSensitive"`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "disallowUploadWhenPredictedAsPorn" boolean NOT NULL DEFAULT false`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "meta" ADD "forceIsSensitiveWhenPredicted" boolean NOT NULL DEFAULT true`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" ADD "predictedIsSensitive" boolean NOT NULL DEFAULT false`); | ||||||
|  |         await queryRunner.query(`ALTER TABLE "drive_file" ADD "forceIsSensitive" boolean NOT NULL DEFAULT false`); | ||||||
|  |         await queryRunner.query(`CREATE INDEX "IDX_fc2d74a6d7d8b11292a851d8f8" ON "drive_file" ("predictedIsSensitive") `); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								packages/backend/migration/1656408772602-nsfw-detection-6.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/backend/migration/1656408772602-nsfw-detection-6.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | ||||||
|  | export class nsfwDetection61656408772602 { | ||||||
|  |     name = 'nsfwDetection61656408772602' | ||||||
|  | 
 | ||||||
|  |     async up(queryRunner) { | ||||||
|  | 				await queryRunner.query(`ALTER TABLE "meta" ADD "enableSensitiveMediaDetectionForVideos" boolean NOT NULL DEFAULT false`); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async down(queryRunner) { | ||||||
|  | 			await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableSensitiveMediaDetectionForVideos"`); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								packages/backend/nsfw-model/group1-shard1of6
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								packages/backend/nsfw-model/group1-shard1of6
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										2
									
								
								packages/backend/nsfw-model/group1-shard2of6
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								packages/backend/nsfw-model/group1-shard2of6
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										3
									
								
								packages/backend/nsfw-model/group1-shard3of6
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/backend/nsfw-model/group1-shard3of6
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										3
									
								
								packages/backend/nsfw-model/group1-shard4of6
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/backend/nsfw-model/group1-shard4of6
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										18
									
								
								packages/backend/nsfw-model/group1-shard5of6
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								packages/backend/nsfw-model/group1-shard5of6
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										3
									
								
								packages/backend/nsfw-model/group1-shard6of6
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/backend/nsfw-model/group1-shard6of6
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								packages/backend/nsfw-model/model.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								packages/backend/nsfw-model/model.json
									
										
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -23,6 +23,7 @@ | ||||||
| 		"@peertube/http-signature": "1.6.0", | 		"@peertube/http-signature": "1.6.0", | ||||||
| 		"@sinonjs/fake-timers": "9.1.2", | 		"@sinonjs/fake-timers": "9.1.2", | ||||||
| 		"@syuilo/aiscript": "0.11.1", | 		"@syuilo/aiscript": "0.11.1", | ||||||
|  | 		"@tensorflow/tfjs-node": "3.18.0", | ||||||
| 		"abort-controller": "3.0.0", | 		"abort-controller": "3.0.0", | ||||||
| 		"ajv": "8.11.0", | 		"ajv": "8.11.0", | ||||||
| 		"archiver": "5.3.1", | 		"archiver": "5.3.1", | ||||||
|  | @ -36,6 +37,7 @@ | ||||||
| 		"cbor": "8.1.0", | 		"cbor": "8.1.0", | ||||||
| 		"chalk": "5.0.1", | 		"chalk": "5.0.1", | ||||||
| 		"chalk-template": "0.4.0", | 		"chalk-template": "0.4.0", | ||||||
|  | 		"chokidar": "3.3.1", | ||||||
| 		"cli-highlight": "2.1.11", | 		"cli-highlight": "2.1.11", | ||||||
| 		"color-convert": "2.0.1", | 		"color-convert": "2.0.1", | ||||||
| 		"content-disposition": "0.5.4", | 		"content-disposition": "0.5.4", | ||||||
|  | @ -74,6 +76,7 @@ | ||||||
| 		"nested-property": "4.0.0", | 		"nested-property": "4.0.0", | ||||||
| 		"node-fetch": "3.2.6", | 		"node-fetch": "3.2.6", | ||||||
| 		"nodemailer": "6.7.6", | 		"nodemailer": "6.7.6", | ||||||
|  | 		"nsfwjs": "2.4.1", | ||||||
| 		"os-utils": "0.0.14", | 		"os-utils": "0.0.14", | ||||||
| 		"parse5": "7.0.0", | 		"parse5": "7.0.0", | ||||||
| 		"pg": "8.7.3", | 		"pg": "8.7.3", | ||||||
|  |  | ||||||
|  | @ -1,12 +1,18 @@ | ||||||
| import * as fs from 'node:fs'; | import * as fs from 'node:fs'; | ||||||
| import * as crypto from 'node:crypto'; | import * as crypto from 'node:crypto'; | ||||||
|  | import { join } from 'node:path'; | ||||||
| import * as stream from 'node:stream'; | import * as stream from 'node:stream'; | ||||||
| import * as util from 'node:util'; | import * as util from 'node:util'; | ||||||
|  | import { FSWatcher } from 'chokidar'; | ||||||
| import { fileTypeFromFile } from 'file-type'; | import { fileTypeFromFile } from 'file-type'; | ||||||
|  | import FFmpeg from 'fluent-ffmpeg'; | ||||||
| import isSvg from 'is-svg'; | import isSvg from 'is-svg'; | ||||||
| import probeImageSize from 'probe-image-size'; | import probeImageSize from 'probe-image-size'; | ||||||
|  | import { type predictionType } from 'nsfwjs'; | ||||||
| import sharp from 'sharp'; | import sharp from 'sharp'; | ||||||
| import { encode } from 'blurhash'; | import { encode } from 'blurhash'; | ||||||
|  | import { detectSensitive } from '@/services/detect-sensitive.js'; | ||||||
|  | import { createTempDir } from './create-temp.js'; | ||||||
| 
 | 
 | ||||||
| const pipeline = util.promisify(stream.pipeline); | const pipeline = util.promisify(stream.pipeline); | ||||||
| 
 | 
 | ||||||
|  | @ -21,6 +27,8 @@ export type FileInfo = { | ||||||
| 	height?: number; | 	height?: number; | ||||||
| 	orientation?: number; | 	orientation?: number; | ||||||
| 	blurhash?: string; | 	blurhash?: string; | ||||||
|  | 	sensitive: boolean; | ||||||
|  | 	porn: boolean; | ||||||
| 	warnings: string[]; | 	warnings: string[]; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -37,7 +45,12 @@ const TYPE_SVG = { | ||||||
| /** | /** | ||||||
|  * Get file information |  * 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 warnings = [] as string[]; | ||||||
| 
 | 
 | ||||||
| 	const size = await getFileSize(path); | 	const size = await getFileSize(path); | ||||||
|  | @ -58,7 +71,7 @@ export async function getFileInfo(path: string): Promise<FileInfo> { | ||||||
| 
 | 
 | ||||||
| 		// うまく判定できない画像は octet-stream にする
 | 		// うまく判定できない画像は octet-stream にする
 | ||||||
| 		if (!imageSize) { | 		if (!imageSize) { | ||||||
| 			warnings.push(`cannot detect image dimensions`); | 			warnings.push('cannot detect image dimensions'); | ||||||
| 			type = TYPE_OCTET_STREAM; | 			type = TYPE_OCTET_STREAM; | ||||||
| 		} else if (imageSize.wUnits === 'px') { | 		} else if (imageSize.wUnits === 'px') { | ||||||
| 			width = imageSize.width; | 			width = imageSize.width; | ||||||
|  | @ -67,7 +80,7 @@ export async function getFileInfo(path: string): Promise<FileInfo> { | ||||||
| 
 | 
 | ||||||
| 			// 制限を超えている画像は octet-stream にする
 | 			// 制限を超えている画像は octet-stream にする
 | ||||||
| 			if (imageSize.width > 16383 || imageSize.height > 16383) { | 			if (imageSize.width > 16383 || imageSize.height > 16383) { | ||||||
| 				warnings.push(`image dimensions exceeds limits`); | 				warnings.push('image dimensions exceeds limits'); | ||||||
| 				type = TYPE_OCTET_STREAM; | 				type = TYPE_OCTET_STREAM; | ||||||
| 			} | 			} | ||||||
| 		} else { | 		} 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 { | 	return { | ||||||
| 		size, | 		size, | ||||||
| 		md5, | 		md5, | ||||||
|  | @ -92,10 +118,150 @@ export async function getFileInfo(path: string): Promise<FileInfo> { | ||||||
| 		height, | 		height, | ||||||
| 		orientation, | 		orientation, | ||||||
| 		blurhash, | 		blurhash, | ||||||
|  | 		sensitive, | ||||||
|  | 		porn, | ||||||
| 		warnings, | 		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 |  * Detect MIME Type and extension | ||||||
|  */ |  */ | ||||||
|  |  | ||||||
|  | @ -156,6 +156,19 @@ export class DriveFile { | ||||||
| 	}) | 	}) | ||||||
| 	public isSensitive: boolean; | 	public isSensitive: boolean; | ||||||
| 
 | 
 | ||||||
|  | 	@Index() | ||||||
|  | 	@Column('boolean', { | ||||||
|  | 		default: false, | ||||||
|  | 		comment: 'Whether the DriveFile is NSFW. (predict)', | ||||||
|  | 	}) | ||||||
|  | 	public maybeSensitive: boolean; | ||||||
|  | 
 | ||||||
|  | 	@Index() | ||||||
|  | 	@Column('boolean', { | ||||||
|  | 		default: false, | ||||||
|  | 	}) | ||||||
|  | 	public maybePorn: boolean; | ||||||
|  | 
 | ||||||
| 	/** | 	/** | ||||||
| 	 * 外部の(信頼されていない)URLへの直リンクか否か | 	 * 外部の(信頼されていない)URLへの直リンクか否か | ||||||
| 	 */ | 	 */ | ||||||
|  |  | ||||||
|  | @ -188,6 +188,28 @@ export class Meta { | ||||||
| 	}) | 	}) | ||||||
| 	public recaptchaSecretKey: string | null; | 	public recaptchaSecretKey: string | null; | ||||||
| 
 | 
 | ||||||
|  | 	@Column('enum', { | ||||||
|  | 		enum: ['none', 'all', 'local', 'remote'], | ||||||
|  | 		default: 'none', | ||||||
|  | 	}) | ||||||
|  | 	public sensitiveMediaDetection: 'none' | 'all' | 'local' | 'remote'; | ||||||
|  | 
 | ||||||
|  | 	@Column('enum', { | ||||||
|  | 		enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'], | ||||||
|  | 		default: 'medium', | ||||||
|  | 	}) | ||||||
|  | 	public sensitiveMediaDetectionSensitivity: 'medium' | 'low' | 'high' | 'veryLow' | 'veryHigh'; | ||||||
|  | 
 | ||||||
|  | 	@Column('boolean', { | ||||||
|  | 		default: false, | ||||||
|  | 	}) | ||||||
|  | 	public setSensitiveFlagAutomatically: boolean; | ||||||
|  | 
 | ||||||
|  | 	@Column('boolean', { | ||||||
|  | 		default: false, | ||||||
|  | 	}) | ||||||
|  | 	public enableSensitiveMediaDetectionForVideos: boolean; | ||||||
|  | 
 | ||||||
| 	@Column('integer', { | 	@Column('integer', { | ||||||
| 		default: 1024, | 		default: 1024, | ||||||
| 		comment: 'Drive capacity of a local user (MB)', | 		comment: 'Drive capacity of a local user (MB)', | ||||||
|  |  | ||||||
|  | @ -152,6 +152,11 @@ export class UserProfile { | ||||||
| 	}) | 	}) | ||||||
| 	public alwaysMarkNsfw: boolean; | 	public alwaysMarkNsfw: boolean; | ||||||
| 
 | 
 | ||||||
|  | 	@Column('boolean', { | ||||||
|  | 		default: false, | ||||||
|  | 	}) | ||||||
|  | 	public autoSensitive: boolean; | ||||||
|  | 
 | ||||||
| 	@Column('boolean', { | 	@Column('boolean', { | ||||||
| 		default: false, | 		default: false, | ||||||
| 	}) | 	}) | ||||||
|  |  | ||||||
|  | @ -360,6 +360,7 @@ export const UserRepository = db.getRepository(User).extend({ | ||||||
| 				injectFeaturedNote: profile!.injectFeaturedNote, | 				injectFeaturedNote: profile!.injectFeaturedNote, | ||||||
| 				receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, | 				receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, | ||||||
| 				alwaysMarkNsfw: profile!.alwaysMarkNsfw, | 				alwaysMarkNsfw: profile!.alwaysMarkNsfw, | ||||||
|  | 				autoSensitive: profile!.autoSensitive, | ||||||
| 				carefulBot: profile!.carefulBot, | 				carefulBot: profile!.carefulBot, | ||||||
| 				autoAcceptFollowed: profile!.autoAcceptFollowed, | 				autoAcceptFollowed: profile!.autoAcceptFollowed, | ||||||
| 				noCrawle: profile!.noCrawle, | 				noCrawle: profile!.noCrawle, | ||||||
|  |  | ||||||
|  | @ -292,6 +292,10 @@ export const packedMeDetailedOnlySchema = { | ||||||
| 			type: 'boolean', | 			type: 'boolean', | ||||||
| 			nullable: true, optional: false, | 			nullable: true, optional: false, | ||||||
| 		}, | 		}, | ||||||
|  | 		autoSensitive: { | ||||||
|  | 			type: 'boolean', | ||||||
|  | 			nullable: true, optional: false, | ||||||
|  | 		}, | ||||||
| 		carefulBot: { | 		carefulBot: { | ||||||
| 			type: 'boolean', | 			type: 'boolean', | ||||||
| 			nullable: true, optional: false, | 			nullable: true, optional: false, | ||||||
|  |  | ||||||
|  | @ -195,6 +195,22 @@ export const meta = { | ||||||
| 				type: 'string', | 				type: 'string', | ||||||
| 				optional: true, nullable: true, | 				optional: true, nullable: true, | ||||||
| 			}, | 			}, | ||||||
|  | 			sensitiveMediaDetection: { | ||||||
|  | 				type: 'string', | ||||||
|  | 				optional: true, nullable: false, | ||||||
|  | 			}, | ||||||
|  | 			sensitiveMediaDetectionSensitivity: { | ||||||
|  | 				type: 'string', | ||||||
|  | 				optional: true, nullable: false, | ||||||
|  | 			}, | ||||||
|  | 			setSensitiveFlagAutomatically: { | ||||||
|  | 				type: 'boolean', | ||||||
|  | 				optional: true, nullable: false, | ||||||
|  | 			}, | ||||||
|  | 			enableSensitiveMediaDetectionForVideos: { | ||||||
|  | 				type: 'boolean', | ||||||
|  | 				optional: true, nullable: false, | ||||||
|  | 			}, | ||||||
| 			proxyAccountId: { | 			proxyAccountId: { | ||||||
| 				type: 'string', | 				type: 'string', | ||||||
| 				optional: true, nullable: true, | 				optional: true, nullable: true, | ||||||
|  | @ -370,6 +386,10 @@ export default define(meta, paramDef, async (ps, me) => { | ||||||
| 		blockedHosts: instance.blockedHosts, | 		blockedHosts: instance.blockedHosts, | ||||||
| 		hcaptchaSecretKey: instance.hcaptchaSecretKey, | 		hcaptchaSecretKey: instance.hcaptchaSecretKey, | ||||||
| 		recaptchaSecretKey: instance.recaptchaSecretKey, | 		recaptchaSecretKey: instance.recaptchaSecretKey, | ||||||
|  | 		sensitiveMediaDetection: instance.sensitiveMediaDetection, | ||||||
|  | 		sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, | ||||||
|  | 		setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, | ||||||
|  | 		enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, | ||||||
| 		proxyAccountId: instance.proxyAccountId, | 		proxyAccountId: instance.proxyAccountId, | ||||||
| 		twitterConsumerKey: instance.twitterConsumerKey, | 		twitterConsumerKey: instance.twitterConsumerKey, | ||||||
| 		twitterConsumerSecret: instance.twitterConsumerSecret, | 		twitterConsumerSecret: instance.twitterConsumerSecret, | ||||||
|  |  | ||||||
|  | @ -58,6 +58,7 @@ export default define(meta, paramDef, async (ps, me) => { | ||||||
| 		autoAcceptFollowed: profile.autoAcceptFollowed, | 		autoAcceptFollowed: profile.autoAcceptFollowed, | ||||||
| 		noCrawle: profile.noCrawle, | 		noCrawle: profile.noCrawle, | ||||||
| 		alwaysMarkNsfw: profile.alwaysMarkNsfw, | 		alwaysMarkNsfw: profile.alwaysMarkNsfw, | ||||||
|  | 		autoSensitive: profile.autoSensitive, | ||||||
| 		carefulBot: profile.carefulBot, | 		carefulBot: profile.carefulBot, | ||||||
| 		injectFeaturedNote: profile.injectFeaturedNote, | 		injectFeaturedNote: profile.injectFeaturedNote, | ||||||
| 		receiveAnnouncementEmail: profile.receiveAnnouncementEmail, | 		receiveAnnouncementEmail: profile.receiveAnnouncementEmail, | ||||||
|  |  | ||||||
|  | @ -48,6 +48,10 @@ export const paramDef = { | ||||||
| 		enableRecaptcha: { type: 'boolean' }, | 		enableRecaptcha: { type: 'boolean' }, | ||||||
| 		recaptchaSiteKey: { type: 'string', nullable: true }, | 		recaptchaSiteKey: { type: 'string', nullable: true }, | ||||||
| 		recaptchaSecretKey: { type: 'string', nullable: true }, | 		recaptchaSecretKey: { type: 'string', nullable: true }, | ||||||
|  | 		sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, | ||||||
|  | 		sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, | ||||||
|  | 		setSensitiveFlagAutomatically: { type: 'boolean' }, | ||||||
|  | 		enableSensitiveMediaDetectionForVideos: { type: 'boolean' }, | ||||||
| 		proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true }, | 		proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true }, | ||||||
| 		maintainerName: { type: 'string', nullable: true }, | 		maintainerName: { type: 'string', nullable: true }, | ||||||
| 		maintainerEmail: { type: 'string', nullable: true }, | 		maintainerEmail: { type: 'string', nullable: true }, | ||||||
|  | @ -213,6 +217,22 @@ export default define(meta, paramDef, async (ps, me) => { | ||||||
| 		set.recaptchaSecretKey = ps.recaptchaSecretKey; | 		set.recaptchaSecretKey = ps.recaptchaSecretKey; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	if (ps.sensitiveMediaDetection !== undefined) { | ||||||
|  | 		set.sensitiveMediaDetection = ps.sensitiveMediaDetection; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if (ps.sensitiveMediaDetectionSensitivity !== undefined) { | ||||||
|  | 		set.sensitiveMediaDetectionSensitivity = ps.sensitiveMediaDetectionSensitivity; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if (ps.setSensitiveFlagAutomatically !== undefined) { | ||||||
|  | 		set.setSensitiveFlagAutomatically = ps.setSensitiveFlagAutomatically; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if (ps.enableSensitiveMediaDetectionForVideos !== undefined) { | ||||||
|  | 		set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	if (ps.proxyAccountId !== undefined) { | 	if (ps.proxyAccountId !== undefined) { | ||||||
| 		set.proxyAccountId = ps.proxyAccountId; | 		set.proxyAccountId = ps.proxyAccountId; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ import ms from 'ms'; | ||||||
| import { addFile } from '@/services/drive/add-file.js'; | import { addFile } from '@/services/drive/add-file.js'; | ||||||
| import { DriveFiles } from '@/models/index.js'; | import { DriveFiles } from '@/models/index.js'; | ||||||
| import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; | import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; | ||||||
|  | import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||||
| import { fetchMeta } from '@/misc/fetch-meta.js'; | import { fetchMeta } from '@/misc/fetch-meta.js'; | ||||||
| import define from '../../../define.js'; | import define from '../../../define.js'; | ||||||
| import { apiLogger } from '../../../logger.js'; | import { apiLogger } from '../../../logger.js'; | ||||||
|  | @ -35,6 +36,18 @@ export const meta = { | ||||||
| 			code: 'INVALID_FILE_NAME', | 			code: 'INVALID_FILE_NAME', | ||||||
| 			id: 'f449b209-0c60-4e51-84d5-29486263bfd4', | 			id: 'f449b209-0c60-4e51-84d5-29486263bfd4', | ||||||
| 		}, | 		}, | ||||||
|  | 
 | ||||||
|  | 		inappropriate: { | ||||||
|  | 			message: 'Cannot upload the file because it has been determined that it possibly contains inappropriate content.', | ||||||
|  | 			code: 'INAPPROPRIATE', | ||||||
|  | 			id: 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2', | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
|  | 		noFreeSpace: { | ||||||
|  | 			message: 'Cannot upload the file because you have no free space of drive.', | ||||||
|  | 			code: 'NO_FREE_SPACE', | ||||||
|  | 			id: 'd08dbc37-a6a9-463a-8c47-96c32ab5f064', | ||||||
|  | 		}, | ||||||
| 	}, | 	}, | ||||||
| } as const; | } as const; | ||||||
| 
 | 
 | ||||||
|  | @ -87,6 +100,10 @@ export default define(meta, paramDef, async (ps, user, _, file, cleanup, ip, hea | ||||||
| 		if (e instanceof Error || typeof e === 'string') { | 		if (e instanceof Error || typeof e === 'string') { | ||||||
| 			apiLogger.error(e); | 			apiLogger.error(e); | ||||||
| 		} | 		} | ||||||
|  | 		if (e instanceof IdentifiableError) { | ||||||
|  | 			if (e.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate); | ||||||
|  | 			if (e.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace); | ||||||
|  | 		} | ||||||
| 		throw new ApiError(); | 		throw new ApiError(); | ||||||
| 	} finally { | 	} finally { | ||||||
| 		cleanup!(); | 		cleanup!(); | ||||||
|  |  | ||||||
|  | @ -1,8 +1,8 @@ | ||||||
| import { publishDriveStream } from '@/services/stream.js'; | import { publishDriveStream } from '@/services/stream.js'; | ||||||
| import define from '../../../define.js'; |  | ||||||
| import { ApiError } from '../../../error.js'; |  | ||||||
| import { DriveFiles, DriveFolders, Users } from '@/models/index.js'; | import { DriveFiles, DriveFolders, Users } from '@/models/index.js'; | ||||||
| import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; | import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; | ||||||
|  | import define from '../../../define.js'; | ||||||
|  | import { ApiError } from '../../../error.js'; | ||||||
| 
 | 
 | ||||||
| export const meta = { | export const meta = { | ||||||
| 	tags: ['drive'], | 	tags: ['drive'], | ||||||
|  |  | ||||||
|  | @ -3,17 +3,17 @@ import * as mfm from 'mfm-js'; | ||||||
| import { publishMainStream, publishUserEvent } from '@/services/stream.js'; | import { publishMainStream, publishUserEvent } from '@/services/stream.js'; | ||||||
| import acceptAllFollowRequests from '@/services/following/requests/accept-all.js'; | import acceptAllFollowRequests from '@/services/following/requests/accept-all.js'; | ||||||
| import { publishToFollowers } from '@/services/i/update.js'; | import { publishToFollowers } from '@/services/i/update.js'; | ||||||
| import define from '../../define.js'; |  | ||||||
| import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; | import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; | ||||||
| import { extractHashtags } from '@/misc/extract-hashtags.js'; | import { extractHashtags } from '@/misc/extract-hashtags.js'; | ||||||
| import { updateUsertags } from '@/services/update-hashtag.js'; | import { updateUsertags } from '@/services/update-hashtag.js'; | ||||||
| import { ApiError } from '../../error.js'; |  | ||||||
| import { Users, DriveFiles, UserProfiles, Pages } from '@/models/index.js'; | import { Users, DriveFiles, UserProfiles, Pages } from '@/models/index.js'; | ||||||
| import { User } from '@/models/entities/user.js'; | import { User } from '@/models/entities/user.js'; | ||||||
| import { UserProfile } from '@/models/entities/user-profile.js'; | import { UserProfile } from '@/models/entities/user-profile.js'; | ||||||
| import { notificationTypes } from '@/types.js'; | import { notificationTypes } from '@/types.js'; | ||||||
| import { normalizeForSearch } from '@/misc/normalize-for-search.js'; | import { normalizeForSearch } from '@/misc/normalize-for-search.js'; | ||||||
| import { langmap } from '@/misc/langmap.js'; | import { langmap } from '@/misc/langmap.js'; | ||||||
|  | import { ApiError } from '../../error.js'; | ||||||
|  | import define from '../../define.js'; | ||||||
| 
 | 
 | ||||||
| export const meta = { | export const meta = { | ||||||
| 	tags: ['account'], | 	tags: ['account'], | ||||||
|  | @ -57,7 +57,7 @@ export const meta = { | ||||||
| 			message: 'Invalid Regular Expression.', | 			message: 'Invalid Regular Expression.', | ||||||
| 			code: 'INVALID_REGEXP', | 			code: 'INVALID_REGEXP', | ||||||
| 			id: '0d786918-10df-41cd-8f33-8dec7d9a89a5', | 			id: '0d786918-10df-41cd-8f33-8dec7d9a89a5', | ||||||
| 		} | 		}, | ||||||
| 	}, | 	}, | ||||||
| 
 | 
 | ||||||
| 	res: { | 	res: { | ||||||
|  | @ -77,7 +77,8 @@ export const paramDef = { | ||||||
| 		lang: { type: 'string', enum: [null, ...Object.keys(langmap)], nullable: true }, | 		lang: { type: 'string', enum: [null, ...Object.keys(langmap)], nullable: true }, | ||||||
| 		avatarId: { type: 'string', format: 'misskey:id', nullable: true }, | 		avatarId: { type: 'string', format: 'misskey:id', nullable: true }, | ||||||
| 		bannerId: { type: 'string', format: 'misskey:id', nullable: true }, | 		bannerId: { type: 'string', format: 'misskey:id', nullable: true }, | ||||||
| 		fields: { type: 'array', | 		fields: { | ||||||
|  | 			type: 'array', | ||||||
| 			minItems: 0, | 			minItems: 0, | ||||||
| 			maxItems: 16, | 			maxItems: 16, | ||||||
| 			items: { | 			items: { | ||||||
|  | @ -102,6 +103,7 @@ export const paramDef = { | ||||||
| 		injectFeaturedNote: { type: 'boolean' }, | 		injectFeaturedNote: { type: 'boolean' }, | ||||||
| 		receiveAnnouncementEmail: { type: 'boolean' }, | 		receiveAnnouncementEmail: { type: 'boolean' }, | ||||||
| 		alwaysMarkNsfw: { type: 'boolean' }, | 		alwaysMarkNsfw: { type: 'boolean' }, | ||||||
|  | 		autoSensitive: { type: 'boolean' }, | ||||||
| 		ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, | 		ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, | ||||||
| 		pinnedPageId: { type: 'array', items: { | 		pinnedPageId: { type: 'array', items: { | ||||||
| 			type: 'string', format: 'misskey:id', | 			type: 'string', format: 'misskey:id', | ||||||
|  | @ -168,6 +170,7 @@ export default define(meta, paramDef, async (ps, _user, token) => { | ||||||
| 	if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; | 	if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; | ||||||
| 	if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; | 	if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; | ||||||
| 	if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; | 	if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; | ||||||
|  | 	if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; | ||||||
| 	if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; | 	if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; | ||||||
| 
 | 
 | ||||||
| 	if (ps.avatarId) { | 	if (ps.avatarId) { | ||||||
|  |  | ||||||
							
								
								
									
										28
									
								
								packages/backend/src/services/detect-sensitive.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								packages/backend/src/services/detect-sensitive.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | ||||||
|  | import * as fs from 'node:fs'; | ||||||
|  | import { fileURLToPath } from 'node:url'; | ||||||
|  | import { dirname } from 'node:path'; | ||||||
|  | import * as nsfw from 'nsfwjs'; | ||||||
|  | import * as tf from '@tensorflow/tfjs-node'; | ||||||
|  | 
 | ||||||
|  | const _filename = fileURLToPath(import.meta.url); | ||||||
|  | const _dirname = dirname(_filename); | ||||||
|  | 
 | ||||||
|  | let model: nsfw.NSFWJS; | ||||||
|  | 
 | ||||||
|  | export async function detectSensitive(path: string): Promise<nsfw.predictionType[] | null> { | ||||||
|  | 	try { | ||||||
|  | 		if (model == null) model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 }); | ||||||
|  | 
 | ||||||
|  | 		const buffer = await fs.promises.readFile(path); | ||||||
|  | 		const image = await tf.node.decodeImage(buffer, 3) as tf.Tensor3D; | ||||||
|  | 		try { | ||||||
|  | 			const predictions = await model.classify(image); | ||||||
|  | 			return predictions; | ||||||
|  | 		} finally { | ||||||
|  | 			image.dispose(); | ||||||
|  | 		} | ||||||
|  | 	} catch (err) { | ||||||
|  | 		console.error(err); | ||||||
|  | 		return null; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -16,6 +16,7 @@ import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/i | ||||||
| import { genId } from '@/misc/gen-id.js'; | import { genId } from '@/misc/gen-id.js'; | ||||||
| import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; | import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; | ||||||
| import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; | import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; | ||||||
|  | import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||||
| import { getS3 } from './s3.js'; | import { getS3 } from './s3.js'; | ||||||
| import { InternalStorage } from './internal-storage.js'; | import { InternalStorage } from './internal-storage.js'; | ||||||
| import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js'; | import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js'; | ||||||
|  | @ -349,9 +350,31 @@ export async function addFile({ | ||||||
| 	requestIp = null, | 	requestIp = null, | ||||||
| 	requestHeaders = null, | 	requestHeaders = null, | ||||||
| }: AddFileArgs): Promise<DriveFile> { | }: AddFileArgs): Promise<DriveFile> { | ||||||
| 	const info = await getFileInfo(path); | 	let skipNsfwCheck = false; | ||||||
|  | 	const instance = await fetchMeta(); | ||||||
|  | 	if (user == null) skipNsfwCheck = true; | ||||||
|  | 	if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true; | ||||||
|  | 	if (user && instance.sensitiveMediaDetection === 'local' && Users.isRemoteUser(user)) skipNsfwCheck = true; | ||||||
|  | 	if (user && instance.sensitiveMediaDetection === 'remote' && Users.isLocalUser(user)) skipNsfwCheck = true; | ||||||
|  | 
 | ||||||
|  | 	const info = await getFileInfo(path, { | ||||||
|  | 		skipSensitiveDetection: skipNsfwCheck, | ||||||
|  | 		sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
 | ||||||
|  | 			instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : | ||||||
|  | 			instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : | ||||||
|  | 			instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : | ||||||
|  | 			instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : | ||||||
|  | 			0.5, | ||||||
|  | 		sensitiveThresholdForPorn: 0.75, | ||||||
|  | 		enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, | ||||||
|  | 	}); | ||||||
| 	logger.info(`${JSON.stringify(info)}`); | 	logger.info(`${JSON.stringify(info)}`); | ||||||
| 
 | 
 | ||||||
|  | 	// 現状 false positive が多すぎて実用に耐えない
 | ||||||
|  | 	//if (info.porn && instance.disallowUploadWhenPredictedAsPorn) {
 | ||||||
|  | 	//	throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.');
 | ||||||
|  | 	//}
 | ||||||
|  | 
 | ||||||
| 	// detect name
 | 	// detect name
 | ||||||
| 	const detectedName = name || (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); | 	const detectedName = name || (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); | ||||||
| 
 | 
 | ||||||
|  | @ -387,7 +410,7 @@ export async function addFile({ | ||||||
| 		// If usage limit exceeded
 | 		// If usage limit exceeded
 | ||||||
| 		if (usage + info.size > driveCapacity) { | 		if (usage + info.size > driveCapacity) { | ||||||
| 			if (Users.isLocalUser(user)) { | 			if (Users.isLocalUser(user)) { | ||||||
| 				throw new Error('no-free-space'); | 				throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); | ||||||
| 			} else { | 			} else { | ||||||
| 				// (アバターまたはバナーを含まず)最も古いファイルを削除する
 | 				// (アバターまたはバナーを含まず)最も古いファイルを削除する
 | ||||||
| 				deleteOldFile(await Users.findOneByOrFail({ id: user.id }) as IRemoteUser); | 				deleteOldFile(await Users.findOneByOrFail({ id: user.id }) as IRemoteUser); | ||||||
|  | @ -441,6 +464,8 @@ export async function addFile({ | ||||||
| 	file.isLink = isLink; | 	file.isLink = isLink; | ||||||
| 	file.requestIp = requestIp; | 	file.requestIp = requestIp; | ||||||
| 	file.requestHeaders = requestHeaders; | 	file.requestHeaders = requestHeaders; | ||||||
|  | 	file.maybeSensitive = info.sensitive; | ||||||
|  | 	file.maybePorn = info.porn; | ||||||
| 	file.isSensitive = user | 	file.isSensitive = user | ||||||
| 		? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : | 		? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : | ||||||
| 		(sensitive !== null && sensitive !== undefined) | 		(sensitive !== null && sensitive !== undefined) | ||||||
|  | @ -448,6 +473,9 @@ export async function addFile({ | ||||||
| 			: false | 			: false | ||||||
| 		: false; | 		: false; | ||||||
| 
 | 
 | ||||||
|  | 	if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; | ||||||
|  | 	if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; | ||||||
|  | 
 | ||||||
| 	if (url !== null) { | 	if (url !== null) { | ||||||
| 		file.src = url; | 		file.src = url; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -10,9 +10,10 @@ const _dirname = dirname(_filename); | ||||||
| describe('Get file info', () => { | describe('Get file info', () => { | ||||||
| 	it('Empty file', async (async () => { | 	it('Empty file', async (async () => { | ||||||
| 		const path = `${_dirname}/resources/emptyfile`; | 		const path = `${_dirname}/resources/emptyfile`; | ||||||
| 		const info = await getFileInfo(path) as any; | 		const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; | ||||||
| 		delete info.warnings; | 		delete info.warnings; | ||||||
| 		delete info.blurhash; | 		delete info.blurhash; | ||||||
|  | 		delete info.sensitive; | ||||||
| 		assert.deepStrictEqual(info, { | 		assert.deepStrictEqual(info, { | ||||||
| 			size: 0, | 			size: 0, | ||||||
| 			md5: 'd41d8cd98f00b204e9800998ecf8427e', | 			md5: 'd41d8cd98f00b204e9800998ecf8427e', | ||||||
|  | @ -28,9 +29,10 @@ describe('Get file info', () => { | ||||||
| 
 | 
 | ||||||
| 	it('Generic JPEG', async (async () => { | 	it('Generic JPEG', async (async () => { | ||||||
| 		const path = `${_dirname}/resources/Lenna.jpg`; | 		const path = `${_dirname}/resources/Lenna.jpg`; | ||||||
| 		const info = await getFileInfo(path) as any; | 		const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; | ||||||
| 		delete info.warnings; | 		delete info.warnings; | ||||||
| 		delete info.blurhash; | 		delete info.blurhash; | ||||||
|  | 		delete info.sensitive; | ||||||
| 		assert.deepStrictEqual(info, { | 		assert.deepStrictEqual(info, { | ||||||
| 			size: 25360, | 			size: 25360, | ||||||
| 			md5: '091b3f259662aa31e2ffef4519951168', | 			md5: '091b3f259662aa31e2ffef4519951168', | ||||||
|  | @ -46,9 +48,10 @@ describe('Get file info', () => { | ||||||
| 
 | 
 | ||||||
| 	it('Generic APNG', async (async () => { | 	it('Generic APNG', async (async () => { | ||||||
| 		const path = `${_dirname}/resources/anime.png`; | 		const path = `${_dirname}/resources/anime.png`; | ||||||
| 		const info = await getFileInfo(path) as any; | 		const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; | ||||||
| 		delete info.warnings; | 		delete info.warnings; | ||||||
| 		delete info.blurhash; | 		delete info.blurhash; | ||||||
|  | 		delete info.sensitive; | ||||||
| 		assert.deepStrictEqual(info, { | 		assert.deepStrictEqual(info, { | ||||||
| 			size: 1868, | 			size: 1868, | ||||||
| 			md5: '08189c607bea3b952704676bb3c979e0', | 			md5: '08189c607bea3b952704676bb3c979e0', | ||||||
|  | @ -64,9 +67,10 @@ describe('Get file info', () => { | ||||||
| 
 | 
 | ||||||
| 	it('Generic AGIF', async (async () => { | 	it('Generic AGIF', async (async () => { | ||||||
| 		const path = `${_dirname}/resources/anime.gif`; | 		const path = `${_dirname}/resources/anime.gif`; | ||||||
| 		const info = await getFileInfo(path) as any; | 		const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; | ||||||
| 		delete info.warnings; | 		delete info.warnings; | ||||||
| 		delete info.blurhash; | 		delete info.blurhash; | ||||||
|  | 		delete info.sensitive; | ||||||
| 		assert.deepStrictEqual(info, { | 		assert.deepStrictEqual(info, { | ||||||
| 			size: 2248, | 			size: 2248, | ||||||
| 			md5: '32c47a11555675d9267aee1a86571e7e', | 			md5: '32c47a11555675d9267aee1a86571e7e', | ||||||
|  | @ -82,9 +86,10 @@ describe('Get file info', () => { | ||||||
| 
 | 
 | ||||||
| 	it('PNG with alpha', async (async () => { | 	it('PNG with alpha', async (async () => { | ||||||
| 		const path = `${_dirname}/resources/with-alpha.png`; | 		const path = `${_dirname}/resources/with-alpha.png`; | ||||||
| 		const info = await getFileInfo(path) as any; | 		const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; | ||||||
| 		delete info.warnings; | 		delete info.warnings; | ||||||
| 		delete info.blurhash; | 		delete info.blurhash; | ||||||
|  | 		delete info.sensitive; | ||||||
| 		assert.deepStrictEqual(info, { | 		assert.deepStrictEqual(info, { | ||||||
| 			size: 3772, | 			size: 3772, | ||||||
| 			md5: 'f73535c3e1e27508885b69b10cf6e991', | 			md5: 'f73535c3e1e27508885b69b10cf6e991', | ||||||
|  | @ -100,9 +105,10 @@ describe('Get file info', () => { | ||||||
| 
 | 
 | ||||||
| 	it('Generic SVG', async (async () => { | 	it('Generic SVG', async (async () => { | ||||||
| 		const path = `${_dirname}/resources/image.svg`; | 		const path = `${_dirname}/resources/image.svg`; | ||||||
| 		const info = await getFileInfo(path) as any; | 		const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; | ||||||
| 		delete info.warnings; | 		delete info.warnings; | ||||||
| 		delete info.blurhash; | 		delete info.blurhash; | ||||||
|  | 		delete info.sensitive; | ||||||
| 		assert.deepStrictEqual(info, { | 		assert.deepStrictEqual(info, { | ||||||
| 			size: 505, | 			size: 505, | ||||||
| 			md5: 'b6f52b4b021e7b92cdd04509c7267965', | 			md5: 'b6f52b4b021e7b92cdd04509c7267965', | ||||||
|  | @ -119,9 +125,10 @@ describe('Get file info', () => { | ||||||
| 	it('SVG with XML definition', async (async () => { | 	it('SVG with XML definition', async (async () => { | ||||||
| 		// https://github.com/misskey-dev/misskey/issues/4413
 | 		// https://github.com/misskey-dev/misskey/issues/4413
 | ||||||
| 		const path = `${_dirname}/resources/with-xml-def.svg`; | 		const path = `${_dirname}/resources/with-xml-def.svg`; | ||||||
| 		const info = await getFileInfo(path) as any; | 		const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; | ||||||
| 		delete info.warnings; | 		delete info.warnings; | ||||||
| 		delete info.blurhash; | 		delete info.blurhash; | ||||||
|  | 		delete info.sensitive; | ||||||
| 		assert.deepStrictEqual(info, { | 		assert.deepStrictEqual(info, { | ||||||
| 			size: 544, | 			size: 544, | ||||||
| 			md5: '4b7a346cde9ccbeb267e812567e33397', | 			md5: '4b7a346cde9ccbeb267e812567e33397', | ||||||
|  | @ -137,9 +144,10 @@ describe('Get file info', () => { | ||||||
| 
 | 
 | ||||||
| 	it('Dimension limit', async (async () => { | 	it('Dimension limit', async (async () => { | ||||||
| 		const path = `${_dirname}/resources/25000x25000.png`; | 		const path = `${_dirname}/resources/25000x25000.png`; | ||||||
| 		const info = await getFileInfo(path) as any; | 		const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; | ||||||
| 		delete info.warnings; | 		delete info.warnings; | ||||||
| 		delete info.blurhash; | 		delete info.blurhash; | ||||||
|  | 		delete info.sensitive; | ||||||
| 		assert.deepStrictEqual(info, { | 		assert.deepStrictEqual(info, { | ||||||
| 			size: 75933, | 			size: 75933, | ||||||
| 			md5: '268c5dde99e17cf8fe09f1ab3f97df56', | 			md5: '268c5dde99e17cf8fe09f1ab3f97df56', | ||||||
|  | @ -155,9 +163,10 @@ describe('Get file info', () => { | ||||||
| 
 | 
 | ||||||
| 	it('Rotate JPEG', async (async () => { | 	it('Rotate JPEG', async (async () => { | ||||||
| 		const path = `${_dirname}/resources/rotate.jpg`; | 		const path = `${_dirname}/resources/rotate.jpg`; | ||||||
| 		const info = await getFileInfo(path) as any; | 		const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any; | ||||||
| 		delete info.warnings; | 		delete info.warnings; | ||||||
| 		delete info.blurhash; | 		delete info.blurhash; | ||||||
|  | 		delete info.sensitive; | ||||||
| 		assert.deepStrictEqual(info, { | 		assert.deepStrictEqual(info, { | ||||||
| 			size: 12624, | 			size: 12624, | ||||||
| 			md5: '68d5b2d8d1d1acbbce99203e3ec3857e', | 			md5: '68d5b2d8d1d1acbbce99203e3ec3857e', | ||||||
|  |  | ||||||
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -14,6 +14,49 @@ | ||||||
| 					<XBotProtection/> | 					<XBotProtection/> | ||||||
| 				</FormFolder> | 				</FormFolder> | ||||||
| 
 | 
 | ||||||
|  | 				<FormFolder class="_formBlock"> | ||||||
|  | 					<template #icon><i class="fas fa-eye-slash"></i></template> | ||||||
|  | 					<template #label>{{ i18n.ts.sensitiveMediaDetection }}</template> | ||||||
|  | 					<template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template> | ||||||
|  | 					<template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template> | ||||||
|  | 					<template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template> | ||||||
|  | 					<template v-else #suffix>{{ i18n.ts.none }}</template> | ||||||
|  | 
 | ||||||
|  | 					<div class="_formRoot"> | ||||||
|  | 						<span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span> | ||||||
|  | 
 | ||||||
|  | 						<FormRadios v-model="sensitiveMediaDetection" class="_formBlock"> | ||||||
|  | 							<option value="none">{{ i18n.ts.none }}</option> | ||||||
|  | 							<option value="all">{{ i18n.ts.all }}</option> | ||||||
|  | 							<option value="local">{{ i18n.ts.localOnly }}</option> | ||||||
|  | 							<option value="remote">{{ i18n.ts.remoteOnly }}</option> | ||||||
|  | 						</FormRadios> | ||||||
|  | 
 | ||||||
|  | 						<FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock"> | ||||||
|  | 							<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template> | ||||||
|  | 							<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template> | ||||||
|  | 						</FormRange> | ||||||
|  | 
 | ||||||
|  | 						<FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock"> | ||||||
|  | 							<template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template> | ||||||
|  | 							<template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template> | ||||||
|  | 						</FormSwitch> | ||||||
|  | 
 | ||||||
|  | 						<FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock"> | ||||||
|  | 							<template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template> | ||||||
|  | 							<template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template> | ||||||
|  | 						</FormSwitch> | ||||||
|  | 
 | ||||||
|  | 						<!-- 現状 false positive が多すぎて実用に耐えない | ||||||
|  | 						<FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock"> | ||||||
|  | 							<template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template> | ||||||
|  | 						</FormSwitch> | ||||||
|  | 						--> | ||||||
|  | 
 | ||||||
|  | 						<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> | ||||||
|  | 					</div> | ||||||
|  | 				</FormFolder> | ||||||
|  | 
 | ||||||
| 				<FormFolder class="_formBlock"> | 				<FormFolder class="_formBlock"> | ||||||
| 					<template #label>Log IP address</template> | 					<template #label>Log IP address</template> | ||||||
| 					<template v-if="enableIpLogging" #suffix>Enabled</template> | 					<template v-if="enableIpLogging" #suffix>Enabled</template> | ||||||
|  | @ -49,10 +92,11 @@ import { } from 'vue'; | ||||||
| import XBotProtection from './bot-protection.vue'; | import XBotProtection from './bot-protection.vue'; | ||||||
| import XHeader from './_header_.vue'; | import XHeader from './_header_.vue'; | ||||||
| import FormFolder from '@/components/form/folder.vue'; | import FormFolder from '@/components/form/folder.vue'; | ||||||
|  | import FormRadios from '@/components/form/radios.vue'; | ||||||
| import FormSwitch from '@/components/form/switch.vue'; | import FormSwitch from '@/components/form/switch.vue'; | ||||||
| import FormInfo from '@/components/ui/info.vue'; | import FormInfo from '@/components/ui/info.vue'; | ||||||
| import FormSuspense from '@/components/form/suspense.vue'; | import FormSuspense from '@/components/form/suspense.vue'; | ||||||
| import FormSection from '@/components/form/section.vue'; | import FormRange from '@/components/form/range.vue'; | ||||||
| import FormInput from '@/components/form/input.vue'; | import FormInput from '@/components/form/input.vue'; | ||||||
| import FormButton from '@/components/ui/button.vue'; | import FormButton from '@/components/ui/button.vue'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
|  | @ -63,6 +107,10 @@ import { definePageMetadata } from '@/scripts/page-metadata'; | ||||||
| let summalyProxy: string = $ref(''); | let summalyProxy: string = $ref(''); | ||||||
| let enableHcaptcha: boolean = $ref(false); | let enableHcaptcha: boolean = $ref(false); | ||||||
| let enableRecaptcha: boolean = $ref(false); | let enableRecaptcha: boolean = $ref(false); | ||||||
|  | let sensitiveMediaDetection: string = $ref('none'); | ||||||
|  | let sensitiveMediaDetectionSensitivity: number = $ref(0); | ||||||
|  | let setSensitiveFlagAutomatically: boolean = $ref(false); | ||||||
|  | let enableSensitiveMediaDetectionForVideos: boolean = $ref(false); | ||||||
| let enableIpLogging: boolean = $ref(false); | let enableIpLogging: boolean = $ref(false); | ||||||
| 
 | 
 | ||||||
| async function init() { | async function init() { | ||||||
|  | @ -70,12 +118,31 @@ async function init() { | ||||||
| 	summalyProxy = meta.summalyProxy; | 	summalyProxy = meta.summalyProxy; | ||||||
| 	enableHcaptcha = meta.enableHcaptcha; | 	enableHcaptcha = meta.enableHcaptcha; | ||||||
| 	enableRecaptcha = meta.enableRecaptcha; | 	enableRecaptcha = meta.enableRecaptcha; | ||||||
|  | 	sensitiveMediaDetection = meta.sensitiveMediaDetection; | ||||||
|  | 	sensitiveMediaDetectionSensitivity = | ||||||
|  | 		meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 : | ||||||
|  | 		meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 : | ||||||
|  | 		meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 : | ||||||
|  | 		meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 : | ||||||
|  | 		meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0; | ||||||
|  | 	setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically; | ||||||
|  | 	enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos; | ||||||
| 	enableIpLogging = meta.enableIpLogging; | 	enableIpLogging = meta.enableIpLogging; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function save() { | function save() { | ||||||
| 	os.apiWithDialog('admin/update-meta', { | 	os.apiWithDialog('admin/update-meta', { | ||||||
| 		summalyProxy, | 		summalyProxy, | ||||||
|  | 		sensitiveMediaDetection, | ||||||
|  | 		sensitiveMediaDetectionSensitivity: | ||||||
|  | 			sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' : | ||||||
|  | 			sensitiveMediaDetectionSensitivity === 1 ? 'low' : | ||||||
|  | 			sensitiveMediaDetectionSensitivity === 2 ? 'medium' : | ||||||
|  | 			sensitiveMediaDetectionSensitivity === 3 ? 'high' : | ||||||
|  | 			sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' : | ||||||
|  | 			0, | ||||||
|  | 		setSensitiveFlagAutomatically, | ||||||
|  | 		enableSensitiveMediaDetectionForVideos, | ||||||
| 		enableIpLogging, | 		enableIpLogging, | ||||||
| 	}).then(() => { | 	}).then(() => { | ||||||
| 		fetchInstance(); | 		fetchInstance(); | ||||||
|  |  | ||||||
|  | @ -28,7 +28,17 @@ | ||||||
| 			<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> | 			<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> | ||||||
| 			<template #suffixIcon><i class="fas fa-folder-open"></i></template> | 			<template #suffixIcon><i class="fas fa-folder-open"></i></template> | ||||||
| 		</FormLink> | 		</FormLink> | ||||||
| 		<FormSwitch v-model="keepOriginalUploading" class="_formBlock">{{ i18n.ts.keepOriginalUploading }}<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template></FormSwitch> | 		<FormSwitch v-model="keepOriginalUploading" class="_formBlock"> | ||||||
|  | 			<template #label>{{ i18n.ts.keepOriginalUploading }}</template> | ||||||
|  | 			<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> | ||||||
|  | 		</FormSwitch> | ||||||
|  | 		<FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:modelValue="saveProfile()"> | ||||||
|  | 			<template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> | ||||||
|  | 		</FormSwitch> | ||||||
|  | 		<FormSwitch v-model="autoSensitive" class="_formBlock" @update:modelValue="saveProfile()"> | ||||||
|  | 			<template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template> | ||||||
|  | 			<template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template> | ||||||
|  | 		</FormSwitch> | ||||||
| 	</FormSection> | 	</FormSection> | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
|  | @ -47,11 +57,14 @@ import { defaultStore } from '@/store'; | ||||||
| import MkChart from '@/components/chart.vue'; | import MkChart from '@/components/chart.vue'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | import { definePageMetadata } from '@/scripts/page-metadata'; | ||||||
|  | import { $i } from '@/account'; | ||||||
| 
 | 
 | ||||||
| const fetching = ref(true); | const fetching = ref(true); | ||||||
| const usage = ref<any>(null); | const usage = ref<any>(null); | ||||||
| const capacity = ref<any>(null); | const capacity = ref<any>(null); | ||||||
| const uploadFolder = ref<any>(null); | const uploadFolder = ref<any>(null); | ||||||
|  | let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw); | ||||||
|  | let autoSensitive = $ref($i.autoSensitive); | ||||||
| 
 | 
 | ||||||
| const meterStyle = computed(() => { | const meterStyle = computed(() => { | ||||||
| 	return { | 	return { | ||||||
|  | @ -94,6 +107,13 @@ function chooseUploadFolder() { | ||||||
| 	}); | 	}); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | function saveProfile() { | ||||||
|  | 	os.api('i/update', { | ||||||
|  | 		alwaysMarkNsfw: !!alwaysMarkNsfw, | ||||||
|  | 		autoSensitive: !!autoSensitive, | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| const headerActions = $computed(() => []); | const headerActions = $computed(() => []); | ||||||
| 
 | 
 | ||||||
| const headerTabs = $computed(() => []); | const headerTabs = $computed(() => []); | ||||||
|  |  | ||||||
|  | @ -56,8 +56,6 @@ | ||||||
| 	<FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch> | 	<FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch> | ||||||
| 	<FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch> | 	<FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch> | ||||||
| 	<FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch> | 	<FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch> | ||||||
| 
 |  | ||||||
| 	<FormSwitch v-model="profile.alwaysMarkNsfw" class="_formBlock">{{ i18n.ts.alwaysMarkSensitive }}</FormSwitch> |  | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
|  | @ -88,7 +86,6 @@ const profile = reactive({ | ||||||
| 	isBot: $i.isBot, | 	isBot: $i.isBot, | ||||||
| 	isCat: $i.isCat, | 	isCat: $i.isCat, | ||||||
| 	showTimelineReplies: $i.showTimelineReplies, | 	showTimelineReplies: $i.showTimelineReplies, | ||||||
| 	alwaysMarkNsfw: $i.alwaysMarkNsfw, |  | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| watch(() => profile, () => { | watch(() => profile, () => { | ||||||
|  | @ -126,7 +123,6 @@ function save() { | ||||||
| 		isBot: !!profile.isBot, | 		isBot: !!profile.isBot, | ||||||
| 		isCat: !!profile.isCat, | 		isCat: !!profile.isCat, | ||||||
| 		showTimelineReplies: !!profile.showTimelineReplies, | 		showTimelineReplies: !!profile.showTimelineReplies, | ||||||
| 		alwaysMarkNsfw: !!profile.alwaysMarkNsfw, |  | ||||||
| 	}); | 	}); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,9 +1,9 @@ | ||||||
| import { ref } from 'vue'; | import { ref } from 'vue'; | ||||||
|  | import { DriveFile } from 'misskey-js/built/entities'; | ||||||
| import * as os from '@/os'; | import * as os from '@/os'; | ||||||
| import { stream } from '@/stream'; | import { stream } from '@/stream'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
| import { DriveFile } from 'misskey-js/built/entities'; |  | ||||||
| import { uploadFile } from '@/scripts/upload'; | import { uploadFile } from '@/scripts/upload'; | ||||||
| 
 | 
 | ||||||
| function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> { | function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> { | ||||||
|  | @ -20,10 +20,7 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv | ||||||
| 				Promise.all(promises).then(driveFiles => { | 				Promise.all(promises).then(driveFiles => { | ||||||
| 					res(multiple ? driveFiles : driveFiles[0]); | 					res(multiple ? driveFiles : driveFiles[0]); | ||||||
| 				}).catch(err => { | 				}).catch(err => { | ||||||
| 					os.alert({ | 					// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
 | ||||||
| 						type: 'error', |  | ||||||
| 						text: err |  | ||||||
| 					}); |  | ||||||
| 				}); | 				}); | ||||||
| 
 | 
 | ||||||
| 				// 一応廃棄
 | 				// 一応廃棄
 | ||||||
|  | @ -47,7 +44,7 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv | ||||||
| 			os.inputText({ | 			os.inputText({ | ||||||
| 				title: i18n.ts.uploadFromUrl, | 				title: i18n.ts.uploadFromUrl, | ||||||
| 				type: 'url', | 				type: 'url', | ||||||
| 				placeholder: i18n.ts.uploadFromUrlDescription | 				placeholder: i18n.ts.uploadFromUrlDescription, | ||||||
| 			}).then(({ canceled, result: url }) => { | 			}).then(({ canceled, result: url }) => { | ||||||
| 				if (canceled) return; | 				if (canceled) return; | ||||||
| 
 | 
 | ||||||
|  | @ -64,35 +61,35 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv | ||||||
| 				os.api('drive/files/upload-from-url', { | 				os.api('drive/files/upload-from-url', { | ||||||
| 					url: url, | 					url: url, | ||||||
| 					folderId: defaultStore.state.uploadFolder, | 					folderId: defaultStore.state.uploadFolder, | ||||||
| 					marker | 					marker, | ||||||
| 				}); | 				}); | ||||||
| 
 | 
 | ||||||
| 				os.alert({ | 				os.alert({ | ||||||
| 					title: i18n.ts.uploadFromUrlRequested, | 					title: i18n.ts.uploadFromUrlRequested, | ||||||
| 					text: i18n.ts.uploadFromUrlMayTakeTime | 					text: i18n.ts.uploadFromUrlMayTakeTime, | ||||||
| 				}); | 				}); | ||||||
| 			}); | 			}); | ||||||
| 		}; | 		}; | ||||||
| 
 | 
 | ||||||
| 		os.popupMenu([label ? { | 		os.popupMenu([label ? { | ||||||
| 			text: label, | 			text: label, | ||||||
| 			type: 'label' | 			type: 'label', | ||||||
| 		} : undefined, { | 		} : undefined, { | ||||||
| 			type: 'switch', | 			type: 'switch', | ||||||
| 			text: i18n.ts.keepOriginalUploading, | 			text: i18n.ts.keepOriginalUploading, | ||||||
| 			ref: keepOriginal | 			ref: keepOriginal, | ||||||
| 		}, { | 		}, { | ||||||
| 			text: i18n.ts.upload, | 			text: i18n.ts.upload, | ||||||
| 			icon: 'fas fa-upload', | 			icon: 'fas fa-upload', | ||||||
| 			action: chooseFileFromPc | 			action: chooseFileFromPc, | ||||||
| 		}, { | 		}, { | ||||||
| 			text: i18n.ts.fromDrive, | 			text: i18n.ts.fromDrive, | ||||||
| 			icon: 'fas fa-cloud', | 			icon: 'fas fa-cloud', | ||||||
| 			action: chooseFileFromDrive | 			action: chooseFileFromDrive, | ||||||
| 		}, { | 		}, { | ||||||
| 			text: i18n.ts.fromUrl, | 			text: i18n.ts.fromUrl, | ||||||
| 			icon: 'fas fa-link', | 			icon: 'fas fa-link', | ||||||
| 			action: chooseFileFromUrl | 			action: chooseFileFromUrl, | ||||||
| 		}], src); | 		}], src); | ||||||
| 	}); | 	}); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ import { defaultStore } from '@/store'; | ||||||
| import { apiUrl } from '@/config'; | import { apiUrl } from '@/config'; | ||||||
| import { $i } from '@/account'; | import { $i } from '@/account'; | ||||||
| import { alert } from '@/os'; | import { alert } from '@/os'; | ||||||
|  | import { i18n } from '@/i18n'; | ||||||
| 
 | 
 | ||||||
| type Uploading = { | type Uploading = { | ||||||
| 	id: string; | 	id: string; | ||||||
|  | @ -80,14 +81,37 @@ export function uploadFile( | ||||||
| 			xhr.open('POST', apiUrl + '/drive/files/create', true); | 			xhr.open('POST', apiUrl + '/drive/files/create', true); | ||||||
| 			xhr.onload = (ev) => { | 			xhr.onload = (ev) => { | ||||||
| 				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { | 				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { | ||||||
| 					// TODO: 消すのではなくて再送できるようにしたい
 | 					// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
 | ||||||
| 					uploads.value = uploads.value.filter(x => x.id !== id); | 					uploads.value = uploads.value.filter(x => x.id !== id); | ||||||
| 
 | 
 | ||||||
|  | 					if (ev.target?.response) { | ||||||
|  | 						const res = JSON.parse(ev.target.response); | ||||||
|  | 						if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { | ||||||
|  | 							alert({ | ||||||
|  | 								type: 'error', | ||||||
|  | 								title: i18n.ts.failedToUpload, | ||||||
|  | 								text: i18n.ts.cannotUploadBecauseInappropriate, | ||||||
|  | 							}); | ||||||
|  | 						} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { | ||||||
|  | 							alert({ | ||||||
|  | 								type: 'error', | ||||||
|  | 								title: i18n.ts.failedToUpload, | ||||||
|  | 								text: i18n.ts.cannotUploadBecauseNoFreeSpace, | ||||||
|  | 							}); | ||||||
|  | 						} else { | ||||||
|  | 							alert({ | ||||||
|  | 								type: 'error', | ||||||
|  | 								title: i18n.ts.failedToUpload, | ||||||
|  | 								text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, | ||||||
|  | 							}); | ||||||
|  | 						} | ||||||
|  | 					} else { | ||||||
| 						alert({ | 						alert({ | ||||||
| 							type: 'error', | 							type: 'error', | ||||||
| 							title: 'Failed to upload', | 							title: 'Failed to upload', | ||||||
| 							text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, | 							text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, | ||||||
| 						}); | 						}); | ||||||
|  | 					} | ||||||
| 
 | 
 | ||||||
| 					reject(); | 					reject(); | ||||||
| 					return; | 					return; | ||||||
|  |  | ||||||
|  | @ -399,6 +399,16 @@ hr { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | ._beta { | ||||||
|  | 	margin-left: 0.7em; | ||||||
|  | 	font-size: 65%; | ||||||
|  | 	padding: 2px 3px; | ||||||
|  | 	color: var(--accent); | ||||||
|  | 	border: solid 1px var(--accent); | ||||||
|  | 	border-radius: 4px; | ||||||
|  | 	vertical-align: top; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| ._table { | ._table { | ||||||
| 	> ._row { | 	> ._row { | ||||||
| 		display: flex; | 		display: flex; | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue