Add network protection and administrator account support

This commit is contained in:
2026-10-15 12:05:52 +09:00
parent 471e2653d8
commit 4d527f74e6
20 changed files with 2913 additions and 106 deletions

187
apps/web/src/AdminAudit.vue Normal file
View File

@@ -0,0 +1,187 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue';
import { Trash2, RefreshCw, X } from '@lucide/vue';
import { api, showError } from './client';
import { formatAdminDate as date, auditChangeLines } from './displayFormat';
const events = ref([]),
nextOffset = ref(null),
selected = ref([]),
busy = ref(false),
notice = ref(''),
deletingAll = ref(false),
dialog = ref(null);
const actionName = (action) =>
({
'settings-change': '관리 설정 변경',
'file-delete': '원본 파일 삭제',
'automatic-block': '자동 IP 차단',
'automatic-unblock': '자동 차단 해제',
'password-reset-email': '비밀번호 재설정 메일 발송',
'account-delete': '사용자 계정 삭제',
})[action] || action;
function lines(event) {
if (event.action === 'settings-change')
return event.details.changes?.length
? event.details.changes.flatMap(auditChangeLines)
: ['변경 전 정보가 없어 상세 비교할 수 없습니다.'];
if (event.action === 'file-delete') return [event.details.name];
if (event.action === 'password-reset-email' || event.action === 'account-delete')
return [event.details.email];
if (event.action === 'automatic-unblock')
return ['자동 차단 해제: ' + event.details.ips.join(', ')];
const reasons = {
'login-failures': '로그인 실패 반복',
'rate-violations': '요청 제한 반복 초과',
'file-requests': '파일 전송 시도 반복',
'file-volume': '요청 파일 용량 초과',
};
return [
`${event.details.ip} · ${reasons[event.details.reason] || event.details.reason}`,
`${date(event.details.expiresAt)} 해제`,
];
}
async function load(more = false) {
const result = await api('/admin/audit?offset=' + (more ? nextOffset.value : 0));
events.value = more ? [...events.value, ...result.events] : result.events;
nextOffset.value = result.nextOffset;
if (!more) selected.value = [];
}
async function act(fn) {
if (busy.value) return;
busy.value = true;
notice.value = '';
try {
await fn();
} catch (error) {
showError(error);
} finally {
busy.value = false;
}
}
async function remove(ids) {
await act(async () => {
for (let offset = 0; offset < ids.length; offset += 100)
await api('/admin/audit/remove', 'POST', { ids: ids.slice(offset, offset + 100) });
await load();
notice.value = '선택한 관리 기록을 삭제했어요.';
});
}
let trigger;
async function confirmAll() {
trigger = document.activeElement;
deletingAll.value = true;
await nextTick();
dialog.value?.querySelector('button')?.focus();
}
function close() {
deletingAll.value = false;
trigger?.focus();
}
async function removeAll() {
await act(async () => {
await api('/admin/audit/remove', 'POST', { all: true });
close();
await load();
notice.value = '관리 기록을 모두 삭제했어요.';
});
}
function modalKey(event) {
if (event.key === 'Escape' && !busy.value) {
event.preventDefault();
close();
return;
}
if (event.key !== 'Tab') return;
const controls = [...dialog.value.querySelectorAll('button:not(:disabled)')],
first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
onMounted(() => load().catch(showError));
</script>
<template>
<section class="admin-audit-panel">
<div class="admin-toolbar">
<button class="button small" :disabled="busy" @click="act(() => load())">
<RefreshCw :size="15" />기록 새로고침</button
><button
class="button small"
:disabled="busy || !selected.length"
@click="remove([...selected])"
>
<Trash2 :size="15" />선택 기록 삭제</button
><button class="text-button" :disabled="busy || !events.length" @click="confirmAll">
전체 기록 삭제
</button>
</div>
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
<p v-if="!events.length" class="admin-muted">관리 기록이 없습니다.</p>
<div class="admin-audit">
<article v-for="event in events" :key="event.id">
<label class="audit-selection"
><input
v-model="selected"
type="checkbox"
:value="event.id"
:disabled="busy"
:aria-label="`${actionName(event.action)} ${date(event.createdAt)} 기록 선택`"
/><strong>{{ actionName(event.action) }}</strong></label
><span>{{ date(event.createdAt) }}</span
><small v-for="(line, index) in lines(event)" :key="index">{{ line }}</small
><button
class="text-button audit-remove"
:disabled="busy"
:aria-label="`${actionName(event.action)} 기록 삭제`"
@click="remove([event.id])"
>
<Trash2 :size="15" />삭제
</button>
</article>
</div>
<button
v-if="nextOffset !== null"
class="button small"
:disabled="busy"
@click="act(() => load(true))"
>
더 보기
</button>
<Teleport to="body"
><div v-if="deletingAll" class="modal-backdrop" @click.self="!busy && close()">
<section
ref="dialog"
class="modal admin-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="audit-delete-title"
@keydown="modalKey"
>
<header class="modal-title">
<h2 id="audit-delete-title">관리 기록을 모두 삭제할까요?</h2>
<button
class="icon-button"
aria-label="기록 삭제 확인 닫기"
:disabled="busy"
@click="close"
>
<X :size="20" />
</button>
</header>
<p>저장된 관리 기록 전체를 삭제합니다. 설정과 파일, 자동 차단 목록은 유지됩니다.</p>
<div class="admin-confirm-actions">
<button class="button" :disabled="busy" @click="close">취소</button
><button class="button primary" :disabled="busy" @click="removeAll">
전체 삭제 확인
</button>
</div>
</section>
</div></Teleport
>
</section>
</template>

View File

@@ -13,12 +13,14 @@ import {
X,
} from '@lucide/vue';
import { api, showError } from './client';
import { formatSize as size, formatAdminDate as date } from './displayFormat';
import { formatSize as size, formatAdminDate as date, formatCountry } from './displayFormat';
import NetworkPanel from './NetworkPanel.vue';
import AdminAudit from './AdminAudit.vue';
import AdminUsers from './AdminUsers.vue';
const emit = defineEmits(['devices']);
const overview = ref(null),
files = ref([]),
cursor = ref(null),
events = ref([]),
view = ref('files'),
busy = ref(false),
loading = ref(true),
@@ -27,14 +29,12 @@ const overview = ref(null),
dialog = ref(null);
const maxConcurrent = ref(4),
capacity = ref(20),
countries = ref(''),
ips = ref('');
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
pendingBlockedIp = ref(null);
function region(location) {
if (!location) return '확인 불가';
return (
[
location.countryCode ? countryNames.of(location.countryCode) : null,
location.countryCode ? formatCountry(location.countryCode) : null,
location.city || location.region,
]
.filter(Boolean)
@@ -44,19 +44,12 @@ function region(location) {
function form(settings) {
maxConcurrent.value = settings.maxConcurrentUploads;
capacity.value = Number(((settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1));
countries.value = settings.blockedCountries.join(', ');
ips.value = settings.blockedIps.join('\n');
}
async function refresh(resetForm = true) {
const [summary, list, audit] = await Promise.all([
api('/admin/summary'),
api('/admin/files'),
api('/admin/audit'),
]);
const [summary, list] = await Promise.all([api('/admin/summary'), api('/admin/files')]);
overview.value = summary;
files.value = list.items;
cursor.value = list.nextCursor;
events.value = audit.events;
if (resetForm) form(summary.settings);
}
async function act(fn) {
@@ -90,15 +83,13 @@ async function pauseUploads() {
await refresh(false);
});
}
const split = (value) =>
value
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean);
function addBlockedIp(ip) {
ips.value = [...new Set([...split(ips.value), ip])].join('\n');
view.value = 'rules';
notice.value = 'IP를 입력했어요. 설정 저장을 누르면 차단됩니다.';
pendingBlockedIp.value = ip;
view.value = 'network';
}
async function networkSaved(settings) {
overview.value.settings = settings;
await refresh(false);
}
async function save() {
await act(async () => {
@@ -110,8 +101,6 @@ async function save() {
Number(((overview.value.settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1))
? overview.value.settings.totalStorageLimitMb
: Math.round((Number(capacity.value) * 1e9) / 1048576),
blockedCountries: split(countries.value),
blockedIps: split(ips.value),
});
overview.value.settings = settings;
form(settings);
@@ -164,8 +153,6 @@ async function remove() {
await refresh(false);
});
}
const actionName = (action) =>
({ 'settings-change': '관리 설정 변경', 'file-delete': '원본 파일 삭제' })[action] || action;
onMounted(async () => {
try {
await refresh();
@@ -240,7 +227,7 @@ onMounted(async () => {
<div v-for="request in overview.requests" :key="request.ip + ':' + request.startedAt">
<code>{{ request.ip || 'IP 확인 불가' }}</code
><span
>{{ request.countryCode ? countryNames.of(request.countryCode) : '지역 확인 불가' }} ·
>{{ request.countryCode ? formatCountry(request.countryCode) : '지역 확인 불가' }} ·
{{ date(request.startedAt) }}</span
><button
class="text-button"
@@ -254,8 +241,10 @@ onMounted(async () => {
<p v-if="notice" class="admin-notice" role="status">{{ notice }}</p>
<nav class="admin-tabs" aria-label="관리 항목">
<button :class="{ selected: view === 'files' }" @click="view = 'files'">저장 파일</button
><button :class="{ selected: view === 'rules' }" @click="view = 'rules'">
접속 · 용량 설정</button
><button :class="{ selected: view === 'rules' }" @click="view = 'rules'">용량 설정</button
><button :class="{ selected: view === 'network' }" @click="view = 'network'">
네트워크</button
><button :class="{ selected: view === 'users' }" @click="view = 'users'">사용자</button
><button :class="{ selected: view === 'audit' }" @click="view = 'audit'">관리 기록</button>
</nav>
<section v-if="view === 'files'" class="admin-files">
@@ -340,42 +329,18 @@ onMounted(async () => {
가득 차면 수신·다운로드는 진행하고 받은 항목에 저장하지 않습니다. 한도를 줄여도 기존
파일은 삭제하지 않습니다.
</p>
<label
>차단할 국가 코드<input
v-model="countries"
placeholder="예: CN, RU"
:disabled="busy"
/><small>CN 중국 · RU 러시아 · US 미국 · JP 일본 · KR 대한민국</small></label
>
<label
>차단할 IP 또는 IP 대역<textarea
v-model="ips"
rows="4"
placeholder="203.0.113.10&#10;198.51.100.0/24"
:disabled="busy"
></textarea
><small>쉼표나 줄바꿈으로 구분합니다. IPv4·IPv6·CIDR을 지원합니다.</small></label
>
<p class="admin-muted">
기본 접속 정보 기준으로 차단합니다. 지역 정보가 없는 접속은 국가 규칙으로 차단되지
않습니다. 현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.
</p>
<button class="button primary" :disabled="busy">설정 저장</button>
</form>
<section v-else class="admin-audit">
<p v-if="!events.length" class="admin-muted">관리 기록이 없습니다.</p>
<article v-for="event in events" :key="event.id">
<strong>{{ actionName(event.action) }}</strong
><span>{{ date(event.createdAt) }}</span
><small v-if="event.action === 'file-delete'">{{ event.details.name }}</small
><small v-else
>업로드 {{ event.details.uploadsPaused ? '중지' : '허용' }} · 동시
{{ event.details.maxConcurrentUploads }}개 · 국가
{{ event.details.blockedCountries?.join(', ') || '없음' }} · IP
{{ event.details.blockedIps?.length || 0 }}개</small
>
</article>
</section>
<NetworkPanel
v-else-if="view === 'network'"
:settings="overview.settings"
:suggested-ip="pendingBlockedIp"
@consumed="pendingBlockedIp = null"
@saved="networkSaved"
@activity="refresh(false)"
/>
<AdminUsers v-else-if="view === 'users'" @changed="refresh(false)" />
<AdminAudit v-else-if="view === 'audit'" />
</template>
<div v-if="confirmation" class="modal-backdrop" @click.self="!busy && closeConfirmation()">
<section

238
apps/web/src/AdminUsers.vue Normal file
View File

@@ -0,0 +1,238 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue';
import { RefreshCw, MoreHorizontal, X } from '@lucide/vue';
import { api, showError } from './client';
import { formatAdminDate as date, formatCountry } from './displayFormat';
const emit = defineEmits(['changed']);
const users = ref([]),
nextOffset = ref(null),
query = ref(''),
busy = ref(false),
notice = ref('');
const menu = ref(null),
menuStyle = ref({}),
menuElement = ref(null),
action = ref(null),
confirmation = ref(''),
dialog = ref(null);
let trigger;
const status = (user) =>
user.status === 'suspended'
? '정지'
: user.status === 'pending' || !user.verifiedAt
? '이메일 인증 전'
: '인증된 계정';
async function fetchUsers(more = false) {
const result = await api(
`/admin/users?offset=${more ? nextOffset.value : 0}&q=${encodeURIComponent(query.value.trim())}`,
);
users.value = more ? [...users.value, ...result.users] : result.users;
nextOffset.value = result.nextOffset;
}
async function act(work) {
if (busy.value) return;
busy.value = true;
notice.value = '';
try {
await work();
} catch (error) {
showError(error);
} finally {
busy.value = false;
}
}
const load = (more = false) => act(() => fetchUsers(more));
async function openMenu(user, event) {
trigger = event.currentTarget;
const rect = trigger.getBoundingClientRect();
menuStyle.value = {
top: Math.max(8, Math.min(rect.bottom + 5, innerHeight - 130)) + 'px',
left: Math.max(8, Math.min(rect.right - 230, innerWidth - 238)) + 'px',
};
menu.value = user;
await nextTick();
menuElement.value?.querySelector('button')?.focus();
}
function closeMenu() {
menu.value = null;
trigger?.focus();
}
async function choose(kind) {
const user = menu.value;
closeMenu();
action.value = { kind, user };
confirmation.value = '';
await nextTick();
dialog.value?.querySelector('button')?.focus();
}
function close() {
action.value = null;
trigger?.focus();
}
async function perform() {
await act(async () => {
const { user, kind } = action.value;
if (kind === 'delete')
await api(`/admin/users/${user.id}/delete`, 'POST', { email: confirmation.value });
else await api(`/admin/users/${user.id}/password-reset`, 'POST', {});
close();
await fetchUsers();
emit('changed');
notice.value = kind === 'delete' ? '계정을 삭제했어요.' : '비밀번호 재설정 메일을 보냈어요.';
});
}
function trap(event, element, exit) {
if (event.key === 'Escape' && !busy.value) {
event.preventDefault();
exit();
return;
}
if (event.key !== 'Tab') return;
const controls = [...element.querySelectorAll('button:not(:disabled),input:not(:disabled)')],
first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
onMounted(() => load());
</script>
<template>
<section class="admin-users">
<form class="admin-toolbar" @submit.prevent="load()">
<label class="network-search"
>이메일 검색<input v-model="query" placeholder="이메일 주소" /></label
><button class="button small" :disabled="busy"><RefreshCw :size="15" />사용자 조회</button>
</form>
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
<p class="admin-muted">
로그인 계정의 마지막 접속 정보입니다. 접속 지역은 IP 기반 추정값이며, 이전 접속 기록이 없는
계정은 확인 불가로 표시합니다.
</p>
<div class="network-table-wrap">
<table class="network-table">
<thead>
<tr>
<th>사용자</th>
<th>최근 접속</th>
<th>접속 지역 · IP</th>
<th><span class="sr-only">계정 관리</span></th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>
<strong>{{ user.email }}</strong
><small
><template v-if="user.name && user.name !== user.email">{{ user.name }} · </template
>{{ status(user) }}</small
>
</td>
<td>{{ user.lastSeenAt ? date(user.lastSeenAt) : '접속 기록 없음' }}</td>
<td>
{{ formatCountry(user.countryCode)
}}<template v-if="user.city || user.region">
· {{ user.city || user.region }}</template
><small>{{ user.ip || 'IP 확인 불가' }}</small>
</td>
<td>
<button
class="icon-button"
aria-haspopup="menu"
:aria-expanded="menu?.id === user.id"
:aria-label="`${user.email} 더보기`"
:disabled="busy"
@click="openMenu(user, $event)"
>
<MoreHorizontal :size="20" />
</button>
</td>
</tr>
<tr v-if="!users.length && !busy">
<td colspan="4" class="network-empty">표시할 사용자가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
<button v-if="nextOffset !== null" class="button small" :disabled="busy" @click="load(true)">
더 보기
</button>
<Teleport to="body">
<div v-if="menu" class="admin-user-menu-backdrop" @click.self="closeMenu">
<div
ref="menuElement"
role="menu"
class="admin-user-menu"
:style="menuStyle"
@keydown="trap($event, menuElement, closeMenu)"
>
<button role="menuitem" @click="choose('reset')">비밀번호 재설정 메일 보내기</button
><button
v-if="menu.email !== 'hello@noname.company'"
role="menuitem"
class="admin-delete"
@click="choose('delete')"
>
계정 삭제
</button>
</div>
</div>
<div v-if="action" class="modal-backdrop" @click.self="!busy && close()">
<section
ref="dialog"
class="modal admin-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="user-action-title"
@keydown="trap($event, dialog, close)"
>
<header class="modal-title">
<h2 id="user-action-title">
{{ action.kind === 'delete' ? '계정을 삭제할까요?' : '비밀번호 재설정 메일 보내기' }}
</h2>
<button
class="icon-button"
aria-label="계정 관리 확인 닫기"
:disabled="busy"
@click="close"
>
<X :size="20" />
</button>
</header>
<strong class="admin-confirm-file">{{ action.user.email }}</strong
><template v-if="action.kind === 'delete'"
><p>
계정과 보관함 접근권을 삭제하고 모든 로그인을 종료합니다. 다른 사람이 받은 파일은 기존
보관 정책에 따라 유지됩니다. 복원할 수 없습니다.
</p>
<label class="admin-delete-email"
>확인을 위해 대상 이메일 입력<input
v-model="confirmation"
type="email"
autocomplete="off"
autocapitalize="none"
:disabled="busy" /></label
></template>
<p v-else>
이 계정의 이메일로 일회용 비밀번호 재설정 링크를 보냅니다. 사용자가 링크에서 새
비밀번호를 정하며, 완료 후 기존 로그인은 종료됩니다.
</p>
<div class="admin-confirm-actions">
<button class="button" :disabled="busy" @click="close">취소</button
><button
class="button primary"
:disabled="busy || (action.kind === 'delete' && confirmation !== action.user.email)"
@click="perform"
>
{{ action.kind === 'delete' ? '계정 삭제 확인' : '메일 보내기' }}
</button>
</div>
</section>
</div>
</Teleport>
</section>
</template>

View File

@@ -0,0 +1,525 @@
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { Plus, Trash2, X, RefreshCw } from '@lucide/vue';
import { api, showError } from './client';
import { formatAdminDate as date, formatCountry as country } from './displayFormat';
const props = defineProps({ settings: { type: Object, required: true }, suggestedIp: String });
const emit = defineEmits(['saved', 'activity', 'consumed']);
const draft = reactive({}),
busy = ref(false),
notice = ref('');
const selectedBlack = ref([]),
selectedWhite = ref([]),
selectedAuto = ref([]);
const automatic = ref([]),
autoTotal = ref(0),
nextOffset = ref(null),
search = ref('');
const modal = ref(null),
kind = ref('country'),
value = ref(''),
dialog = ref(null);
const ruleName = (entry) => (entry.kind === 'country' ? country(entry.value) : entry.value);
const reasonName = (reason) =>
({
'login-failures': '로그인 실패 반복',
'rate-violations': '요청 제한 반복 초과',
'file-requests': '파일 전송 시도 반복',
'file-volume': '요청 파일 용량 초과',
})[reason] || reason;
function fill(settings) {
for (const key of [
'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 = [];
}
watch(
() => props.settings.revision,
() => fill(props.settings),
{ immediate: true },
);
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) => ({
kind: 'country',
value,
id: 'country:' + value,
})),
...draft[list === 'black' ? 'blockedIps' : 'allowedIps'].map((value) => ({
kind: 'ip',
value,
id: 'ip:' + value,
})),
];
const black = computed(() => entries('black')),
white = computed(() => entries('white'));
async function act(work) {
if (busy.value) return;
busy.value = true;
notice.value = '';
try {
await work();
} catch (error) {
showError(error);
} finally {
busy.value = false;
}
}
async function loadAutomatic(more = false) {
const offset = more ? nextOffset.value : 0;
const result = await api(
`/admin/automatic-blocks?offset=${offset}&q=${encodeURIComponent(search.value.trim())}`,
);
automatic.value = more ? [...automatic.value, ...result.items] : result.items;
autoTotal.value = result.total;
nextOffset.value = result.nextOffset;
if (!more) selectedAuto.value = [];
}
loadAutomatic().catch(showError);
async function save() {
await act(async () => {
const settings = await api('/admin/settings', 'PATCH', {
revision: props.settings.revision,
...draft,
});
fill(settings);
emit('saved', settings);
notice.value = '네트워크 설정을 저장했어요.';
});
}
let trigger;
async function open(list) {
trigger = document.activeElement;
modal.value = list;
kind.value = 'country';
value.value = '';
await nextTick();
dialog.value?.querySelector('select')?.focus();
}
watch(
() => props.suggestedIp,
async (ip) => {
if (!ip) return;
await open('black');
kind.value = 'ip';
value.value = ip;
emit('consumed');
},
{ immediate: true },
);
function close() {
modal.value = null;
trigger?.focus();
}
async function add() {
await act(async () => {
const entry = await api('/admin/network-rules/validate', 'POST', {
kind: kind.value,
value: value.value.trim(),
});
const field =
modal.value === 'black'
? entry.kind === 'country'
? 'blockedCountries'
: 'blockedIps'
: entry.kind === 'country'
? 'allowedCountries'
: 'allowedIps';
draft[field] = [...new Set([...draft[field], entry.value])];
close();
notice.value = '표에 추가했어요. 네트워크 설정 저장을 누르면 적용됩니다.';
});
}
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 = '표에서 제거했어요. 네트워크 설정 저장을 누르면 적용됩니다.';
}
async function removeAutomatic() {
await act(async () => {
const selected = [...selectedAuto.value];
for (let offset = 0; offset < selected.length; offset += 100)
await api('/admin/automatic-blocks/remove', 'POST', {
ips: selected.slice(offset, offset + 100),
});
await loadAutomatic();
emit('activity');
notice.value = '선택한 IP의 자동 차단을 해제했어요.';
});
}
function modalKey(event) {
if (event.key === 'Escape' && !busy.value) {
event.preventDefault();
close();
return;
}
if (event.key !== 'Tab') return;
const controls = [
...dialog.value.querySelectorAll(
'button:not(:disabled),input:not(:disabled),select:not(:disabled)',
),
],
first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
</script>
<template>
<section class="network-panel">
<p class="admin-muted">
목록과 감지 기준을 편집한 뒤 네트워크 설정을 저장하세요. 블랙리스트와 자동 차단이 우선
적용됩니다.
</p>
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
<form @submit.prevent="save">
<section class="network-section" aria-labelledby="automatic-title">
<header>
<h2 id="automatic-title">자동 블랙리스트</h2>
<button
type="button"
role="switch"
class="network-switch"
:aria-checked="draft.autoBlockEnabled"
aria-label="자동 블랙리스트 사용"
:disabled="busy"
@click="draft.autoBlockEnabled = !draft.autoBlockEnabled"
>
<span></span>{{ draft.autoBlockEnabled ? '사용 중' : '사용 안 함' }}
</button>
</header>
<p class="admin-muted">
확인된 원본 공인 IP를 기준으로 반복 로그인 실패·요청 제한 위반·대량 파일 전송 시도를
감지합니다. 정상적인 파일 조각 전송과 서버 혼잡 재시도는 횟수에 포함하지 않습니다.
</p>
<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
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>
<p class="admin-muted">
감지 기간 안에 로그인 실패·제한 위반이 설정 횟수에 도달하거나, 파일 전송 횟수·요청 용량을
초과하면 해당 IP를 차단합니다. 수락 전 파일 전송 시도도 계산하며 같은 요청의 재시도는 중복
계산하지 않습니다. 같은 공인 IP를 쓰는 사람은 기준을 함께 적용받습니다. 자동 차단은 설정한
시간 뒤 해제되고, 기존 관리자 로그인은 자동 차단을 해제할 수 있습니다.
</p>
<div class="network-actions">
<button
type="button"
class="button small"
:disabled="busy || !selectedAuto.length"
@click="removeAutomatic"
>
<Trash2 :size="15" />선택 차단 해제</button
><span class="admin-muted">{{ autoTotal }}개</span
><label class="network-search"
>IP 검색<input
v-model="search"
@keydown.enter.prevent="act(() => loadAutomatic())" /></label
><button
type="button"
class="icon-button"
aria-label="자동 차단 목록 새로고침"
:disabled="busy"
@click="act(() => loadAutomatic())"
>
<RefreshCw :size="17" />
</button>
</div>
<div class="network-table-wrap">
<table class="network-table">
<thead>
<tr>
<th>선택</th>
<th>IP · 국가</th>
<th>차단 사유</th>
<th>차단 / 해제 시각</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in automatic" :key="entry.ip">
<td>
<input
v-model="selectedAuto"
type="checkbox"
:value="entry.ip"
:disabled="busy"
:aria-label="`${entry.ip} 자동 차단 선택`"
/>
</td>
<td>
<code>{{ entry.ip }}</code
><small v-if="entry.countryCode">{{ country(entry.countryCode) }}</small>
</td>
<td>{{ reasonName(entry.reason) }}</td>
<td>
{{ date(entry.createdAt) }}<small>{{ date(entry.expiresAt) }} 해제</small>
</td>
</tr>
<tr v-if="!automatic.length">
<td colspan="4" class="network-empty">자동 차단된 IP가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
<button
v-if="nextOffset !== null"
type="button"
class="button small"
:disabled="busy"
@click="act(() => loadAutomatic(true))"
>
더 보기
</button>
</section>
<section class="network-section" aria-labelledby="blacklist-title">
<header>
<h2 id="blacklist-title">블랙리스트</h2>
<button
type="button"
role="switch"
class="network-switch"
:aria-checked="draft.blacklistEnabled"
aria-label="블랙리스트 사용"
:disabled="busy"
@click="draft.blacklistEnabled = !draft.blacklistEnabled"
>
<span></span>{{ draft.blacklistEnabled ? '사용 중' : '사용 안 함' }}
</button>
</header>
<p class="admin-muted">
등록한 국가·IP·IP 대역의 접속을 차단합니다. 화이트리스트와 겹쳐도 차단이 우선합니다.
</p>
<div class="network-actions">
<button type="button" class="button small" :disabled="busy" @click="open('black')">
<Plus :size="15" />블랙리스트 추가</button
><button
type="button"
class="button small"
:disabled="busy || !selectedBlack.length"
@click="remove('black')"
>
<Trash2 :size="15" />선택 항목 제거
</button>
</div>
<div class="network-table-wrap">
<table class="network-table">
<thead>
<tr>
<th>선택</th>
<th>구분</th>
<th>국가 · IP 또는 IP 대역</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in black" :key="entry.id">
<td>
<input
v-model="selectedBlack"
type="checkbox"
:value="entry.id"
:aria-label="`${ruleName(entry)} 차단 규칙 선택`"
:disabled="busy"
/>
</td>
<td>{{ entry.kind === 'country' ? '국가' : 'IP / 대역' }}</td>
<td>{{ ruleName(entry) }}</td>
</tr>
<tr v-if="!black.length">
<td colspan="3" class="network-empty">등록된 차단 규칙이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="network-section" aria-labelledby="whitelist-title">
<header>
<h2 id="whitelist-title">화이트리스트</h2>
<span class="admin-muted">{{ white.length ? '등록한 곳만 허용' : '공개' }}</span>
</header>
<p class="admin-muted">
비어 있으면 공개합니다. 항목이 있으면 등록한 국가 또는 IP·IP 대역과 일치하는 접속만
허용하고 나머지는 모두 차단합니다. 국가를 확인할 수 없는 접속도 허용 IP와 일치하지 않으면
차단됩니다.
</p>
<div class="network-actions">
<button type="button" class="button small" :disabled="busy" @click="open('white')">
<Plus :size="15" />화이트리스트 추가</button
><button
type="button"
class="button small"
:disabled="busy || !selectedWhite.length"
@click="remove('white')"
>
<Trash2 :size="15" />선택 항목 제거
</button>
</div>
<div class="network-table-wrap">
<table class="network-table">
<thead>
<tr>
<th>선택</th>
<th>구분</th>
<th>국가 · IP 또는 IP 대역</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in white" :key="entry.id">
<td>
<input
v-model="selectedWhite"
type="checkbox"
:value="entry.id"
:aria-label="`${ruleName(entry)} 허용 규칙 선택`"
:disabled="busy"
/>
</td>
<td>{{ entry.kind === 'country' ? '국가' : 'IP / 대역' }}</td>
<td>{{ ruleName(entry) }}</td>
</tr>
<tr v-if="!white.length">
<td colspan="3" class="network-empty">
등록된 허용 규칙이 없습니다. 블랙리스트에 없는 접속은 공개됩니다.
</td>
</tr>
</tbody>
</table>
</div>
<p class="admin-muted">
현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다. 자동 블랙리스트가 켜져 있으면
허용된 국가나 IP에서도 악용 감지 기준을 적용합니다.
</p>
</section>
<button class="button primary" :disabled="busy">네트워크 설정 저장</button>
</form>
<Teleport to="body"
><div v-if="modal" class="modal-backdrop" @click.self="!busy && close()">
<section
ref="dialog"
class="modal network-rule-modal"
role="dialog"
aria-modal="true"
aria-labelledby="network-rule-title"
@keydown="modalKey"
>
<header class="modal-title">
<h2 id="network-rule-title">
{{ modal === 'black' ? '블랙리스트 추가' : '화이트리스트 추가' }}
</h2>
<button class="icon-button" aria-label="규칙 추가 닫기" :disabled="busy" @click="close">
<X :size="20" />
</button>
</header>
<form class="network-rule-form" @submit.prevent="add">
<label
>등록 대상<select v-model="kind" :disabled="busy" @change="value = ''">
<option value="country">국가</option>
<option value="ip">IP 주소 / IP 대역</option>
</select></label
><label
>{{ kind === 'country' ? '국가 코드' : 'IP 주소 또는 CIDR'
}}<input
v-model="value"
:maxlength="kind === 'country' ? 2 : 100"
:placeholder="
kind === 'country' ? '예: JP' : '예: 203.0.113.10 또는 198.51.100.0/24'
"
required
:disabled="busy"
autocapitalize="characters"
/></label>
<p v-if="kind === 'country' && /^[A-Za-z]{2}$/.test(value)" class="admin-muted">
{{ country(value.toUpperCase()) }}
</p>
<p v-else class="admin-muted">
국가, IPv4·IPv6 주소 또는 CIDR 대역을 한 항목씩 추가합니다.
</p>
<button class="button primary" :disabled="busy || !value.trim()">추가</button>
</form>
</section>
</div></Teleport
>
</section>
</template>

View File

@@ -4,6 +4,10 @@ export function formatSize(bytes) {
if (bytes >= 1000) return `${(bytes / 1000).toFixed(1).replace(/\.0$/, '')}KB`;
return bytes === 0 ? '0KB' : '1KB 미만';
}
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
export function formatCountry(code) {
return code ? `${countryNames.of(code)}(${code})` : '확인 불가';
}
export function formatAdminDate(value) {
if (!value) return '확인 불가';
@@ -22,3 +26,54 @@ export function formatAdminDate(value) {
);
return `${parts.year}년 ${parts.month}월 ${parts.day}일 ${parts.hour}:${parts.minute}`;
}
export function auditChangeLines(change) {
const lists = {
blockedCountries: '블랙리스트',
blockedIps: '블랙리스트',
allowedCountries: '화이트리스트',
allowedIps: '화이트리스트',
};
if (lists[change.field]) {
const values = (items) =>
items
.map((item) => (change.field.endsWith('Countries') ? formatCountry(item) : item))
.join(', ');
return [
change.added?.length ? `${lists[change.field]} 추가: ${values(change.added)}` : null,
change.removed?.length ? `${lists[change.field]} 제거: ${values(change.removed)}` : null,
].filter(Boolean);
}
const names = {
uploadsPaused: '신규 업로드',
maxConcurrentUploads: '동시에 처리할 업로드 수',
totalStorageLimitMb: '전체 파일 저장 한도',
blacklistEnabled: '블랙리스트 사용',
autoBlockEnabled: '자동 블랙리스트 사용',
autoWindowMinutes: '자동 감지 기간',
autoLoginFailures: '로그인 실패 기준',
autoRateViolations: '요청 제한 위반 기준',
autoUploadRequests: '파일 전송 시도 기준',
autoUploadBytes: '요청한 파일 용량 기준',
autoBlockMinutes: '자동 차단 시간',
};
const value = (item) =>
change.field === 'uploadsPaused'
? item
? '중지'
: '허용'
: typeof item === 'boolean'
? item
? '켜짐'
: '꺼짐'
: change.field === 'totalStorageLimitMb'
? formatSize(item * 1048576)
: change.field === 'autoUploadBytes'
? formatSize(item)
: change.field.endsWith('Minutes')
? `${item}분`
: item;
return [
`${names[change.field] || change.field} ${value(change.before)} > ${value(change.after)}`,
];
}

View File

@@ -4226,3 +4226,221 @@ a.button {
.admin-activity span {
color: var(--muted);
}
.network-panel > form {
display: grid;
gap: 24px;
margin-top: 20px;
}
.network-section {
border: 1px solid var(--line);
border-radius: 16px;
padding: 22px;
display: grid;
gap: 18px;
min-width: 0;
}
.network-section > header,
.network-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.network-section > header {
justify-content: space-between;
}
.network-section h2 {
margin: 0;
font-size: 18px;
}
.network-switch {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
background: none;
border: 0;
padding: 0;
font-size: 13px;
}
.network-switch > span {
display: block;
width: 38px;
height: 22px;
padding: 3px;
border-radius: 20px;
background: var(--line);
}
.network-switch > span::after {
content: '';
display: block;
width: 16px;
height: 16px;
background: var(--muted);
border-radius: 50%;
transition: transform 160ms;
}
.network-switch[aria-checked='true'] > span {
background: var(--accent);
}
.network-switch[aria-checked='true'] > span::after {
background: var(--on-accent);
transform: translateX(16px);
}
.network-auto-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.network-auto-fields label,
.network-rule-form label {
display: grid;
gap: 8px;
font-size: 14px;
}
.network-auto-fields input,
.network-rule-form input,
.network-rule-form select,
.network-search input {
width: 100%;
min-width: 0;
font-size: 16px;
}
.network-search {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
font-size: 13px;
}
.network-search input {
max-width: 180px;
}
.network-table-wrap {
overflow-x: auto;
}
.network-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 13px;
}
.network-table th,
.network-table td {
padding: 13px 10px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
overflow-wrap: anywhere;
}
.network-table th {
color: var(--muted);
font-weight: 500;
}
.network-table input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.network-table small {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 12px;
}
.network-empty {
text-align: center;
color: var(--muted);
line-height: 1.6;
}
.network-rule-modal .modal-title {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.network-rule-form {
display: grid;
gap: 20px;
margin-top: 24px;
}
.network-panel > form > .button {
justify-self: start;
}
@media (max-width: 639px) {
.admin-tabs {
gap: 16px;
overflow-x: auto;
white-space: nowrap;
}
.network-section {
padding: 16px;
}
.network-auto-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.network-search {
margin-left: 0;
}
.network-panel > form > .button {
justify-self: stretch;
}
}
.admin-audit-panel,
.admin-users {
display: grid;
gap: 20px;
}
.audit-selection {
display: flex;
align-items: center;
gap: 10px;
}
.audit-selection input {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.audit-remove {
grid-column: 1/-1;
justify-self: end;
}
.admin-user-menu-backdrop {
position: fixed;
inset: 0;
z-index: 1100;
}
.admin-user-menu {
position: fixed;
width: 230px;
padding: 8px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
box-shadow: 0 8px 30px #0003;
}
.admin-user-menu button {
display: block;
width: 100%;
min-height: 44px;
padding: 10px;
text-align: left;
font-size: 13px;
background: none;
border: 0;
border-radius: 7px;
}
.admin-user-menu button:hover {
background: var(--soft);
}
.admin-delete-email {
display: grid;
gap: 10px;
margin-top: 20px;
font-size: 14px;
}
.admin-delete-email input {
width: 100%;
font-size: 16px;
}