diff --git a/README.md b/README.md index f20020c..a460de3 100644 --- a/README.md +++ b/README.md @@ -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`처럼 천 단위 쉼표를 사용합니다. diff --git a/apps/server/src/accounts.ts b/apps/server/src/accounts.ts index 47a84be..3cdfc9a 100644 --- a/apps/server/src/accounts.ts +++ b/apps/server/src/accounts.ts @@ -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'); diff --git a/apps/server/src/administration.js b/apps/server/src/administration.js index f4ae5c8..e447b7c 100644 --- a/apps/server/src/administration.js +++ b/apps/server/src/administration.js @@ -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 }; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 23b2baf..2cc74a3 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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, diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts index 3efef8a..e539496 100644 --- a/apps/server/src/service.ts +++ b/apps/server/src/service.ts @@ -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 && diff --git a/apps/server/test/admin-capacity.test.js b/apps/server/test/admin-capacity.test.js index e336558..3af199e 100644 --- a/apps/server/test/admin-capacity.test.js +++ b/apps/server/test/admin-capacity.test.js @@ -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(); + } + }, +); diff --git a/apps/server/test/display-format.test.js b/apps/server/test/display-format.test.js index de2eec9..10a2f65 100644 --- a/apps/server/test/display-format.test.js +++ b/apps/server/test/display-format.test.js @@ -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); }); diff --git a/apps/web/src/AccountPanel.vue b/apps/web/src/AccountPanel.vue index e459c16..181e5b9 100644 --- a/apps/web/src/AccountPanel.vue +++ b/apps/web/src/AccountPanel.vue @@ -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() { +
+ 새 파일 전송·저장은 제한되어 있어요. 링크와 텍스트는 계속 사용할 수 있어요. +