Merge branch 'misskey-dev:develop' into repair-style
This commit is contained in:
		
						commit
						eebdb35dda
					
				
					 65 changed files with 1832 additions and 285 deletions
				
			
		
							
								
								
									
										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", | ||||
| 		"@sinonjs/fake-timers": "9.1.2", | ||||
| 		"@syuilo/aiscript": "0.11.1", | ||||
| 		"@tensorflow/tfjs-node": "3.18.0", | ||||
| 		"abort-controller": "3.0.0", | ||||
| 		"ajv": "8.11.0", | ||||
| 		"archiver": "5.3.1", | ||||
|  | @ -36,6 +37,7 @@ | |||
| 		"cbor": "8.1.0", | ||||
| 		"chalk": "5.0.1", | ||||
| 		"chalk-template": "0.4.0", | ||||
| 		"chokidar": "3.3.1", | ||||
| 		"cli-highlight": "2.1.11", | ||||
| 		"color-convert": "2.0.1", | ||||
| 		"content-disposition": "0.5.4", | ||||
|  | @ -74,6 +76,7 @@ | |||
| 		"nested-property": "4.0.0", | ||||
| 		"node-fetch": "3.2.6", | ||||
| 		"nodemailer": "6.7.6", | ||||
| 		"nsfwjs": "2.4.1", | ||||
| 		"os-utils": "0.0.14", | ||||
| 		"parse5": "7.0.0", | ||||
| 		"pg": "8.7.3", | ||||
|  |  | |||
|  | @ -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 | ||||
|  */ | ||||
|  |  | |||
|  | @ -156,6 +156,19 @@ export class DriveFile { | |||
| 	}) | ||||
| 	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への直リンクか否か | ||||
| 	 */ | ||||
|  |  | |||
|  | @ -188,6 +188,28 @@ export class Meta { | |||
| 	}) | ||||
| 	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', { | ||||
| 		default: 1024, | ||||
| 		comment: 'Drive capacity of a local user (MB)', | ||||
|  |  | |||
|  | @ -152,6 +152,11 @@ export class UserProfile { | |||
| 	}) | ||||
| 	public alwaysMarkNsfw: boolean; | ||||
| 
 | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
| 	public autoSensitive: boolean; | ||||
| 
 | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
