diff --git a/.env.example b/.env.example index 95990f0..3c469f7 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,8 @@ GUEST_SESSION_RETENTION_DAYS=30 LOCAL_REQUEST_TIMEOUT_SECONDS=60 ACCEPTED_UPLOAD_START_HOURS=24 SPACE_RETENTION_MINUTES=10 +# Invite codes can be 4 or 6 characters. +SPACE_INVITE_CODE_LENGTH=4 SPACE_MAX_DEVICES=10 OFFLINE_GRACE_MS=3000 MAX_URL_PAYLOAD_SIZE_KB=1024 diff --git a/README.md b/README.md index 31599e9..6cebbc8 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ QR·8자리 코드는 기본 10분의 임시 공간 초대입니다. 최대 10 ## NAS 업데이트·PWA·개인 용량 -이번 NAS 업데이트는 [deploy.md](deploy.md)의 7단계부터 진행합니다. 운영 자동 발견은 실제 Tunnel → NPM → hop. 경로를 진단해 인접 프록시만 신뢰하며, Cloudflare의 원본 IP로 같은 네트워크 기기를 구분합니다. 공개 주소는 HTTPS를 사용합니다. 모바일 상단 설치 버튼으로 PWA 설치 또는 iPhone 홈 화면 추가를 안내합니다. 파일·계정 기록·API 응답은 오프라인 캐시에 넣지 않습니다. +이번 NAS 업데이트는 [deploy.md](deploy.md)의 7단계부터 진행합니다. 운영 자동 발견은 실제 Tunnel → NPM → hop. 경로를 진단해 인접 프록시만 신뢰하며, Cloudflare의 원본 IP로 같은 네트워크 기기를 구분합니다. 공개 주소는 HTTPS를 사용합니다. 상단 더보기의 앱 설치 메뉴로 PWA 설치 또는 iPhone 홈 화면 추가를 안내합니다. 파일·계정 기록·API 응답은 오프라인 캐시에 넣지 않습니다. 로그인 계정의 임시 저장과 받은 파일을 합쳐 개인별 2GiB로 제한합니다. 같은 원본은 한 계정에서 한 번 계산하고 다른 계정의 한도와 분리합니다. 한도가 차면 파일 수신·다운로드는 계속하고 받은 항목에 저장하지 않았다는 안내를 표시합니다. 익명 업로드는 기기 세션당 2GiB, 서버 전체 20GiB이며 화면에는 내역을 표시하지 않습니다. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 38cb7d2..b12d859 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -331,6 +331,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {}) device: service.device(g.id), devices: service.peers(g), sameNetworkDiscoveryEnabled: cfg.discoveryEnabled, + spaceInviteCodeLength: cfg.spaceInviteCodeLength, locationDiscovery: { enabled: cfg.locationDiscoveryEnabled, radiusMeters: cfg.locationRadiusMeters, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1982c98..e11a38f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -18,6 +18,7 @@ export type Config = { requestSeconds: number; uploadStartHours: number; spaceMinutes: number; + spaceInviteCodeLength: number; spaceMax: number; offlineGraceMs: number; maxUrlBytes: number; @@ -67,6 +68,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { throw Error('PROXY_DIAGNOSTIC_TOKEN must contain at least 32 characters'); if (num('LOCATION_DISCOVERY_RADIUS_METERS', 100) > 1000) throw Error('LOCATION_DISCOVERY_RADIUS_METERS must not exceed 1000'); + const spaceInviteCodeLength = num('SPACE_INVITE_CODE_LENGTH', 4); + if (![4, 6].includes(spaceInviteCodeLength)) + throw Error('SPACE_INVITE_CODE_LENGTH must be 4 or 6'); return { production, appUrl, @@ -92,6 +96,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60), uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24), spaceMinutes: num('SPACE_RETENTION_MINUTES', 10), + spaceInviteCodeLength, spaceMax: num('SPACE_MAX_DEVICES', 10), offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0), maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024, diff --git a/apps/server/src/inviteCode.js b/apps/server/src/inviteCode.js new file mode 100644 index 0000000..e3dd9f6 --- /dev/null +++ b/apps/server/src/inviteCode.js @@ -0,0 +1,10 @@ +import { randomInt } from 'node:crypto'; +const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +export function newInviteCode(length, isTaken, random = randomInt) { + for (let attempt = 0; attempt < 32; attempt++) { + const candidate = Array.from({ length }, () => alphabet[random(alphabet.length)]).join(''); + if (!isTaken(candidate)) return candidate; + } + return null; +} diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts index 9f36c57..45bb951 100644 --- a/apps/server/src/service.ts +++ b/apps/server/src/service.ts @@ -5,7 +5,8 @@ import { Storage } from './storage.js'; import { ResumableUploads } from './resumable.js'; import { NearbyLocations } from './nearby.js'; import { Vault } from './vault.js'; -import { randomInt, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; +import { newInviteCode } from './inviteCode.js'; import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm'; import type { DB } from './db/index.js'; import * as s from './db/schema.js'; @@ -380,8 +381,18 @@ export class Service { const now = Date.now(), id = randomUUID(), token = randomToken(); - const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; - const code = Array.from({ length: 8 }, () => alphabet[randomInt(alphabet.length)]).join(''); + // The unique DB constraint includes historical spaces; never reuse their codes. + const code = + newInviteCode(this.cfg.spaceInviteCodeLength, (candidate: string) => + Boolean( + this.db + .select({ id: s.spaces.id }) + .from(s.spaces) + .where(eq(s.spaces.codeHash, hmac(this.cfg.discoverySecret, candidate))) + .get(), + ), + ) ?? + fail(503, 'SPACE_CODE_BUSY', '초대 코드를 만들지 못했습니다. 잠시 후 다시 시도해 주세요.'); this.db.transaction(() => { this.db .insert(s.spaces) diff --git a/apps/server/test/invite-code.test.js b/apps/server/test/invite-code.test.js new file mode 100644 index 0000000..bdc284e --- /dev/null +++ b/apps/server/test/invite-code.test.js @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { eq } from 'drizzle-orm'; +import { fixture } from './helpers.js'; +import { newInviteCode } from '../src/inviteCode.js'; +import { loadConfig } from '../src/config.js'; +import { hmac } from '../src/security.js'; +import * as s from '../src/db/schema.js'; + +test('짧은 초대 코드는 충돌 시 다시 생성하고 반복 충돌은 제한한다', () => { + let calls = 0; + const code = newInviteCode( + 4, + (value) => value === 'AAAA', + () => (calls++ < 4 ? 0 : 1), + ); + assert.equal(code, 'BBBB'); + let attempts = 0; + assert.equal( + newInviteCode( + 4, + () => { + attempts++; + return true; + }, + () => 0, + ), + null, + ); + assert.equal(attempts, 32); + assert.equal(loadConfig({ SPACE_INVITE_CODE_LENGTH: '6' }).spaceInviteCodeLength, 6); + assert.throws(() => loadConfig({ SPACE_INVITE_CODE_LENGTH: '5' }), /SPACE_INVITE_CODE_LENGTH/); +}); + +test('새 초대 4자리와 6자리 설정, 기존 8자리 초대 모두 참여 가능하다', async () => { + const f = await fixture(); + try { + const a = await f.create(), + b = await f.create(); + const room = (await f.call(a, '/spaces', 'POST', {})).json(); + assert.match(room.code, /^[A-HJ-NP-Z2-9]{4}$/); + assert.equal( + (await f.call(b, '/spaces/join', 'POST', { code: room.code.toLowerCase() })).statusCode, + 200, + ); + await f.call(a, `/spaces/${room.space.id}`, 'DELETE'); + f.service.cfg.spaceInviteCodeLength = 6; + const six = (await f.call(a, '/spaces', 'POST', {})).json(); + assert.match(six.code, /^[A-HJ-NP-Z2-9]{6}$/); + assert.equal((await f.call(a, '/device/snapshot')).json().spaceInviteCodeLength, 6); + assert.equal((await f.call(b, '/spaces/join', 'POST', { code: six.code })).statusCode, 200); + await f.call(a, `/spaces/${six.space.id}`, 'DELETE'); + const legacy = (await f.call(a, '/spaces', 'POST', {})).json(); + f.service.db + .update(s.spaces) + .set({ codeHash: hmac(f.service.cfg.discoverySecret, 'ABCDEFGH') }) + .where(eq(s.spaces.id, legacy.space.id)) + .run(); + assert.equal((await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' })).statusCode, 200); + assert.equal((await f.call(b, '/spaces/join', 'POST', { code: 'ABCDE' })).statusCode, 400); + } finally { + await f.close(); + } +}); diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 3aeebf9..d47824a 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -3,6 +3,8 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'; import { ArrowUpRight, ArrowRight, + ArrowLeft, + LogIn, Plus, Monitor, Smartphone, @@ -29,19 +31,23 @@ import { Download, RotateCcw, EllipsisVertical, + Ellipsis, + Info, } from '@lucide/vue'; import QRCode from 'qrcode'; import AccountPanel from './AccountPanel.vue'; import SendComposer from './SendComposer.vue'; import InstallButton from './InstallButton.vue'; import { createNearbySearch } from './nearbySearch'; +import { useVisibleViewport } from './viewport'; +useVisibleViewport(); import { createDeliveryReceiver } from './deliveryReceiver'; import { initialTheme, applyTheme as setTheme } from './theme'; import { device, account, connected, - discoveryEnabled, + spaceInviteCodeLength, locationSettings, publishLocation, clearLocation, @@ -74,12 +80,34 @@ const tab = ref('devices'), nameDraft = ref(''), notice = ref(''), modal = ref(null), + headerMore = ref(false), + connectView = ref('choose'), joinCode = ref(''), approvalRequired = ref(false), invite = ref(null), qr = ref(''), now = ref(Date.now()); -const localOnlyQr = /^(localhost|127(?:\.\d+){3}|\[::1\])$/.test(location.hostname); +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), +); +const connectTitle = computed(() => + connectView.value === 'join' + ? '연결 공간 참여' + : connectView.value === 'choose' + ? '다른 기기 연결' + : '연결 공간 만들기', +); +watch(modal, (value) => { + if (value === 'connect') { + connectView.value = + invite.value && invite.value.space.expiresAt > now.value ? 'invite' : 'choose'; + joinCode.value = ''; + } +}); +function formatInviteCode(code) { + return code.match(new RegExp(`.{1,${code.length === 6 ? 3 : 4}}`, 'g')).join(' '); +} const theme = ref(initialTheme()); const media = matchMedia('(prefers-color-scheme: dark)'); function applyTheme() { @@ -133,6 +161,49 @@ async function receiveAction(r, action) { }); } const deviceMenu = ref(null); +async function toggleHeaderMore() { + if (headerMore.value) { + closeHeaderMore(true); + return; + } + closeDeviceMenu(); + headerMore.value = true; + await nextTick(); + document.querySelector('#header-more-menu [role="menuitem"]')?.focus(); +} +function closeHeaderMore(restore = false) { + headerMore.value = false; + if (restore) document.getElementById('header-more-toggle')?.focus(); +} +function headerMenuKey(event) { + if (event.key === 'Escape') { + event.preventDefault(); + closeHeaderMore(true); + return; + } + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const options = [...event.currentTarget.querySelectorAll('[role="menuitem"]')].filter( + (el) => el.getClientRects().length, + ); + const current = options.indexOf(document.activeElement); + const index = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? options.length - 1 + : (current + (event.key === 'ArrowUp' ? -1 : 1) + options.length) % options.length; + options[index]?.focus(); +} +function openAbout() { + closeHeaderMore(); + modal.value = 'about'; +} +function changeTheme() { + toggleTheme(); + closeHeaderMore(true); +} + function isHost(id) { return Boolean(id && spaces.value.some((s) => s.creatorDeviceId === id)); } @@ -192,8 +263,13 @@ function closeDeviceMenu(restore = false) { } function outsideDeviceMenu(event) { if (!event.target.closest('[data-device-menu]')) closeDeviceMenu(); + if (!event.target.closest('[data-header-more]')) closeHeaderMore(); } function escapeDeviceMenu(event) { + if (event.key === 'Escape' && headerMore.value) { + event.preventDefault(); + closeHeaderMore(true); + } if (event.key === 'Escape' && deviceMenu.value) { event.preventDefault(); closeDeviceMenu(true); @@ -436,7 +512,8 @@ async function openDeviceSettings() { nameDraft.value = device.value?.name ?? ''; modal.value = 'device'; await nextTick(); - document.querySelector('.device-settings-form input')?.focus(); + if (!matchMedia('(max-width: 639px)').matches) + document.querySelector('.device-settings-form input')?.focus(); } async function saveName() { await act(async () => { @@ -480,12 +557,13 @@ async function createSpace() { color: { dark: '#111111', light: '#ffffff' }, errorCorrectionLevel: 'M', }); + connectView.value = 'invite'; await refresh(); }); } async function joinSpace() { await act(async () => { - await api('/spaces/join', 'POST', { code: joinCode.value.replace(/[\s-]/g, '').toUpperCase() }); + await api('/spaces/join', 'POST', { code: normalizedJoinCode.value }); joinCode.value = ''; await refresh(); modal.value = null; @@ -556,16 +634,50 @@ async function spaceAction(space, action, memberId) { 기기 사이를 가볍게.
- - +
+ + + +
@@ -807,13 +919,13 @@ async function spaceAction(space, action, memberId) { v-if="canInvite" key="qr-connect" class="qr-connect-card" - :aria-label="spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결'" + :aria-label="spaces.length ? '초대 보기' : '다른 기기 연결'" @click="modal = 'connect'" > {{ - spaces.length ? 'QR 초대 보기' : 'QR로 연결' + spaces.length ? '초대 보기' : '다른 기기 연결' }}다른 기기 연결하기 + >QR 또는 코드 @@ -925,58 +1037,108 @@ async function spaceAction(space, action, memberId) { class="modal" role="dialog" aria-modal="true" - :aria-label="modal === 'connect' ? 'QR·코드 연결' : '설정'" + :aria-label=" + modal === 'connect' ? connectTitle : modal === 'about' ? '서비스 정보' : '설정' + " >
-

{{ modal === 'connect' ? 'QR · 코드로 연결' : '내 기기 설정' }}

+
+ +

+ {{ + modal === 'connect' + ? connectTitle + : modal === 'about' + ? '서비스 정보' + : '내 기기 설정' + }} +

+
- + +
+ {{ file.name }}{{ size(file.size) }} +
+
+ + {{ fileProgress(index).progress }}% + {{ + fileProgress(index).message + }} +
+ + -

+

{{ retryable ? '같은 파일로 이어 보낼 수 있어요.' @@ -408,12 +429,13 @@ function size(value) { : '전송 중입니다.' }}

-
- {{ upload.name }} · - {{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }} -
+