Add verified administrator controls and upload abuse limits

This commit is contained in:
2026-10-14 20:37:55 +09:00
parent c3573b605c
commit 3786baf60c
23 changed files with 1941 additions and 22 deletions

393
apps/web/src/AdminPanel.vue Normal file
View File

@@ -0,0 +1,393 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue';
import {
ShieldCheck,
RefreshCw,
Trash2,
ArrowLeft,
Pause,
Play,
Folder,
Globe,
HardDrive,
X,
} from '@lucide/vue';
import { api, showError } from './client';
const emit = defineEmits(['devices']);
const overview = ref(null),
files = ref([]),
cursor = ref(null),
events = ref([]),
view = ref('files'),
busy = ref(false),
loading = ref(true),
notice = ref(''),
confirmation = ref(null),
dialog = ref(null);
const maxConcurrent = ref(4),
capacity = ref(20),
countries = ref(''),
ips = ref('');
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
const size = (bytes) =>
bytes >= 1073741824
? `${(bytes / 1073741824).toFixed(1)} GiB`
: bytes >= 1048576
? `${(bytes / 1048576).toFixed(1)} MiB`
: `${Math.max(0, Math.round(bytes / 1024))} KiB`;
const date = (value) =>
value
? new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value))
: '확인 불가';
function region(location) {
if (!location) return '확인 불가';
return (
[
location.countryCode ? countryNames.of(location.countryCode) : null,
location.city || location.region,
]
.filter(Boolean)
.join(' · ') || '확인 불가'
);
}
function form(settings) {
maxConcurrent.value = settings.maxConcurrentUploads;
capacity.value = settings.totalStorageLimitMb / 1024;
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'),
]);
overview.value = summary;
files.value = list.items;
cursor.value = list.nextCursor;
events.value = audit.events;
if (resetForm) form(summary.settings);
}
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 reload() {
await act(async () => {
await refresh();
notice.value = '최신 관리 정보를 불러왔어요.';
});
}
async function pauseUploads() {
await act(async () => {
const settings = await api('/admin/settings', 'PATCH', {
revision: overview.value.settings.revision,
uploadsPaused: !overview.value.settings.uploadsPaused,
});
overview.value.settings = settings;
notice.value = settings.uploadsPaused
? '신규 업로드를 중지했어요. 기존 전송은 계속됩니다.'
: '신규 업로드를 다시 허용했어요.';
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를 입력했어요. 설정 저장을 누르면 차단됩니다.';
}
async function save() {
await act(async () => {
const settings = await api('/admin/settings', 'PATCH', {
revision: overview.value.settings.revision,
maxConcurrentUploads: Number(maxConcurrent.value),
totalStorageLimitMb: Math.round(Number(capacity.value) * 1024),
blockedCountries: split(countries.value),
blockedIps: split(ips.value),
});
overview.value.settings = settings;
form(settings);
notice.value = '관리 설정을 저장했어요.';
await refresh(false);
});
}
async function more() {
await act(async () => {
const result = await api('/admin/files?cursor=' + encodeURIComponent(cursor.value));
files.value.push(...result.items);
cursor.value = result.nextCursor;
});
}
let trigger;
async function confirm(file) {
trigger = document.activeElement;
confirmation.value = file;
await nextTick();
dialog.value?.querySelector('button')?.focus();
}
function closeConfirmation() {
confirmation.value = null;
trigger?.focus();
}
function dialogKey(event) {
if (event.key === 'Escape' && !busy.value) {
event.preventDefault();
closeConfirmation();
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();
}
}
async function remove() {
const file = confirmation.value;
await act(async () => {
await api('/admin/files/' + file.id, 'DELETE');
closeConfirmation();
notice.value = '원본과 연결된 항목을 삭제했어요.';
await refresh(false);
});
}
const actionName = (action) =>
({ 'settings-change': '관리 설정 변경', 'file-delete': '원본 파일 삭제' })[action] || action;
onMounted(async () => {
try {
await refresh();
} catch (error) {
showError(error);
} finally {
loading.value = false;
}
});
</script>
<template>
<section class="admin-panel">
<header class="admin-heading">
<div>
<p class="admin-eyebrow"><ShieldCheck :size="17" />관리자</p>
<h1>서비스 관리</h1>
</div>
<div class="admin-toolbar">
<button class="button small" :disabled="busy || loading" @click="reload">
<RefreshCw :size="16" />새로고침</button
><button class="text-button" @click="emit('devices')">
<ArrowLeft :size="16" />메인으로
</button>
</div>
</header>
<p v-if="loading" role="status" class="admin-muted">관리 정보를 불러오고 있어요.</p>
<template v-if="overview">
<div class="admin-stats">
<article>
<Folder :size="21" /><span>저장 파일</span><strong>{{ overview.storedFiles }}개</strong
><small
>{{ size(overview.storedBytes) }} /
{{ size(overview.settings.totalStorageLimitMb * 1048576) }}</small
>
</article>
<article>
<HardDrive :size="21" /><span>디스크 여유</span
><strong>{{ size(overview.diskFreeBytes) }}</strong
><small>업로드 예약 {{ size(overview.reservedBytes) }}</small>
</article>
<article>
<RefreshCw :size="21" /><span>업로드 처리</span
><strong
>{{ overview.activeRequests }} / {{ overview.settings.maxConcurrentUploads }}</strong
><small>파일 저장 작업 {{ overview.activeFileWrites }}개</small>
</article>
<article>
<ShieldCheck :size="21" /><span>활성 계정</span
><strong>{{ overview.activeAccounts }}명</strong
><small
>차단 국가 {{ overview.settings.blockedCountries.length }} · IP 규칙
{{ overview.settings.blockedIps.length }}</small
>
</article>
</div>
<div class="admin-upload-control">
<div>
<strong>{{
overview.settings.uploadsPaused ? '신규 업로드 중지됨' : '신규 업로드 허용 중'
}}</strong>
<p>이미 시작한 전송과 기존 파일 다운로드는 유지됩니다.</p>
</div>
<button class="button" :disabled="busy" @click="pauseUploads">
<Play v-if="overview.settings.uploadsPaused" :size="16" /><Pause v-else :size="16" />{{
overview.settings.uploadsPaused ? '업로드 재개' : '신규 업로드 중지'
}}
</button>
</div>
<section v-if="overview.requests.length" class="admin-activity">
<strong>진행 중인 업로드 요청</strong>
<div v-for="request in overview.requests" :key="request.ip + ':' + request.startedAt">
<code>{{ request.ip || 'IP 확인 불가' }}</code
><span
>{{ request.countryCode ? countryNames.of(request.countryCode) : '지역 확인 불가' }} ·
{{ date(request.startedAt) }}</span
><button
class="text-button"
:disabled="busy || !request.ip"
@click="addBlockedIp(request.ip)"
>
차단 규칙에 추가
</button>
</div>
</section>
<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 === 'audit' }" @click="view = 'audit'">관리 기록</button>
</nav>
<section v-if="view === 'files'" class="admin-files">
<p v-if="!files.length" class="admin-muted">저장된 파일이 없습니다.</p>
<article v-for="file in files" :key="file.id" class="admin-file">
<div class="admin-file-heading">
<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>
<div class="admin-file-info">
<Globe :size="15" /><span>최근 접속 지역 · {{ region(file.lastLocation) }}</span
><code>{{ file.lastLocation?.ip || 'IP 확인 불가' }}</code>
</div>
<footer>
<small>보관 만료 · {{ date(file.expiresAt) }}까지</small
><button class="text-button admin-delete" :disabled="busy" @click="confirm(file)">
<Trash2 :size="16" />원본 삭제
</button>
</footer>
</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
>전체 파일 저장 한도 (GiB)<input
v-model="capacity"
type="number"
min="1"
max="1024"
step="any"
required
:disabled="busy"
/></label>
</div>
<p class="admin-muted">
기기·개인별 한도와 디스크 최소 여유 공간도 함께 적용합니다. 한도를 줄여도 기존 파일은
삭제하지 않습니다.
</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>
</template>
<div v-if="confirmation" class="modal-backdrop" @click.self="!busy && closeConfirmation()">
<section
ref="dialog"
class="modal admin-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="admin-confirm-title"
aria-describedby="admin-confirm-description"
@keydown="dialogKey"
>
<header class="modal-title">
<h2 id="admin-confirm-title">원본 파일을 삭제할까요?</h2>
<button
class="icon-button"
:disabled="busy"
aria-label="삭제 확인 닫기"
@click="closeConfirmation"
>
<X :size="20" />
</button>
</header>
<strong class="admin-confirm-file">{{ confirmation.name }}</strong>
<p id="admin-confirm-description">
원본과 모든 사용자의 연결된 항목이 삭제됩니다. 복원할 수 없습니다.
</p>
<div class="admin-confirm-actions">
<button class="button" :disabled="busy" @click="closeConfirmation">취소</button
><button class="button primary" :disabled="busy" @click="remove">원본 삭제 확인</button>
</div>
</section>
</div>
</section>
</template>

View File

@@ -40,6 +40,7 @@ import QRCode from 'qrcode';
import { version as appVersion } from '../package.json';
import { DEVICE_NAME_MAX_LENGTH } from '@hop/shared';
import AccountPanel from './AccountPanel.vue';
import AdminPanel from './AdminPanel.vue';
import SendComposer from './SendComposer.vue';
import InstallButton from './InstallButton.vue';
import LogoMark from './LogoMark.vue';
@@ -550,6 +551,12 @@ watch(
},
);
let timer;
watch(
() => account.value?.administrator,
(allowed) => {
if (!allowed && tab.value === 'admin') tab.value = 'devices';
},
);
onMounted(async () => {
applyTheme();
media.addEventListener('change', applyTheme);
@@ -816,6 +823,16 @@ async function spaceAction(space, action, memberId) {
<button role="menuitem" @click="openAbout">
<Info :size="18" /><span>서비스 정보</span>
</button>
<button
v-if="account?.administrator"
role="menuitem"
@click="
tab = 'admin';
closeHeaderMore();
"
>
<ShieldCheck :size="18" /><span>관리자</span>
</button>
</div>
</div>
</div>
@@ -1166,6 +1183,10 @@ async function spaceAction(space, action, memberId) {
</div>
</section>
</template>
<AdminPanel
v-else-if="tab === 'admin' && account?.administrator"
@devices="tab = 'devices'"
/>
<template v-else
><AccountPanel initial-view="inbox" @devices="tab = 'devices'" @send-item="prepareVault"
/></template>

View File

@@ -166,8 +166,13 @@ function connect() {
account.value = null;
window.dispatchEvent(new Event('hop-account-revoked'));
});
socket.on('service:blocked', () => showError(Error('이 접속은 서비스 이용이 제한되었습니다.')));
socket.on('connect_error', (err) => {
connected.value = false;
if (err.message === 'ACCESS_BLOCKED') {
showError(Error('이 접속은 서비스 이용이 제한되었습니다.'));
return;
}
if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return;
reauthorizing = (async () => {
const session = await api('/guest/session', 'POST', {});

View File

@@ -3908,3 +3908,303 @@ a.button {
flex-shrink: 0;
margin: 0;
}
.admin-panel {
width: 100%;
min-width: 0;
padding-top: 32px;
}
.admin-heading,
.admin-toolbar,
.admin-upload-control,
.admin-file-heading,
.admin-file footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.admin-heading {
margin-bottom: 28px;
flex-wrap: wrap;
}
.admin-heading h1 {
margin: 6px 0 0;
font-size: 28px;
line-height: 1.3;
}
.admin-eyebrow {
display: flex;
align-items: center;
gap: 7px;
margin: 0;
color: var(--muted);
font-size: 13px;
}
.admin-toolbar {
justify-content: flex-end;
flex-wrap: wrap;
}
.admin-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.admin-stats article {
display: flex;
flex-direction: column;
gap: 9px;
padding: 20px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--surface);
min-width: 0;
}
.admin-stats svg,
.admin-stats span,
.admin-stats small,
.admin-muted {
color: var(--muted);
}
.admin-stats strong {
font-size: 25px;
line-height: 1.3;
}
.admin-stats small {
font-size: 12px;
line-height: 1.5;
}
.admin-upload-control {
border: 1px solid var(--line);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
}
.admin-upload-control p {
color: var(--muted);
font-size: 13px;
margin: 7px 0 0;
}
.admin-upload-control button {
flex-shrink: 0;
}
.admin-notice {
font-size: 14px;
margin: 16px 0;
}
.admin-tabs {
display: flex;
gap: 24px;
border-bottom: 1px solid var(--line);
margin: 24px 0;
}
.admin-tabs button {
background: none;
border: 0;
border-bottom: 2px solid transparent;
color: var(--muted);
font: inherit;
padding: 12px 0;
cursor: pointer;
}
.admin-tabs button.selected {
color: var(--ink);
border-bottom-color: var(--ink);
}
.admin-files {
display: grid;
gap: 14px;
}
.admin-file {
min-width: 0;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--surface);
padding: 22px;
}
.admin-file-heading {
align-items: flex-start;
margin-bottom: 14px;
}
.admin-file-heading strong {
overflow-wrap: anywhere;
min-width: 0;
}
.admin-file-heading > span {
color: var(--muted);
font-size: 13px;
white-space: nowrap;
}
.admin-file-info {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
margin-top: 9px;
}
.admin-file-info code {
font-size: 12px;
overflow-wrap: anywhere;
max-width: 100%;
}
.admin-file footer {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.admin-file footer small {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.admin-delete {
flex-shrink: 0;
}
.admin-settings {
display: grid;
gap: 22px;
max-width: 840px;
}
.admin-settings label {
display: grid;
gap: 10px;
font-size: 15px;
font-weight: 500;
}
.admin-settings input,
.admin-settings textarea {
width: 100%;
font-size: 16px;
min-width: 0;
}
.admin-settings small {
color: var(--muted);
font-weight: 400;
line-height: 1.5;
}
.admin-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.admin-muted {
font-size: 14px;
line-height: 1.6;
margin: 0;
}
.admin-settings > .button {
justify-self: start;
}
.admin-audit {
display: grid;
gap: 12px;
}
.admin-audit article {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 9px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px;
}
.admin-audit article span,
.admin-audit small {
color: var(--muted);
font-size: 13px;
}
.admin-audit small {
grid-column: 1/-1;
overflow-wrap: anywhere;
line-height: 1.6;
}
.admin-confirm .modal-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.admin-confirm-file {
display: block;
margin: 22px 0 12px;
overflow-wrap: anywhere;
}
.admin-confirm p {
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.admin-confirm-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 24px;
}
@media (max-width: 639px) {
.admin-panel {
padding-top: 16px;
}
.admin-heading {
gap: 16px;
margin-bottom: 20px;
}
.admin-heading h1 {
font-size: 24px;
}
.admin-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.admin-stats article {
padding: 15px;
}
.admin-stats strong {
font-size: 22px;
}
.admin-upload-control {
align-items: stretch;
flex-direction: column;
padding: 16px;
}
.admin-settings-grid {
grid-template-columns: 1fr;
}
.admin-tabs {
gap: 18px;
}
.admin-tabs button {
font-size: 14px;
}
.admin-file {
padding: 17px;
}
.admin-file footer {
align-items: flex-start;
}
.admin-settings > .button {
justify-self: stretch;
}
}
.admin-activity {
display: grid;
gap: 12px;
margin: 20px 0;
padding: 18px;
border: 1px solid var(--line);
border-radius: 14px;
}
.admin-activity > div {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
font-size: 13px;
}
.admin-activity code {
overflow-wrap: anywhere;
}
.admin-activity span {
color: var(--muted);
}