diff --git a/README.md b/README.md index a460de3..31f204c 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 ** 저장 파일·업로드 주체·파일별 전송 요청 시점의 접속 지역/IP·남은 디스크와 진행 중 업로드를 확인하고, 확인 후 원본을 삭제할 수 있습니다. 삭제하면 같은 원본을 참조하는 모든 보관함 항목도 무효화됩니다. 파일 내용 다운로드 기능은 제공하지 않습니다. 화면은 새로고침할 때 갱신하며 지속적인 폴링은 하지 않습니다. -관리자는 신규 파일 업로드 중지/재개, 동시 업로드 수와 전체 파일 저장 한도, 국가 코드 및 IP/CIDR 차단을 설정할 수 있습니다. 기본값은 **동시 4개·전체 20GiB·차단 없음**입니다. 개인/기기별 한도와 최소 디스크 여유 공간은 함께 적용합니다. 중지 전에 수락한 전송·기존 다운로드·링크/텍스트는 유지합니다. 전체 한도를 줄여도 기존 파일은 삭제하지 않습니다. +관리자는 신규 파일 업로드 중지/재개, 동시 업로드 수와 전체 파일 저장 한도, 국가 코드 및 IP/CIDR 차단을 설정할 수 있습니다. 기본값은 **동시 4개·전체 20GB·차단 없음**입니다. 개인/기기별 한도와 최소 디스크 여유 공간은 함께 적용합니다. 중지 전에 수락한 전송·기존 다운로드·링크/텍스트는 유지합니다. 전체 한도를 줄여도 기존 파일은 삭제하지 않습니다. 네트워크 탭에서 국가·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시간으로 표시합니다. 푸터의 발견 허용/꺼짐 문구는 숨깁니다. -전송·수신·보관함·관리자 화면은 같은 용량 표시 함수를 사용합니다. 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 좌표를 추가로 수집하지 않으며 관리자 외에는 제공하지 않습니다. ## 네트워크·사용자 관리 -관리자 탭은 **저장 파일 / 용량 설정 / 네트워크 / 사용자 / 관리 기록**입니다. 국가는 `일본(JP)`처럼 이름과 코드를 함께 표시합니다. 네트워크에는 자동 블랙리스트·블랙리스트·화이트리스트를 둡니다. 국가 또는 IPv4/IPv6 주소·CIDR을 추가 모달에서 한 항목씩 등록하고 표에서 선택 제거한 뒤 네트워크 설정을 저장합니다. 기존 수동 차단 규칙은 유지합니다. +관리자 탭은 **저장 파일 / 용량 설정 / 네트워크 / 사용자 / 관리 기록**입니다. 국가는 `일본(JP)`처럼 이름과 코드를 함께 표시합니다. 네트워크에는 자동 블랙리스트·블랙리스트·화이트리스트를 둡니다. 국가 또는 IPv4/IPv6 주소·CIDR을 추가 모달에서 한 항목씩 등록하고 표에서 선택 제거하며, 추가·제거는 즉시 적용합니다. 기존 수동 차단 규칙은 유지합니다. 블랙리스트·자동 차단이 우선합니다. 화이트리스트가 비어 있으면 공개하고, 항목이 있으면 등록 국가 **또는** IP/대역에 해당하는 접속만 허용합니다. 국가가 확인되지 않은 접속은 허용 IP와 일치해야 합니다. 현재 관리자 자신을 차단하는 수동 규칙은 저장할 수 없습니다. @@ -147,6 +147,6 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 ** 관리 기록은 `동시에 처리할 업로드 수 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`처럼 천 단위 쉼표를 사용합니다. diff --git a/apps/server/src/administration.js b/apps/server/src/administration.js index e447b7c..6c025ce 100644 --- a/apps/server/src/administration.js +++ b/apps/server/src/administration.js @@ -68,11 +68,31 @@ const fields = { uploadsPaused: z.boolean(), maxConcurrentUploads: z.number().int().min(1).max(16), totalStorageLimitMb: z.number().int().min(1024).max(1048576), - accountBytes: z.number().int().min(1).max(1e12), - deviceBytes: z.number().int().min(1).max(1e12), - devicesTotalBytes: z.number().int().min(1).max(1e12), - perFileBytes: z.number().int().min(1).max(1e12), - minDiskFreeBytes: z.number().int().min(0).max(1e12), + accountBytes: z + .number() + .int() + .min(1) + .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 .array( z @@ -101,7 +121,11 @@ const fields = { autoLoginFailures: z.number().int().min(3).max(1000), autoRateViolations: z.number().int().min(3).max(1000), 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), }; const networkDefaults = { @@ -113,7 +137,7 @@ const networkDefaults = { autoLoginFailures: 10, autoRateViolations: 20, autoUploadRequests: 30, - autoUploadBytes: 10e9, + autoUploadBytes: 10 * 1024 ** 3, autoBlockMinutes: 30, }; 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')) .get(); const capacityDefaults = { + maxConcurrentUploads: service.cfg.maxConcurrentUploads, + totalStorageLimitMb: service.cfg.totalStorageQuota / 1048576, accountBytes: service.cfg.userQuota, deviceBytes: service.cfg.guestQuota, devicesTotalBytes: service.cfg.totalGuestQuota, perFileBytes: service.cfg.maxFileBytes, minDiskFreeBytes: service.cfg.minFreeBytes, }; + this.capacityDefaults = Object.freeze(capacityDefaults); this.state = row ? stateSchema.parse({ ...networkDefaults, ...capacityDefaults, ...JSON.parse(row.value) }) : { revision: 0, uploadsPaused: false, - maxConcurrentUploads: service.cfg.maxConcurrentUploads, - totalStorageLimitMb: service.cfg.totalStorageQuota / 1048576, blockedCountries: [], blockedIps: [], ...networkDefaults, @@ -371,6 +396,7 @@ export class Administration { const disk = statfsSync(this.service.cfg.uploadDir, { bigint: true }); return { settings: { ...this.state }, + capacityDefaults: { ...this.capacityDefaults }, limits: { perFileBytes: this.state.perFileBytes, accountBytes: this.state.accountBytes, diff --git a/apps/server/test/admin-capacity.test.js b/apps/server/test/admin-capacity.test.js index 3af199e..4bed78d 100644 --- a/apps/server/test/admin-capacity.test.js +++ b/apps/server/test/admin-capacity.test.js @@ -102,6 +102,10 @@ test('administrator limits persist, report to clients, reject invalid updates an a = await f.create(), b = await f.create(); 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 own(f, user)).json().code, 'TRANSFER_LIMIT'); const updated = await patch(f, admin, { @@ -112,6 +116,8 @@ test('administrator limits persist, report to clients, reject invalid updates an minDiskFreeBytes: 0, }); 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 [ 'perFileBytes', 'accountBytes', @@ -151,7 +157,7 @@ test('administrator limits persist, report to clients, reject invalid updates an { perFileBytes: 0 }, { accountBytes: -1 }, { deviceBytes: 1.2 }, - { devicesTotalBytes: 1e12 + 1 }, + { devicesTotalBytes: 1024 ** 4 + 1 }, { minDiskFreeBytes: -1 }, ]) { 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 { await f.close(); } diff --git a/apps/server/test/display-format.test.js b/apps/server/test/display-format.test.js index 10a2f65..8f3d8ae 100644 --- a/apps/server/test/display-format.test.js +++ b/apps/server/test/display-format.test.js @@ -1,20 +1,27 @@ import { test } from 'node:test'; 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 [ [0, '0KB'], [10, '1KB 미만'], - [1000, '1KB'], - [938000, '938KB'], - [999000, '999KB'], - [1000000, '1.0MB'], - [1100000, '1.1MB'], - [1000000000, '1.0GB'], - [1700000000, '1.7GB'], - [7687e9, '7,687.0GB'], - [1234567e9, '1,234,567.0GB'], + [SIZE_KB, '1KB'], + [938 * SIZE_KB, '938KB'], + [1023 * SIZE_KB, '1,023KB'], + [SIZE_MB, '1.0MB'], + [1.1 * SIZE_MB, '1.1MB'], + [SIZE_GB, '1.0GB'], + [1.7 * SIZE_GB, '1.7GB'], + [20 * SIZE_GB, '20.0GB'], + [7687 * SIZE_GB, '7,687.0GB'], + [1234567 * SIZE_GB, '1,234,567.0GB'], ]) assert.equal(formatSize(bytes), expected); }); diff --git a/apps/web/src/AdminPanel.vue b/apps/web/src/AdminPanel.vue index fef4e92..f59a471 100644 --- a/apps/web/src/AdminPanel.vue +++ b/apps/web/src/AdminPanel.vue @@ -227,6 +227,7 @@ onMounted(async () => { import { ref, reactive } from 'vue'; -import { Pencil } from '@lucide/vue'; +import { Pencil, RotateCcw } from '@lucide/vue'; import { api, showError } from './client'; -import { formatSize as size } from './displayFormat'; -const props = defineProps({ settings: { type: Object, required: true } }); +import { formatSize as size, SIZE_GB } from './displayFormat'; +const props = defineProps({ + settings: { type: Object, required: true }, + defaults: { type: Object, required: true }, +}); const emit = defineEmits(['saved']); const editing = ref(false), busy = ref(false), @@ -14,7 +17,7 @@ const limits = [ key: 'totalStorageLimitMb', label: '전체 파일 저장 한도', hint: '모든 사용자의 원본 파일과 업로드 예약 합계', - max: 1099.511, + max: 1024, }, { key: 'accountBytes', @@ -40,15 +43,30 @@ const limits = [ ]; const bytes = (settings, key) => key === 'totalStorageLimitMb' ? settings[key] * 1048576 : settings[key]; -const gb = (settings, key) => Number((bytes(settings, key) / 1e9).toFixed(3)); -let original; +const gb = (settings, key) => (bytes(settings, key) / SIZE_GB).toFixed(3); +let original, baseline; +function fill(settings) { + baseline = { ...settings }; + draft.maxConcurrentUploads = settings.maxConcurrentUploads; + for (const { key } of limits) draft[key] = gb(settings, key); +} function edit() { original = { ...props.settings }; - draft.maxConcurrentUploads = original.maxConcurrentUploads; - for (const { key } of limits) draft[key] = gb(original, key); + fill(original); notice.value = ''; 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() { editing.value = false; notice.value = '변경을 취소했어요.'; @@ -64,9 +82,9 @@ async function save() { for (const { key } of limits) { const value = Number(draft[key]); changes[key] = - value === gb(original, key) - ? original[key] - : Math.round((value * 1e9) / (key === 'totalStorageLimitMb' ? 1048576 : 1)); + value === Number(gb(baseline, key)) + ? baseline[key] + : Math.round((value * SIZE_GB) / (key === 'totalStorageLimitMb' ? 1048576 : 1)); } const settings = await api('/admin/settings', 'PATCH', changes); editing.value = false; @@ -93,6 +111,10 @@ async function save() { 개인별 한도는 각 계정·기기 세션에 공통 적용합니다. 전체 한도는 모든 사용자가 함께 쓰는 공간입니다.

