import { readDeviceInfo } from './deviceInfo'; import { PASSWORD_MIN_LENGTH } from '@hop/shared'; import { createRefreshQueue } from './refreshQueue'; import { ref, watch } from 'vue'; import { io } from 'socket.io-client'; export const account = ref(null), registrationOpen = ref(false), passwordMinLength = ref(PASSWORD_MIN_LENGTH); export const refreshAccount = createRefreshQueue(async () => { const result = await api('/auth/me'); account.value = result.account; registrationOpen.value = result.registrationOpen; passwordMinLength.value = result.passwordMinLength ?? PASSWORD_MIN_LENGTH; }); export function reconnect() { connect(); } export const device = ref(null), csrfToken = ref(''), sessionExpiresAt = ref(0), connected = ref(false), peers = ref([]), requests = ref([]), items = ref([]), spaces = ref([]), discoveryEnabled = ref(false), error = ref(''); export const pendingPeers = ref([]); export const transferCooldown = ref({ until: 0, targetDeviceId: null }); export const transferConnecting = ref(false); let pendingTimer; let deviceInfo = {}; export const nextCursor = ref(null); export const clipboardTextLimitBytes = ref(262144); export const spaceInviteCodeLength = ref(4); export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 }); export async function publishLocation(position) { if (!socket?.connected) throw Error('서버 연결을 확인해 주세요.'); const result = await socket.timeout(5000).volatile.emitWithAck('location:update', position); if (!result?.ok) throw Error(result?.message ?? '위치를 확인하지 못했습니다.'); } export function clearLocation() { if (socket?.connected) socket.emit('location:clear'); } export function reserveLocationAcceptance(requestId) { if (socket?.connected) socket.emit('location:accept', { requestId }); } let socket; let reauthorizing; export async function waitForConnection() { if (socket?.connected) return; if (!socket) throw Error('서버 연결을 확인해 주세요.'); await new Promise((resolve, reject) => { const stop = watch(connected, (ready) => { if (!ready) return; clearTimeout(timer); stop(); resolve(); }); const timer = setTimeout(() => { stop(); reject(Error('서버에 다시 연결하지 못했어요. 잠시 후 다시 시도해 주세요.')); }, 5000); socket.connect(); }); } export async function requestTransfer(path, input) { const key = crypto.randomUUID(), owner = device.value?.id; try { transferConnecting.value = !socket?.connected; await waitForConnection(); const deadline = Date.now() + 5000; while (true) { if (device.value?.id !== owner) throw Error('기기 세션이 변경되었습니다. 다시 선택해 주세요.'); try { return await api(path, 'POST', input, key); } catch (error) { // Only a known, still-authorized peer in its reconnect grace period is retried. if (error.code !== 'TARGET_RECONNECTING' || Date.now() >= deadline) throw error; transferConnecting.value = true; await new Promise((resolve) => setTimeout(resolve, 500)); } } } finally { transferConnecting.value = false; } } const drafts = new Map(); const completing = new Set(); 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' } : {}), ...(method !== 'GET' ? { 'X-CSRF-Token': csrfToken.value } : {}), ...(key ? { 'Idempotency-Key': key } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const payload = await response.json(); if (!response.ok) { const retryAfterSeconds = Number( payload.retryAfterSeconds ?? response.headers.get('Retry-After'), ); if ( response.status === 429 && retryAfterSeconds > 0 && Number.isFinite(retryAfterSeconds) && method === 'POST' && (path === '/transfer-requests' || /^\/vault\/[^/]+\/send$/.test(path)) ) { transferCooldown.value = { until: Date.now() + Math.ceil(retryAfterSeconds) * 1000, targetDeviceId: payload.limitScope === 'pair' ? body?.targetDeviceId : null, }; } throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), { status: response.status, code: payload.code, retryAfterSeconds, }); } return payload; } export async function bootstrap() { const init = async () => { const s = await api('/guest/session', 'POST', {}); device.value = s.device; csrfToken.value = s.csrfToken; sessionExpiresAt.value = s.expiresAt; // An open site participates in discovery, including sessions that opted out in older UI. if (!s.device.discoverable) { await api('/device/me', 'PATCH', { discoverable: true }); device.value = { ...s.device, discoverable: true }; } }; if (navigator.locks) await navigator.locks.request('hop-session-bootstrap', init); else await init(); await refreshAccount(); deviceInfo = await readDeviceInfo(); connect(); await refresh(); startReconciliation(); } function connect() { socket?.disconnect(); socket = io({ auth: { csrfToken: csrfToken.value, deviceInfo }, reconnection: true }); socket.on('connect', () => { connected.value = true; void refresh().catch(showError); }); socket.on('disconnect', () => { 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; window.dispatchEvent(new Event('hop-account-revoked')); }); socket.on('connect_error', (err) => { connected.value = false; if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return; reauthorizing = (async () => { const session = await api('/guest/session', 'POST', {}); device.value = session.device; csrfToken.value = session.csrfToken; sessionExpiresAt.value = session.expiresAt; if (socket) { socket.auth = { csrfToken: csrfToken.value, deviceInfo }; socket.connect(); } })() .catch(showError) .finally(() => { reauthorizing = undefined; }); }); socket.on('device:updated', (event) => { const known = new Set(peers.value.map((peer) => peer.id)); pendingPeers.value = (event?.devices ?? []) .filter((peer) => !known.has(peer.id)) .map((peer) => peer.id); clearTimeout(pendingTimer); pendingTimer = setTimeout(() => { pendingPeers.value = []; }, 5000); void refresh().catch(showError); }); for (const event of [ 'device:online', 'device:offline', 'space:updated', 'request:created', 'request:updated', 'transfer:updated', 'access:updated', ]) socket.on(event, () => void refresh().catch(showError)); } let errorTimer; export function showError(err) { clearTimeout(errorTimer); const raw = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.'; const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw) ? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.' : raw; const displayed = err?.retryAfterSeconds > 0 ? `${message} ${Math.ceil(err.retryAfterSeconds)}초 후 다시 시도할 수 있어요.` : message; error.value = displayed; errorTimer = setTimeout(() => { if (error.value === displayed) error.value = ''; }, 5000); } export const refresh = createRefreshQueue(async () => { const snapshot = await api('/device/snapshot'); device.value = snapshot.device; peers.value = snapshot.devices; clipboardTextLimitBytes.value = snapshot.clipboardTextLimitBytes ?? 262144; pendingPeers.value = pendingPeers.value.filter( (id) => !snapshot.devices.some((peer) => peer.id === id), ); discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled; spaceInviteCodeLength.value = snapshot.spaceInviteCodeLength ?? 4; locationSettings.value = snapshot.locationDiscovery ?? { enabled: false, radiusMeters: 100, refreshMs: 120000, }; requests.value = snapshot.requests; items.value = snapshot.items; nextCursor.value = snapshot.nextCursor; spaces.value = snapshot.spaces; for (const req of requests.value) { if ( req.kind === 'file' && req.status === 'accepted' && req.transferStatus === 'waiting' && fileDrafts.has(req.id) && !Object.keys(uploadProgress.value).some( (k) => k.startsWith(req.id) && uploadProgress.value[k]?.status === 'failed', ) ) void deliverFiles(req.id); if (req.status === 'accepted' && req.transferStatus === 'waiting' && drafts.has(req.id)) void deliver(req.id); if ( ['rejected', 'expired', 'cancelled'].includes(req.status) || req.transferStatus === 'stored' ) drafts.delete(req.id); } }); export async function send(targetDeviceId, kind, values) { const content = kind === 'text' ? values.text : values.urls.join('\n'); const { requestId } = await requestTransfer('/transfer-requests', { targetDeviceId, kind, byteSize: new TextEncoder().encode(content).length, count: kind === 'text' ? 1 : values.urls.length, }); drafts.set(requestId, { ...values, key: crypto.randomUUID() }); await refresh(); return requestId; } async function deliver(id) { if (completing.has(id)) return; const draft = drafts.get(id); if (!draft) return; completing.add(id); try { await api( `/transfers/${id}/complete`, 'POST', draft.text !== undefined ? { text: draft.text } : { urls: draft.urls }, draft.key, ); drafts.delete(id); await refresh(); return true; } catch (err) { showError(err); return false; } finally { completing.delete(id); } } export async function retryDelivery(id, values) { drafts.set(id, { ...values, key: crypto.randomUUID() }); if (!(await deliver(id))) throw new Error(error.value || '전송을 완료하지 못했습니다.'); } export async function requestAction(id, action) { await api( `/transfer-requests/${id}/${action}`, 'POST', {}, action === 'accept' ? crypto.randomUUID() : undefined, ); await refresh(); } export async function patchDevice(input) { await api('/device/me', 'PATCH', input); await refresh(); } export async function markRead(item, value = true) { await api(`/access/${item.accessId}/read`, 'PATCH', { read: value }); await refresh(); } export async function loadMore() { if (!nextCursor.value) return; const result = await api(`/device/items?cursor=${encodeURIComponent(nextCursor.value)}`); items.value.push(...result.items); nextCursor.value = result.nextCursor; } export async function reset() { const result = await api('/device/reset', 'POST', {}); drafts.clear(); fileDrafts.clear(); for (const [key, xhr] of uploadRequests) if (!uploadProgress.value[key]?.accountId) xhr.abort(); uploadProgress.value = Object.fromEntries( Object.entries(uploadProgress.value).filter(([, p]) => p.accountId), ); device.value = result.device; csrfToken.value = result.csrfToken; sessionExpiresAt.value = result.expiresAt; connect(); await refresh(); } export const uploadProgress = ref({}); const fileDrafts = new Map(); const fileSending = new Set(); const uploadRequests = new Map(); const cancelledTransfers = new Set(); export async function sendFiles(targetDeviceId, files) { const input = { targetDeviceId, kind: 'file', byteSize: files.reduce((n, f) => n + f.size, 0), count: files.length, files: files.map((f) => ({ name: f.name, size: f.size, type: f.type || 'application/octet-stream', })), }; const { requestId } = await requestTransfer('/transfer-requests', input); fileDrafts.set(requestId, files); await refresh(); return requestId; } 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, index, progress: 0, status: 'uploading', accountId, }; 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 { 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, index, progress: 100, status: 'stored', accountId, }; } catch (error) { if (cancelledTransfers.has(id) || (accountId && account.value?.id !== accountId)) delete uploadProgress.value[progressKey]; else uploadProgress.value[progressKey] = { name: file.name, index, progress: 0, status: 'failed', accountId, message: error.message || '업로드가 중단되었습니다.', }; throw error; } finally { uploadRequests.delete(progressKey); } } async function deliverFiles(id) { if (fileSending.has(id)) return; const files = fileDrafts.get(id), request = requests.value.find((r) => r.id === id); if (!files || !request) return; fileSending.add(id); try { for (const [index, file] of files.entries()) { if (cancelledTransfers.has(id)) break; if (request.files?.[index]?.status === 'stored') continue; try { await uploadFile(id, index, file); } catch (err) { if (!cancelledTransfers.has(id)) showError(err); } 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 { fileSending.delete(id); } } export async function retryFiles(id, files) { const r = requests.value.find((r) => r.id === id); if ( !r?.files || r.files.length !== files.length || files.some((f, i) => f.name !== r.files[i].name || f.size !== r.files[i].size) ) throw Error('수락한 파일 목록과 이름·크기가 같은 파일을 선택해 주세요.'); fileDrafts.set(id, files); await deliverFiles(id); } export function cancelUpload(key) { uploadRequests.get(key)?.abort(); } export async function cancelTransfer(id) { // Cancel on the server before aborting uploads so a failed upload cannot restart delivery. cancelledTransfers.add(id); try { await api(`/transfer-requests/${id}/cancel`, 'POST', {}); } catch (error) { cancelledTransfers.delete(id); throw error; } drafts.delete(id); fileDrafts.delete(id); for (const key of uploadRequests.keys()) { if (key.startsWith(`${id}:`)) uploadRequests.get(key)?.abort(); } for (const key of Object.keys(uploadProgress.value)) { if (key.startsWith(`${id}:`)) delete uploadProgress.value[key]; } await refresh(); } export async function deleteItems(ids) { const original = [...items.value]; items.value = items.value.filter((i) => !ids.includes(i.accessId)); try { const result = await api('/access/delete-batch', 'POST', { ids }); await refresh(); return result; } catch (err) { items.value = original; throw err; } } export async function undoDelete(id) { await api(`/deletion-operations/${id}/undo`, 'POST', {}); await refresh(); } export async function download(item) { try { await api(`/access/${item.accessId}`); } catch (err) { await refresh().catch(() => {}); throw err; } const a = document.createElement('a'); a.href = `/api/access/${item.accessId}/download`; a.download = item.file?.name ?? ''; document.body.append(a); a.click(); a.remove(); await markRead(item); } export function clearAccountUploads(accountId) { for (const [key, progress] of Object.entries(uploadProgress.value)) { if (!progress.accountId || (accountId && progress.accountId !== accountId)) continue; uploadRequests.get(key)?.abort(); delete uploadProgress.value[key]; } } let reconciliation; function startReconciliation() { if (reconciliation) return; reconciliation = setInterval(() => { if ( document.visibilityState !== 'visible' || !requests.value.some( (r) => r.status === 'pending' || (r.status === 'accepted' && r.transferStatus !== 'stored'), ) ) return; void api('/transfer-requests') .then((result) => { const summary = (rs) => JSON.stringify(rs.map((r) => [r.id, r.status, r.transferStatus])); if (summary(result.requests) !== summary(requests.value)) return refresh(); }) .catch(showError); }, 5000); }