From 3852c87e2ea84daa3ea4ac178108fbb60c4b6fe2 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 14 Oct 2026 12:22:07 +0900 Subject: [PATCH] Clarify receive requests and add retry countdowns and device unblock controls --- .env.example | 6 +- README.md | 2 + apps/server/src/app.ts | 23 ++- apps/server/src/config.ts | 6 +- apps/server/src/security.ts | 36 +++- apps/server/src/service.ts | 50 ++++- apps/server/test/transfer-controls.test.js | 121 ++++++++++++ apps/web/src/App.vue | 155 ++++++++++++---- apps/web/src/SendComposer.vue | 18 +- apps/web/src/client.js | 28 ++- apps/web/src/style.css | 117 ++++++++++++ deploy.md | 18 ++ docs/VERIFICATION.md | 7 + package.json | 3 +- tests/e2e/layout.mjs | 4 +- tests/e2e/receive-controls.mjs | 205 +++++++++++++++++++++ 16 files changed, 739 insertions(+), 60 deletions(-) create mode 100644 apps/server/test/transfer-controls.test.js create mode 100644 tests/e2e/receive-controls.mjs diff --git a/.env.example b/.env.example index 596aaaf..f7e46ac 100644 --- a/.env.example +++ b/.env.example @@ -31,9 +31,9 @@ MAX_URL_PAYLOAD_SIZE_KB=1024 MAX_TEXT_PAYLOAD_SIZE_KB=256 MAX_ITEMS_PER_TRANSFER=20 DEFAULT_URL_RETENTION_DAYS=7 -GUEST_REQUESTS_PER_MINUTE=5 -IP_REQUESTS_PER_MINUTE=20 -PAIR_REQUESTS_PER_MINUTE=2 +GUEST_REQUESTS_PER_MINUTE=30 +IP_REQUESTS_PER_MINUTE=120 +PAIR_REQUESTS_PER_MINUTE=10 REJECT_COOLDOWN_SECONDS=300 # Resend 발송 설정과 실제 인증 메일 수신을 확인한 뒤 공개 가입을 엽니다. REGISTRATION_MODE=closed diff --git a/README.md b/README.md index 2b76d1d..605d066 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,5 @@ NAS의 파일 원본은 `/volume1/docker/projects/apps/hop/uploads/originals`에 로고 원본은 `apps/web/public/logo.svg`, 아이콘 원본은 `apps/web/public/icon.svg`입니다. 헤더·서비스 정보 로고는 SVG 경로를 사용하므로 기기 기본 폰트에 영향을 받지 않습니다. 흰 글자는 테마에 맞춰 변경하고 청록색 점은 유지합니다. 아이콘 원본을 교체한 뒤 `node scripts/generate-icons.mjs`를 실행하면 192px·512px·Android maskable PNG가 다시 생성됩니다. 기기 이름은 서버 DB에 저장되며 동일 브라우저의 기기 세션 쿠키가 유효한 동안 유지됩니다. 쿠키를 지우거나 다른 브라우저로 접속하면 새 기기로 인식됩니다. + +전송 제한의 기본값은 상대 10회·기기 세션 30회·같은 IP 전체 120회/분입니다. 거절 후 5분 대기는 유지하며 전송 버튼에 재시도까지 남은 초를 표시합니다. 기존 NAS `.env` 한도 변경은 [deploy.md](deploy.md)의 전송 요청 제한 안내를 따릅니다. 수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하며 차단은 확인 후 실행합니다. 푸터의 내 기기 설정에서 차단한 기기를 관리·해제할 수 있습니다. `npm run test:e2e:receive`로 새 수신·제한·차단 흐름을 검증합니다. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 0cc50a4..d7c4f4b 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -281,7 +281,23 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) code = 'INVALID_REQUEST'; message = '요청 형식이나 크기를 확인해 주세요.'; } - reply.status(status).send({ code, message, requestId: req.id }); + const retryAfterSeconds = + err instanceof ApiError && err.retryAt + ? Math.max(1, Math.ceil((err.retryAt - Date.now()) / 1000)) + : undefined; + if (retryAfterSeconds) reply.header('Retry-After', retryAfterSeconds); + reply.status(status).send({ + code, + message, + requestId: req.id, + ...(retryAfterSeconds + ? { + retryAfterSeconds, + retryAt: (err as ApiError).retryAt, + limitScope: (err as ApiError).limitScope, + } + : {}), + }); }); const setCookie = (reply: any, token: string, expiresAt: number) => reply.setCookie('hop_guest', token, { @@ -354,6 +370,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) service.patchDevice(guest, devicePatch.parse(req.body)); return { device: service.device(guest.id) }; }); + app.get('/api/device/blocks', async (req) => ({ blocks: service.listBlocks(auth(req)) })); + app.delete('/api/device/blocks/:id', async (req) => { + service.unblock(auth(req), id(req)); + return { ok: true }; + }); app.post('/api/device/reset', async (req, reply) => { const guest = auth(req); service.reset(guest); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 215dd72..8950a06 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -106,9 +106,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { maxTextBytes: num('MAX_TEXT_PAYLOAD_SIZE_KB', 256) * 1024, maxItems: num('MAX_ITEMS_PER_TRANSFER', 20), urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)), - sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5), - ipRate: num('IP_REQUESTS_PER_MINUTE', 20), - pairRate: num('PAIR_REQUESTS_PER_MINUTE', 2), + sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 30), + ipRate: num('IP_REQUESTS_PER_MINUTE', 120), + pairRate: num('PAIR_REQUESTS_PER_MINUTE', 10), rejectCooldownMs: num('REJECT_COOLDOWN_SECONDS', 300) * 1000, uploadDir: env.UPLOAD_DIR ?? fileURLToPath(new URL('../../../.data/uploads', import.meta.url)), maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576, diff --git a/apps/server/src/security.ts b/apps/server/src/security.ts index 468975b..afb706c 100644 --- a/apps/server/src/security.ts +++ b/apps/server/src/security.ts @@ -6,6 +6,8 @@ export class ApiError extends Error { public status: number, public code: string, message: string, + public retryAt?: number, + public limitScope?: 'pair' | 'sender' | 'ip', ) { super(message); } @@ -77,13 +79,37 @@ export function publicIp(ip: string | null): boolean { export class Limiter { private windows = new Map(); hit(key: string, max: number, windowMs = 60000) { + this.hitMany([{ key, max }], windowMs); + } + hitMany(limits: { key: string; max: number }[], windowMs = 60000) { const now = Date.now(); - let w = this.windows.get(key); - if (!w || w.until <= now) { - w = { count: 0, until: now + windowMs }; - this.windows.set(key, w); + const windows = limits.map(({ key, max }) => { + const previous = this.windows.get(key); + const window = + previous && previous.until > now ? previous : { count: 0, until: now + windowMs }; + return { key, max, window }; + }); + const exhausted = windows + .filter(({ max, window }) => window.count >= max) + .sort((a, b) => b.window.until - a.window.until)[0]; + if (exhausted) { + const scope = exhausted.key.startsWith('request-pair:') + ? 'pair' + : exhausted.key.startsWith('request-ip:') + ? 'ip' + : 'sender'; + throw new ApiError( + 429, + 'RATE_LIMIT', + '요청이 많아요. 잠시 후 다시 시도해 주세요.', + exhausted.window.until, + scope, + ); + } + for (const { key, window } of windows) { + window.count++; + this.windows.set(key, window); } - if (++w.count > max) fail(429, 'RATE_LIMIT', '요청이 많습니다. 잠시 후 다시 시도해 주세요.'); } prune() { const now = Date.now(); diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts index 84b9b50..2bfdae2 100644 --- a/apps/server/src/service.ts +++ b/apps/server/src/service.ts @@ -14,7 +14,7 @@ import type { DB } from './db/index.js'; import * as s from './db/schema.js'; import type { Config } from './config.js'; import type { Presence } from './presence.js'; -import { fail, hash, hmac, randomToken, Limiter } from './security.js'; +import { fail, hash, hmac, randomToken, Limiter, ApiError } from './security.js'; import { type Device, type Item, type Peer, type Space, type TransferRequest } from '@hop/shared'; export type Guest = typeof s.guests.$inferSelect; export type RequestRow = typeof s.requests.$inferSelect; @@ -599,9 +599,6 @@ export class Service { input.files?.some((f) => f.size > this.cfg.maxFileBytes) ) fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.'); - this.limiter.hit(`request-ip:${ip}`, this.cfg.ipRate); - this.limiter.hit(`request-guest:${g.id}`, this.cfg.sessionRate); - this.limiter.hit(`request-pair:${g.id}:${target!.guestId}`, this.cfg.pairRate); const rejected = this.db .select() .from(s.requests) @@ -613,9 +610,16 @@ export class Service { gt(s.requests.rejectedAt, Date.now() - this.cfg.rejectCooldownMs), ), ) + .orderBy(desc(s.requests.rejectedAt)) .get(); if (rejected) - fail(429, 'REJECTION_COOLDOWN', '상대가 거절했습니다. 잠시 후 다시 시도해 주세요.'); + throw new ApiError( + 429, + 'REJECTION_COOLDOWN', + '상대가 거절했어요. 잠시 후 다시 시도해 주세요.', + rejected.rejectedAt! + this.cfg.rejectCooldownMs, + 'pair', + ); const active = this.db .select() .from(s.requests) @@ -629,6 +633,11 @@ export class Service { ) .get(); if (active) fail(409, 'TRANSFER_ACTIVE', '진행 중인 전송을 먼저 완료하거나 취소해 주세요.'); + this.limiter.hitMany([ + { key: `request-ip:${ip}`, max: this.cfg.ipRate }, + { key: `request-guest:${g.id}`, max: this.cfg.sessionRate }, + { key: `request-pair:${g.id}:${target!.guestId}`, max: this.cfg.pairRate }, + ]); const now = Date.now(), id = randomUUID(); this.db @@ -752,6 +761,37 @@ export class Service { this.notifyRequests(); this.refresh(); } + listBlocks(g: Guest) { + return this.db + .select({ deviceId: s.devices.id, name: s.devices.name, code: s.devices.code }) + .from(s.blocks) + .innerJoin(s.devices, eq(s.blocks.targetGuestId, s.devices.guestId)) + .where(eq(s.blocks.guestId, g.id)) + .all(); + } + unblock(g: Guest, targetDeviceId: string) { + const target = this.deviceGuest(targetDeviceId); + if (!target) return; + this.db.transaction(() => { + const removed = this.db + .delete(s.blocks) + .where(and(eq(s.blocks.guestId, g.id), eq(s.blocks.targetGuestId, target.guestId))) + .run(); + if (removed.changes) + this.db + .update(s.requests) + .set({ rejectedAt: null }) + .where( + and( + eq(s.requests.senderGuestId, target.guestId), + eq(s.requests.recipientGuestId, g.id), + eq(s.requests.status, 'rejected'), + ), + ) + .run(); + }); + this.refresh(); + } complete(g: Guest, id: string, input: CompleteInput) { const row = this.request(g, id); if (row.kind === 'file') diff --git a/apps/server/test/transfer-controls.test.js b/apps/server/test/transfer-controls.test.js new file mode 100644 index 0000000..914af87 --- /dev/null +++ b/apps/server/test/transfer-controls.test.js @@ -0,0 +1,121 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fixture } from './helpers.js'; +import { Limiter, ApiError } from '../src/security.js'; + +test('rate windows report their deadline; denied retries do not charge other quotas', (t) => { + let now = 1000; + t.mock.method(Date, 'now', () => now); + const limiter = new Limiter(); + const limits = [ + { key: 'request-guest:a', max: 2 }, + { key: 'request-pair:a:b', max: 1 }, + ]; + limiter.hitMany(limits); + now += 5000; + for (let i = 0; i < 20; i++) + assert.throws( + () => limiter.hitMany(limits), + (err) => { + assert(err instanceof ApiError); + assert.equal(err.status, 429); + assert.equal(err.retryAt, 61000); + assert.equal(err.limitScope, 'pair'); + return true; + }, + ); + limiter.hitMany([ + { key: 'request-guest:a', max: 2 }, + { key: 'request-pair:a:c', max: 1 }, + ]); + assert.throws(() => limiter.hit('request-guest:a', 2)); + now = 61000; + assert.doesNotThrow(() => limiter.hitMany(limits)); +}); + +test('transfer limit returns Retry-After and cooldown metadata without creating a request', async () => { + const f = await fixture(); + try { + f.cfg.pairRate = 2; + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + for (let i = 0; i < 2; i++) { + const response = await f.request(a, b); + assert.equal(response.statusCode, 200); + await f.call(a, `/transfer-requests/${response.json().requestId}/cancel`, 'POST', {}); + } + const limited = await f.request(a, b); + assert.equal(limited.statusCode, 429); + assert.equal(limited.json().code, 'RATE_LIMIT'); + assert.equal(limited.json().limitScope, 'pair'); + assert(Number(limited.headers['retry-after']) > 0); + assert(Number(limited.headers['retry-after']) <= 60); + assert.equal(Number(limited.headers['retry-after']), limited.json().retryAfterSeconds); + assert(limited.json().retryAt > Date.now()); + const next = await f.request(a, b); + assert.equal(next.json().retryAt, limited.json().retryAt); + assert.equal( + (await f.call(b, '/device/snapshot')).json().requests.filter((r) => r.status === 'pending') + .length, + 0, + ); + } finally { + await f.close(); + } +}); + +test('rejection cooldown reports time and does not use a successful-transfer quota', async () => { + const f = await fixture(); + try { + f.cfg.sessionRate = 2; + const a = await f.create(), + b = await f.create(), + c = await f.create(); + await f.space(a, b); + await f.space(a, c); + const request = await f.request(a, b); + await f.call(b, `/transfer-requests/${request.json().requestId}/reject`, 'POST', {}); + for (let i = 0; i < 4; i++) { + const denied = await f.request(a, b); + assert.equal(denied.statusCode, 429); + assert.equal(denied.json().code, 'REJECTION_COOLDOWN'); + assert.equal(denied.json().limitScope, 'pair'); + assert(denied.json().retryAfterSeconds <= 300 && denied.json().retryAfterSeconds > 290); + } + assert.equal((await f.request(a, c)).statusCode, 200); + } finally { + await f.close(); + } +}); + +test('block list is private; unblock only removes the current device block and never revives a request', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(), + c = await f.create(); + await f.space(a, b); + const id = (await f.request(a, b)).json().requestId; + await f.call(b, `/transfer-requests/${id}/block`, 'POST', {}); + assert.equal((await f.call(null, '/device/blocks')).statusCode, 401); + assert.deepEqual((await f.call(a, '/device/blocks')).json().blocks, []); + const blocks = (await f.call(b, '/device/blocks')).json().blocks; + assert.equal(blocks.length, 1); + assert.equal(blocks[0].deviceId, a.deviceId); + assert.deepEqual(Object.keys(blocks[0]).sort(), ['code', 'deviceId', 'name']); + await f.call(c, `/device/blocks/${a.deviceId}`, 'DELETE'); + assert.equal((await f.call(b, '/device/blocks')).json().blocks.length, 1); + assert.equal((await f.request(a, b)).statusCode, 403); + assert.equal((await f.call(b, `/device/blocks/${a.deviceId}`, 'DELETE')).statusCode, 200); + assert.deepEqual((await f.call(b, '/device/blocks')).json().blocks, []); + assert.equal( + (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, crypto.randomUUID())) + .statusCode, + 409, + ); + assert.equal((await f.request(a, b)).statusCode, 200); + } finally { + await f.close(); + } +}); diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 8a4f292..b77f381 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -65,6 +65,7 @@ import { items, spaces, error, + transferCooldown, bootstrap, api, refresh, @@ -94,6 +95,35 @@ const tab = ref('devices'), invite = ref(null), qr = ref(''), now = ref(Date.now()); +const blockedDevices = ref([]), + confirmBlockId = ref(null); +const retrySeconds = computed(() => + transferCooldown.value.targetDeviceId && + transferCooldown.value.targetDeviceId !== selected.value?.id + ? 0 + : Math.max( + 0, + Math.ceil((transferCooldown.value.until - Math.max(now.value, Date.now())) / 1000), + ), +); +const modalTitle = computed(() => + modal.value === 'connect' + ? connectTitle.value + : modal.value === 'about' + ? '서비스 정보' + : modal.value === 'blocks' + ? '차단한 기기' + : '내 기기 설정', +); +function contentLabel(kind) { + return kind === 'file' ? '파일' : kind === 'text' ? '텍스트' : '링크'; +} +function transferSize(bytes) { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`; + if (bytes >= 1e5) return `${(bytes / 1e6).toFixed(1)} MB`; + if (bytes >= 1000) return `${(bytes / 1000).toFixed(1)} KB`; + return '1 KB 미만'; +} const normalizedJoinCode = computed(() => joinCode.value.replace(/[\s-]/g, '').toUpperCase()); const validJoinCode = computed(() => /^(?:[A-Z2-9]{4}|[A-Z2-9]{6}|[A-Z2-9]{8})$/.test(normalizedJoinCode.value), @@ -186,6 +216,10 @@ async function receiveAction(r, action) { await act(async () => { try { await requestAction(r.id, action); + if (action === 'block') { + confirmBlockId.value = null; + notify('기기를 차단했어요. 내 기기 설정에서 해제할 수 있어요.'); + } } catch (error) { if (action === 'accept') receiver.abort(r.id); throw error; @@ -563,6 +597,20 @@ async function openDeviceSettings() { if (!matchMedia('(max-width: 639px)').matches) document.querySelector('.device-settings-form input')?.focus(); } +async function openBlockedDevices() { + await act(async () => { + blockedDevices.value = (await api('/device/blocks')).blocks; + modal.value = 'blocks'; + }); +} +async function unblockDevice(peer) { + await act(async () => { + await api(`/device/blocks/${peer.deviceId}`, 'DELETE'); + blockedDevices.value = (await api('/device/blocks')).blocks; + await refresh(); + notify('차단을 해제했어요.'); + }); +} async function saveName() { await act(async () => { await patchDevice({ name: nameDraft.value }); @@ -879,20 +927,37 @@ async function spaceAction(space, action, memberId) { aria-label="전송 수락 요청" tabindex="-1" > -
-
- {{ r.senderDevice?.name }} #{{ r.senderDevice?.code }} -