+

+ 용량은 1GB = 1,024MB 기준으로 소수점 셋째 자리까지 입력하세요. 예: 5.000GB. 입력을 마치면 세 + 자리로 표시합니다. +

{{ notice }}

diff --git a/apps/web/src/NetworkPanel.vue b/apps/web/src/NetworkPanel.vue index 9418a88..7ddd361 100644 --- a/apps/web/src/NetworkPanel.vue +++ b/apps/web/src/NetworkPanel.vue @@ -6,24 +6,41 @@ import { formatAdminDate as date, formatCountry as country, formatSize as size, + SIZE_GB, } from './displayFormat'; const props = defineProps({ settings: { type: Object, required: true }, suggestedIp: String }); const emit = defineEmits(['saved', 'activity', 'consumed']); -const draft = reactive({}), +const state = ref(props.settings), + draft = reactive({}), busy = ref(false), notice = ref(''), editing = ref(false); -let editRevision; +const autoKeys = [ + 'autoWindowMinutes', + 'autoLoginFailures', + 'autoRateViolations', + 'autoUploadRequests', + 'autoUploadBytes', + 'autoBlockMinutes', +]; +const uploadGb = ref('0.000'); +let autoOriginal; function edit() { - fill(props.settings); - editRevision = props.settings.revision; + autoOriginal = Object.fromEntries(autoKeys.map((key) => [key, state.value[key]])); + Object.assign(draft, autoOriginal); + uploadGb.value = (draft.autoUploadBytes / SIZE_GB).toFixed(3); editing.value = true; notice.value = ''; } function cancel() { - fill(props.settings); 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([]), selectedWhite = ref([]), @@ -45,45 +62,21 @@ const reasonName = (reason) => 'file-volume': '요청 파일 용량 초과', })[reason] || reason; function fill(settings) { - for (const key of [ - 'uploadsPaused', - 'blacklistEnabled', - '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 = []; + // An older parent refresh must not undo an already completed action. + if (settings.revision < state.value.revision) return; + state.value = settings; } watch( () => props.settings.revision, - () => { - if (!editing.value) fill(props.settings); - }, - { immediate: true }, + () => fill(props.settings), ); -const uploadGb = computed({ - get: () => draft.autoUploadBytes / 1e9, - set: (number) => { - draft.autoUploadBytes = Math.round(Number(number) * 1e9); - }, -}); const entries = (list) => [ - ...draft[list === 'black' ? 'blockedCountries' : 'allowedCountries'].map((value) => ({ + ...state.value[list === 'black' ? 'blockedCountries' : 'allowedCountries'].map((value) => ({ kind: 'country', value, id: 'country:' + value, })), - ...draft[list === 'black' ? 'blockedIps' : 'allowedIps'].map((value) => ({ + ...state.value[list === 'black' ? 'blockedIps' : 'allowedIps'].map((value) => ({ kind: 'ip', value, id: 'ip:' + value, @@ -103,6 +96,20 @@ async function act(work) { 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) { const offset = more ? nextOffset.value : 0; const result = await api( @@ -117,19 +124,23 @@ loadAutomatic().catch(showError); async function save() { if (!editing.value) return; await act(async () => { - const settings = await api('/admin/settings', 'PATCH', { - revision: editRevision, + if (autoKeys.some((key) => state.value[key] !== autoOriginal[key])) + throw Error('다른 화면에서 감지 기준이 변경됐어요. 수정을 취소한 뒤 다시 열어 주세요.'); + const gb = Number(uploadGb.value); + await persist({ ...draft, + autoUploadBytes: + gb === Number((autoOriginal.autoUploadBytes / SIZE_GB).toFixed(3)) + ? autoOriginal.autoUploadBytes + : Math.round(gb * SIZE_GB), }); - fill(settings); editing.value = false; - emit('saved', settings); - notice.value = '네트워크 설정을 저장했어요.'; + notice.value = '자동 감지 기준을 저장했어요.'; }); } let trigger; async function open(list) { - if (!editing.value) return; + if (busy.value) return; trigger = document.activeElement; modal.value = list; kind.value = 'country'; @@ -141,7 +152,6 @@ watch( () => props.suggestedIp, async (ip) => { if (!ip) return; - if (!editing.value) edit(); await open('black'); kind.value = 'ip'; value.value = ip; @@ -154,7 +164,6 @@ function close() { trigger?.focus(); } async function add() { - if (!editing.value) return; await act(async () => { const entry = await api('/admin/network-rules/validate', 'POST', { kind: kind.value, @@ -168,22 +177,26 @@ async function add() { : entry.kind === 'country' ? 'allowedCountries' : 'allowedIps'; - draft[field] = [...new Set([...draft[field], entry.value])]; + await persist({ [field]: [...new Set([...state.value[field], entry.value])] }); close(); - notice.value = '표에 추가했어요. 네트워크 설정 저장을 누르면 적용됩니다.'; + notice.value = '규칙을 추가하고 적용했어요.'; }); } -function remove(list) { - if (!editing.value) return; +async function remove(list) { const selected = list === 'black' ? selectedBlack : selectedWhite; - const countries = list === 'black' ? 'blockedCountries' : 'allowedCountries', - ips = list === 'black' ? 'blockedIps' : 'allowedIps'; - draft[countries] = draft[countries].filter( - (value) => !selected.value.includes('country:' + value), - ); - draft[ips] = draft[ips].filter((value) => !selected.value.includes('ip:' + value)); - selected.value = []; - notice.value = '표에서 제거했어요. 네트워크 설정 저장을 누르면 적용됩니다.'; + if (!selected.value.length) return; + await act(async () => { + const countries = list === 'black' ? 'blockedCountries' : 'allowedCountries', + ips = list === 'black' ? 'blockedIps' : 'allowedIps'; + await persist({ + [countries]: state.value[countries].filter( + (value) => !selected.value.includes('country:' + value), + ), + [ips]: state.value[ips].filter((value) => !selected.value.includes('ip:' + value)), + }); + selected.value = []; + notice.value = '선택한 규칙을 제거하고 적용했어요.'; + }); } async function removeAutomatic() { await act(async () => { @@ -225,34 +238,27 @@ function modalKey(event) {

접속 보호 설정

- - 수정 중

- 수정을 누르면 목록과 감지 기준을 변경할 수 있습니다. 블랙리스트와 자동 차단이 우선 적용됩니다. + 토글과 규칙 추가·제거는 즉시 적용됩니다. 자동 감지 기준은 해당 영역의 수정 버튼으로 + 변경합니다. 블랙리스트와 자동 차단이 우선 적용됩니다.

{{ notice }}

-
+

파일 전송 금지

- {{ - draft.uploadsPaused ? '켜짐 · 링크와 텍스트만 허용' : '꺼짐 · 파일 전송 허용' - }}

켜면 새 파일 전송·보관함 파일 저장을 막고 사용자 화면의 파일 선택 메뉴를 숨깁니다. 링크와 @@ -262,107 +268,134 @@ function modalKey(event) {

자동 블랙리스트

- - {{ - draft.autoBlockEnabled ? '사용 중' : '사용 안 함' - }} +
+ + 감지 기준 수정 중 + +

확인된 원본 공인 IP를 기준으로 반복 로그인 실패·요청 제한 위반·대량 파일 전송 시도를 감지합니다. 정상적인 파일 조각 전송과 서버 혼잡 재시도는 횟수에 포함하지 않습니다.

-
-
+
감지 기간
-
{{ draft.autoWindowMinutes }}분
+
{{ state.autoWindowMinutes }}분
로그인 실패
-
{{ draft.autoLoginFailures }}회
+
{{ state.autoLoginFailures }}회
요청 제한 위반
-
{{ draft.autoRateViolations }}회
+
{{ state.autoRateViolations }}회
파일 전송 시도
-
{{ draft.autoUploadRequests }}회
+
{{ state.autoUploadRequests }}회
요청한 파일 용량
-
{{ size(draft.autoUploadBytes) }}
+
{{ size(state.autoUploadBytes) }}
자동 차단 시간
-
{{ draft.autoBlockMinutes }}분
+
{{ state.autoBlockMinutes }}분

@@ -445,24 +478,20 @@ function modalKey(event) {

블랙리스트

- {{ - draft.blacklistEnabled ? '사용 중' : '사용 안 함' - }}

등록한 국가·IP·IP 대역의 접속을 차단합니다. 화이트리스트와 겹쳐도 차단이 우선합니다.

-
+
-
- -
- +