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'
+ ? '서비스 정보'
+ : '내 기기 설정'
+ }}
+
+
-
- 네트워크가 달라도 연결할 수 있어요.
연결한 뒤에도 전송마다 수락이 필요합니다.
-
-
-
기기 참여를 기다리고 있어요.
-
![임시 연결 공간 초대 QR 코드]()
-
- 현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의
- 다른 브라우저에서 아래 코드를 입력해 주세요.
-
-
{{ invite.code.slice(0, 4) }} {{ invite.code.slice(4) }}
-
-
- {{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 · 최대
- 10대
-
-
-
+
+
+ 서로 다른 네트워크에서도 기기를 연결할 수 있어요.
+
+
+
+
+
+
- 또는 코드로 참여
-
+
+ spellcheck="false"
+ />
+
+
+
+
![연결 공간 초대 QR 코드]()
+
{{ formatInviteCode(invite.code) }}
+
+ {{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 유효
+
+
+
다른 기기의 참여를 기다리고 있어요.
+
+ 초대가 만료됐어요.
+
+
+
+
hop.
+
기기 사이를 가볍게.
+
+ 파일과 링크를 다른 기기로 보낼 수 있어요.
보관함에 저장한 자료는 최대 7일 후
+ 삭제됩니다.
+
+
by noname.company
+
+
+
+ {{ file.name }}{{ size(file.size) }}
+
+
+
+
{{ fileProgress(index).progress }}%
+
{{
+ fileProgress(index).message
+ }}
+
+
+
@@ -399,7 +416,11 @@ function size(value) {
-
+
{{
retryable
? '같은 파일로 이어 보낼 수 있어요.'
@@ -408,12 +429,13 @@ function size(value) {
: '전송 중입니다.'
}}
-
-
{{ upload.name }} ·
- {{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}
-
+
+
{{ upload.name }} ·
+ {{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}