From 12a3c6872f0a31c923bf0cd7c183cb8776d58dda Mon Sep 17 00:00:00 2001
From: tamaina <tamaina@hotmail.co.jp>
Date: Thu, 28 Apr 2022 11:14:03 +0900
Subject: [PATCH] =?UTF-8?q?enhance:=20=E3=83=89=E3=83=A9=E3=82=A4=E3=83=96?=
 =?UTF-8?q?=E3=81=AB=E7=94=BB=E5=83=8F=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?=
 =?UTF-8?q?=E3=82=92=E3=82=A2=E3=83=83=E3=83=97=E3=83=AD=E3=83=BC=E3=83=89?=
 =?UTF-8?q?=E3=81=99=E3=82=8B=E3=81=A8=E3=81=8D=E3=82=AA=E3=83=AA=E3=82=B8?=
 =?UTF-8?q?=E3=83=8A=E3=83=AB=E7=94=BB=E5=83=8F=E3=82=92=E7=A0=B4=E6=A3=84?=
 =?UTF-8?q?=E3=81=97=E3=81=A6webpublic=E3=81=AE=E3=81=BF=E4=BF=9D=E6=8C=81?=
 =?UTF-8?q?=E3=81=99=E3=82=8B=E3=82=AA=E3=83=97=E3=82=B7=E3=83=A7=E3=83=B3?=
 =?UTF-8?q?=20(#8216)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* Update packages/client/src/os.ts

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* メニューをComposition API化、switchアイテム追加
クライアントサイド画像圧縮の準備

* メニュー型定義を分離 (TypeScriptの型支援が効かないので)

* disabled

* make keepOriginal to follow setting value

* :v:

* fix

* fix

* :v:

* WEBP

* aaa

* :v:

* webp

* lazy load browser-image-resizer

* rename

* rename 2

* Fix

* clean up

* add comment

* clean up

* jpeg, pngにもどす

* fix

* fix name

* webpでなくする ただしサムネやプレビューはwebpのまま (テスト)

* 動画サムネイルはjpegに

* エラーハンドリング

* :v:

* v2.2.1-misskey-beta.2

* browser-image-resizer#v2.2.1-misskey.1

* :v:

* fix alert

* update browser-image-resizer to v2.2.1-misskey.2

* lockfile

Co-authored-by: mei23 <m@m544.net>
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>
---
 packages/backend/src/misc/populate-emojis.ts  |   2 +-
 .../src/server/file/send-drive-file.ts        |   8 +-
 .../backend/src/server/proxy/proxy-media.ts   |   8 +-
 .../backend/src/server/web/url-preview.ts     |   2 +-
 .../backend/src/services/drive/add-file.ts    |  29 +++--
 .../drive/generate-video-thumbnail.ts         |   1 +
 .../src/services/drive/image-processor.ts     |  28 +----
 packages/client/package.json                  |   1 +
 packages/client/src/components/drive.vue      |  10 +-
 packages/client/src/components/post-form.vue  |   3 +-
 packages/client/src/os.ts                     |  75 +-----------
 .../pages/messaging/messaging-room.form.vue   |   3 +-
 packages/client/src/scripts/select-file.ts    |   3 +-
 packages/client/src/scripts/upload.ts         | 114 ++++++++++++++++++
 packages/client/src/ui/_common_/common.vue    |   3 +-
 packages/client/src/ui/_common_/upload.vue    |   2 +-
 packages/client/yarn.lock                     |   4 +
 17 files changed, 169 insertions(+), 127 deletions(-)
 create mode 100644 packages/client/src/scripts/upload.ts

diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts
index 86f1356c31..6a185d09f6 100644
--- a/packages/backend/src/misc/populate-emojis.ts
+++ b/packages/backend/src/misc/populate-emojis.ts
@@ -63,7 +63,7 @@ export async function populateEmoji(emojiName: string, noteUserHost: string | nu
 
 	const isLocal = emoji.host == null;
 	const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため
-	const url = isLocal ? emojiUrl : `${config.url}/proxy/image.png?${query({ url: emojiUrl })}`;
+	const url = isLocal ? emojiUrl : `${config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`;
 
 	return {
 		name: emojiName,
diff --git a/packages/backend/src/server/file/send-drive-file.ts b/packages/backend/src/server/file/send-drive-file.ts
index 6bc220b362..027d078ce1 100644
--- a/packages/backend/src/server/file/send-drive-file.ts
+++ b/packages/backend/src/server/file/send-drive-file.ts
@@ -11,7 +11,7 @@ import { DriveFiles } from '@/models/index.js';
 import { InternalStorage } from '@/services/drive/internal-storage.js';
 import { downloadUrl } from '@/misc/download-url.js';
 import { detectType } from '@/misc/get-file-info.js';
-import { convertToJpeg, convertToPng, convertToPngOrJpeg } from '@/services/drive/image-processor.js';
+import { convertToWebp, convertToJpeg, convertToPng } from '@/services/drive/image-processor.js';
 import { GenerateVideoThumbnail } from '@/services/drive/generate-video-thumbnail.js';
 import { StatusError } from '@/misc/fetch.js';
 import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
@@ -64,10 +64,8 @@ export default async function(ctx: Koa.Context) {
 
 				const convertFile = async () => {
 					if (isThumbnail) {
-						if (['image/jpeg', 'image/webp'].includes(mime)) {
-							return await convertToJpeg(path, 498, 280);
-						} else if (['image/png', 'image/svg+xml'].includes(mime)) {
-							return await convertToPngOrJpeg(path, 498, 280);
+						if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(mime)) {
+							return await convertToWebp(path, 498, 280);
 						} else if (mime.startsWith('video/')) {
 							return await GenerateVideoThumbnail(path);
 						}
diff --git a/packages/backend/src/server/proxy/proxy-media.ts b/packages/backend/src/server/proxy/proxy-media.ts
index 3cc5b827a6..48887bf12f 100644
--- a/packages/backend/src/server/proxy/proxy-media.ts
+++ b/packages/backend/src/server/proxy/proxy-media.ts
@@ -1,7 +1,7 @@
 import * as fs from 'node:fs';
 import Koa from 'koa';
 import { serverLogger } from '../index.js';
-import { IImage, convertToPng, convertToJpeg } from '@/services/drive/image-processor.js';
+import { IImage, convertToWebp } from '@/services/drive/image-processor.js';
 import { createTemp } from '@/misc/create-temp.js';
 import { downloadUrl } from '@/misc/download-url.js';
 import { detectType } from '@/misc/get-file-info.js';
@@ -27,11 +27,11 @@ export async function proxyMedia(ctx: Koa.Context) {
 		let image: IImage;
 
 		if ('static' in ctx.query && ['image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/svg+xml'].includes(mime)) {
-			image = await convertToPng(path, 498, 280);
+			image = await convertToWebp(path, 498, 280);
 		} else if ('preview' in ctx.query && ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/svg+xml'].includes(mime)) {
-			image = await convertToJpeg(path, 200, 200);
+			image = await convertToWebp(path, 200, 200);
 		}	else if (['image/svg+xml'].includes(mime)) {
-			image = await convertToPng(path, 2048, 2048);
+			image = await convertToWebp(path, 2048, 2048, 1);
 		} else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) {
 			throw new StatusError('Rejected type', 403, 'Rejected type');
 		} else {
diff --git a/packages/backend/src/server/web/url-preview.ts b/packages/backend/src/server/web/url-preview.ts
index 6bd8ead5b5..1e259649f9 100644
--- a/packages/backend/src/server/web/url-preview.ts
+++ b/packages/backend/src/server/web/url-preview.ts
@@ -56,7 +56,7 @@ export const urlPreviewHandler = async (ctx: Koa.Context) => {
 function wrap(url?: string): string | null {
 	return url != null
 		? url.match(/^https?:\/\//)
-			? `${config.url}/proxy/preview.jpg?${query({
+			? `${config.url}/proxy/preview.webp?${query({
 				url,
 				preview: '1',
 			})}`
diff --git a/packages/backend/src/services/drive/add-file.ts b/packages/backend/src/services/drive/add-file.ts
index 549b11c9fe..cfbcb60ddf 100644
--- a/packages/backend/src/services/drive/add-file.ts
+++ b/packages/backend/src/services/drive/add-file.ts
@@ -7,7 +7,7 @@ import { deleteFile } from './delete-file.js';
 import { fetchMeta } from '@/misc/fetch-meta.js';
 import { GenerateVideoThumbnail } from './generate-video-thumbnail.js';
 import { driveLogger } from './logger.js';
-import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng, convertSharpToPngOrJpeg } from './image-processor.js';
+import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js';
 import { contentDisposition } from '@/misc/content-disposition.js';
 import { getFileInfo } from '@/misc/get-file-info.js';
 import { DriveFiles, DriveFolders, Users, Instances, UserProfiles } from '@/models/index.js';
@@ -179,6 +179,7 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
 	}
 
 	let img: sharp.Sharp | null = null;
+	let satisfyWebpublic: boolean;
 
 	try {
 		img = sharp(path);
@@ -192,6 +193,13 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
 				thumbnail: null,
 			};
 		}
+
+		satisfyWebpublic = !!(
+			type !== 'image/svg+xml' && type !== 'image/webp' &&
+			!(metadata.exif || metadata.iptc || metadata.xmp || metadata.tifftagPhotoshop) &&
+			metadata.width && metadata.width <= 2048 &&
+			metadata.height && metadata.height <= 2048
+		);
 	} catch (err) {
 		logger.warn(`sharp failed: ${err}`);
 		return {
@@ -203,15 +211,15 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
 	// #region webpublic
 	let webpublic: IImage | null = null;
 
-	if (generateWeb) {
+	if (generateWeb && !satisfyWebpublic) {
 		logger.info(`creating web image`);
 
 		try {
-			if (['image/jpeg'].includes(type)) {
+			if (['image/jpeg', 'image/webp'].includes(type)) {
 				webpublic = await convertSharpToJpeg(img, 2048, 2048);
-			} else if (['image/webp'].includes(type)) {
-				webpublic = await convertSharpToWebp(img, 2048, 2048);
-			} else if (['image/png', 'image/svg+xml'].includes(type)) {
+			} else if (['image/png'].includes(type)) {
+				webpublic = await convertSharpToPng(img, 2048, 2048);
+			} else if (['image/svg+xml'].includes(type)) {
 				webpublic = await convertSharpToPng(img, 2048, 2048);
 			} else {
 				logger.debug(`web image not created (not an required image)`);
@@ -220,7 +228,8 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
 			logger.warn(`web image not created (an error occured)`, err as Error);
 		}
 	} else {
-		logger.info(`web image not created (from remote)`);
+		if (satisfyWebpublic) logger.info(`web image not created (original satisfies webpublic)`);
+		else logger.info(`web image not created (from remote)`);
 	}
 	// #endregion webpublic
 
@@ -228,10 +237,8 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
 	let thumbnail: IImage | null = null;
 
 	try {
-		if (['image/jpeg', 'image/webp'].includes(type)) {
-			thumbnail = await convertSharpToJpeg(img, 498, 280);
-		} else if (['image/png', 'image/svg+xml'].includes(type)) {
-			thumbnail = await convertSharpToPngOrJpeg(img, 498, 280);
+		if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) {
+			thumbnail = await convertSharpToWebp(img, 498, 280);
 		} else {
 			logger.debug(`thumbnail not created (not an required file)`);
 		}
diff --git a/packages/backend/src/services/drive/generate-video-thumbnail.ts b/packages/backend/src/services/drive/generate-video-thumbnail.ts
index 04a7a83346..da93bc97c7 100644
--- a/packages/backend/src/services/drive/generate-video-thumbnail.ts
+++ b/packages/backend/src/services/drive/generate-video-thumbnail.ts
@@ -27,6 +27,7 @@ export async function GenerateVideoThumbnail(path: string): Promise<IImage> {
 
 	const outPath = `${outDir}/output.png`;
 
+	// JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる)
 	const thumbnail = await convertToJpeg(outPath, 498, 280);
 
 	// cleanup
diff --git a/packages/backend/src/services/drive/image-processor.ts b/packages/backend/src/services/drive/image-processor.ts
index 146dcfb6ca..2c564ea595 100644
--- a/packages/backend/src/services/drive/image-processor.ts
+++ b/packages/backend/src/services/drive/image-processor.ts
@@ -38,11 +38,11 @@ export async function convertSharpToJpeg(sharp: sharp.Sharp, width: number, heig
  * Convert to WebP
  *   with resize, remove metadata, resolve orientation, stop animation
  */
-export async function convertToWebp(path: string, width: number, height: number): Promise<IImage> {
-	return convertSharpToWebp(await sharp(path), width, height);
+export async function convertToWebp(path: string, width: number, height: number, quality: number = 85): Promise<IImage> {
+	return convertSharpToWebp(await sharp(path), width, height, quality);
 }
 
-export async function convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
+export async function convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, quality: number = 85): Promise<IImage> {
 	const data = await sharp
 		.resize(width, height, {
 			fit: 'inside',
@@ -50,7 +50,7 @@ export async function convertSharpToWebp(sharp: sharp.Sharp, width: number, heig
 		})
 		.rotate()
 		.webp({
-			quality: 85,
+			quality,
 		})
 		.toBuffer();
 
@@ -85,23 +85,3 @@ export async function convertSharpToPng(sharp: sharp.Sharp, width: number, heigh
 		type: 'image/png',
 	};
 }
-
-/**
- * Convert to PNG or JPEG
- *   with resize, remove metadata, resolve orientation, stop animation
- */
-export async function convertToPngOrJpeg(path: string, width: number, height: number): Promise<IImage> {
-	return convertSharpToPngOrJpeg(await sharp(path), width, height);
-}
-
-export async function convertSharpToPngOrJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
-	const stats = await sharp.stats();
-	const metadata = await sharp.metadata();
-
-	// 不透明で300x300pxの範囲を超えていればJPEG
-	if (stats.isOpaque && ((metadata.width && metadata.width >= 300) || (metadata.height && metadata!.height >= 300))) {
-		return await convertSharpToJpeg(sharp, width, height);
-	} else {
-		return await convertSharpToPng(sharp, width, height);
-	}
-}
diff --git a/packages/client/package.json b/packages/client/package.json
index 21093cdb7c..e533e1fb87 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -21,6 +21,7 @@
 		"autwh": "0.1.0",
 		"blurhash": "1.1.5",
 		"broadcast-channel": "4.11.0",
+		"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.2",
 		"chart.js": "3.7.1",
 		"chartjs-adapter-date-fns": "2.0.0",
 		"chartjs-plugin-gradient": "0.2.2",
diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue
index e044c67523..2ec885b00c 100644
--- a/packages/client/src/components/drive.vue
+++ b/packages/client/src/components/drive.vue
@@ -97,6 +97,7 @@ import * as os from '@/os';
 import { stream } from '@/stream';
 import { defaultStore } from '@/store';
 import { i18n } from '@/i18n';
+import { uploadFile, uploads } from '@/scripts/upload';
 
 const props = withDefaults(defineProps<{
 	initialFolder?: Misskey.entities.DriveFolder;
@@ -127,8 +128,9 @@ const moreFolders = ref(false);
 const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
 const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
 const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
-const uploadings = os.uploads;
+const uploadings = uploads;
 const connection = stream.useChannel('drive');
+const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい
 
 // ドロップされようとしているか
 const draghover = ref(false);
@@ -355,7 +357,7 @@ function onChangeFileInput() {
 }
 
 function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) {
-	os.upload(file, (folderToUpload && typeof folderToUpload == 'object') ? folderToUpload.id : null).then(res => {
+	uploadFile(file, (folderToUpload && typeof folderToUpload == 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => {
 		addFile(res, true);
 	});
 }
@@ -562,6 +564,10 @@ function fetchMoreFiles() {
 
 function getMenu() {
 	return [{
+		type: 'switch',
+		text: i18n.ts.keepOriginalUploading,
+		ref: keepOriginal,
+	}, null, {
 		text: i18n.ts.addFile,
 		type: 'label'
 	}, {
diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue
index 656689ddcb..241c726c11 100644
--- a/packages/client/src/components/post-form.vue
+++ b/packages/client/src/components/post-form.vue
@@ -87,6 +87,7 @@ import MkInfo from '@/components/ui/info.vue';
 import { i18n } from '@/i18n';
 import { instance } from '@/instance';
 import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
+import { uploadFile } from '@/scripts/upload';
 
 const modal = inject('modal');
 
@@ -372,7 +373,7 @@ function updateFileName(file, name) {
 }
 
 function upload(file: File, name?: string) {
-	os.upload(file, defaultStore.state.uploadFolder, name).then(res => {
+	uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
 		files.push(res);
 	});
 }
diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts
index 43c110555f..b8a3f94cc8 100644
--- a/packages/client/src/os.ts
+++ b/packages/client/src/os.ts
@@ -1,6 +1,6 @@
 // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
 
-import { Component, defineAsyncComponent, markRaw, reactive, Ref, ref } from 'vue';
+import { Component, markRaw, Ref, ref } from 'vue';
 import { EventEmitter } from 'eventemitter3';
 import insertTextAtCursor from 'insert-text-at-cursor';
 import * as Misskey from 'misskey-js';
@@ -10,7 +10,6 @@ import MkWaitingDialog from '@/components/waiting-dialog.vue';
 import { MenuItem } from '@/types/menu';
 import { resolve } from '@/router';
 import { $i } from '@/account';
-import { defaultStore } from '@/store';
 
 export const pendingApiRequestsCount = ref(0);
 
@@ -537,78 +536,6 @@ export function post(props: Record<string, any> = {}) {
 
 export const deckGlobalEvents = new EventEmitter();
 
-export const uploads = ref<{
-	id: string;
-	name: string;
-	progressMax: number | undefined;
-	progressValue: number | undefined;
-	img: string;
-}[]>([]);
-
-export function upload(file: File, folder?: any, name?: string, keepOriginal: boolean = defaultStore.state.keepOriginalUploading): Promise<Misskey.entities.DriveFile> {
-	if (folder && typeof folder === 'object') folder = folder.id;
-
-	return new Promise((resolve, reject) => {
-		const id = Math.random().toString();
-
-		const reader = new FileReader();
-		reader.onload = (e) => {
-			const ctx = reactive({
-				id: id,
-				name: name || file.name || 'untitled',
-				progressMax: undefined,
-				progressValue: undefined,
-				img: window.URL.createObjectURL(file)
-			});
-
-			uploads.value.push(ctx);
-
-			console.log(keepOriginal);
-
-			const data = new FormData();
-			data.append('i', $i.token);
-			data.append('force', 'true');
-			data.append('file', file);
-
-			if (folder) data.append('folderId', folder);
-			if (name) data.append('name', name);
-
-			const xhr = new XMLHttpRequest();
-			xhr.open('POST', apiUrl + '/drive/files/create', true);
-			xhr.onload = (ev) => {
-				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
-					// TODO: 消すのではなくて再送できるようにしたい
-					uploads.value = uploads.value.filter(x => x.id != id);
-
-					alert({
-						type: 'error',
-						text: 'upload failed'
-					});
-
-					reject();
-					return;
-				}
-
-				const driveFile = JSON.parse(ev.target.response);
-
-				resolve(driveFile);
-
-				uploads.value = uploads.value.filter(x => x.id != id);
-			};
-
-			xhr.upload.onprogress = e => {
-				if (e.lengthComputable) {
-					ctx.progressMax = e.total;
-					ctx.progressValue = e.loaded;
-				}
-			};
-
-			xhr.send(data);
-		};
-		reader.readAsArrayBuffer(file);
-	});
-}
-
 /*
 export function checkExistence(fileData: ArrayBuffer): Promise<any> {
 	return new Promise((resolve, reject) => {
diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue
index 3863c8f82b..35cb75743f 100644
--- a/packages/client/src/pages/messaging/messaging-room.form.vue
+++ b/packages/client/src/pages/messaging/messaging-room.form.vue
@@ -31,6 +31,7 @@ import * as os from '@/os';
 import { stream } from '@/stream';
 import { Autocomplete } from '@/scripts/autocomplete';
 import { throttle } from 'throttle-debounce';
+import { uploadFile } from '@/scripts/upload';
 
 export default defineComponent({
 	props: {
@@ -164,7 +165,7 @@ export default defineComponent({
 		},
 
 		upload(file: File, name?: string) {
-			os.upload(file, this.$store.state.uploadFolder, name).then(res => {
+			uploadFile(file, this.$store.state.uploadFolder, name).then(res => {
 				this.file = res;
 			});
 		},
diff --git a/packages/client/src/scripts/select-file.ts b/packages/client/src/scripts/select-file.ts
index 23df4edf54..49a46f0bb2 100644
--- a/packages/client/src/scripts/select-file.ts
+++ b/packages/client/src/scripts/select-file.ts
@@ -4,6 +4,7 @@ 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[]> {
 	return new Promise((res, rej) => {
@@ -14,7 +15,7 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv
 			input.type = 'file';
 			input.multiple = multiple;
 			input.onchange = () => {
-				const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
+				const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
 
 				Promise.all(promises).then(driveFiles => {
 					res(multiple ? driveFiles : driveFiles[0]);
diff --git a/packages/client/src/scripts/upload.ts b/packages/client/src/scripts/upload.ts
new file mode 100644
index 0000000000..7e4f793b44
--- /dev/null
+++ b/packages/client/src/scripts/upload.ts
@@ -0,0 +1,114 @@
+import { reactive, ref } from 'vue';
+import { defaultStore } from '@/store';
+import { apiUrl } from '@/config';
+import * as Misskey from 'misskey-js';
+import { $i } from '@/account';
+import { readAndCompressImage } from 'browser-image-resizer';
+import { alert } from '@/os';
+
+type Uploading = {
+	id: string;
+	name: string;
+	progressMax: number | undefined;
+	progressValue: number | undefined;
+	img: string;
+};
+export const uploads = ref<Uploading[]>([]);
+
+const compressTypeMap = {
+	'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
+	'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
+	'image/svg+xml': { quality: 1, mimeType: 'image/png' },
+} as const;
+
+const mimeTypeMap = {
+	'image/webp': 'webp',
+	'image/jpeg': 'jpg',
+	'image/png': 'png',
+} as const;
+
+export function uploadFile(
+	file: File,
+	folder?: any,
+	name?: string,
+	keepOriginal: boolean = defaultStore.state.keepOriginalUploading
+): Promise<Misskey.entities.DriveFile> {
+	if (folder && typeof folder == 'object') folder = folder.id;
+
+	return new Promise((resolve, reject) => {
+		const id = Math.random().toString();
+
+		const reader = new FileReader();
+		reader.onload = async (e) => {
+			const ctx = reactive<Uploading>({
+				id: id,
+				name: name || file.name || 'untitled',
+				progressMax: undefined,
+				progressValue: undefined,
+				img: window.URL.createObjectURL(file)
+			});
+
+			uploads.value.push(ctx);
+
+			let resizedImage: any;
+			if (!keepOriginal && file.type in compressTypeMap) {
+				const imgConfig = compressTypeMap[file.type];
+
+				const config = {
+					maxWidth: 2048,
+					maxHeight: 2048,
+					debug: true,
+					...imgConfig,
+				};
+
+				try {
+					resizedImage = await readAndCompressImage(file, config);
+					ctx.name = file.type !== imgConfig.mimeType ? `${ctx.name}.${mimeTypeMap[compressTypeMap[file.type].mimeType]}` : ctx.name;
+				} catch (e) {
+					console.error('Failed to resize image', e);
+				}
+			}
+
+			const data = new FormData();
+			data.append('i', $i.token);
+			data.append('force', 'true');
+			data.append('file', resizedImage || file);
+			data.append('name', ctx.name);
+			if (folder) data.append('folderId', folder);
+
+			const xhr = new XMLHttpRequest();
+			xhr.open('POST', apiUrl + '/drive/files/create', true);
+			xhr.onload = (ev) => {
+				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
+					// 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)}`
+					});
+
+					reject();
+					return;
+				}
+
+				const driveFile = JSON.parse(ev.target.response);
+
+				resolve(driveFile);
+
+				uploads.value = uploads.value.filter(x => x.id != id);
+			};
+
+			xhr.upload.onprogress = e => {
+				if (e.lengthComputable) {
+					ctx.progressMax = e.total;
+					ctx.progressValue = e.loaded;
+				}
+			};
+
+			xhr.send(data);
+		};
+		reader.readAsArrayBuffer(file);
+	});
+}
diff --git a/packages/client/src/ui/_common_/common.vue b/packages/client/src/ui/_common_/common.vue
index 05688d7c53..50d95539d1 100644
--- a/packages/client/src/ui/_common_/common.vue
+++ b/packages/client/src/ui/_common_/common.vue
@@ -17,7 +17,8 @@
 
 <script lang="ts">
 import { defineAsyncComponent, defineComponent } from 'vue';
-import { popup, popups, uploads, pendingApiRequestsCount } from '@/os';
+import { popup, popups, pendingApiRequestsCount } from '@/os';
+import { uploads } from '@/scripts/upload';
 import * as sound from '@/scripts/sound';
 import { $i } from '@/account';
 import { stream } from '@/stream';
diff --git a/packages/client/src/ui/_common_/upload.vue b/packages/client/src/ui/_common_/upload.vue
index ab7678a505..f3703d0e8f 100644
--- a/packages/client/src/ui/_common_/upload.vue
+++ b/packages/client/src/ui/_common_/upload.vue
@@ -20,8 +20,8 @@
 <script lang="ts" setup>
 import { } from 'vue';
 import * as os from '@/os';
+import { uploads } from '@/scripts/upload';
 
-const uploads = os.uploads;
 const zIndex = os.claimZIndex('high');
 </script>
 
diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock
index 05b586eb17..59abe67862 100644
--- a/packages/client/yarn.lock
+++ b/packages/client/yarn.lock
@@ -1299,6 +1299,10 @@ broadcast-channel@4.11.0:
     rimraf "3.0.2"
     unload "2.3.1"
 
+"browser-image-resizer@git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.2":
+  version "2.2.1-misskey.2"
+  resolved "git+https://github.com/misskey-dev/browser-image-resizer#a58834f5fe2af9f9f31ff115121aef3de6f9d416"
+
 browser-stdout@1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"