Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
409
apps/web/src/client.js
Normal file
409
apps/web/src/client.js
Normal file
@@ -0,0 +1,409 @@
|
||||
import { createRefreshQueue } from './refreshQueue';
|
||||
import { ref } from 'vue';
|
||||
import { io } from 'socket.io-client';
|
||||
export const account = ref(null),
|
||||
registrationOpen = ref(false),
|
||||
passwordMinLength = ref(12);
|
||||
export async function refreshAccount() {
|
||||
const result = await api('/auth/me');
|
||||
account.value = result.account;
|
||||
registrationOpen.value = result.registrationOpen;
|
||||
passwordMinLength.value = result.passwordMinLength ?? 12;
|
||||
}
|
||||
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 nextCursor = ref(null);
|
||||
let socket;
|
||||
let reauthorizing;
|
||||
const drafts = new Map();
|
||||
const completing = new Set();
|
||||
export async function api(path, method = 'GET', body, key) {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method,
|
||||
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) throw new Error(payload.message ?? '요청을 처리하지 못했습니다.');
|
||||
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;
|
||||
};
|
||||
if (navigator.locks) await navigator.locks.request('hop-session-bootstrap', init);
|
||||
else await init();
|
||||
await refreshAccount();
|
||||
connect();
|
||||
await refresh();
|
||||
startReconciliation();
|
||||
}
|
||||
function connect() {
|
||||
socket?.disconnect();
|
||||
socket = io({ auth: { csrfToken: csrfToken.value }, 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('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 };
|
||||
socket.connect();
|
||||
}
|
||||
})()
|
||||
.catch(showError)
|
||||
.finally(() => {
|
||||
reauthorizing = undefined;
|
||||
});
|
||||
});
|
||||
for (const event of [
|
||||
'device:online',
|
||||
'device:offline',
|
||||
'device:updated',
|
||||
'space:updated',
|
||||
'request:created',
|
||||
'request:updated',
|
||||
'transfer:updated',
|
||||
'access:updated',
|
||||
])
|
||||
socket.on(event, () => void refresh().catch(showError));
|
||||
}
|
||||
export function showError(err) {
|
||||
error.value = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
||||
}
|
||||
export const refresh = createRefreshQueue(async () => {
|
||||
const snapshot = await api('/device/snapshot');
|
||||
device.value = snapshot.device;
|
||||
peers.value = snapshot.devices;
|
||||
discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled;
|
||||
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 = values.urls.join('\n');
|
||||
const { requestId } = await api(
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{
|
||||
targetDeviceId,
|
||||
kind,
|
||||
byteSize: new TextEncoder().encode(content).length,
|
||||
count: values.urls.length,
|
||||
},
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
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', { 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 api('/transfer-requests', 'POST', input, crypto.randomUUID());
|
||||
fileDrafts.set(requestId, files);
|
||||
await refresh();
|
||||
return requestId;
|
||||
}
|
||||
export function uploadFile(id, index, file, accountId) {
|
||||
const progressKey = `${id}:${index}`;
|
||||
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 = '업로드하지 못했습니다.';
|
||||
try {
|
||||
message = JSON.parse(xhr.responseText).message;
|
||||
} catch {}
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
accountId,
|
||||
message,
|
||||
};
|
||||
reject(Error(message));
|
||||
}
|
||||
};
|
||||
xhr.onerror = xhr.onabort = () => {
|
||||
finish();
|
||||
if (accountId && account.value?.id !== accountId) {
|
||||
delete uploadProgress.value[progressKey];
|
||||
reject(Error('계정 세션이 종료되었습니다.'));
|
||||
return;
|
||||
}
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
accountId,
|
||||
message: '업로드가 중단되었습니다.',
|
||||
};
|
||||
reject(Error('업로드가 중단되었습니다.'));
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user