|  |  | |||
|  | @ -360,6 +360,7 @@ export const UserRepository = db.getRepository(User).extend({ | |||
| 				injectFeaturedNote: profile!.injectFeaturedNote, | ||||
| 				receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, | ||||
| 				alwaysMarkNsfw: profile!.alwaysMarkNsfw, | ||||
| 				autoSensitive: profile!.autoSensitive, | ||||
| 				carefulBot: profile!.carefulBot, | ||||
| 				autoAcceptFollowed: profile!.autoAcceptFollowed, | ||||
| 				noCrawle: profile!.noCrawle, | ||||
|  |  | |||
|  | @ -161,19 +161,19 @@ export const packedUserDetailedNotMeOnlySchema = { | |||
| 			type: 'array', | ||||
| 			nullable: false, optional: false, | ||||
| 			items: { | ||||
| 					type: 'object', | ||||
| 					nullable: false, optional: false, | ||||
| 					properties: { | ||||
| 						name: { | ||||
| 							type: 'string', | ||||
| 							nullable: false, optional: false, | ||||
| 						}, | ||||
| 						value: { | ||||
| 							type: 'string', | ||||
| 							nullable: false, optional: false, | ||||
| 						}, | ||||
| 				type: 'object', | ||||
| 				nullable: false, optional: false, | ||||
| 				properties: { | ||||
| 					name: { | ||||
| 						type: 'string', | ||||
| 						nullable: false, optional: false, | ||||
| 					}, | ||||
| 					maxLength: 4, | ||||
| 					value: { | ||||
| 						type: 'string', | ||||
| 						nullable: false, optional: false, | ||||
| 					}, | ||||
| 				}, | ||||
| 				maxLength: 4, | ||||
| 			}, | ||||
| 		}, | ||||
| 		followersCount: { | ||||
|  | @ -292,6 +292,10 @@ export const packedMeDetailedOnlySchema = { | |||
| 			type: 'boolean', | ||||
| 			nullable: true, optional: false, | ||||
| 		}, | ||||
| 		autoSensitive: { | ||||
| 			type: 'boolean', | ||||
| 			nullable: true, optional: false, | ||||
| 		}, | ||||
| 		carefulBot: { | ||||
| 			type: 'boolean', | ||||
| 			nullable: true, optional: false, | ||||
|  |  | |||
|  | @ -195,6 +195,22 @@ export const meta = { | |||
| 				type: 'string', | ||||
| 				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: { | ||||
| 				type: 'string', | ||||
| 				optional: true, nullable: true, | ||||
|  | @ -370,6 +386,10 @@ export default define(meta, paramDef, async (ps, me) => { | |||
| 		blockedHosts: instance.blockedHosts, | ||||
| 		hcaptchaSecretKey: instance.hcaptchaSecretKey, | ||||
| 		recaptchaSecretKey: instance.recaptchaSecretKey, | ||||
| 		sensitiveMediaDetection: instance.sensitiveMediaDetection, | ||||
| 		sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, | ||||
| 		setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, | ||||
| 		enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, | ||||
| 		proxyAccountId: instance.proxyAccountId, | ||||
| 		twitterConsumerKey: instance.twitterConsumerKey, | ||||
| 		twitterConsumerSecret: instance.twitterConsumerSecret, | ||||
|  |  | |||
|  | @ -58,6 +58,7 @@ export default define(meta, paramDef, async (ps, me) => { | |||
| 		autoAcceptFollowed: profile.autoAcceptFollowed, | ||||
| 		noCrawle: profile.noCrawle, | ||||
| 		alwaysMarkNsfw: profile.alwaysMarkNsfw, | ||||
| 		autoSensitive: profile.autoSensitive, | ||||
| 		carefulBot: profile.carefulBot, | ||||
| 		injectFeaturedNote: profile.injectFeaturedNote, | ||||
| 		receiveAnnouncementEmail: profile.receiveAnnouncementEmail, | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ export const paramDef = { | |||
| 		offset: { type: 'integer', default: 0 }, | ||||
| 		sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, | ||||
| 		state: { type: 'string', enum: ['all', 'alive', 'available', 'admin', 'moderator', 'adminOrModerator', 'silenced', 'suspended'], default: 'all' }, | ||||
| 		origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, | ||||
| 		origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, | ||||
| 		username: { type: 'string', nullable: true, default: null }, | ||||
| 		hostname: { | ||||
| 			type: 'string', | ||||
|  | @ -61,7 +61,7 @@ export default define(meta, paramDef, async (ps, me) => { | |||
| 	} | ||||
| 
 | ||||
| 	if (ps.hostname) { | ||||
| 		query.andWhere('user.host like :hostname', { hostname: '%' + ps.hostname.toLowerCase() + '%' }); | ||||
| 		query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); | ||||
| 	} | ||||
| 
 | ||||
| 	switch (ps.sort) { | ||||
|  |  | |||
|  | @ -48,6 +48,10 @@ export const paramDef = { | |||
| 		enableRecaptcha: { type: 'boolean' }, | ||||
| 		recaptchaSiteKey: { 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 }, | ||||
| 		maintainerName: { 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; | ||||
| 	} | ||||
| 
 | ||||
| 	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) { | ||||
| 		set.proxyAccountId = ps.proxyAccountId; | ||||
| 	} | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import ms from 'ms'; | |||
| import { addFile } from '@/services/drive/add-file.js'; | ||||
| import { DriveFiles } from '@/models/index.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 define from '../../../define.js'; | ||||
| import { apiLogger } from '../../../logger.js'; | ||||
|  | @ -35,6 +36,18 @@ export const meta = { | |||
| 			code: 'INVALID_FILE_NAME', | ||||
| 			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; | ||||
| 
 | ||||
|  | @ -87,6 +100,10 @@ export default define(meta, paramDef, async (ps, user, _, file, cleanup, ip, hea | |||
| 		if (e instanceof Error || typeof e === 'string') { | ||||
| 			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(); | ||||
| 	} finally { | ||||
| 		cleanup!(); | ||||
|  |  | |||
|  | @ -1,8 +1,8 @@ | |||
| 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 { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; | ||||
| import define from '../../../define.js'; | ||||
| import { ApiError } from '../../../error.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['drive'], | ||||
|  |  | |||
|  | @ -3,17 +3,17 @@ import * as mfm from 'mfm-js'; | |||
| import { publishMainStream, publishUserEvent } from '@/services/stream.js'; | ||||
| import acceptAllFollowRequests from '@/services/following/requests/accept-all.js'; | ||||
| import { publishToFollowers } from '@/services/i/update.js'; | ||||
| import define from '../../define.js'; | ||||
| import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; | ||||
| import { extractHashtags } from '@/misc/extract-hashtags.js'; | ||||
| import { updateUsertags } from '@/services/update-hashtag.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| import { Users, DriveFiles, UserProfiles, Pages } from '@/models/index.js'; | ||||
| import { User } from '@/models/entities/user.js'; | ||||
| import { UserProfile } from '@/models/entities/user-profile.js'; | ||||
| import { notificationTypes } from '@/types.js'; | ||||
| import { normalizeForSearch } from '@/misc/normalize-for-search.js'; | ||||
| import { langmap } from '@/misc/langmap.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| import define from '../../define.js'; | ||||
| 
 | ||||
| export const meta = { | ||||
| 	tags: ['account'], | ||||
|  | @ -57,7 +57,7 @@ export const meta = { | |||
| 			message: 'Invalid Regular Expression.', | ||||
| 			code: 'INVALID_REGEXP', | ||||
| 			id: '0d786918-10df-41cd-8f33-8dec7d9a89a5', | ||||
| 		} | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	res: { | ||||
|  | @ -77,7 +77,8 @@ export const paramDef = { | |||
| 		lang: { type: 'string', enum: [null, ...Object.keys(langmap)], nullable: true }, | ||||
| 		avatarId: { type: 'string', format: 'misskey:id', nullable: true }, | ||||
| 		bannerId: { type: 'string', format: 'misskey:id', nullable: true }, | ||||
| 		fields: { type: 'array', | ||||
| 		fields: { | ||||
| 			type: 'array', | ||||
| 			minItems: 0, | ||||
| 			maxItems: 16, | ||||
| 			items: { | ||||
|  | @ -102,6 +103,7 @@ export const paramDef = { | |||
| 		injectFeaturedNote: { type: 'boolean' }, | ||||
| 		receiveAnnouncementEmail: { type: 'boolean' }, | ||||
| 		alwaysMarkNsfw: { type: 'boolean' }, | ||||
| 		autoSensitive: { type: 'boolean' }, | ||||
| 		ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, | ||||
| 		pinnedPageId: { type: 'array', items: { | ||||
| 			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.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; | ||||
| 	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.avatarId) { | ||||
|  |  | |||
|  | @ -102,29 +102,169 @@ | |||
| 		document.head.appendChild(style); | ||||
| 	} | ||||
| 
 | ||||
| 	// eslint-disable-next-line no-inner-declarations
 | ||||
| 	async function addStyle(styleText) { | ||||
| 		let css = document.createElement('style'); | ||||
| 		css.appendChild(document.createTextNode(styleText)); | ||||
| 		document.head.appendChild(css); | ||||
| 	} | ||||
| 
 | ||||
| 	function renderError(code, details) { | ||||
| 		let errorsElement = document.getElementById('errors'); | ||||
| 
 | ||||
| 		if (!errorsElement) { | ||||
| 			document.documentElement.innerHTML = ` | ||||
| 			<h1>⚠ An error has occurred. ⚠</h1> | ||||
| 			<p>If the problem persists, please contact the administrator. You may also try the following options:</p> | ||||
| 			<ul> | ||||
| 				<li>Start <a href="/cli">the simple client</a></li> | ||||
| 				<li>Attempt to repair in <a href="/bios">BIOS</a></li> | ||||
| 				<li><a href="/flush">Flush preferences and cache</a></li> | ||||
| 			</ul> | ||||
| 			<hr> | ||||
| 			<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alert-triangle" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> | ||||
|    			<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> | ||||
|    			<path d="M12 9v2m0 4v.01"></path> | ||||
|    			<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path> | ||||
| 			</svg> | ||||
| 			<h1>An error has occurred!</h1> | ||||
| 			<button class="button-big" onclick="location.reload(true);"> | ||||
| 				<span class="button-label-big">Refresh</span> | ||||
| 			</button> | ||||
|       <p class="dont-worry">Don't worry, it's (probably) not your fault.</p> | ||||
| 			<p>If the problem persists after refreshing, please contact your instance's administrator.<br>You may also try the following options:</p> | ||||
|       <a href="/flush"> | ||||
|       <button class="button-small"> | ||||
|         <span class="button-label-small">Clear preferences and cache</span> | ||||
|       </button> | ||||
|       </a> | ||||
| 			<br> | ||||
|       <a href="/cli"> | ||||
|         <button class="button-small"> | ||||
|           <span class="button-label-small">Start the simple client</span> | ||||
|         </button> | ||||
|       </a> | ||||
| 			<br> | ||||
|       <a href="/bios"> | ||||
|         <button class="button-small"> | ||||
|           <span class="button-label-small">Start the repair tool</span> | ||||
|         </button> | ||||
|       </a> | ||||
| 			<br> | ||||
| 			<div id="errors"></div> | ||||
| 			`;
 | ||||
| 
 | ||||
| 			errorsElement = document.getElementById('errors'); | ||||
| 		} | ||||
| 
 | ||||
| 		const detailsElement = document.createElement('details'); | ||||
| 		detailsElement.innerHTML = `<summary><code>ERROR CODE: ${code}</code></summary>${JSON.stringify(details)}`; | ||||
| 
 | ||||
| 		detailsElement.innerHTML = ` | ||||
| 		<br> | ||||
| 		<summary> | ||||
| 			<code>ERROR CODE: ${code}</code> | ||||
| 		</summary> | ||||
| 		<code>${JSON.stringify(details)}</code>`; | ||||
| 		errorsElement.appendChild(detailsElement); | ||||
| 		addStyle(` | ||||
| 		* { | ||||
| 			font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; | ||||
| 		} | ||||
| 
 | ||||
| 		#misskey_app, | ||||
| 		#splash { | ||||
| 			display: none !important; | ||||
| 		} | ||||
| 
 | ||||
| 		body, | ||||
| 		html { | ||||
| 			background-color: #222; | ||||
| 			color: #dfddcc; | ||||
| 			justify-content: center; | ||||
| 			margin: auto; | ||||
| 			padding: 10px; | ||||
| 			text-align: center; | ||||
| 		} | ||||
| 
 | ||||
| 		button { | ||||
| 			border-radius: 999px; | ||||
| 			padding: 0px 12px 0px 12px; | ||||
| 			border: none; | ||||
| 			cursor: pointer; | ||||
| 			margin-bottom: 12px; | ||||
| 		} | ||||
| 
 | ||||
| 		.button-big { | ||||
| 			background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); | ||||
| 			line-height: 50px; | ||||
| 		} | ||||
| 
 | ||||
| 		.button-big:hover { | ||||
| 			background: rgb(153, 204, 0); | ||||
| 		} | ||||
| 
 | ||||
| 		.button-small { | ||||
| 			background: #444; | ||||
| 			line-height: 40px; | ||||
| 		} | ||||
| 
 | ||||
| 		.button-small:hover { | ||||
| 			background: #555; | ||||
| 		} | ||||
| 
 | ||||
| 		.button-label-big { | ||||
| 			color: #222; | ||||
| 			font-weight: bold; | ||||
| 			font-size: 20px; | ||||
| 			padding: 12px; | ||||
| 		} | ||||
| 
 | ||||
| 		.button-label-small { | ||||
| 			color: rgb(153, 204, 0); | ||||
| 			font-size: 16px; | ||||
| 			padding: 12px; | ||||
| 		} | ||||
| 
 | ||||
| 		a { | ||||
| 			color: rgb(134, 179, 0); | ||||
| 			text-decoration: none; | ||||
| 		} | ||||
| 
 | ||||
| 		p, | ||||
| 		li { | ||||
| 			font-size: 16px; | ||||
| 		} | ||||
| 
 | ||||
| 		.dont-worry, | ||||
| 		#msg { | ||||
| 			font-size: 18px; | ||||
| 		} | ||||
| 
 | ||||
| 		.icon-warning { | ||||
| 			color: #dec340; | ||||
| 			height: 4rem; | ||||
| 			padding-top: 2rem; | ||||
| 		} | ||||
| 
 | ||||
| 		h1 { | ||||
| 			font-size: 32px; | ||||
| 		} | ||||
| 
 | ||||
| 		code { | ||||
| 			font-family: Fira, FiraCode, monospace; | ||||
| 		} | ||||
| 
 | ||||
| 		details { | ||||
| 			background: #333; | ||||
| 			margin-bottom: 2rem; | ||||
| 			padding: 0.5rem 1rem; | ||||
| 			width: 40rem; | ||||
| 			border-radius: 10px; | ||||
| 			justify-content: center; | ||||
| 			margin: auto; | ||||
| 		} | ||||
| 
 | ||||
| 		summary { | ||||
| 			cursor: pointer; | ||||
| 		} | ||||
| 
 | ||||
| 		summary > * { | ||||
| 			display: inline; | ||||
| 		} | ||||
| 
 | ||||
| 		@media screen and (max-width: 500px) { | ||||
| 			details { | ||||
| 				width: 50%; | ||||
| 			} | ||||
| 		`)
 | ||||
| 	} | ||||
| 
 | ||||
| 	// eslint-disable-next-line no-inner-declarations
 | ||||
|  |  | |||
							
								
								
									
										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 { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; | ||||
| import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; | ||||
| import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||
| import { getS3 } from './s3.js'; | ||||
| import { InternalStorage } from './internal-storage.js'; | ||||
| import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js'; | ||||
|  | @ -349,9 +350,31 @@ export async function addFile({ | |||
| 	requestIp = null, | ||||
| 	requestHeaders = null, | ||||
| }: 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)}`); | ||||
| 
 | ||||
| 	// 現状 false positive が多すぎて実用に耐えない
 | ||||
| 	//if (info.porn && instance.disallowUploadWhenPredictedAsPorn) {
 | ||||
| 	//	throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.');
 | ||||
| 	//}
 | ||||
| 
 | ||||
| 	// detect name
 | ||||
| 	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 + info.size > driveCapacity) { | ||||
| 			if (Users.isLocalUser(user)) { | ||||
| 				throw new Error('no-free-space'); | ||||
| 				throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); | ||||
| 			} else { | ||||
| 				// (アバターまたはバナーを含まず)最も古いファイルを削除する
 | ||||
| 				deleteOldFile(await Users.findOneByOrFail({ id: user.id }) as IRemoteUser); | ||||
|  | @ -441,6 +464,8 @@ export async function addFile({ | |||
| 	file.isLink = isLink; | ||||
| 	file.requestIp = requestIp; | ||||
| 	file.requestHeaders = requestHeaders; | ||||
| 	file.maybeSensitive = info.sensitive; | ||||
| 	file.maybePorn = info.porn; | ||||
| 	file.isSensitive = user | ||||
| 		? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : | ||||
| 		(sensitive !== null && sensitive !== undefined) | ||||
|  | @ -448,6 +473,9 @@ export async function addFile({ | |||
| 			: false | ||||
| 		: false; | ||||
| 
 | ||||
| 	if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; | ||||
| 	if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; | ||||
| 
 | ||||
| 	if (url !== null) { | ||||
| 		file.src = url; | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,9 +10,11 @@ const _dirname = dirname(_filename); | |||
| describe('Get file info', () => { | ||||
| 	it('Empty file', async (async () => { | ||||
| 		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.blurhash; | ||||
| 		delete info.sensitive; | ||||
| 		delete info.porn; | ||||
| 		assert.deepStrictEqual(info, { | ||||
| 			size: 0, | ||||
| 			md5: 'd41d8cd98f00b204e9800998ecf8427e', | ||||
|  | @ -28,9 +30,11 @@ describe('Get file info', () => { | |||
| 
 | ||||
| 	it('Generic JPEG', async (async () => { | ||||
| 		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.blurhash; | ||||
| 		delete info.sensitive; | ||||
| 		delete info.porn; | ||||
| 		assert.deepStrictEqual(info, { | ||||
| 			size: 25360, | ||||
| 			md5: '091b3f259662aa31e2ffef4519951168', | ||||
|  | @ -46,9 +50,11 @@ describe('Get file info', () => { | |||
| 
 | ||||
| 	it('Generic APNG', async (async () => { | ||||
| 		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.blurhash; | ||||
| 		delete info.sensitive; | ||||
| 		delete info.porn; | ||||
| 		assert.deepStrictEqual(info, { | ||||
| 			size: 1868, | ||||
| 			md5: '08189c607bea3b952704676bb3c979e0', | ||||
|  | @ -64,9 +70,11 @@ describe('Get file info', () => { | |||
| 
 | ||||
| 	it('Generic AGIF', async (async () => { | ||||
| 		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.blurhash; | ||||
| 		delete info.sensitive; | ||||
| 		delete info.porn; | ||||
| 		assert.deepStrictEqual(info, { | ||||
| 			size: 2248, | ||||
| 			md5: '32c47a11555675d9267aee1a86571e7e', | ||||
|  | @ -82,9 +90,11 @@ describe('Get file info', () => { | |||
| 
 | ||||
| 	it('PNG with alpha', async (async () => { | ||||
| 		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.blurhash; | ||||
| 		delete info.sensitive; | ||||
| 		delete info.porn; | ||||
| 		assert.deepStrictEqual(info, { | ||||
| 			size: 3772, | ||||
| 			md5: 'f73535c3e1e27508885b69b10cf6e991', | ||||
|  | @ -100,9 +110,11 @@ describe('Get file info', () => { | |||
| 
 | ||||
| 	it('Generic SVG', async (async () => { | ||||
| 		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.blurhash; | ||||
| 		delete info.sensitive; | ||||
| 		delete info.porn; | ||||
| 		assert.deepStrictEqual(info, { | ||||
| 			size: 505, | ||||
| 			md5: 'b6f52b4b021e7b92cdd04509c7267965', | ||||
|  | @ -119,9 +131,11 @@ describe('Get file info', () => { | |||
| 	it('SVG with XML definition', async (async () => { | ||||
| 		// https://github.com/misskey-dev/misskey/issues/4413
 | ||||
| 		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.blurhash; | ||||
| 		delete info.sensitive; | ||||
| 		delete info.porn; | ||||
| 		assert.deepStrictEqual(info, { | ||||
| 			size: 544, | ||||
| 			md5: '4b7a346cde9ccbeb267e812567e33397', | ||||
|  | @ -137,9 +151,11 @@ describe('Get file info', () => { | |||
| 
 | ||||
| 	it('Dimension limit', async (async () => { | ||||
| 		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.blurhash; | ||||
| 		delete info.sensitive; | ||||
| 		delete info.porn; | ||||
| 		assert.deepStrictEqual(info, { | ||||
| 			size: 75933, | ||||
| 			md5: '268c5dde99e17cf8fe09f1ab3f97df56', | ||||
|  | @ -155,9 +171,11 @@ describe('Get file info', () => { | |||
| 
 | ||||
| 	it('Rotate JPEG', async (async () => { | ||||
| 		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.blurhash; | ||||
| 		delete info.sensitive; | ||||
| 		delete info.porn; | ||||
| 		assert.deepStrictEqual(info, { | ||||
| 			size: 12624, | ||||
| 			md5: '68d5b2d8d1d1acbbce99203e3ec3857e', | ||||
|  |  | |||
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							|  | @ -15,7 +15,7 @@ | |||
| 		"@rollup/plugin-alias": "3.1.9", | ||||
| 		"@rollup/plugin-json": "4.1.0", | ||||
| 		"@syuilo/aiscript": "0.11.1", | ||||
| 		"@vitejs/plugin-vue": "3.0.0-beta.0", | ||||
| 		"@vitejs/plugin-vue": "3.0.0-beta.1", | ||||
| 		"@vue/compiler-sfc": "3.2.37", | ||||
| 		"abort-controller": "3.0.0", | ||||
| 		"autobind-decorator": "2.4.0", | ||||
|  | @ -74,7 +74,7 @@ | |||
| 		"uuid": "8.3.2", | ||||
| 		"v-debounce": "0.1.2", | ||||
| 		"vanilla-tilt": "1.7.2", | ||||
| 		"vite": "3.0.0-beta.6", | ||||
| 		"vite": "3.0.0-beta.7", | ||||
| 		"vue": "3.2.37", | ||||
| 		"vue-prism-editor": "2.0.0-alpha.2", | ||||
| 		"vuedraggable": "4.0.1", | ||||
|  |  | |||
|  | @ -198,7 +198,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { | |||
| 					height: 100%; | ||||
| 					background: var(--accent); | ||||
| 					opacity: 0.5; | ||||
| 					transition: width 0.2s cubic-bezier(0,0,0,1); | ||||
| 					//transition: width 0.2s cubic-bezier(0,0,0,1); | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
|  | @ -231,7 +231,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { | |||
| 				cursor: grab; | ||||
| 				background: var(--accent); | ||||
| 				border-radius: 999px; | ||||
| 				transition: left 0.2s cubic-bezier(0,0,0,1); | ||||
| 				//transition: left 0.2s cubic-bezier(0,0,0,1); | ||||
| 
 | ||||
| 				&:hover { | ||||
| 					background: var(--accentLighten); | ||||
|  |  | |||
|  | @ -154,7 +154,7 @@ function createDoughnut(chartEl, tooltip, data) { | |||
| } | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	os.apiGet('federation/stats', { limit: 20 }).then(fedStats => { | ||||
| 	os.apiGet('federation/stats', { limit: 30 }).then(fedStats => { | ||||
| 		createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ | ||||
| 			name: x.host, | ||||
| 			color: x.themeColor, | ||||
|  |  | |||
|  | @ -26,12 +26,7 @@ | |||
| 				<i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> | ||||
| 				<MkTime :time="note.createdAt"/> | ||||
| 			</button> | ||||
| 			<span v-if="note.visibility !== 'public'" class="visibility"> | ||||
| 				<i v-if="note.visibility === 'home'" class="fas fa-home"></i> | ||||
| 				<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> | ||||
| 				<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> | ||||
| 			</span> | ||||
| 			<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> | ||||
| 			<MkVisibility :note="note"/> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<article class="article" @contextmenu.stop="onContextmenu"> | ||||
|  | @ -43,12 +38,9 @@ | |||
| 						<MkUserName :user="appearNote.user"/> | ||||
| 					</MkA> | ||||
| 					<span v-if="appearNote.user.isBot" class="is-bot">bot</span> | ||||
| 					<span v-if="appearNote.visibility !== 'public'" class="visibility"> | ||||
| 						<i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i> | ||||
| 						<i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i> | ||||
| 						<i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i> | ||||
| 					</span> | ||||
| 					<span v-if="appearNote.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> | ||||
| 					<div class="info"> | ||||
| 						<MkVisibility :note="appearNote"/> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div class="username"><MkAcct :user="appearNote.user"/></div> | ||||
| 				<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> | ||||
|  | @ -134,6 +126,7 @@ import XPoll from './poll.vue'; | |||
| import XRenoteButton from './renote-button.vue'; | ||||
| import MkUrlPreview from '@/components/url-preview.vue'; | ||||
| import MkInstanceTicker from '@/components/instance-ticker.vue'; | ||||
| import MkVisibility from '@/components/visibility.vue'; | ||||
| import { pleaseLogin } from '@/scripts/please-login'; | ||||
| import { checkWordMute } from '@/scripts/check-word-mute'; | ||||
| import { userPage } from '@/filters/user'; | ||||
|  | @ -388,14 +381,6 @@ if (appearNote.replyId) { | |||
| 					margin-right: 4px; | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			> .visibility { | ||||
| 				margin-left: 8px; | ||||
| 			} | ||||
| 
 | ||||
| 			> .localOnly { | ||||
| 				margin-left: 8px; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
|  | @ -441,6 +426,10 @@ if (appearNote.replyId) { | |||
| 						border: solid 0.5px var(--divider); | ||||
| 						border-radius: 4px; | ||||
| 					} | ||||
| 
 | ||||
| 					> .info { | ||||
| 						float: right; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  |  | |||
|  | @ -9,12 +9,7 @@ | |||
| 		<MkA class="created-at" :to="notePage(note)"> | ||||
| 			<MkTime :time="note.createdAt"/> | ||||
| 		</MkA> | ||||
| 		<span v-if="note.visibility !== 'public'" class="visibility"> | ||||
| 			<i v-if="note.visibility === 'home'" class="fas fa-home"></i> | ||||
| 			<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> | ||||
| 			<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> | ||||
| 		</span> | ||||
| 		<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> | ||||
| 		<MkVisibility :note="note"/> | ||||
| 	</div> | ||||
| </header> | ||||
| </template> | ||||
|  | @ -22,6 +17,7 @@ | |||
| <script lang="ts" setup> | ||||
| import { } from 'vue'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import MkVisibility from '@/components/visibility.vue'; | ||||
| import { notePage } from '@/filters/note'; | ||||
| import { userPage } from '@/filters/user'; | ||||
| 
 | ||||
|  | @ -74,14 +70,6 @@ defineProps<{ | |||
| 		flex-shrink: 0; | ||||
| 		margin-left: auto; | ||||
| 		font-size: 0.9em; | ||||
| 
 | ||||
| 		> .visibility { | ||||
| 			margin-left: 8px; | ||||
| 		} | ||||
| 
 | ||||
| 		> .localOnly { | ||||
| 			margin-left: 8px; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -28,12 +28,7 @@ | |||
| 				<i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> | ||||
| 				<MkTime :time="note.createdAt"/> | ||||
| 			</button> | ||||
| 			<span v-if="note.visibility !== 'public'" class="visibility"> | ||||
| 				<i v-if="note.visibility === 'home'" class="fas fa-home"></i> | ||||
| 				<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> | ||||
| 				<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> | ||||
| 			</span> | ||||
| 			<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> | ||||
| 			<MkVisibility :note="note"/> | ||||
| 		</div> | ||||
| 	</div> | ||||
| 	<article class="article" @contextmenu.stop="onContextmenu"> | ||||
|  | @ -118,6 +113,7 @@ import XPoll from './poll.vue'; | |||
| import XRenoteButton from './renote-button.vue'; | ||||
| import MkUrlPreview from '@/components/url-preview.vue'; | ||||
| import MkInstanceTicker from '@/components/instance-ticker.vue'; | ||||
| import MkVisibility from '@/components/visibility.vue'; | ||||
| import { pleaseLogin } from '@/scripts/please-login'; | ||||
| import { focusPrev, focusNext } from '@/scripts/focus'; | ||||
| import { checkWordMute } from '@/scripts/check-word-mute'; | ||||
|  | @ -406,14 +402,6 @@ function readPromo() { | |||
| 					margin-right: 4px; | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			> .visibility { | ||||
| 				margin-left: 8px; | ||||
| 			} | ||||
| 
 | ||||
| 			> .localOnly { | ||||
| 				margin-left: 8px; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ | |||
| 
 | ||||
| <script lang="ts"> | ||||
| import { computed, defineComponent, ref } from 'vue'; | ||||
| import XDetails from '@/components/renote.details.vue'; | ||||
| import XDetails from '@/components/users-tooltip.vue'; | ||||
| import { pleaseLogin } from '@/scripts/please-login'; | ||||
| import * as os from '@/os'; | ||||
| import { $i } from '@/account'; | ||||
|  |  | |||
							
								
								
									
										47
									
								
								packages/client/src/components/visibility.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								packages/client/src/components/visibility.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,47 @@ | |||
| <template> | ||||
| <span v-if="note.visibility !== 'public'" :class="$style.visibility"> | ||||
| 	<i v-if="note.visibility === 'home'" class="fas fa-home"></i> | ||||
| 	<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> | ||||
| 	<i v-else-if="note.visibility === 'specified'" ref="specified" class="fas fa-envelope"></i> | ||||
| </span> | ||||
| <span v-if="note.localOnly" :class="$style.localOnly"><i class="fas fa-biohazard"></i></span> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
| import { ref } from 'vue'; | ||||
| import XDetails from '@/components/users-tooltip.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { useTooltip } from '@/scripts/use-tooltip'; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
| 	note: { | ||||
| 		visibility: string; | ||||
| 		localOnly?: boolean; | ||||
| 		visibleUserIds?: string[]; | ||||
| 	}, | ||||
| }>(); | ||||
| 
 | ||||
| const specified = $ref<HTMLElement>(); | ||||
| 
 | ||||
| if (props.note.visibility === 'specified') { | ||||
| 	useTooltip($$(specified), async (showing) => { | ||||
| 		const users = await os.api('users/show', { | ||||
| 			userIds: props.note.visibleUserIds, | ||||
| 			limit: 10, | ||||
| 		}); | ||||
| 
 | ||||
| 		os.popup(XDetails, { | ||||
| 			showing, | ||||
| 			users, | ||||
| 			count: props.note.visibleUserIds.length, | ||||
| 			targetElement: specified, | ||||
| 		}, {}, 'closed'); | ||||
| 	}); | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" module> | ||||
| .visibility, .localOnly { | ||||
| 	margin-left: 0.5em; | ||||
| } | ||||
| </style> | ||||
|  | @ -1,6 +1,5 @@ | |||
| import { computed, ref, reactive } from 'vue'; | ||||
| import { $i } from './account'; | ||||
| import { mainRouter } from '@/router'; | ||||
| import { search } from '@/scripts/search'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
|  | @ -55,26 +54,7 @@ export const menuDef = reactive({ | |||
| 		title: 'lists', | ||||
| 		icon: 'fas fa-list-ul', | ||||
| 		show: computed(() => $i != null), | ||||
| 		active: computed(() => mainRouter.currentRoute.value.path.startsWith('/timeline/list/') || mainRouter.currentRoute.value.path === '/my/lists' || mainRouter.currentRoute.value.path.startsWith('/my/lists/')), | ||||
| 		action: (ev) => { | ||||
| 			const items = ref([{ | ||||
| 				type: 'pending', | ||||
| 			}]); | ||||
| 			os.api('users/lists/list').then(lists => { | ||||
| 				const _items = [...lists.map(list => ({ | ||||
| 					type: 'link', | ||||
| 					text: list.name, | ||||
| 					to: `/timeline/list/${list.id}`, | ||||
| 				})), null, { | ||||
| 					type: 'link', | ||||
| 					to: '/my/lists', | ||||
| 					text: i18n.ts.manageLists, | ||||
| 					icon: 'fas fa-cog', | ||||
| 				}]; | ||||
| 				items.value = _items; | ||||
| 			}); | ||||
| 			os.popupMenu(items, ev.currentTarget ?? ev.target); | ||||
| 		}, | ||||
| 		to: '/my/lists', | ||||
| 	}, | ||||
| 	/* | ||||
| 	groups: { | ||||
|  | @ -88,26 +68,7 @@ export const menuDef = reactive({ | |||
| 		title: 'antennas', | ||||
| 		icon: 'fas fa-satellite', | ||||
| 		show: computed(() => $i != null), | ||||
| 		active: computed(() => mainRouter.currentRoute.value.path.startsWith('/timeline/antenna/') || mainRouter.currentRoute.value.path === '/my/antennas' || mainRouter.currentRoute.value.path.startsWith('/my/antennas/')), | ||||
| 		action: (ev) => { | ||||
| 			const items = ref([{ | ||||
| 				type: 'pending', | ||||
| 			}]); | ||||
| 			os.api('antennas/list').then(antennas => { | ||||
| 				const _items = [...antennas.map(antenna => ({ | ||||
| 					type: 'link', | ||||
| 					text: antenna.name, | ||||
| 					to: `/timeline/antenna/${antenna.id}`, | ||||
| 				})), null, { | ||||
| 					type: 'link', | ||||
| 					to: '/my/antennas', | ||||
| 					text: i18n.ts.manageAntennas, | ||||
| 					icon: 'fas fa-cog', | ||||
| 				}]; | ||||
| 				items.value = _items; | ||||
| 			}); | ||||
| 			os.popupMenu(items, ev.currentTarget ?? ev.target); | ||||
| 		}, | ||||
| 		to: '/my/antennas', | ||||
| 	}, | ||||
| 	favorites: { | ||||
| 		title: 'favorites', | ||||
|  |  | |||
|  | @ -14,6 +14,49 @@ | |||
| 					<XBotProtection/> | ||||
| 				</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"> | ||||
| 					<template #label>Log IP address</template> | ||||
| 					<template v-if="enableIpLogging" #suffix>Enabled</template> | ||||
|  | @ -49,10 +92,11 @@ import { } from 'vue'; | |||
| import XBotProtection from './bot-protection.vue'; | ||||
| import XHeader from './_header_.vue'; | ||||
| import FormFolder from '@/components/form/folder.vue'; | ||||
| import FormRadios from '@/components/form/radios.vue'; | ||||
| import FormSwitch from '@/components/form/switch.vue'; | ||||
| import FormInfo from '@/components/ui/info.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 FormButton from '@/components/ui/button.vue'; | ||||
| import * as os from '@/os'; | ||||
|  | @ -63,6 +107,10 @@ import { definePageMetadata } from '@/scripts/page-metadata'; | |||
| let summalyProxy: string = $ref(''); | ||||
| let enableHcaptcha: 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); | ||||
| 
 | ||||
| async function init() { | ||||
|  | @ -70,12 +118,31 @@ async function init() { | |||
| 	summalyProxy = meta.summalyProxy; | ||||
| 	enableHcaptcha = meta.enableHcaptcha; | ||||
| 	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; | ||||
| } | ||||
| 
 | ||||
| function save() { | ||||
| 	os.apiWithDialog('admin/update-meta', { | ||||
| 		summalyProxy, | ||||
| 		sensitiveMediaDetection, | ||||
| 		sensitiveMediaDetectionSensitivity: | ||||
| 			sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' : | ||||
| 			sensitiveMediaDetectionSensitivity === 1 ? 'low' : | ||||
| 			sensitiveMediaDetectionSensitivity === 2 ? 'medium' : | ||||
| 			sensitiveMediaDetectionSensitivity === 3 ? 'high' : | ||||
| 			sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' : | ||||
| 			0, | ||||
| 		setSensitiveFlagAutomatically, | ||||
| 		enableSensitiveMediaDetectionForVideos, | ||||
| 		enableIpLogging, | ||||
| 	}).then(() => { | ||||
| 		fetchInstance(); | ||||
|  |  | |||
|  | @ -1,34 +1,37 @@ | |||
| <template> | ||||
| <div> | ||||
| 	<MkPagination ref="paginationComponent" :pagination="pagination"> | ||||
| 		<template #empty> | ||||
| 			<div class="_fullinfo"> | ||||
| 				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> | ||||
| 				<div>{{ $ts.noFollowRequests }}</div> | ||||
| 			</div> | ||||
| 		</template> | ||||
| 		<template #default="{items}"> | ||||
| 			<div class="mk-follow-requests"> | ||||
| 				<div v-for="req in items" :key="req.id" class="user _panel"> | ||||
| 					<MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> | ||||
| 					<div class="body"> | ||||
| 						<div class="name"> | ||||
| 							<MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> | ||||
| 							<p class="acct">@{{ acct(req.follower) }}</p> | ||||
| 						</div> | ||||
| 						<div v-if="req.follower.description" class="description" :title="req.follower.description"> | ||||
| 							<Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> | ||||
| 						</div> | ||||
| 						<div class="actions"> | ||||
| 							<button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button> | ||||
| 							<button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader/></template> | ||||
| 	<MkSpacer :content-max="800"> | ||||
| 		<MkPagination ref="paginationComponent" :pagination="pagination"> | ||||
| 			<template #empty> | ||||
| 				<div class="_fullinfo"> | ||||
| 					<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> | ||||
| 					<div>{{ $ts.noFollowRequests }}</div> | ||||
| 				</div> | ||||
| 			</template> | ||||
| 			<template #default="{items}"> | ||||
| 				<div class="mk-follow-requests"> | ||||
| 					<div v-for="req in items" :key="req.id" class="user _panel"> | ||||
| 						<MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> | ||||
| 						<div class="body"> | ||||
| 							<div class="name"> | ||||
| 								<MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> | ||||
| 								<p class="acct">@{{ acct(req.follower) }}</p> | ||||
| 							</div> | ||||
| 							<div v-if="req.follower.description" class="description" :title="req.follower.description"> | ||||
| 								<Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> | ||||
| 							</div> | ||||
| 							<div class="actions"> | ||||
| 								<button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button> | ||||
| 								<button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</template> | ||||
| 	</MkPagination> | ||||
| </div> | ||||
| 			</template> | ||||
| 		</MkPagination> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts" setup> | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ | |||
| 				<template #label>Moderation</template> | ||||
| 				<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.stopActivityDelivery }}</FormSwitch> | ||||
| 				<FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ $ts.blockThisInstance }}</FormSwitch> | ||||
| 				<MkButton @click="refreshMetadata">Refresh metadata</MkButton> | ||||
| 				<MkButton @click="refreshMetadata"><i class="fas fa-refresh"></i> Refresh metadata</MkButton> | ||||
| 			</FormSection> | ||||
| 
 | ||||
| 			<FormSection> | ||||
|  | @ -56,8 +56,12 @@ | |||
| 	 | ||||
| 			<FormSection> | ||||
| 				<MkKeyValue oneline style="margin: 1em 0;"> | ||||
| 					<template #key>Open Registrations</template> | ||||
| 					<template #value>{{ instance.openRegistrations ? $ts.yes : $ts.no }}</template> | ||||
| 					<template #key>Following (Pub)</template> | ||||
| 					<template #value>{{ number(instance.followingCount) }}</template> | ||||
| 				</MkKeyValue> | ||||
| 				<MkKeyValue oneline style="margin: 1em 0;"> | ||||
| 					<template #key>Followers (Sub)</template> | ||||
| 					<template #value>{{ number(instance.followersCount) }}</template> | ||||
| 				</MkKeyValue> | ||||
| 			</FormSection> | ||||
| 
 | ||||
|  |  | |||
|  | @ -28,7 +28,17 @@ | |||
| 			<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> | ||||
| 			<template #suffixIcon><i class="fas fa-folder-open"></i></template> | ||||
| 		</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> | ||||
| </div> | ||||
| </template> | ||||
|  | @ -47,11 +57,14 @@ import { defaultStore } from '@/store'; | |||
| import MkChart from '@/components/chart.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { $i } from '@/account'; | ||||
| 
 | ||||
| const fetching = ref(true); | ||||
| const usage = ref<any>(null); | ||||
| const capacity = ref<any>(null); | ||||
| const uploadFolder = ref<any>(null); | ||||
| let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw); | ||||
| let autoSensitive = $ref($i.autoSensitive); | ||||
| 
 | ||||
| const meterStyle = computed(() => { | ||||
| 	return { | ||||
|  | @ -94,6 +107,13 @@ function chooseUploadFolder() { | |||
| 	}); | ||||
| } | ||||
| 
 | ||||
| function saveProfile() { | ||||
| 	os.api('i/update', { | ||||
| 		alwaysMarkNsfw: !!alwaysMarkNsfw, | ||||
| 		autoSensitive: !!autoSensitive, | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| const headerActions = $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.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.alwaysMarkNsfw" class="_formBlock">{{ i18n.ts.alwaysMarkSensitive }}</FormSwitch> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
|  | @ -88,7 +86,6 @@ const profile = reactive({ | |||
| 	isBot: $i.isBot, | ||||
| 	isCat: $i.isCat, | ||||
| 	showTimelineReplies: $i.showTimelineReplies, | ||||
| 	alwaysMarkNsfw: $i.alwaysMarkNsfw, | ||||
| }); | ||||
| 
 | ||||
| watch(() => profile, () => { | ||||
|  | @ -126,7 +123,6 @@ function save() { | |||
| 		isBot: !!profile.isBot, | ||||
| 		isCat: !!profile.isCat, | ||||
| 		showTimelineReplies: !!profile.showTimelineReplies, | ||||
| 		alwaysMarkNsfw: !!profile.alwaysMarkNsfw, | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -8,7 +8,7 @@ | |||
| 	</FormSelect> | ||||
| 
 | ||||
| 	<MkInput v-model="statusbar.name" manual-save class="_formBlock"> | ||||
| 		<template #label>Name</template> | ||||
| 		<template #label>{{ i18n.ts.label }}</template> | ||||
| 	</MkInput> | ||||
| 
 | ||||
| 	<MkSwitch v-model="statusbar.black" class="_formBlock"> | ||||
|  | @ -16,7 +16,7 @@ | |||
| 	</MkSwitch> | ||||
| 
 | ||||
| 	<FormRadios v-model="statusbar.size" class="_formBlock"> | ||||
| 		<template #label>Size</template> | ||||
| 		<template #label>{{ i18n.ts.size }}</template> | ||||
| 		<option value="verySmall">{{ i18n.ts.small }}+</option> | ||||
| 		<option value="small">{{ i18n.ts.small }}</option> | ||||
| 		<option value="medium">{{ i18n.ts.medium }}</option> | ||||
|  | @ -29,27 +29,29 @@ | |||
| 			<template #label>URL</template> | ||||
| 		</MkInput> | ||||
| 		<MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> | ||||
| 			<template #label>Refresh interval</template> | ||||
| 		</MkInput> | ||||
| 		<MkInput v-model="statusbar.props.marqueeDuration" manual-save class="_formBlock" type="number"> | ||||
| 			<template #label>Duration</template> | ||||
| 			<template #label>{{ i18n.ts.refreshInterval }}</template> | ||||
| 		</MkInput> | ||||
| 		<FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> | ||||
| 			<template #label>{{ i18n.ts.speed }}</template> | ||||
| 			<template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> | ||||
| 		</FormRange> | ||||
| 		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> | ||||
| 			<template #label>Reverse</template> | ||||
| 			<template #label>{{ i18n.ts.reverse }}</template> | ||||
| 		</MkSwitch> | ||||
| 	</template> | ||||
| 	<template v-else-if="statusbar.type === 'federation'"> | ||||
| 		<MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> | ||||
| 			<template #label>Refresh interval</template> | ||||
| 		</MkInput> | ||||
| 		<MkInput v-model="statusbar.props.marqueeDuration" manual-save class="_formBlock" type="number"> | ||||
| 			<template #label>Duration</template> | ||||
| 			<template #label>{{ i18n.ts.refreshInterval }}</template> | ||||
| 		</MkInput> | ||||
| 		<FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> | ||||
| 			<template #label>{{ i18n.ts.speed }}</template> | ||||
| 			<template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> | ||||
| 		</FormRange> | ||||
| 		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> | ||||
| 			<template #label>Reverse</template> | ||||
| 			<template #label>{{ i18n.ts.reverse }}</template> | ||||
| 		</MkSwitch> | ||||
| 		<MkSwitch v-model="statusbar.props.colored" class="_formBlock"> | ||||
| 			<template #label>Colored</template> | ||||
| 			<template #label>{{ i18n.ts.colored }}</template> | ||||
| 		</MkSwitch> | ||||
| 	</template> | ||||
| 	<template v-else-if="statusbar.type === 'userList' && userLists != null"> | ||||
|  | @ -58,18 +60,19 @@ | |||
| 			<option v-for="list in userLists" :value="list.id">{{ list.name }}</option> | ||||
| 		</FormSelect> | ||||
| 		<MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> | ||||
| 			<template #label>Refresh interval</template> | ||||
| 		</MkInput> | ||||
| 		<MkInput v-model="statusbar.props.marqueeDuration" manual-save class="_formBlock" type="number"> | ||||
| 			<template #label>Duration</template> | ||||
| 			<template #label>{{ i18n.ts.refreshInterval }}</template> | ||||
| 		</MkInput> | ||||
| 		<FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> | ||||
| 			<template #label>{{ i18n.ts.speed }}</template> | ||||
| 			<template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> | ||||
| 		</FormRange> | ||||
| 		<MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> | ||||
| 			<template #label>Reverse</template> | ||||
| 			<template #label>{{ i18n.ts.reverse }}</template> | ||||
| 		</MkSwitch> | ||||
| 	</template> | ||||
| 
 | ||||
| 	<div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> | ||||
| 		<FormButton danger @click="del">Delete</FormButton> | ||||
| 		<FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton> | ||||
| 	</div> | ||||
| </div> | ||||
| </template> | ||||
|  | @ -81,6 +84,7 @@ import MkInput from '@/components/form/input.vue'; | |||
| import MkSwitch from '@/components/form/switch.vue'; | ||||
| import FormRadios from '@/components/form/radios.vue'; | ||||
| import FormButton from '@/components/ui/button.vue'; | ||||
| import FormRange from '@/components/form/range.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { menuDef } from '@/menu'; | ||||
| import { defaultStore } from '@/store'; | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ | |||
| 		<template #suffix>{{ x.name }}</template> | ||||
| 		<XStatusbar :_id="x.id" :user-lists="userLists"/> | ||||
| 	</FormFolder> | ||||
| 	<FormButton @click="add">add</FormButton> | ||||
| 	<FormButton primary @click="add">{{ i18n.ts.add }}</FormButton> | ||||
| </div> | ||||
| </template> | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,9 +1,9 @@ | |||
| import { ref } from 'vue'; | ||||
| import { DriveFile } from 'misskey-js/built/entities'; | ||||
| import * as os from '@/os'; | ||||
| import { stream } from '@/stream'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { defaultStore } from '@/store'; | ||||
| import { DriveFile } from 'misskey-js/built/entities'; | ||||
| import { uploadFile } from '@/scripts/upload'; | ||||
| 
 | ||||
| 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 => { | ||||
| 					res(multiple ? driveFiles : driveFiles[0]); | ||||
| 				}).catch(err => { | ||||
| 					os.alert({ | ||||
| 						type: 'error', | ||||
| 						text: err | ||||
| 					}); | ||||
| 					// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
 | ||||
| 				}); | ||||
| 
 | ||||
| 				// 一応廃棄
 | ||||
|  | @ -47,7 +44,7 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv | |||
| 			os.inputText({ | ||||
| 				title: i18n.ts.uploadFromUrl, | ||||
| 				type: 'url', | ||||
| 				placeholder: i18n.ts.uploadFromUrlDescription | ||||
| 				placeholder: i18n.ts.uploadFromUrlDescription, | ||||
| 			}).then(({ canceled, result: url }) => { | ||||
| 				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', { | ||||
| 					url: url, | ||||
| 					folderId: defaultStore.state.uploadFolder, | ||||
| 					marker | ||||
| 					marker, | ||||
| 				}); | ||||
| 
 | ||||
| 				os.alert({ | ||||
| 					title: i18n.ts.uploadFromUrlRequested, | ||||
| 					text: i18n.ts.uploadFromUrlMayTakeTime | ||||
| 					text: i18n.ts.uploadFromUrlMayTakeTime, | ||||
| 				}); | ||||
| 			}); | ||||
| 		}; | ||||
| 
 | ||||
| 		os.popupMenu([label ? { | ||||
| 			text: label, | ||||
| 			type: 'label' | ||||
| 			type: 'label', | ||||
| 		} : undefined, { | ||||
| 			type: 'switch', | ||||
| 			text: i18n.ts.keepOriginalUploading, | ||||
| 			ref: keepOriginal | ||||
| 			ref: keepOriginal, | ||||
| 		}, { | ||||
| 			text: i18n.ts.upload, | ||||
| 			icon: 'fas fa-upload', | ||||
| 			action: chooseFileFromPc | ||||
| 			action: chooseFileFromPc, | ||||
| 		}, { | ||||
| 			text: i18n.ts.fromDrive, | ||||
| 			icon: 'fas fa-cloud', | ||||
| 			action: chooseFileFromDrive | ||||
| 			action: chooseFileFromDrive, | ||||
| 		}, { | ||||
| 			text: i18n.ts.fromUrl, | ||||
| 			icon: 'fas fa-link', | ||||
| 			action: chooseFileFromUrl | ||||
| 			action: chooseFileFromUrl, | ||||
| 		}], src); | ||||
| 	}); | ||||
| } | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ import { defaultStore } from '@/store'; | |||
| import { apiUrl } from '@/config'; | ||||
| import { $i } from '@/account'; | ||||
| import { alert } from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| 
 | ||||
| type Uploading = { | ||||
| 	id: string; | ||||
|  | @ -80,14 +81,37 @@ export function uploadFile( | |||
| 			xhr.open('POST', apiUrl + '/drive/files/create', true); | ||||
| 			xhr.onload = (ev) => { | ||||
| 				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { | ||||
| 					// TODO: 消すのではなくて再送できるようにしたい
 | ||||
| 					// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
 | ||||
| 					uploads.value = uploads.value.filter(x => x.id !== id); | ||||
| 
 | ||||
| 					alert({ | ||||
| 						type: 'error', | ||||
| 						title: 'Failed to upload', | ||||
| 						text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, | ||||
| 					}); | ||||
| 					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({ | ||||
| 							type: 'error', | ||||
| 							title: 'Failed to upload', | ||||
| 							text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, | ||||
| 						}); | ||||
| 					} | ||||
| 
 | ||||
| 					reject(); | ||||
| 					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 { | ||||
| 	> ._row { | ||||
| 		display: flex; | ||||
|  |  | |||
|  | @ -80,7 +80,7 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { | |||
| 	::v-deep(.item) { | ||||
| 		display: inline-block; | ||||
| 		vertical-align: bottom; | ||||
| 		margin-right: 3em; | ||||
| 		margin-right: 5em; | ||||
| 
 | ||||
| 		> .icon { | ||||
| 			display: inline-block; | ||||
|  |  | |||
|  | @ -79,7 +79,7 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { | |||
| 			display: inline-block; | ||||
| 			width: 0.5px; | ||||
| 			height: var(--height); | ||||
| 			margin: 0 2em; | ||||
| 			margin: 0 3em; | ||||
| 			background: currentColor; | ||||
| 			opacity: 0.3; | ||||
| 		} | ||||
|  |  | |||
|  | @ -104,7 +104,7 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { | |||
| 			display: inline-block; | ||||
| 			width: 0.5px; | ||||
| 			height: 16px; | ||||
| 			margin: 0 2em; | ||||
| 			margin: 0 3em; | ||||
| 			background: currentColor; | ||||
| 			opacity: 0; | ||||
| 		} | ||||
|  |  | |||
|  | @ -71,6 +71,10 @@ const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue') | |||
| 			padding: 0 var(--nameMargin); | ||||
| 			font-weight: bold; | ||||
| 			color: var(--accent); | ||||
| 
 | ||||
| 			&:empty { | ||||
| 				display: none; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		> .body { | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ | |||
| 		<XWidgets @mounted="attachSticky"/> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<button class="widgetButton _button" :class="{ show: true }" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> | ||||
| 	<button v-if="!isDesktop && !isMobile" class="widgetButton _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> | ||||
| 
 | ||||
| 	<div v-if="isMobile" class="buttons"> | ||||
| 		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> | ||||
|  | @ -249,7 +249,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null; | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| /* | ||||
| 	> .widgetButton { | ||||
| 		display: block; | ||||
| 		position: fixed; | ||||
|  | @ -262,18 +261,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null; | |||
| 		box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); | ||||
| 		font-size: 22px; | ||||
| 		background: var(--panel); | ||||
| 
 | ||||
| 		&.navHidden { | ||||
| 			display: none; | ||||
| 		} | ||||
| 
 | ||||
| 		@media (min-width: ($widgets-hide-threshold + 1px)) { | ||||
| 			display: none; | ||||
| 		} | ||||
| 	}*/ | ||||
| 
 | ||||
| 	> .widgetButton { | ||||
| 		display: none; | ||||
| 	} | ||||
| 
 | ||||
| 	> .widgetsDrawer-back { | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
| <MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-instance-cloud"> | ||||
| 	<div class=""> | ||||
| 		<MkTagCloud v-if="activeInstances"> | ||||
| 			<li v-for="instance in activeInstances"> | ||||
| 			<li v-for="instance in activeInstances" :key="instance.id"> | ||||
| 				<a @click.prevent="onInstanceClick(instance)"> | ||||
| 					<img style="width: 32px;" :src="instance.iconUrl"> | ||||
| 				</a> | ||||
|  |  | |||
|  | @ -600,10 +600,10 @@ | |||
|   resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" | ||||
|   integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== | ||||
| 
 | ||||
| "@vitejs/plugin-vue@3.0.0-beta.0": | ||||
|   version "3.0.0-beta.0" | ||||
|   resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-3.0.0-beta.0.tgz#092f4f50ee183818e252331833541dbdcae1b91d" | ||||
|   integrity sha512-t8os1QK1qpovpgYAJSOWYEu+Doy/DZRW1cNwMvUl0qo+Yv7D9a3cxo24oL01lbojcc9ABQhyvUP3BsvFNtriqg== | ||||
| "@vitejs/plugin-vue@3.0.0-beta.1": | ||||
|   version "3.0.0-beta.1" | ||||
|   resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-3.0.0-beta.1.tgz#65a6be6ed619955a5edea6115dedcfc5da4ed3f6" | ||||
|   integrity sha512-cPVQHIKZkVEQ8qW7+BlbTrGJXNpP2aMKzVhQdTnWK9u6cSDmVdZOXHmKPO2KVvrNpFXXS8R7hHBXMsSApA+XOA== | ||||
| 
 | ||||
| "@vue/compiler-core@3.2.37": | ||||
|   version "3.2.37" | ||||
|  | @ -4215,10 +4215,10 @@ verror@1.10.0: | |||
|     core-util-is "1.0.2" | ||||
|     extsprintf "^1.2.0" | ||||
| 
 | ||||
| vite@3.0.0-beta.6: | ||||
|   version "3.0.0-beta.6" | ||||
|   resolved "https://registry.yarnpkg.com/vite/-/vite-3.0.0-beta.6.tgz#dd54c304ce7ceca243be8a114f28c431bbc447a1" | ||||
|   integrity sha512-jAxxCGXs6oIO3dFh7gwDEP9RqFzYY+ULDWawS1dd3HfM4FCr8rkOnLljDoBBIDdTNM8M7pDzdoYSmpPEOJqyZQ== | ||||
| vite@3.0.0-beta.7: | ||||
|   version "3.0.0-beta.7" | ||||
|   resolved "https://registry.yarnpkg.com/vite/-/vite-3.0.0-beta.7.tgz#ded6483ef3b9b16dbe3a912a35accb9cc3498530" | ||||
|   integrity sha512-yjw154hB229qq5Bl6+/CJSTxC/yIDmDJbaAjE/pdracz3jytNEd2ovk5BvxgZT6+qPiUc2rRH3FgGqiZnweIFw== | ||||
|   dependencies: | ||||
|     esbuild "^0.14.47" | ||||
|     postcss "^8.4.14" | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue