Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults

This commit is contained in:
2026-10-12 15:54:26 +09:00
parent 439c2ae61b
commit 464715aa69
43 changed files with 1647 additions and 137 deletions

View File

@@ -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 {