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

@@ -148,3 +148,5 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 **
관리 기록은 `동시에 처리할 업로드 수 5 > 10`, `블랙리스트 추가: 중국(CN)`처럼 변경 사항만 보여 줍니다. 변경이 없는 저장은 기록하지 않습니다. 이전 기록은 비교 가능한 전 정보가 있을 때만 차이를 표시합니다. 읽음 기능은 두지 않고 개별·선택·전체 삭제만 제공하며 기록을 지워도 설정·파일·자동 차단 목록은 바뀌지 않습니다.
관리자 용량·네트워크 설정은 기본적으로 값만 표시하며 **수정 → 저장/취소**로 변경합니다. 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간도 관리자 화면에서 설정하며 저장한 값은 DB에 보존됩니다. IP 대역은 CIDR 외에 `203.0.113.*`처럼 뒤쪽 전체 자리의 IPv4 와일드카드도 입력할 수 있고, `203.0.113.0/24`로 정규화합니다. 저장 파일 목록은 업로더 ID와 계정 이메일(비로그인은 ‘비로그인’)을 표시합니다.
네트워크의 **파일 전송 금지**를 켜면 새 파일 전송/저장 메뉴를 숨기고 링크·텍스트만 허용합니다. 이미 수락한 파일 업로드와 기존 다운로드는 유지하며, 변경 사항은 접속 중인 사용자에게도 반영합니다. 용량 표시는 `7,687.0GB`처럼 천 단위 쉼표를 사용합니다.

View File

@@ -213,6 +213,7 @@ export class Accounts {
this.policy(row.senderUserId!, userId) === 'block'
)
fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.');
if (row.kind === 'file') this.service.admin.assertNewUploads();
if (row.kind === 'file')
this.service.storage.reserve({ id: row.senderUserId!, kind: 'user' }, id);
if (allowSender) this.rule(userId, row.senderUserId!, 'allow');

View File

@@ -224,12 +224,15 @@ export class Administration {
if (this.blocked(req))
fail(403, 'ACCESS_BLOCKED', '이 접속은 서비스 이용이 제한되어 있습니다.');
}
publicPolicy() {
return { fileTransfersAllowed: !this.state.uploadsPaused, revision: this.state.revision };
}
assertNewUploads() {
if (this.state.uploadsPaused)
throw new ApiError(
503,
'UPLOADS_PAUSED',
'새 파일 업로드를 잠시 중지했습니다. 나중에 다시 시도해 주세요.',
'파일 전송이 일시 제한되어 있어요. 링크와 텍스트는 보낼 수 있어요.',
);
}
admit(req, reply, actor) {
@@ -322,7 +325,9 @@ export class Administration {
)
)
this.automatic.resetCounters();
const filePolicyChanged = next.uploadsPaused !== this.state.uploadsPaused;
this.state = next;
if (filePolicyChanged) this.service.io?.emit('service:policy', this.publicPolicy());
this.compile();
this.disconnectBlocked();
return { ...this.state };

View File

