Simplify connection flow, mobile transfer UI, and header actions

This commit is contained in:
2026-10-12 18:14:54 +09:00
parent 5f431ee153
commit 4f0231da3d
22 changed files with 820 additions and 131 deletions

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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)

View File

@@ -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();
}
});