Move file transfer restriction into network settings
This commit is contained in:
@@ -129,6 +129,11 @@ try {
|
||||
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
|
||||
),
|
||||
);
|
||||
if (verifiedAccount && process.env.HOP_CHECK_FILE_POLICY === '1') {
|
||||
node(
|
||||
`const base='http://127.0.0.1:${port}',cookie=${JSON.stringify(verifiedAccount.cookie)};const headers={Origin:'https://hop.test',Cookie:cookie,'Content-Type':'application/json'};const session=await(await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'})).json();const summary=await(await fetch(base+'/api/admin/summary',{headers})).json();const paused=await fetch(base+'/api/admin/settings',{method:'PATCH',headers:{...headers,'X-CSRF-Token':session.csrfToken},body:JSON.stringify({revision:summary.settings.revision,uploadsPaused:true})});if(!paused.ok)throw Error(await paused.text());console.log('file policy paused');`,
|
||||
);
|
||||
}
|
||||
docker('restart', name);
|
||||
wait();
|
||||
const result = JSON.parse(
|
||||
@@ -165,6 +170,16 @@ try {
|
||||
assert.deepEqual(settings.blockedCountries, ['CN']);
|
||||
assert.deepEqual(settings.allowedIps, ['127.0.0.1']);
|
||||
assert.equal(settings.autoBlockEnabled, true);
|
||||
if (process.env.HOP_CHECK_FILE_POLICY === '1') {
|
||||
assert.equal(settings.uploadsPaused, true);
|
||||
const policy = JSON.parse(
|
||||
node(
|
||||
`const base='http://127.0.0.1:${port}',cookie=${JSON.stringify(fileState.cookie)};const response=await fetch(base+'/api/device/snapshot',{headers:{Cookie:cookie}});if(!response.ok)throw Error(await response.text());console.log(JSON.stringify((await response.json()).servicePolicy));`,
|
||||
),
|
||||
);
|
||||
assert.equal(policy.fileTransfersAllowed, false);
|
||||
assert.equal(policy.revision, settings.revision);
|
||||
}
|
||||
const adminState = JSON.parse(
|
||||
node(
|
||||
`const headers={Cookie:${JSON.stringify(verifiedAccount.cookie)}};const base='http://127.0.0.1:${port}';const blocks=await(await fetch(base+'/api/admin/automatic-blocks',{headers})).json();const users=await(await fetch(base+'/api/admin/users',{headers})).json();const audit=await(await fetch(base+'/api/admin/audit',{headers})).json();console.log(JSON.stringify({blocks,users,audit}));`,
|
||||
@@ -297,7 +312,7 @@ try {
|
||||
assert.deepEqual(emptied, { files: 0, requests: 0, guests: 0, integrity: 'ok' });
|
||||
}
|
||||
console.log(
|
||||
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${process.env.HOP_CHECK_RESET === '1' ? ' Offline transfer reset preserves accounts and admin settings.' : ''}${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token, admin role and persisted admin controls after restart.' : ''}`,
|
||||
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${process.env.HOP_CHECK_FILE_POLICY === '1' ? ' File ban survives restart while previously accepted chunks and existing downloads complete.' : ''}${process.env.HOP_CHECK_RESET === '1' ? ' Offline transfer reset preserves accounts and admin settings.' : ''}${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token, admin role and persisted admin controls after restart.' : ''}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));
|
||||
|
||||
@@ -26,6 +26,9 @@ const runtime = await createApp(
|
||||
},
|
||||
{ mailer: async () => {} },
|
||||
);
|
||||
// A NAS-sized disk verifies comma grouping in the rendered admin card.
|
||||
const realSummary = runtime.service.admin.summary.bind(runtime.service.admin);
|
||||
runtime.service.admin.summary = () => ({ ...realSummary(), diskFreeBytes: 7687e9 });
|
||||
let browser;
|
||||
try {
|
||||
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
|
||||
@@ -90,7 +93,7 @@ try {
|
||||
const response = await fetch('/api' + path, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
'X-CSRF-Token': csrf,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
@@ -150,6 +153,10 @@ try {
|
||||
await admin.getByRole('button', { name: '더보기', exact: true }).click();
|
||||
await admin.getByRole('menuitem', { name: '관리자', exact: true }).click();
|
||||
await admin.locator('.admin-stats').waitFor();
|
||||
assert.equal(
|
||||
await admin.locator('.admin-stats article').nth(1).locator('strong').innerText(),
|
||||
'7,687.0GB',
|
||||
);
|
||||
await admin.locator('.admin-file').filter({ hasText: 'sample.mp4' }).waitFor();
|
||||
assert.match(await admin.locator('.admin-file').innerText(), /중국\(CN\) · Beijing/);
|
||||
assert.match(await admin.locator('.admin-file').innerText(), /1\.1\.1\.1/);
|
||||
@@ -161,16 +168,119 @@ try {
|
||||
await admin.locator('.admin-file footer small').innerText(),
|
||||
/^\[보관 만료\] \d{4}년 \d{1,2}월 \d{1,2}일 \d{2}:\d{2} 까지$/,
|
||||
);
|
||||
await admin.getByRole('button', { name: '신규 업로드 중지', exact: true }).click();
|
||||
await admin.getByText('신규 업로드 중지됨', { exact: true }).waitFor();
|
||||
assert.equal(await admin.locator('.admin-upload-control').count(), 0);
|
||||
// Prepare a stored account file and an unsent file draft before pausing.
|
||||
const normalSessionBefore = await session(normal);
|
||||
const policyFile = await call(normal, normalSessionBefore.csrfToken, '/vault', {
|
||||
kind: 'file',
|
||||
files: [{ name: 'policy-file.txt', size: 4, type: 'text/plain' }],
|
||||
});
|
||||
await uploadFile(
|
||||
normal,
|
||||
normalSessionBefore.csrfToken,
|
||||
policyFile.requestId,
|
||||
'policy-file.txt',
|
||||
'text/plain',
|
||||
);
|
||||
await normal.getByRole('button', { name: '더보기', exact: true }).click();
|
||||
await normal.getByRole('tab', { name: '임시 저장', exact: true }).click();
|
||||
const policyCard = normal.locator('.compact-item').filter({ hasText: 'policy-file.txt' });
|
||||
await policyCard.waitFor();
|
||||
await normal.getByRole('button', { name: '보관함에 담기', exact: true }).click();
|
||||
await normal.getByRole('button', { name: '파일 저장', exact: true }).click();
|
||||
await sender
|
||||
.locator('.peer-card')
|
||||
.filter({ hasText: '#' + b.device.code })
|
||||
.click();
|
||||
const composer = sender.locator('.send-dialog');
|
||||
await composer
|
||||
.locator('input[type=file]')
|
||||
.setInputFiles({ name: 'draft.txt', mimeType: 'text/plain', buffer: Buffer.from('draft') });
|
||||
await composer.locator('.selected-files').waitFor();
|
||||
await admin.getByRole('button', { name: '네트워크', exact: true }).click();
|
||||
async function banFiles(value) {
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
const toggle = admin.getByRole('switch', { name: '파일 전송 금지', exact: true });
|
||||
if ((await toggle.getAttribute('aria-checked')) !== String(value)) await toggle.click();
|
||||
await admin.getByRole('button', { name: '네트워크 설정 저장', exact: true }).click();
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).waitFor();
|
||||
}
|
||||
await banFiles(true);
|
||||
assert.equal(runtime.service.admin.state.uploadsPaused, true);
|
||||
await admin.getByRole('button', { name: '업로드 재개', exact: true }).click();
|
||||
await admin.getByText('신규 업로드 허용 중', { exact: true }).waitFor();
|
||||
await composer
|
||||
.getByText('파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await composer.locator('input[type=file]').count(), 0);
|
||||
assert.equal(
|
||||
await composer.getByRole('button', { name: '대신 파일 선택하기', exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(await composer.locator('.selected-files').count(), 0);
|
||||
await normal.getByLabel('보낼 텍스트', { exact: true }).waitFor();
|
||||
assert.equal(await normal.locator('input[type=file]').count(), 0);
|
||||
assert.equal(
|
||||
await policyCard.getByRole('button', { name: '기기로 보내기', exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(await policyCard.getByRole('button', { name: '다운로드', exact: true }).count(), 1);
|
||||
await normal.getByRole('button', { name: '다른 종류 선택', exact: true }).click();
|
||||
assert.equal(await normal.getByRole('button', { name: '파일 저장', exact: true }).count(), 0);
|
||||
await normal.getByRole('button', { name: '보관함 저장 닫기', exact: true }).click();
|
||||
const pausedGuest = await page(390);
|
||||
await pausedGuest.locator('.file-policy-note').waitFor();
|
||||
await pausedGuest.context().close();
|
||||
const message = '파일 제한 중에도 한글 日本語 😀\n 텍스트를 전송합니다.';
|
||||
await composer.getByLabel('보낼 텍스트', { exact: true }).fill(message);
|
||||
await composer.locator('.unified-composer').evaluate((element) => {
|
||||
const data = new DataTransfer();
|
||||
data.items.add(new File(['ignored'], 'blocked.txt', { type: 'text/plain' }));
|
||||
element.dispatchEvent(
|
||||
new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: data }),
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }),
|
||||
);
|
||||
});
|
||||
assert.equal(await composer.getByLabel('보낼 텍스트', { exact: true }).inputValue(), message);
|
||||
await sender.screenshot({ path: 'tests/e2e/file-policy-sender.png', fullPage: true });
|
||||
await composer.getByRole('button', { name: '전송하기', exact: true }).click();
|
||||
await receiver.getByRole('button', { name: '수락', exact: true }).click();
|
||||
await receiver.getByRole('dialog', { name: '받은 텍스트', exact: true }).waitFor();
|
||||
assert.equal(await receiver.getByLabel('받은 텍스트 내용', { exact: true }).innerText(), message);
|
||||
await receiver.getByRole('button', { name: '받은 텍스트 닫기', exact: true }).click();
|
||||
await composer.waitFor({ state: 'hidden' });
|
||||
// Unpausing updates existing clients without a reload and preserves typed text.
|
||||
await sender
|
||||
.locator('.peer-card')
|
||||
.filter({ hasText: '#' + b.device.code })
|
||||
.click();
|
||||
await composer.getByLabel('보낼 텍스트', { exact: true }).fill('작성 중인 내용은 유지');
|
||||
await banFiles(false);
|
||||
await composer.getByRole('button', { name: '대신 파일 선택하기', exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await composer.getByLabel('보낼 텍스트', { exact: true }).inputValue(),
|
||||
'작성 중인 내용은 유지',
|
||||
);
|
||||
await composer.getByRole('button', { name: '전송 화면 닫기', exact: true }).click();
|
||||
const removed = (
|
||||
await normal.evaluate(async () => (await (await fetch('/api/vault')).json()).items)
|
||||
).find((item) => item.file?.name === 'policy-file.txt');
|
||||
const adminSession = await session(admin);
|
||||
await call(admin, adminSession.csrfToken, '/admin/files/' + removed.file.id, undefined, 'DELETE');
|
||||
await admin.getByRole('button', { name: '용량 설정', exact: true }).click();
|
||||
const capacityForm = admin.locator('.admin-settings');
|
||||
assert.equal(await capacityForm.locator('input').count(), 0);
|
||||
assert.match(await capacityForm.innerText(), /로그인 보관함 계정당 한도[\s\S]*2\.1GB/);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
const rowInputs = await admin.locator('.admin-settings-grid input').evaluateAll((inputs) =>
|
||||
inputs.slice(0, 2).map((input) => ({
|
||||
top: input.getBoundingClientRect().top,
|
||||
height: input.getBoundingClientRect().height,
|
||||
})),
|
||||
);
|
||||
assert.equal(rowInputs[0].top, rowInputs[1].top);
|
||||
assert.equal(rowInputs[0].height, rowInputs[1].height);
|
||||
await admin.screenshot({ path: 'tests/e2e/admin-capacity-edit-desktop.png', fullPage: true });
|
||||
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
|
||||
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
@@ -293,7 +403,7 @@ try {
|
||||
assert.doesNotMatch(ownerLabel, /user-|guest-|\[/);
|
||||
await admin.screenshot({ path: 'tests/e2e/admin-file-account.png', fullPage: true });
|
||||
await admin.getByRole('button', { name: '관리 기록', exact: true }).click();
|
||||
await admin.getByText('원본 파일 삭제', { exact: true }).waitFor();
|
||||
await admin.getByText('원본 파일 삭제', { exact: true }).first().waitFor();
|
||||
const changedRow = admin
|
||||
.locator('.admin-audit article')
|
||||
.filter({ hasText: '동시에 처리할 업로드 수 4 > 3' });
|
||||
@@ -384,7 +494,7 @@ try {
|
||||
assert.equal(await mobile.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'PASS: verified hello-only menu and server authorization, admin storage/location/IP overview, pause/resume, saved quotas and country/IP rules, delete confirmation/audit, mobile layout and font size, logout privilege removal.',
|
||||
'PASS: verified hello-only menu and server authorization, admin storage/location/IP overview, file-only ban/live menu updates and clipboard-only transfer, comma-separated GB, aligned capacity inputs, saved quotas and country/IP rules, delete confirmation/audit, mobile layout and font size, logout privilege removal.',
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
|
||||
Reference in New Issue
Block a user