Move file transfer restriction into network settings

This commit is contained in:
2026-10-15 13:51:36 +09:00
parent 328fad2e36
commit 9cda9cbe84
20 changed files with 456 additions and 88 deletions

View File

@@ -36,6 +36,7 @@ import {
cancelTransfer,
uploadProgress,
clipboardTextLimitBytes,
fileTransfersAllowed,
} from './client';
const props = defineProps({ initialView: { type: String, default: 'inbox' } });
const emit = defineEmits(['devices', 'send-item']);
@@ -62,6 +63,9 @@ const compose = ref(false),
preferencesOpen = ref(false),
editing = ref(null),
noteDraft = ref('');
watch(fileTransfersAllowed, (allowed) => {
if (!allowed && saveKind.value === 'file' && !saveTransfer.value) saveKind.value = 'clipboard';
});
const storage = ref({ usedBytes: 0, limitBytes: 0 });
const securityAction = ref('none'),
currentPassword = ref(''),
@@ -292,6 +296,8 @@ async function save(values) {
busy.value = true;
let id = saveTransfer.value?.id;
try {
if (values.files && !fileTransfersAllowed.value && !id)
throw Error('지금은 링크와 텍스트만 저장할 수 있어요.');
const result = id
? { requestId: id }
: await api(
@@ -598,6 +604,9 @@ async function resendVerification() {
</button>
</div>
</div>
<p v-if="!fileTransfersAllowed" class="file-policy-note" role="status">
새 파일 전송·저장은 제한되어 있어요. 링크와 텍스트는 계속 사용할 수 있어요.
</p>
<div class="account-tabs" role="tablist" aria-label="보관함 목록">
<button
role="tab"
@@ -662,7 +671,11 @@ async function resendVerification() {
<div class="item-footer">
<span>[보관 만료] {{ expiryDate(item.expiresAt) }} 까지</span>
<div>
<button class="button small" @click="emit('send-item', item)">
<button
v-if="!item.file || fileTransfersAllowed"
class="button small"
@click="emit('send-item', item)"
>
<Send :size="14" />기기로 보내기</button
><button v-if="item.file" class="button small" @click="act(() => download(item))">
<Download :size="14" />다운로드</button
@@ -733,10 +746,15 @@ async function resendVerification() {
<X :size="20" />
</button>
</header>
<p v-if="!fileTransfersAllowed && !saveKind" class="content-feedback" role="status">
파일 전송이 일시 제한되어 링크와 텍스트만 저장할 수 있어요.
</p>
<div v-if="!saveKind" class="save-kind-choices">
<button class="button" @click="saveKind = 'clipboard'">
<Copy :size="22" />클립보드 저장</button
><button class="button" @click="saveKind = 'file'"><FileIcon :size="22" />파일 저장</button>
><button v-if="fileTransfersAllowed" class="button" @click="saveKind = 'file'">
<FileIcon :size="22" />파일 저장
</button>
</div>
<template v-else>
<label class="vault-note-label"
@@ -747,6 +765,7 @@ async function resendVerification() {
placeholder="예: 나중에 읽을 글"
:disabled="Boolean(saveTransfer) || busy" /></label
><SendComposer
:files-allowed="fileTransfersAllowed"
target-name="임시 보관함"
target-key="vault-save"
:save-mode="true"

View File

@@ -5,8 +5,6 @@ import {
RefreshCw,
Trash2,
ArrowLeft,
Pause,
Play,
Folder,
Globe,
HardDrive,
@@ -64,19 +62,6 @@ async function reload() {
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();
});
}
function addBlockedIp(ip) {
pendingBlockedIp.value = ip;
view.value = 'network';
@@ -186,19 +171,6 @@ onMounted(async () => {
>
</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">

View File

@@ -58,6 +58,7 @@ import {
spaceInviteCodeLength,
locationSettings,
clipboardTextLimitBytes,
fileTransfersAllowed,
publishLocation,
clearLocation,
reserveLocationAcceptance,
@@ -510,7 +511,14 @@ async function moreVault() {
vaultCursor.value = result.nextCursor;
});
}
watch(fileTransfersAllowed, (allowed) => {
if (!allowed && itemToSend.value?.file) itemToSend.value = null;
});
function prepareVault(item) {
if (item.file && !fileTransfersAllowed.value) {
showError(Error('지금은 링크와 텍스트만 보낼 수 있어요.'));
return;
}
itemToSend.value = item;
selected.value = null;
sendOpen.value = false;
@@ -519,6 +527,10 @@ function prepareVault(item) {
notify('받을 기기를 선택해 주세요.');
}
async function forwardVault(item) {
if (item.file && !fileTransfersAllowed.value) {
showError(Error('지금은 링크와 텍스트만 보낼 수 있어요.'));
return;
}
if (!selected.value) {
itemToSend.value = item;
tab.value = 'devices';
@@ -680,6 +692,8 @@ async function closeReceivedText(item) {
async function submitFiles(files) {
await act(async () => {
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
if (!fileTransfersAllowed.value && currentTransfer.value?.status !== 'accepted')
throw Error('지금은 링크와 텍스트만 보낼 수 있어요.');
const target = selected.value;
forwardedItem.value = null;
itemToSend.value = null;
@@ -987,10 +1001,17 @@ async function spaceAction(space, action, memberId) {
><span class="request-file-size">{{ size(file.size) }}</span>
</li>
</ul>
<p
v-if="r.kind === 'file' && !fileTransfersAllowed"
class="content-feedback"
role="status"
>
파일 전송이 일시 제한되어 수락할 수 없어요.
</p>
<div class="incoming-actions">
<button
class="button primary small"
:disabled="busy"
:disabled="busy || (r.kind === 'file' && !fileTransfersAllowed)"
@click="receiveAction(r, 'accept')"
>
<Check :size="16" /> 수락</button
@@ -1026,6 +1047,9 @@ async function spaceAction(space, action, memberId) {
<template v-if="tab === 'devices'">
<section class="devices-stage">
<section class="nearby-section">
<p v-if="!fileTransfersAllowed" class="file-policy-note" role="status">
파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.
</p>
<div class="section-heading">
<div>
<h2>
@@ -1220,6 +1244,7 @@ async function spaceAction(space, action, memberId) {
</button>
</header>
<SendComposer
:files-allowed="fileTransfersAllowed"
:target-name="selected?.name"
:target-key="selected?.id"
:clipboard-key="selectionEpoch"

View File

@@ -97,12 +97,14 @@ async function save() {
<label
>동시에 처리할 업로드 수<input
v-model.number="draft.maxConcurrentUploads"
aria-label="동시에 처리할 업로드 수"
type="number"
min="1"
max="16"
required
:disabled="busy"
/></label>
/><small>서버 전체에서 동시에 처리할 업로드 요청 수</small></label
>
<label v-for="limit in limits" :key="limit.key"
>{{ limit.label }} (GB)<input
v-model.number="draft[limit.key]"
@@ -127,6 +129,7 @@ async function save() {
<dt>동시에 처리할 업로드 수</dt>
<dd>
<strong>{{ settings.maxConcurrentUploads }}개</strong>
<small>서버 전체에서 동시에 처리할 업로드 요청 수</small>
</dd>
</div>
<div v-for="limit in limits" :key="limit.key">

View File

@@ -2,7 +2,11 @@
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { Plus, Trash2, X, RefreshCw, Pencil } from '@lucide/vue';
import { api, showError } from './client';
import { formatAdminDate as date, formatCountry as country } from './displayFormat';
import {
formatAdminDate as date,
formatCountry as country,
formatSize as size,
} from './displayFormat';
const props = defineProps({ settings: { type: Object, required: true }, suggestedIp: String });
const emit = defineEmits(['saved', 'activity', 'consumed']);
const draft = reactive({}),
@@ -42,6 +46,7 @@ const reasonName = (reason) =>
})[reason] || reason;
function fill(settings) {
for (const key of [
'uploadsPaused',
'blacklistEnabled',
'allowedCountries',
'allowedIps',
@@ -230,6 +235,30 @@ function modalKey(event) {
</p>
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
<form @submit.prevent="save">
<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-label="파일 전송 금지"
:disabled="busy"
@click="draft.uploadsPaused = !draft.uploadsPaused"
>
<span></span>{{ draft.uploadsPaused ? '켜짐' : '꺼짐' }}
</button>
<span v-else class="admin-muted">{{
draft.uploadsPaused ? '켜짐 · 링크와 텍스트만 허용' : '꺼짐 · 파일 전송 허용'
}}</span>
</header>
<p class="admin-muted">
켜면 새 파일 전송·보관함 파일 저장을 막고 사용자 화면의 파일 선택 메뉴를 숨깁니다. 링크와
텍스트는 계속 보낼 수 있습니다. 이미 수락한 파일 전송과 기존 파일 다운로드는 유지됩니다.
</p>
</section>
<section class="network-section" aria-labelledby="automatic-title">
<header>
<h2 id="automatic-title">자동 블랙리스트</h2>
@@ -329,7 +358,7 @@ function modalKey(event) {
</div>
<div>
<dt>요청한 파일 용량</dt>
<dd>{{ uploadGb }}GB</dd>
<dd>{{ size(draft.autoUploadBytes) }}</dd>
</div>
<div>
<dt>자동 차단 시간</dt>

View File

@@ -16,6 +16,7 @@ import { classifyClipboard, clipboardCharacterCount } from '@hop/shared';
import { mayReadClipboardAutomatically } from './clipboardAccess';
const props = defineProps({
targetName: String,
filesAllowed: { type: Boolean, default: true },
textLimitBytes: { type: Number, default: 262144 },
targetKey: String,
clipboardKey: Number,
@@ -47,7 +48,16 @@ const source = ref('url'),
editor = ref(null),
compositionKind = ref(null);
let generation = 0;
const clipboardOnly = computed(() => ['url', 'clipboard'].includes(props.contentMode));
const canSelectFiles = computed(
() =>
props.filesAllowed || (props.transfer?.kind === 'file' && props.transfer.status === 'accepted'),
);
const clipboardOnly = computed(
() => !canSelectFiles.value || ['url', 'clipboard'].includes(props.contentMode),
);
const availableVaultItems = computed(() =>
(props.vaultItems || []).filter((item) => !item.file || props.filesAllowed),
);
const classified = computed(() => classifyClipboard(urlInput.value));
const editorKind = computed(() => compositionKind.value ?? classified.value.kind);
const contentBytes = computed(() => new TextEncoder().encode(urlInput.value).length);
@@ -72,8 +82,8 @@ const ready = computed(() =>
source.value === 'url'
? validClipboard.value && !reading.value && !compositionKind.value
: source.value === 'file'
? files.value.length > 0
: Boolean(selectedVault.value),
? canSelectFiles.value && files.value.length > 0
: Boolean(selectedVault.value && (!selectedVault.value.file || props.filesAllowed)),
);
async function loadClipboard(force = false, explicit = false) {
if (
@@ -127,9 +137,9 @@ watch(
selectedVault.value = null;
clipboardManaged.value = false;
feedback.value = '';
source.value = props.contentMode === 'file' ? 'file' : 'url';
source.value = props.contentMode === 'file' && canSelectFiles.value ? 'file' : 'url';
}
if (props.initialItem) {
if (props.initialItem && (!props.initialItem.file || props.filesAllowed)) {
source.value = 'vault';
selectedVault.value = props.initialItem;
} else if (current[0] !== previous?.[0] || current[1] !== previous?.[1])
@@ -164,7 +174,7 @@ watch(
() => props.vaultEnabled,
(enabled) => {
if (!enabled && source.value === 'vault') {
source.value = 'file';
source.value = props.filesAllowed ? 'file' : 'url';
selectedVault.value = null;
}
},
@@ -186,7 +196,20 @@ watch(
(owner, previous) => {
if (owner !== previous && source.value === 'vault') {
selectedVault.value = null;
source.value = 'file';
source.value = props.filesAllowed ? 'file' : 'url';
}
},
);
watch(
() => props.filesAllowed,
(allowed) => {
if (allowed || props.transfer) return;
if (source.value === 'file' || selectedVault.value?.file) {
generation++;
reading.value = false;
files.value = [];
selectedVault.value = null;
source.value = 'url';
}
},
);
@@ -208,7 +231,7 @@ function choose(list) {
feedback.value = '';
}
function pickFiles() {
if (mutable.value && fileInput.value) {
if (mutable.value && !clipboardOnly.value && fileInput.value) {
fileInput.value.value = '';
fileInput.value.click();
}
@@ -235,14 +258,17 @@ function toVault() {
emit('vault-open');
}
function selectVault(item) {
if (mutable.value) selectedVault.value = item;
if (mutable.value && (!item.file || props.filesAllowed)) selectedVault.value = item;
}
function pasted(event) {
if (!mutable.value) return;
if (event.clipboardData?.files.length) {
event.preventDefault();
choose(event.clipboardData.files);
return;
if (!clipboardOnly.value) {
choose(event.clipboardData.files);
return;
}
if (!event.clipboardData.getData('text/plain')) return;
}
if (props.contentMode === 'file' || props.transfer) return;
const value = event.clipboardData?.getData('text/plain');
@@ -454,7 +480,7 @@ function fileProgress(index) {
</div>
<template v-else
><button
v-for="item in vaultItems"
v-for="item in availableVaultItems"
:key="item.accessId"
class="vault-choice"
:class="{ chosen: selectedVault?.accessId === item.accessId }"
@@ -473,8 +499,14 @@ function fileProgress(index) {
></span
><Check v-if="selectedVault?.accessId === item.accessId" :size="16" />
</button>
<p v-if="!vaultItems?.length" class="content-feedback">
{{ busy ? '보관함을 불러오고 있어요.' : '보관함이 비어 있어요.' }}
<p v-if="!availableVaultItems.length" class="content-feedback">
{{
busy
? '보관함을 불러오고 있어요.'
: !filesAllowed
? '보낼 링크나 텍스트가 없어요.'
: '보관함이 비어 있어요.'
}}
</p>
<button
v-if="vaultCursor"
@@ -533,7 +565,12 @@ function fileProgress(index) {
<button class="text-button room-leave" @click="emit('cancel')">전송 취소</button>
</div>
<div v-else-if="!contentMode && !transfer" class="content-switches">
<button v-if="source !== 'file'" class="text-button" :disabled="!mutable" @click="pickFiles">
<button
v-if="!clipboardOnly && source !== 'file'"
class="text-button"
:disabled="!mutable"
@click="pickFiles"
>
<FileIcon :size="14" />대신 파일 선택하기</button
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">
<Link :size="14" />대신 클립보드에서 가져오기</button
@@ -546,6 +583,13 @@ function fileProgress(index) {
<Inbox :size="14" />보관함에서 가져오기
</button>
</div>
<p v-if="!filesAllowed" class="content-feedback" role="status">
{{
transfer?.kind === 'file' && transfer.status === 'accepted'
? '새 파일 전송은 제한되어 있어요. 이미 수락한 전송은 계속할 수 있어요.'
: '파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.'
}}
</p>
<p class="consent-note">
{{ saveMode ? '저장한 자료는 최대 7일 후 삭제됩니다.' : '상대가 수락한 후에만 전달됩니다.' }}
</p>

View File

@@ -32,6 +32,19 @@ let pendingTimer;
let deviceInfo = {};
export const nextCursor = ref(null);
export const clipboardTextLimitBytes = ref(262144);
export const fileTransfersAllowed = ref(false);
let policyRevision = -1;
function applyServicePolicy(policy) {
if (
!policy ||
!Number.isSafeInteger(policy.revision) ||
policy.revision < policyRevision ||
typeof policy.fileTransfersAllowed !== 'boolean'
)
return;
policyRevision = policy.revision;
fileTransfersAllowed.value = policy.fileTransfersAllowed;
}
export const spaceInviteCodeLength = ref(4);
export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 });
export async function publishLocation(position) {
@@ -129,6 +142,7 @@ export async function api(path, method = 'GET', body, key, signal) {
export async function bootstrap() {
const init = async () => {
const s = await api('/guest/session', 'POST', {});
applyServicePolicy(s.servicePolicy);
device.value = s.device;
csrfToken.value = s.csrfToken;
sessionExpiresAt.value = s.expiresAt;
@@ -166,6 +180,7 @@ function connect() {
account.value = null;
window.dispatchEvent(new Event('hop-account-revoked'));
});
socket.on('service:policy', applyServicePolicy);
socket.on('service:blocked', () => showError(Error('이 접속은 서비스 이용이 제한되었습니다.')));
socket.on('connect_error', (err) => {
connected.value = false;
@@ -176,6 +191,7 @@ function connect() {
if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return;
reauthorizing = (async () => {
const session = await api('/guest/session', 'POST', {});
applyServicePolicy(session.servicePolicy);
device.value = session.device;
csrfToken.value = session.csrfToken;
sessionExpiresAt.value = session.expiresAt;
@@ -229,6 +245,7 @@ export function showError(err) {
}
export const refresh = createRefreshQueue(async () => {
const snapshot = await api('/device/snapshot');
applyServicePolicy(snapshot.servicePolicy);
device.value = snapshot.device;
peers.value = snapshot.devices;
clipboardTextLimitBytes.value = snapshot.clipboardTextLimitBytes ?? 262144;

View File

@@ -1,7 +1,11 @@
const sizeNumber = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
export function formatSize(bytes) {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)}GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)}MB`;
if (bytes >= 1000) return `${(bytes / 1000).toFixed(1).replace(/\.0$/, '')}KB`;
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`;
return bytes === 0 ? '0KB' : '1KB 미만';
}
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
@@ -45,7 +49,7 @@ export function auditChangeLines(change) {
].filter(Boolean);
}
const names = {
uploadsPaused: '신규 업로드',
uploadsPaused: '파일 전송 금지',
maxConcurrentUploads: '동시에 처리할 업로드 수',
totalStorageLimitMb: '전체 파일 저장 한도',
accountBytes: '로그인 보관함 계정당 한도',
@@ -65,8 +69,8 @@ export function auditChangeLines(change) {
const value = (item) =>
change.field === 'uploadsPaused'
? item
? '중지'
: '허용'
? '켜짐'
: '꺼짐'
: typeof item === 'boolean'
? item
? '켜짐'

View File

@@ -3916,7 +3916,6 @@ a.button {
}
.admin-heading,
.admin-toolbar,
.admin-upload-control,
.admin-file-heading,
.admin-file footer {
display: flex;
@@ -3975,20 +3974,6 @@ a.button {
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;
@@ -4088,6 +4073,7 @@ a.button {
.admin-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: start;
gap: 18px;
}
.admin-limit-details {
@@ -4099,6 +4085,7 @@ a.button {
}
.admin-limit-details > div {
display: grid;
align-content: start;
gap: 3px;
}
.admin-limit-details dt {
@@ -4181,11 +4168,6 @@ a.button {
.admin-stats strong {
font-size: 22px;
}
.admin-upload-control {
align-items: stretch;
flex-direction: column;
padding: 16px;
}
.admin-settings-grid {
grid-template-columns: 1fr;
}
@@ -4484,3 +4466,10 @@ a.button {
grid-template-columns: 1fr;
}
}
.file-policy-note {
color: var(--muted);
font-size: 14px;
line-height: 1.6;
margin: 0 0 20px;
}