Simplify connection flow, mobile transfer UI, and header actions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
apps/server/src/inviteCode.js
Normal file
10
apps/server/src/inviteCode.js
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
64
apps/server/test/invite-code.test.js
Normal file
64
apps/server/test/invite-code.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user