Upload 2260 files
Browse files- public/js/api.js +66 -18
- public/js/app.js +19 -5
- public/js/share.js +6 -3
public/js/api.js
CHANGED
|
@@ -16,24 +16,69 @@ const API = (() => {
|
|
| 16 |
}
|
| 17 |
const res = await fetch(base + url, { method, headers, body: payload });
|
| 18 |
if (!res.ok) {
|
| 19 |
-
let msg = '
|
| 20 |
-
try { msg = (await res.json()).error ||
|
| 21 |
-
|
| 22 |
-
// молча биться в стену, а отправляем обратно на вход
|
| 23 |
-
if (res.status === 401 && !url.startsWith('/webauthn/') && !url.startsWith('/auth/login') && !url.startsWith('/auth/register') && !url.startsWith('/share/')) {
|
| 24 |
-
sessionStorage.clear();
|
| 25 |
-
if (!location.pathname.endsWith('index.html') && !location.pathname.endsWith('share.html')) {
|
| 26 |
-
location.href = 'index.html?expired=1';
|
| 27 |
-
}
|
| 28 |
-
}
|
| 29 |
-
const err = new Error(msg);
|
| 30 |
-
err.status = res.status;
|
| 31 |
-
throw err;
|
| 32 |
}
|
| 33 |
const ct = res.headers.get('content-type') || '';
|
| 34 |
if (ct.includes('application/json')) return res.json();
|
| 35 |
return res.arrayBuffer();
|
| 36 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return {
|
| 38 |
register: (username, password) => req('POST', '/auth/register', { username, password }),
|
| 39 |
login: (username, password) => req('POST', '/auth/login', { username, password }),
|
|
@@ -44,9 +89,9 @@ const API = (() => {
|
|
| 44 |
deleteFolder: (id) => req('DELETE', `/folders/${id}`),
|
| 45 |
rubbish: () => req('GET', '/rubbish'),
|
| 46 |
emptyRubbish: () => req('POST', '/rubbish/empty', {}),
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
fileMeta: (id) => req('GET', `/files/${id}/meta`),
|
| 51 |
downloadFile: (id) => req('GET', `/files/${id}/download`),
|
| 52 |
patchFile: (id, body) => req('PATCH', `/files/${id}`, body),
|
|
@@ -59,8 +104,11 @@ const API = (() => {
|
|
| 59 |
shareMeta: (publicId) => req('GET', `/share/${publicId}/meta`),
|
| 60 |
shareUnlock: (publicId, verifier) => req('POST', `/share/${publicId}/unlock`, { verifier }),
|
| 61 |
// верификатор идёт заголовком, а не в URL: так он не попадёт в логи прокси
|
| 62 |
-
shareDownload: (publicId, verifier) =>
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
| 64 |
// WebAuthn / биометрия
|
| 65 |
webauthnRegisterOptions: () => req('POST', '/webauthn/register-options', {}),
|
| 66 |
webauthnRegisterVerify: (attestationResponse, deviceLabel) => req('POST', '/webauthn/register-verify', { attestationResponse, deviceLabel }),
|
|
|
|
| 16 |
}
|
| 17 |
const res = await fetch(base + url, { method, headers, body: payload });
|
| 18 |
if (!res.ok) {
|
| 19 |
+
let msg = '';
|
| 20 |
+
try { msg = (await res.json()).error || ''; } catch (e) {}
|
| 21 |
+
throw failure(res.status, url, msg);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
const ct = res.headers.get('content-type') || '';
|
| 24 |
if (ct.includes('application/json')) return res.json();
|
| 25 |
return res.arrayBuffer();
|
| 26 |
}
|
| 27 |
+
|
| 28 |
+
// Разбор неуспешного ответа, общий для fetch- и XHR-путей.
|
| 29 |
+
function failure(status, url, msg) {
|
| 30 |
+
// сессия истекла или недействительна — не оставляем пользователя
|
| 31 |
+
// молча биться в стену, а отправляем обратно на вход
|
| 32 |
+
if (status === 401 && !url.startsWith('/webauthn/') && !url.startsWith('/auth/login') && !url.startsWith('/auth/register') && !url.startsWith('/share/')) {
|
| 33 |
+
sessionStorage.clear();
|
| 34 |
+
if (!location.pathname.endsWith('index.html') && !location.pathname.endsWith('share.html')) {
|
| 35 |
+
location.href = 'index.html?expired=1';
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
const err = new Error(msg || 'Ошибка запроса');
|
| 39 |
+
err.status = status;
|
| 40 |
+
return err;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// fetch() не сообщает, сколько байт тела уже ушло на сервер, поэтому запросы
|
| 44 |
+
// с полосой прогресса идут через XHR — только ради событий progress.
|
| 45 |
+
// onUploadProgress/onDownloadProgress получают долю 0..1 и вызываются лишь
|
| 46 |
+
// когда известен полный размер; без них запрос ведёт себя как обычный.
|
| 47 |
+
function reqWithProgress(method, url, body, opts = {}) {
|
| 48 |
+
return new Promise((resolve, reject) => {
|
| 49 |
+
const xhr = new XMLHttpRequest();
|
| 50 |
+
xhr.open(method, base + url);
|
| 51 |
+
xhr.responseType = 'arraybuffer';
|
| 52 |
+
const t = token();
|
| 53 |
+
if (t) xhr.setRequestHeader('Authorization', 'Bearer ' + t);
|
| 54 |
+
for (const [k, v] of Object.entries(opts.headers || {})) xhr.setRequestHeader(k, v);
|
| 55 |
+
let payload = body;
|
| 56 |
+
if (body && !opts.form) {
|
| 57 |
+
xhr.setRequestHeader('Content-Type', 'application/json');
|
| 58 |
+
payload = JSON.stringify(body);
|
| 59 |
+
}
|
| 60 |
+
const track = (target, cb) => {
|
| 61 |
+
if (cb) target.onprogress = (e) => { if (e.lengthComputable && e.total) cb(e.loaded / e.total); };
|
| 62 |
+
};
|
| 63 |
+
track(xhr.upload, opts.onUploadProgress);
|
| 64 |
+
track(xhr, opts.onDownloadProgress);
|
| 65 |
+
xhr.onload = () => {
|
| 66 |
+
const text = () => new TextDecoder().decode(xhr.response || new ArrayBuffer(0));
|
| 67 |
+
if (xhr.status < 200 || xhr.status > 299) {
|
| 68 |
+
let msg = '';
|
| 69 |
+
try { msg = JSON.parse(text()).error || ''; } catch (e) {}
|
| 70 |
+
return reject(failure(xhr.status, url, msg));
|
| 71 |
+
}
|
| 72 |
+
if ((xhr.getResponseHeader('content-type') || '').includes('application/json')) {
|
| 73 |
+
try { return resolve(JSON.parse(text())); } catch (e) { return reject(new Error('Некорректный ответ сервера')); }
|
| 74 |
+
}
|
| 75 |
+
resolve(xhr.response);
|
| 76 |
+
};
|
| 77 |
+
xhr.onerror = () => reject(new Error('Нет связи с сервером'));
|
| 78 |
+
xhr.onabort = () => reject(new Error('Загрузка отменена'));
|
| 79 |
+
xhr.send(payload === undefined ? null : payload);
|
| 80 |
+
});
|
| 81 |
+
}
|
| 82 |
return {
|
| 83 |
register: (username, password) => req('POST', '/auth/register', { username, password }),
|
| 84 |
login: (username, password) => req('POST', '/auth/login', { username, password }),
|
|
|
|
| 89 |
deleteFolder: (id) => req('DELETE', `/folders/${id}`),
|
| 90 |
rubbish: () => req('GET', '/rubbish'),
|
| 91 |
emptyRubbish: () => req('POST', '/rubbish/empty', {}),
|
| 92 |
+
// onProgress получает долю отправленных байт (0..1)
|
| 93 |
+
uploadFile: (formData, onProgress) =>
|
| 94 |
+
reqWithProgress('POST', '/files/upload', formData, { form: true, onUploadProgress: onProgress }),
|
| 95 |
fileMeta: (id) => req('GET', `/files/${id}/meta`),
|
| 96 |
downloadFile: (id) => req('GET', `/files/${id}/download`),
|
| 97 |
patchFile: (id, body) => req('PATCH', `/files/${id}`, body),
|
|
|
|
| 104 |
shareMeta: (publicId) => req('GET', `/share/${publicId}/meta`),
|
| 105 |
shareUnlock: (publicId, verifier) => req('POST', `/share/${publicId}/unlock`, { verifier }),
|
| 106 |
// верификатор идёт заголовком, а не в URL: так он не попадёт в логи прокси
|
| 107 |
+
shareDownload: (publicId, verifier, onProgress) =>
|
| 108 |
+
reqWithProgress('GET', `/share/${publicId}/download`, undefined, {
|
| 109 |
+
headers: verifier ? { 'x-share-verifier': verifier } : {},
|
| 110 |
+
onDownloadProgress: onProgress,
|
| 111 |
+
}),
|
| 112 |
// WebAuthn / биометрия
|
| 113 |
webauthnRegisterOptions: () => req('POST', '/webauthn/register-options', {}),
|
| 114 |
webauthnRegisterVerify: (attestationResponse, deviceLabel) => req('POST', '/webauthn/register-verify', { attestationResponse, deviceLabel }),
|
public/js/app.js
CHANGED
|
@@ -1002,7 +1002,13 @@ async function handleUpload(fileList) {
|
|
| 1002 |
const fill = row.querySelector('.progress-fill');
|
| 1003 |
const pctEl = row.querySelector('.fname span:last-child');
|
| 1004 |
try {
|
| 1005 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1006 |
fill.style.width = '100%'; pctEl.innerHTML = icon('check', 'icon-sm');
|
| 1007 |
} catch (e) {
|
| 1008 |
pctEl.textContent = 'Ошибка';
|
|
@@ -1014,15 +1020,21 @@ async function handleUpload(fileList) {
|
|
| 1014 |
load(); refreshQuota();
|
| 1015 |
}
|
| 1016 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1017 |
async function uploadOne(file, onProgress) {
|
| 1018 |
const fileKey = await VLT.generateFileKey();
|
| 1019 |
const buffer = await file.arrayBuffer();
|
| 1020 |
-
onProgress(
|
| 1021 |
const { ciphertext, iv } = await VLT.encryptBuffer(fileKey, buffer);
|
| 1022 |
-
onProgress(45);
|
| 1023 |
const { data: attrsEncrypted, iv: attrsIv } = await VLT.encryptJson(fileKey, { name: file.name, type: file.type });
|
| 1024 |
const { keyWrapped, keyWrapIv } = await VLT.wrapFileKey(masterKey, fileKey);
|
| 1025 |
-
onProgress(
|
| 1026 |
|
| 1027 |
const form = new FormData();
|
| 1028 |
form.append('blob', new Blob([ciphertext]), 'blob');
|
|
@@ -1034,7 +1046,9 @@ async function uploadOne(file, onProgress) {
|
|
| 1034 |
form.append('keyWrapped', keyWrapped);
|
| 1035 |
form.append('keyWrapIv', keyWrapIv);
|
| 1036 |
|
| 1037 |
-
await API.uploadFile(form)
|
|
|
|
|
|
|
| 1038 |
onProgress(100);
|
| 1039 |
}
|
| 1040 |
|
|
|
|
| 1002 |
const fill = row.querySelector('.progress-fill');
|
| 1003 |
const pctEl = row.querySelector('.fname span:last-child');
|
| 1004 |
try {
|
| 1005 |
+
// события прогресса сыпятся часто — перерисовываем только на смене процента
|
| 1006 |
+
let shown = -1;
|
| 1007 |
+
await uploadOne(file, (pct) => {
|
| 1008 |
+
if (pct === shown) return;
|
| 1009 |
+
shown = pct;
|
| 1010 |
+
fill.style.width = pct + '%'; pctEl.textContent = pct + '%';
|
| 1011 |
+
});
|
| 1012 |
fill.style.width = '100%'; pctEl.innerHTML = icon('check', 'icon-sm');
|
| 1013 |
} catch (e) {
|
| 1014 |
pctEl.textContent = 'Ошибка';
|
|
|
|
| 1020 |
load(); refreshQuota();
|
| 1021 |
}
|
| 1022 |
|
| 1023 |
+
// Вехи полосы загрузки. Чтение и шифрование быстрые и от сети не зависят,
|
| 1024 |
+
// поэтому им отдано только начало шкалы; всё остальное — отправка, где прогресс
|
| 1025 |
+
// приходит от XHR по фактически ушедшим байтам. При отправке до 100% не
|
| 1026 |
+
// дотягиваем: последние проценты закрывает ответ сервера, иначе полоса
|
| 1027 |
+
// показывала бы «готово», пока сервер ещё принимает файл.
|
| 1028 |
+
const UP_READ = 4, UP_ENCRYPTED = 12, UP_SENT = 97;
|
| 1029 |
+
|
| 1030 |
async function uploadOne(file, onProgress) {
|
| 1031 |
const fileKey = await VLT.generateFileKey();
|
| 1032 |
const buffer = await file.arrayBuffer();
|
| 1033 |
+
onProgress(UP_READ);
|
| 1034 |
const { ciphertext, iv } = await VLT.encryptBuffer(fileKey, buffer);
|
|
|
|
| 1035 |
const { data: attrsEncrypted, iv: attrsIv } = await VLT.encryptJson(fileKey, { name: file.name, type: file.type });
|
| 1036 |
const { keyWrapped, keyWrapIv } = await VLT.wrapFileKey(masterKey, fileKey);
|
| 1037 |
+
onProgress(UP_ENCRYPTED);
|
| 1038 |
|
| 1039 |
const form = new FormData();
|
| 1040 |
form.append('blob', new Blob([ciphertext]), 'blob');
|
|
|
|
| 1046 |
form.append('keyWrapped', keyWrapped);
|
| 1047 |
form.append('keyWrapIv', keyWrapIv);
|
| 1048 |
|
| 1049 |
+
await API.uploadFile(form, (ratio) => {
|
| 1050 |
+
onProgress(UP_ENCRYPTED + Math.round(ratio * (UP_SENT - UP_ENCRYPTED)));
|
| 1051 |
+
});
|
| 1052 |
onProgress(100);
|
| 1053 |
}
|
| 1054 |
|
public/js/share.js
CHANGED
|
@@ -134,10 +134,13 @@ async function showFile(publicId, meta, fileKey) {
|
|
| 134 |
const progLine = document.getElementById('progLine');
|
| 135 |
const progFill = document.getElementById('progFill');
|
| 136 |
btn.disabled = true; btn.innerHTML = `${icon('download', 'icon-sm')} Скачивание…`;
|
| 137 |
-
progLine.style.display = 'block'; progFill.style.width = '
|
| 138 |
try {
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
const plain = await VLT.decryptBuffer(fileKey, VLT.b64.toBuf(meta.contentIv), buf);
|
| 142 |
progFill.style.width = '100%';
|
| 143 |
const blob = new Blob([plain], { type: attrs.type || 'application/octet-stream' });
|
|
|
|
| 134 |
const progLine = document.getElementById('progLine');
|
| 135 |
const progFill = document.getElementById('progFill');
|
| 136 |
btn.disabled = true; btn.innerHTML = `${icon('download', 'icon-sm')} Скачивание…`;
|
| 137 |
+
progLine.style.display = 'block'; progFill.style.width = '0%';
|
| 138 |
try {
|
| 139 |
+
// полоса идёт по фактически полученным байтам; остаток шкалы оставлен
|
| 140 |
+
// на расшифровку, она начинается только после полной загрузки
|
| 141 |
+
const buf = await API.shareDownload(publicId, shareVerifier, (ratio) => {
|
| 142 |
+
progFill.style.width = (ratio * 92).toFixed(1) + '%';
|
| 143 |
+
});
|
| 144 |
const plain = await VLT.decryptBuffer(fileKey, VLT.b64.toBuf(meta.contentIv), buf);
|
| 145 |
progFill.style.width = '100%';
|
| 146 |
const blob = new Blob([plain], { type: attrs.type || 'application/octet-stream' });
|