Align capacity units and scope administrator network edits
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -227,6 +227,7 @@ onMounted(async () => {
|
||||
<CapacityPanel
|
||||
v-else-if="view === 'rules'"
|
||||
:settings="overview.settings"
|
||||
:defaults="overview.capacityDefaults"
|
||||
@saved="networkSaved"
|
||||
/>
|
||||
<NetworkPanel
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup>
|
||||
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() {
|
||||
개인별 한도는 각 계정·기기 세션에 공통 적용합니다. 전체 한도는 모든 사용자가 함께 쓰는
|
||||
공간입니다.
|
||||
</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">
|
||||
<label
|
||||
>동시에 처리할 업로드 수<input
|
||||
@@ -107,18 +129,19 @@ async function save() {
|
||||
>
|
||||
<label v-for="limit in limits" :key="limit.key"
|
||||
>{{ 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)'"
|
||||
type="number"
|
||||
:min="
|
||||
limit.key === 'minDiskFreeBytes'
|
||||
? 0
|
||||
: limit.key === 'totalStorageLimitMb'
|
||||
? 1.074
|
||||
: 0.001
|
||||
limit.key === 'minDiskFreeBytes' ? 0 : limit.key === 'totalStorageLimitMb' ? 1 : 0.001
|
||||
"
|
||||
:max="limit.max || 1000"
|
||||
step="any"
|
||||
:max="limit.max || 1024"
|
||||
step="0.001"
|
||||
required
|
||||
:disabled="busy"
|
||||
/><small>{{ limit.hint }}</small></label
|
||||
@@ -148,6 +171,9 @@ async function save() {
|
||||
<div v-if="editing" class="admin-edit-actions">
|
||||
<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="resetDefaults">
|
||||
<RotateCcw :size="15" />기본값으로 돌리기
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
|
||||
</form>
|
||||
|
||||
@@ -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) {
|
||||
<section class="network-panel">
|
||||
<header class="admin-edit-heading">
|
||||
<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>
|
||||
<p class="admin-muted">
|
||||
수정을 누르면 목록과 감지 기준을 변경할 수 있습니다. 블랙리스트와 자동 차단이 우선 적용됩니다.
|
||||
토글과 규칙 추가·제거는 즉시 적용됩니다. 자동 감지 기준은 해당 영역의 수정 버튼으로
|
||||
변경합니다. 블랙리스트와 자동 차단이 우선 적용됩니다.
|
||||
</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">
|
||||
<header>
|
||||
<h2 id="file-policy-title">파일 전송 금지</h2>
|
||||
<button
|
||||
v-if="editing"
|
||||
type="button"
|
||||
role="switch"
|
||||
class="network-switch"
|
||||
:aria-checked="draft.uploadsPaused"
|
||||
:aria-checked="state.uploadsPaused"
|
||||
aria-label="파일 전송 금지"
|
||||
:disabled="busy"
|
||||
@click="draft.uploadsPaused = !draft.uploadsPaused"
|
||||
@click="toggle('uploadsPaused')"
|
||||
>
|
||||
<span></span>{{ draft.uploadsPaused ? '켜짐' : '꺼짐' }}
|
||||
<span></span>{{ state.uploadsPaused ? '켜짐' : '꺼짐' }}
|
||||
</button>
|
||||
<span v-else class="admin-muted">{{
|
||||
draft.uploadsPaused ? '켜짐 · 링크와 텍스트만 허용' : '꺼짐 · 파일 전송 허용'
|
||||
}}</span>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
켜면 새 파일 전송·보관함 파일 저장을 막고 사용자 화면의 파일 선택 메뉴를 숨깁니다. 링크와
|
||||
@@ -262,107 +268,134 @@ function modalKey(event) {
|
||||
<section class="network-section" aria-labelledby="automatic-title">
|
||||
<header>
|
||||
<h2 id="automatic-title">자동 블랙리스트</h2>
|
||||
<button
|
||||
type="button"
|
||||
v-if="editing"
|
||||
role="switch"
|
||||
class="network-switch"
|
||||
:aria-checked="draft.autoBlockEnabled"
|
||||
aria-label="자동 블랙리스트 사용"
|
||||
:disabled="busy"
|
||||
@click="draft.autoBlockEnabled = !draft.autoBlockEnabled"
|
||||
>
|
||||
<span></span>{{ draft.autoBlockEnabled ? '사용 중' : '사용 안 함' }}
|
||||
</button>
|
||||
<span v-else class="admin-muted">{{
|
||||
draft.autoBlockEnabled ? '사용 중' : '사용 안 함'
|
||||
}}</span>
|
||||
<div class="network-section-controls">
|
||||
<button
|
||||
v-if="!editing"
|
||||
type="button"
|
||||
class="button small"
|
||||
aria-label="자동 감지 기준 수정"
|
||||
:disabled="busy"
|
||||
@click="edit"
|
||||
>
|
||||
<Pencil :size="15" />수정
|
||||
</button>
|
||||
<span v-else class="admin-muted">감지 기준 수정 중</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
class="network-switch"
|
||||
:aria-checked="state.autoBlockEnabled"
|
||||
aria-label="자동 블랙리스트 사용"
|
||||
:disabled="busy"
|
||||
@click="toggle('autoBlockEnabled')"
|
||||
>
|
||||
<span></span>{{ state.autoBlockEnabled ? '사용 중' : '사용 안 함' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
확인된 원본 공인 IP를 기준으로 반복 로그인 실패·요청 제한 위반·대량 파일 전송 시도를
|
||||
감지합니다. 정상적인 파일 조각 전송과 서버 혼잡 재시도는 횟수에 포함하지 않습니다.
|
||||
</p>
|
||||
<div v-if="editing" class="network-auto-fields">
|
||||
<label
|
||||
>감지 기간 (분)<input
|
||||
v-model.number="draft.autoWindowMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
required
|
||||
<form v-if="editing" class="network-criteria-form" @submit.prevent="save">
|
||||
<div class="network-auto-fields">
|
||||
<label
|
||||
>감지 기간 (분)<input
|
||||
v-model.number="draft.autoWindowMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
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"
|
||||
/></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
|
||||
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>
|
||||
@click="cancel"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<dl v-else class="admin-limit-details network-auto-summary">
|
||||
<div>
|
||||
<dt>감지 기간</dt>
|
||||
<dd>{{ draft.autoWindowMinutes }}분</dd>
|
||||
<dd>{{ state.autoWindowMinutes }}분</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>로그인 실패</dt>
|
||||
<dd>{{ draft.autoLoginFailures }}회</dd>
|
||||
<dd>{{ state.autoLoginFailures }}회</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>요청 제한 위반</dt>
|
||||
<dd>{{ draft.autoRateViolations }}회</dd>
|
||||
<dd>{{ state.autoRateViolations }}회</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>파일 전송 시도</dt>
|
||||
<dd>{{ draft.autoUploadRequests }}회</dd>
|
||||
<dd>{{ state.autoUploadRequests }}회</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>요청한 파일 용량</dt>
|
||||
<dd>{{ size(draft.autoUploadBytes) }}</dd>
|
||||
<dd>{{ size(state.autoUploadBytes) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>자동 차단 시간</dt>
|
||||
<dd>{{ draft.autoBlockMinutes }}분</dd>
|
||||
<dd>{{ state.autoBlockMinutes }}분</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="admin-muted">
|
||||
@@ -445,24 +478,20 @@ function modalKey(event) {
|
||||
<h2 id="blacklist-title">블랙리스트</h2>
|
||||
<button
|
||||
type="button"
|
||||
v-if="editing"
|
||||
role="switch"
|
||||
class="network-switch"
|
||||
:aria-checked="draft.blacklistEnabled"
|
||||
:aria-checked="state.blacklistEnabled"
|
||||
aria-label="블랙리스트 사용"
|
||||
:disabled="busy"
|
||||
@click="draft.blacklistEnabled = !draft.blacklistEnabled"
|
||||
@click="toggle('blacklistEnabled')"
|
||||
>
|
||||
<span></span>{{ draft.blacklistEnabled ? '사용 중' : '사용 안 함' }}
|
||||
<span></span>{{ state.blacklistEnabled ? '사용 중' : '사용 안 함' }}
|
||||
</button>
|
||||
<span v-else class="admin-muted">{{
|
||||
draft.blacklistEnabled ? '사용 중' : '사용 안 함'
|
||||
}}</span>
|
||||
</header>
|
||||
<p class="admin-muted">
|
||||
등록한 국가·IP·IP 대역의 접속을 차단합니다. 화이트리스트와 겹쳐도 차단이 우선합니다.
|
||||
</p>
|
||||
<div v-if="editing" class="network-actions">
|
||||
<div class="network-actions">
|
||||
<button type="button" class="button small" :disabled="busy" @click="open('black')">
|
||||
<Plus :size="15" />블랙리스트 추가</button
|
||||
><button
|
||||
@@ -478,14 +507,14 @@ function modalKey(event) {
|
||||
<table class="network-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="editing">선택</th>
|
||||
<th>선택</th>
|
||||
<th>구분</th>
|
||||
<th>국가 · IP 또는 IP 대역</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in black" :key="entry.id">
|
||||
<td v-if="editing">
|
||||
<td>
|
||||
<input
|
||||
v-model="selectedBlack"
|
||||
type="checkbox"
|
||||
@@ -498,9 +527,7 @@ function modalKey(event) {
|
||||
<td>{{ ruleName(entry) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!black.length">
|
||||
<td :colspan="editing ? 3 : 2" class="network-empty">
|
||||
등록된 차단 규칙이 없습니다.
|
||||
</td>
|
||||
<td colspan="3" class="network-empty">등록된 차단 규칙이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -516,7 +543,7 @@ function modalKey(event) {
|
||||
허용하고 나머지는 모두 차단합니다. 국가를 확인할 수 없는 접속도 허용 IP와 일치하지 않으면
|
||||
차단됩니다.
|
||||
</p>
|
||||
<div v-if="editing" class="network-actions">
|
||||
<div class="network-actions">
|
||||
<button type="button" class="button small" :disabled="busy" @click="open('white')">
|
||||
<Plus :size="15" />화이트리스트 추가</button
|
||||
><button
|
||||
@@ -532,14 +559,14 @@ function modalKey(event) {
|
||||
<table class="network-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="editing">선택</th>
|
||||
<th>선택</th>
|
||||
<th>구분</th>
|
||||
<th>국가 · IP 또는 IP 대역</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in white" :key="entry.id">
|
||||
<td v-if="editing">
|
||||
<td>
|
||||
<input
|
||||
v-model="selectedWhite"
|
||||
type="checkbox"
|
||||
@@ -552,7 +579,7 @@ function modalKey(event) {
|
||||
<td>{{ ruleName(entry) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!white.length">
|
||||
<td :colspan="editing ? 3 : 2" class="network-empty">
|
||||
<td colspan="3" class="network-empty">
|
||||
등록된 허용 규칙이 없습니다. 블랙리스트에 없는 접속은 공개됩니다.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -564,11 +591,7 @@ function modalKey(event) {
|
||||
허용된 국가나 IP에서도 악용 감지 기준을 적용합니다.
|
||||
</p>
|
||||
</section>
|
||||
<div v-if="editing" class="admin-edit-actions">
|
||||
<button class="button primary" :disabled="busy">네트워크 설정 저장</button
|
||||
><button type="button" class="button" :disabled="busy" @click="cancel">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Teleport to="body"
|
||||
><div v-if="modal" class="modal-backdrop" @click.self="!busy && close()">
|
||||
<section
|
||||
|
||||
@@ -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', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
export function formatSize(bytes) {
|
||||
if (bytes >= 1e9) return `${sizeNumber.format(bytes / 1e9)}GB`;
|
||||
if (bytes >= 1e6) return `${sizeNumber.format(bytes / 1e6)}MB`;
|
||||
if (bytes >= 1000) return `${sizeNumber.format(bytes / 1000).replace(/\.0$/, '')}KB`;
|
||||
if (bytes >= SIZE_GB) return `${sizeNumber.format(bytes / SIZE_GB)}GB`;
|
||||
if (bytes >= SIZE_MB) return `${sizeNumber.format(bytes / SIZE_MB)}MB`;
|
||||
if (bytes >= SIZE_KB) return `${sizeNumber.format(bytes / SIZE_KB).replace(/\.0$/, '')}KB`;
|
||||
return bytes === 0 ? '0KB' : '1KB 미만';
|
||||
}
|
||||
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
|
||||
|
||||
@@ -4209,7 +4209,7 @@ a.button {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.network-panel > form {
|
||||
.network-sections {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
margin-top: 20px;
|
||||
@@ -4346,9 +4346,6 @@ a.button {
|
||||
gap: 20px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.network-panel > form > .button {
|
||||
justify-self: start;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.admin-tabs {
|
||||
gap: 16px;
|
||||
@@ -4364,9 +4361,6 @@ a.button {
|
||||
.network-search {
|
||||
margin-left: 0;
|
||||
}
|
||||
.network-panel > form > .button {
|
||||
justify-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-audit-panel,
|
||||
@@ -4473,3 +4467,15 @@ a.button {
|
||||
line-height: 1.6;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user