Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults
This commit is contained in:
74
apps/server/test/cloudflare-discovery.test.js
Normal file
74
apps/server/test/cloudflare-discovery.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
182
apps/server/test/resumable.test.js
Normal file
182
apps/server/test/resumable.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user