Make admin quotas editable and simplify uploader storage

This commit is contained in:
2026-10-15 12:51:42 +09:00
parent 4d527f74e6
commit 328fad2e36
26 changed files with 1101 additions and 205 deletions

View File

@@ -15,6 +15,7 @@ import {
import { api, showError } from './client';
import { formatSize as size, formatAdminDate as date, formatCountry } from './displayFormat';
import NetworkPanel from './NetworkPanel.vue';
import CapacityPanel from './CapacityPanel.vue';
import AdminAudit from './AdminAudit.vue';
import AdminUsers from './AdminUsers.vue';
const emit = defineEmits(['devices']);
@@ -27,9 +28,7 @@ const overview = ref(null),
notice = ref(''),
confirmation = ref(null),
dialog = ref(null);
const maxConcurrent = ref(4),
capacity = ref(20),
pendingBlockedIp = ref(null);
const pendingBlockedIp = ref(null);
function region(location) {
if (!location) return '확인 불가';
return (
@@ -41,16 +40,11 @@ function region(location) {
.join(' · ') || '확인 불가'
);
}
function form(settings) {
maxConcurrent.value = settings.maxConcurrentUploads;
capacity.value = Number(((settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1));
}
async function refresh(resetForm = true) {
async function refresh() {
const [summary, list] = await Promise.all([api('/admin/summary'), api('/admin/files')]);
overview.value = summary;
files.value = list.items;
cursor.value = list.nextCursor;
if (resetForm) form(summary.settings);
}
async function act(fn) {
if (busy.value) return;
@@ -80,7 +74,7 @@ async function pauseUploads() {
notice.value = settings.uploadsPaused
? '신규 업로드를 중지했어요. 기존 전송은 계속됩니다.'
: '신규 업로드를 다시 허용했어요.';
await refresh(false);
await refresh();
});
}
function addBlockedIp(ip) {
@@ -89,24 +83,7 @@ function addBlockedIp(ip) {
}
async function networkSaved(settings) {
overview.value.settings = settings;
await refresh(false);
}
async function save() {
await act(async () => {
const settings = await api('/admin/settings', 'PATCH', {
revision: overview.value.settings.revision,
maxConcurrentUploads: Number(maxConcurrent.value),
totalStorageLimitMb:
Number(capacity.value) ===
Number(((overview.value.settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1))
? overview.value.settings.totalStorageLimitMb
: Math.round((Number(capacity.value) * 1e9) / 1048576),
});
overview.value.settings = settings;
form(settings);
notice.value = '관리 설정을 저장했어요.';
await refresh(false);
});
await refresh();
}
async function more() {
await act(async () => {
@@ -150,7 +127,7 @@ async function remove() {
await api('/admin/files/' + file.id, 'DELETE');
closeConfirmation();
notice.value = '원본과 연결된 항목을 삭제했어요.';
await refresh(false);
await refresh();
});
}
onMounted(async () => {
@@ -254,13 +231,13 @@ onMounted(async () => {
<strong>{{ file.name }}</strong
><span>{{ size(file.size) }}</span>
</div>
<div class="admin-file-info">
<span
>[{{
file.uploader?.name ||
(file.uploader?.kind === 'user' ? '로그인 계정' : '비로그인 기기')
}}]</span
><code>{{ file.path.split('/')[0] }}</code>
<div class="admin-file-info admin-file-owner">
<code>{{
file.uploader?.id || file.path.split('/')[0].replace(/^(user|guest)-/, '')
}}</code>
<span>{{
file.uploader?.kind === 'user' ? file.uploader.email || '삭제된 계정' : '비로그인'
}}</span>
</div>
<div class="admin-file-info">
<Globe :size="15" /><span>접속 지역 · {{ region(file.lastLocation) }}</span
@@ -275,71 +252,20 @@ onMounted(async () => {
</article>
<button v-if="cursor" class="button small" :disabled="busy" @click="more">더 보기</button>
</section>
<form v-else-if="view === 'rules'" class="admin-settings" @submit.prevent="save">
<div class="admin-settings-grid">
<label
>동시에 처리할 업로드 수<input
v-model="maxConcurrent"
type="number"
min="1"
max="16"
required
:disabled="busy" /></label
><label
>전체 파일 저장 한도 (GB)<input
v-model="capacity"
type="number"
min="1.1"
max="1099.5"
step="any"
required
:disabled="busy"
/></label>
</div>
<p class="admin-muted">
전체 한도는 모든 사용자가 올린 원본 파일과 업로드 예약을 합친 용량입니다. 개인마다 이
용량을 배정하는 것은 아닙니다.
</p>
<dl class="admin-limit-details">
<div>
<dt>로그인 보관함</dt>
<dd>계정당 {{ size(overview.limits.accountBytes) }} · 임시 저장과 받은 파일 합산</dd>
</div>
<div>
<dt>기기 전송 업로드</dt>
<dd>
기기 세션당 {{ size(overview.limits.deviceBytes) }} · 로그인 여부와 관계없이 적용
</dd>
</div>
<div>
<dt>기기 전송 업로드 전체</dt>
<dd>모든 기기 세션 합계 {{ size(overview.limits.devicesTotalBytes) }}</dd>
</div>
<div>
<dt>파일 한 개</dt>
<dd>최대 {{ size(overview.limits.perFileBytes) }}</dd>
</div>
<div>
<dt>NAS 최소 여유 공간</dt>
<dd>업로드 예약을 반영해 {{ size(overview.limits.minDiskFreeBytes) }} 이상 확보</dd>
</div>
</dl>
<p class="admin-muted">
전체·기기 업로드 한도나 NAS 여유 공간이 부족하면 새 업로드를 거절합니다. 로그인 보관함이
가득 차면 수신·다운로드는 진행하고 받은 항목에 저장하지 않습니다. 한도를 줄여도 기존
파일은 삭제하지 않습니다.
</p>
<button class="button primary" :disabled="busy">설정 저장</button>
</form>
<CapacityPanel
v-else-if="view === 'rules'"
:settings="overview.settings"
@saved="networkSaved"
/>
<NetworkPanel
v-else-if="view === 'network'"
:settings="overview.settings"
:suggested-ip="pendingBlockedIp"
@consumed="pendingBlockedIp = null"
@saved="networkSaved"
@activity="refresh(false)"
@activity="refresh()"
/>
<AdminUsers v-else-if="view === 'users'" @changed="refresh(false)" />
<AdminUsers v-else-if="view === 'users'" @changed="refresh()" />
<AdminAudit v-else-if="view === 'audit'" />
</template>
<div v-if="confirmation" class="modal-backdrop" @click.self="!busy && closeConfirmation()">

View File

@@ -0,0 +1,151 @@
<script setup>
import { ref, reactive } from 'vue';
import { Pencil } from '@lucide/vue';
import { api, showError } from './client';
import { formatSize as size } from './displayFormat';
const props = defineProps({ settings: { type: Object, required: true } });
const emit = defineEmits(['saved']);
const editing = ref(false),
busy = ref(false),
notice = ref(''),
draft = reactive({});
const limits = [
{
key: 'totalStorageLimitMb',
label: '전체 파일 저장 한도',
hint: '모든 사용자의 원본 파일과 업로드 예약 합계',
max: 1099.511,
},
{
key: 'accountBytes',
label: '로그인 보관함 계정당 한도',
hint: '각 계정에 공통 적용 · 임시 저장과 받은 파일 합산',
},
{
key: 'deviceBytes',
label: '기기 세션당 업로드 한도',
hint: '로그인 여부와 관계없이 기기 전송에 적용',
},
{
key: 'devicesTotalBytes',
label: '기기 전송 업로드 전체 한도',
hint: '모든 기기 세션이 올린 파일과 예약 합계',
},
{ key: 'perFileBytes', label: '파일 한 개 한도', hint: '새로 요청하는 파일 한 개의 최대 크기' },
{
key: 'minDiskFreeBytes',
label: 'NAS 최소 여유 공간',
hint: '업로드 예약을 반영한 뒤에도 남겨둘 공간',
},
];
const bytes = (settings, key) =>
key === 'totalStorageLimitMb' ? settings[key] * 1048576 : settings[key];
const gb = (settings, key) => Number((bytes(settings, key) / 1e9).toFixed(3));
let original;
function edit() {
original = { ...props.settings };
draft.maxConcurrentUploads = original.maxConcurrentUploads;
for (const { key } of limits) draft[key] = gb(original, key);
notice.value = '';
editing.value = true;
}
function cancel() {
editing.value = false;
notice.value = '변경을 취소했어요.';
}
async function save() {
if (!editing.value || busy.value) return;
busy.value = true;
try {
const changes = {
revision: original.revision,
maxConcurrentUploads: Number(draft.maxConcurrentUploads),
};
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));
}
const settings = await api('/admin/settings', 'PATCH', changes);
editing.value = false;
emit('saved', settings);
notice.value = '관리 설정을 저장했어요.';
} catch (error) {
showError(error);
} finally {
busy.value = false;
}
}
</script>
<template>
<form class="admin-settings" @submit.prevent="save">
<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>
<div v-if="editing" class="admin-settings-grid">
<label
>동시에 처리할 업로드 수<input
v-model.number="draft.maxConcurrentUploads"
type="number"
min="1"
max="16"
required
:disabled="busy"
/></label>
<label v-for="limit in limits" :key="limit.key"
>{{ limit.label }} (GB)<input
v-model.number="draft[limit.key]"
:aria-label="limit.label + ' (GB)'"
type="number"
:min="
limit.key === 'minDiskFreeBytes'
? 0
: limit.key === 'totalStorageLimitMb'
? 1.074
: 0.001
"
:max="limit.max || 1000"
step="any"
required
:disabled="busy"
/><small>{{ limit.hint }}</small></label
>
</div>
<dl v-else class="admin-limit-details admin-limit-values">
<div>
<dt>동시에 처리할 업로드 수</dt>
<dd>
<strong>{{ settings.maxConcurrentUploads }}개</strong>
</dd>
</div>
<div v-for="limit in limits" :key="limit.key">
<dt>{{ limit.label }}</dt>
<dd>
<strong>{{ size(bytes(settings, limit.key)) }}</strong
><small>{{ limit.hint }}</small>
</dd>
</div>
</dl>
<p class="admin-muted">
전체·개인 업로드 한도나 NAS 여유 공간이 부족하면 새 업로드를 거절합니다. 로그인 보관함이 가득
차면 수신·다운로드는 진행하고 받은 항목에 저장하지 않습니다. 한도를 줄여도 기존 파일은
삭제하지 않으며 이미 예약한 업로드는 유지합니다.
</p>
<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>
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
</form>
</template>

View File

@@ -1,13 +1,26 @@
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { Plus, Trash2, X, RefreshCw } from '@lucide/vue';
import { Plus, Trash2, X, RefreshCw, Pencil } 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('');
notice = ref(''),
editing = ref(false);
let editRevision;
function edit() {
fill(props.settings);
editRevision = props.settings.revision;
editing.value = true;
notice.value = '';
}
function cancel() {
fill(props.settings);
editing.value = false;
notice.value = '변경을 취소했어요.';
}
const selectedBlack = ref([]),
selectedWhite = ref([]),
selectedAuto = ref([]);
@@ -48,7 +61,9 @@ function fill(settings) {
}
watch(
() => props.settings.revision,
() => fill(props.settings),
() => {
if (!editing.value) fill(props.settings);
},
{ immediate: true },
);
const uploadGb = computed({
@@ -95,18 +110,21 @@ async function loadAutomatic(more = false) {
}
loadAutomatic().catch(showError);
async function save() {
if (!editing.value) return;
await act(async () => {
const settings = await api('/admin/settings', 'PATCH', {
revision: props.settings.revision,
revision: editRevision,
...draft,
});
fill(settings);
editing.value = false;
emit('saved', settings);
notice.value = '네트워크 설정을 저장했어요.';
});
}
let trigger;
async function open(list) {
if (!editing.value) return;
trigger = document.activeElement;
modal.value = list;
kind.value = 'country';
@@ -118,6 +136,7 @@ watch(
() => props.suggestedIp,
async (ip) => {
if (!ip) return;
if (!editing.value) edit();
await open('black');
kind.value = 'ip';
value.value = ip;
@@ -130,6 +149,7 @@ 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,
@@ -149,6 +169,7 @@ async function add() {
});
}
function remove(list) {
if (!editing.value) return;
const selected = list === 'black' ? selectedBlack : selectedWhite;
const countries = list === 'black' ? 'blockedCountries' : 'allowedCountries',
ips = list === 'black' ? 'blockedIps' : 'allowedIps';
@@ -197,9 +218,15 @@ function modalKey(event) {
<template>
<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">
@@ -208,6 +235,7 @@ function modalKey(event) {
<h2 id="automatic-title">자동 블랙리스트</h2>
<button
type="button"
v-if="editing"
role="switch"
class="network-switch"
:aria-checked="draft.autoBlockEnabled"
@@ -217,12 +245,15 @@ function modalKey(event) {
>
<span></span>{{ draft.autoBlockEnabled ? '사용 중' : '사용 안 함' }}
</button>
<span v-else class="admin-muted">{{
draft.autoBlockEnabled ? '사용 중' : '사용 안 함'
}}</span>
</header>
<p class="admin-muted">
확인된 원본 공인 IP를 기준으로 반복 로그인 실패·요청 제한 위반·대량 파일 전송 시도를
감지합니다. 정상적인 파일 조각 전송과 서버 혼잡 재시도는 횟수에 포함하지 않습니다.
</p>
<div class="network-auto-fields">
<div v-if="editing" class="network-auto-fields">
<label
>감지 기간 (분)<input
v-model.number="draft.autoWindowMinutes"
@@ -279,6 +310,32 @@ function modalKey(event) {
:disabled="busy"
/></label>
</div>
<dl v-else class="admin-limit-details network-auto-summary">
<div>
<dt>감지 기간</dt>
<dd>{{ draft.autoWindowMinutes }}분</dd>
</div>
<div>
<dt>로그인 실패</dt>
<dd>{{ draft.autoLoginFailures }}회</dd>
</div>
<div>
<dt>요청 제한 위반</dt>
<dd>{{ draft.autoRateViolations }}회</dd>
</div>
<div>
<dt>파일 전송 시도</dt>
<dd>{{ draft.autoUploadRequests }}회</dd>
</div>
<div>
<dt>요청한 파일 용량</dt>
<dd>{{ uploadGb }}GB</dd>
</div>
<div>
<dt>자동 차단 시간</dt>
<dd>{{ draft.autoBlockMinutes }}분</dd>
</div>
</dl>
<p class="admin-muted">
감지 기간 안에 로그인 실패·제한 위반이 설정 횟수에 도달하거나, 파일 전송 횟수·요청 용량을
초과하면 해당 IP를 차단합니다. 수락 전 파일 전송 시도도 계산하며 같은 요청의 재시도는 중복
@@ -359,6 +416,7 @@ function modalKey(event) {
<h2 id="blacklist-title">블랙리스트</h2>
<button
type="button"
v-if="editing"
role="switch"
class="network-switch"
:aria-checked="draft.blacklistEnabled"
@@ -368,11 +426,14 @@ function modalKey(event) {
>
<span></span>{{ draft.blacklistEnabled ? '사용 중' : '사용 안 함' }}
</button>
<span v-else class="admin-muted">{{
draft.blacklistEnabled ? '사용 중' : '사용 안 함'
}}</span>
</header>
<p class="admin-muted">
등록한 국가·IP·IP 대역의 접속을 차단합니다. 화이트리스트와 겹쳐도 차단이 우선합니다.
</p>
<div class="network-actions">
<div v-if="editing" class="network-actions">
<button type="button" class="button small" :disabled="busy" @click="open('black')">
<Plus :size="15" />블랙리스트 추가</button
><button
@@ -388,14 +449,14 @@ function modalKey(event) {
<table class="network-table">
<thead>
<tr>
<th>선택</th>
<th v-if="editing">선택</th>
<th>구분</th>
<th>국가 · IP 또는 IP 대역</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in black" :key="entry.id">
<td>
<td v-if="editing">
<input
v-model="selectedBlack"
type="checkbox"
@@ -408,7 +469,9 @@ function modalKey(event) {
<td>{{ ruleName(entry) }}</td>
</tr>
<tr v-if="!black.length">
<td colspan="3" class="network-empty">등록된 차단 규칙이 없습니다.</td>
<td :colspan="editing ? 3 : 2" class="network-empty">
등록된 차단 규칙이 없습니다.
</td>
</tr>
</tbody>
</table>
@@ -424,7 +487,7 @@ function modalKey(event) {
허용하고 나머지는 모두 차단합니다. 국가를 확인할 수 없는 접속도 허용 IP와 일치하지 않으면
차단됩니다.
</p>
<div class="network-actions">
<div v-if="editing" class="network-actions">
<button type="button" class="button small" :disabled="busy" @click="open('white')">
<Plus :size="15" />화이트리스트 추가</button
><button
@@ -440,14 +503,14 @@ function modalKey(event) {
<table class="network-table">
<thead>
<tr>
<th>선택</th>
<th v-if="editing">선택</th>
<th>구분</th>
<th>국가 · IP 또는 IP 대역</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in white" :key="entry.id">
<td>
<td v-if="editing">
<input
v-model="selectedWhite"
type="checkbox"
@@ -460,7 +523,7 @@ function modalKey(event) {
<td>{{ ruleName(entry) }}</td>
</tr>
<tr v-if="!white.length">
<td colspan="3" class="network-empty">
<td :colspan="editing ? 3 : 2" class="network-empty">
등록된 허용 규칙이 없습니다. 블랙리스트에 없는 접속은 공개됩니다.
</td>
</tr>
@@ -472,7 +535,10 @@ function modalKey(event) {
허용된 국가나 IP에서도 악용 감지 기준을 적용합니다.
</p>
</section>
<button class="button primary" :disabled="busy">네트워크 설정 저장</button>
<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>
<Teleport to="body"
><div v-if="modal" class="modal-backdrop" @click.self="!busy && close()">
@@ -499,12 +565,12 @@ function modalKey(event) {
<option value="ip">IP 주소 / IP 대역</option>
</select></label
><label
>{{ kind === 'country' ? '국가 코드' : 'IP 주소 또는 CIDR'
>{{ kind === 'country' ? '국가 코드' : 'IP 주소 또는 IP 대역'
}}<input
v-model="value"
:maxlength="kind === 'country' ? 2 : 100"
:placeholder="
kind === 'country' ? '예: JP' : '예: 203.0.113.10 또는 198.51.100.0/24'
kind === 'country' ? '예: JP' : '예: 203.0.113.* 또는 198.51.100.0/24'
"
required
:disabled="busy"
@@ -514,7 +580,9 @@ function modalKey(event) {
{{ country(value.toUpperCase()) }}
</p>
<p v-else class="admin-muted">
국가, IPv4·IPv6 주소 또는 CIDR 대역을 한 항목씩 추가합니다.
국가, IP 주소, CIDR 또는 IPv4 와일드카드를 한 항목씩 추가합니다. 203.0.113.*는
203.0.113.0/24로 저장됩니다. *는 뒤쪽부터 전체 자리 단위로 사용하세요. IPv6 대역은
CIDR로 입력하세요.
</p>
<button class="button primary" :disabled="busy || !value.trim()">추가</button>
</form>

View File

@@ -48,6 +48,11 @@ export function auditChangeLines(change) {
uploadsPaused: '신규 업로드',
maxConcurrentUploads: '동시에 처리할 업로드 수',
totalStorageLimitMb: '전체 파일 저장 한도',
accountBytes: '로그인 보관함 계정당 한도',
deviceBytes: '기기 세션당 업로드 한도',
devicesTotalBytes: '기기 전송 업로드 전체 한도',
perFileBytes: '파일 한 개 한도',
minDiskFreeBytes: 'NAS 최소 여유 공간',
blacklistEnabled: '블랙리스트 사용',
autoBlockEnabled: '자동 블랙리스트 사용',
autoWindowMinutes: '자동 감지 기간',
@@ -68,7 +73,7 @@ export function auditChangeLines(change) {
: '꺼짐'
: change.field === 'totalStorageLimitMb'
? formatSize(item * 1048576)
: change.field === 'autoUploadBytes'
: change.field.endsWith('Bytes')
? formatSize(item)
: change.field.endsWith('Minutes')
? `${item}분`

View File

@@ -4444,3 +4444,43 @@ a.button {
width: 100%;
font-size: 16px;
}
.admin-edit-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.admin-edit-heading h2 {
margin: 0;
font-size: 19px;
}
.admin-edit-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.admin-limit-values {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px;
}
.admin-limit-values dd strong {
display: block;
color: var(--ink);
font-size: 20px;
font-weight: 550;
}
.admin-limit-values dd small {
display: block;
margin-top: 4px;
}
.network-auto-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
@media (max-width: 600px) {
.admin-limit-values,
.network-auto-summary {
grid-template-columns: 1fr;
}
}