@@ -118,6 +118,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
}
});
io.on('connection', (socket) => {
socket.emit('service:policy', service.admin.publicPolicy());
presence.connect((socket.data.guest as Guest).id, socket, socket.data.ip);
if (socket.data.accountId) {
socket.join(`account:${socket.data.accountId}`);
@@ -478,6 +479,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
device: service.device(guest.id),
csrfToken: service.csrf(token!),
expiresAt: guest.expiresAt,
servicePolicy: service.admin.publicPolicy(),
};
});
app.get('/api/device/snapshot', async (req) => {
@@ -491,6 +493,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
spaceInviteCodeLength: cfg.spaceInviteCodeLength,
clipboardTextLimitBytes: cfg.maxTextBytes,
servicePolicy: service.admin.publicPolicy(),
locationDiscovery: {
enabled: cfg.locationDiscoveryEnabled,
radiusMeters: cfg.locationRadiusMeters,

View File

@@ -588,7 +588,7 @@ export class Service {
storageUserId?: string,
) {
this.cleanup();
if (input.kind === 'file' && !storedSource) this.admin.assertNewUploads();
if (input.kind === 'file') this.admin.assertNewUploads();
const target = this.deviceGuest(input.targetDeviceId);
if (
target &&
@@ -696,6 +696,7 @@ export class Service {
if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 수락할 수 있습니다.');
if (row.status !== 'pending' || row.expiresAt <= Date.now())
fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.');
if (row.kind === 'file') this.admin.assertNewUploads();
const permission = this.locationAcceptances.get(id);
const reserved =
this.cfg.locationDiscoveryEnabled &&

View File

@@ -274,7 +274,7 @@ test('resumable offsets and completion honor the accepted manifest after reducin
'upload-offset': String(offset),
});
assert.equal((await put(0, bytes.subarray(0, CHUNK_BYTES))).statusCode, 200);
await patch(f, admin, { perFileBytes: 1, accountBytes: 1 });
await patch(f, admin, { perFileBytes: 1, accountBytes: 1, uploadsPaused: true });
assert.equal(
(await put(CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode,
200,
@@ -283,7 +283,7 @@ test('resumable offsets and completion honor the accepted manifest after reducin
const completed = await f.call(user, `/uploads/resumable/${id}/complete`, 'POST', {});
assert.equal(completed.statusCode, 200, completed.body);
assert.equal((await f.call(user, '/account/storage')).json().usedBytes, bytes.length);
assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT');
assert.equal((await own(f, user)).json().code, 'UPLOADS_PAUSED');
} finally {
await f.close();
}
@@ -364,3 +364,119 @@ test('offline transfer reset removes only transfer/guest data and keeps accounts
await rm(folder, { recursive: true, force: true });
}
});
test(
'file-only ban publishes a minimal live policy, blocks new uploads, stored-file forwarding and pending acceptance while allowing clipboard and accepted uploads',
{ timeout: 20000 },
async () => {
const f = await fixture();
try {
const admin = await signed(f, 'hello@noname.company'),
a = await f.create(),
b = await f.create(),
c = await f.create(),
d = await f.create();
await f.space(a, b);
await f.space(a, admin);
await f.space(c, d);
const original = await own(f, admin);
assert.equal(
(await upload(f, admin, original.json().requestId, Buffer.from('12345678'))).statusCode,
200,
);
const stored = (await f.call(admin, '/vault')).json().items[0];
const pending = await request(f, a, b),
accepted = await accept(f, d, await request(f, c, d));
assert.equal(pending.statusCode, 200, pending.body);
const event = new Promise((resolve) => a.socket.once('service:policy', resolve));
const settings = await patch(f, admin, { uploadsPaused: true });
assert.deepEqual(await event, { fileTransfersAllowed: false, revision: settings.revision });
const snapshot = (await f.call(a, '/device/snapshot')).json();
assert.deepEqual(snapshot.servicePolicy, {
fileTransfersAllowed: false,
revision: settings.revision,
});
assert.equal('blockedIps' in snapshot.servicePolicy, false);
assert.equal((await request(f, a, b)).json().code, 'UPLOADS_PAUSED');
assert.equal((await own(f, admin)).json().code, 'UPLOADS_PAUSED');
assert.equal(
(
await f.call(
b,
`/transfer-requests/${pending.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).json().code,
'UPLOADS_PAUSED',
);
assert.equal(
(
await f.call(
admin,
`/vault/${stored.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
)
).json().code,
'UPLOADS_PAUSED',
);
assert.deepEqual(
(await f.call(admin, `/access/${stored.accessId}/download`)).rawPayload,
Buffer.from('12345678'),
);
assert.equal((await upload(f, c, accepted, Buffer.from('12345678'))).statusCode, 200);
await f.call(
a,
`/transfer-requests/${pending.json().requestId}/cancel`,
'POST',
{},
randomUUID(),
);
await f.complete(a, b, 'https://example.test/allowed');
for (const body of [
{ kind: 'url', urls: ['https://example.test/clipboard'] },
{ kind: 'text', text: '한글 日本語 😀\n 공백 유지' },
]) {
const saved = await f.call(admin, '/vault', 'POST', body, randomUUID());
assert.equal(saved.statusCode, 200, saved.body);
const item = (await f.call(admin, '/vault'))
.json()
.items.find((item) => item.kind === body.kind);
const forwarded = await f.call(
admin,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
assert.equal(forwarded.statusCode, 200, forwarded.body);
const received = await f.call(
b,
`/transfer-requests/${forwarded.json().requestId}/accept`,
'POST',
{},
randomUUID(),
);
assert.equal(received.statusCode, 200, received.body);
}
const reconnected = new Promise((resolve) => a.socket.once('service:policy', resolve));
a.socket.disconnect();
a.socket.connect();
assert.deepEqual(await reconnected, {
fileTransfersAllowed: false,
revision: settings.revision,
});
await patch(f, admin, { uploadsPaused: false });
assert.equal((await request(f, a, b)).statusCode, 200);
assert.equal(
(await f.call(a, '/device/snapshot')).json().servicePolicy.fileTransfersAllowed,
true,
);
} finally {
await f.close();
}
},
);

View File

@@ -13,6 +13,8 @@ test('shared display sizes use decimal KB below 1MB and switch only at MB/GB bou
[1100000, '1.1MB'],
[1000000000, '1.0GB'],
[1700000000, '1.7GB'],
[7687e9, '7,687.0GB'],
[1234567e9, '1,234,567.0GB'],
])
assert.equal(formatSize(bytes), expected);
});

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;
}

View File

@@ -483,7 +483,7 @@ sed -i -e 's/^GUEST_REQUESTS_PER_MINUTE=5$/GUEST_REQUESTS_PER_MINUTE=30/' -e 's/
**용량 설정 → 수정**에서 동시 업로드 1~16개, 전체 파일 저장 한도, 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간을 변경합니다. 평소에는 값만 표시하고 수정 중에만 입력창과 저장/취소 버튼을 표시합니다. 개인 한도는 모든 계정·기기에 같은 기준으로 적용하며 계정별 예외 설정은 아닙니다. 화면은 GB(1,000 단위)를 사용하며, 서버 설정 범위인 1~1024GiB는 화면에서 약 1.1~1099.5GB에 해당합니다. 기본은 4개·20GiB이고, 업로드 요청은 같은 계정/기기 주체당 동시에 최대 2개만 처리합니다. 새 용량 항목의 초기값은 기존 `.env`를 따릅니다. 파일/개인/기기 한도와 NAS 최소 여유는 관리자 설정을 저장한 뒤 재시작 없이 적용하고 전송 횟수 제한은 유지합니다. 이미 수락해 공간을 예약한 업로드는 한도를 낮춰도 계속되며, 이후 새 공간 예약에는 변경된 한도를 적용합니다. 업로드 본문을 읽기 전에 서버 전체 동시 요청 제한을 적용하고, 들어오는 HTTP 요청에 5분 타임아웃을 적용합니다. 큰 파일은 기존 8MiB 조각 전송을 사용합니다.
**신규 업로드 중지**는 새 파일 전송·임시 저장을 거절합니다. 중지 전에 수락한 파일, 이미 저장된 원본의 재전송·다운로드, 링크·텍스트 전송은 계속됩니다. 한도를 낮춰도 기존 자료를 자동 삭제하지 않습니다.
**네트워크 → 수정 → 파일 전송 금지**를 켜고 저장하면 새 파일 전송·보관함 파일 저장·기존 보관함 파일의 새 기기 전송을 거절합니다. 아직 수락하지 않은 파일 요청도 제한 중에는 수락할 수 없습니다. 켜기 전에 이미 수락한 파일 업로드와 기존 파일 다운로드, 링크·텍스트 전송/저장은 계속됩니다. 기존 자료를 삭제하지 않습니다. 설정은 기존 `uploadsPaused` 값을 재사용하며 기본값은 꺼짐입니다. 사용자 화면은 파일 선택/저장/보관함 파일 보내기 메뉴를 숨기고 링크·텍스트만 가능한 안내를 표시합니다. 파일 드롭·붙여넣기도 같은 제한을 적용합니다. 현재 접속자에게 Socket.IO로 공개 정책만 전달하고, 최초 접속/재연결/스냅샷에서도 최신 정책을 확인합니다. 늦게 도착한 과거 정책이 새 상태를 덮어쓰지 않도록 리비전을 비교합니다.
**네트워크 → 수정 → 블랙리스트/화이트리스트 → 추가**에서 국가 코드 또는 IPv4/IPv6·CIDR을 한 항목씩 등록합니다. IPv4는 `203.0.113.*`, `203.0.*.*`처럼 뒤쪽 전체 자리의 와일드카드도 지원하며 각각 `/24`, `/16` CIDR로 저장합니다. `203.*.113.*`나 `113*` 같은 불연속·부분 자리 패턴은 지원하지 않습니다. IPv6는 CIDR을 사용합니다. 모달로 추가한 표를 확인하고 네트워크 설정을 저장하며, 제거할 때는 표의 항목을 선택합니다. 비어 있으면 차단하지 않습니다. 저장 즉시 일치하는 새 HTTP·Socket.IO 접속을 거절하고, 진행 중 업로드와 기기 연결도 종료합니다. `CLIENT_IP_SOURCE`와 `TRUSTED_PROXY_CIDRS`로 검증한 원본 IP를 사용하며 원본 IP가 없으면 직접 연결 주소를 기준으로 합니다. 국가 정보는 검증된 Cloudflare 전달 값만 사용합니다. 국가를 모르면 국가 블랙리스트만으로 차단하지 않지만, 화이트리스트가 있으면 허용 IP와 일치해야 접속할 수 있습니다. 현재 관리자의 IP/국가·인접 연결을 차단하는 규칙은 저장할 수 없습니다. 차단 해제는 목록에서 해당 규칙을 지우고 다시 저장합니다. 잘못된 규칙으로 관리자가 차단되면 기존 NAS 접근 권한으로 DB의 `admin_settings`를 복구해야 하므로 실제 규칙을 확인한 뒤 적용합니다.
@@ -545,3 +545,7 @@ sudo docker compose -f docker-compose.yaml up -d --build
```
스크립트는 `hop` 컨테이너가 중지됐는지 확인하고 해당 컨테이너의 DB 볼륨·실제 이미지 ID·실행 UID를 그대로 사용합니다. 외부 네트워크나 앱 프로세스 없이 전송·보관함·예약·기기 세션·재시도 기록만 트랜잭션으로 비우고 외래 키를 검사합니다. 계정·비밀번호·이메일 인증 상태·계정 로그인 세션·계정 설정·접속 기록과 관리자 설정/기록은 보존합니다. 파일은 자동 삭제하지 않습니다. 계정별 보관함의 파일·링크·텍스트와 기기 연결 공간은 모두 초기화됩니다. NAS에서 직접 실행하기 전에는 데이터가 바뀌지 않습니다.
## 관리자 용량 표시·파일 전송 금지
관리자 상단의 신규 업로드 허용/중지 영역은 제거하고 파일 전송 금지를 네트워크 수정 화면으로 옮겼습니다. 용량 단위는 KB/MB/GB를 유지하고 `7,687.0GB`처럼 천 단위 쉼표를 표시합니다. 동시에 처리할 업로드 수에도 설명을 표시하고 읽기/수정 화면의 수치와 입력창을 같은 높이로 정렬합니다. 이번 변경은 기존 관리 설정 필드를 재사용하므로 DB 초기화나 Compose 변경 없이 최신 소스로 재빌드·배포하면 됩니다.

View File

@@ -219,3 +219,10 @@
- 새 파일 폴더는 `originals/업로더UUID/`이다. 관리 목록은 기기명을 생략하고 UUID + 계정 이메일 또는 비로그인을 표시한다. 계정 이메일은 관리자 API에서 DB의 업로더 계정으로 조회하며 일반 전송 API에 추가하지 않는다. 기존 prefix 폴더를 강제로 옮기는 새 이관 기능은 추가하지 않았다.
- 출시 전 자료를 비워도 된다는 운영자 요청에 맞춰 별도 오프라인 초기화 도구를 준비했다. 서비스 실행 중 실행을 막는 NAS 래퍼를 사용하며 전송/보관함/기기 세션만 DB 트랜잭션으로 비운다. 격리 DB와 실제 Docker DB 볼륨에서 계정·비밀번호·인증/로그인 세션·사용자 정보·관리 설정을 그대로 보존하고 외래 키/SQLite integrity를 확인했다. uploads의 내용은 운영자가 서비스 중지 상태에서 직접 삭제한다. NAS 운영 DB나 파일은 여기서 초기화하지 않았다.
- 전체 API 144개, 관리자 PC/모바일 브라우저, 타입 검사와 빌드를 통과했다. 운영 이미지의 격리 Docker에서 설정/파일/텍스트/인증 재시작 보존, 8MiB 조각 재개, UUID 경로·NAS UID·호스트 수동 삭제 대응, 오프라인 기록 초기화 후 계정/관리 설정 보존과 재시작을 통과했다. 테스트 자원은 종료 후 제거했고 운영 Compose 설정은 변경하지 않았다.
## 2026-10-15 파일 전송 금지·용량 쉼표·한도 정렬
- 용량 표시를 KB/MB/GB로 유지하면서 `7,687.0GB`처럼 천 단위 쉼표를 붙였다. 공통 포맷 경계 테스트와 NAS 크기의 디스크 값을 사용하는 실제 관리자 카드에서 확인했다. 업로드 수에 설명을 추가하고 읽기/수정 화면을 위쪽 정렬하여 첫 행 입력창의 실제 top/height 일치를 검증했다.
- 관리자 상단 업로드 허용/중지 영역을 제거하고 네트워크 수정 화면에 ‘파일 전송 금지’ 토글을 배치했다. 기존 `uploadsPaused` 설정과 리비전/명시적 저장을 재사용한다. 새 파일 업로드·보관함 파일 저장·저장된 파일의 새 재전송과 미수락 파일 요청 수락을 서버에서 거절하며 링크/텍스트 전송·저장은 유지한다.
- 초기 세션·기기 스냅샷·소켓 연결/재연결에서 파일 전송 가능 여부와 정책 리비전만 공개한다. 변경 시 기존 접속자에게 전달하고 오래된 스냅샷이 최신 상태를 덮어쓰지 못하게 비교한다. 사용자 화면의 파일 선택/보관함 저장/파일 재전송 메뉴를 숨기고 링크·텍스트 안내를 표시한다. 열린 파일 초안도 전환하고 파일 드롭/이미지 붙여넣기는 차단하며 작성 중인 텍스트는 유지한다. 이미 수락한 파일은 이어 보내기/취소를 제공하고 기존 다운로드도 유지한다.
- API 145개, 관리자 PC/390px 모바일, 기존 클립보드·계정 보관함·101MiB 파일 분할 전송 브라우저 회귀, 타입 검사·빌드·포맷 검사를 통과했다. 브라우저에서 금지 중 한글/일본어/이모지/줄바꿈 텍스트를 실제 전송하고 금지 해제 후 파일 메뉴 복구·작성 내용 보존을 확인했다. 격리 운영 Docker에서 금지 설정의 재시작 보존, 수락된 8MiB 조각 이어 보내기와 기존 다운로드, 파일/텍스트/인증·한도 유지, NAS UID 바인드 저장소·수동 파일 삭제 대응도 통과했다. NAS 운영 서비스/자료와 Compose 설정은 변경하지 않았다.

View File

@@ -129,6 +129,11 @@ try {
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
),
);
if (verifiedAccount && process.env.HOP_CHECK_FILE_POLICY === '1') {
node(
`const base='http://127.0.0.1:${port}',cookie=${JSON.stringify(verifiedAccount.cookie)};const headers={Origin:'https://hop.test',Cookie:cookie,'Content-Type':'application/json'};const session=await(await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'})).json();const summary=await(await fetch(base+'/api/admin/summary',{headers})).json();const paused=await fetch(base+'/api/admin/settings',{method:'PATCH',headers:{...headers,'X-CSRF-Token':session.csrfToken},body:JSON.stringify({revision:summary.settings.revision,uploadsPaused:true})});if(!paused.ok)throw Error(await paused.text());console.log('file policy paused');`,
);
}
docker('restart', name);
wait();
const result = JSON.parse(
@@ -165,6 +170,16 @@ try {
assert.deepEqual(settings.blockedCountries, ['CN']);
assert.deepEqual(settings.allowedIps, ['127.0.0.1']);
assert.equal(settings.autoBlockEnabled, true);
if (process.env.HOP_CHECK_FILE_POLICY === '1') {
assert.equal(settings.uploadsPaused, true);
const policy = JSON.parse(
node(
`const base='http://127.0.0.1:${port}',cookie=${JSON.stringify(fileState.cookie)};const response=await fetch(base+'/api/device/snapshot',{headers:{Cookie:cookie}});if(!response.ok)throw Error(await response.text());console.log(JSON.stringify((await response.json()).servicePolicy));`,
),
);
assert.equal(policy.fileTransfersAllowed, false);
assert.equal(policy.revision, settings.revision);
}
const adminState = JSON.parse(
node(
`const headers={Cookie:${JSON.stringify(verifiedAccount.cookie)}};const base='http://127.0.0.1:${port}';const blocks=await(await fetch(base+'/api/admin/automatic-blocks',{headers})).json();const users=await(await fetch(base+'/api/admin/users',{headers})).json();const audit=await(await fetch(base+'/api/admin/audit',{headers})).json();console.log(JSON.stringify({blocks,users,audit}));`,
@@ -297,7 +312,7 @@ try {
assert.deepEqual(emptied, { files: 0, requests: 0, guests: 0, integrity: 'ok' });
}
console.log(
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${process.env.HOP_CHECK_RESET === '1' ? ' Offline transfer reset preserves accounts and admin settings.' : ''}${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token, admin role and persisted admin controls after restart.' : ''}`,
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${process.env.HOP_CHECK_FILE_POLICY === '1' ? ' File ban survives restart while previously accepted chunks and existing downloads complete.' : ''}${process.env.HOP_CHECK_RESET === '1' ? ' Offline transfer reset preserves accounts and admin settings.' : ''}${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token, admin role and persisted admin controls after restart.' : ''}`,
);
} catch (error) {
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));

View File

@@ -26,6 +26,9 @@ const runtime = await createApp(
},
{ mailer: async () => {} },
);
// A NAS-sized disk verifies comma grouping in the rendered admin card.
const realSummary = runtime.service.admin.summary.bind(runtime.service.admin);
runtime.service.admin.summary = () => ({ ...realSummary(), diskFreeBytes: 7687e9 });
let browser;
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
@@ -90,7 +93,7 @@ try {
const response = await fetch('/api' + path, {
method,
headers: {
'Content-Type': 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
'X-CSRF-Token': csrf,
'Idempotency-Key': crypto.randomUUID(),
},
@@ -150,6 +153,10 @@ try {
await admin.getByRole('button', { name: '더보기', exact: true }).click();
await admin.getByRole('menuitem', { name: '관리자', exact: true }).click();
await admin.locator('.admin-stats').waitFor();
assert.equal(
await admin.locator('.admin-stats article').nth(1).locator('strong').innerText(),
'7,687.0GB',
);
await admin.locator('.admin-file').filter({ hasText: 'sample.mp4' }).waitFor();
assert.match(await admin.locator('.admin-file').innerText(), /중국\(CN\) · Beijing/);
assert.match(await admin.locator('.admin-file').innerText(), /1\.1\.1\.1/);
@@ -161,16 +168,119 @@ try {
await admin.locator('.admin-file footer small').innerText(),
/^\[보관 만료\] \d{4}년 \d{1,2}월 \d{1,2}일 \d{2}:\d{2} 까지$/,
);
await admin.getByRole('button', { name: '신규 업로드 중지', exact: true }).click();
await admin.getByText('신규 업로드 중지됨', { exact: true }).waitFor();
assert.equal(await admin.locator('.admin-upload-control').count(), 0);
// Prepare a stored account file and an unsent file draft before pausing.
const normalSessionBefore = await session(normal);
const policyFile = await call(normal, normalSessionBefore.csrfToken, '/vault', {
kind: 'file',
files: [{ name: 'policy-file.txt', size: 4, type: 'text/plain' }],
});
await uploadFile(
normal,
normalSessionBefore.csrfToken,
policyFile.requestId,
'policy-file.txt',
'text/plain',
);
await normal.getByRole('button', { name: '더보기', exact: true }).click();
await normal.getByRole('tab', { name: '임시 저장', exact: true }).click();
const policyCard = normal.locator('.compact-item').filter({ hasText: 'policy-file.txt' });
await policyCard.waitFor();
await normal.getByRole('button', { name: '보관함에 담기', exact: true }).click();
await normal.getByRole('button', { name: '파일 저장', exact: true }).click();
await sender
.locator('.peer-card')
.filter({ hasText: '#' + b.device.code })
.click();
const composer = sender.locator('.send-dialog');
await composer
.locator('input[type=file]')
.setInputFiles({ name: 'draft.txt', mimeType: 'text/plain', buffer: Buffer.from('draft') });
await composer.locator('.selected-files').waitFor();
await admin.getByRole('button', { name: '네트워크', exact: true }).click();
async function banFiles(value) {
await admin.getByRole('button', { name: '수정', exact: true }).click();
const toggle = admin.getByRole('switch', { name: '파일 전송 금지', exact: true });
if ((await toggle.getAttribute('aria-checked')) !== String(value)) await toggle.click();
await admin.getByRole('button', { name: '네트워크 설정 저장', exact: true }).click();
await admin.getByRole('button', { name: '수정', exact: true }).waitFor();
}
await banFiles(true);
assert.equal(runtime.service.admin.state.uploadsPaused, true);
await admin.getByRole('button', { name: '업로드 재개', exact: true }).click();
await admin.getByText('신규 업로드 허용 중', { exact: true }).waitFor();
await composer
.getByText('파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.', { exact: true })
.waitFor();
assert.equal(await composer.locator('input[type=file]').count(), 0);
assert.equal(
await composer.getByRole('button', { name: '대신 파일 선택하기', exact: true }).count(),
0,
);
assert.equal(await composer.locator('.selected-files').count(), 0);
await normal.getByLabel('보낼 텍스트', { exact: true }).waitFor();
assert.equal(await normal.locator('input[type=file]').count(), 0);
assert.equal(
await policyCard.getByRole('button', { name: '기기로 보내기', exact: true }).count(),
0,
);
assert.equal(await policyCard.getByRole('button', { name: '다운로드', exact: true }).count(), 1);
await normal.getByRole('button', { name: '다른 종류 선택', exact: true }).click();
assert.equal(await normal.getByRole('button', { name: '파일 저장', exact: true }).count(), 0);
await normal.getByRole('button', { name: '보관함 저장 닫기', exact: true }).click();
const pausedGuest = await page(390);
await pausedGuest.locator('.file-policy-note').waitFor();
await pausedGuest.context().close();
const message = '파일 제한 중에도 한글 日本語 😀\n 텍스트를 전송합니다.';
await composer.getByLabel('보낼 텍스트', { exact: true }).fill(message);
await composer.locator('.unified-composer').evaluate((element) => {
const data = new DataTransfer();
data.items.add(new File(['ignored'], 'blocked.txt', { type: 'text/plain' }));
element.dispatchEvent(
new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: data }),
);
element.dispatchEvent(
new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }),
);
});
assert.equal(await composer.getByLabel('보낼 텍스트', { exact: true }).inputValue(), message);
await sender.screenshot({ path: 'tests/e2e/file-policy-sender.png', fullPage: true });
await composer.getByRole('button', { name: '전송하기', exact: true }).click();
await receiver.getByRole('button', { name: '수락', exact: true }).click();
await receiver.getByRole('dialog', { name: '받은 텍스트', exact: true }).waitFor();
assert.equal(await receiver.getByLabel('받은 텍스트 내용', { exact: true }).innerText(), message);
await receiver.getByRole('button', { name: '받은 텍스트 닫기', exact: true }).click();
await composer.waitFor({ state: 'hidden' });
// Unpausing updates existing clients without a reload and preserves typed text.
await sender
.locator('.peer-card')
.filter({ hasText: '#' + b.device.code })
.click();
await composer.getByLabel('보낼 텍스트', { exact: true }).fill('작성 중인 내용은 유지');
await banFiles(false);
await composer.getByRole('button', { name: '대신 파일 선택하기', exact: true }).waitFor();
assert.equal(
await composer.getByLabel('보낼 텍스트', { exact: true }).inputValue(),
'작성 중인 내용은 유지',
);
await composer.getByRole('button', { name: '전송 화면 닫기', exact: true }).click();
const removed = (
await normal.evaluate(async () => (await (await fetch('/api/vault')).json()).items)
).find((item) => item.file?.name === 'policy-file.txt');
const adminSession = await session(admin);
await call(admin, adminSession.csrfToken, '/admin/files/' + removed.file.id, undefined, 'DELETE');
await admin.getByRole('button', { name: '용량 설정', exact: true }).click();
const capacityForm = admin.locator('.admin-settings');
assert.equal(await capacityForm.locator('input').count(), 0);
assert.match(await capacityForm.innerText(), /로그인 보관함 계정당 한도[\s\S]*2\.1GB/);
await admin.getByRole('button', { name: '수정', exact: true }).click();
const rowInputs = await admin.locator('.admin-settings-grid input').evaluateAll((inputs) =>
inputs.slice(0, 2).map((input) => ({
top: input.getBoundingClientRect().top,
height: input.getBoundingClientRect().height,
})),
);
assert.equal(rowInputs[0].top, rowInputs[1].top);
assert.equal(rowInputs[0].height, rowInputs[1].height);
await admin.screenshot({ path: 'tests/e2e/admin-capacity-edit-desktop.png', fullPage: true });
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
assert.equal(
@@ -293,7 +403,7 @@ try {
assert.doesNotMatch(ownerLabel, /user-|guest-|\[/);
await admin.screenshot({ path: 'tests/e2e/admin-file-account.png', fullPage: true });
await admin.getByRole('button', { name: '관리 기록', exact: true }).click();
await admin.getByText('원본 파일 삭제', { exact: true }).waitFor();
await admin.getByText('원본 파일 삭제', { exact: true }).first().waitFor();
const changedRow = admin
.locator('.admin-audit article')
.filter({ hasText: '동시에 처리할 업로드 수 4 > 3' });
@@ -384,7 +494,7 @@ try {
assert.equal(await mobile.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0);
assert.deepEqual(errors, []);
console.log(
'PASS: verified hello-only menu and server authorization, admin storage/location/IP overview, pause/resume, saved quotas and country/IP rules, delete confirmation/audit, mobile layout and font size, logout privilege removal.',
'PASS: verified hello-only menu and server authorization, admin storage/location/IP overview, file-only ban/live menu updates and clipboard-only transfer, comma-separated GB, aligned capacity inputs, saved quotas and country/IP rules, delete confirmation/audit, mobile layout and font size, logout privilege removal.',
);
} finally {
await browser?.close();