Make admin quotas editable and simplify uploader storage
This commit is contained in:
@@ -115,10 +115,11 @@ 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'],allowedIps:['127.0.0.1'],autoBlockEnabled:true})});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify(await r.json()));`,
|
||||
`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,accountBytes:3e9,deviceBytes:4e9,devicesTotalBytes:12e9,perFileBytes:1e9,minDiskFreeBytes:0,blockedCountries:['CN'],allowedIps:['127.0.0.1'],autoBlockEnabled:true})});if(!r.ok)throw Error(await r.text());console.log(JSON.stringify(await r.json()));`,
|
||||
),
|
||||
);
|
||||
assert.equal(saved.maxConcurrentUploads, 3);
|
||||
assert.equal(saved.accountBytes, 3e9);
|
||||
node(
|
||||
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');db.prepare('INSERT INTO automatic_blocks (ip,countryCode,reason,createdAt,expiresAt) VALUES (?,?,?,?,?)').run('8.8.8.8','KR','login-failures',Date.now(),Date.now()+1800000);db.close();`,
|
||||
);
|
||||
@@ -153,6 +154,14 @@ try {
|
||||
),
|
||||
);
|
||||
assert.equal(settings.maxConcurrentUploads, 3);
|
||||
for (const [key, value] of Object.entries({
|
||||
accountBytes: 3e9,
|
||||
deviceBytes: 4e9,
|
||||
devicesTotalBytes: 12e9,
|
||||
perFileBytes: 1e9,
|
||||
minDiskFreeBytes: 0,
|
||||
}))
|
||||
assert.equal(settings[key], value, key);
|
||||
assert.deepEqual(settings.blockedCountries, ['CN']);
|
||||
assert.deepEqual(settings.allowedIps, ['127.0.0.1']);
|
||||
assert.equal(settings.autoBlockEnabled, true);
|
||||
@@ -216,7 +225,7 @@ try {
|
||||
assert.deepEqual(mode, { uid: 12001, gid: 12001, mode: 0o700 });
|
||||
const originals = resolve(folder, 'originals');
|
||||
const stored = readdirSync(originals)
|
||||
.filter((key) => key.startsWith('guest-'))
|
||||
.filter((key) => /^[a-f0-9-]{36}$/.test(key))
|
||||
.flatMap((owner) =>
|
||||
readdirSync(resolve(originals, owner)).map((key) => ({
|
||||
owner,
|
||||
@@ -226,7 +235,7 @@ try {
|
||||
)
|
||||
.find(({ path }) => readFileSync(path).toString() === 'Docker file persistence');
|
||||
assert(stored, 'uploaded file is visible under its guest owner in the host project');
|
||||
assert.match(stored.owner, /^guest-[a-f0-9-]{36}$/);
|
||||
assert.match(stored.owner, /^[a-f0-9-]{36}$/);
|
||||
assert.match(stored.key, /^[a-f0-9-]{36}\.txt$/);
|
||||
assert.equal(existsSync(resolve(folder, 'completed')), false);
|
||||
unlinkSync(stored.path);
|
||||
@@ -249,8 +258,46 @@ try {
|
||||
assert(visible.some((item) => item.file?.name === 'resume.bin'));
|
||||
}
|
||||
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
|
||||
if (process.env.HOP_CHECK_RESET === '1') {
|
||||
const protectedState = `import Database from 'better-sqlite3';import {createHash} from 'node:crypto';const db=new Database('/data/database/hop.sqlite');const tables=['users','account_sessions','account_tokens','user_rules','admin_settings','user_activity'];const rows=tables.map(t=>db.prepare('SELECT * FROM '+t).all());console.log(createHash('sha256').update(JSON.stringify(rows)).digest('hex'));db.close();`;
|
||||
const beforeReset = node(protectedState);
|
||||
docker('stop', name);
|
||||
const reset = docker(
|
||||
'run',
|
||||
'--rm',
|
||||
'--network',
|
||||
'none',
|
||||
'--volumes-from',
|
||||
name,
|
||||
'--user',
|
||||
runtimeUser,
|
||||
'--mount',
|
||||
`type=bind,source=${resolve('scripts/reset-transfer-records.mjs')},target=/app/scripts/reset-transfer-records.mjs,readonly`,
|
||||
'--env',
|
||||
'DATABASE_PATH=/data/database/hop.sqlite',
|
||||
'--entrypoint',
|
||||
'node',
|
||||
process.env.HOP_CHECK_IMAGE ?? 'hop-phase1-check:local',
|
||||
'/app/scripts/reset-transfer-records.mjs',
|
||||
'--confirm-reset-transfer-records',
|
||||
);
|
||||
assert.match(reset, /초기화 완료/);
|
||||
docker('start', name);
|
||||
wait();
|
||||
assert.equal(
|
||||
node(protectedState),
|
||||
beforeReset,
|
||||
'offline reset preserves user/auth/admin data exactly',
|
||||
);
|
||||
const emptied = JSON.parse(
|
||||
node(
|
||||
`import Database from 'better-sqlite3';const db=new Database('/data/database/hop.sqlite');console.log(JSON.stringify({files:db.prepare('SELECT count(*) AS n FROM files').get().n,requests:db.prepare('SELECT count(*) AS n FROM transfer_requests').get().n,guests:db.prepare('SELECT count(*) AS n FROM guest_sessions').get().n,integrity:db.pragma('integrity_check',{simple:true})}));db.close();`,
|
||||
),
|
||||
);
|
||||
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.${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_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' }));
|
||||
|
||||
@@ -113,19 +113,22 @@ try {
|
||||
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 uploadFile(page, csrf, id, name, type) {
|
||||
await page.evaluate(
|
||||
async ({ csrf, id, name, type }) => {
|
||||
const body = new FormData();
|
||||
body.append('file', new Blob(['test'], { type }), name);
|
||||
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, id, name, type },
|
||||
);
|
||||
}
|
||||
await uploadFile(sender, a.csrfToken, transfer.requestId, 'sample.mp4', 'video/mp4');
|
||||
async function login(page, email) {
|
||||
await page.locator('.login-button').click();
|
||||
await page.locator('.account-auth').waitFor();
|
||||
@@ -150,7 +153,8 @@ try {
|
||||
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/);
|
||||
assert.match(await admin.locator('.admin-file-info').first().innerText(), /^\[.+\]\s+guest-/);
|
||||
assert.match(await admin.locator('.admin-file-owner').innerText(), /^[a-f0-9-]{36}\s+비로그인$/);
|
||||
assert.doesNotMatch(await admin.locator('.admin-file-owner').innerText(), /guest-|user-/);
|
||||
assert.match(await admin.locator('.admin-file').innerText(), /접속 지역 · 중국\(CN\) · Beijing/);
|
||||
assert.doesNotMatch(await admin.locator('.admin-file').innerText(), /최근 접속/);
|
||||
assert.match(
|
||||
@@ -163,42 +167,67 @@ try {
|
||||
await admin.getByRole('button', { name: '업로드 재개', exact: true }).click();
|
||||
await admin.getByText('신규 업로드 허용 중', { exact: true }).waitFor();
|
||||
await admin.getByRole('button', { name: '용량 설정', exact: true }).click();
|
||||
assert.match(
|
||||
await admin.locator('.admin-limit-details').innerText(),
|
||||
/로그인 보관함[\s\S]*계정당 2\.1GB/,
|
||||
);
|
||||
assert.match(
|
||||
await admin.locator('.admin-limit-details').innerText(),
|
||||
/기기 전송 업로드[\s\S]*기기 세션당 2\.1GB/,
|
||||
);
|
||||
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();
|
||||
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
|
||||
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
runtime.service.admin.state.totalStorageLimitMb,
|
||||
20480,
|
||||
'rounded GB presentation must not change the stored quota',
|
||||
'unmodified rounded GB keeps exact stored quota',
|
||||
);
|
||||
await admin.getByLabel('동시에 처리할 업로드 수').fill('3');
|
||||
await admin.getByLabel('전체 파일 저장 한도 (GB)').fill('16');
|
||||
assert.equal(runtime.service.admin.state.accountBytes, 2147483648);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
await admin.getByLabel('동시에 처리할 업로드 수', { exact: true }).fill('8');
|
||||
await admin.getByRole('button', { name: '취소', exact: true }).click();
|
||||
assert.equal(runtime.service.admin.state.maxConcurrentUploads, 4);
|
||||
assert.equal(await capacityForm.locator('input').count(), 0);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
await admin.getByLabel('동시에 처리할 업로드 수', { exact: true }).fill('3');
|
||||
await admin.getByLabel('전체 파일 저장 한도 (GB)', { exact: true }).fill('16');
|
||||
await admin.getByLabel('로그인 보관함 계정당 한도 (GB)', { exact: true }).fill('3');
|
||||
await admin.getByLabel('기기 세션당 업로드 한도 (GB)', { exact: true }).fill('4');
|
||||
await admin.getByLabel('기기 전송 업로드 전체 한도 (GB)', { exact: true }).fill('12');
|
||||
await admin.getByLabel('파일 한 개 한도 (GB)', { exact: true }).fill('1');
|
||||
await admin.getByLabel('NAS 최소 여유 공간 (GB)', { exact: true }).fill('0');
|
||||
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, Math.round(16e9 / 1048576));
|
||||
for (const [key, expected] of Object.entries({
|
||||
accountBytes: 3e9,
|
||||
deviceBytes: 4e9,
|
||||
devicesTotalBytes: 12e9,
|
||||
perFileBytes: 1e9,
|
||||
minDiskFreeBytes: 0,
|
||||
}))
|
||||
assert.equal(runtime.service.admin.state[key], expected);
|
||||
assert.equal(await capacityForm.locator('input').count(), 0);
|
||||
await admin.screenshot({ path: 'tests/e2e/admin-capacity-desktop.png', fullPage: true });
|
||||
await admin.getByRole('button', { name: '네트워크', exact: true }).click();
|
||||
assert.equal(await admin.locator('.network-auto-fields input').count(), 0);
|
||||
assert.equal(await admin.getByRole('switch').count(), 0);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('50');
|
||||
await admin.getByRole('button', { name: '취소', exact: true }).click();
|
||||
assert.equal(await admin.getByRole('switch').count(), 0);
|
||||
await admin.getByRole('button', { name: '수정', exact: true }).click();
|
||||
async function addRule(page, list, kind, value) {
|
||||
await page.getByRole('button', { name: list + ' 추가', exact: true }).click();
|
||||
const modal = page.getByRole('dialog', { name: list + ' 추가', exact: true });
|
||||
await modal.getByLabel('등록 대상').selectOption(kind);
|
||||
await modal
|
||||
.getByLabel(kind === 'country' ? '국가 코드' : 'IP 주소 또는 CIDR', { exact: true })
|
||||
.getByLabel(kind === 'country' ? '국가 코드' : 'IP 주소 또는 IP 대역', { exact: true })
|
||||
.fill(value);
|
||||
await modal.getByRole('button', { name: '추가', exact: true }).click();
|
||||
await modal.waitFor({ state: 'hidden' });
|
||||
}
|
||||
await addRule(admin, '블랙리스트', 'country', 'cn');
|
||||
await addRule(admin, '블랙리스트', 'ip', '9.9.9.0/24');
|
||||
await addRule(admin, '블랙리스트', 'ip', '9.9.9.*');
|
||||
await addRule(admin, '화이트리스트', 'country', 'KR');
|
||||
await addRule(admin, '화이트리스트', 'ip', '8.8.8.0/24');
|
||||
await addRule(admin, '화이트리스트', 'ip', '8.8.8.*');
|
||||
await addRule(admin, '화이트리스트', 'ip', '4.4.4.4');
|
||||
await admin.getByRole('checkbox', { name: '4.4.4.4 허용 규칙 선택', exact: true }).check();
|
||||
await admin
|
||||
@@ -209,6 +238,9 @@ try {
|
||||
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('3');
|
||||
await admin.getByRole('button', { name: '네트워크 설정 저장', exact: true }).click();
|
||||
await admin.getByText('네트워크 설정을 저장했어요.', { exact: true }).waitFor();
|
||||
assert.equal(await admin.getByRole('switch').count(), 0);
|
||||
assert.equal(runtime.service.admin.state.autoLoginFailures, 3);
|
||||
assert.deepEqual(runtime.service.admin.state.blockedIps, ['9.9.9.0/24']);
|
||||
assert.deepEqual(runtime.service.admin.state.allowedCountries, ['KR']);
|
||||
assert.deepEqual(runtime.service.admin.state.allowedIps, ['8.8.8.0/24']);
|
||||
assert.equal(runtime.service.admin.state.autoBlockEnabled, true);
|
||||
@@ -241,6 +273,25 @@ try {
|
||||
await admin.getByRole('button', { name: '원본 삭제 확인', exact: true }).click();
|
||||
await admin.getByText('저장된 파일이 없습니다.', { exact: true }).waitFor();
|
||||
assert.equal(runtime.service.admin.summary().storedBytes, 0);
|
||||
const normalSession = await session(normal);
|
||||
const owned = await call(normal, normalSession.csrfToken, '/vault', {
|
||||
kind: 'file',
|
||||
files: [{ name: 'account-owner.txt', size: 4, type: 'text/plain' }],
|
||||
});
|
||||
await uploadFile(
|
||||
normal,
|
||||
normalSession.csrfToken,
|
||||
owned.requestId,
|
||||
'account-owner.txt',
|
||||
'text/plain',
|
||||
);
|
||||
await admin.getByRole('button', { name: '새로고침', exact: true }).click();
|
||||
const ownedCard = admin.locator('.admin-file').filter({ hasText: 'account-owner.txt' });
|
||||
await ownedCard.waitFor();
|
||||
const ownerLabel = await ownedCard.locator('.admin-file-owner').innerText();
|
||||
assert.match(ownerLabel, /^[a-f0-9-]{36}\s+other@hop\.test$/);
|
||||
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();
|
||||
const changedRow = admin
|
||||
@@ -296,11 +347,22 @@ try {
|
||||
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
assert.equal(await mobile.locator('.admin-settings input').count(), 0);
|
||||
await mobile.getByRole('button', { name: '수정', exact: true }).click();
|
||||
assert.equal(await mobile.getByLabel('파일 한 개 한도 (GB)', { exact: true }).inputValue(), '1');
|
||||
assert.equal(
|
||||
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
await mobile.screenshot({ path: 'tests/e2e/admin-capacity-mobile.png', fullPage: true });
|
||||
await mobile.getByRole('button', { name: '취소', exact: true }).click();
|
||||
await mobile.getByRole('button', { name: '네트워크', exact: true }).click();
|
||||
assert.equal(
|
||||
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
assert.equal(await mobile.getByRole('switch').count(), 0);
|
||||
await mobile.getByRole('button', { name: '수정', exact: true }).click();
|
||||
await mobile.getByRole('button', { name: '블랙리스트 추가', exact: true }).click();
|
||||
const ruleModal = mobile.getByRole('dialog', { name: '블랙리스트 추가', exact: true });
|
||||
assert.equal(
|
||||
|
||||
Reference in New Issue
Block a user