- {{ r.kind === 'file' ? '파일' : r.kind === 'text' ? '텍스트' : '링크' }} · - {{ r.count }}개 · {{ r.byteSize.toLocaleString() }}바이트 - · {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음 -

+
  • - {{ file.name }} · {{ size(file.size) }} + {{ file.name }}{{ transferSize(file.size) }}
@@ -903,16 +968,31 @@ async function spaceAction(space, action, memberId) { > 수락 +
+ -

기기 이름은 신원이 인증된 표시가 아닙니다.

@@ -1115,6 +1195,7 @@ async function spaceAction(space, action, memberId) { :clipboard-key="selectionEpoch" :read-clipboard-on-selection="true" :busy="busy" + :retry-seconds="retrySeconds" :transfer="currentTransfer" :progress=" Object.entries(visibleUploadProgress) @@ -1147,14 +1228,7 @@ async function spaceAction(space, action, memberId) { + diff --git a/apps/web/src/SendComposer.vue b/apps/web/src/SendComposer.vue index 1570ae2..3ec2fcd 100644 --- a/apps/web/src/SendComposer.vue +++ b/apps/web/src/SendComposer.vue @@ -19,6 +19,7 @@ const props = defineProps({ targetKey: String, clipboardKey: Number, busy: Boolean, + retrySeconds: { type: Number, default: 0 }, disabled: Boolean, transfer: Object, progress: Array, @@ -290,7 +291,7 @@ function submit() { emit('cancel'); return; } - if (!ready.value || !mutable.value) return; + if (!ready.value || !mutable.value || props.retrySeconds > 0) return; if (source.value === 'url') emit( 'clipboard', @@ -514,10 +515,21 @@ function size(value) {
diff --git a/apps/web/src/client.js b/apps/web/src/client.js index 875c1e6..a221adb 100644 --- a/apps/web/src/client.js +++ b/apps/web/src/client.js @@ -26,6 +26,7 @@ export const device = ref(null), discoveryEnabled = ref(false), error = ref(''); export const pendingPeers = ref([]); +export const transferCooldown = ref({ until: 0, targetDeviceId: null }); let pendingTimer; let deviceInfo = {}; export const nextCursor = ref(null); @@ -60,11 +61,28 @@ export async function api(path, method = 'GET', body, key, signal) { body: body !== undefined ? JSON.stringify(body) : undefined, }); const payload = await response.json(); - if (!response.ok) + if (!response.ok) { + const retryAfterSeconds = Number( + payload.retryAfterSeconds ?? response.headers.get('Retry-After'), + ); + if ( + response.status === 429 && + retryAfterSeconds > 0 && + Number.isFinite(retryAfterSeconds) && + method === 'POST' && + (path === '/transfer-requests' || /^\/vault\/[^/]+\/send$/.test(path)) + ) { + transferCooldown.value = { + until: Date.now() + Math.ceil(retryAfterSeconds) * 1000, + targetDeviceId: payload.limitScope === 'pair' ? body?.targetDeviceId : null, + }; + } throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), { status: response.status, code: payload.code, + retryAfterSeconds, }); + } return payload; } export async function bootstrap() { @@ -154,9 +172,13 @@ export function showError(err) { const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw) ? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.' : raw; - error.value = message; + const displayed = + err?.retryAfterSeconds > 0 + ? `${message} ${Math.ceil(err.retryAfterSeconds)}초 후 다시 시도할 수 있어요.` + : message; + error.value = displayed; errorTimer = setTimeout(() => { - if (error.value === message) error.value = ''; + if (error.value === displayed) error.value = ''; }, 5000); } export const refresh = createRefreshQueue(async () => { diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 3cb8c21..3fe0fe0 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -2419,6 +2419,123 @@ a.button { .consent-card .text-button { color: #666; } +.consent-heading { + display: flex; + align-items: center; + gap: 16px; + width: 100%; +} +.consent-card .incoming-icon { + width: 52px; + height: 52px; + flex: 0 0 52px; +} +.consent-kind { + font-size: 22px; + line-height: 1.3; + font-weight: 700; + margin: 0 0 8px; +} +.consent-card .incoming-info strong { + font-size: 15px; + overflow-wrap: anywhere; +} +.consent-card .incoming-info small { + font-size: 12px; +} +.consent-card .incoming-info p { + font-size: 12px; +} +.consent-card .request-files { + width: 100%; + margin: 6px 0; + padding: 0; + list-style: none; + max-height: 160px; +} +.consent-card .request-files li { + display: flex; + align-items: baseline; + gap: 12px; + font-size: 14px; + padding: 6px 0; +} +.request-file-name { + min-width: 0; + flex: 1; + overflow-wrap: anywhere; +} +.request-file-size { + color: #666; + font-size: 12px; + white-space: nowrap; +} +.consent-card .incoming-actions { + display: grid; + grid-template-columns: 2fr 1fr; + padding: 0; + gap: 10px; + margin-top: 10px; +} +.consent-card .incoming-actions .button { + width: 100%; + min-width: 0; + min-height: 48px; + font-size: 14px; + justify-content: center; +} +.consent-footer { + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 12px; + margin-top: 0; + font-size: 12px; +} +.consent-footer .text-button { + font-size: 12px; + padding: 0; + min-height: 44px; +} +.consent-footer .block-confirm { + color: #a33; +} +.device-blocks-setting { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 28px; + font-size: 14px; +} +.blocked-device-list { + margin: 0 0 20px; + padding: 0; + list-style: none; +} +.blocked-device-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} +.blocked-device-list li > div { + min-width: 0; +} +.blocked-device-list strong { + display: block; + overflow-wrap: anywhere; +} +.blocked-device-list small { + color: var(--muted); +} +.empty-blocks { + margin: 0 0 20px; + color: var(--muted); +} .composer-transfer-state { margin: 16px 0; font-size: 13px; diff --git a/deploy.md b/deploy.md index f6d5826..f64da47 100644 --- a/deploy.md +++ b/deploy.md @@ -442,3 +442,21 @@ pull_policy: build 가입 비밀번호는 최소 8자입니다. 인증 메일의 링크를 열고 **이메일 인증 완료하기**를 누르면 해당 계정으로 자동 로그인하며 보관함의 받은 항목을 표시합니다. 다른 계정으로 로그인한 브라우저에서는 이전 세션을 종료하고 인증한 계정으로 전환합니다. 다른 기기의 로그인은 유지합니다. 만료·재사용된 링크로는 현재 로그인을 변경하지 않습니다. 이번 인증 변경은 **Git pull → 이미지 재빌드 → 재배포**로 적용합니다. 기존 계정·파일은 유지하고 YAML·환경변수·DB 구조는 변경하지 않습니다. 기존 DB·업로드를 초기화하지 않습니다. + +## 전송 요청 제한과 차단 해제 + +새 기본 한도는 같은 상대 10회/분, 기기 세션 전체 30회/분, 같은 IP 전체 120회/분입니다. 로그인 중 기기 전송에도 같은 기준을 적용합니다. 파일 개수가 아닌 전송 요청 횟수를 계산하며, 거절·취소한 전송 요청도 이미 사용한 횟수에 포함됩니다. 제한된 재시도나 진행 중 전송 때문에 거절된 요청은 다른 한도를 추가로 소모하지 않습니다. + +제한 창은 첫 허용 요청부터 60초이며 반복 재시도로 종료 시간이 연장되지 않습니다. 상대가 거절하면 별도로 기본 300초 동안 그 상대에게 재요청할 수 없습니다. 서버는 `Retry-After`와 남은 시간을 반환하고, 전송 버튼에 카운트를 표시합니다. 대기 중에도 입력·파일 선택은 유지하며 시간이 끝나면 전송 버튼이 다시 활성화됩니다. + +**기존 NAS `.env`에 이전 기본값이 있으면 한 번 아래 명령으로 그 값만 변경합니다. 직접 조정한 다른 값과 비밀값은 유지합니다.** hop 폴더에서 실행하세요: + +```sh +sed -i -e 's/^GUEST_REQUESTS_PER_MINUTE=5$/GUEST_REQUESTS_PER_MINUTE=30/' -e 's/^IP_REQUESTS_PER_MINUTE=20$/IP_REQUESTS_PER_MINUTE=120/' -e 's/^PAIR_REQUESTS_PER_MINUTE=2$/PAIR_REQUESTS_PER_MINUTE=10/' .env +``` + +이후 Git pull과 같은 `docker-compose.yaml`로 빌드·재배포합니다. YAML이나 DB 구조 변경은 없습니다. 명시적인 `.env` 값이 새 기본값보다 우선합니다. + +수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하고 수락/거절 버튼은 2:1입니다. 차단은 오른쪽 하단의 작은 텍스트를 누른 뒤 확인해야 실행됩니다. **푸터의 내 기기 설정 → 차단한 기기 관리 → 차단 해제**로 해제합니다. 차단은 현재 브라우저의 기기 세션에 저장되며 로그인 여부와 별개입니다. 해제해도 이전 요청을 다시 수락하는 방식으로 복구하지 않으므로 발신자가 새로 전송해야 합니다. 상대가 나를 별도로 차단했다면 내 차단 해제만으로 상대의 차단을 풀 수 없습니다. + +수신 파일 용량은 0.1MB 이상부터 MB, 1GB 이상부터 GB로 표시합니다. 그보다 작은 파일은 KB 또는 ‘1 KB 미만’이며, 파일 한 개의 용량은 파일명 옆에 한 번만 표시합니다. 단위는 MB=1,000,000바이트, GB=1,000,000,000바이트입니다. 서버의 MiB/GiB 기준 저장·파일 한도는 유지합니다. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index fb5dacb..1c112a4 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -160,3 +160,10 @@ - Git에서 관리하는 `docker-compose.yaml` 하나로 통합했다. 중복 설정 파일을 제거하고 설정·자동 발견 스크립트와 배포 문서에서 같은 파일을 사용한다. 초기 설정은 `.env`와 저장 권한만 준비하며 Compose 파일의 내용을 변경하지 않는다. - 현재 NAS 값으로 이전 설정과 새 파일을 실제 Docker Compose로 해석해 전체 설정이 동일한지 비교했다. 프로젝트 `hop`, 컨테이너 `hop`, 네트워크 `hop_hop_network`, DB `hop_hop_database`, 사용자 `1000:10`, 포트 `192.168.50.146:50189:50189`, 빌드 정책, 소스·환경파일·업로드 경로와 재시작·로그 설정이 동일했다. 새 파일은 `name: hop`으로 별도 `-p` 없이 기존 프로젝트를 지정한다. - NAS 설정 검증에서 Compose 파일 미변경, 비밀값 유지, 환경파일 권한, 기존 저장소 식별자, `nocopy`, 실행 중 권한 변경 거절을 확인했다. 기존 운영 이미지로 격리한 실제 Compose 컨테이너를 시작해 health, UID/GID, 임시 DB 볼륨과 업로드 바인드 연결을 확인하고 테스트 자원을 정리했다. 셸 문법·포맷 검사도 통과했다. 운영 NAS의 컨테이너·볼륨·자료에는 접근하거나 재배포하지 않았다. + +## 2026-10-14 전송 제한 안내와 수신 화면 + +- 기본 전송 한도를 상대 10회·기기 세션 30회·IP 120회/분으로 확대했다. 거절 후 300초 대기는 유지한다. 제한 응답에 `Retry-After`와 남은 시간을 제공하고 전송 버튼에 카운트를 표시하며 대기 중 입력·파일 선택을 유지한다. 여러 제한을 함께 확인하여 제한된 재시도가 다른 한도를 소모하거나 기존 창의 종료 시간을 연장하지 않도록 했다. 기존 NAS의 명시적인 `.env` 값은 유지하므로 이전 기본값만 변경하는 명령을 deploy.md에 기록했다. +- 수신 카드에 유형별 아이콘·제목, 수락/거절 2:1 버튼, 오른쪽 하단 차단 확인을 적용했다. 현재 기기만 자신의 차단 목록을 조회·해제할 수 있으며 해제해도 이전 요청은 되살리지 않는다. 수신 파일 목록의 기본 들여쓰기를 제거하고 0.1MB 이상은 MB·1GB 이상은 GB로 표시하며 한 파일의 용량을 중복 표시하지 않는다. 기기 이름 관련 안내 문구는 제거했다. +- API 102개 통과. 새 검증에서 실제 제한 응답의 종료 시간·헤더, 반복 재시도의 다른 한도 보존, 거절 대기, 차단 목록 비공개·다른 기기의 해제 불가·해제 후 재전송과 기존 요청 재수락 불가를 확인했다. 전체 빌드·타입 검사와 기존 파일/링크·클립보드·반응형 레이아웃 브라우저 검증도 통과했다. +- 새 실제 Chromium 검증에서 모바일/PC 버튼 비율, 파일 용량 0.1/1.3MB·1.7GB, 파일 목록 여백·가로 넘침 없음, 초안 유지·카운트 종료 후 재활성화, 차단 확인 취소·실행·관리 화면 해제를 확인하고 스크린샷을 확인했다. 브라우저 카운트 검사는 3초 제한 응답을 주입했고 실제 60초 제한·서버 권한은 API 테스트로 검증했다. NAS 재배포와 실물 Safari 검증은 수행하지 않았다. diff --git a/package.json b/package.json index 8d673fb..107df01 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "test:e2e:chunks": "node --import tsx tests/e2e/chunks.mjs", "test:e2e:pwa": "node --import tsx tests/e2e/pwa.mjs", "test:e2e:nearby": "node --import tsx tests/e2e/nearby.mjs", - "test:e2e:clipboard": "node --import tsx tests/e2e/clipboard.mjs" + "test:e2e:clipboard": "node --import tsx tests/e2e/clipboard.mjs", + "test:e2e:receive": "node --import tsx tests/e2e/receive-controls.mjs" }, "engines": { "node": ">=22.12" diff --git a/tests/e2e/layout.mjs b/tests/e2e/layout.mjs index c57873d..70a19fc 100644 --- a/tests/e2e/layout.mjs +++ b/tests/e2e/layout.mjs @@ -281,7 +281,7 @@ try { await page.screenshot({ path: 'tests/e2e/send-sheet.png' }); await page.getByRole('button', { name: '전송 화면 닫기' }).click(); await page.locator('.footer-device-summary').click(); - const settings = page.getByRole('dialog', { name: '설정', exact: true }); + const settings = page.getByRole('dialog', { name: '내 기기 설정', exact: true }); assert.equal( await page .getByLabel('기기 이름', { exact: true }) @@ -380,7 +380,7 @@ try { await page.keyboard.press('Escape'); await sheet.waitFor({ state: 'hidden' }); await page.locator('.footer-device-summary').click(); - const deviceModal = page.getByRole('dialog', { name: '설정', exact: true }); + const deviceModal = page.getByRole('dialog', { name: '내 기기 설정', exact: true }); const titleBounds = await deviceModal.locator('header').boundingBox(); const formBounds = await deviceModal.locator('.device-settings-form').boundingBox(); assert( diff --git a/tests/e2e/receive-controls.mjs b/tests/e2e/receive-controls.mjs new file mode 100644 index 0000000..fdac5a4 --- /dev/null +++ b/tests/e2e/receive-controls.mjs @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { chromium, expect } from '@playwright/test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createApp } from '../../apps/server/src/app.ts'; +import { loadConfig } from '../../apps/server/src/config.ts'; + +const folder = await mkdtemp(resolve(tmpdir(), 'hop-receive-controls-')); +const runtime = await createApp({ + ...loadConfig({ + APP_URL: 'http://127.0.0.1', + DATABASE_PATH: resolve(folder, 'db.sqlite'), + UPLOAD_DIR: resolve(folder, 'uploads'), + MIN_FREE_DISK_MB: '0', + SESSION_SECRET: 's'.repeat(40), + DISCOVERY_SECRET: 'd'.repeat(40), + PAIR_REQUESTS_PER_MINUTE: '100', + GUEST_REQUESTS_PER_MINUTE: '100', + IP_REQUESTS_PER_MINUTE: '200', + }), + webRoot: resolve('apps/web/dist'), +}); +let browser; +try { + await runtime.app.listen({ host: '127.0.0.1', port: 0 }); + const url = `http://127.0.0.1:${runtime.app.server.address().port}`; + runtime.setDevOrigin(url); + browser = await chromium.launch({ + headless: true, + ...(process.env.HOP_BROWSER_EXECUTABLE + ? { executablePath: process.env.HOP_BROWSER_EXECUTABLE } + : {}), + }); + const a = await browser.newContext({ viewport: { width: 1280, height: 900 } }), + b = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const sender = await a.newPage(), + receiver = await b.newPage(), + errors = []; + sender.setDefaultTimeout(10000); + receiver.setDefaultTimeout(10000); + for (const page of [sender, receiver]) page.on('pageerror', (err) => errors.push(err.message)); + await Promise.all([sender.goto(url), receiver.goto(url)]); + await sender.locator('.peer-card').first().waitFor(); + await receiver.locator('.peer-card').first().waitFor(); + async function api(page, path, method = 'GET', body) { + return page.evaluate( + async ({ path, method, body }) => { + if (!window.__testCsrf) { + const s = await fetch('/api/guest/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + window.__testCsrf = (await s.json()).csrfToken; + } + const response = await fetch(`/api${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.__testCsrf, + 'Idempotency-Key': crypto.randomUUID(), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + if (!response.ok) throw Error(`${response.status}: ${await response.text()}`); + return response.json(); + }, + { path, method, body }, + ); + } + const recipientId = (await api(receiver, '/device/me')).device.id; + async function request(kind, size = 10) { + return ( + await api(sender, '/transfer-requests', 'POST', { + targetDeviceId: recipientId, + kind, + byteSize: size, + count: 1, + ...(kind === 'file' + ? { + files: [ + { + name: 'Screenshot_20261012_152625_Samsung Browser.jpg', + size, + type: 'image/jpeg', + }, + ], + } + : {}), + }) + ).requestId; + } + for (const [kind, size, label] of [ + ['text', 10, '텍스트'], + ['url', 20, '링크'], + ['file', 1333196, '파일'], + ['file', 100000, '파일'], + ['file', 1700000000, '파일'], + ]) { + const id = await request(kind, size); + const card = receiver.getByRole('dialog', { name: '전송 수락 요청' }); + await expect(card.locator('.consent-kind')).toHaveText(`${label} 전송 요청`); + assert.equal(await card.locator('.request-note').count(), 0); + assert.equal(await card.locator('.incoming-icon svg').count(), 1); + const ratio = await card.evaluate((el) => { + const a = el.querySelector('.incoming-actions .primary').getBoundingClientRect(), + b = el.querySelector('.incoming-actions .button:not(.primary)').getBoundingClientRect(); + return a.width / b.width; + }); + assert(Math.abs(ratio - 2) < 0.02); + assert.equal(await card.getByText('기기 이름은 신원이 인증된 표시가 아닙니다.').count(), 0); + if (kind === 'file') { + await expect(card.locator('.request-file-size')).toHaveText( + size === 1700000000 ? '1.7 GB' : size === 100000 ? '0.1 MB' : '1.3 MB', + ); + const spacing = await card.locator('.request-files').evaluate((el) => ({ + padding: getComputedStyle(el).paddingLeft, + margin: getComputedStyle(el).marginLeft, + overflow: el.scrollWidth > el.clientWidth, + })); + assert.deepEqual(spacing, { padding: '0px', margin: '0px', overflow: false }); + assert( + !(await card + .locator('.incoming-info p') + .textContent() + .then((v) => /바이트|MB|GB/.test(v))), + 'single file size is shown once', + ); + if (size === 1333196) + await receiver.screenshot({ path: 'tests/e2e/receive-file-mobile.png' }); + } + if (kind === 'text') { + await receiver.setViewportSize({ width: 1280, height: 900 }); + const desktop = await card.evaluate((el) => { + const a = el.querySelector('.incoming-actions .primary').getBoundingClientRect(), + b = el.querySelector('.incoming-actions .button:not(.primary)').getBoundingClientRect(); + return a.width / b.width; + }); + assert(Math.abs(desktop - 2) < 0.02); + await receiver.screenshot({ path: 'tests/e2e/receive-text-desktop.png' }); + await receiver.setViewportSize({ width: 390, height: 844 }); + } + await api(sender, `/transfer-requests/${id}/cancel`, 'POST', {}); + await card.waitFor({ state: 'hidden' }); + } + await sender.locator('.peer-card').first().click(); + await sender + .getByLabel('보낼 텍스트', { exact: true }) + .fill('제한이 걸려도 이 초안은 유지됩니다.'); + await sender.route('**/api/transfer-requests', (route) => + route.fulfill({ + status: 429, + contentType: 'application/json', + headers: { 'Retry-After': '3' }, + body: JSON.stringify({ + code: 'RATE_LIMIT', + message: '전송 요청이 많아요.', + retryAfterSeconds: 3, + limitScope: 'pair', + }), + }), + ); + await sender.getByRole('button', { name: '전송하기', exact: true }).click(); + const submit = sender.locator('.content-submit'); + await expect(submit).toBeDisabled(); + await expect(submit).toHaveText(/\d+초 후 전송 가능/); + await expect(sender.getByLabel('보낼 텍스트', { exact: true })).toBeEnabled(); + await expect(sender.getByLabel('보낼 텍스트', { exact: true })).toHaveValue( + '제한이 걸려도 이 초안은 유지됩니다.', + ); + await expect(submit).toHaveText('전송하기', { timeout: 5500 }); + await expect(submit).toBeEnabled(); + await sender.unroute('**/api/transfer-requests'); + await sender.getByRole('button', { name: '전송 화면 닫기' }).click(); + await request('text'); + const card = receiver.getByRole('dialog', { name: '전송 수락 요청' }); + await card.getByRole('button', { name: '이 기기 차단', exact: true }).click(); + assert.equal( + (await api(receiver, '/device/blocks')).blocks.length, + 0, + 'first click does not block', + ); + await card.locator('.consent-footer').getByRole('button', { name: '취소', exact: true }).click(); + await card.getByRole('button', { name: '이 기기 차단', exact: true }).click(); + await card.getByRole('button', { name: '차단하기', exact: true }).click(); + await card.waitFor({ state: 'hidden' }); + await expect(receiver.locator('.peer-card')).toHaveCount(0); + await receiver.getByRole('button', { name: '내 기기 설정', exact: true }).click(); + await receiver.getByRole('button', { name: '관리', exact: true }).click(); + const blocked = receiver.getByRole('dialog', { name: '차단한 기기', exact: true }); + await expect(blocked.locator('.blocked-device-list li')).toHaveCount(1); + await blocked.getByRole('button', { name: '차단 해제', exact: true }).click(); + await expect(blocked.getByText('차단한 기기가 없어요.')).toBeVisible(); + await expect(receiver.locator('.peer-card')).toHaveCount(1); + assert.equal((await api(receiver, '/device/blocks')).blocks.length, 0); + assert.deepEqual(errors, []); + console.log( + 'PASS: content icons/titles, desktop/mobile 2:1 buttons, single file sizes 0.1/1.3MB and 1.7GB, no list indent or overflow, retry countdown and draft preservation, block confirmation and private unblock UI.', + ); +} finally { + await browser?.close(); + await runtime.app.close(); + await rm(folder, { recursive: true, force: true }); +}