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

@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { io } from 'socket.io-client';
import { fixture } from './helpers.js';
import { clientIp } from '../src/security.js';
test('Cloudflare 원본 IP는 신뢰한 인접 프록시의 단일 공인 IP만 허용한다', () => {
const resolve = (peer, headers) => clientIp(peer, headers, ['127.0.0.1'], 'cloudflare');
assert.equal(
resolve('127.0.0.1', { 'cf-connecting-ip': '8.8.8.8', 'x-forwarded-for': '1.1.1.1' }),
'8.8.8.8',
);
assert.equal(resolve('8.8.4.4', { 'cf-connecting-ip': '8.8.8.8' }), null);
for (const value of ['192.168.1.1', '127.0.0.1', 'bad', '8.8.8.8,1.1.1.1', ['8.8.8.8']])
assert.equal(resolve('127.0.0.1', { 'cf-connecting-ip': value }), null);
assert.equal(resolve('127.0.0.1', {}), null);
});
test('실제 소켓의 같은 원본 IP는 자동 발견하고 다른 네트워크·발견 꺼짐은 숨긴다', async () => {
const f = await fixture(true),
sockets = [];
f.cfg.clientIpSource = 'cloudflare';
try {
const clients = [];
for (const ip of ['8.8.8.8', '8.8.8.8', '1.1.1.1']) {
const client = await f.create(ip, false);
const socket = io(f.url, {
transports: ['websocket'],
auth: { csrfToken: client.csrf },
extraHeaders: {
Origin: f.cfg.appUrl,
Cookie: client.cookie,
'CF-Connecting-IP': ip,
'X-Forwarded-For': '9.9.9.9',
},
});
sockets.push(socket);
await new Promise((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
});
clients.push(client);
}
const list = (await f.call(clients[0], '/discovery/devices')).json().devices;
assert.deepEqual(
list.map((device) => device.id),
[clients[1].deviceId],
);
await f.call(clients[1], '/device/me', 'PATCH', { discoverable: false });
assert.equal((await f.call(clients[0], '/discovery/devices')).json().devices.length, 0);
} finally {
sockets.forEach((socket) => socket.disconnect());
await f.close();
}
});
test('프록시 진단은 운영자 토큰 없이 원본 정보를 노출하지 않는다', async () => {
const f = await fixture();
f.cfg.proxyDiagnosticToken = 'p'.repeat(40);
try {
const denied = await f.call(null, '/ops/proxy-diagnostic');
assert.equal(denied.statusCode, 403);
assert.equal(denied.json().peerIp, undefined);
const allowed = await f.call(null, '/ops/proxy-diagnostic', 'GET', undefined, undefined, {
authorization: `Bearer ${f.cfg.proxyDiagnosticToken}`,
'cf-connecting-ip': '8.8.8.8',
});
assert.equal(allowed.statusCode, 200);
assert.equal(allowed.json().peerIp, '127.0.0.1');
assert.equal(allowed.json().cloudflareClientIpPresent, true);
f.cfg.proxyDiagnosticToken = undefined;
assert.equal((await f.call(null, '/ops/proxy-diagnostic')).statusCode, 404);
} finally {
await f.close();
}
});

View File

@@ -418,14 +418,14 @@ test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명
const item = (await f.call(b, '/inbox')).json().items[0],
download = await f.call(b, `/access/${item.accessId}/download`);
assert.deepEqual(download.rawPayload, bytes);
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length);
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0);
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, bytes.length);
assert.equal(
(await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true }))
.statusCode,
410,
);
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, bytes.length);
} finally {
await f.close();
}

View File

