Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults
This commit is contained in:
@@ -28,9 +28,10 @@ let socket;
|
||||
let reauthorizing;
|
||||
const drafts = new Map();
|
||||
const completing = new Set();
|
||||
export async function api(path, method = 'GET', body, key) {
|
||||
export async function api(path, method = 'GET', body, key, signal) {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method,
|
||||
signal,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
@@ -40,7 +41,11 @@ export async function api(path, method = 'GET', body, key) {
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message ?? '요청을 처리하지 못했습니다.');
|
||||
if (!response.ok)
|
||||
throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), {
|
||||
status: response.status,
|
||||
code: payload.code,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
export async function bootstrap() {
|
||||
@@ -68,6 +73,10 @@ function connect() {
|
||||
connected.value = false;
|
||||
});
|
||||
socket.on('inbox:updated', () => window.dispatchEvent(new Event('hop-account-updated')));
|
||||
socket.on('receipt:skipped', (event) => {
|
||||
if (event.accountId === account.value?.id)
|
||||
window.dispatchEvent(new Event('hop-receipt-skipped'));
|
||||
});
|
||||
socket.on('account:revoked', () => {
|
||||
clearAccountUploads(account.value?.id);
|
||||
account.value = null;
|
||||
@@ -232,73 +241,156 @@ export async function sendFiles(targetDeviceId, files) {
|
||||
await refresh();
|
||||
return requestId;
|
||||
}
|
||||
export function uploadFile(id, index, file, accountId) {
|
||||
const progressKey = `${id}:${index}`;
|
||||
const chunkBytes = 8 * 1024 * 1024;
|
||||
async function fileFingerprint(file, signal) {
|
||||
const digests = [];
|
||||
for (let offset = 0; offset < file.size; offset += chunkBytes) {
|
||||
signal.throwIfAborted();
|
||||
const bytes = await file.slice(offset, offset + chunkBytes).arrayBuffer();
|
||||
digests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const combined = new Uint8Array(digests.length * 32);
|
||||
digests.forEach((digest, index) => combined.set(digest, index * 32));
|
||||
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', combined))]
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
export async function uploadFile(id, index, file, accountId) {
|
||||
const progressKey = `${id}:${index}`,
|
||||
controller = new AbortController();
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
accountId,
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
uploadRequests.set(progressKey, xhr);
|
||||
xhr.open('POST', `/api/uploads?requestId=${id}&fileIndex=${index}`);
|
||||
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
|
||||
xhr.setRequestHeader('Idempotency-Key', crypto.randomUUID());
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable)
|
||||
uploadProgress.value[progressKey].progress = Math.min(
|
||||
100,
|
||||
Math.round((e.loaded / e.total) * 100),
|
||||
);
|
||||
};
|
||||
const finish = () => uploadRequests.delete(progressKey);
|
||||
xhr.onload = () => {
|
||||
finish();
|
||||
if (accountId && account.value?.id !== accountId) {
|
||||
delete uploadProgress.value[progressKey];
|
||||
reject(Error('계정 세션이 종료되었습니다.'));
|
||||
return;
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
uploadProgress.value[progressKey] = { name: file.name, progress: 100, status: 'stored' };
|
||||
resolve();
|
||||
} else {
|
||||
let message = '업로드하지 못했습니다.';
|
||||
uploadRequests.set(progressKey, controller);
|
||||
const check = () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (cancelledTransfers.has(id)) throw Error('전송이 취소되었습니다.');
|
||||
if (accountId && account.value?.id !== accountId) throw Error('계정 세션이 종료되었습니다.');
|
||||
};
|
||||
const sendChunk = (uploadId, offset) =>
|
||||
new Promise((resolve, reject) => {
|
||||
check();
|
||||
const xhr = new XMLHttpRequest();
|
||||
const abort = () => xhr.abort();
|
||||
const finish = () => controller.signal.removeEventListener('abort', abort);
|
||||
controller.signal.addEventListener('abort', abort, { once: true });
|
||||
xhr.open('PUT', `/api/uploads/resumable/${uploadId}`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
|
||||
xhr.setRequestHeader('Upload-Offset', String(offset));
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (uploadProgress.value[progressKey])
|
||||
uploadProgress.value[progressKey].progress = Math.min(
|
||||
99,
|
||||
Math.round(((offset + event.loaded) / Math.max(1, file.size)) * 100),
|
||||
);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
finish();
|
||||
let payload;
|
||||
try {
|
||||
message = JSON.parse(xhr.responseText).message;
|
||||
} catch {}
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
accountId,
|
||||
message,
|
||||
};
|
||||
reject(Error(message));
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(payload);
|
||||
else
|
||||
reject(
|
||||
Object.assign(Error(payload.message || '파일 조각을 보내지 못했습니다.'), {
|
||||
status: xhr.status,
|
||||
code: payload.code,
|
||||
}),
|
||||
);
|
||||
};
|
||||
xhr.onerror = xhr.onabort = () => {
|
||||
finish();
|
||||
reject(Object.assign(Error('업로드 연결이 중단되었습니다.'), { status: 0 }));
|
||||
};
|
||||
xhr.send(file.slice(offset, offset + chunkBytes));
|
||||
});
|
||||
const retryable = (error) =>
|
||||
error.status === 0 ||
|
||||
error.status >= 500 ||
|
||||
[408, 429].includes(error.status) ||
|
||||
['UPLOAD_OFFSET', 'UPLOAD_ACTIVE'].includes(error.code);
|
||||
try {
|
||||
const fingerprint = await fileFingerprint(file, controller.signal);
|
||||
check();
|
||||
let state = await api(
|
||||
'/uploads/resumable',
|
||||
'POST',
|
||||
{ requestId: id, fileIndex: index, fingerprint },
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
let failures = 0;
|
||||
while (state.offset < file.size) {
|
||||
check();
|
||||
try {
|
||||
state = await sendChunk(state.uploadId, state.offset);
|
||||
failures = 0;
|
||||
} catch (error) {
|
||||
check();
|
||||
if (!retryable(error) || ++failures > 3) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, failures * 400));
|
||||
check();
|
||||
state = await api(
|
||||
`/uploads/resumable/${state.uploadId}`,
|
||||
'GET',
|
||||
undefined,
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
check();
|
||||
if (state.status !== 'stored') {
|
||||
try {
|
||||
await api(
|
||||
`/uploads/resumable/${state.uploadId}/complete`,
|
||||
'POST',
|
||||
{},
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
check();
|
||||
const current = await api(
|
||||
`/uploads/resumable/${state.uploadId}`,
|
||||
'GET',
|
||||
undefined,
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
if (current.status !== 'stored') throw error;
|
||||
}
|
||||
}
|
||||
check();
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 100,
|
||||
status: 'stored',
|
||||
accountId,
|
||||
};
|
||||
xhr.onerror = xhr.onabort = () => {
|
||||
finish();
|
||||
if (accountId && account.value?.id !== accountId) {
|
||||
delete uploadProgress.value[progressKey];
|
||||
reject(Error('계정 세션이 종료되었습니다.'));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelledTransfers.has(id) || (accountId && account.value?.id !== accountId))
|
||||
delete uploadProgress.value[progressKey];
|
||||
else
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
accountId,
|
||||
message: '업로드가 중단되었습니다.',
|
||||
message: error.message || '업로드가 중단되었습니다.',
|
||||
};
|
||||
reject(Error('업로드가 중단되었습니다.'));
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
xhr.send(form);
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
uploadRequests.delete(progressKey);
|
||||
}
|
||||
}
|
||||
async function deliverFiles(id) {
|
||||
if (fileSending.has(id)) return;
|
||||
@@ -317,6 +409,12 @@ async function deliverFiles(id) {
|
||||
}
|
||||
if (requests.value.find((r) => r.id === id)?.status !== 'accepted') break;
|
||||
}
|
||||
if (
|
||||
Object.entries(uploadProgress.value).some(
|
||||
([key, value]) => key.startsWith(`${id}:`) && value.status === 'failed',
|
||||
)
|
||||
)
|
||||
return;
|
||||
await refresh();
|
||||
if (requests.value.find((r) => r.id === id)?.transferStatus === 'stored') fileDrafts.delete(id);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user