Align capacity units and scope administrator network edits

This commit is contained in:
2026-10-15 14:20:39 +09:00
parent 9cda9cbe84
commit 60a14e9c6c
14 changed files with 458 additions and 259 deletions

View File

@@ -83,7 +83,7 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **
저장 파일·업로드 주체·파일별 전송 요청 시점의 접속 지역/IP·남은 디스크와 진행 중 업로드를 확인하고, 확인 후 원본을 삭제할 수 있습니다. 삭제하면 같은 원본을 참조하는 모든 보관함 항목도 무효화됩니다. 파일 내용 다운로드 기능은 제공하지 않습니다. 화면은 새로고침할 때 갱신하며 지속적인 폴링은 하지 않습니다. 저장 파일·업로드 주체·파일별 전송 요청 시점의 접속 지역/IP·남은 디스크와 진행 중 업로드를 확인하고, 확인 후 원본을 삭제할 수 있습니다. 삭제하면 같은 원본을 참조하는 모든 보관함 항목도 무효화됩니다. 파일 내용 다운로드 기능은 제공하지 않습니다. 화면은 새로고침할 때 갱신하며 지속적인 폴링은 하지 않습니다.
관리자는 신규 파일 업로드 중지/재개, 동시 업로드 수와 전체 파일 저장 한도, 국가 코드 및 IP/CIDR 차단을 설정할 수 있습니다. 기본값은 **동시 4개·전체 20GiB·차단 없음**입니다. 개인/기기별 한도와 최소 디스크 여유 공간은 함께 적용합니다. 중지 전에 수락한 전송·기존 다운로드·링크/텍스트는 유지합니다. 전체 한도를 줄여도 기존 파일은 삭제하지 않습니다. 관리자는 신규 파일 업로드 중지/재개, 동시 업로드 수와 전체 파일 저장 한도, 국가 코드 및 IP/CIDR 차단을 설정할 수 있습니다. 기본값은 **동시 4개·전체 20GB·차단 없음**입니다. 개인/기기별 한도와 최소 디스크 여유 공간은 함께 적용합니다. 중지 전에 수락한 전송·기존 다운로드·링크/텍스트는 유지합니다. 전체 한도를 줄여도 기존 파일은 삭제하지 않습니다.
네트워크 탭에서 국가·IP 규칙과 자동 차단을 관리합니다. 규칙은 HTTP·기기 연결에 적용하고 차단된 진행 중 업로드와 연결도 종료합니다. IPv4·IPv6와 IP 대역을 지원하며 현재 관리자 접속을 차단하는 설정은 거절합니다. 국가는 검증된 Cloudflare 제공 값만 사용하고, 미제공 국가는 추정하지 않습니다. VPN·실제 거주지 추적은 하지 않습니다. 도시 정보 설정은 [배포 문서](deploy.md#전송-파일이-저장되는-곳과-정리-시점)를 참고하세요. 네트워크 탭에서 국가·IP 규칙과 자동 차단을 관리합니다. 규칙은 HTTP·기기 연결에 적용하고 차단된 진행 중 업로드와 연결도 종료합니다. IPv4·IPv6와 IP 대역을 지원하며 현재 관리자 접속을 차단하는 설정은 거절합니다. 국가는 검증된 Cloudflare 제공 값만 사용하고, 미제공 국가는 추정하지 않습니다. VPN·실제 거주지 추적은 하지 않습니다. 도시 정보 설정은 [배포 문서](deploy.md#전송-파일이-저장되는-곳과-정리-시점)를 참고하세요.
@@ -129,13 +129,13 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **
업로드 주체 이름은 `[iPhone]`처럼 대괄호로 구분하고 관리자·보관함의 만료일은 `[보관 만료] 2026년 10월 22일 11:02 까지`처럼 한국 시간의 연도·24시간으로 표시합니다. 푸터의 발견 허용/꺼짐 문구는 숨깁니다. 업로드 주체 이름은 `[iPhone]`처럼 대괄호로 구분하고 관리자·보관함의 만료일은 `[보관 만료] 2026년 10월 22일 11:02 까지`처럼 한국 시간의 연도·24시간으로 표시합니다. 푸터의 발견 허용/꺼짐 문구는 숨깁니다.
전송·수신·보관함·관리자 화면은 같은 용량 표시 함수를 사용합니다. 1MB 미만은 KB, 1MB 이상은 MB, 1GB 이상은 GB이며 KB/MB/GB는 1,000 단위입니다. 예: `938KB`, `1.1MB`, `1.7GB`. 기존 서버 저장 한도와 환경변수의 MiB 기준은 유지합니다. 관리자 화면의 GB 설정을 바꾸지 않고 다른 규칙만 저장할 때는 반올림된 표시값 때문에 기존 용량이 바뀌지 않습니다. 전송·수신·보관함·관리자 화면은 같은 용량 표시 함수를 사용합니다. 1MB 미만은 KB, 1MB 이상은 MB, 1GB 이상은 GB이며 사용자 요청에 따라 **1,024 기준에 익숙한 KB/MB/GB 명칭**을 사용합니다(1GB = 1,024MB). 예: `938KB`, `1.1MB`, `20.0GB`. 기존 서버 저장 한도와 환경변수의 MiB 기준은 유지하므로 기존 20GiB 한도는 화면에서 20GB입니다. 관리자 입력도 같은 기준이며 `15` 입력은 15GB를 뜻합니다. 반올림된 표시값을 수정하지 않으면 기존의 정확한 바이트 한도를 보존합니다.
파일별 접속 정보는 DB에 최초 전송 요청 당시의 값을 저장하여 이후 같은 사람이 다른 지역에서 전송해도 바뀌지 않습니다. 같은 요청의 여러 파일은 같은 요청 당시 정보를 사용하며 원본 재전송은 원래 정보를 유지합니다. 기본 최대 7일 보관하고 실패·삭제한 파일의 기록은 주기 정리합니다. 기존 파일은 `owner-info.json`에 동일한 요청의 기록이 있을 때만 복원하고, 없으면 다른 파일의 최근 정보로 추정하지 않습니다. 파일 내용·GPS 좌표를 추가로 수집하지 않으며 관리자 외에는 제공하지 않습니다. 파일별 접속 정보는 DB에 최초 전송 요청 당시의 값을 저장하여 이후 같은 사람이 다른 지역에서 전송해도 바뀌지 않습니다. 같은 요청의 여러 파일은 같은 요청 당시 정보를 사용하며 원본 재전송은 원래 정보를 유지합니다. 기본 최대 7일 보관하고 실패·삭제한 파일의 기록은 주기 정리합니다. 기존 파일은 `owner-info.json`에 동일한 요청의 기록이 있을 때만 복원하고, 없으면 다른 파일의 최근 정보로 추정하지 않습니다. 파일 내용·GPS 좌표를 추가로 수집하지 않으며 관리자 외에는 제공하지 않습니다.
## 네트워크·사용자 관리 ## 네트워크·사용자 관리
관리자 탭은 **저장 파일 / 용량 설정 / 네트워크 / 사용자 / 관리 기록**입니다. 국가는 `일본(JP)`처럼 이름과 코드를 함께 표시합니다. 네트워크에는 자동 블랙리스트·블랙리스트·화이트리스트를 둡니다. 국가 또는 IPv4/IPv6 주소·CIDR을 추가 모달에서 한 항목씩 등록하고 표에서 선택 제거한 뒤 네트워크 설정을 저장합니다. 기존 수동 차단 규칙은 유지합니다. 관리자 탭은 **저장 파일 / 용량 설정 / 네트워크 / 사용자 / 관리 기록**입니다. 국가는 `일본(JP)`처럼 이름과 코드를 함께 표시합니다. 네트워크에는 자동 블랙리스트·블랙리스트·화이트리스트를 둡니다. 국가 또는 IPv4/IPv6 주소·CIDR을 추가 모달에서 한 항목씩 등록하고 표에서 선택 제거하며, 추가·제거는 즉시 적용합니다. 기존 수동 차단 규칙은 유지합니다.
블랙리스트·자동 차단이 우선합니다. 화이트리스트가 비어 있으면 공개하고, 항목이 있으면 등록 국가 **또는** IP/대역에 해당하는 접속만 허용합니다. 국가가 확인되지 않은 접속은 허용 IP와 일치해야 합니다. 현재 관리자 자신을 차단하는 수동 규칙은 저장할 수 없습니다. 블랙리스트·자동 차단이 우선합니다. 화이트리스트가 비어 있으면 공개하고, 항목이 있으면 등록 국가 **또는** IP/대역에 해당하는 접속만 허용합니다. 국가가 확인되지 않은 접속은 허용 IP와 일치해야 합니다. 현재 관리자 자신을 차단하는 수동 규칙은 저장할 수 없습니다.
@@ -147,6 +147,6 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **
관리 기록은 `동시에 처리할 업로드 수 5 > 10`, `블랙리스트 추가: 중국(CN)`처럼 변경 사항만 보여 줍니다. 변경이 없는 저장은 기록하지 않습니다. 이전 기록은 비교 가능한 전 정보가 있을 때만 차이를 표시합니다. 읽음 기능은 두지 않고 개별·선택·전체 삭제만 제공하며 기록을 지워도 설정·파일·자동 차단 목록은 바뀌지 않습니다. 관리 기록은 `동시에 처리할 업로드 수 5 > 10`, `블랙리스트 추가: 중국(CN)`처럼 변경 사항만 보여 줍니다. 변경이 없는 저장은 기록하지 않습니다. 이전 기록은 비교 가능한 전 정보가 있을 때만 차이를 표시합니다. 읽음 기능은 두지 않고 개별·선택·전체 삭제만 제공하며 기록을 지워도 설정·파일·자동 차단 목록은 바뀌지 않습니다.
관리자 용량·네트워크 설정은 기본적으로 값만 표시하며 **수정 → 저장/취소**로 변경합니다. 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간도 관리자 화면에서 설정하며 저장한 값은 DB에 보존됩니다. IP 대역은 CIDR 외에 `203.0.113.*`처럼 뒤쪽 전체 자리의 IPv4 와일드카드도 입력할 수 있고, `203.0.113.0/24`로 정규화합니다. 저장 파일 목록은 업로더 ID와 계정 이메일(비로그인은 ‘비로그인’)을 표시합니다. 관리자 용량은 기본적으로 값만 표시하며 **수정 → 설정 저장/취소**로 변경합니다. 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간도 설정하며 저장한 값은 DB에 보존됩니다. GB 입력은 소수점 셋째 자리까지 지원하고 입력창을 벗어나면 `20.000`처럼 정리합니다. **기본값으로 돌리기**는 NAS `.env`의 초기 설정값을 초안으로 불러오며 **설정 저장**을 눌러야 적용됩니다. 네트워크 토글과 규칙 추가·제거는 언제든 각각 즉시 적용하고, 자동 블랙리스트의 숫자 감지 기준만 해당 영역의 **수정 → 감지 기준 저장/취소**로 변경합니다. 서로 다른 영역의 변경이 입력 중인 감지 기준을 함께 저장하지 않습니다. IP 대역은 CIDR 외에 `203.0.113.*`처럼 뒤쪽 전체 자리의 IPv4 와일드카드도 입력할 수 있고, `203.0.113.0/24`로 정규화합니다. 저장 파일 목록은 업로더 ID와 계정 이메일(비로그인은 ‘비로그인’)을 표시합니다.
네트워크의 **파일 전송 금지**를 켜면 새 파일 전송/저장 메뉴를 숨기고 링크·텍스트만 허용합니다. 이미 수락한 파일 업로드와 기존 다운로드는 유지하며, 변경 사항은 접속 중인 사용자에게도 반영합니다. 용량 표시는 `7,687.0GB`처럼 천 단위 쉼표를 사용합니다. 네트워크의 **파일 전송 금지**를 켜면 새 파일 전송/저장 메뉴를 숨기고 링크·텍스트만 허용합니다. 이미 수락한 파일 업로드와 기존 다운로드는 유지하며, 변경 사항은 접속 중인 사용자에게도 반영합니다. 용량 표시는 `7,687.0GB`처럼 천 단위 쉼표를 사용합니다.

View File

@@ -68,11 +68,31 @@ const fields = {
uploadsPaused: z.boolean(), uploadsPaused: z.boolean(),
maxConcurrentUploads: z.number().int().min(1).max(16), maxConcurrentUploads: z.number().int().min(1).max(16),
totalStorageLimitMb: z.number().int().min(1024).max(1048576), totalStorageLimitMb: z.number().int().min(1024).max(1048576),
accountBytes: z.number().int().min(1).max(1e12), accountBytes: z
deviceBytes: z.number().int().min(1).max(1e12), .number()
devicesTotalBytes: z.number().int().min(1).max(1e12), .int()
perFileBytes: z.number().int().min(1).max(1e12), .min(1)
minDiskFreeBytes: z.number().int().min(0).max(1e12), .max(1024 ** 4),
deviceBytes: z
.number()
.int()
.min(1)
.max(1024 ** 4),
devicesTotalBytes: z
.number()
.int()
.min(1)
.max(1024 ** 4),
perFileBytes: z
.number()
.int()
.min(1)
.max(1024 ** 4),
minDiskFreeBytes: z
.number()
.int()
.min(0)
.max(1024 ** 4),
blockedCountries: z blockedCountries: z
.array( .array(
z z
@@ -101,7 +121,11 @@ const fields = {
autoLoginFailures: z.number().int().min(3).max(1000), autoLoginFailures: z.number().int().min(3).max(1000),
autoRateViolations: z.number().int().min(3).max(1000), autoRateViolations: z.number().int().min(3).max(1000),
autoUploadRequests: z.number().int().min(3).max(10000), autoUploadRequests: z.number().int().min(3).max(10000),
autoUploadBytes: z.number().int().min(1000000).max(1e12), autoUploadBytes: z
.number()
.int()
.min(1000000)
.max(1024 ** 4),
autoBlockMinutes: z.number().int().min(1).max(10080), autoBlockMinutes: z.number().int().min(1).max(10080),
}; };
const networkDefaults = { const networkDefaults = {
@@ -113,7 +137,7 @@ const networkDefaults = {
autoLoginFailures: 10, autoLoginFailures: 10,
autoRateViolations: 20, autoRateViolations: 20,
autoUploadRequests: 30, autoUploadRequests: 30,
autoUploadBytes: 10e9, autoUploadBytes: 10 * 1024 ** 3,
autoBlockMinutes: 30, autoBlockMinutes: 30,
}; };
const stateSchema = z.object({ revision: z.number().int().min(0), ...fields }).strict(); const stateSchema = z.object({ revision: z.number().int().min(0), ...fields }).strict();
@@ -154,19 +178,20 @@ export class Administration {
.where(eq(s.adminSettings.name, 'controls')) .where(eq(s.adminSettings.name, 'controls'))
.get(); .get();
const capacityDefaults = { const capacityDefaults = {
maxConcurrentUploads: service.cfg.maxConcurrentUploads,
totalStorageLimitMb: service.cfg.totalStorageQuota / 1048576,
accountBytes: service.cfg.userQuota, accountBytes: service.cfg.userQuota,
deviceBytes: service.cfg.guestQuota, deviceBytes: service.cfg.guestQuota,
devicesTotalBytes: service.cfg.totalGuestQuota, devicesTotalBytes: service.cfg.totalGuestQuota,
perFileBytes: service.cfg.maxFileBytes, perFileBytes: service.cfg.maxFileBytes,
minDiskFreeBytes: service.cfg.minFreeBytes, minDiskFreeBytes: service.cfg.minFreeBytes,
}; };
this.capacityDefaults = Object.freeze(capacityDefaults);
this.state = row this.state = row
? stateSchema.parse({ ...networkDefaults, ...capacityDefaults, ...JSON.parse(row.value) }) ? stateSchema.parse({ ...networkDefaults, ...capacityDefaults, ...JSON.parse(row.value) })
: { : {
revision: 0, revision: 0,
uploadsPaused: false, uploadsPaused: false,
maxConcurrentUploads: service.cfg.maxConcurrentUploads,
totalStorageLimitMb: service.cfg.totalStorageQuota / 1048576,
blockedCountries: [], blockedCountries: [],
blockedIps: [], blockedIps: [],
...networkDefaults, ...networkDefaults,
@@ -371,6 +396,7 @@ export class Administration {
const disk = statfsSync(this.service.cfg.uploadDir, { bigint: true }); const disk = statfsSync(this.service.cfg.uploadDir, { bigint: true });
return { return {
settings: { ...this.state }, settings: { ...this.state },
capacityDefaults: { ...this.capacityDefaults },
limits: { limits: {
perFileBytes: this.state.perFileBytes, perFileBytes: this.state.perFileBytes,
accountBytes: this.state.accountBytes, accountBytes: this.state.accountBytes,

View File

@@ -102,6 +102,10 @@ test('administrator limits persist, report to clients, reject invalid updates an
a = await f.create(), a = await f.create(),
b = await f.create(); b = await f.create();
await f.space(a, b); await f.space(a, b);
const defaults = (await f.call(admin, '/admin/summary')).json().capacityDefaults;
assert.equal(defaults.perFileBytes, 4);
assert.equal(defaults.accountBytes, f.cfg.userQuota);
assert.equal(defaults.totalStorageLimitMb, f.cfg.totalStorageQuota / 1048576);
assert.equal((await request(f, a, b)).json().code, 'TRANSFER_LIMIT'); assert.equal((await request(f, a, b)).json().code, 'TRANSFER_LIMIT');
assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT'); assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT');
const updated = await patch(f, admin, { const updated = await patch(f, admin, {
@@ -112,6 +116,8 @@ test('administrator limits persist, report to clients, reject invalid updates an
minDiskFreeBytes: 0, minDiskFreeBytes: 0,
}); });
const restored = new Administration(f.service); const restored = new Administration(f.service);
assert.deepEqual(restored.capacityDefaults, defaults);
assert.deepEqual((await f.call(admin, '/admin/summary')).json().capacityDefaults, defaults);
for (const key of [ for (const key of [
'perFileBytes', 'perFileBytes',
'accountBytes', 'accountBytes',
@@ -151,7 +157,7 @@ test('administrator limits persist, report to clients, reject invalid updates an
{ perFileBytes: 0 }, { perFileBytes: 0 },
{ accountBytes: -1 }, { accountBytes: -1 },
{ deviceBytes: 1.2 }, { deviceBytes: 1.2 },
{ devicesTotalBytes: 1e12 + 1 }, { devicesTotalBytes: 1024 ** 4 + 1 },
{ minDiskFreeBytes: -1 }, { minDiskFreeBytes: -1 },
]) { ]) {
assert.equal( assert.equal(
@@ -169,6 +175,13 @@ test('administrator limits persist, report to clients, reject invalid updates an
), ),
), ),
); );
await patch(f, admin, { uploadsPaused: true, autoLoginFailures: 7 });
await patch(f, admin, defaults);
for (const [key, value] of Object.entries(defaults))
assert.equal(f.service.admin.state[key], value, key);
assert.equal(f.service.admin.state.uploadsPaused, true);
assert.equal(f.service.admin.state.autoLoginFailures, 7);
assert.equal(f.service.admin.summary().storedFiles, list.length);
} finally { } finally {
await f.close(); await f.close();
} }

View File

@@ -1,20 +1,27 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { formatSize, formatAdminDate } from '../../web/src/displayFormat.js'; import {
formatSize,
formatAdminDate,
SIZE_KB,
SIZE_MB,
SIZE_GB,
} from '../../web/src/displayFormat.js';
test('shared display sizes use decimal KB below 1MB and switch only at MB/GB boundaries', () => { test('shared display uses familiar 1024-based KB/MB/GB with comma grouping and readable whole-GB quotas', () => {
for (const [bytes, expected] of [ for (const [bytes, expected] of [
[0, '0KB'], [0, '0KB'],
[10, '1KB 미만'], [10, '1KB 미만'],
[1000, '1KB'], [SIZE_KB, '1KB'],
[938000, '938KB'], [938 * SIZE_KB, '938KB'],
[999000, '999KB'], [1023 * SIZE_KB, '1,023KB'],
[1000000, '1.0MB'], [SIZE_MB, '1.0MB'],
[1100000, '1.1MB'], [1.1 * SIZE_MB, '1.1MB'],
[1000000000, '1.0GB'], [SIZE_GB, '1.0GB'],
[1700000000, '1.7GB'], [1.7 * SIZE_GB, '1.7GB'],
[7687e9, '7,687.0GB'], [20 * SIZE_GB, '20.0GB'],
[1234567e9, '1,234,567.0GB'], [7687 * SIZE_GB, '7,687.0GB'],
[1234567 * SIZE_GB, '1,234,567.0GB'],
]) ])
assert.equal(formatSize(bytes), expected); assert.equal(formatSize(bytes), expected);
}); });

View File

@@ -227,6 +227,7 @@ onMounted(async () => {
<CapacityPanel <CapacityPanel
v-else-if="view === 'rules'" v-else-if="view === 'rules'"
:settings="overview.settings" :settings="overview.settings"
:defaults="overview.capacityDefaults"
@saved="networkSaved" @saved="networkSaved"
/> />
<NetworkPanel <NetworkPanel

View File

@@ -1,9 +1,12 @@
<script setup> <script setup>
import { ref, reactive } from 'vue'; import { ref, reactive } from 'vue';
import { Pencil } from '@lucide/vue'; import { Pencil, RotateCcw } from '@lucide/vue';
import { api, showError } from './client'; import { api, showError } from './client';
import { formatSize as size } from './displayFormat'; import { formatSize as size, SIZE_GB } from './displayFormat';
const props = defineProps({ settings: { type: Object, required: true } }); const props = defineProps({
settings: { type: Object, required: true },
defaults: { type: Object, required: true },
});
const emit = defineEmits(['saved']); const emit = defineEmits(['saved']);
const editing = ref(false), const editing = ref(false),
busy = ref(false), busy = ref(false),
@@ -14,7 +17,7 @@ const limits = [
key: 'totalStorageLimitMb', key: 'totalStorageLimitMb',
label: '전체 파일 저장 한도', label: '전체 파일 저장 한도',
hint: '모든 사용자의 원본 파일과 업로드 예약 합계', hint: '모든 사용자의 원본 파일과 업로드 예약 합계',
max: 1099.511, max: 1024,
}, },
{ {
key: 'accountBytes', key: 'accountBytes',
@@ -40,15 +43,30 @@ const limits = [
]; ];
const bytes = (settings, key) => const bytes = (settings, key) =>
key === 'totalStorageLimitMb' ? settings[key] * 1048576 : settings[key]; key === 'totalStorageLimitMb' ? settings[key] * 1048576 : settings[key];
const gb = (settings, key) => Number((bytes(settings, key) / 1e9).toFixed(3)); const gb = (settings, key) => (bytes(settings, key) / SIZE_GB).toFixed(3);
let original; let original, baseline;
function fill(settings) {
baseline = { ...settings };
draft.maxConcurrentUploads = settings.maxConcurrentUploads;
for (const { key } of limits) draft[key] = gb(settings, key);
}
function edit() { function edit() {
original = { ...props.settings }; original = { ...props.settings };
draft.maxConcurrentUploads = original.maxConcurrentUploads; fill(original);
for (const { key } of limits) draft[key] = gb(original, key);
notice.value = ''; notice.value = '';
editing.value = true; editing.value = true;
} }
function resetDefaults() {
if (busy.value || !editing.value) return;
fill(props.defaults);
notice.value = 'NAS 초기 설정값을 불러왔어요. 설정 저장을 누르면 적용됩니다.';
}
function normalize(key, input) {
if (input.value !== '' && input.validity.valid) {
draft[key] = Number(input.value).toFixed(3);
input.value = draft[key];
}
}
function cancel() { function cancel() {
editing.value = false; editing.value = false;
notice.value = '변경을 취소했어요.'; notice.value = '변경을 취소했어요.';
@@ -64,9 +82,9 @@ async function save() {
for (const { key } of limits) { for (const { key } of limits) {
const value = Number(draft[key]); const value = Number(draft[key]);
changes[key] = changes[key] =
value === gb(original, key) value === Number(gb(baseline, key))
? original[key] ? baseline[key]
: Math.round((value * 1e9) / (key === 'totalStorageLimitMb' ? 1048576 : 1)); : Math.round((value * SIZE_GB) / (key === 'totalStorageLimitMb' ? 1048576 : 1));
} }
const settings = await api('/admin/settings', 'PATCH', changes); const settings = await api('/admin/settings', 'PATCH', changes);
editing.value = false; editing.value = false;
@@ -93,6 +111,10 @@ async function save() {
개인별 한도는 각 계정·기기 세션에 공통 적용합니다. 전체 한도는 모든 사용자가 함께 쓰는 개인별 한도는 각 계정·기기 세션에 공통 적용합니다. 전체 한도는 모든 사용자가 함께 쓰는
공간입니다. 공간입니다.
</p> </p>
<p v-if="editing" id="capacity-input-hint" class="admin-muted">
용량은 1GB = 1,024MB 기준으로 소수점 셋째 자리까지 입력하세요. 예: 5.000GB. 입력을 마치면 세
자리로 표시합니다.
</p>
<div v-if="editing" class="admin-settings-grid"> <div v-if="editing" class="admin-settings-grid">
<label <label
>동시에 처리할 업로드 수<input >동시에 처리할 업로드 수<input
@@ -107,18 +129,19 @@ async function save() {
> >
<label v-for="limit in limits" :key="limit.key" <label v-for="limit in limits" :key="limit.key"
>{{ limit.label }} (GB)<input >{{ limit.label }} (GB)<input
v-model.number="draft[limit.key]" :value="draft[limit.key]"
@input="draft[limit.key] = $event.target.value"
@blur="normalize(limit.key, $event.target)"
aria-describedby="capacity-input-hint"
placeholder="0.000"
inputmode="decimal"
:aria-label="limit.label + ' (GB)'" :aria-label="limit.label + ' (GB)'"
type="number" type="number"
:min=" :min="
limit.key === 'minDiskFreeBytes' limit.key === 'minDiskFreeBytes' ? 0 : limit.key === 'totalStorageLimitMb' ? 1 : 0.001
? 0
: limit.key === 'totalStorageLimitMb'
? 1.074
: 0.001
" "
:max="limit.max || 1000" :max="limit.max || 1024"
step="any" step="0.001"
required required
:disabled="busy" :disabled="busy"
/><small>{{ limit.hint }}</small></label /><small>{{ limit.hint }}</small></label
@@ -148,6 +171,9 @@ async function save() {
<div v-if="editing" class="admin-edit-actions"> <div v-if="editing" class="admin-edit-actions">
<button class="button primary" :disabled="busy">설정 저장</button <button class="button primary" :disabled="busy">설정 저장</button
><button type="button" class="button" :disabled="busy" @click="cancel">취소</button> ><button type="button" class="button" :disabled="busy" @click="cancel">취소</button>
<button type="button" class="button" :disabled="busy" @click="resetDefaults">
<RotateCcw :size="15" />기본값으로 돌리기
</button>
</div> </div>
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p> <p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
</form> </form>

View File

@@ -6,24 +6,41 @@ import {
formatAdminDate as date, formatAdminDate as date,
formatCountry as country, formatCountry as country,
formatSize as size, formatSize as size,
SIZE_GB,
} from './displayFormat'; } from './displayFormat';
const props = defineProps({ settings: { type: Object, required: true }, suggestedIp: String }); const props = defineProps({ settings: { type: Object, required: true }, suggestedIp: String });
const emit = defineEmits(['saved', 'activity', 'consumed']); const emit = defineEmits(['saved', 'activity', 'consumed']);
const draft = reactive({}), const state = ref(props.settings),
draft = reactive({}),
busy = ref(false), busy = ref(false),
notice = ref(''), notice = ref(''),
editing = ref(false); editing = ref(false);
let editRevision; const autoKeys = [
'autoWindowMinutes',
'autoLoginFailures',
'autoRateViolations',
'autoUploadRequests',
'autoUploadBytes',
'autoBlockMinutes',
];
const uploadGb = ref('0.000');
let autoOriginal;
function edit() { function edit() {
fill(props.settings); autoOriginal = Object.fromEntries(autoKeys.map((key) => [key, state.value[key]]));
editRevision = props.settings.revision; Object.assign(draft, autoOriginal);
uploadGb.value = (draft.autoUploadBytes / SIZE_GB).toFixed(3);
editing.value = true; editing.value = true;
notice.value = ''; notice.value = '';
} }
function cancel() { function cancel() {
fill(props.settings);
editing.value = false; editing.value = false;
notice.value = '변경을 취소했어요.'; notice.value = '감지 기준 수정을 취소했어요.';
}
function normalizeUpload(input) {
if (input.value !== '' && input.validity.valid) {
uploadGb.value = Number(input.value).toFixed(3);
input.value = uploadGb.value;
}
} }
const selectedBlack = ref([]), const selectedBlack = ref([]),
selectedWhite = ref([]), selectedWhite = ref([]),
@@ -45,45 +62,21 @@ const reasonName = (reason) =>
'file-volume': '요청 파일 용량 초과', 'file-volume': '요청 파일 용량 초과',
})[reason] || reason; })[reason] || reason;
function fill(settings) { function fill(settings) {
for (const key of [ // An older parent refresh must not undo an already completed action.
'uploadsPaused', if (settings.revision < state.value.revision) return;
'blacklistEnabled', state.value = settings;
'allowedCountries',
'allowedIps',
'blockedCountries',
'blockedIps',
'autoBlockEnabled',
'autoWindowMinutes',
'autoLoginFailures',
'autoRateViolations',
'autoUploadRequests',
'autoUploadBytes',
'autoBlockMinutes',
])
draft[key] = Array.isArray(settings[key]) ? [...settings[key]] : settings[key];
selectedBlack.value = [];
selectedWhite.value = [];
} }
watch( watch(
() => props.settings.revision, () => props.settings.revision,
() => { () => fill(props.settings),
if (!editing.value) fill(props.settings);
},
{ immediate: true },
); );
const uploadGb = computed({
get: () => draft.autoUploadBytes / 1e9,
set: (number) => {
draft.autoUploadBytes = Math.round(Number(number) * 1e9);
},
});
const entries = (list) => [ const entries = (list) => [
...draft[list === 'black' ? 'blockedCountries' : 'allowedCountries'].map((value) => ({ ...state.value[list === 'black' ? 'blockedCountries' : 'allowedCountries'].map((value) => ({
kind: 'country', kind: 'country',
value, value,
id: 'country:' + value, id: 'country:' + value,
})), })),
...draft[list === 'black' ? 'blockedIps' : 'allowedIps'].map((value) => ({ ...state.value[list === 'black' ? 'blockedIps' : 'allowedIps'].map((value) => ({
kind: 'ip', kind: 'ip',
value, value,
id: 'ip:' + value, id: 'ip:' + value,
@@ -103,6 +96,20 @@ async function act(work) {
busy.value = false; busy.value = false;
} }
} }
async function persist(changes) {
const settings = await api('/admin/settings', 'PATCH', {
revision: state.value.revision,
...changes,
});
fill(settings);
emit('saved', settings);
}
async function toggle(field) {
await act(async () => {
await persist({ [field]: !state.value[field] });
notice.value = '변경 사항을 적용했어요.';
});
}
async function loadAutomatic(more = false) { async function loadAutomatic(more = false) {
const offset = more ? nextOffset.value : 0; const offset = more ? nextOffset.value : 0;
const result = await api( const result = await api(
@@ -117,19 +124,23 @@ loadAutomatic().catch(showError);
async function save() { async function save() {
if (!editing.value) return; if (!editing.value) return;
await act(async () => { await act(async () => {
const settings = await api('/admin/settings', 'PATCH', { if (autoKeys.some((key) => state.value[key] !== autoOriginal[key]))
revision: editRevision, throw Error('다른 화면에서 감지 기준이 변경됐어요. 수정을 취소한 뒤 다시 열어 주세요.');
const gb = Number(uploadGb.value);
await persist({
...draft, ...draft,
autoUploadBytes:
gb === Number((autoOriginal.autoUploadBytes / SIZE_GB).toFixed(3))
? autoOriginal.autoUploadBytes
: Math.round(gb * SIZE_GB),
}); });
fill(settings);
editing.value = false; editing.value = false;
emit('saved', settings); notice.value = '자동 감지 기준을 저장했어요.';
notice.value = '네트워크 설정을 저장했어요.';
}); });
} }
let trigger; let trigger;
async function open(list) { async function open(list) {
if (!editing.value) return; if (busy.value) return;
trigger = document.activeElement; trigger = document.activeElement;
modal.value = list; modal.value = list;
kind.value = 'country'; kind.value = 'country';
@@ -141,7 +152,6 @@ watch(
() => props.suggestedIp, () => props.suggestedIp,
async (ip) => { async (ip) => {
if (!ip) return; if (!ip) return;
if (!editing.value) edit();
await open('black'); await open('black');
kind.value = 'ip'; kind.value = 'ip';
value.value = ip; value.value = ip;
@@ -154,7 +164,6 @@ function close() {
trigger?.focus(); trigger?.focus();
} }
async function add() { async function add() {
if (!editing.value) return;
await act(async () => { await act(async () => {
const entry = await api('/admin/network-rules/validate', 'POST', { const entry = await api('/admin/network-rules/validate', 'POST', {
kind: kind.value, kind: kind.value,
@@ -168,22 +177,26 @@ async function add() {
: entry.kind === 'country' : entry.kind === 'country'
? 'allowedCountries' ? 'allowedCountries'
: 'allowedIps'; : 'allowedIps';
draft[field] = [...new Set([...draft[field], entry.value])]; await persist({ [field]: [...new Set([...state.value[field], entry.value])] });
close(); close();
notice.value = '표에 추가했어요. 네트워크 설정 저장을 누르면 적용됩니다.'; notice.value = '규칙을 추가하고 적용했어요.';
}); });
} }
function remove(list) { async function remove(list) {
if (!editing.value) return;
const selected = list === 'black' ? selectedBlack : selectedWhite; const selected = list === 'black' ? selectedBlack : selectedWhite;
const countries = list === 'black' ? 'blockedCountries' : 'allowedCountries', if (!selected.value.length) return;
ips = list === 'black' ? 'blockedIps' : 'allowedIps'; await act(async () => {
draft[countries] = draft[countries].filter( const countries = list === 'black' ? 'blockedCountries' : 'allowedCountries',
(value) => !selected.value.includes('country:' + value), ips = list === 'black' ? 'blockedIps' : 'allowedIps';
); await persist({
draft[ips] = draft[ips].filter((value) => !selected.value.includes('ip:' + value)); [countries]: state.value[countries].filter(
selected.value = []; (value) => !selected.value.includes('country:' + value),
notice.value = '표에서 제거했어요. 네트워크 설정 저장을 누르면 적용됩니다.'; ),
[ips]: state.value[ips].filter((value) => !selected.value.includes('ip:' + value)),
});
selected.value = [];
notice.value = '선택한 규칙을 제거하고 적용했어요.';
});
} }
async function removeAutomatic() { async function removeAutomatic() {
await act(async () => { await act(async () => {
@@ -225,34 +238,27 @@ function modalKey(event) {
<section class="network-panel"> <section class="network-panel">
<header class="admin-edit-heading"> <header class="admin-edit-heading">
<h2>접속 보호 설정</h2> <h2>접속 보호 설정</h2>
<button v-if="!editing" type="button" class="button small" :disabled="busy" @click="edit">
<Pencil :size="15" />수정
</button>
<span v-else class="admin-muted">수정 중</span>
</header> </header>
<p class="admin-muted"> <p class="admin-muted">
수정을 누르면 목록과 감지 기준을 변경할 수 있습니다. 블랙리스트와 자동 차단이 우선 적용됩니다. 토글과 규칙 추가·제거는 즉시 적용됩니다. 자동 감지 기준은 해당 영역의 수정 버튼으로
변경합니다. 블랙리스트와 자동 차단이 우선 적용됩니다.
</p> </p>
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p> <p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
<form @submit.prevent="save"> <div class="network-sections">
<section class="network-section" aria-labelledby="file-policy-title"> <section class="network-section" aria-labelledby="file-policy-title">
<header> <header>
<h2 id="file-policy-title">파일 전송 금지</h2> <h2 id="file-policy-title">파일 전송 금지</h2>
<button <button
v-if="editing"
type="button" type="button"
role="switch" role="switch"
class="network-switch" class="network-switch"
:aria-checked="draft.uploadsPaused" :aria-checked="state.uploadsPaused"
aria-label="파일 전송 금지" aria-label="파일 전송 금지"
:disabled="busy" :disabled="busy"
@click="draft.uploadsPaused = !draft.uploadsPaused" @click="toggle('uploadsPaused')"
> >
<span></span>{{ draft.uploadsPaused ? '켜짐' : '꺼짐' }} <span></span>{{ state.uploadsPaused ? '켜짐' : '꺼짐' }}
</button> </button>
<span v-else class="admin-muted">{{
draft.uploadsPaused ? '켜짐 · 링크와 텍스트만 허용' : '꺼짐 · 파일 전송 허용'
}}</span>
</header> </header>
<p class="admin-muted"> <p class="admin-muted">
켜면 새 파일 전송·보관함 파일 저장을 막고 사용자 화면의 파일 선택 메뉴를 숨깁니다. 링크와 켜면 새 파일 전송·보관함 파일 저장을 막고 사용자 화면의 파일 선택 메뉴를 숨깁니다. 링크와
@@ -262,107 +268,134 @@ function modalKey(event) {
<section class="network-section" aria-labelledby="automatic-title"> <section class="network-section" aria-labelledby="automatic-title">
<header> <header>
<h2 id="automatic-title">자동 블랙리스트</h2> <h2 id="automatic-title">자동 블랙리스트</h2>
<button <div class="network-section-controls">
type="button" <button
v-if="editing" v-if="!editing"
role="switch" type="button"
class="network-switch" class="button small"
:aria-checked="draft.autoBlockEnabled" aria-label="자동 감지 기준 수정"
aria-label="자동 블랙리스트 사용" :disabled="busy"
:disabled="busy" @click="edit"
@click="draft.autoBlockEnabled = !draft.autoBlockEnabled" >
> <Pencil :size="15" />수정
<span></span>{{ draft.autoBlockEnabled ? '사용 중' : '사용 안 함' }} </button>
</button> <span v-else class="admin-muted">감지 기준 수정 중</span>
<span v-else class="admin-muted">{{ <button
draft.autoBlockEnabled ? '사용 중' : '사용 안 함' type="button"
}}</span> role="switch"
class="network-switch"
:aria-checked="state.autoBlockEnabled"
aria-label="자동 블랙리스트 사용"
:disabled="busy"
@click="toggle('autoBlockEnabled')"
>
<span></span>{{ state.autoBlockEnabled ? '사용 중' : '사용 안 함' }}
</button>
</div>
</header> </header>
<p class="admin-muted"> <p class="admin-muted">
확인된 원본 공인 IP를 기준으로 반복 로그인 실패·요청 제한 위반·대량 파일 전송 시도를 확인된 원본 공인 IP를 기준으로 반복 로그인 실패·요청 제한 위반·대량 파일 전송 시도를
감지합니다. 정상적인 파일 조각 전송과 서버 혼잡 재시도는 횟수에 포함하지 않습니다. 감지합니다. 정상적인 파일 조각 전송과 서버 혼잡 재시도는 횟수에 포함하지 않습니다.
</p> </p>
<div v-if="editing" class="network-auto-fields"> <form v-if="editing" class="network-criteria-form" @submit.prevent="save">
<label <div class="network-auto-fields">
>감지 기간 (분)<input <label
v-model.number="draft.autoWindowMinutes" >감지 기간 (분)<input
type="number" v-model.number="draft.autoWindowMinutes"
min="1" type="number"
max="1440" min="1"
required max="1440"
required
:disabled="busy"
/></label>
<label
>로그인 실패 (회)<input
v-model.number="draft.autoLoginFailures"
type="number"
min="3"
max="1000"
required
:disabled="busy"
/></label>
<label
>요청 제한 위반 (회)<input
v-model.number="draft.autoRateViolations"
type="number"
min="3"
max="1000"
required
:disabled="busy"
/></label>
<label
>파일 전송 시도 (회)<input
v-model.number="draft.autoUploadRequests"
type="number"
min="3"
max="10000"
required
:disabled="busy"
/></label>
<label
>요청한 파일 용량 (GB)<input
:value="uploadGb"
@input="uploadGb = $event.target.value"
@blur="normalizeUpload($event.target)"
placeholder="0.000"
inputmode="decimal"
type="number"
min="0.001"
max="1024"
step="0.001"
required
:disabled="busy"
/></label>
<label
>자동 차단 시간 (분)<input
v-model.number="draft.autoBlockMinutes"
type="number"
min="1"
max="10080"
required
:disabled="busy"
/></label>
</div>
<div class="admin-edit-actions">
<button class="button primary" :disabled="busy">감지 기준 저장</button
><button
type="button"
class="button"
aria-label="감지 기준 수정 취소"
:disabled="busy" :disabled="busy"
/></label> @click="cancel"
<label >
>로그인 실패 (회)<input 취소
v-model.number="draft.autoLoginFailures" </button>
type="number" </div>
min="3" </form>
max="1000"
required
:disabled="busy"
/></label>
<label
>요청 제한 위반 (회)<input
v-model.number="draft.autoRateViolations"
type="number"
min="3"
max="1000"
required
:disabled="busy"
/></label>
<label
>파일 전송 시도 (회)<input
v-model.number="draft.autoUploadRequests"
type="number"
min="3"
max="10000"
required
:disabled="busy"
/></label>
<label
>요청한 파일 용량 (GB)<input
v-model="uploadGb"
type="number"
min="0.001"
max="1000"
step="0.001"
required
:disabled="busy"
/></label>
<label
>자동 차단 시간 (분)<input
v-model.number="draft.autoBlockMinutes"
type="number"
min="1"
max="10080"
required
:disabled="busy"
/></label>
</div>
<dl v-else class="admin-limit-details network-auto-summary"> <dl v-else class="admin-limit-details network-auto-summary">
<div> <div>
<dt>감지 기간</dt> <dt>감지 기간</dt>
<dd>{{ draft.autoWindowMinutes }}분</dd> <dd>{{ state.autoWindowMinutes }}분</dd>
</div> </div>
<div> <div>
<dt>로그인 실패</dt> <dt>로그인 실패</dt>
<dd>{{ draft.autoLoginFailures }}회</dd> <dd>{{ state.autoLoginFailures }}회</dd>
</div> </div>
<div> <div>
<dt>요청 제한 위반</dt> <dt>요청 제한 위반</dt>
<dd>{{ draft.autoRateViolations }}회</dd> <dd>{{ state.autoRateViolations }}회</dd>
</div> </div>
<div> <div>
<dt>파일 전송 시도</dt> <dt>파일 전송 시도</dt>
<dd>{{ draft.autoUploadRequests }}회</dd> <dd>{{ state.autoUploadRequests }}회</dd>
</div> </div>
<div> <div>
<dt>요청한 파일 용량</dt> <dt>요청한 파일 용량</dt>
<dd>{{ size(draft.autoUploadBytes) }}</dd> <dd>{{ size(state.autoUploadBytes) }}</dd>
</div> </div>
<div> <div>
<dt>자동 차단 시간</dt> <dt>자동 차단 시간</dt>
<dd>{{ draft.autoBlockMinutes }}분</dd> <dd>{{ state.autoBlockMinutes }}분</dd>
</div> </div>
</dl> </dl>
<p class="admin-muted"> <p class="admin-muted">
@@ -445,24 +478,20 @@ function modalKey(event) {
<h2 id="blacklist-title">블랙리스트</h2> <h2 id="blacklist-title">블랙리스트</h2>
<button <button
type="button" type="button"
v-if="editing"
role="switch" role="switch"
class="network-switch" class="network-switch"
:aria-checked="draft.blacklistEnabled" :aria-checked="state.blacklistEnabled"
aria-label="블랙리스트 사용" aria-label="블랙리스트 사용"
:disabled="busy" :disabled="busy"
@click="draft.blacklistEnabled = !draft.blacklistEnabled" @click="toggle('blacklistEnabled')"
> >
<span></span>{{ draft.blacklistEnabled ? '사용 중' : '사용 안 함' }} <span></span>{{ state.blacklistEnabled ? '사용 중' : '사용 안 함' }}
</button> </button>
<span v-else class="admin-muted">{{
draft.blacklistEnabled ? '사용 중' : '사용 안 함'
}}</span>
</header> </header>
<p class="admin-muted"> <p class="admin-muted">
등록한 국가·IP·IP 대역의 접속을 차단합니다. 화이트리스트와 겹쳐도 차단이 우선합니다. 등록한 국가·IP·IP 대역의 접속을 차단합니다. 화이트리스트와 겹쳐도 차단이 우선합니다.
</p> </p>
<div v-if="editing" class="network-actions"> <div class="network-actions">
<button type="button" class="button small" :disabled="busy" @click="open('black')"> <button type="button" class="button small" :disabled="busy" @click="open('black')">
<Plus :size="15" />블랙리스트 추가</button <Plus :size="15" />블랙리스트 추가</button
><button ><button
@@ -478,14 +507,14 @@ function modalKey(event) {
<table class="network-table"> <table class="network-table">
<thead> <thead>
<tr> <tr>
<th v-if="editing">선택</th> <th>선택</th>
<th>구분</th> <th>구분</th>
<th>국가 · IP 또는 IP 대역</th> <th>국가 · IP 또는 IP 대역</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="entry in black" :key="entry.id"> <tr v-for="entry in black" :key="entry.id">
<td v-if="editing"> <td>
<input <input
v-model="selectedBlack" v-model="selectedBlack"
type="checkbox" type="checkbox"
@@ -498,9 +527,7 @@ function modalKey(event) {
<td>{{ ruleName(entry) }}</td> <td>{{ ruleName(entry) }}</td>
</tr> </tr>
<tr v-if="!black.length"> <tr v-if="!black.length">
<td :colspan="editing ? 3 : 2" class="network-empty"> <td colspan="3" class="network-empty">등록된 차단 규칙이 없습니다.</td>
등록된 차단 규칙이 없습니다.
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -516,7 +543,7 @@ function modalKey(event) {
허용하고 나머지는 모두 차단합니다. 국가를 확인할 수 없는 접속도 허용 IP와 일치하지 않으면 허용하고 나머지는 모두 차단합니다. 국가를 확인할 수 없는 접속도 허용 IP와 일치하지 않으면
차단됩니다. 차단됩니다.
</p> </p>
<div v-if="editing" class="network-actions"> <div class="network-actions">
<button type="button" class="button small" :disabled="busy" @click="open('white')"> <button type="button" class="button small" :disabled="busy" @click="open('white')">
<Plus :size="15" />화이트리스트 추가</button <Plus :size="15" />화이트리스트 추가</button
><button ><button
@@ -532,14 +559,14 @@ function modalKey(event) {
<table class="network-table"> <table class="network-table">
<thead> <thead>
<tr> <tr>
<th v-if="editing">선택</th> <th>선택</th>
<th>구분</th> <th>구분</th>
<th>국가 · IP 또는 IP 대역</th> <th>국가 · IP 또는 IP 대역</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="entry in white" :key="entry.id"> <tr v-for="entry in white" :key="entry.id">
<td v-if="editing"> <td>
<input <input
v-model="selectedWhite" v-model="selectedWhite"
type="checkbox" type="checkbox"
@@ -552,7 +579,7 @@ function modalKey(event) {
<td>{{ ruleName(entry) }}</td> <td>{{ ruleName(entry) }}</td>
</tr> </tr>
<tr v-if="!white.length"> <tr v-if="!white.length">
<td :colspan="editing ? 3 : 2" class="network-empty"> <td colspan="3" class="network-empty">
등록된 허용 규칙이 없습니다. 블랙리스트에 없는 접속은 공개됩니다. 등록된 허용 규칙이 없습니다. 블랙리스트에 없는 접속은 공개됩니다.
</td> </td>
</tr> </tr>
@@ -564,11 +591,7 @@ function modalKey(event) {
허용된 국가나 IP에서도 악용 감지 기준을 적용합니다. 허용된 국가나 IP에서도 악용 감지 기준을 적용합니다.
</p> </p>
</section> </section>
<div v-if="editing" class="admin-edit-actions"> </div>
<button class="button primary" :disabled="busy">네트워크 설정 저장</button
><button type="button" class="button" :disabled="busy" @click="cancel">취소</button>
</div>
</form>
<Teleport to="body" <Teleport to="body"
><div v-if="modal" class="modal-backdrop" @click.self="!busy && close()"> ><div v-if="modal" class="modal-backdrop" @click.self="!busy && close()">
<section <section

View File

@@ -1,11 +1,14 @@
export const SIZE_KB = 1024;
export const SIZE_MB = SIZE_KB ** 2;
export const SIZE_GB = SIZE_KB ** 3;
const sizeNumber = new Intl.NumberFormat('en-US', { const sizeNumber = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 1, minimumFractionDigits: 1,
maximumFractionDigits: 1, maximumFractionDigits: 1,
}); });
export function formatSize(bytes) { export function formatSize(bytes) {
if (bytes >= 1e9) return `${sizeNumber.format(bytes / 1e9)}GB`; if (bytes >= SIZE_GB) return `${sizeNumber.format(bytes / SIZE_GB)}GB`;
if (bytes >= 1e6) return `${sizeNumber.format(bytes / 1e6)}MB`; if (bytes >= SIZE_MB) return `${sizeNumber.format(bytes / SIZE_MB)}MB`;
if (bytes >= 1000) return `${sizeNumber.format(bytes / 1000).replace(/\.0$/, '')}KB`; if (bytes >= SIZE_KB) return `${sizeNumber.format(bytes / SIZE_KB).replace(/\.0$/, '')}KB`;
return bytes === 0 ? '0KB' : '1KB 미만'; return bytes === 0 ? '0KB' : '1KB 미만';
} }
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' }); const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });

View File

@@ -4209,7 +4209,7 @@ a.button {
color: var(--muted); color: var(--muted);
} }
.network-panel > form { .network-sections {
display: grid; display: grid;
gap: 24px; gap: 24px;
margin-top: 20px; margin-top: 20px;
@@ -4346,9 +4346,6 @@ a.button {
gap: 20px; gap: 20px;
margin-top: 24px; margin-top: 24px;
} }
.network-panel > form > .button {
justify-self: start;
}
@media (max-width: 639px) { @media (max-width: 639px) {
.admin-tabs { .admin-tabs {
gap: 16px; gap: 16px;
@@ -4364,9 +4361,6 @@ a.button {
.network-search { .network-search {
margin-left: 0; margin-left: 0;
} }
.network-panel > form > .button {
justify-self: stretch;
}
} }
.admin-audit-panel, .admin-audit-panel,
@@ -4473,3 +4467,15 @@ a.button {
line-height: 1.6; line-height: 1.6;
margin: 0 0 20px; margin: 0 0 20px;
} }
.network-section-controls {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.network-criteria-form {
display: grid;
gap: 18px;
}

View File

@@ -195,7 +195,7 @@ https://hop.noname.company
### 용량 설정 ### 용량 설정
환경변수의 MB는 MiB 기준입니다. `2048`은 2GiB입니다. 환경변수의 MB는 MiB 기준입니다. `2048`은 2GiB이며 사용자 화면에서는 익숙한 명칭인 2GB로 표시합니다. 화면 표시와 관리자 GB 입력은 모두 1,024 기준입니다(1GB = 1,024MB).
| 설정 | 기준 | | 설정 | 기준 |
| ------------------------------------ | ------------------------------------------------------------------------------------------------- | | ------------------------------------ | ------------------------------------------------------------------------------------------------- |
@@ -473,7 +473,7 @@ sed -i -e 's/^GUEST_REQUESTS_PER_MINUTE=5$/GUEST_REQUESTS_PER_MINUTE=30/' -e 's/
수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하고 수락/거절 버튼은 2:1입니다. 차단은 오른쪽 하단의 작은 텍스트를 누른 뒤 확인해야 실행됩니다. **푸터의 내 기기 설정 → 차단한 기기 관리 → 차단 해제**로 해제합니다. 차단은 현재 브라우저의 기기 세션에 저장되며 로그인 여부와 별개입니다. 해제해도 이전 요청을 다시 수락하는 방식으로 복구하지 않으므로 발신자가 새로 전송해야 합니다. 상대가 나를 별도로 차단했다면 내 차단 해제만으로 상대의 차단을 풀 수 없습니다. 수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하고 수락/거절 버튼은 2:1입니다. 차단은 오른쪽 하단의 작은 텍스트를 누른 뒤 확인해야 실행됩니다. **푸터의 내 기기 설정 → 차단한 기기 관리 → 차단 해제**로 해제합니다. 차단은 현재 브라우저의 기기 세션에 저장되며 로그인 여부와 별개입니다. 해제해도 이전 요청을 다시 수락하는 방식으로 복구하지 않으므로 발신자가 새로 전송해야 합니다. 상대가 나를 별도로 차단했다면 내 차단 해제만으로 상대의 차단을 풀 수 없습니다.
수신 파일 용량은 0.1MB 이상부터 MB, 1GB 이상부터 GB로 표시합니다. 그보다 작은 파일은 KB 또는 ‘1 KB 미만’이며, 파일 한 개의 용량은 파일명 옆에 한 번만 표시합니다. 단위는 MB=1,000,000바이트, GB=1,000,000,000바이트입니다. 서버의 MiB/GiB 기준 저장·파일 한도는 유지합니다. 수신 파일 용량은 1MB 미만은 KB, 1MB 이상은 MB, 1GB 이상은 GB로 표시합니다. 1KB보다 작은 파일은 ‘1KB 미만’이며, 파일 한 개의 용량은 파일명 옆에 한 번만 표시합니다. 화면은 1,024 기준에 익숙한 KB/MB/GB 명칭을 사용합니다(1GB = 1,024MB). 서버의 기존 저장·파일 한도는 유지합니다.
## 관리자 화면과 업로드 보호 ## 관리자 화면과 업로드 보호
@@ -481,11 +481,11 @@ sed -i -e 's/^GUEST_REQUESTS_PER_MINUTE=5$/GUEST_REQUESTS_PER_MINUTE=30/' -e 's/
저장 파일의 업로드 주체·파일별 접속 지역/IP·만료일, 전체 사용량·예약량·디스크 여유와 현재 업로드를 확인할 수 있습니다. 원본 삭제는 확인창을 거치며 모든 사용자의 연결된 항목도 무효화합니다. 이미 응답 중인 다운로드까지 회수하는 기능은 아닙니다. 관리 화면의 지역은 각 파일의 최초 전송 요청 당시 전달된 IP 기반 추정 정보입니다. 이후 다른 파일을 올린 지역으로 덮어쓰지 않습니다. 같은 요청의 여러 파일에는 같은 정보를 사용합니다. 저장 파일의 업로드 주체·파일별 접속 지역/IP·만료일, 전체 사용량·예약량·디스크 여유와 현재 업로드를 확인할 수 있습니다. 원본 삭제는 확인창을 거치며 모든 사용자의 연결된 항목도 무효화합니다. 이미 응답 중인 다운로드까지 회수하는 기능은 아닙니다. 관리 화면의 지역은 각 파일의 최초 전송 요청 당시 전달된 IP 기반 추정 정보입니다. 이후 다른 파일을 올린 지역으로 덮어쓰지 않습니다. 같은 요청의 여러 파일에는 같은 정보를 사용합니다.
**용량 설정 → 수정**에서 동시 업로드 1~16개, 전체 파일 저장 한도, 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간을 변경합니다. 평소에는 값만 표시하고 수정 중에만 입력창과 저장/취소 버튼을 표시합니다. 개인 한도는 모든 계정·기기에 같은 기준으로 적용하며 계정별 예외 설정은 아닙니다. 화면은 GB(1,000 단위)를 사용하며, 서버 설정 범위인 1~1024GiB는 화면에서 약 1.1~1099.5GB에 해당합니다. 기본은 4개·20GiB이고, 업로드 요청은 같은 계정/기기 주체당 동시에 최대 2개만 처리합니다. 새 용량 항목의 초기값은 기존 `.env`를 따릅니다. 파일/개인/기기 한도와 NAS 최소 여유는 관리자 설정을 저장한 뒤 재시작 없이 적용하고 전송 횟수 제한은 유지합니다. 이미 수락해 공간을 예약한 업로드는 한도를 낮춰도 계속되며, 이후 새 공간 예약에는 변경된 한도를 적용합니다. 업로드 본문을 읽기 전에 서버 전체 동시 요청 제한을 적용하고, 들어오는 HTTP 요청에 5분 타임아웃을 적용합니다. 큰 파일은 기존 8MiB 조각 전송을 사용합니다. **용량 설정 → 수정**에서 동시 업로드 1~16개, 전체 파일 저장 한도, 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간을 변경합니다. 평소에는 값만 표시하고 수정 중에만 입력창과 저장/취소 버튼을 표시합니다. 개인 한도는 모든 계정·기기에 같은 기준으로 적용하며 계정별 예외 설정은 아닙니다. 화면과 GB 입력은 1,024 기준을 사용하므로 기존 기본값은 20GB이며 15를 입력하면 15GB입니다. 전체 저장 한도 입력 범위는 1~1,024GB이고 나머지 용량은 최대 1,024GB입니다. GB 입력은 소수점 셋째 자리까지 지원하며 입력창을 벗어나면 `20.000`처럼 표시합니다. 빈칸에는 `0.000` 예시를 보여 줍니다. **기본값으로 돌리기**는 NAS `.env`의 초기 설정값을 초안으로 불러오며 **설정 저장**을 눌러야 적용됩니다. 취소하면 기존 설정을 유지하고 네트워크 설정은 바꾸지 않습니다. 기본은 4개·20GB이고, 업로드 요청은 같은 계정/기기 주체당 동시에 최대 2개만 처리합니다. 새 용량 항목의 초기값은 기존 `.env`를 따릅니다. 파일/개인/기기 한도와 NAS 최소 여유는 관리자 설정을 저장한 뒤 재시작 없이 적용하고 전송 횟수 제한은 유지합니다. 이미 수락해 공간을 예약한 업로드는 한도를 낮춰도 계속되며, 이후 새 공간 예약에는 변경된 한도를 적용합니다. 업로드 본문을 읽기 전에 서버 전체 동시 요청 제한을 적용하고, 들어오는 HTTP 요청에 5분 타임아웃을 적용합니다. 큰 파일은 기존 8MiB 조각 전송을 사용합니다.
**네트워크 → 수정 → 파일 전송 금지**를 켜고 저장하면 새 파일 전송·보관함 파일 저장·기존 보관함 파일의 새 기기 전송을 거절합니다. 아직 수락하지 않은 파일 요청도 제한 중에는 수락할 수 없습니다. 켜기 전에 이미 수락한 파일 업로드와 기존 파일 다운로드, 링크·텍스트 전송/저장은 계속됩니다. 기존 자료를 삭제하지 않습니다. 설정은 기존 `uploadsPaused` 값을 재사용하며 기본값은 꺼짐입니다. 사용자 화면은 파일 선택/저장/보관함 파일 보내기 메뉴를 숨기고 링크·텍스트만 가능한 안내를 표시합니다. 파일 드롭·붙여넣기도 같은 제한을 적용합니다. 현재 접속자에게 Socket.IO로 공개 정책만 전달하고, 최초 접속/재연결/스냅샷에서도 최신 정책을 확인합니다. 늦게 도착한 과거 정책이 새 상태를 덮어쓰지 않도록 리비전을 비교합니다. **네트워크 → 파일 전송 금지**를 켜면 즉시 새 파일 전송·보관함 파일 저장·기존 보관함 파일의 새 기기 전송을 거절합니다. 아직 수락하지 않은 파일 요청도 제한 중에는 수락할 수 없습니다. 켜기 전에 이미 수락한 파일 업로드와 기존 파일 다운로드, 링크·텍스트 전송/저장은 계속됩니다. 기존 자료를 삭제하지 않습니다. 설정은 기존 `uploadsPaused` 값을 재사용하며 기본값은 꺼짐입니다. 사용자 화면은 파일 선택/저장/보관함 파일 보내기 메뉴를 숨기고 링크·텍스트만 가능한 안내를 표시합니다. 파일 드롭·붙여넣기도 같은 제한을 적용합니다. 현재 접속자에게 Socket.IO로 공개 정책만 전달하고, 최초 접속/재연결/스냅샷에서도 최신 정책을 확인합니다. 늦게 도착한 과거 정책이 새 상태를 덮어쓰지 않도록 리비전을 비교합니다.
**네트워크 → 수정 → 블랙리스트/화이트리스트 → 추가**에서 국가 코드 또는 IPv4/IPv6·CIDR을 한 항목씩 등록합니다. IPv4는 `203.0.113.*`, `203.0.*.*`처럼 뒤쪽 전체 자리의 와일드카드도 지원하며 각각 `/24`, `/16` CIDR로 저장합니다. `203.*.113.*`나 `113*` 같은 불연속·부분 자리 패턴은 지원하지 않습니다. IPv6는 CIDR을 사용합니다. 모달로 추가한 표를 확인하고 네트워크 설정을 저장하며, 제거할 때는 표의 항목을 선택합니다. 비어 있으면 차단하지 않습니다. 저장 즉시 일치하는 새 HTTP·Socket.IO 접속을 거절하고, 진행 중 업로드와 기기 연결도 종료합니다. `CLIENT_IP_SOURCE`와 `TRUSTED_PROXY_CIDRS`로 검증한 원본 IP를 사용하며 원본 IP가 없으면 직접 연결 주소를 기준으로 합니다. 국가 정보는 검증된 Cloudflare 전달 값만 사용합니다. 국가를 모르면 국가 블랙리스트만으로 차단하지 않지만, 화이트리스트가 있으면 허용 IP와 일치해야 접속할 수 있습니다. 현재 관리자의 IP/국가·인접 연결을 차단하는 규칙은 저장할 수 없습니다. 차단 해제는 목록에서 해당 규칙을 지우고 다시 저장합니다. 잘못된 규칙으로 관리자가 차단되면 기존 NAS 접근 권한으로 DB의 `admin_settings`를 복구해야 하므로 실제 규칙을 확인한 뒤 적용합니다. **네트워크 → 블랙리스트/화이트리스트 → 추가**에서 국가 코드 또는 IPv4/IPv6·CIDR을 한 항목씩 등록합니다. IPv4는 `203.0.113.*`, `203.0.*.*`처럼 뒤쪽 전체 자리의 와일드카드도 지원하며 각각 `/24`, `/16` CIDR로 저장합니다. `203.*.113.*`나 `113*` 같은 불연속·부분 자리 패턴은 지원하지 않습니다. IPv6는 CIDR을 사용합니다. 모달에서 추가하거나 표의 항목을 선택 제거하면 해당 목록에 즉시 적용합니다. 별도의 수정 모드나 전체 설정 저장은 필요 없습니다. 비어 있으면 차단하지 않습니다. 적용 즉시 일치하는 새 HTTP·Socket.IO 접속을 거절하고, 진행 중 업로드와 기기 연결도 종료합니다. `CLIENT_IP_SOURCE`와 `TRUSTED_PROXY_CIDRS`로 검증한 원본 IP를 사용하며 원본 IP가 없으면 직접 연결 주소를 기준으로 합니다. 국가 정보는 검증된 Cloudflare 전달 값만 사용합니다. 국가를 모르면 국가 블랙리스트만으로 차단하지 않지만, 화이트리스트가 있으면 허용 IP와 일치해야 접속할 수 있습니다. 현재 관리자의 IP/국가·인접 연결을 차단하는 규칙은 저장할 수 없습니다. 차단 해제는 목록에서 해당 규칙을 선택 제거합니다. 잘못된 규칙으로 관리자가 차단되면 기존 NAS 접근 권한으로 DB의 `admin_settings`를 복구해야 하므로 실제 규칙을 확인한 뒤 적용합니다.
관리 설정은 DB의 `admin_settings`, 변경·원본 삭제 이력은 `admin_audit`에 보관합니다. 이력은 기본 30일이며 설정은 재시작 뒤 유지됩니다. 한 번 저장한 관리 설정은 `.env` 초기값보다 우선하고 이후에도 관리자 화면에서 수정합니다. 화면은 수동 새로고침만 사용하며 상시 목록 조회를 하지 않습니다. 관리 설정은 DB의 `admin_settings`, 변경·원본 삭제 이력은 `admin_audit`에 보관합니다. 이력은 기본 30일이며 설정은 재시작 뒤 유지됩니다. 한 번 저장한 관리 설정은 `.env` 초기값보다 우선하고 이후에도 관리자 화면에서 수정합니다. 화면은 수동 새로고침만 사용하며 상시 목록 조회를 하지 않습니다.
@@ -502,17 +502,17 @@ sudo docker compose -f docker-compose.yaml up -d --build
`file_upload_contexts` 테이블을 자동 추가하여 각 원본 파일의 최초 전송 요청 정보를 보관합니다. 새 요청을 다른 IP/지역에서 보내거나 재시도해도 기존 파일의 정보는 바뀌지 않습니다. 기존 파일은 소유자 정보의 동일 요청 ID 기록이 있을 때만 복구하며 다른 요청의 최신 지역은 사용하지 않습니다. 기록은 기본 최대 7일이고 파일 실패·삭제 시에도 약 1분 주기 정리에서 제거합니다. 관리자 외의 전송·보관함 API에는 노출하지 않습니다. `file_upload_contexts` 테이블을 자동 추가하여 각 원본 파일의 최초 전송 요청 정보를 보관합니다. 새 요청을 다른 IP/지역에서 보내거나 재시도해도 기존 파일의 정보는 바뀌지 않습니다. 기존 파일은 소유자 정보의 동일 요청 ID 기록이 있을 때만 복구하며 다른 요청의 최신 지역은 사용하지 않습니다. 기록은 기본 최대 7일이고 파일 실패·삭제 시에도 약 1분 주기 정리에서 제거합니다. 관리자 외의 전송·보관함 API에는 노출하지 않습니다.
관리자 이름은 대괄호로 감싸고, 만료일은 관리자·보관함 모두 `[보관 만료] 2026년 10월 22일 11:02 까지` 형식의 한국 시간으로 표시합니다. 푸터의 발견 허용 문구는 숨깁니다. 용량은 모든 화면에서 KB/MB/GB(1,000 단위)로 통일하며 실제 파일 한도·개인별 한도·서버 전체 한도는 그대로입니다. `.env` 용량 값의 MiB 기준은 유지하고 관리자 화면의 전체 용량 입력은 GB로 환산합니다. 반올림된 GB를 수정하지 않았다면 다른 설정을 저장해도 원래 한도를 보존합니다. 관리자 이름은 대괄호로 감싸고, 만료일은 관리자·보관함 모두 `[보관 만료] 2026년 10월 22일 11:02 까지` 형식의 한국 시간으로 표시합니다. 푸터의 발견 허용 문구는 숨깁니다. 용량은 모든 화면에서 KB/MB/GB(1,024 기준)로 통일하며 실제 파일 한도·개인별 한도·서버 전체 한도는 그대로입니다. `.env` 용량 값의 MiB 기준은 유지하고 관리자 화면의 전체 용량 입력은 GB로 환산합니다. 반올림된 GB를 수정하지 않았다면 다른 설정을 저장해도 원래 한도를 보존합니다.
Compose 변경이나 DB 초기화 없이 평소처럼 Git pull 후 `sudo docker compose -f docker-compose.yaml up -d --build`로 적용합니다. Compose 변경이나 DB 초기화 없이 평소처럼 Git pull 후 `sudo docker compose -f docker-compose.yaml up -d --build`로 적용합니다.
관리자 한도 설명에는 실제 서버의 계정별 보관함·기기 세션별 업로드·기기 세션 전체 업로드·파일당 크기·NAS 최소 여유 공간을 각각 표시합니다. 전체 파일 한도는 모두가 공유하며 개인에게 같은 양을 배정하지 않습니다. 기본값 2GiB는 약 2.1GB, 최소 여유 5GiB는 약 5.4GB, 전체 20GiB는 약 21.5GB로 표시됩니다. 이 값은 각 NAS의 기존 `.env`와 저장된 관리 설정을 따르며 이번 표시 변경으로 값을 재설정하지 않습니다. 관리자 한도 설명에는 실제 서버의 계정별 보관함·기기 세션별 업로드·기기 세션 전체 업로드·파일당 크기·NAS 최소 여유 공간을 각각 표시합니다. 전체 파일 한도는 모두가 공유하며 개인에게 같은 양을 배정하지 않습니다. 기본값 2GiB는 화면에서 2GB, 최소 여유 5GiB는 5GB, 전체 20GiB는 20GB로 표시됩니다. 이 값은 각 NAS의 기존 `.env`와 저장된 관리 설정을 따르며 이번 표시 변경으로 값을 재설정하지 않습니다.
## 네트워크·사용자·관리 기록 업데이트 ## 네트워크·사용자·관리 기록 업데이트
`0014_network_protection`은 `automatic_blocks`, `user_activity` 테이블만 추가합니다. 기존 차단 규칙·관리 설정·계정·자료와 Compose는 유지합니다. 새 설정이 없는 기존 DB에는 화이트리스트 빈 목록과 자동 차단 꺼짐 기본값을 적용합니다. 별도 `.env` 변경은 없습니다. `0014_network_protection`은 `automatic_blocks`, `user_activity` 테이블만 추가합니다. 기존 차단 규칙·관리 설정·계정·자료와 Compose는 유지합니다. 새 설정이 없는 기존 DB에는 화이트리스트 빈 목록과 자동 차단 꺼짐 기본값을 적용합니다. 별도 `.env` 변경은 없습니다.
네트워크 탭의 세 영역에서 관리합니다. 수동 블랙리스트는 국가/IP/CIDR 차단, 화이트리스트는 등록 국가 또는 IP/CIDR만 허용하는 목록입니다. 비어 있으면 공개하고 블랙리스트·자동 차단이 화이트리스트보다 우선합니다. 국가 미제공 시 허용 IP와 일치하지 않으면 비공개입니다. 자동 차단을 켜면 원본 공인 IP별 기본 10분 창에서 로그인 실패 10회·요청 제한 위반 20회·파일 요청 시도 30회 초과·요청 용량 10GB 초과를 감지해 기본 30분 차단합니다. 기준과 차단 시간을 화면에서 조정합니다. 동일 IP의 로그인 보관함/기기 전송·여러 기기 세션을 함께 계산하며 같은 요청의 재시도는 제외합니다. 수락 전의 유효한 파일 요청 본문을 기준으로 세며 실제 업로드 성공 용량 통계는 아닙니다. 정상 조각 API·UPLOAD_BUSY·거절 대기는 위반 횟수에서 제외합니다. 공유 공인 IP/NAT의 다른 사람도 영향을 받을 수 있습니다. 네트워크 탭의 세 영역에서 관리합니다. 수동 블랙리스트는 국가/IP/CIDR 차단, 화이트리스트는 등록 국가 또는 IP/CIDR만 허용하는 목록입니다. 비어 있으면 공개하고 블랙리스트·자동 차단이 화이트리스트보다 우선합니다. 국가 미제공 시 허용 IP와 일치하지 않으면 비공개입니다. 자동 차단을 켜면 원본 공인 IP별 기본 10분 창에서 로그인 실패 10회·요청 제한 위반 20회·파일 요청 시도 30회 초과·요청 용량 10GB 초과를 감지해 기본 30분 차단합니다. 자동 블랙리스트의 **수정 → 감지 기준 저장/취소**로 기준과 차단 시간을 조정합니다. 자동 블랙리스트 사용·파일 전송 금지·수동 블랙리스트 사용 토글은 각각 즉시 적용합니다. 규칙 추가·제거와 토글을 변경해도 입력 중인 감지 기준은 함께 저장하지 않습니다. 동일 IP의 로그인 보관함/기기 전송·여러 기기 세션을 함께 계산하며 같은 요청의 재시도는 제외합니다. 수락 전의 유효한 파일 요청 본문을 기준으로 세며 실제 업로드 성공 용량 통계는 아닙니다. 정상 조각 API·UPLOAD_BUSY·거절 대기는 위반 횟수에서 제외합니다. 공유 공인 IP/NAT의 다른 사람도 영향을 받을 수 있습니다.
자동 IP 차단은 DB에 보존하고 만료 후 약 1분 주기로 정리합니다. 감지 중인 횟수·용량 창과 중복 요청 키는 최대 각 10,000개 메모리 항목으로 제한하며 재시작 시 초기화됩니다. 자동 차단 목록은 최대 10,000개의 활성 IP까지 보관하고, 그 이후에도 기존 요청 제한·전체 용량·동시 작업 제한은 유지합니다. 자동 차단을 끄면 기록을 유지한 채 적용/감지를 중지하며, 기간이 끝나기 전에 다시 켜면 남은 차단도 적용됩니다. 표에서 선택 해제하면 해당 IP의 감지 누적도 초기화합니다. 알려지지 않은 프록시 주소는 자동 차단하지 않습니다. 기존 인증된 관리자 로그인은 자동 차단 상태를 복구할 수 있으며 수동 규칙은 우회하지 않습니다. 자동 IP 차단은 DB에 보존하고 만료 후 약 1분 주기로 정리합니다. 감지 중인 횟수·용량 창과 중복 요청 키는 최대 각 10,000개 메모리 항목으로 제한하며 재시작 시 초기화됩니다. 자동 차단 목록은 최대 10,000개의 활성 IP까지 보관하고, 그 이후에도 기존 요청 제한·전체 용량·동시 작업 제한은 유지합니다. 자동 차단을 끄면 기록을 유지한 채 적용/감지를 중지하며, 기간이 끝나기 전에 다시 켜면 남은 차단도 적용됩니다. 표에서 선택 해제하면 해당 IP의 감지 누적도 초기화합니다. 알려지지 않은 프록시 주소는 자동 차단하지 않습니다. 기존 인증된 관리자 로그인은 자동 차단 상태를 복구할 수 있으며 수동 규칙은 우회하지 않습니다.
@@ -548,4 +548,4 @@ sudo docker compose -f docker-compose.yaml up -d --build
## 관리자 용량 표시·파일 전송 금지 ## 관리자 용량 표시·파일 전송 금지
관리자 상단의 신규 업로드 허용/중지 영역은 제거하고 파일 전송 금지를 네트워크 수정 화면으로 옮겼습니다. 용량 단위는 KB/MB/GB를 유지하고 `7,687.0GB`처럼 천 단위 쉼표를 표시합니다. 동시에 처리할 업로드 수에도 설명을 표시하고 읽기/수정 화면의 수치와 입력창을 같은 높이로 정렬합니다. 이번 변경은 기존 관리 설정 필드를 재사용하므로 DB 초기화나 Compose 변경 없이 최신 소스로 재빌드·배포하면 됩니다. 관리자 상단의 신규 업로드 허용/중지 영역은 제거하고 파일 전송 금지를 네트워크의 개별 토글로 옮겼습니다. 용량 단위는 KB/MB/GB를 유지하고 `7,687.0GB`처럼 천 단위 쉼표를 표시합니다. 동시에 처리할 업로드 수에도 설명을 표시하고 읽기/수정 화면의 수치와 입력창을 같은 높이로 정렬합니다. 이번 변경은 기존 관리 설정 필드를 재사용하므로 DB 초기화나 Compose 변경 없이 최신 소스로 재빌드·배포하면 됩니다.

View File

@@ -226,3 +226,10 @@
- 관리자 상단 업로드 허용/중지 영역을 제거하고 네트워크 수정 화면에 ‘파일 전송 금지’ 토글을 배치했다. 기존 `uploadsPaused` 설정과 리비전/명시적 저장을 재사용한다. 새 파일 업로드·보관함 파일 저장·저장된 파일의 새 재전송과 미수락 파일 요청 수락을 서버에서 거절하며 링크/텍스트 전송·저장은 유지한다. - 관리자 상단 업로드 허용/중지 영역을 제거하고 네트워크 수정 화면에 ‘파일 전송 금지’ 토글을 배치했다. 기존 `uploadsPaused` 설정과 리비전/명시적 저장을 재사용한다. 새 파일 업로드·보관함 파일 저장·저장된 파일의 새 재전송과 미수락 파일 요청 수락을 서버에서 거절하며 링크/텍스트 전송·저장은 유지한다.
- 초기 세션·기기 스냅샷·소켓 연결/재연결에서 파일 전송 가능 여부와 정책 리비전만 공개한다. 변경 시 기존 접속자에게 전달하고 오래된 스냅샷이 최신 상태를 덮어쓰지 못하게 비교한다. 사용자 화면의 파일 선택/보관함 저장/파일 재전송 메뉴를 숨기고 링크·텍스트 안내를 표시한다. 열린 파일 초안도 전환하고 파일 드롭/이미지 붙여넣기는 차단하며 작성 중인 텍스트는 유지한다. 이미 수락한 파일은 이어 보내기/취소를 제공하고 기존 다운로드도 유지한다. - 초기 세션·기기 스냅샷·소켓 연결/재연결에서 파일 전송 가능 여부와 정책 리비전만 공개한다. 변경 시 기존 접속자에게 전달하고 오래된 스냅샷이 최신 상태를 덮어쓰지 못하게 비교한다. 사용자 화면의 파일 선택/보관함 저장/파일 재전송 메뉴를 숨기고 링크·텍스트 안내를 표시한다. 열린 파일 초안도 전환하고 파일 드롭/이미지 붙여넣기는 차단하며 작성 중인 텍스트는 유지한다. 이미 수락한 파일은 이어 보내기/취소를 제공하고 기존 다운로드도 유지한다.
- API 145개, 관리자 PC/390px 모바일, 기존 클립보드·계정 보관함·101MiB 파일 분할 전송 브라우저 회귀, 타입 검사·빌드·포맷 검사를 통과했다. 브라우저에서 금지 중 한글/일본어/이모지/줄바꿈 텍스트를 실제 전송하고 금지 해제 후 파일 메뉴 복구·작성 내용 보존을 확인했다. 격리 운영 Docker에서 금지 설정의 재시작 보존, 수락된 8MiB 조각 이어 보내기와 기존 다운로드, 파일/텍스트/인증·한도 유지, NAS UID 바인드 저장소·수동 파일 삭제 대응도 통과했다. NAS 운영 서비스/자료와 Compose 설정은 변경하지 않았다. - API 145개, 관리자 PC/390px 모바일, 기존 클립보드·계정 보관함·101MiB 파일 분할 전송 브라우저 회귀, 타입 검사·빌드·포맷 검사를 통과했다. 브라우저에서 금지 중 한글/일본어/이모지/줄바꿈 텍스트를 실제 전송하고 금지 해제 후 파일 메뉴 복구·작성 내용 보존을 확인했다. 격리 운영 Docker에서 금지 설정의 재시작 보존, 수락된 8MiB 조각 이어 보내기와 기존 다운로드, 파일/텍스트/인증·한도 유지, NAS UID 바인드 저장소·수동 파일 삭제 대응도 통과했다. NAS 운영 서비스/자료와 Compose 설정은 변경하지 않았다.
## 2026-10-15 익숙한 GB 입력·기본값 복원·네트워크 개별 적용
- 용량 표시와 관리자 입력을 1,024 기준의 KB/MB/GB 명칭으로 통일했다. 기존 20GiB·2GiB·5GiB 한도는 20GB·2GB·5GB로 보이고 실제 바이트 값은 유지한다. 공통 단위 경계·천 단위 쉼표와 수정하지 않은 정확한 한도 보존을 검증했다. GB 입력은 소수점 셋째 자리까지 지원하고, 입력 중에는 자유롭게 지우고 입력창을 벗어나면 `15.000`처럼 표시한다. 빈칸 예시·초과 자릿수/범위 거절과 PC/390px 모바일 정렬을 확인했다.
- 기본값 복원은 서버가 제공하는 NAS 초기 설정값을 초안에만 채운다. 취소 시 기존 설정을 유지하고 명시적으로 저장하면 일곱 용량/동시 업로드 항목만 복원하며 네트워크 토글·감지 기준·기존 파일은 유지한다. 저장된 관리 설정과 초기 기본값을 분리하고 별도 인스턴스 및 Docker 재시작 뒤에도 초기값을 그대로 제공함을 확인했다.
- 네트워크 전체 수정 모드를 없애고 규칙 추가/선택 제거와 각 토글을 즉시 적용한다. 자동 감지 숫자만 해당 영역에서 수정/저장/취소하며, 수정 중 목록/토글 작업이 숫자 초안을 저장하지 않음을 실제 브라우저로 검증했다. 관리자 자신 차단 거절 시 추가 모달과 기존 목록 유지, 영역 간 여백, 자동 차단/해제와 기존 파일 금지 정책도 확인했다.
- 전체 API 145개, 관리자 PC/모바일 및 기존 수신 화면 브라우저 회귀, 타입 검사·빌드·포맷 검사를 통과했다. 최종 운영 Docker 이미지의 격리 컨테이너에서 기본값과 저장 설정 분리, 정책/파일/텍스트/인증 재시작 보존, 수락된 8MiB 조각 재개와 다운로드, SQLite integrity·NAS UID 바인드 저장소·수동 삭제 대응도 통과했다. 테스트 자원은 종료 후 제거했으며 NAS 운영 데이터·Compose는 변경하지 않았다.

View File

@@ -153,11 +153,20 @@ try {
assert.equal(restored.account.id, verifiedAccount.userId); assert.equal(restored.account.id, verifiedAccount.userId);
assert.equal(restored.account.verified, true); assert.equal(restored.account.verified, true);
assert.equal(restored.account.administrator, true); assert.equal(restored.account.administrator, true);
const settings = JSON.parse( const { settings, capacityDefaults } = JSON.parse(
node( 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));`, `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());const {settings,capacityDefaults}=await r.json();console.log(JSON.stringify({settings,capacityDefaults}));`,
), ),
); );
assert.deepEqual(capacityDefaults, {
maxConcurrentUploads: 4,
totalStorageLimitMb: 20480,
accountBytes: 2 * 1024 ** 3,
deviceBytes: 2 * 1024 ** 3,
devicesTotalBytes: 20 * 1024 ** 3,
perFileBytes: 2 * 1024 ** 3,
minDiskFreeBytes: 5 * 1024 ** 3,
});
assert.equal(settings.maxConcurrentUploads, 3); assert.equal(settings.maxConcurrentUploads, 3);
for (const [key, value] of Object.entries({ for (const [key, value] of Object.entries({
accountBytes: 3e9, accountBytes: 3e9,

View File

@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises'; import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { chromium } from '@playwright/test'; import { chromium, expect } from '@playwright/test';
import { createApp } from '../../apps/server/src/app.ts'; import { createApp } from '../../apps/server/src/app.ts';
import { loadConfig } from '../../apps/server/src/config.ts'; import { loadConfig } from '../../apps/server/src/config.ts';
import { users } from '../../apps/server/src/db/schema.ts'; import { users } from '../../apps/server/src/db/schema.ts';
@@ -28,7 +28,7 @@ const runtime = await createApp(
); );
// A NAS-sized disk verifies comma grouping in the rendered admin card. // A NAS-sized disk verifies comma grouping in the rendered admin card.
const realSummary = runtime.service.admin.summary.bind(runtime.service.admin); const realSummary = runtime.service.admin.summary.bind(runtime.service.admin);
runtime.service.admin.summary = () => ({ ...realSummary(), diskFreeBytes: 7687e9 }); runtime.service.admin.summary = () => ({ ...realSummary(), diskFreeBytes: 7687 * 1024 ** 3 });
let browser; let browser;
try { try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 }); await runtime.app.listen({ host: '127.0.0.1', port: 0 });
@@ -199,11 +199,9 @@ try {
await composer.locator('.selected-files').waitFor(); await composer.locator('.selected-files').waitFor();
await admin.getByRole('button', { name: '네트워크', exact: true }).click(); await admin.getByRole('button', { name: '네트워크', exact: true }).click();
async function banFiles(value) { async function banFiles(value) {
await admin.getByRole('button', { name: '수정', exact: true }).click();
const toggle = admin.getByRole('switch', { name: '파일 전송 금지', exact: true }); const toggle = admin.getByRole('switch', { name: '파일 전송 금지', exact: true });
if ((await toggle.getAttribute('aria-checked')) !== String(value)) await toggle.click(); if ((await toggle.getAttribute('aria-checked')) !== String(value)) await toggle.click();
await admin.getByRole('button', { name: '네트워크 설정 저장', exact: true }).click(); await expect(toggle).toHaveAttribute('aria-checked', String(value));
await admin.getByRole('button', { name: '수정', exact: true }).waitFor();
} }
await banFiles(true); await banFiles(true);
assert.equal(runtime.service.admin.state.uploadsPaused, true); assert.equal(runtime.service.admin.state.uploadsPaused, true);
@@ -270,7 +268,7 @@ try {
await admin.getByRole('button', { name: '용량 설정', exact: true }).click(); await admin.getByRole('button', { name: '용량 설정', exact: true }).click();
const capacityForm = admin.locator('.admin-settings'); const capacityForm = admin.locator('.admin-settings');
assert.equal(await capacityForm.locator('input').count(), 0); assert.equal(await capacityForm.locator('input').count(), 0);
assert.match(await capacityForm.innerText(), /로그인 보관함 계정당 한도[\s\S]*2\.1GB/); assert.match(await capacityForm.innerText(), /로그인 보관함 계정당 한도[\s\S]*2\.0GB/);
await admin.getByRole('button', { name: '수정', exact: true }).click(); await admin.getByRole('button', { name: '수정', exact: true }).click();
const rowInputs = await admin.locator('.admin-settings-grid input').evaluateAll((inputs) => const rowInputs = await admin.locator('.admin-settings-grid input').evaluateAll((inputs) =>
inputs.slice(0, 2).map((input) => ({ inputs.slice(0, 2).map((input) => ({
@@ -305,25 +303,59 @@ try {
await admin.getByRole('button', { name: '설정 저장', exact: true }).click(); await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor(); await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
assert.equal(runtime.service.admin.state.maxConcurrentUploads, 3); assert.equal(runtime.service.admin.state.maxConcurrentUploads, 3);
assert.equal(runtime.service.admin.state.totalStorageLimitMb, Math.round(16e9 / 1048576)); assert.equal(runtime.service.admin.state.totalStorageLimitMb, 16 * 1024);
for (const [key, expected] of Object.entries({ for (const [key, expected] of Object.entries({
accountBytes: 3e9, accountBytes: 3 * 1024 ** 3,
deviceBytes: 4e9, deviceBytes: 4 * 1024 ** 3,
devicesTotalBytes: 12e9, devicesTotalBytes: 12 * 1024 ** 3,
perFileBytes: 1e9, perFileBytes: 1024 ** 3,
minDiskFreeBytes: 0, minDiskFreeBytes: 0,
})) }))
assert.equal(runtime.service.admin.state[key], expected); assert.equal(runtime.service.admin.state[key], expected);
assert.equal(await capacityForm.locator('input').count(), 0); assert.equal(await capacityForm.locator('input').count(), 0);
await admin.screenshot({ path: 'tests/e2e/admin-capacity-desktop.png', fullPage: true }); await admin.screenshot({ path: 'tests/e2e/admin-capacity-desktop.png', fullPage: true });
// Fixed precision is formatting, not an automatic save or a quota conversion.
await admin.getByRole('button', { name: '수정', exact: true }).click();
const singleFile = admin.getByLabel('파일 한 개 한도 (GB)', { exact: true });
assert.equal(await singleFile.inputValue(), '1.000');
await singleFile.fill('');
assert.equal(await singleFile.getAttribute('placeholder'), '0.000');
await singleFile.fill('15');
await singleFile.blur();
assert.equal(await singleFile.inputValue(), '15.000');
await singleFile.fill('15.125');
await singleFile.blur();
assert.equal(await singleFile.inputValue(), '15.125');
await singleFile.fill('1.2345');
assert.equal(await singleFile.evaluate((input) => input.checkValidity()), false);
const totalLimit = admin.getByLabel('전체 파일 저장 한도 (GB)', { exact: true });
await totalLimit.fill('1234567');
assert.equal(await totalLimit.evaluate((input) => input.checkValidity()), false);
const beforeReset = { ...runtime.service.admin.state };
const defaults = await admin.evaluate(
async () => (await (await fetch('/api/admin/summary')).json()).capacityDefaults,
);
await admin.getByRole('button', { name: '기본값으로 돌리기', exact: true }).click();
assert.equal(await totalLimit.inputValue(), '20.000');
assert.equal(await singleFile.inputValue(), '2.000');
assert.equal(await totalLimit.evaluate((input) => input.checkValidity()), true);
assert.deepEqual(runtime.service.admin.state, beforeReset, 'reset fills the form without saving');
await admin.getByRole('button', { name: '취소', exact: true }).click();
assert.deepEqual(runtime.service.admin.state, beforeReset, 'cancel preserves saved settings');
await admin.getByRole('button', { name: '수정', exact: true }).click();
await admin.getByRole('button', { name: '기본값으로 돌리기', exact: true }).click();
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
await admin.getByRole('button', { name: '수정', exact: true }).waitFor();
for (const [key, value] of Object.entries(defaults))
assert.equal(runtime.service.admin.state[key], value, key);
assert.equal(
runtime.service.admin.state.uploadsPaused,
false,
'capacity reset never changes file policy',
);
await admin.getByRole('button', { name: '네트워크', exact: true }).click(); await admin.getByRole('button', { name: '네트워크', exact: true }).click();
assert.equal(await admin.locator('.network-auto-fields input').count(), 0); assert.equal(await admin.locator('.network-auto-fields input').count(), 0);
assert.equal(await admin.getByRole('switch').count(), 0); assert.equal(await admin.getByRole('switch').count(), 3);
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) { async function addRule(page, list, kind, value) {
await page.getByRole('button', { name: list + ' 추가', exact: true }).click(); await page.getByRole('button', { name: list + ' 추가', exact: true }).click();
const modal = page.getByRole('dialog', { name: list + ' 추가', exact: true }); const modal = page.getByRole('dialog', { name: list + ' 추가', exact: true });
@@ -336,6 +368,28 @@ try {
} }
await addRule(admin, '블랙리스트', 'country', 'cn'); await addRule(admin, '블랙리스트', 'country', 'cn');
await addRule(admin, '블랙리스트', 'ip', '9.9.9.*'); await addRule(admin, '블랙리스트', 'ip', '9.9.9.*');
assert.deepEqual(runtime.service.admin.state.blockedCountries, ['CN']);
assert.deepEqual(runtime.service.admin.state.blockedIps, ['9.9.9.0/24']);
assert.equal(await admin.locator('.network-auto-fields input').count(), 0);
await admin.getByRole('button', { name: '블랙리스트 추가', exact: true }).click();
const blockedModal = admin.getByRole('dialog', { name: '블랙리스트 추가', exact: true });
await blockedModal.getByLabel('국가 코드', { exact: true }).fill('KR');
await blockedModal.getByRole('button', { name: '추가', exact: true }).click();
await admin
.locator('.error-toast')
.filter({ hasText: '현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.' })
.waitFor();
assert.deepEqual(runtime.service.admin.state.blockedCountries, ['CN']);
assert.equal(await blockedModal.isVisible(), true);
await blockedModal.getByRole('button', { name: '규칙 추가 닫기', exact: true }).click();
await admin.getByRole('button', { name: '오류 닫기', exact: true }).click();
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(runtime.service.admin.state.autoLoginFailures, 10);
assert.equal(await admin.locator('.network-auto-fields input').count(), 0);
await admin.getByRole('button', { name: '자동 감지 기준 수정', exact: true }).click();
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('5');
await addRule(admin, '화이트리스트', 'country', 'KR'); await addRule(admin, '화이트리스트', 'country', 'KR');
await addRule(admin, '화이트리스트', 'ip', '8.8.8.*'); await addRule(admin, '화이트리스트', 'ip', '8.8.8.*');
await addRule(admin, '화이트리스트', 'ip', '4.4.4.4'); await addRule(admin, '화이트리스트', 'ip', '4.4.4.4');
@@ -344,11 +398,25 @@ try {
.locator('section[aria-labelledby="whitelist-title"]') .locator('section[aria-labelledby="whitelist-title"]')
.getByRole('button', { name: '선택 항목 제거', exact: true }) .getByRole('button', { name: '선택 항목 제거', exact: true })
.click(); .click();
assert.equal(await admin.getByLabel('로그인 실패 (회)', { exact: true }).inputValue(), '5');
assert.equal(
runtime.service.admin.state.autoLoginFailures,
10,
'list operations never save numeric drafts',
);
await admin.getByRole('switch', { name: '자동 블랙리스트 사용', exact: true }).click(); await admin.getByRole('switch', { name: '자동 블랙리스트 사용', exact: true }).click();
await expect(
admin.getByRole('switch', { name: '자동 블랙리스트 사용', exact: true }),
).toHaveAttribute('aria-checked', 'true');
assert.equal(
runtime.service.admin.state.autoLoginFailures,
10,
'a toggle saves only its own flag',
);
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('3'); await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('3');
await admin.getByRole('button', { name: '네트워크 설정 저장', exact: true }).click(); await admin.getByRole('button', { name: '감지 기준 저장', exact: true }).click();
await admin.getByText('네트워크 설정을 저장했어요.', { exact: true }).waitFor(); await admin.getByText('자동 감지 기준을 저장했어요.', { exact: true }).waitFor();
assert.equal(await admin.getByRole('switch').count(), 0); assert.equal(await admin.getByRole('switch').count(), 3);
assert.equal(runtime.service.admin.state.autoLoginFailures, 3); 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.blockedIps, ['9.9.9.0/24']);
assert.deepEqual(runtime.service.admin.state.allowedCountries, ['KR']); assert.deepEqual(runtime.service.admin.state.allowedCountries, ['KR']);
@@ -366,6 +434,14 @@ try {
}, attackSession.csrfToken); }, attackSession.csrfToken);
await admin.getByRole('button', { name: '자동 차단 목록 새로고침', exact: true }).click(); await admin.getByRole('button', { name: '자동 차단 목록 새로고침', exact: true }).click();
await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).waitFor(); await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).waitFor();
const networkSections = await admin.locator('.network-sections > section').evaluateAll((items) =>
items.map((item) => {
const { top, bottom } = item.getBoundingClientRect();
return { top, bottom };
}),
);
for (let index = 1; index < networkSections.length; index++)
assert.ok(networkSections[index].top - networkSections[index - 1].bottom >= 20);
await admin.screenshot({ path: 'tests/e2e/network-desktop.png', fullPage: true }); await admin.screenshot({ path: 'tests/e2e/network-desktop.png', fullPage: true });
await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).check(); await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).check();
await admin.getByRole('button', { name: '선택 차단 해제', exact: true }).click(); await admin.getByRole('button', { name: '선택 차단 해제', exact: true }).click();
@@ -459,7 +535,10 @@ try {
); );
assert.equal(await mobile.locator('.admin-settings input').count(), 0); assert.equal(await mobile.locator('.admin-settings input').count(), 0);
await mobile.getByRole('button', { name: '수정', exact: true }).click(); await mobile.getByRole('button', { name: '수정', exact: true }).click();
assert.equal(await mobile.getByLabel('파일 한 개 한도 (GB)', { exact: true }).inputValue(), '1'); assert.equal(
await mobile.getByLabel('파일 한 개 한도 (GB)', { exact: true }).inputValue(),
'2.000',
);
assert.equal( assert.equal(
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth), await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true, true,
@@ -471,8 +550,7 @@ try {
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth), await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true, true,
); );
assert.equal(await mobile.getByRole('switch').count(), 0); assert.equal(await mobile.getByRole('switch').count(), 3);
await mobile.getByRole('button', { name: '수정', exact: true }).click();
await mobile.getByRole('button', { name: '블랙리스트 추가', exact: true }).click(); await mobile.getByRole('button', { name: '블랙리스트 추가', exact: true }).click();
const ruleModal = mobile.getByRole('dialog', { name: '블랙리스트 추가', exact: true }); const ruleModal = mobile.getByRole('dialog', { name: '블랙리스트 추가', exact: true });
assert.equal( assert.equal(
@@ -494,7 +572,7 @@ try {
assert.equal(await mobile.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0); assert.equal(await mobile.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0);
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
console.log( console.log(
'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.', 'PASS: administrator authorization, live file-only ban, 1024-based GB and three-decimal input, explicit default restore/save/cancel, immediate network rules/toggles independent of numeric drafts, scoped detection editor, self-block rejection, section spacing, audit and user support, desktop/mobile layouts and logout.',
); );
} finally { } finally {
await browser?.close(); await browser?.close();

View File

@@ -112,7 +112,7 @@ try {
assert.equal(await card.getByText('기기 이름은 신원이 인증된 표시가 아닙니다.').count(), 0); assert.equal(await card.getByText('기기 이름은 신원이 인증된 표시가 아닙니다.').count(), 0);
if (kind === 'file') { if (kind === 'file') {
await expect(card.locator('.request-file-size')).toHaveText( await expect(card.locator('.request-file-size')).toHaveText(
size === 1700000000 ? '1.7GB' : size === 100000 ? '100KB' : '1.3MB', size === 1700000000 ? '1.6GB' : size === 100000 ? '97.7KB' : '1.3MB',
); );
const spacing = await card.locator('.request-files').evaluate((el) => ({ const spacing = await card.locator('.request-files').evaluate((el) => ({
padding: getComputedStyle(el).paddingLeft, padding: getComputedStyle(el).paddingLeft,
@@ -196,7 +196,7 @@ try {
assert.equal((await api(receiver, '/device/blocks')).blocks.length, 0); assert.equal((await api(receiver, '/device/blocks')).blocks.length, 0);
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
console.log( console.log(
'PASS: content icons/titles, desktop/mobile 2:1 buttons, consistent single file sizes 100KB/1.3MB/1.7GB, no list indent or overflow, retry countdown and draft preservation, block confirmation and private unblock UI.', 'PASS: content icons/titles, desktop/mobile 2:1 buttons, consistent single file sizes 97.7KB/1.3MB/1.6GB, no list indent or overflow, retry countdown and draft preservation, block confirmation and private unblock UI.',
); );
} finally { } finally {
await browser?.close(); await browser?.close();