@@ -0,0 +1,182 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import { CHUNK_BYTES, ResumableUploads } from '../src/resumable.js';
import * as schema from '../src/db/schema.js';
const digest = (bytes) => createHash('sha256').update(bytes).digest();
function fingerprint(bytes) {
const hashes = [];
for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES)
hashes.push(digest(bytes.subarray(offset, offset + CHUNK_BYTES)));
return digest(Buffer.concat(hashes)).toString('hex');
}
async function prepare(f, bytes, accept = true) {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const response = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: bytes.length,
count: 1,
files: [{ name: 'resumable.bin', size: bytes.length, type: 'application/octet-stream' }],
},
randomUUID(),
);
assert.equal(response.statusCode, 200, response.body);
const requestId = response.json().requestId;
if (accept)
assert.equal(
(await f.call(b, `/transfer-requests/${requestId}/accept`, 'POST', {}, randomUUID()))
.statusCode,
200,
);
const start = (sender = a, content = bytes) =>
f.call(sender, '/uploads/resumable', 'POST', {
requestId,
fileIndex: 0,
fingerprint: fingerprint(content),
});
return { a, b, requestId, start };
}
const put = (f, sender, id, offset, bytes) =>
f.call(sender, `/uploads/resumable/${id}`, 'PUT', bytes, undefined, {
'content-type': 'application/octet-stream',
'upload-offset': String(offset),
});
test('분할 업로드는 수락·발신 권한·Origin·CSRF를 검사한다', async () => {
const f = await fixture();
try {
const p = await prepare(f, Buffer.from('hello'), false);
assert.equal((await p.start()).statusCode, 409);
await f.call(p.b, `/transfer-requests/${p.requestId}/accept`, 'POST', {}, randomUUID());
assert.equal((await p.start(p.b)).statusCode, 403);
const state = (await p.start()).json();
assert.equal(
(
await f.call(
p.a,
`/uploads/resumable/${state.uploadId}`,
'PUT',
Buffer.from('hello'),
undefined,
{
'content-type': 'application/octet-stream',
'upload-offset': '0',
origin: 'https://evil.test',
},
)
).statusCode,
403,
);
assert.equal(
(
await f.call(
p.a,
`/uploads/resumable/${state.uploadId}`,
'PUT',
Buffer.from('hello'),
undefined,
{
'content-type': 'application/octet-stream',
'upload-offset': '0',
'x-csrf-token': 'wrong',
},
)
).statusCode,
403,
);
} finally {
await f.close();
}
});
test('받은 조각의 중복·다른 내용·순서를 검사하고 이어 받아 원본을 확정한다', async () => {
const f = await fixture();
try {
const bytes = Buffer.alloc(CHUNK_BYTES + 13, 71),
p = await prepare(f, bytes);
const state = (await p.start()).json(),
path = `/uploads/resumable/${state.uploadId}`;
const first = bytes.subarray(0, CHUNK_BYTES);
assert.equal(
(await put(f, p.a, state.uploadId, CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).json().code,
'UPLOAD_OFFSET',
);
assert.equal((await put(f, p.a, state.uploadId, 0, first)).json().offset, CHUNK_BYTES);
assert.equal((await put(f, p.a, state.uploadId, 0, first)).json().offset, CHUNK_BYTES);
assert.equal(
(await put(f, p.a, state.uploadId, 0, Buffer.alloc(CHUNK_BYTES, 72))).json().code,
'CHUNK_CONFLICT',
);
assert.equal(
(await f.call(p.a, `${path}/complete`, 'POST', {})).json().code,
'UPLOAD_INCOMPLETE',
);
f.service.resumable = new ResumableUploads(f.service);
assert.equal((await p.start()).json().offset, CHUNK_BYTES);
assert.equal((await p.start(p.a, Buffer.alloc(bytes.length, 73))).json().code, 'FILE_CHANGED');
assert.equal(
(await put(f, p.a, state.uploadId, CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode,
200,
);
assert.equal((await f.call(p.a, `${path}/complete`, 'POST', {})).statusCode, 200);
assert.equal((await f.call(p.a, `${path}/complete`, 'POST', {})).statusCode, 200);
const item = (await f.call(p.b, '/device/items')).json().items[0];
const download = await f.call(p.b, `/access/${item.accessId}/download`);
assert.deepEqual(download.rawPayload, bytes);
assert.equal((await readdir(f.service.resumable.directory)).length, 0);
} finally {
await f.close();
}
});
test('8MiB 초과·전체 파일 검증 실패·취소는 자료 확정을 막고 임시 파일을 정리한다', async () => {
const f = await fixture();
try {
const bytes = Buffer.from('original'),
p = await prepare(f, bytes);
const state = (await p.start()).json();
assert.equal(
(await put(f, p.a, state.uploadId, 0, Buffer.alloc(CHUNK_BYTES + 1))).statusCode,
413,
);
assert.equal((await put(f, p.a, state.uploadId, 0, Buffer.from('tampered'))).statusCode, 200);
assert.equal(
(await f.call(p.a, `/uploads/resumable/${state.uploadId}/complete`, 'POST', {})).json().code,
'FILE_CHECKSUM',
);
assert.equal((await f.call(p.b, '/device/items')).json().items.length, 0);
await f.call(p.a, `/transfer-requests/${p.requestId}/cancel`, 'POST', {});
assert.equal((await readdir(f.service.resumable.directory)).length, 0);
assert.equal((await put(f, p.a, state.uploadId, 0, bytes)).statusCode, 409);
} finally {
await f.close();
}
});
test('메타데이터 저장 전 중단된 쓰기는 마지막 확정 위치로 복구하고 만료 조각을 정리한다', async () => {
const f = await fixture();
try {
const p = await prepare(f, Buffer.from('crash')),
state = (await p.start()).json();
await writeFile(f.service.resumable.path(state.uploadId, 'part'), 'uncommitted');
assert.equal((await p.start()).json().offset, 0);
assert.equal((await readFile(f.service.resumable.path(state.uploadId, 'part'))).length, 0);
f.service.db
.update(schema.requests)
.set({ startBy: Date.now() - 1 })
.where(eq(schema.requests.id, p.requestId))
.run();
f.service.resumable.cleanup();
assert.equal((await readdir(f.service.resumable.directory)).length, 0);
} finally {
await f.close();
}
});

View File

@@ -45,6 +45,96 @@ function limits(f) {
f.cfg.ipRate = 60;
}
test('개인 보관함은 임시 저장·받은 파일을 합산하고 초과 수신은 다운로드만 유지한다', async () => {
const f = await fixture();
try {
limits(f);
f.cfg.userQuota = 7;
const sender = await f.create(),
receiver = await f.create(),
outsider = await f.create();
await f.space(sender, receiver);
await signed(f, receiver, 'quota@hop.test');
await signed(f, outsider, 'other-quota@hop.test');
const saved = (
await f.call(
receiver,
'/vault',
'POST',
{
kind: 'file',
files: [{ name: 'saved.txt', size: 4, type: 'text/plain' }],
},
randomUUID(),
)
).json();
assert.equal((await upload(f, receiver, saved.requestId, Buffer.from('save'))).statusCode, 200);
async function received(bytes) {
const r = await f.call(
sender,
'/transfer-requests',
'POST',
{
targetDeviceId: receiver.deviceId,
kind: 'file',
byteSize: bytes.length,
count: 1,
files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }],
},
randomUUID(),
);
const id = r.json().requestId;
assert.equal(
(await f.call(receiver, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()))
.statusCode,
200,
);
assert.equal((await upload(f, sender, id, bytes)).statusCode, 200);
return id;
}
const skipped = await received(Buffer.from('full'));
assert.equal((await f.call(receiver, '/inbox')).json().items.length, 0);
const guestItem = (await f.call(receiver, '/device/items'))
.json()
.items.find((item) => item.file?.size === 4 && item.role === 'recipient');
assert.deepEqual(
(await f.call(receiver, `/access/${guestItem.accessId}/download`)).rawPayload,
Buffer.from('full'),
);
await received(Buffer.from('fit'));
assert.equal((await f.call(receiver, '/account/storage')).json().usedBytes, 7);
assert.equal((await f.call(outsider, '/account/storage')).json().usedBytes, 0);
const own = (await f.call(receiver, '/vault')).json().items[0];
const deleted = (
await f.call(receiver, '/access/delete-batch', 'POST', { ids: [own.accessId] })
).json();
assert.equal((await f.call(receiver, '/account/storage')).json().usedBytes, 3);
const replacement = (
await f.call(
receiver,
'/vault',
'POST',
{
kind: 'file',
files: [{ name: 'saved.txt', size: 4, type: 'text/plain' }],
},
randomUUID(),
)
).json();
assert.equal(
(await upload(f, receiver, replacement.requestId, Buffer.from('more'))).statusCode,
200,
);
assert.equal(
(await f.call(receiver, `/deletion-operations/${deleted.operationId}/undo`, 'POST', {}))
.statusCode,
413,
);
} finally {
await f.close();
}
});
test('기기 수신은 로그인 계정의 날짜 기록에만 자동 저장, 익명 과거 자료와 로그아웃 후 도착은 가져오지 않음', async () => {
const f = await fixture();
try {