Add verified administrator controls and upload abuse limits

This commit is contained in:
2026-10-14 20:37:55 +09:00
parent c3573b605c
commit 3786baf60c
23 changed files with 1941 additions and 22 deletions

View File

@@ -90,7 +90,7 @@ try {
const database=new Database('/data/database/hop.sqlite'), db=drizzle(database);
const oldId=randomUUID(), nextId=randomUUID();
db.insert(users).values({id:oldId,email:'previous@docker.hop.test',displayName:'previous',passwordHash,status:'active',verifiedAt:now,createdAt:now}).run();
db.insert(users).values({id:nextId,email:'verified@docker.hop.test',displayName:'verified',passwordHash,status:'pending',createdAt:now}).run();
db.insert(users).values({id:nextId,email:'hello@noname.company',displayName:'verified',passwordHash,status:'pending',createdAt:now}).run();
db.insert(accountTokens).values({id:randomUUID(),userId:nextId,purpose:'verify',tokenHash:hash(token),createdAt:now,expiresAt:now+60000}).run();
database.close();
const sessionResponse=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:guestCookie,'Content-Type':'application/json'},body:'{}'});
@@ -112,6 +112,14 @@ try {
`),
);
}
if (verifiedAccount) {
const saved = JSON.parse(
node(
`const base='http://127.0.0.1:${port}', cookie=${JSON.stringify(verifiedAccount.cookie)};const session=await(await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:cookie,'Content-Type':'application/json'},body:'{}'})).json();const r=await fetch(base+'/api/admin/settings',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({revision:0,maxConcurrentUploads:3,blockedCountries:['CN']})});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify(await r.json()));`,
),
);
assert.equal(saved.maxConcurrentUploads, 3);
}
const fileState = JSON.parse(
node(
`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}}));`,
@@ -135,6 +143,14 @@ try {
assert.equal(restored.passwordMinLength, 8);
assert.equal(restored.account.id, verifiedAccount.userId);
assert.equal(restored.account.verified, true);
assert.equal(restored.account.administrator, true);
const settings = JSON.parse(
node(
`const r=await fetch('http://127.0.0.1:${port}/api/admin/summary',{headers:{Cookie:${JSON.stringify(verifiedAccount.cookie)}}});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify((await r.json()).settings));`,
),
);
assert.equal(settings.maxConcurrentUploads, 3);
assert.deepEqual(settings.blockedCountries, ['CN']);
}
const preservedText = JSON.parse(
node(
@@ -208,7 +224,7 @@ try {
}
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
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.${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 and session persistence 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.${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' }));

213
tests/e2e/admin.mjs Normal file
View File

@@ -0,0 +1,213 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { chromium } from '@playwright/test';
import { createApp } from '../../apps/server/src/app.ts';
import { loadConfig } from '../../apps/server/src/config.ts';
import { users } from '../../apps/server/src/db/schema.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-admin-e2e-'));
const runtime = await createApp(
{
...loadConfig({
APP_URL: 'http://127.0.0.1',
DATABASE_PATH: resolve(folder, 'db.sqlite'),
UPLOAD_DIR: resolve(folder, 'uploads'),
MIN_FREE_DISK_MB: '0',
LOCATION_DISCOVERY_ENABLED: 'false',
CLIENT_IP_SOURCE: 'cloudflare',
TRUSTED_PROXY_CIDRS: '127.0.0.1,::1',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
webRoot: resolve('apps/web/dist'),
},
{ mailer: async () => {} },
);
let browser;
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
runtime.setDevOrigin(url);
const passwordHash = await runtime.service.auth.password('12345678');
for (const email of ['hello@noname.company', 'other@hop.test'])
runtime.service.db
.insert(users)
.values({
id: randomUUID(),
email,
displayName: email,
passwordHash,
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
browser = await chromium.launch({
headless: true,
...(process.env.HOP_BROWSER_EXECUTABLE
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const errors = [];
async function page(width = 1280, ip = '8.8.4.4', country = 'KR') {
const context = await browser.newContext({
viewport: { width, height: 900 },
isMobile: width < 600,
hasTouch: width < 600,
extraHTTPHeaders: {
'CF-Connecting-IP': ip,
'CF-IPCountry': country,
'CF-IPCity': country === 'CN' ? 'Beijing' : 'Daejeon',
'CF-Region': country === 'CN' ? 'Beijing' : 'Daejeon',
},
});
const page = await context.newPage();
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(url);
await page.locator('.app-shell[data-online="true"]').waitFor();
return page;
}
const sender = await page(1280, '1.1.1.1', 'CN'),
receiver = await page(390, '8.8.8.8', 'KR');
async function session(page) {
return page.evaluate(async () => {
const response = await fetch('/api/guest/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
return response.json();
});
}
const a = await session(sender),
b = await session(receiver);
async function call(page, csrf, path, body, method = 'POST') {
return page.evaluate(
async ({ csrf, path, body, method }) => {
const response = await fetch('/api' + path, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrf,
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify(body),
});
const value = await response.json();
if (!response.ok) throw Error(value.message);
return value;
},
{ csrf, path, body, method },
);
}
const invite = await call(sender, a.csrfToken, '/spaces', {});
await call(receiver, b.csrfToken, '/spaces/join', { code: invite.code });
const transfer = await call(sender, a.csrfToken, '/transfer-requests', {
targetDeviceId: b.device.id,
kind: 'file',
count: 1,
byteSize: 4,
files: [{ name: 'sample.mp4', size: 4, type: 'video/mp4' }],
});
await call(receiver, b.csrfToken, '/transfer-requests/' + transfer.requestId + '/accept', {});
await sender.evaluate(
async ({ csrf, id }) => {
const body = new FormData();
body.append('file', new Blob(['test'], { type: 'video/mp4' }), 'sample.mp4');
const response = await fetch('/api/uploads?requestId=' + id + '&fileIndex=0', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf, 'Idempotency-Key': crypto.randomUUID() },
body,
});
if (!response.ok) throw Error(await response.text());
},
{ csrf: a.csrfToken, id: transfer.requestId },
);
async function login(page, email) {
await page.locator('.login-button').click();
await page.locator('.account-auth').waitFor();
await page.getByLabel('이메일', { exact: true }).fill(email);
await page.getByLabel('비밀번호', { exact: true }).fill('12345678');
await page
.locator('.account-auth')
.getByRole('button', { name: '로그인', exact: true })
.click();
await page.locator('.account-workspace').waitFor();
}
const normal = await page();
await login(normal, 'other@hop.test');
await normal.getByRole('button', { name: '더보기', exact: true }).click();
assert.equal(await normal.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0);
assert.equal(await normal.evaluate(async () => (await fetch('/api/admin/summary')).status), 403);
const admin = await page();
await login(admin, 'hello@noname.company');
await admin.getByRole('button', { name: '더보기', exact: true }).click();
await admin.getByRole('menuitem', { name: '관리자', exact: true }).click();
await admin.locator('.admin-stats').waitFor();
await admin.locator('.admin-file').filter({ hasText: 'sample.mp4' }).waitFor();
assert.match(await admin.locator('.admin-file').innerText(), /중국 · Beijing/);
assert.match(await admin.locator('.admin-file').innerText(), /1\.1\.1\.1/);
await admin.getByRole('button', { name: '신규 업로드 중지', exact: true }).click();
await admin.getByText('신규 업로드 중지됨', { exact: true }).waitFor();
assert.equal(runtime.service.admin.state.uploadsPaused, true);
await admin.getByRole('button', { name: '업로드 재개', exact: true }).click();
await admin.getByText('신규 업로드 허용 중', { exact: true }).waitFor();
await admin.getByRole('button', { name: '접속 · 용량 설정', exact: true }).click();
await admin.getByLabel('동시에 처리할 업로드 수').fill('3');
await admin.getByLabel('전체 파일 저장 한도 (GiB)').fill('16');
await admin.getByLabel('차단할 국가 코드').fill('CN');
await admin.getByLabel('차단할 IP 또는 IP 대역').fill('9.9.9.0/24');
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
assert.equal(runtime.service.admin.state.maxConcurrentUploads, 3);
assert.equal(runtime.service.admin.state.totalStorageLimitMb, 16384);
assert.deepEqual(runtime.service.admin.state.blockedCountries, ['CN']);
assert.equal(await sender.evaluate(async () => (await fetch('/api/device/me')).status), 403);
await admin.getByRole('button', { name: '저장 파일', exact: true }).click();
await admin.screenshot({ path: 'tests/e2e/admin-desktop.png', fullPage: true });
await admin.getByRole('button', { name: '원본 삭제', exact: true }).click();
await admin.getByRole('alertdialog').waitFor();
await admin.getByRole('button', { name: '취소', exact: true }).click();
assert.equal(await admin.locator('.admin-file').count(), 1);
await admin.getByRole('button', { name: '원본 삭제', exact: true }).click();
await admin.getByRole('button', { name: '원본 삭제 확인', exact: true }).click();
await admin.getByText('저장된 파일이 없습니다.', { exact: true }).waitFor();
assert.equal(runtime.service.admin.summary().storedBytes, 0);
await admin.getByRole('button', { name: '관리 기록', exact: true }).click();
await admin.getByText('원본 파일 삭제', { exact: true }).waitFor();
const mobile = await page(390);
await login(mobile, 'hello@noname.company');
await mobile.getByRole('button', { name: '더보기', exact: true }).click();
await mobile.getByRole('menuitem', { name: '관리자', exact: true }).click();
await mobile.locator('.admin-stats').waitFor();
await mobile.getByRole('button', { name: '접속 · 용량 설정', exact: true }).click();
assert.equal(
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
assert.equal(
await mobile
.getByLabel('차단할 국가 코드')
.evaluate((element) => parseFloat(getComputedStyle(element).fontSize) >= 16),
true,
);
await mobile.screenshot({ path: 'tests/e2e/admin-mobile.png', fullPage: true });
await mobile.getByRole('button', { name: '메인으로', exact: true }).click();
await mobile.locator('.nearby-section').waitFor();
await mobile.locator('.login-button').click();
await mobile.getByRole('button', { name: '로그아웃', exact: true }).click();
await mobile.locator('.nearby-section').waitFor();
await mobile.getByRole('button', { name: '더보기', exact: true }).click();
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.',
);
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}

View File

@@ -123,13 +123,11 @@ try {
document.dispatchEvent(new Event('visibilitychange'));
});
await receiver.locator('button.peer-card').waitFor({ state: 'hidden' });
await sender
.locator('.send-dialog input[type="file"]')
.setInputFiles({
name: 'picker.txt',
mimeType: 'text/plain',
buffer: Buffer.from('file picker return'),
});
await sender.locator('.send-dialog input[type="file"]').setInputFiles({
name: 'picker.txt',
mimeType: 'text/plain',
buffer: Buffer.from('file picker return'),
});
await sender.evaluate(() => {
window.__geoVisible = true;
document.dispatchEvent(new Event('visibilitychange'));