315 lines
9.2 KiB
TypeScript
315 lines
9.2 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
import { defineAsyncComponent } from 'vue';
|
|
import * as Misskey from 'misskey-js';
|
|
import { apiUrl } from '@@/js/config.js';
|
|
import type { UploaderFeatures } from '@/composables/use-uploader.js';
|
|
import * as os from '@/os.js';
|
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
|
import { useStream } from '@/stream.js';
|
|
import { i18n } from '@/i18n.js';
|
|
import { prefer } from '@/preferences.js';
|
|
import { $i } from '@/i.js';
|
|
import { instance } from '@/instance.js';
|
|
import { globalEvents } from '@/events.js';
|
|
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
|
|
import { genId } from '@/utility/id.js';
|
|
|
|
type UploadReturnType = {
|
|
filePromise: Promise<Misskey.entities.DriveFile>;
|
|
abort: () => void;
|
|
};
|
|
|
|
export class UploadAbortedError extends Error {
|
|
constructor() {
|
|
super('Upload aborted');
|
|
}
|
|
}
|
|
|
|
export function uploadFile(file: File | Blob, options: {
|
|
name?: string;
|
|
folderId?: string | null;
|
|
isSensitive?: boolean;
|
|
onProgress?: (ctx: { total: number; loaded: number; }) => void;
|
|
} = {}): UploadReturnType {
|
|
const xhr = new XMLHttpRequest();
|
|
const abortController = new AbortController();
|
|
const { signal } = abortController;
|
|
|
|
const filePromise = new Promise<Misskey.entities.DriveFile>((resolve, reject) => {
|
|
if ($i == null) return reject();
|
|
|
|
// こっち側で検出するMIME typeとサーバーで検出するMIME typeは異なる場合があるため、こっち側ではやらないことにする
|
|
// https://github.com/misskey-dev/misskey/issues/16091
|
|
//const allowedMimeTypes = $i.policies.uploadableFileTypes;
|
|
//const isAllowedMimeType = allowedMimeTypes.some(mimeType => {
|
|
// if (mimeType === '*' || mimeType === '*/*') return true;
|
|
// if (mimeType.endsWith('/*')) return file.type.startsWith(mimeType.slice(0, -1));
|
|
// return file.type === mimeType;
|
|
//});
|
|
//if (!isAllowedMimeType) {
|
|
// os.alert({
|
|
// type: 'error',
|
|
// title: i18n.ts.failedToUpload,
|
|
// text: i18n.ts.cannotUploadBecauseUnallowedFileType,
|
|
// });
|
|
// return reject();
|
|
//}
|
|
|
|
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
|
|
os.alert({
|
|
type: 'error',
|
|
title: i18n.ts.failedToUpload,
|
|
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
|
|
});
|
|
return reject();
|
|
}
|
|
|
|
signal.addEventListener('abort', () => {
|
|
reject(new UploadAbortedError());
|
|
}, { once: true });
|
|
|
|
xhr.open('POST', apiUrl + '/drive/files/create', true);
|
|
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
|
|
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
|
|
if (xhr.status === 413) {
|
|
os.alert({
|
|
type: 'error',
|
|
title: i18n.ts.failedToUpload,
|
|
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
|
|
});
|
|
} else if (ev.target?.response) {
|
|
const res = JSON.parse(ev.target.response);
|
|
if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
|
|
os.alert({
|
|
type: 'error',
|
|
title: i18n.ts.failedToUpload,
|
|
text: i18n.ts.cannotUploadBecauseInappropriate,
|
|
});
|
|
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
|
|
os.alert({
|
|
type: 'error',
|
|
title: i18n.ts.failedToUpload,
|
|
text: i18n.ts.cannotUploadBecauseNoFreeSpace,
|
|
});
|
|
} else if (res.error?.id === '4becd248-7f2c-48c4-a9f0-75edc4f9a1ea') {
|
|
os.alert({
|
|
type: 'error',
|
|
title: i18n.ts.failedToUpload,
|
|
text: i18n.ts.cannotUploadBecauseUnallowedFileType,
|
|
});
|
|
} else {
|
|
os.alert({
|
|
type: 'error',
|
|
title: i18n.ts.failedToUpload,
|
|
text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
|
|
});
|
|
}
|
|
} else {
|
|
os.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);
|
|
globalEvents.emit('driveFileCreated', driveFile);
|
|
resolve(driveFile);
|
|
}) as (ev: ProgressEvent<EventTarget>) => any;
|
|
|
|
if (options.onProgress) {
|
|
xhr.upload.onprogress = ev => {
|
|
if (ev.lengthComputable && options.onProgress != null) {
|
|
options.onProgress({
|
|
total: ev.total,
|
|
loaded: ev.loaded,
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('i', $i.token);
|
|
formData.append('force', 'true');
|
|
formData.append('file', file);
|
|
formData.append('name', options.name ?? (file instanceof File ? file.name : 'untitled'));
|
|
formData.append('isSensitive', options.isSensitive ? 'true' : 'false');
|
|
if (options.folderId) formData.append('folderId', options.folderId);
|
|
|
|
xhr.send(formData);
|
|
});
|
|
|
|
const abort = () => {
|
|
xhr.abort();
|
|
abortController.abort();
|
|
};
|
|
|
|
return { filePromise, abort };
|
|
}
|
|
|
|
export function chooseFileFromPcAndUpload(
|
|
options: {
|
|
multiple?: boolean;
|
|
features?: UploaderFeatures;
|
|
folderId?: string | null;
|
|
} = {},
|
|
): Promise<Misskey.entities.DriveFile[]> {
|
|
return new Promise((res, rej) => {
|
|
os.chooseFileFromPc({ multiple: options.multiple }).then(files => {
|
|
if (files.length === 0) return;
|
|
os.launchUploader(files, {
|
|
folderId: options.folderId,
|
|
features: options.features,
|
|
}).then(driveFiles => {
|
|
res(driveFiles);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
export function chooseDriveFile(options: {
|
|
multiple?: boolean;
|
|
} = {}): Promise<Misskey.entities.DriveFile[]> {
|
|
return new Promise(async resolve => {
|
|
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveFileSelectDialog.vue').then(x => x.default), {
|
|
multiple: options.multiple ?? false,
|
|
}, {
|
|
done: files => {
|
|
if (files) {
|
|
resolve(files);
|
|
}
|
|
},
|
|
closed: () => dispose(),
|
|
});
|
|
});
|
|
}
|
|
|
|
export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> {
|
|
return new Promise((res, rej) => {
|
|
os.inputText({
|
|
title: i18n.ts.uploadFromUrl,
|
|
type: 'url',
|
|
placeholder: i18n.ts.uploadFromUrlDescription,
|
|
}).then(({ canceled, result: url }) => {
|
|
if (canceled || url == null) return;
|
|
|
|
const marker = genId();
|
|
|
|
// TODO: no websocketモード対応
|
|
const connection = useStream().useChannel('main');
|
|
connection.on('urlUploadFinished', urlResponse => {
|
|
if (urlResponse.marker === marker) {
|
|
res(urlResponse.file);
|
|
connection.dispose();
|
|
}
|
|
});
|
|
|
|
misskeyApi('drive/files/upload-from-url', {
|
|
url: url,
|
|
folderId: prefer.s.uploadFolder,
|
|
marker,
|
|
});
|
|
|
|
os.alert({
|
|
title: i18n.ts.uploadFromUrlRequested,
|
|
text: i18n.ts.uploadFromUrlMayTakeTime,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function select(anchorElement: HTMLElement | EventTarget | null, label: string | null, multiple: boolean, features?: UploaderDialogFeatures): Promise<Misskey.entities.DriveFile[]> {
|
|
return new Promise((res, rej) => {
|
|
os.popupMenu([label ? {
|
|
text: label,
|
|
type: 'label',
|
|
} : undefined, {
|
|
text: i18n.ts.upload,
|
|
icon: 'ti ti-upload',
|
|
action: () => chooseFileFromPcAndUpload({ multiple, features }).then(files => res(files)),
|
|
}, {
|
|
text: i18n.ts.fromDrive,
|
|
icon: 'ti ti-cloud',
|
|
action: () => chooseDriveFile({ multiple }).then(files => res(files)),
|
|
}, {
|
|
text: i18n.ts.fromUrl,
|
|
icon: 'ti ti-link',
|
|
action: () => chooseFileFromUrl().then(file => res([file])),
|
|
}], anchorElement);
|
|
});
|
|
}
|
|
|
|
type SelectFileOptions<M extends boolean> = {
|
|
anchorElement: HTMLElement | EventTarget | null;
|
|
multiple: M;
|
|
label?: string | null;
|
|
features?: UploaderDialogFeatures;
|
|
};
|
|
|
|
export async function selectFile<
|
|
M extends boolean,
|
|
MR extends M extends true ? Misskey.entities.DriveFile[] : Misskey.entities.DriveFile,
|
|
>(opts: SelectFileOptions<M>): Promise<MR> {
|
|
const files = await select(opts.anchorElement, opts.label ?? null, opts.multiple ?? false, opts.features);
|
|
return opts.multiple ? (files as MR) : (files[0]! as MR);
|
|
}
|
|
|
|
export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: {
|
|
aspectRatio: number | null;
|
|
}): Promise<Misskey.entities.DriveFile> {
|
|
return new Promise((resolve, reject) => {
|
|
const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true);
|
|
const image = new Image();
|
|
image.src = imgUrl;
|
|
image.onload = () => {
|
|
const canvas = window.document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d')!;
|
|
canvas.width = image.width;
|
|
canvas.height = image.height;
|
|
ctx.drawImage(image, 0, 0);
|
|
canvas.toBlob(blob => {
|
|
if (blob == null) {
|
|
reject();
|
|
return;
|
|
}
|
|
|
|
os.cropImageFile(blob, {
|
|
aspectRatio: options.aspectRatio,
|
|
}).then(croppedImageFile => {
|
|
const { filePromise } = uploadFile(croppedImageFile, {
|
|
name: imageDriveFile.name,
|
|
folderId: imageDriveFile.folderId,
|
|
});
|
|
|
|
filePromise.then(driveFile => {
|
|
resolve(driveFile);
|
|
});
|
|
});
|
|
});
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<Misskey.entities.DriveFolder[]> {
|
|
return new Promise(async resolve => {
|
|
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveFolderSelectDialog.vue').then(x => x.default), {
|
|
initialFolder,
|
|
}, {
|
|
done: folders => {
|
|
if (folders) {
|
|
resolve(folders);
|
|
}
|
|
},
|
|
closed: () => dispose(),
|
|
});
|
|
});
|
|
}
|